tasks-cli-rs 0.9.0

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

use chrono::{DateTime, NaiveDate, TimeZone, Utc};

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

/// Derives a title: first `# heading` in the content, else the filename
/// with extension and any leading YYYY-MM-DD prefix stripped.
pub fn derive_title(path: &Path, content: &str) -> String {
    for line in content.lines() {
        let line = line.trim();
        if let Some(h) = line.strip_prefix("# ") {
            let h = h.trim();
            if !h.is_empty() {
                return h.to_string();
            }
        }
    }
    let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
    strip_date_prefix(&stem).trim_matches(['-', '_', ' ']).to_string()
}

fn strip_date_prefix(stem: &str) -> &str {
    if let Some(prefix) = stem.get(..10)
        && prefix.parse::<NaiveDate>().is_ok() {
            return &stem[10..];
        }
    stem
}

/// Derives created_at: YYYY-MM-DD filename prefix (local midnight), else
/// file mtime, else now.
pub fn derive_created_at(path: &Path) -> DateTime<Utc> {
    let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
    if let Some(prefix) = stem.get(..10)
        && let Ok(date) = prefix.parse::<NaiveDate>()
            && let Some(dt) = chrono::Local
                .from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap())
                .single()
            {
                return dt.to_utc();
            }
    std::fs::metadata(path)
        .and_then(|m| m.modified())
        .map(DateTime::<Utc>::from)
        .unwrap_or_else(|_| Utc::now())
}

/// Converts a plain markdown file into a task file in place: allocates
/// seq/id, derives title and created_at, keeps the original content as body.
pub fn adopt_file(root: &Path, path: &Path) -> Result<Task> {
    let content = std::fs::read_to_string(path)?;
    if storage::parse_task(&content).is_ok() {
        return Err(Error::InvalidTaskFile(format!(
            "{} is already a task file",
            path.display()
        )));
    }
    let seq = storage::LibraryMeta::allocate_seq(root)?;
    let mut task = Task::new(seq, derive_title(path, &content));
    task.meta.created_at = derive_created_at(path);
    task.body = content;
    storage::write_task(path, &task)?;
    Ok(task)
}

pub struct AdoptArgs {
    pub paths: Vec<String>,
    pub all: bool,
    pub dry_run: bool,
    pub rename: bool,
}

pub fn run(args: AdoptArgs) -> Result<()> {
    let root = super::task::active_library_root()?;
    let candidates: Vec<PathBuf> = if args.paths.is_empty() {
        storage::load_library(&root)?.skipped
    } else {
        args.paths
            .iter()
            .map(|p| {
                let path = PathBuf::from(p);
                if path.is_absolute() { path } else { root.join(path) }
            })
            .collect()
    };
    if candidates.is_empty() {
        println!("nothing to adopt");
        return Ok(());
    }

    let mut adopted = 0usize;
    let mut adopt_rest = args.all;
    for path in &candidates {
        if !path.exists() {
            eprintln!("warning: {} does not exist, skipping", path.display());
            continue;
        }
        if args.dry_run {
            let content = std::fs::read_to_string(path)?;
            println!(
                "would adopt: {} (title: \"{}\")",
                path.display(),
                derive_title(path, &content)
            );
            continue;
        }
        if !adopt_rest {
            match prompt(&format!("adopt {}?", path.display()))? {
                Choice::Yes => {}
                Choice::No => continue,
                Choice::All => adopt_rest = true,
                Choice::Quit => break,
            }
        }
        let task = adopt_file(&root, path)?;
        println!("adopted #{} [{}] {}", task.meta.seq, task.short_id(), task.meta.title);
        if args.rename {
            let st = storage::StoredTask { path: path.clone(), task };
            super::task::sync_filename(&root, &st)?;
        }
        adopted += 1;
    }
    if !args.dry_run {
        println!("adopted {adopted} file(s)");
    }
    Ok(())
}

enum Choice {
    Yes,
    No,
    All,
    Quit,
}

fn prompt(msg: &str) -> Result<Choice> {
    print!("{msg} [y/N/a(ll)/q(uit)] ");
    std::io::stdout().flush()?;
    let mut input = String::new();
    std::io::stdin().read_line(&mut input)?;
    Ok(match input.trim().to_lowercase().as_str() {
        "y" | "yes" => Choice::Yes,
        "a" | "all" => Choice::All,
        "q" | "quit" => Choice::Quit,
        _ => Choice::No,
    })
}

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

    #[test]
    fn title_from_heading() {
        let p = Path::new("2026-06-17-langchain研究.md");
        assert_eq!(derive_title(p, "intro\n\n# LangChain 研究笔记\n"), "LangChain 研究笔记");
    }

    #[test]
    fn title_from_filename_strips_date() {
        let p = Path::new("2026-06-17-langchain研究.md");
        assert_eq!(derive_title(p, "no heading here"), "langchain研究");
        let p = Path::new("plain-note.md");
        assert_eq!(derive_title(p, ""), "plain-note");
    }

    #[test]
    fn created_at_from_filename_date() {
        let p = Path::new("2026-06-17-note.md");
        let dt = derive_created_at(p);
        let local = dt.with_timezone(&chrono::Local);
        assert_eq!(local.format("%Y-%m-%d").to_string(), "2026-06-17");
    }

    #[test]
    fn multibyte_filename_does_not_panic() {
        // '数据' is 6 bytes, so byte 10 lands inside a char — must not panic
        let p = Path::new("数据笔记研究记录.md");
        assert_eq!(derive_title(p, ""), "数据笔记研究记录");
        let _ = derive_created_at(p);
    }

    #[test]
    fn adopt_preserves_body_and_derives_meta() {
        let dir = std::env::temp_dir().join(format!("tasks-adopt-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&dir).unwrap();
        let file = dir.join("2026-06-17-研究.md");
        let original = "# 研究笔记\n\n一些内容\n";
        std::fs::write(&file, original).unwrap();

        let task = adopt_file(&dir, &file).unwrap();
        assert_eq!(task.meta.title, "研究笔记");
        assert_eq!(task.body, original);
        assert_eq!(task.meta.seq, 1);

        // file now parses as a task and round-trips
        let loaded = storage::read_task(&file).unwrap();
        assert_eq!(loaded.meta.title, "研究笔记");
        assert_eq!(loaded.body, original);

        // adopting twice is rejected
        assert!(adopt_file(&dir, &file).is_err());

        std::fs::remove_dir_all(&dir).unwrap();
    }
}