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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295use clap::{Args, Subcommand};use doing_ops::backup::write_with_backup;use doing_taskpaper::{Entry, Section};
use crate::{Error, Result, cli::AppContext};
/// List, add, or remove sections in the doing file.////// Sections are top-level headings in the doing file (e.g. `Currently:`,/// `Archive:`). Use `doing sections` to list all sections, `doing sections add`/// to create a new one, or `doing sections remove` to delete an empty section.////// # Examples////// ```text/// doing sections # list all sections/// doing sections add Ideas # add a new "Ideas" section/// doing sections remove Ideas # remove the empty "Ideas" section/// ```#[derive(Args, Clone, Debug)]pub struct Command { #[command(subcommand)] action: Option<Action>,}
impl Command { pub fn call(&self, ctx: &mut AppContext) -> Result<()> { match &self.action { None => list_sections(ctx), Some(Action::Add(args)) => add_section(&args.name, ctx), Some(Action::Remove(args)) => remove_section(&args.name, args.archive, ctx), } }}
/// Subcommands for managing sections.#[derive(Clone, Debug, Subcommand)]enum Action { /// Add a new section to the doing file Add(AddArgs), /// Remove an empty section from the doing file Remove(RemoveArgs),}
/// Arguments for the `sections add` subcommand.#[derive(Args, Clone, Debug)]struct AddArgs { /// Name of the section to add #[arg(index = 1, value_name = "NAME")] name: String,}
/// Arguments for the `sections remove` subcommand.#[derive(Args, Clone, Debug)]struct RemoveArgs { /// Archive entries to the Archive section before removing #[arg(short, long)] archive: bool,
/// Name of the section to remove #[arg(index = 1, value_name = "NAME")] name: String,}
fn add_section(name: &str, ctx: &mut AppContext) -> Result<()> { if ctx.document.has_section(name) { return Err(Error::Config(format!("section '{name}' already exists"))); }
ctx.document.add_section(Section::new(name)); write_with_backup(&ctx.document, &ctx.doing_file, &ctx.config)?;
ctx.status(format!("Added section '{name}'")); Ok(())}
fn list_sections(ctx: &AppContext) -> Result<()> { if ctx.quiet { return Ok(()); }
if ctx.document.is_empty() { println!("No sections found."); return Ok(()); }
for section in ctx.document.sections() { println!("{} ({} entries)", section.title(), section.len()); }
Ok(())}
fn remove_section(name: &str, archive: bool, ctx: &mut AppContext) -> Result<()> { let section = ctx .document .section_by_name(name) .ok_or_else(|| Error::Config(format!("section '{name}' not found")))?;
if !section.is_empty() && !archive { return Err(Error::Config(format!( "section '{name}' is not empty ({} entries)", section.len() ))); }
let archived_count = if archive { let section = ctx .document .section_by_name_mut(name) .ok_or_else(|| Error::Config(format!("section '{name}' not found")))?; let entries: Vec<Entry> = std::mem::take(section.entries_mut()); let count = entries.len();
if count > 0 { if !ctx.document.has_section("Archive") { ctx.document.add_section(Section::new("Archive")); } let archive_section = ctx .document .section_by_name_mut("Archive") .ok_or_else(|| Error::Config("Archive section not found after creation".to_string()))?; for entry in entries { archive_section.add_entry(entry); } }
count } else { 0 };
ctx.document.remove_section(name); write_with_backup(&ctx.document, &ctx.doing_file, &ctx.config)?;
if archived_count > 0 { ctx.status(format!( "Archived {archived_count} entries and removed section '{name}'" )); } else { ctx.status(format!("Removed section '{name}'")); } Ok(())}
#[cfg(test)]mod test { use doing_taskpaper::{Document, Entry, Note, Section, Tags};
use super::*;
fn sample_ctx() -> AppContext { let mut doc = Document::new(); let mut section = Section::new("Currently"); section.add_entry(Entry::new( chrono::Local::now(), "Test task", Tags::new(), Note::new(), "Currently", None::<String>, )); doc.add_section(section); doc.add_section(Section::new("Archive"));
let mut ctx = AppContext::for_test(std::path::PathBuf::from("/tmp/test_doing.md")); ctx.document = doc; ctx }
mod add_section { use super::*;
#[test] fn it_returns_error_for_duplicate_section() { let mut ctx = sample_ctx();
let result = super::super::add_section("Currently", &mut ctx);
assert!(result.is_err()); } }
mod list_sections { use super::*;
#[test] fn it_handles_empty_document() { let ctx = AppContext::for_test(std::path::PathBuf::from("/tmp/test_doing.md"));
let result = super::super::list_sections(&ctx);
assert!(result.is_ok()); }
#[test] fn it_lists_sections() { let ctx = sample_ctx();
let result = super::super::list_sections(&ctx);
assert!(result.is_ok()); } }
mod remove_section { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_archives_entries_before_removing_section() { let dir = tempfile::tempdir().unwrap(); let mut ctx = sample_ctx(); ctx.doing_file = dir.path().join("doing.md"); let entry_count = ctx.document.entries_in_section("Currently").count();
let result = super::super::remove_section("Currently", true, &mut ctx);
assert!(result.is_ok()); assert!(!ctx.document.has_section("Currently")); assert_eq!(ctx.document.entries_in_section("Archive").count(), entry_count); }
#[test] fn it_creates_archive_section_when_missing() { let dir = tempfile::tempdir().unwrap(); let mut doc = Document::new(); let mut section = Section::new("Ideas"); section.add_entry(Entry::new( chrono::Local::now(), "Idea task", Tags::new(), Note::new(), "Ideas", None::<String>, )); doc.add_section(section);
let mut ctx = { let mut ctx = AppContext::for_test(dir.path().join("doing.md")); ctx.document = doc; ctx };
let result = super::super::remove_section("Ideas", true, &mut ctx);
assert!(result.is_ok()); assert!(!ctx.document.has_section("Ideas")); assert!(ctx.document.has_section("Archive")); assert_eq!(ctx.document.entries_in_section("Archive").count(), 1); }
#[test] fn it_removes_empty_section() { let mut ctx = sample_ctx();
let result = super::super::remove_section("Archive", false, &mut ctx);
assert!(result.is_ok()); assert!(!ctx.document.has_section("Archive")); }
#[test] fn it_removes_empty_section_with_archive_flag() { let dir = tempfile::tempdir().unwrap(); let mut ctx = sample_ctx(); ctx.doing_file = dir.path().join("doing.md");
let result = super::super::remove_section("Archive", true, &mut ctx);
assert!(result.is_ok()); assert!(!ctx.document.has_section("Archive")); }
#[test] fn it_returns_error_for_missing_section() { let mut ctx = sample_ctx();
let result = super::super::remove_section("Nonexistent", false, &mut ctx);
assert!(result.is_err()); }
#[test] fn it_returns_error_for_non_empty_section() { let mut ctx = sample_ctx();
let result = super::super::remove_section("Currently", false, &mut ctx);
assert!(result.is_err()); } }}