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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521pub mod args;pub mod commands;pub mod editor;pub mod entry_location;pub mod interactive;pub mod pager;pub mod process;#[cfg(test)]pub mod test_helpers;pub mod title_note;
use std::{ffi::OsString, io::IsTerminal, path::PathBuf};
use clap::{ArgAction, CommandFactory, Parser, Subcommand};use doing_config::Config;use doing_taskpaper::{Document, Section, io as taskpaper_io};use log::debug;use yansi::Condition;
use crate::Result;
/// The date-time format string used for `@done` tag values throughout the CLI.pub const DONE_DATE_FORMAT: &str = "%Y-%m-%d %H:%M";
/// Resolve a tag value from `--date` and `--value` flags.////// When `date` is `true`, returns the current local time formatted with/// [`DONE_DATE_FORMAT`]. Otherwise returns `value` as-is.pub fn resolve_tag_value(date: bool, value: &Option<String>) -> Option<String> { if date { Some(chrono::Local::now().format(DONE_DATE_FORMAT).to_string()) } else { value.clone() }}
/// Create a "section not found" error for the given section name.pub fn section_not_found_err(section_name: &str) -> crate::Error { crate::Error::Config(format!("section \"{section_name}\" not found"))}
/// Shared application context passed to all command handlers.#[allow(dead_code)]pub struct AppContext { pub config: Config, pub default_answer: bool, pub document: Document, pub doing_file: PathBuf, pub include_notes: bool, pub no: bool, pub noauto: bool, pub quiet: bool, pub stdout: bool, pub use_color: bool, pub use_pager: bool, pub yes: bool,}
impl AppContext { /// Confirm that a new section should be created. /// /// When running interactively (TTY), prompts the user to confirm before creating. /// Respects `--yes` and `--no` global flags. In non-interactive mode (e.g. piped /// input), sections are created silently. pub fn confirm_new_section(&self, section_name: &str) -> Result<bool> { if self.yes { return Ok(true); } if self.no { return Ok(false); } if self.default_answer || !std::io::stderr().is_terminal() { return Ok(true); }
let result = dialoguer::Confirm::new() .with_prompt(format!("Section \"{section_name}\" does not exist. Create it?")) .default(true) .interact_opt() .map_err(crate::cli::interactive::dialoguer_error)?;
Ok(result.unwrap_or(false)) }
/// Ensure a section exists in the document, prompting for confirmation if needed. /// /// Returns `Ok(true)` if the section exists (or was created), `Ok(false)` if creation /// was declined, or an error if the prompt failed. pub fn ensure_section(&mut self, section_name: &str) -> Result<bool> { if self.document.has_section(section_name) { return Ok(true); }
if !self.confirm_new_section(section_name)? { return Ok(false); }
self.document.add_section(Section::new(section_name)); Ok(true) }
/// Create a minimal `AppContext` for unit tests. /// /// Uses `Config::default()`, an empty document, and sensible test defaults. /// Callers can override any field after construction. #[cfg(test)] pub fn for_test(doing_file: impl Into<PathBuf>) -> Self { Self { config: Config::default(), default_answer: false, document: Document::new(), doing_file: doing_file.into(), include_notes: true, no: false, noauto: false, quiet: false, stdout: false, use_color: false, use_pager: false, yes: false, } }
/// Resolve a section name from an optional override, falling back to the config default. pub fn resolve_section(&self, section: &Option<String>) -> String { section.clone().unwrap_or_else(|| self.config.current_section.clone()) }
/// Print a user-facing status message to stderr. /// /// Respects `--quiet` — when quiet mode is active, the message is suppressed. pub fn status(&self, msg: impl std::fmt::Display) { if !self.quiet { if self.stdout { println!("{msg}"); } else { eprintln!("{msg}"); } } }}
/// A CLI for a What Was I Doing system.#[derive(Debug, Parser)]#[command( about = env!("CARGO_PKG_DESCRIPTION"), author = "Aaron Allen <@aaronmallen>", long_about = "Doing uses a TaskPaper-like formatting to keep a plain text record of what \ you've been doing, complete with tag-based time tracking. The command line tool allows you \ to add entries, annotate with tags and notes, and view your entries with myriad options, \ with a focus on a \"natural\" language syntax.", name = "doing", version = env!("CARGO_PKG_VERSION"),)]pub struct Cli { /// Colored output #[arg(long, action = ArgAction::SetTrue, overrides_with = "no_color", global = true)] color: bool,
#[arg(long = "no-color", action = ArgAction::SetTrue, hide = true, overrides_with = "color", global = true)] no_color: bool,
#[command(subcommand)] command: Option<Command>,
/// Enable debug output #[arg(long, global = true)] debug: bool,
/// Answer yes/no menus with default option #[arg(long, global = true)] default: bool,
/// Specify a different doing_file [env: DOING_FILE=] #[arg(short = 'f', long, global = true)] doing_file: Option<PathBuf>,
/// Answer all yes/no menus with no #[arg(long, global = true)] no: bool,
/// Exclude auto tags and default tags #[arg(short = 'X', long, action = ArgAction::SetTrue, overrides_with = "no_noauto", global = true)] noauto: bool,
#[arg( long = "no-noauto", action = ArgAction::SetTrue, hide = true, overrides_with = "noauto", global = true )] no_noauto: bool,
/// Output notes if included in the template #[arg(long, action = ArgAction::SetTrue, overrides_with = "no_notes", global = true)] notes: bool,
#[arg( long = "no-notes", action = ArgAction::SetTrue, hide = true, overrides_with = "notes", global = true )] no_notes: bool,
/// Use a pager when output is longer than screen #[arg(short = 'p', long, action = ArgAction::SetTrue, overrides_with = "no_pager", global = true)] pager: bool,
#[arg( long = "no-pager", action = ArgAction::SetTrue, hide = true, overrides_with = "pager", global = true )] no_pager: bool,
/// Silence status messages #[arg(long, global = true)] quiet: bool,
/// Send results report to STDOUT instead of STDERR #[arg(long, global = true)] stdout: bool,
/// Answer all yes/no menus with yes #[arg(long, global = true)] yes: bool,}
impl Cli { fn call(&self) -> Result<()> { doing_template::colors::init(); init_logger(self.log_level());
debug!("CLI parsed successfully");
let config = Config::load()?;
if let Some(config_path) = doing_config::loader::discover_global_config() { debug!("Using config file: {}", config_path.display()); } else { debug!("No config file found, using defaults"); }
let doing_file = self.doing_file.clone().unwrap_or_else(|| config.doing_file.clone());
debug!("Using doing file: {}", doing_file.display());
Document::create_file(&doing_file, &config.current_section)?; let document = taskpaper_io::read_file(&doing_file)?;
let include_notes = if self.notes { true } else if self.no_notes { false } else { config.include_notes };
let use_color = if self.color { true } else if self.no_color { false } else { Condition::stdouterr_are_tty_live() };
if use_color { yansi::enable(); } else { yansi::disable(); }
let use_pager = if self.pager { true } else if self.no_pager { false } else { config.paginate };
let quiet = self.quiet || doing_config::env::DOING_QUIET.value().unwrap_or(false);
let mut ctx = AppContext { config, default_answer: self.default, document, doing_file, include_notes, no: self.no, noauto: self.noauto && !self.no_noauto, quiet, stdout: self.stdout, use_color, use_pager, yes: self.yes, };
let default_cmd = Command::Recent(commands::recent::Command::default()); self.command.as_ref().unwrap_or(&default_cmd).call(&mut ctx) }
fn log_level(&self) -> log::LevelFilter { let env_debug = doing_config::env::DOING_DEBUG.value().unwrap_or(false); let env_level = doing_config::env::DOING_LOG_LEVEL.value().ok(); let env_quiet = doing_config::env::DOING_QUIET.value().unwrap_or(false);
// Quiet always wins, then debug, then env level, then default (info) if self.quiet || env_quiet { log::LevelFilter::Error } else if self.debug || env_debug { log::LevelFilter::Debug } else if let Some(ref level) = env_level { match level.to_lowercase().as_str() { "debug" => log::LevelFilter::Debug, "error" => log::LevelFilter::Error, "info" => log::LevelFilter::Info, "trace" => log::LevelFilter::Trace, "warn" => log::LevelFilter::Warn, _ => log::LevelFilter::Info, } } else { log::LevelFilter::Info } }}
/// All available subcommands.#[derive(Debug, Subcommand)]enum Command { /// Repeat the last entry #[command(visible_alias = "resume")] Again(commands::again::Command), /// Move entries to the Archive section #[command(visible_alias = "move")] Archive(commands::archive::Command), /// Apply autotagging rules to existing entries Autotag(commands::autotag::Command), /// Manage time budgets for tags Budget(commands::budget::Command), /// Mark the last entry as cancelled Cancel(commands::cancel::Command), /// List recent changes in Doing #[command(visible_alias = "changelog")] Changes(commands::changes::Command), /// Fuzzy select an entry to act on Choose(commands::choose::Command), /// Show available color template tokens Colors(commands::colors::Command), /// Manage optional commands Commands(commands::commands::Command), /// List commands accepting a given option #[command(hide = true)] CommandsAccepting(commands::commands_accepting::Command), /// Generate shell completions Completion(commands::completion::Command), /// View, edit, and manage configuration Config(commands::config::Command), /// Add a completed entry #[command(visible_alias = "did")] Done(commands::done::Command), /// Catch-all for unknown subcommands (checked against custom views) #[command(external_subcommand)] External(Vec<OsString>), /// Mark the last entry as finished Finish(commands::finish::Command), /// Search for entries matching a pattern #[command(visible_alias = "search")] Grep(commands::grep::Command), /// Import entries from other sources Import(commands::import::Command), /// Show the last entry Last(commands::last::Command), /// Toggle the marker tag on the last entry #[command(visible_alias = "flag")] Mark(commands::mark::Command), /// Add an entry while finishing the last Meanwhile(commands::meanwhile::Command), /// Add or display notes on the last entry Note(commands::note::Command), /// Add a new entry #[command(visible_alias = "next")] Now(commands::now::Command), /// Show entries from a specific date On(commands::on::Command), /// Open the doing file in an editor Open(commands::open::Command), /// List installed plugins Plugins(commands::plugins::Command), /// Show recent entries Recent(commands::recent::Command), /// Redo the last undone change Redo(commands::redo::Command), /// Reset the start date of the last entry #[command(visible_alias = "begin")] Reset(commands::reset::Command), /// Move entries to a dated archive file Rotate(commands::rotate::Command), /// List available sections Sections(commands::sections::Command), /// Interactively select entries to act on Select(commands::select::Command), /// Update doing to the latest version SelfUpdate(commands::update::Command), /// Show entries from a section Show(commands::show::Command), /// Show entries since a given date Since(commands::since::Command), /// Add or remove tags from entries Tag(commands::tag::Command), /// Set the default tags directory TagDir(commands::tag_dir::Command), /// List all tags in the doing file Tags(commands::tags::Command), /// Show or edit entry templates Template(commands::template::Command), /// Show entries from today Today(commands::today::Command), /// Undo the last change Undo(commands::undo::Command), /// Display a custom view View(commands::view::Command), /// List available views Views(commands::views::Command), /// Show entries from yesterday Yesterday(commands::yesterday::Command),}
impl Command { fn call(&self, ctx: &mut AppContext) -> Result<()> { match self { Self::Again(cmd) => cmd.call(ctx), Self::Archive(cmd) => cmd.call(ctx), Self::Autotag(cmd) => cmd.call(ctx), Self::Budget(cmd) => cmd.call(ctx), Self::Cancel(cmd) => cmd.call(ctx), Self::Changes(cmd) => cmd.call(ctx), Self::Choose(cmd) => cmd.call(ctx), Self::Colors(cmd) => cmd.call(), Self::Commands(cmd) => cmd.call(ctx, &Cli::command()), Self::CommandsAccepting(cmd) => cmd.call(&Cli::command()), Self::Completion(cmd) => cmd.call(), Self::Config(cmd) => cmd.call(ctx), Self::Done(cmd) => cmd.call(ctx), Self::External(args) => { let name = args.first().and_then(|s| s.to_str()).unwrap_or(""); if ctx.config.views.contains_key(name) { commands::view::Command::call_external(name, ctx) } else if args.len() > 1 { let title: Vec<String> = args.iter().filter_map(|s| s.to_str().map(String::from)).collect(); commands::now::Command::call_external(title, ctx) } else { let mut message = format!("unknown command: {name}"); if let Some(suggestion) = suggest_subcommand(name) { message.push_str(&format!("\n\n\tDid you mean '{suggestion}'?")); } Err(crate::Error::Config(message)) } } Self::Finish(cmd) => cmd.call(ctx), Self::Grep(cmd) => cmd.call(ctx), Self::Import(cmd) => cmd.call(ctx), Self::Last(cmd) => cmd.call(ctx), Self::Mark(cmd) => cmd.call(ctx), Self::Meanwhile(cmd) => cmd.call(ctx), Self::Note(cmd) => cmd.call(ctx), Self::Now(cmd) => cmd.call(ctx), Self::On(cmd) => cmd.call(ctx), Self::Open(cmd) => cmd.call(ctx), Self::Plugins(cmd) => cmd.call(), Self::Recent(cmd) => cmd.call(ctx), Self::Redo(cmd) => cmd.call(ctx), Self::Reset(cmd) => cmd.call(ctx), Self::Rotate(cmd) => cmd.call(ctx), Self::Sections(cmd) => cmd.call(ctx), Self::Select(cmd) => cmd.call(ctx), Self::SelfUpdate(cmd) => cmd.call(), Self::Show(cmd) => cmd.call(ctx), Self::Since(cmd) => cmd.call(ctx), Self::Tag(cmd) => cmd.call(ctx), Self::TagDir(cmd) => cmd.call(ctx), Self::Tags(cmd) => cmd.call(ctx), Self::Template(cmd) => cmd.call(ctx), Self::Today(cmd) => cmd.call(ctx), Self::Undo(cmd) => cmd.call(ctx), Self::View(cmd) => cmd.call(ctx), Self::Views(cmd) => cmd.call(ctx), Self::Yesterday(cmd) => cmd.call(ctx), } }}
pub fn run() -> Result<()> { Cli::parse().call()}
fn init_logger(level: log::LevelFilter) { env_logger::Builder::new().filter_level(level).init();}
fn suggest_subcommand(name: &str) -> Option<String> { let threshold = 0.6; Cli::command() .get_subcommands() .filter_map(|cmd| { let cmd_name = cmd.get_name(); let similarity = strsim::jaro_winkler(name, cmd_name); if similarity >= threshold { Some((cmd_name.to_owned(), similarity)) } else { None } }) .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()) .map(|(name, _)| name)}