Something went wrong. Try again.
A Rust clone of doing by Brett Terpstra — a command line tool for remembering what you were doing and tracking what you've done. doing.aaronmallen.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277use std::{ path::Path, process::{Command, Stdio},};
use doing_config::Config;
use crate::{Error, Result};
/// Parse a shell-style command string and launch it with an optional file argument.////// Splits `command` on whitespace into program + arguments, appends `file` if/// provided, then runs the command and waits for it to exit. Returns an error/// when the command string is empty or the process exits with a non-zero status.pub fn launch(command: &str, file: Option<&Path>) -> Result<()> { let parts: Vec<&str> = command.split_whitespace().collect(); let (cmd, args) = parts .split_first() .ok_or_else(|| Error::Config("command must not be empty".into()))?;
let mut process = Command::new(cmd); process.args(args); if let Some(path) = file { process.arg(path); }
let status = process.status()?; if !status.success() { return Err(Error::Io(std::io::Error::other(format!( "command exited with status {status}" )))); }
Ok(())}
/// Resolve the doing-file editor command.////// Priority: `config.editors.doing_file` → [`resolve_editor`] fallback chain.pub fn resolve_doing_file_editor(config: &Config) -> String { if let Some(ref editor) = config.editors.doing_file { return editor.clone(); }
resolve_editor(config)}
/// Resolve the default editor command.////// Priority: `$DOING_EDITOR` → `config.editors.default` → `$VISUAL` → `$EDITOR` → `vi`.pub fn resolve_editor(config: &Config) -> String { if let Ok(editor) = doing_config::env::DOING_EDITOR.value() { return editor; }
if let Some(ref editor) = config.editors.default { return editor.clone(); }
if let Ok(editor) = doing_config::env::VISUAL.value() { return editor; }
if let Ok(editor) = doing_config::env::EDITOR.value() { return editor; }
"vi".into()}
/// Resolve the pager command.////// Priority: `config.editors.pager` → `$PAGER` → `less -FRX`.pub fn resolve_pager(config: &Config) -> String { if let Some(ref pager) = config.editors.pager { return pager.clone(); }
if let Ok(pager) = doing_config::env::PAGER.value() { return pager; }
"less -FRX".into()}
/// Parse a shell-style command string and start it without waiting for it to exit.////// Behaves like [`launch`] except that it returns as soon as the child starts./// Use it where `doing` has no work left once the program is running, and never/// for a terminal program that needs the terminal to itself.////// The child's streams are detached from ours. Were they inherited, the child/// would hold our pipes open after we exit, and anything reading `doing`'s/// output would wait for the editor after all.pub fn spawn(command: &str, file: Option<&Path>) -> Result<()> { let parts: Vec<&str> = command.split_whitespace().collect(); let (cmd, args) = parts .split_first() .ok_or_else(|| Error::Config("command must not be empty".into()))?;
let mut process = Command::new(cmd); process.args(args); if let Some(path) = file { process.arg(path); }
process .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn()?;
Ok(())}
#[cfg(test)]mod test { use doing_config::{Config, EditorsConfig};
use super::*;
mod launch { use super::*;
#[test] fn it_returns_error_for_empty_command() { let result = launch("", None);
assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("must not be empty")); }
#[test] fn it_returns_error_for_whitespace_command() { let result = launch(" ", None);
assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("must not be empty")); } }
mod resolve_doing_file_editor { use super::*;
#[test] fn it_falls_back_to_resolve_editor() { let config = Config { editors: EditorsConfig { config: None, default: Some("default-editor".into()), doing_file: None, pager: None, }, ..Config::default() };
if doing_config::env::DOING_EDITOR.value().is_err() { let editor = super::super::resolve_doing_file_editor(&config);
assert_eq!(editor, "default-editor"); } }
#[test] fn it_uses_doing_file_editor_when_set() { let config = Config { editors: EditorsConfig { config: None, default: Some("default-editor".into()), doing_file: Some("doing-editor".into()), pager: None, }, ..Config::default() };
let editor = super::super::resolve_doing_file_editor(&config);
assert_eq!(editor, "doing-editor"); } }
mod resolve_editor { use super::*;
#[test] fn it_falls_back_to_vi() { let config = Config { editors: EditorsConfig { config: None, default: None, doing_file: None, pager: None, }, ..Config::default() };
let editor = super::super::resolve_editor(&config);
// In CI/test environments VISUAL or EDITOR may be set, so we just verify // it returns a non-empty string. assert!(!editor.is_empty()); }
#[test] fn it_uses_config_editor_when_set() { let config = Config { editors: EditorsConfig { config: None, default: Some("custom-editor".into()), doing_file: None, pager: None, }, ..Config::default() };
if doing_config::env::DOING_EDITOR.value().is_err() { let editor = super::super::resolve_editor(&config);
assert_eq!(editor, "custom-editor"); } } }
mod resolve_pager { use super::*;
#[test] fn it_returns_a_pager_command() { let config = Config::default();
let pager = super::super::resolve_pager(&config);
assert!(!pager.is_empty()); }
#[test] fn it_uses_config_pager_when_set() { let config = Config { editors: EditorsConfig { config: None, default: None, doing_file: None, pager: Some("bat".into()), }, ..Config::default() };
let pager = super::super::resolve_pager(&config);
assert_eq!(pager, "bat"); } } mod spawn { use super::*;
#[test] fn it_returns_error_for_empty_command() { let result = spawn("", None);
assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("must not be empty")); }
#[test] fn it_returns_without_waiting_for_the_child_to_exit() { let start = std::time::Instant::now();
spawn("sleep 5", None).expect("expected sleep to start");
assert!( start.elapsed() < std::time::Duration::from_secs(2), "expected spawn to return before the child exited" ); } }}