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