tto 0.1.5

`to` is a small Rust CLI for project-scoped TODO lists. It walks up from your current working directory, finds the nearest `.todo` file, and operates on that task list automatically.
Documentation
use std::fs::{self, File};
use std::path::{Path, PathBuf};

use crate::error::{AppError, Result};

const AGENT_TODO_SECTION: &str = "\
<!-- to:todo-instructions:start -->
## Project TODOs

This project uses the `to` CLI for project-scoped task tracking.

- Run `to ls` to inspect current tasks before starting work.
- Run `to tree <number>` to inspect a task and its subtasks.
- Run `to add \"task text\"` to add follow-up work.
- Run `to add \"task text\" --parent <number>` to add subtasks.
- Use `--priority <high|medium|low>` and `--label <label>` for task metadata.
- Run `to done <number>` when a task is complete.
- Prefer `to do <number> --create-branch` when starting agent-driven work.
<!-- to:todo-instructions:end -->
";

pub fn find_todo_file(start: &Path) -> Result<PathBuf> {
    let mut current = start.to_path_buf();

    loop {
        let candidate = current.join(".todo");
        if candidate.is_file() {
            return Ok(candidate);
        }

        if !current.pop() {
            return Err(AppError::TodoNotFound(start.to_path_buf()));
        }
    }
}

pub fn init_todo_file(dir: &Path) -> Result<PathBuf> {
    let path = dir.join(".todo");

    if !path.exists() {
        File::create(&path)?;
    }

    Ok(path)
}

pub fn ensure_agent_docs(dir: &Path) -> Result<Vec<PathBuf>> {
    ["CLAUDE.md", "AGENTS.md"]
        .into_iter()
        .map(|file_name| ensure_agent_doc(dir, file_name))
        .collect()
}

fn ensure_agent_doc(dir: &Path, file_name: &str) -> Result<PathBuf> {
    let path = dir.join(file_name);
    if !path.exists() {
        fs::write(&path, AGENT_TODO_SECTION)?;
        return Ok(path);
    }

    let existing = fs::read_to_string(&path)?;
    if existing.contains("<!-- to:todo-instructions:start -->") {
        return Ok(path);
    }

    let mut updated = existing;
    if !updated.is_empty() && !updated.ends_with('\n') {
        updated.push('\n');
    }
    if !updated.is_empty() {
        updated.push('\n');
    }
    updated.push_str(AGENT_TODO_SECTION);
    fs::write(&path, updated)?;

    Ok(path)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::time::{SystemTime, UNIX_EPOCH};

    struct TempDir {
        path: PathBuf,
    }

    impl TempDir {
        fn new(name: &str) -> Self {
            let unique = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos();
            let path = std::env::temp_dir().join(format!("to-{name}-{unique}"));
            fs::create_dir_all(&path).unwrap();
            Self { path }
        }
    }

    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.path);
        }
    }

    #[test]
    fn finds_parent_todo_file() {
        let temp = TempDir::new("project-search");
        let project = temp.path.join("workspace");
        let nested = project.join("service").join("src");
        fs::create_dir_all(&nested).unwrap();
        fs::write(project.join(".todo"), "[ ] parent task\n").unwrap();

        let found = find_todo_file(&nested).unwrap();
        assert_eq!(found, project.join(".todo"));
    }

    #[test]
    fn init_todo_file_is_idempotent() {
        let temp = TempDir::new("init-idempotent");
        let first = init_todo_file(&temp.path).unwrap();
        let second = init_todo_file(&temp.path).unwrap();
        assert_eq!(first, second);
        assert!(first.is_file());
    }

    #[test]
    fn ensure_agent_docs_creates_claude_and_agents_docs() {
        let temp = TempDir::new("agent-docs");
        let docs = ensure_agent_docs(&temp.path).unwrap();
        assert_eq!(
            docs,
            vec![temp.path.join("CLAUDE.md"), temp.path.join("AGENTS.md")]
        );

        let claude = fs::read_to_string(temp.path.join("CLAUDE.md")).unwrap();
        let agents = fs::read_to_string(temp.path.join("AGENTS.md")).unwrap();
        assert!(claude.contains("Run `to ls`"));
        assert!(agents.contains("to do <number> --create-branch"));
    }

    #[test]
    fn ensure_agent_docs_does_not_duplicate_section() {
        let temp = TempDir::new("agent-docs-existing");
        fs::write(temp.path.join("CLAUDE.md"), "# Claude\n").unwrap();

        ensure_agent_docs(&temp.path).unwrap();
        ensure_agent_docs(&temp.path).unwrap();

        let claude = fs::read_to_string(temp.path.join("CLAUDE.md")).unwrap();
        assert_eq!(
            claude
                .matches("<!-- to:todo-instructions:start -->")
                .count(),
            1
        );
        assert!(claude.starts_with("# Claude\n\n"));
    }
}