oxur-odm 0.3.2

An odd document manager - CLI tool for managing design documentation
Documentation
//! Design documentation CLI tool

use anyhow::Result;
use clap::Parser;
use design::config::Config;
use design::index::DocumentIndex;
use design::state::StateManager;

mod cli;
mod commands;

use cli::{Cli, Commands, DebugCommands};
use commands::*;

fn main() -> Result<()> {
    let cli = Cli::parse();

    // Load configuration (handles odm.toml, .odmrc, and CLI overrides)
    let docs_dir_override = if cli.docs_dir != "docs" { Some(cli.docs_dir.as_str()) } else { None };
    let config = match Config::load(docs_dir_override) {
        Ok(cfg) => cfg,
        Err(e) => {
            design::errors::print_error("Failed to load configuration", &e);
            std::process::exit(1);
        }
    };

    let docs_dir = config.docs_directory.to_string_lossy().to_string();

    // Setup state manager
    let mut state_mgr = match StateManager::new(&config.docs_directory) {
        Ok(mgr) => mgr,
        Err(e) => {
            design::errors::print_error_with_suggestion(
                "Failed to initialize state manager",
                &e,
                &format!("Make sure '{}' exists and contains design documents", docs_dir),
            );
            std::process::exit(1);
        }
    };

    // Scan on startup
    if let Err(e) = scan_on_startup(&mut state_mgr, &cli.command) {
        design::errors::print_error("Startup scan failed", &e);
        // Non-fatal, continue
    }

    // Create document index
    let index = match create_document_index(&state_mgr, &docs_dir) {
        Ok(idx) => idx,
        Err(e) => {
            design::errors::print_error_with_suggestion(
                "Failed to load document index",
                &e,
                &format!("Make sure '{}' exists and contains design documents", docs_dir),
            );
            std::process::exit(1);
        }
    };

    // Execute command
    if let Err(e) = execute_command(cli.command, &index, &mut state_mgr) {
        design::errors::print_error("Command failed", &e);
        std::process::exit(1);
    }

    Ok(())
}

/// Scan for filesystem changes on startup (unless running scan command explicitly)
pub(crate) fn scan_on_startup(state_mgr: &mut StateManager, command: &Commands) -> Result<()> {
    let needs_scan = !matches!(command, Commands::Scan { .. });

    if needs_scan {
        let result = state_mgr.quick_scan()?;
        if result.has_changes() {
            let total = result.total_changes();
            if total > 0 {
                let msg = format!(
                    "Detected {} change(s) ({} new, {} modified, {} deleted)",
                    total,
                    result.new_files.len(),
                    result.changed.len(),
                    result.deleted.len()
                );
                oxur_cli::common::output::info(&msg);
            }
        }
    }

    Ok(())
}

/// Create document index from state with filesystem fallback
pub(crate) fn create_document_index(
    state_mgr: &StateManager,
    docs_dir: &str,
) -> Result<DocumentIndex> {
    match DocumentIndex::from_state(state_mgr.state(), docs_dir) {
        Ok(idx) => Ok(idx),
        Err(_) => {
            oxur_cli::common::output::warning(
                "State loading failed, falling back to filesystem scan",
            );
            DocumentIndex::new(docs_dir)
        }
    }
}

