tasks-cli-rs 0.9.0

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
use std::path::{Path, PathBuf};

use serde::Deserialize;

use crate::error::{Error, Result};
use crate::model::{Priority, Status, Task};

use super::task::active_library_root;

/// Optional defaults a template may provide. Unknown fields (id, seq,
/// created_at, ...) are deliberately ignored: those are always generated.
#[derive(Debug, Default, Deserialize)]
pub struct TemplateMeta {
    pub status: Option<Status>,
    pub priority: Option<Priority>,
    pub tags: Option<Vec<String>>,
}

#[derive(Debug)]
pub struct Template {
    pub meta: TemplateMeta,
    pub body: String,
}

fn split_front_matter(content: &str) -> (Option<&str>, &str) {
    let Some(rest) = content.strip_prefix("---\n") else {
        return (None, content);
    };
    match rest.find("\n---") {
        Some(end) => {
            let yaml = &rest[..end];
            let after = &rest[end + 4..];
            (Some(yaml), after.trim_start_matches('\n'))
        }
        None => (None, content),
    }
}

pub fn parse_template(content: &str) -> Result<Template> {
    let (yaml, body) = split_front_matter(content);
    let meta = match yaml {
        Some(y) => serde_yaml::from_str(y)?,
        None => TemplateMeta::default(),
    };
    Ok(Template { meta, body: body.to_string() })
}

/// Applies template defaults to a freshly created task. CLI-provided values
/// win: pass `cli_priority_set` / `cli_tags_set` accordingly.
pub fn apply(template: &Template, task: &mut Task, cli_priority_set: bool, cli_tags_set: bool) {
    if !cli_priority_set
        && let Some(p) = template.meta.priority {
            task.meta.priority = p;
        }
    if !cli_tags_set
        && let Some(tags) = &template.meta.tags {
            task.meta.tags = tags.clone();
        }
    if let Some(status) = template.meta.status {
        task.set_status(status);
    }
    let local = task.meta.created_at.with_timezone(&chrono::Local);
    task.body = template
        .body
        .replace("{{title}}", &task.meta.title)
        .replace("{{date}}", &local.format("%Y-%m-%d").to_string());
    task.steps = crate::checkbox::parse_checkboxes(&task.body);
}

fn global_dir() -> Result<PathBuf> {
    Ok(crate::config::base_dir()?.join("templates"))
}

fn lib_dir(root: &Path) -> PathBuf {
    root.join(".templates")
}

/// Library template shadows the global one with the same name.
pub fn resolve(name: &str) -> Result<PathBuf> {
    let file = format!("{name}.md");
    if let Ok(root) = active_library_root() {
        let p = lib_dir(&root).join(&file);
        if p.exists() {
            return Ok(p);
        }
    }
    let p = global_dir()?.join(&file);
    if p.exists() {
        return Ok(p);
    }
    Err(Error::TaskNotFound(format!("template '{name}'")))
}

pub fn load(name: &str) -> Result<Template> {
    parse_template(&std::fs::read_to_string(resolve(name)?)?)
}

pub fn list() -> Result<()> {
    let mut found = false;
    let mut print_dir = |dir: &Path, origin: &str| -> Result<()> {
        if !dir.exists() {
            return Ok(());
        }
        let mut names: Vec<String> = std::fs::read_dir(dir)?
            .filter_map(|e| e.ok())
            .filter(|e| e.path().extension().is_some_and(|x| x == "md"))
            .filter_map(|e| e.path().file_stem().map(|s| s.to_string_lossy().to_string()))
            .collect();
        names.sort();
        for n in names {
            println!("{n}\t({origin})");
            found = true;
        }
        Ok(())
    };
    if let Ok(root) = active_library_root() {
        print_dir(&lib_dir(&root), "library")?;
    }
    print_dir(&global_dir()?, "global")?;
    if !found {
        println!("no templates; create one with 'tasks template new <name>'");
    }
    Ok(())
}

pub fn show(name: &str) -> Result<()> {
    print!("{}", std::fs::read_to_string(resolve(name)?)?);
    Ok(())
}

const SKELETON: &str = "---\n\
# All fields are optional defaults; delete what you don't need.\n\
# id / seq / created_at are ignored: they are always generated.\n\
priority: medium\n\
tags: []\n\
# status: todo\n\
---\n\
\n\
- [ ] first step\n\
\n\
## {{title}}\n\
\n\
Created on {{date}}.\n";

pub fn new(name: &str, lib: bool, editor_flag: Option<String>) -> Result<()> {
    let dir = if lib { lib_dir(&active_library_root()?) } else { global_dir()? };
    let path = dir.join(format!("{name}.md"));
    if path.exists() {
        return Err(Error::LibraryExists(format!("template '{name}'")));
    }
    std::fs::create_dir_all(&dir)?;
    std::fs::write(&path, SKELETON)?;
    let config = crate::config::Config::load()?;
    let editor = crate::editor::detect(editor_flag.as_deref(), &config)?;
    crate::editor::open(&editor, &path)?;
    parse_template(&std::fs::read_to_string(&path)?)?;
    println!("created template '{name}' at {}", path.display());
    Ok(())
}

pub fn edit(name: &str, editor_flag: Option<String>) -> Result<()> {
    let path = resolve(name)?;
    let config = crate::config::Config::load()?;
    let editor = crate::editor::detect(editor_flag.as_deref(), &config)?;
    crate::editor::open(&editor, &path)?;
    parse_template(&std::fs::read_to_string(&path)?)?;
    println!("saved template '{name}'");
    Ok(())
}

pub fn remove(name: &str) -> Result<()> {
    let path = resolve(name)?;
    std::fs::remove_file(&path)?;
    println!("removed template '{name}' ({})", path.display());
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    const BUG: &str = "---\npriority: high\ntags: [bug]\n---\n\n- [ ] 复现\n\n## {{title}}\n\n报于 {{date}}\n";

    #[test]
    fn parse_and_apply_defaults() {
        let tpl = parse_template(BUG).unwrap();
        let mut task = Task::new(1, "登录崩溃");
        apply(&tpl, &mut task, false, false);
        assert_eq!(task.meta.priority, Priority::High);
        assert_eq!(task.meta.tags, vec!["bug"]);
        assert_eq!(task.steps.len(), 1);
        assert_eq!(task.steps[0].title, "复现");
        assert!(task.body.contains("## 登录崩溃"));
        assert!(!task.body.contains("{{date}}"));
    }

    #[test]
    fn cli_values_win() {
        let tpl = parse_template(BUG).unwrap();
        let mut task = Task::new(1, "t");
        task.meta.priority = Priority::Urgent;
        task.meta.tags = vec!["cli".into()];
        apply(&tpl, &mut task, true, true);
        assert_eq!(task.meta.priority, Priority::Urgent);
        assert_eq!(task.meta.tags, vec!["cli"]);
    }

    #[test]
    fn status_uses_lifecycle_rules() {
        let tpl = parse_template("---\nstatus: in_progress\n---\nbody").unwrap();
        let mut task = Task::new(1, "t");
        apply(&tpl, &mut task, false, false);
        assert_eq!(task.meta.status, Status::InProgress);
        assert!(task.meta.started_at.is_some());
    }

    #[test]
    fn unknown_fields_ignored_and_no_front_matter_ok() {
        let tpl = parse_template("---\nid: whatever\nseq: 99\n---\nbody").unwrap();
        assert!(tpl.meta.priority.is_none());
        let tpl = parse_template("just a body").unwrap();
        assert_eq!(tpl.body, "just a body");
    }
}