tasks-cli-rs 0.1.1

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

use serde::{Deserialize, Serialize};

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

const FRONT_MATTER_DELIM: &str = "---";

/// Parses a task file: YAML front matter followed by a Markdown body.
pub fn parse_task(content: &str) -> Result<Task> {
    let rest = content
        .strip_prefix(FRONT_MATTER_DELIM)
        .and_then(|r| r.strip_prefix('\n'))
        .ok_or_else(|| Error::InvalidTaskFile("missing front matter".into()))?;
    let end = rest
        .find("\n---")
        .ok_or_else(|| Error::InvalidTaskFile("unterminated front matter".into()))?;
    let yaml = &rest[..end];
    let body_start = rest[end + 1..]
        .find('\n')
        .map(|i| end + 1 + i + 1)
        .unwrap_or(rest.len());
    let body = rest[body_start..].trim_start_matches('\n').to_string();
    let meta: TaskMeta = serde_yaml::from_str(yaml)?;
    Ok(Task { meta, body })
}

/// Renders a task back to file content. Round-trips with `parse_task`.
pub fn render_task(task: &Task) -> Result<String> {
    let yaml = serde_yaml::to_string(&task.meta)?;
    let mut out = format!("{FRONT_MATTER_DELIM}\n{yaml}{FRONT_MATTER_DELIM}\n");
    if !task.body.is_empty() {
        out.push('\n');
        out.push_str(&task.body);
        if !task.body.ends_with('\n') {
            out.push('\n');
        }
    }
    Ok(out)
}

pub fn read_task(path: &Path) -> Result<Task> {
    parse_task(&std::fs::read_to_string(path)?)
}

pub fn write_task(path: &Path, task: &Task) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(path, render_task(task)?)?;
    Ok(())
}

/// Converts a title into a filesystem-safe slug. Unicode alphanumeric
/// characters (including CJK) are kept; everything else becomes '-'.
pub fn slugify(title: &str) -> String {
    let mut slug = String::new();
    let mut prev_dash = true;
    for c in title.chars() {
        if c.is_alphanumeric() {
            slug.extend(c.to_lowercase());
            prev_dash = false;
        } else if !prev_dash {
            slug.push('-');
            prev_dash = true;
        }
    }
    slug.trim_end_matches('-').to_string()
}

/// Default filename template; `{short_id}` guarantees uniqueness.
pub const DEFAULT_FILENAME_FORMAT: &str = "{slug}-{short_id}";

/// Renders a task filename from a template. Supported variables:
/// {date} {datetime} {seq} {slug} {short_id}. `{short_id}` is appended
/// automatically when missing. Returns the name with ".md" extension.
pub fn render_filename(template: &str, task: &Task) -> Result<String> {
    let mut template = template.to_string();
    if !template.contains("{short_id}") {
        template.push_str("-{short_id}");
    }
    let local = task.meta.created_at.with_timezone(&chrono::Local);
    let mut out = String::new();
    let mut rest = template.as_str();
    while let Some(start) = rest.find('{') {
        out.push_str(&rest[..start]);
        let Some(end) = rest[start..].find('}') else {
            return Err(Error::BadTemplate(format!("unclosed '{{' in '{template}'")));
        };
        let var = &rest[start + 1..start + end];
        match var {
            "date" => out.push_str(&local.format("%Y-%m-%d").to_string()),
            "datetime" => out.push_str(&local.format("%Y%m%d-%H%M").to_string()),
            "seq" => out.push_str(&task.meta.seq.to_string()),
            "slug" => out.push_str(&slugify(&task.meta.title)),
            "short_id" => out.push_str(&task.short_id()),
            other => {
                return Err(Error::BadTemplate(format!("unknown variable '{{{other}}}'")));
            }
        }
        rest = &rest[start + end + 1..];
    }
    out.push_str(rest);
    let name: String = out
        .chars()
        .map(|c| if c == '/' || c == '\\' { '-' } else { c })
        .collect();
    let name = name.trim_matches(|c| c == '-' || c == '.' || c == ' ').to_string();
    Ok(format!("{name}.md"))
}