/// Dispatch and execute the requested command
pub(crate) fn execute_command(
    command: Commands,
    index: &DocumentIndex,
    state_mgr: &mut StateManager,
) -> Result<()> {
    match command {
        Commands::List { state, verbose, removed, dev, component, tags, limit, all } => {
            let filters = commands::list::ListFilters { state, component, tags, limit, all };
            list_documents_with_state(index, Some(state_mgr), &filters, verbose, removed, dev)
        }
        Commands::Show { number, metadata_only } => show_document(index, number, metadata_only),
        Commands::New { title, author, component, tags } => {
            new_document(index, title, author, component, tags)
        }
        Commands::Validate { fix } => validate_documents(index, state_mgr, fix),
        Commands::Index { format } => generate_index(index, &format),
        Commands::AddHeaders { path } => add_headers(&path),
        Commands::Transition { path, state } => {
            transition_document(index, state_mgr, &path, &state)
        }
        Commands::SyncLocation { path } => sync_location(index, state_mgr, &path),
        Commands::UpdateIndex => update_index(index),
        Commands::Add { path, state, dry_run, interactive, yes, preview } => {
            if preview {
                preview_add(&path, state_mgr)
            } else {
                add_document(state_mgr, &path, state.as_deref(), dry_run, interactive, yes)
            }
        }
        Commands::AddBatch { patterns, dry_run, interactive } => {
            add_batch(state_mgr, patterns, dry_run, interactive)
        }
        Commands::Scan { fix, verbose } => scan_documents(state_mgr, fix, verbose),
        Commands::Debug(debug_cmd) => match debug_cmd {
            DebugCommands::State { number, format } => {
                if let Some(num) = number {
                    show_document_state(state_mgr, num)
                } else {
                    show_state(state_mgr, &format)
                }
            }
            DebugCommands::Checksums { verbose } => show_checksums(state_mgr, verbose),
            DebugCommands::Stats => show_stats(state_mgr),
            DebugCommands::Diff => show_diff(state_mgr),
            DebugCommands::Orphans => show_orphans(state_mgr),
            DebugCommands::Verify { number } => verify_document(state_mgr, number),
        },
        Commands::Search { query, state, metadata, case_sensitive } => {
            search(state_mgr, &query, state, metadata, case_sensitive)
        }
        Commands::Info { subcommand } => commands::info::execute(subcommand, state_mgr),
        Commands::Remove { doc } => remove_document(state_mgr, &doc),
        Commands::Rename { old, new } => commands::rename::execute(state_mgr, &old, &new),
        Commands::Replace { old, new, version } => replace_document(state_mgr, &old, &new, version),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    /// Helper to create a test docs directory with sample documents
    fn setup_test_docs_dir() -> TempDir {
        let temp = TempDir::new().unwrap();
        let docs_dir = temp.path();

        // Create directory structure
        fs::create_dir_all(docs_dir.join("01-draft")).unwrap();
        fs::create_dir_all(docs_dir.join(".odm")).unwrap();

        // Create a sample document
        let doc_path = docs_dir.join("01-draft/0001-test-document.md");
        fs::write(
            &doc_path,
            r#"---
number: 1
title: Test Document
author: Test Author
state: Draft
created: 2024-01-01
updated: 2024-01-01
---

# Test Document

This is a test document.
"#,
        )
        .unwrap();

        // Initialize git repo for state manager
        std::process::Command::new("git").args(["init"]).current_dir(docs_dir).output().unwrap();

        std::process::Command::new("git")
            .args(["add", "."])
            .current_dir(docs_dir)
            .output()
            .unwrap();

        std::process::Command::new("git")
            .args(["commit", "-m", "Initial commit"])
            .current_dir(docs_dir)
            .output()
            .unwrap();

        temp
    }

    #[test]
    fn test_state_manager_creation() {
        let temp = setup_test_docs_dir();

        let result = StateManager::new(temp.path());
        assert!(result.is_ok());

        let state_mgr = result.unwrap();
        assert_eq!(state_mgr.docs_dir(), temp.path());
    }

    #[test]
    fn test_scan_on_startup_with_scan_command() {
        let temp = setup_test_docs_dir();
        let mut state_mgr = StateManager::new(temp.path()).unwrap();

        // When command is Scan, should skip the scan
        let command = Commands::Scan { fix: false, verbose: false };

        let result = scan_on_startup(&mut state_mgr, &command);
        assert!(result.is_ok());
    }

    #[test]
    fn test_scan_on_startup_with_other_command() {
        let temp = setup_test_docs_dir();
        let mut state_mgr = StateManager::new(temp.path()).unwrap();

        // When command is not Scan, should perform scan
        let command = Commands::List {
            state: None,
            verbose: false,
            removed: false,
            dev: false,
            component: None,
            tags: Vec::new(),
            limit: 20,
            all: false,
        };

        let result = scan_on_startup(&mut state_mgr, &command);
        assert!(result.is_ok());
    }

    #[test]
    fn test_scan_on_startup_detects_new_file() {
        let temp = setup_test_docs_dir();
        let mut state_mgr = StateManager::new(temp.path()).unwrap();

        // Initial scan to clear state
        state_mgr.quick_scan().unwrap();

        // Add a new file
        let new_doc = temp.path().join("01-draft/0002-new-doc.md");
        fs::write(
            &new_doc,
            r#"---
number: 2
title: New Document
author: Test Author
state: Draft
created: 2024-01-02
updated: 2024-01-02
---

# New Document
"#,
        )
        .unwrap();

        let command = Commands::List {
            state: None,
            verbose: false,
            removed: false,
            dev: false,
            component: None,
            tags: Vec::new(),
            limit: 20,
            all: false,
        };

        // This should detect the new file
        let result = scan_on_startup(&mut state_mgr, &command);
        assert!(result.is_ok());
    }

    #[test]
    fn test_create_document_index_success() {
        let temp = setup_test_docs_dir();
        let state_mgr = StateManager::new(temp.path()).unwrap();

        let result = create_document_index(&state_mgr, temp.path().to_str().unwrap());
        assert!(result.is_ok());

        let index = result.unwrap();
        // Just verify the index was created - don't check for specific documents
        // since the index might be empty depending on state
        assert!(index.next_number() >= 1);
    }

    #[test]
    fn test_create_document_index_fallback() {
        let temp = setup_test_docs_dir();
        let state_mgr = StateManager::new(temp.path()).unwrap();

        // Even if state loading fails, should fall back to filesystem scan
        let result = create_document_index(&state_mgr, temp.path().to_str().unwrap());
        assert!(result.is_ok());
    }

    #[test]
    fn test_execute_command_list() {
        let temp = setup_test_docs_dir();
        let mut state_mgr = StateManager::new(temp.path()).unwrap();
        let index = DocumentIndex::new(temp.path()).unwrap();

        let command = Commands::List {
            state: None,
            verbose: false,
            removed: false,
            dev: false,
            component: None,
            tags: Vec::new(),
            limit: 20,
            all: false,
        };

        let result = execute_command(command, &index, &mut state_mgr);
        assert!(result.is_ok());
    }

    #[test]
    fn test_execute_command_show() {
        let temp = setup_test_docs_dir();
        let mut state_mgr = StateManager::new(temp.path()).unwrap();
        let index = DocumentIndex::new(temp.path()).unwrap();

        let command = Commands::Show { number: 1, metadata_only: false };

        let result = execute_command(command, &index, &mut state_mgr);
        assert!(result.is_ok());
    }

    #[test]
    fn test_execute_command_show_nonexistent() {
        let temp = setup_test_docs_dir();
        let mut state_mgr = StateManager::new(temp.path()).unwrap();
        let index = DocumentIndex::new(temp.path()).unwrap();

        let command = Commands::Show { number: 9999, metadata_only: false };

        let result = execute_command(command, &index, &mut state_mgr);
        assert!(result.is_err());
    }

    #[test]
    fn test_config_loading() {
        // Test that Config::load works with a custom docs directory
        let temp = setup_test_docs_dir();
        let config = Config::load(Some(temp.path().to_str().unwrap()));
        assert!(config.is_ok());

        let config = config.unwrap();
        assert_eq!(config.docs_directory.to_str().unwrap(), temp.path().to_str().unwrap());
    }
}