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>>> = OnceLock::new(); struct QrojectGo { inner: Arc>>, } struct QrojectInfo { inner: Arc>>, } struct QrojectDir { inner: Arc>>, } struct Qroject { inner: Arc>> } 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, } impl QrojectInner { fn dbpath() -> Option { let home = std::env::home_dir(); home.map(|mut homedir| { homedir.push(".config"); homedir.push("qroject"); homedir.push("projects.db"); homedir }) } fn new() -> Arc>> { 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.