use std::ffi::OsString; use std::io::Read; use std::io::Seek; use std::io::Write; use std::os::unix::prelude::OsStrExt; use std::path::PathBuf; use std::process::ExitCode; use clap::{Parser, Subcommand}; mod shared; use shared::{Project, DbCtx}; #[derive(Parser)] #[command(version, about, long_about = None)] // , multicall(true))] struct Args { #[arg(long)] db: Option, #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { /// initialize a qroject database. you should run this first, and shouldn't need to again. Init, /// add a project to the qroject database. projects are unique by their name. Add { /// the name of the project. project: String, /// the directory at which the project lives. dir: OsString, /// if the project is on some other computer, the hostname or address of that computer. /// /// with this set, `qg ` will ssh to that computer and switch into that directory. #[arg(long)] host: Option, /// misc additional information about the project. not used by qroject itself. info: Option, /// where the project can be found on the wider internet, if anywhere. /// /// this is mostly useful as notes for homepages, git remotes, etc. upstream: Option, }, /// forget a project of the given name. /// /// this deletes the database row. this does not make changes to any other files. still, be /// careful. Forget { project: String, }, /// print the directory for the given project. /// /// this is mostly useful as, for example, `$(q dir foo)` as a stand-in for the actual /// directory of foo. Dir { project: String, }, /// print information about the given project. Info { project: String, }, /// print the project's upstream, if any. Upstream { project: String, }, /// edit `project`'s record. /// /// this opens the project's information in vim (not $EDITOR yet sorry). edits to the project /// are written back to the database (purusant to the unique name restriction). optional fields /// are omitted when null, but can be set by adding a corresponding line in the file. Edit { project: String, }, /// list all known projects (optionally, with details) List { #[arg(short, default_value_t = false)] verbose: bool, }, } enum CliResult { Ok, /// a project-specifying command selected a project that doesn't exist NotAProject, /// a project-specifying command wanted a field that was None NoField, /// a command that involved disk i/o failed to either i or o. IoError, /// something happened related to the database DbError, } impl std::process::Termination for CliResult { fn report(self) -> ExitCode { match self { CliResult::Ok => ExitCode::SUCCESS, CliResult::NotAProject => ExitCode::from(1), CliResult::NoField => ExitCode::from(2), CliResult::IoError => ExitCode::from(3), CliResult::DbError => ExitCode::from(4), } } } unsafe extern "C" { // from libc. no rustix binding.. fn mkstemp(template: *mut std::ffi::c_char) -> std::ffi::c_int; fn perror(msg: *const std::ffi::c_char); } fn print_project_name(p: Project) { println!("{}", p.name); } fn print_project(p: Project) { println!("{}", p.name); if p.is_here() { println!(" {: <8}: {}", "path", p.dir.display()); } else if let Some(host) = p.host.as_ref() { println!(" {: <8}: {}:{}", "path", host, p.dir.display()); } else { println!(" {: <8}: {}:{}", "path", "", p.dir.display()); } if let Some(info) = p.info.as_ref() { println!(" {: <8}: {}", "info", info); } if let Some(upstream) = p.upstream.as_ref() { println!(" {: <8}: {}", "upstream", upstream); } } fn main() -> CliResult { let args = Args::parse(); let dbpath = args.db.clone().unwrap_or_else(|| { let mut path = std::env::home_dir().expect("have a homedir"); path.push(".config"); path.push("qroject"); path.push("projects.db"); path }); match process(args, dbpath) { Ok(()) => CliResult::Ok, Err(e) => e, } } fn process(args: Args, dbpath: PathBuf) -> Result<(), CliResult> { if let Command::Init = args.command { return DbCtx::init(&dbpath).map_err(|e| { eprintln!("{e}"); CliResult::IoError }); } let conn = DbCtx::new(&dbpath).map_err(|e| { eprintln!("{e}"); CliResult::IoError })?; match args.command { Command::Init => { Ok(()) /* unreachable */ }, Command::Add { project, dir, info, upstream, host } => { conn.project_add( Project { id: 0, name: project, dir, info, upstream, host, } ).map_err(|e| { eprintln!("could not add project: {e}"); CliResult::DbError }) }, Command::Forget { project } => { conn.project_forget( &project, ).map_err(|e| { eprintln!("could not forget project: {e}"); CliResult::DbError }) } Command::Dir { project } => { let proj = conn.project_by_name(&project).map_err(|e| { eprintln!("could not look up project: {e}"); CliResult::DbError })?; let proj = proj.ok_or(CliResult::NotAProject)?; println!("{}", proj.dir.display()); Ok(()) } Command::Info { project } => { let proj = conn.project_by_name(&project).map_err(|e| { eprintln!("could not look up project: {e}"); CliResult::DbError })?; let proj = proj.ok_or(CliResult::NotAProject)?; print_project(proj); Ok(()) } Command::Upstream { project } => { let proj = conn.project_by_name(&project).map_err(|e| { eprintln!("could not look up project: {e}"); CliResult::DbError })?; let proj = proj.ok_or(CliResult::NotAProject)?; let upstream = proj.upstream.as_ref().ok_or(CliResult::NoField)?; println!("{}", upstream); Ok(()) } Command::Edit { project } => { let proj = conn.project_by_name(&project).map_err(|e| { eprintln!("could not look up project: {e}"); CliResult::DbError })?; let mut p = proj.ok_or(CliResult::NotAProject)?; let mut existing = String::new(); use std::fmt::{Write as FmtWrite}; use std::os::fd::FromRawFd; writeln!(existing, "name:{}", p.name) .expect("can write to a string..."); writeln!(existing, "dir:{}", p.dir.display()) .expect("can write to a string..."); if let Some(host) = p.host.as_ref() { writeln!(existing, "host:{}", host) .expect("can write to a string..."); } if let Some(info) = p.info.as_ref() { writeln!(existing, "info:{}", info) .expect("can write to a string..."); } if let Some(upstream) = p.upstream.as_ref() { writeln!(existing, "upstream:{}", upstream) .expect("can write to a string..."); } let mut template: [u8; 18] = *b"/tmp/qrojectXXXXXX"; let tmpfd = unsafe { mkstemp(template.as_mut_ptr() as *mut std::ffi::c_char) }; if tmpfd == -1 { unsafe { perror(b"mkstemp\0".as_ptr() as *const _); } return Err(CliResult::IoError); } let mut file = unsafe { std::fs::File::from_raw_fd(tmpfd) }; let res = file.write_all(existing.as_bytes()); if let Err(e) = res { eprintln!("could not write: {:?}", e); return Err(CliResult::IoError); } let res = match std::process::Command::new("vim") .arg(str::from_utf8(&template[..]).expect("valid")) .status() { Ok(res) => res, Err(e) => { eprintln!("failed to exec editor: {:?}", e); return Err(CliResult::IoError); } }; if !res.success() { return Err(CliResult::IoError); } let mut edited = String::new(); if let Err(_e) = file.rewind() { return Err(CliResult::IoError); } if let Err(_e) = file.read_to_string(&mut edited) { return Err(CliResult::IoError); } let lines = edited.split("\n"); let mut new_name = None; let mut new_dir = None; p.host = None; p.info = None; p.upstream = None; for line in lines { if line.trim().is_empty() { continue; } let Some((field, value)) = line.split_once(":") else { eprintln!("bogus line: {line}"); return Err(CliResult::IoError); }; let value = value.to_owned(); if field == "name" { new_name = Some(value); } else if field == "dir" { new_dir = Some(value); } else if field == "host" { p.host = Some(value); } else if field == "info" { p.info = Some(value); } else if field == "upstream" { p.upstream = Some(value); } } let (Some(name), Some(dir)) = (new_name, new_dir) else { eprintln!("missing name or dir"); return Err(CliResult::IoError); }; p.name = name; let dir: &[u8] = dir.as_bytes(); let dir: &std::ffi::OsStr = OsStrExt::from_bytes(dir); p.dir = dir.to_os_string(); conn.project_update(&p) .map_err(|e| { eprintln!("failed to update project: {}", e); CliResult::IoError }) } Command::List { verbose: details } => { let op = if details { print_project } else { print_project_name }; conn.for_each_project(op).map_err(|e| { eprintln!("could iterate projects: {e}"); CliResult::DbError }) } } }