diff options
| author | iximeow <me@iximeow.net> | 2026-03-23 03:16:30 +0000 |
|---|---|---|
| committer | iximeow <me@iximeow.net> | 2026-04-26 05:19:05 +0000 |
| commit | 5ef38f263df2cec25d44cb35d1486c2d4d59bc2a (patch) | |
| tree | 61706a5096440b9f91b0b715eb54ca8afab08979 /src | |
initialHEAD1.0.0no-gods-no-
Diffstat (limited to 'src')
| -rw-r--r-- | src/main.rs | 360 | ||||
| -rw-r--r-- | src/qroject-bash.rs | 720 | ||||
| -rw-r--r-- | src/shared.rs | 167 |
3 files changed, 1247 insertions, 0 deletions
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<PathBuf>, + + #[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 <proj>` will ssh to that computer and switch into that directory. + #[arg(long)] + host: Option<String>, + /// misc additional information about the project. not used by qroject itself. + info: Option<String>, + /// 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<String>, + }, + + /// 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", "<unknown>", 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 + }) + } + } +} diff --git a/src/qroject-bash.rs b/src/qroject-bash.rs new file mode 100644 index 0000000..e54b807 --- /dev/null +++ b/src/qroject-bash.rs @@ -0,0 +1,720 @@ +use bash_builtins::{ + builtin_metadata, Args, Builtin, Error, Result +}; + +use std::ffi::{CString, OsStr}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::io::{stdout, stderr, Seek, Read, Write}; +use std::path::PathBuf; + +#[cfg(target_family="unix")] +use std::os::unix::ffi::OsStrExt; + +mod shared; +use shared::{Project, DbCtx}; + +static QROJECT: OnceLock<Arc<Mutex<core::result::Result<QrojectInner, i32>>>> = OnceLock::new(); + +struct QrojectGo { + inner: Arc<Mutex<core::result::Result<QrojectInner, i32>>>, +} + +struct QrojectInfo { + inner: Arc<Mutex<core::result::Result<QrojectInner, i32>>>, +} + +struct QrojectDir { + inner: Arc<Mutex<core::result::Result<QrojectInner, i32>>>, +} + +struct Qroject { + inner: Arc<Mutex<core::result::Result<QrojectInner, i32>>> +} + +impl QrojectGo { + fn new() -> Self { + Self { + inner: QrojectInner::new(), + } + } +} + +impl Builtin for QrojectGo { + fn call(&mut self, args: &mut Args) -> Result<()> { + args.no_options()?; + let mut arg_iter = args.raw_arguments(); + let projname = arg_iter.next() + .ok_or(Error::ExitCode(1))? + .to_str() + .map_err(|_| Error::ExitCode(2))? + .to_owned(); + drop(arg_iter); + args.finished()?; + + let mut guard = self.inner.lock().unwrap(); + let handle = guard.as_mut() + .map_err(|c| Error::ExitCode(*c))?; + + handle.op_go(&projname) + } +} + +impl QrojectInfo { + fn new() -> Self { + Self { + inner: QrojectInner::new(), + } + } +} + +impl Builtin for QrojectInfo { + fn call(&mut self, args: &mut Args) -> Result<()> { + args.no_options()?; + let mut arg_iter = args.raw_arguments(); + let projname = arg_iter.next() + .ok_or(Error::ExitCode(1))? + .to_str() + .map_err(|_| Error::ExitCode(2))? + .to_owned(); + drop(arg_iter); + args.finished()?; + + let mut guard = self.inner.lock().unwrap(); + let handle = guard.as_mut() + .map_err(|c| Error::ExitCode(*c))?; + + handle.op_info(&projname) + } +} + +impl QrojectDir { + fn new() -> Self { + Self { + inner: QrojectInner::new(), + } + } +} + +impl Builtin for QrojectDir { + fn call(&mut self, args: &mut Args) -> Result<()> { + args.no_options()?; + let mut arg_iter = args.raw_arguments(); + let projname = arg_iter.next() + .ok_or(Error::ExitCode(1))? + .to_str() + .map_err(|_| Error::ExitCode(2))? + .to_owned(); + drop(arg_iter); + args.finished()?; + + let mut guard = self.inner.lock().unwrap(); + let handle = guard.as_mut() + .map_err(|c| Error::ExitCode(*c))?; + + handle.op_dir(&projname) + } +} + +impl Qroject { + fn new() -> Self { + Self { + inner: QrojectInner::new(), + } + } +} + +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); + + // from bash + fn evalstring( + cmd: *const std::ffi::c_char, + from_file: *const std::ffi::c_char, + flags: std::ffi::c_int + ) -> std::ffi::c_int; + fn set_working_directory(path: *const std::ffi::c_char); +} + +fn project_go_chdir(path: &OsStr) -> Result<()> { + // do the same mechanics as `cd` as in bash/builtins/cd.def, ish. + // that is: + // * `chdir()`, + // * then set_working_directory, + // * then set OLDPWD and PWD. + + let prev_pwd = bash_builtins::variables::find_as_string("PWD"); + + rustix::process::chdir(path).map_err(|e| { + let _ = writeln!(stderr(), "chdir fail: {e}"); + Error::ExitCode(5) + })?; + + unsafe { + set_working_directory(path.as_bytes().as_ptr() as *const std::ffi::c_char); + } + + bash_builtins::variables::set("PWD", path.as_bytes())?; + if let Some(prev_pwd) = prev_pwd { + bash_builtins::variables::set("OLDPWD", prev_pwd.as_bytes())?; + } + + Ok(()) +} + +fn project_go_ssh(host: &str, p: Project) -> Result<()> { + let command = format!( + "ssh {} -t 'cd {}; bash'", + host, + p.dir.display() + ); + let cstr = CString::new(command).expect("TODO: it's valid though!"); + + // from `builtins/common.h` + const SEVAL_NOHIST: std::ffi::c_int = 0x004; + const SEVAL_NOFREE: std::ffi::c_int = 0x008; + + let res = unsafe { + evalstring( + cstr.as_bytes().as_ptr() as *const _, + std::ptr::null(), + SEVAL_NOHIST | SEVAL_NOFREE, + ) + }; + if res != 0 { + let _ = writeln!(stderr(), "could not eval"); + return Err(Error::ExitCode(2)); + } + + Ok(()) +} + +impl Builtin for Qroject { + fn call(&mut self, args: &mut Args) -> Result<()> { + args.no_options()?; + let mut arg_iter = args.raw_arguments(); + let command = arg_iter.next() + .ok_or(Error::ExitCode(1))? + .to_str() + .map_err(|_| Error::ExitCode(2))? + .to_owned(); + + if command == "init" { + let path = QrojectInner::dbpath(); + let Some(path) = path else { + let _ = writeln!(stderr(), "no home dir?"); + return Err(Error::ExitCode(2)); + }; + + return match DbCtx::init(path) { + Ok(()) => Ok(()), + Err(e) => { + eprintln!("could not init db: {e}"); + Err(Error::ExitCode(3)) + } + }; + } + + let mut guard = self.inner.lock().unwrap(); + let handle = guard.as_mut() + .map_err(|c| Error::ExitCode(*c))?; + + let maybe_projname = arg_iter.next() + .map(|arg| match arg.to_str() { + Ok(arg) => Ok(arg), + Err(e) => { + let _ = writeln!(stderr(), "failed to read project name: {e:?}"); + Err(Error::ExitCode(2)) + } + }) + .transpose()?; + + match command.as_str() { + "info" => { + let projname = maybe_projname + .ok_or(Error::ExitCode(1))? + .to_owned(); + + // TODO: check per-command remaining arglists.. + drop(arg_iter); + args.finished()?; + handle.op_info(&projname) + } + "upstream" => { + let projname = maybe_projname + .ok_or(Error::ExitCode(1))? + .to_owned(); + + // TODO: check per-command remaining arglists.. + drop(arg_iter); + args.finished()?; + handle.op_upstream(&projname) + } + "go" => { + let projname = maybe_projname + .ok_or(Error::ExitCode(1))? + .to_owned(); + + // TODO: check per-command remaining arglists.. + drop(arg_iter); + args.finished()?; + handle.op_go(&projname) + } + "dir" => { + let projname = maybe_projname + .ok_or(Error::ExitCode(1))? + .to_owned(); + + // TODO: check per-command remaining arglists.. + drop(arg_iter); + args.finished()?; + handle.op_dir(&projname) + } + "add" => { + let projname = maybe_projname + .ok_or_else(|| { + let _ = writeln!(stderr(), "q add: q add [project] [projdir]"); + Error::ExitCode(1) + })? + .to_owned(); + + let projdir = arg_iter.next() + .ok_or_else(|| { + let _ = writeln!(stderr(), "q add: q add [project] [projdir]"); + Error::ExitCode(1) + })? + .to_str() + .map_err(|_| Error::ExitCode(2))? + .to_owned(); + + handle.op_add(&projname, &projdir) + } + "edit" => { + let projname = maybe_projname + .ok_or(Error::ExitCode(1))? + .to_owned(); + + handle.op_edit(&projname) + } + "forget" => { + let projname = maybe_projname + .ok_or(Error::ExitCode(1))? + .to_owned(); + + handle.op_forget(&projname) + } + "list" => { + let prefix = if let Some(prefix) = maybe_projname { + Some(prefix.to_owned()) + } else { + None + }; + + // TODO: check per-command remaining arglists.. + drop(arg_iter); + args.finished()?; + handle.op_list(prefix.as_ref().map(|s| s.as_str())) + } + other => { + let _ = writeln!(stderr(), "unknown command: {}", other); + Err(Error::ExitCode(127)) + } + } + } +} + +struct QrojectInner { + // can be None if there's no ~/.config/qroject/ + db: Option<DbCtx>, +} + +impl QrojectInner { + fn dbpath() -> Option<PathBuf> { + let home = std::env::home_dir(); + home.map(|mut homedir| { + homedir.push(".config"); + homedir.push("qroject"); + homedir.push("projects.db"); + homedir + }) + } + + fn new() -> Arc<Mutex<core::result::Result<Self, i32>>> { + let r = QROJECT.get_or_init(|| { + let conn = Self::dbpath().as_ref().map(|p| DbCtx::new(p)).transpose(); + let inner = match conn { + Ok(db) => Ok(QrojectInner { db }), + Err(e) => { + eprintln!("{e}"); + Err(126) + } + }; + Arc::new(Mutex::new(inner)) + }); + + Arc::clone(r) + } + + fn op_go(&mut self, projname: &str) -> Result<()> { + let Some(db) = self.db.as_ref() else { + return Err(Error::ExitCode(16)); + }; + + let project = db.project_by_name(&projname) + .map_err(|e| { + let _ = writeln!(stderr(), "could not find project: {e}"); + Error::ExitCode(17) + })?; + let Some(p) = project else { + let _ = writeln!(stderr(), "no such project: {}", projname)?; + return Err(Error::ExitCode(4)); + }; + + if p.is_here() { + project_go_chdir(&p.dir) + } else if let Some(host) = p.host.clone().as_ref() { + project_go_ssh(host, p) + } else { + let _ = writeln!(stderr(), "don't know how to get to: {}", projname); + return Err(Error::ExitCode(5)); + } + } + + fn op_info(&mut self, projname: &str) -> Result<()> { + let Some(db) = self.db.as_ref() else { + return Err(Error::ExitCode(16)); + }; + + let project = db.project_by_name(&projname) + .map_err(|e| { + let _ = writeln!(stderr(), "could not find project: {e}"); + Error::ExitCode(17) + })?; + let Some(p) = project else { + let _ = writeln!(stderr(), "no such project: {}", projname)?; + return Err(Error::ExitCode(4)); + }; + + let mut w = stdout(); + let _ = writeln!(w, "{}", p.name)?; + let _ = writeln!(w, " {: <8}: {}", "path", p.dir.display())?; + if let Some(info) = p.info.as_ref() { + let _ = writeln!(w, " {: <8}: {}", "info", info)?; + } + if let Some(upstream) = p.upstream.as_ref() { + let _ = writeln!(w, " {: <8}: {}", "upstream", upstream)?; + } + + Ok(()) + } + + fn op_upstream(&mut self, projname: &str) -> Result<()> { + let Some(db) = self.db.as_ref() else { + return Err(Error::ExitCode(16)); + }; + + let project = db.project_by_name(&projname) + .map_err(|e| { + let _ = writeln!(stderr(), "could not find project: {e}"); + Error::ExitCode(17) + })?; + let Some(p) = project else { + let _ = writeln!(stderr(), "no such project: {}", projname)?; + return Err(Error::ExitCode(4)); + }; + + let Some(upstream) = p.upstream.as_ref() else { + return Err(Error::ExitCode(1)); + }; + + let mut w = stdout(); + let _ = writeln!(w, "{}", upstream)?; + Ok(()) + } + + fn op_list(&mut self, prefix: Option<&str>) -> Result<()> { + let Some(db) = self.db.as_ref() else { + return Err(Error::ExitCode(16)); + }; + + let print_project_name = |p: Project| { + if let Some(prefix) = prefix { + if !p.name.starts_with(prefix) { + return; + } + } + + let _ = writeln!(stdout(), "{}", p.name); + }; + + db.for_each_project(print_project_name) + .map_err(|e| { + let _ = writeln!(stderr(), "could not list projects: {e}"); + Error::ExitCode(2) + }) + } + + fn op_dir(&mut self, projname: &str) -> Result<()> { + let Some(db) = self.db.as_ref() else { + return Err(Error::ExitCode(16)); + }; + + let project = db.project_by_name(&projname) + .map_err(|e| { + let _ = writeln!(stderr(), "could not find project: {e}"); + Error::ExitCode(17) + })?; + let Some(p) = project else { + let _ = writeln!(stderr(), "no such project: {}", projname)?; + return Err(Error::ExitCode(4)); + }; + + if let Some(host) = p.host.as_ref() { + let _ = writeln!(stderr(), "project is at {}, not local", host)?; + return Err(Error::ExitCode(5)); + } + + let mut w = stdout(); + let _ = writeln!(w, "{}", p.dir.display())?; + Ok(()) + } + + fn op_forget(&mut self, projname: &str) -> Result<()> { + let Some(db) = self.db.as_ref() else { + return Err(Error::ExitCode(16)); + }; + + db.project_forget(&projname) + .map_err(|e| { + let _ = writeln!(stderr(), "could not forget {}: {}", projname, e); + Error::ExitCode(2) + })?; + + Ok(()) + } + + fn op_edit(&mut self, projname: &str) -> Result<()> { + let Some(db) = self.db.as_ref() else { + return Err(Error::ExitCode(16)); + }; + + let project = db.project_by_name(&projname) + .map_err(|e| { + let _ = writeln!(stderr(), "could not find project: {e}"); + Error::ExitCode(17) + })?; + let Some(mut p) = project else { + let _ = writeln!(stderr(), "no such project: {}", projname)?; + return Err(Error::ExitCode(4)); + }; + + let mut existing = String::new(); + use std::fmt::{Write as FmtWrite}; + use std::os::fd::FromRawFd; + writeln!(existing, "name:{}", p.name) + .map_err(|_| Error::ExitCode(5))?; + writeln!(existing, "dir:{}", p.dir.display()) + .map_err(|_| Error::ExitCode(5))?; + if let Some(host) = p.host.as_ref() { + writeln!(existing, "host:{}", host) + .map_err(|_| Error::ExitCode(5))?; + } + if let Some(info) = p.info.as_ref() { + writeln!(existing, "info:{}", info) + .map_err(|_| Error::ExitCode(5))?; + } + if let Some(upstream) = p.upstream.as_ref() { + writeln!(existing, "upstream:{}", upstream) + .map_err(|_| Error::ExitCode(5))?; + } + + 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(Error::ExitCode(5)); + } + + let mut file = unsafe { std::fs::File::from_raw_fd(tmpfd) }; + + let res = file.write_all(existing.as_bytes()); + if let Err(e) = res { + writeln!(stderr(), "could not write: {:?}", e)?; + return Err(Error::ExitCode(6)); + } + + let command = format!( + "vim {}", + str::from_utf8(&template[..]).expect("valid") + ); + let cstr = CString::new(command).expect("TODO: it's valid though!"); + + // from `builtins/common.h` + const SEVAL_NOHIST: std::ffi::c_int = 0x004; + const SEVAL_NOFREE: std::ffi::c_int = 0x008; + + let res = unsafe { + evalstring( + cstr.as_bytes().as_ptr() as *const _, + std::ptr::null(), + SEVAL_NOHIST | SEVAL_NOFREE, + ) + }; + if res != 0 { + let _ = writeln!(stderr(), "could not eval"); + return Err(Error::ExitCode(2)); + } + + let mut edited = String::new(); + file.rewind() + .map_err(|_e| Error::ExitCode(5))?; + file.read_to_string(&mut edited) + .map_err(|_e| Error::ExitCode(5))?; + + 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 { + writeln!(stderr(), "bogus line: {line}")?; + return Err(Error::ExitCode(7)); + }; + 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 { + writeln!(stderr(), "missing name or dir")?; + return Err(Error::ExitCode(8)); + }; + + p.name = name; + let dir: &[u8] = dir.as_bytes(); + let dir: &std::ffi::OsStr = OsStrExt::from_bytes(dir); + p.dir = dir.to_os_string(); + + db.project_update(&p) + .map_err(|e| { + let _ = writeln!(stderr(), "failed to update project: {}", e); + Error::ExitCode(9) + })?; + + std::fs::remove_file(OsStr::from_bytes(&template)) + .map_err(|e| { + let _ = writeln!(stderr(), "couldn't unlink tempfile? {}", e); + Error::ExitCode(10) + })?; + + Ok(()) + } + + fn op_add(&mut self, projname: &str, projdir: &str) -> Result<()> { + let abspath = std::path::absolute(projdir) + .map_err(|_| Error::ExitCode(8))?; + + let Some(db) = self.db.as_ref() else { + return Err(Error::ExitCode(16)); + }; + + let new = Project { + id: 0, + name: projname.to_string(), + dir: abspath.into_os_string(), + info: None, + upstream: None, + host: None, + }; + db.project_add(new) + .map_err(|e| { + let _ = writeln!(stderr(), "failed to add project: {}", e); + Error::ExitCode(9) + })?; + Ok(()) + } +} + +builtin_metadata!( + name = "q", + create = Qroject::new, + short_doc = "q {add,go,edit,dir,forget, upstream,info,list} [project]", + long_doc = "project switcher.\n\ + \n\ + switch to, edit, or print information about, a project. all operations\n\ + are either shell built-ins of their own (qg, qi, qd), or subcommands\n\ + of this program, \"q\".\n\ + \n\ + COMMANDS:\n\ + \n \ + add [projname] [projdir]: add a project named \"projname\" at \n \ + \"projdir\" to the project database. if this project should \n \ + have additional fields (like info, upstream, ..) it can be edited\n \ + after adding.\n \ + go [projname]: change directory to \"projname\".\n \ + edit [projname]: edit a project definition in vim (not $EDITOR yet, sorry)\n \ + dir [projname]: print the directory for \"projname\"\n \ + upstream [projname]: print the upstream for \"projname\"\n \ + info [projname]: print all info for \"projname\"\n \ + forget [projname]: forget about \"projname\". this deletes the row\n \ + from the database, does not change any other files. still, be careful.\n \ + list [prefix]: list projects, optionally restricting print all info for \"projname\"\n\ + ", +); + +builtin_metadata!( + name = "qg", + create = QrojectGo::new, + short_doc = "qg [project]", + long_doc = "go to [project]", +); + +builtin_metadata!( + name = "qi", + create = QrojectInfo::new, + short_doc = "qi [project]", + long_doc = "info for [project]", +); + +builtin_metadata!( + name = "qd", + create = QrojectDir::new, + short_doc = "qd [project]", + long_doc = "print directory for [project], if local", +); + +// this is probably the kind of software that, circa 2026, you'd ask an LLM to cough up. +// probably would do an ok job of it too, even. see `shoutouts` in the readme for a sense of why i +// didn't. someone put in the work for `bash_builtins`, complete with rather nice docs! i +// appreciate that! in the spirit of collaboration, i wanted to build on that. +// +// stamping out a few thousand lines of emdashful code would not have known about "related +// bindings". try it if you want. there's no love of the game in it. fully spiritless. it's very +// easy to simply Not Care about our peers and neighbors in open source. don't do that. it's +// important to exist, and we only exist if we see each other. +// +// i had fun learning how to put this together and hope you have a bit of fun reading this too. or +// maybe it's just useful, and you never read this; that's ok too. +// +// it's wartful, because of course it is, because *i* coughed this up in basically a day. +// hope you appreciate those too. diff --git a/src/shared.rs b/src/shared.rs new file mode 100644 index 0000000..bbca537 --- /dev/null +++ b/src/shared.rs @@ -0,0 +1,167 @@ +use std::ffi::{OsStr, OsString}; +#[cfg(target_family="unix")] +use std::os::unix::ffi::OsStrExt; +#[cfg(target_family="windows")] +use std::os::windows::ffi::OsStrExt; +use std::sync::Mutex; +use std::path::Path; + +use rusqlite::{Connection, params}; + +// in some configurations (`main.rs`), nothing actually uses id. +#[allow(dead_code)] +pub struct Project { + pub id: u32, + pub name: String, + pub dir: OsString, + pub host: Option<String>, + pub info: Option<String>, + pub upstream: Option<String>, +} + +impl Project { + pub fn is_here(&self) -> bool { + let uname = rustix::system::uname(); + let me = uname.nodename(); + if let Some(host) = self.host.as_ref() { + host.as_bytes() == me.to_bytes() + } else { + std::path::Path::new(&self.dir).exists() + } + } +} + +pub struct DbCtx { + pub conn: Mutex<Connection>, +} + +impl DbCtx { + pub fn new<P: AsRef<Path>>(db_path: P) -> Result<Self, String> { + let conn = Connection::open(db_path) + .map_err(|e| format!("failed to open db: {e}"))?; + + Ok(Self { + conn: Mutex::new(conn) + }) + } + + pub fn init<P: AsRef<Path>>(db_path: P) -> Result<(), String> { + let conn = Connection::open(db_path) + .map_err(|e| format!("failed to open db: {e}"))?; + + conn.execute( + "CREATE TABLE projects (\ + id INTEGER PRIMARY KEY AUTOINCREMENT, \ + name TEXT UNIQUE NOT NULL, \ + dir BLOB NOT NULL, \ + host TEXT, \ + info TEXT, \ + upstream TEXT\ + );", params![]) + .map_err(|e| format!("failed to create projects table: {e}"))?; + + conn.execute( + "CREATE INDEX projname on projects(name);", params![]) + .map_err(|e| format!("failed to create index?!: {e}"))?; + + Ok(()) + } + + pub fn project_add(&self, p: Project) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn + .execute( + "insert into projects \ + (name, dir, host, info, upstream) \ + values (?1, ?2, ?3, ?4, ?5);",/* \ + on conflict (name) do update \ + set dir=excluded.dir \ + info=excluded.info \ + upstream=excluded.upstream;",*/ + params![p.name, p.dir.as_os_str().as_bytes(), p.host, p.info, p.upstream] + ) + .map_err(|e| format!("failed to insert project: {e}"))?; + Ok(()) + } + + pub fn project_update(&self, p: &Project) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + // TODO: don't panic on name conflict.. + conn + .execute( + "update projects \ + set name=?2, \ + dir=?3, \ + host=?4, \ + info=?5, \ + upstream=?6 \ + where id=?1", + params![p.id.to_string(), p.name, p.dir.as_os_str().as_bytes(), p.host, p.info, p.upstream] + ) + .map_err(|e| format!("failed to update project: {e}"))?; + Ok(()) + } + + pub fn project_forget(&self, name: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn + .execute( + "delete from projects where name=?1;", + params![name] + ) + .map_err(|e| format!("failed to delete project: {e}"))?; + Ok(()) + } + + pub fn for_each_project(&self, op: impl Fn(Project)) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("select * from projects").expect("can prepare"); + let mut rows = stmt.query([]) + .map_err(|e| format!("failed to select projects: {e}"))?; + + while let Some(row) = rows.next().expect("can read row") { + let id: u32 = row.get(0).expect("typecheck"); + let dir: Box<[u8]> = row.get(2).expect("typecheck"); + let osstr: &OsStr = OsStrExt::from_bytes(&dir); + let project = Project { + id, + name: row.get(1).expect("typecheck"), + dir: osstr.to_os_string(), + host: row.get(3).expect("typecheck"), + info: row.get(4).expect("typecheck"), + upstream: row.get(5).expect("typecheck"), + }; + + op(project); + } + Ok(()) + } + + pub fn project_by_name(&self, name: &str) -> Result<Option<Project>, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("select id, name, dir, host, info, upstream from projects where name=?1") + .expect("can prepare"); + let mut rows = stmt.query([name]) + .map_err(|e| format!("failed to find projects: {e}"))?; + + let project = if let Some(row) = rows.next().expect("can read row") { + let id: u32 = row.get(0).expect("typecheck"); + let dir: Box<[u8]> = row.get(2).expect("typecheck"); + let osstr: &OsStr = OsStrExt::from_bytes(&dir); + Some(Project { + id, + name: row.get(1).expect("typecheck"), + dir: osstr.to_os_string(), + host: row.get(3).expect("typecheck"), + info: row.get(4).expect("typecheck"), + upstream: row.get(5).expect("typecheck"), + }) + } else { + None + }; + + assert!(rows.next().expect("can try getting next row").is_none()); + + Ok(project) + } +} |
