diff options
Diffstat (limited to 'src/qroject-bash.rs')
| -rw-r--r-- | src/qroject-bash.rs | 720 |
1 files changed, 720 insertions, 0 deletions
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. |
