minni 0.1.1

Local memory, task, and codebase indexing tool for AI agents
Documentation
use crate::cli::TaskCommands;
use crate::db::Database;
use crate::task::TaskManager;
use anyhow::Result;
use colored::Colorize;
use std::path::Path;

const VALID_PRIORITIES: &[&str] = &["high", "medium", "low"];
const VALID_STATUSES: &[&str] = &["pending", "in_progress", "completed", "cancelled"];

fn validate_priority(priority: &str) -> Result<()> {
    if VALID_PRIORITIES.contains(&priority) {
        Ok(())
    } else {
        anyhow::bail!(
            "invalid priority '{}': must be one of {}",
            priority,
            VALID_PRIORITIES.join(", ")
        )
    }
}

fn validate_status(status: &str) -> Result<()> {
    if VALID_STATUSES.contains(&status) {
        Ok(())
    } else {
        anyhow::bail!(
            "invalid status '{}': must be one of {}",
            status,
            VALID_STATUSES.join(", ")
        )
    }
}

fn color_status(status: &str) -> String {
    match status {
        "pending" => status.yellow().to_string(),
        "in_progress" => status.blue().to_string(),
        "completed" => status.green().to_string(),
        "cancelled" => status.dimmed().to_string(),
        other => other.to_string(),
    }
}

/// Convert a task title to a URL-safe slug for use in filenames.
/// Lowercases, replaces runs of non-alphanumeric characters with `-`,
/// and trims leading/trailing `-`.
fn title_slug(title: &str) -> String {
    let lower = title.to_lowercase();
    let mut slug = String::new();
    let mut in_sep = false;
    for ch in lower.chars() {
        if ch.is_alphanumeric() {
            slug.push(ch);
            in_sep = false;
        } else if !in_sep {
            slug.push('-');
            in_sep = true;
        }
    }
    slug.trim_matches('-').to_string()
}

/// Append `tasks/` to `.gitignore` at `project_root` if it isn't already present.
fn ensure_gitignore_entry(project_root: &Path) -> Result<()> {
    let gitignore = project_root.join(".gitignore");
    let entry = "tasks/";

    if gitignore.exists() {
        let contents = std::fs::read_to_string(&gitignore)?;
        // Check for an exact line match (ignoring trailing whitespace per line)
        let already_present = contents
            .lines()
            .any(|l| l.trim() == entry.trim_end_matches('/') || l.trim() == entry);
        if already_present {
            return Ok(());
        }
        // Append, ensuring we start on a new line
        let needs_newline = !contents.is_empty() && !contents.ends_with('\n');
        let mut append = String::new();
        if needs_newline {
            append.push('\n');
        }
        append.push_str(entry);
        append.push('\n');
        use std::io::Write;
        let mut file = std::fs::OpenOptions::new().append(true).open(&gitignore)?;
        file.write_all(append.as_bytes())?;
    } else {
        std::fs::write(&gitignore, format!("{}\n", entry))?;
    }

    Ok(())
}

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

    // --- title_slug ---

    #[test]
    fn slug_basic() {
        assert_eq!(title_slug("Implement search API"), "implement-search-api");
    }

    #[test]
    fn slug_runs_of_non_alnum_collapsed() {
        assert_eq!(title_slug("Fix  the  bug!"), "fix-the-bug");
    }

    #[test]
    fn slug_leading_trailing_trimmed() {
        assert_eq!(title_slug("  Hello World  "), "hello-world");
    }

    #[test]
    fn slug_all_punctuation_becomes_single_dash() {
        assert_eq!(title_slug("foo---bar"), "foo-bar");
    }

    #[test]
    fn slug_empty_string() {
        assert_eq!(title_slug(""), "");
    }

    // --- ensure_gitignore_entry ---

    #[test]
    fn gitignore_created_when_absent() {
        let dir = tempfile::tempdir().unwrap();
        ensure_gitignore_entry(dir.path()).unwrap();
        let contents = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(contents.contains("tasks/"));
    }

    #[test]
    fn gitignore_appended_when_entry_missing() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
        ensure_gitignore_entry(dir.path()).unwrap();
        let contents = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(contents.contains("tasks/"));
        assert!(contents.contains("target/"));
    }

    #[test]
    fn gitignore_not_duplicated_when_entry_present() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join(".gitignore"), "target/\ntasks/\n").unwrap();
        ensure_gitignore_entry(dir.path()).unwrap();
        let contents = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        let count = contents.lines().filter(|l| *l == "tasks/").count();
        assert_eq!(count, 1, "tasks/ should appear exactly once");
    }

    #[test]
    fn gitignore_appended_with_newline_when_file_lacks_trailing_newline() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join(".gitignore"), "target/").unwrap(); // no trailing newline
        ensure_gitignore_entry(dir.path()).unwrap();
        let contents = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        // Must not smash "target/tasks/" together
        assert!(contents.contains('\n'));
        assert!(contents.contains("tasks/"));
        assert!(!contents.contains("target/tasks/"));
    }
}

