From 5ef38f263df2cec25d44cb35d1486c2d4d59bc2a Mon Sep 17 00:00:00 2001 From: iximeow Date: Mon, 23 Mar 2026 03:16:30 +0000 Subject: initial --- src/main.rs | 360 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 src/main.rs (limited to 'src/main.rs') diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..91eba62 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,360 @@ +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 + }) + } + } +} -- cgit v1.1