/// Filename for a task using the default template.
#[cfg(test)]
pub fn task_filename(task: &Task) -> String {
    render_filename(DEFAULT_FILENAME_FORMAT, task).expect("default template is valid")
}

/// A task together with the file it was loaded from.
#[derive(Debug)]
pub struct StoredTask {
    pub path: std::path::PathBuf,
    pub task: Task,
}

/// Recursively loads all task files (*.md) under `root`, skipping hidden
/// entries. Unparseable files are reported to stderr and skipped.
pub fn load_all_tasks(root: &Path) -> Result<Vec<StoredTask>> {
    let mut out = Vec::new();
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        for entry in std::fs::read_dir(&dir)? {
            let entry = entry?;
            let path = entry.path();
            let name = entry.file_name();
            if name.to_string_lossy().starts_with('.') {
                continue;
            }
            if path.is_dir() {
                stack.push(path);
            } else if path.extension().is_some_and(|e| e == "md") {
                match read_task(&path) {
                    Ok(task) => out.push(StoredTask { path, task }),
                    Err(err) => eprintln!("warning: skipping {}: {err}", path.display()),
                }
            }
        }
    }
    out.sort_by_key(|t| t.task.meta.seq);
    Ok(out)
}

/// Resolves a user-supplied identifier (seq, #seq, short id, or UUID) to a
/// single task.
pub fn resolve_task(root: &Path, query: &str) -> Result<StoredTask> {
    let mut matches: Vec<StoredTask> = load_all_tasks(root)?
        .into_iter()
        .filter(|t| t.task.matches_identifier(query))
        .collect();
    match matches.len() {
        0 => Err(Error::TaskNotFound(query.to_string())),
        1 => Ok(matches.remove(0)),
        _ => Err(Error::AmbiguousId(query.to_string())),
    }
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct LibraryMeta {
    #[serde(default = "default_next_seq")]
    pub next_seq: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub filename_format: Option<String>,
}

fn default_next_seq() -> u64 {
    1
}

impl LibraryMeta {
    pub fn path(library_root: &Path) -> std::path::PathBuf {
        library_root.join(".tasks-meta.toml")
    }

    /// Creates the meta file with defaults if it does not exist yet.
    pub fn default_init(library_root: &Path) -> Result<()> {
        if !Self::path(library_root).exists() {
            LibraryMeta { next_seq: 1, filename_format: None }.save(library_root)?;
        }
        Ok(())
    }

    pub fn load(library_root: &Path) -> Result<Self> {
        let path = Self::path(library_root);
        if !path.exists() {
            return Ok(LibraryMeta { next_seq: 1, filename_format: None });
        }
        Ok(toml::from_str(&std::fs::read_to_string(path)?)?)
    }

    pub fn save(&self, library_root: &Path) -> Result<()> {
        std::fs::create_dir_all(library_root)?;
        let content = toml::to_string(self).map_err(|e| Error::InvalidTaskFile(e.to_string()))?;
        std::fs::write(Self::path(library_root), content)?;
        Ok(())
    }

    /// Returns the next sequence number and persists the incremented counter.
    pub fn allocate_seq(library_root: &Path) -> Result<u64> {
        let mut meta = Self::load(library_root)?;
        let seq = meta.next_seq;
        meta.next_seq += 1;
        meta.save(library_root)?;
        Ok(seq)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Status, Step};

    fn sample_task() -> Task {
        let mut task = Task::new(7, "Fix login bug");
        task.meta.tags = vec!["backend".into(), "bugfix".into()];
        task.meta.steps = vec![Step {
            id: "s1".into(),
            title: "Reproduce".into(),
            status: Status::Done,
            due_date: None,
            depends_on: vec![],
        }];
        task.body = "## Notes\n\nSome details here.\n".to_string();
        task
    }

    #[test]
    fn round_trip_preserves_everything() {
        let task = sample_task();
        let rendered = render_task(&task).unwrap();
        let parsed = parse_task(&rendered).unwrap();
        assert_eq!(parsed, task);
    }

    #[test]
    fn round_trip_empty_body() {
        let task = Task::new(1, "no body");
        let parsed = parse_task(&render_task(&task).unwrap()).unwrap();
        assert_eq!(parsed, task);
    }

    #[test]
    fn parse_rejects_missing_front_matter() {
        assert!(parse_task("just some text").is_err());
        assert!(parse_task("---\nid: x").is_err());
    }

    #[test]
    fn slugify_handles_english() {
        assert_eq!(slugify("Fix Login Bug!"), "fix-login-bug");
        assert_eq!(slugify("  spaces   everywhere  "), "spaces-everywhere");
    }

    #[test]
    fn slugify_keeps_cjk() {
        assert_eq!(slugify("修复登录 bug"), "修复登录-bug");
    }

    #[test]
    fn slugify_special_chars_only_yields_empty() {
        assert_eq!(slugify("!!! ???"), "");
        let task = Task::new(1, "!!!");
        assert_eq!(task_filename(&task), format!("{}.md", task.short_id()));
    }

    #[test]
    fn render_filename_variables() {
        let mut task = Task::new(7, "Fix Bug");
        task.meta.created_at = chrono::DateTime::parse_from_rfc3339("2026-07-30T12:00:00Z")
            .unwrap()
            .to_utc();
        let sid = task.short_id();
        let local_date = task
            .meta
            .created_at
            .with_timezone(&chrono::Local)
            .format("%Y-%m-%d")
            .to_string();

        assert_eq!(
            render_filename("{date}-{slug}-{short_id}", &task).unwrap(),
            format!("{local_date}-fix-bug-{sid}.md")
        );
        assert_eq!(
            render_filename("{seq}-{slug}", &task).unwrap(),
            format!("7-fix-bug-{sid}.md"),
            "short_id auto-appended when missing"
        );
    }

    #[test]
    fn render_filename_rejects_bad_templates() {
        let task = Task::new(1, "t");
        assert!(render_filename("{typo}-{short_id}", &task).is_err());
        assert!(render_filename("{date-{short_id}", &task).is_err());
    }

    #[test]
    fn render_filename_sanitizes_separators() {
        let task = Task::new(1, "t");
        let name = render_filename("a/b\\c-{short_id}", &task).unwrap();
        assert!(!name.contains('/') && !name.contains('\\'), "{name}");
    }

    #[test]
    fn render_filename_is_idempotent_for_same_task() {
        let task = Task::new(3, "stable name");
        let a = render_filename("{date}-{slug}-{short_id}", &task).unwrap();
        let b = render_filename("{date}-{slug}-{short_id}", &task).unwrap();
        assert_eq!(a, b);
    }

    #[test]
    fn seq_allocation_is_monotonic() {
        let dir = std::env::temp_dir().join(format!("tasks-test-{}", uuid::Uuid::new_v4()));
        assert_eq!(LibraryMeta::allocate_seq(&dir).unwrap(), 1);
        assert_eq!(LibraryMeta::allocate_seq(&dir).unwrap(), 2);
        assert_eq!(LibraryMeta::allocate_seq(&dir).unwrap(), 3);
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn file_write_read_round_trip() {
        let dir = std::env::temp_dir().join(format!("tasks-test-{}", uuid::Uuid::new_v4()));
        let task = sample_task();
        let path = dir.join(task_filename(&task));
        write_task(&path, &task).unwrap();
        let loaded = read_task(&path).unwrap();
        assert_eq!(loaded, task);
        std::fs::remove_dir_all(&dir).unwrap();
    }
}