pub fn run(command: TaskCommands, project_root: &Path) -> Result<()> {
    let db = Database::open(project_root)?;
    let tm = TaskManager::new(&db);

    match command {
        TaskCommands::Add {
            context,
            title,
            description,
            priority,
        } => {
            validate_priority(&priority)?;
            let task = tm.add_task(&context, &title, description.as_deref(), &priority)?;
            println!("Created task {:03} in {}", task.seq, context.cyan());
            println!("  Title:    {}", task.title);
            println!("  Priority: {}", task.priority);
            println!("  Status:   {}", color_status(&task.status));
        }

        TaskCommands::Update {
            context,
            seq,
            title,
            description,
            status,
            priority,
        } => {
            if let Some(ref p) = priority {
                validate_priority(p)?;
            }
            if let Some(ref s) = status {
                validate_status(s)?;
            }
            let task = tm.update_task(
                &context,
                seq,
                title.as_deref(),
                description.as_deref(),
                status.as_deref(),
                priority.as_deref(),
            )?;
            println!("Updated task {:03} in {}", task.seq, context.cyan());
            println!("  Title:    {}", task.title);
            println!("  Priority: {}", task.priority);
            println!("  Status:   {}", color_status(&task.status));
        }

        TaskCommands::List { context, json } => {
            let tasks = tm.list_tasks(&context)?;

            if json {
                println!("{}", serde_json::to_string_pretty(&tasks)?);
                return Ok(());
            }

            println!(
                "{}  ({} task{})",
                context.cyan(),
                tasks.len(),
                if tasks.len() == 1 { "" } else { "s" }
            );
            for task in &tasks {
                println!(
                    "  {:03}  {:<13}  {:<6}  {}",
                    task.seq,
                    color_status(&task.status),
                    task.priority,
                    task.title
                );
            }
        }

        TaskCommands::Show { context, seq, json } => {
            let full = tm.get_full_task(&context, seq)?;

            if json {
                println!("{}", serde_json::to_string_pretty(&full)?);
                return Ok(());
            }

            println!(
                "[{:03}] {}  ({})",
                full.task.seq,
                full.task.title.bold(),
                context.cyan()
            );
            println!("Status:      {}", color_status(&full.task.status));
            println!("Priority:    {}", full.task.priority);
            if let Some(ref desc) = full.task.description {
                println!("Description: {}", desc);
            }
            if !full.todos.is_empty() {
                println!("Todos:");
                for todo in &full.todos {
                    let check = if todo.done { "x" } else { " " };
                    println!("  [{}] {}. {}", check, todo.seq, todo.text);
                }
            }
            println!("Created:  {}", full.task.created_at);
            println!("Updated:  {}", full.task.updated_at);
        }

        TaskCommands::Todo {
            context,
            seq,
            text,
            done,
        } => {
            match (text, done) {
                (Some(_), Some(_)) => {
                    anyhow::bail!("provide either a todo text or --done <n>, not both");
                }
                (None, None) => {
                    anyhow::bail!("provide a todo text or --done <n>");
                }
                (Some(text), None) => {
                    let todo = tm.add_todo(&context, seq, &text)?;
                    println!(
                        "Added todo {} to task {:03} in {}",
                        todo.seq,
                        seq,
                        context.cyan()
                    );
                    println!("  [ ] {}. {}", todo.seq, todo.text);
                    // Re-fetch and print the full updated todo list
                    let task = tm.get_task(&context, seq)?;
                    let todos = tm.get_todos(&task.id)?;
                    println!("Todos:");
                    for t in &todos {
                        let check = if t.done { "x" } else { " " };
                        println!("  [{}] {}. {}", check, t.seq, t.text);
                    }
                }
                (None, Some(done_seq)) => {
                    tm.mark_todo_done(&context, seq, done_seq)?;
                    // Re-fetch to get the todo text for the confirmation line
                    let task = tm.get_task(&context, seq)?;
                    let todos = tm.get_todos(&task.id)?;
                    let marked = todos
                        .iter()
                        .find(|t| t.seq == done_seq)
                        .ok_or_else(|| anyhow::anyhow!("todo {} not found", done_seq))?;
                    println!(
                        "Marked todo {} done on task {:03} in {}",
                        done_seq,
                        seq,
                        context.cyan()
                    );
                    println!("  [x] {}. {}", marked.seq, marked.text);
                    // Print the full updated todo list
                    println!("Todos:");
                    for t in &todos {
                        let check = if t.done { "x" } else { " " };
                        println!("  [{}] {}. {}", check, t.seq, t.text);
                    }
                }
            }
        }

        TaskCommands::Export { context, seq } => {
            let full = tm.get_full_task(&context, seq)?;

            // Build output path: tasks/<context>/<NNN>-<slug>.md
            let slug = title_slug(&full.task.title);
            let filename = format!("{:03}-{}.md", full.task.seq, slug);
            let out_dir = project_root.join("tasks").join(&context);
            std::fs::create_dir_all(&out_dir)?;
            let out_path = out_dir.join(&filename);

            // Render Markdown
            let description = full
                .task
                .description
                .as_deref()
                .unwrap_or("*(no description)*");

            // Capitalise first letter of priority
            let priority_display = {
                let mut chars = full.task.priority.chars();
                match chars.next() {
                    None => String::new(),
                    Some(c) => c.to_uppercase().to_string() + chars.as_str(),
                }
            };

            let mut md = format!(
                "# {:03}{}\n\n## Context\n\n{}\n\n## Status\n\n**{}** | Priority: {}\n",
                full.task.seq, full.task.title, description, full.task.status, priority_display,
            );

            if !full.todos.is_empty() {
                md.push_str("\n## Todo\n\n");
                for todo in &full.todos {
                    let check = if todo.done { "x" } else { " " };
                    md.push_str(&format!("- [{}] {}. {}\n", check, todo.seq, todo.text));
                }
            }

            std::fs::write(&out_path, &md)?;

            // Ensure tasks/ is in .gitignore
            ensure_gitignore_entry(project_root)?;

            // Relative display path for the confirmation message
            let display_path = format!("tasks/{}/{}", context, filename);
            println!("Exported task {:03} to {}", full.task.seq, display_path);
        }
    }

    Ok(())
}