tasks-cli-rs 0.11.0

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
use chrono::{Local, NaiveDate};

use crate::cli::RecurCommand;
use crate::error::{Error, Result};
use crate::model::Priority;
use crate::recur::{self, Rule, RuleMeta, StoredRule};

use super::task::{active_library_root, confirm};

pub fn run(command: RecurCommand) -> Result<()> {
    match command {
        RecurCommand::Add {
            title,
            rule,
            description,
            priority,
            tags,
            start,
            until,
            max_count,
        } => add(title, &rule, description, priority, tags, start, until, max_count),
        RecurCommand::List => list(),
        RecurCommand::Show { rule } => show(&rule),
        RecurCommand::Edit { rule, editor } => edit(&rule, editor),
        RecurCommand::Pause { rule } => set_enabled(&rule, false),
        RecurCommand::Resume { rule } => set_enabled(&rule, true),
        RecurCommand::Remove { rule, force } => remove(&rule, force),
        RecurCommand::Tick => tick(),
    }
}

fn parse_date(label: &str, raw: &str) -> Result<NaiveDate> {
    raw.parse().map_err(|_| {
        Error::InvalidTaskFile(format!("invalid {label} '{raw}', expected YYYY-MM-DD"))
    })
}

#[allow(clippy::too_many_arguments)]
fn add(
    title: String,
    rule: &str,
    description: Option<String>,
    priority: Option<String>,
    tags: Vec<String>,
    start: Option<String>,
    until: Option<String>,
    max_count: Option<u64>,
) -> Result<()> {
    let root = active_library_root()?;
    let rule: Rule = rule.parse().map_err(Error::BadRule)?;
    let start_date = match &start {
        Some(s) => parse_date("start date", s)?,
        None => Local::now().date_naive(),
    };
    let mut meta = RuleMeta::new(title, rule, start_date);
    meta.description = description;
    meta.tags = tags;
    meta.until = until.as_deref().map(|u| parse_date("until date", u)).transpose()?;
    meta.max_count = max_count;
    if let Some(p) = &priority {
        meta.priority = p.parse::<Priority>().map_err(Error::InvalidTaskFile)?;
    } else if let Some(p) = crate::config::Config::load().ok().and_then(|c| c.default_priority) {
        meta.priority = p.parse::<Priority>().map_err(Error::InvalidTaskFile)?;
    }
    if let Some(until) = meta.until
        && until < meta.start_date
    {
        return Err(Error::BadRule(format!(
            "--until {until} is before the start date {}",
            meta.start_date
        )));
    }

    let path = recur::rule_path(&root, &meta);
    if path.exists() {
        return Err(Error::InvalidTaskFile(format!("{} already exists", path.display())));
    }
    let stored = StoredRule { path, meta, body: String::new() };
    recur::save(&stored)?;
    println!(
        "created rule [{}] {} ({})",
        stored.meta.short_id(),
        stored.meta.title,
        stored.meta.rule
    );
    println!("  next: {}", stored.meta.next_run);
    println!("  {}", stored.path.display());
    Ok(())
}

fn list() -> Result<()> {
    let root = active_library_root()?;
    let rules = recur::load_all(&root)?;
    if rules.is_empty() {
        println!("no rules; create one with 'tasks recur add <title> --rule daily'");
        return Ok(());
    }
    println!("{:<9} {:<16} {:<12} {:<10} {:<7} TITLE", "ID", "RULE", "NEXT", "STATE", "COUNT");
    for stored in &rules {
        let m = &stored.meta;
        let state = if !m.enabled {
            "paused".to_string()
        } else if m.exhausted() {
            "finished".to_string()
        } else {
            "active".to_string()
        };
        let count = match m.max_count {
            Some(max) => format!("{}/{max}", m.created_count),
            None => m.created_count.to_string(),
        };
        println!(
            "{:<9} {:<16} {:<12} {:<10} {:<7} {}",
            m.short_id(),
            m.rule.to_string(),
            m.next_run.to_string(),
            state,
            count,
            m.title
        );
    }
    Ok(())
}

fn show(query: &str) -> Result<()> {
    let root = active_library_root()?;
    let stored = recur::resolve(&root, query)?;
    let m = &stored.meta;
    println!("[{}] {}", m.short_id(), m.title);
    println!("  uuid:      {}", m.id);
    println!("  rule:      {}", m.rule);
    println!("  enabled:   {}", m.enabled);
    println!("  priority:  {}", m.priority);
    println!("  tags:      {}", if m.tags.is_empty() { "-".into() } else { m.tags.join(", ") });
    if let Some(d) = &m.description {
        println!("  desc:      {d}");
    }
    println!("  start:     {}", m.start_date);
    if let Some(until) = m.until {
        println!("  until:     {until}");
    }
    println!("  next:      {}", m.next_run);
    if let Some(last) = m.last_run {
        println!("  last:      {last}");
    }
    match m.max_count {
        Some(max) => println!("  generated: {}/{max}", m.created_count),
        None => println!("  generated: {}", m.created_count),
    }
    if m.exhausted() {
        println!("  state:     finished");
    }
    println!("  file:      {}", stored.path.display());
    if !stored.body.is_empty() {
        println!("\n{}", stored.body.trim_end());
    }
    Ok(())
}

