aboutsummaryrefslogtreecommitdiff
path: root/src/qroject-bash.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/qroject-bash.rs')
-rw-r--r--src/qroject-bash.rs720
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.