fn edit(query: &str, editor_flag: Option<String>) -> Result<()> {
    let root = active_library_root()?;
    let stored = recur::resolve(&root, query)?;
    let config = crate::config::Config::load()?;
    let editor = crate::editor::detect(editor_flag.as_deref(), &config)?;
    let original = std::fs::read_to_string(&stored.path)?;
    crate::editor::open(&editor, &stored.path)?;
    let edited = std::fs::read_to_string(&stored.path)?;
    match validate_edit(&stored.meta, &edited) {
        Ok((mut meta, body)) => {
            // a changed schedule invalidates the stored next occurrence, but
            // an explicitly edited next_run is the user's to keep
            let schedule_changed =
                meta.rule != stored.meta.rule || meta.start_date != stored.meta.start_date;
            if schedule_changed && meta.next_run == stored.meta.next_run {
                let from = Local::now().date_naive().max(meta.start_date);
                meta.next_run = meta.rule.first_on_or_after(from, meta.start_date);
            }
            let title = meta.title.clone();
            let next_run = meta.next_run;
            let new_path = recur::rule_path(&root, &meta);
            recur::save(&StoredRule { path: stored.path.clone(), meta, body })?;
            if new_path != stored.path {
                std::fs::rename(&stored.path, &new_path)?;
                println!("renamed: {}", new_path.file_name().unwrap_or_default().to_string_lossy());
            }
            println!("saved rule '{title}'");
            println!("  next: {next_run}");
            Ok(())
        }
        Err(reason) => {
            std::fs::write(&stored.path, original)?;
            Err(Error::EditRejected(reason))
        }
    }
}

/// The file must still parse and id/created_at must be unchanged.
fn validate_edit(
    before: &RuleMeta,
    edited_content: &str,
) -> std::result::Result<(RuleMeta, String), String> {
    let (meta, body) = recur::parse_rule(edited_content).map_err(|e| e.to_string())?;
    if meta.id != before.id {
        return Err(format!("field 'id' is immutable ({} -> {})", before.id, meta.id));
    }
    if meta.created_at != before.created_at {
        return Err("field 'created_at' is immutable".into());
    }
    Ok((meta, body))
}

fn set_enabled(query: &str, enabled: bool) -> Result<()> {
    let root = active_library_root()?;
    let mut stored = recur::resolve(&root, query)?;
    if stored.meta.enabled == enabled {
        println!(
            "rule {} is already {}",
            stored.meta.title,
            if enabled { "active" } else { "paused" }
        );
        return Ok(());
    }
    stored.meta.enabled = enabled;
    recur::save(&stored)?;
    println!(
        "{} {}",
        if enabled { "resumed" } else { "paused" },
        stored.meta.title
    );
    if enabled {
        println!("  next: {}", stored.meta.next_run);
    }
    Ok(())
}

fn remove(query: &str, force: bool) -> Result<()> {
    let root = active_library_root()?;
    let stored = recur::resolve(&root, query)?;
    if !force && !confirm(&format!("delete rule '{}'?", stored.meta.title))? {
        println!("aborted");
        return Ok(());
    }
    std::fs::remove_file(&stored.path)?;
    println!("removed rule '{}'", stored.meta.title);
    Ok(())
}

fn tick() -> Result<()> {
    let root = active_library_root()?;
    let generated = recur::run_due(&root, Local::now().date_naive())?;
    if generated.is_empty() {
        println!("no rules are due");
        return Ok(());
    }
    for g in &generated {
        println!("created task #{} {} (for {})", g.seq, g.title, g.occurrence);
        println!("  {}", g.path.display());
    }
    println!("generated {} task(s)", generated.len());
    Ok(())
}

/// Lazy catch-up before any other command: rules are calendar-driven, and
/// nothing else in this CLI runs in the background. Failures are reported but
/// never block the command the user actually asked for.
pub fn autorun() {
    let Ok(root) = active_library_root() else { return };
    match recur::run_due(&root, Local::now().date_naive()) {
        Ok(generated) if !generated.is_empty() => {
            let summary: Vec<String> =
                generated.iter().map(|g| format!("#{} {}", g.seq, g.title)).collect();
            eprintln!(
                "generated {} recurring task(s): {}",
                generated.len(),
                summary.join(", ")
            );
        }
        Ok(_) => {}
        Err(err) => eprintln!("warning: recurring task generation failed: {err}"),
    }
}