checkmate-cli 0.4.1

Checkmate - API Testing Framework CLI
//! Project discovery and initialization

use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ProjectError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Already initialized: .checkmate/ exists in {0}")]
    AlreadyInitialized(PathBuf),

    #[error("Not initialized: no .checkmate/ found. Run 'cm init' first.")]
    NotInitialized,

    #[error("Invalid base URL: {0}")]
    InvalidUrl(String),
}

/// Represents an initialized Checkmate project
#[derive(Debug, Clone)]
pub struct CheckmateProject {
    /// Project root directory (parent of .checkmate/)
    pub root: PathBuf,
    /// Path to .checkmate/ directory
    pub checkmate_dir: PathBuf,
    /// Path to config.toml
    pub config_path: PathBuf,
    /// Path to tests/ directory
    pub tests_dir: PathBuf,
    /// Path to runs/ directory
    pub runs_dir: PathBuf,
    /// Path to hooks/ directory
    pub hooks_dir: PathBuf,
}

impl CheckmateProject {
    /// Discover an existing project by walking up the directory tree
    pub fn discover() -> Option<Self> {
        let checkmate_dir = find_checkmate_dir()?;
        let checkmate_dir = follow_redirect(&checkmate_dir);
        Self::from_checkmate_dir(checkmate_dir)
    }

    /// Create project from a known .checkmate/ directory
    fn from_checkmate_dir(checkmate_dir: PathBuf) -> Option<Self> {
        let root = checkmate_dir.parent()?.to_path_buf();
        Some(Self {
            config_path: checkmate_dir.join("config.toml"),
            tests_dir: checkmate_dir.join("tests"),
            runs_dir: checkmate_dir.join("runs"),
            hooks_dir: checkmate_dir.join("hooks"),
            checkmate_dir,
            root,
        })
    }

    /// Check if a project is already initialized at the given path
    pub fn exists_at(path: &Path) -> bool {
        path.join(".checkmate").is_dir()
    }

    /// Initialize a new project
    pub fn init(root: &Path, base_url: &str, stealth: bool) -> Result<Self, ProjectError> {
        // Check for existing initialization
        if Self::exists_at(root) {
            return Err(ProjectError::AlreadyInitialized(root.to_path_buf()));
        }

        // Validate and normalize base URL
        let base_url = normalize_url(base_url)?;

        // Create directory structure
        let checkmate_dir = root.join(".checkmate");
        let tests_dir = checkmate_dir.join("tests");
        let runs_dir = checkmate_dir.join("runs");
        let hooks_dir = checkmate_dir.join("hooks");

        fs::create_dir_all(&tests_dir)?;
        fs::create_dir_all(&runs_dir)?;
        fs::create_dir_all(&hooks_dir)?;

        // Create config.toml
        let config_path = checkmate_dir.join("config.toml");
        let config_content = generate_config(&base_url);
        fs::write(&config_path, config_content)?;

        // Create .gitignore for runs (don't track run history by default)
        let runs_gitignore = runs_dir.join(".gitignore");
        fs::write(&runs_gitignore, "# Run history is local by default\n*.jsonl\n")?;

        // Handle stealth mode
        if stealth {
            add_to_git_exclude(root, ".checkmate/")?;
        }

        Ok(Self {
            root: root.to_path_buf(),
            checkmate_dir,
            config_path,
            tests_dir,
            runs_dir,
            hooks_dir,
        })
    }

    /// Get the runs.jsonl file path
    pub fn runs_file(&self) -> PathBuf {
        self.runs_dir.join("runs.jsonl")
    }
}

/// Walk up directory tree to find .checkmate/
pub fn find_checkmate_dir() -> Option<PathBuf> {
    let mut current = std::env::current_dir().ok()?;
    loop {
        let checkmate_dir = current.join(".checkmate");
        if checkmate_dir.is_dir() {
            return Some(checkmate_dir);
        }
        if !current.pop() {
            return None;
        }
    }
}

/// Follow redirect file if present (for multi-workspace setups)
pub fn follow_redirect(checkmate_dir: &Path) -> PathBuf {
    let redirect_file = checkmate_dir.join("redirect");
    if redirect_file.exists() {
        if let Ok(target) = fs::read_to_string(&redirect_file) {
            let target = target.trim();
            if !target.is_empty() {
                // Resolve relative to .checkmate's parent (project root)
                if let Some(parent) = checkmate_dir.parent() {
                    let resolved = parent.join(target);
                    if resolved.is_dir() {
                        return resolved;
                    }
                }
            }
        }
    }
    checkmate_dir.to_path_buf()
}

/// Normalize URL: trim whitespace, remove trailing slash
fn normalize_url(url: &str) -> Result<String, ProjectError> {
    let url = url.trim();
    if url.is_empty() {
        return Err(ProjectError::InvalidUrl("URL cannot be empty".to_string()));
    }
    // Basic validation
    if !url.starts_with("http://") && !url.starts_with("https://") {
        return Err(ProjectError::InvalidUrl(format!(
            "URL must start with http:// or https://, got: {}",
            url
        )));
    }
    Ok(url.trim_end_matches('/').to_string())
}

/// Generate default config.toml content
fn generate_config(base_url: &str) -> String {
    format!(
        r#"# Checkmate Configuration
# Documentation: cm docs

[env]
base_url = "{}"
timeout_ms = 5000

[defaults]
fail_fast = false
expect_status = 200
"#,
        base_url
    )
}

/// Add pattern to .git/info/exclude for stealth mode
fn add_to_git_exclude(repo_root: &Path, pattern: &str) -> Result<(), ProjectError> {
    let git_dir = repo_root.join(".git");
    if !git_dir.is_dir() {
        // Not a git repo, silently skip
        return Ok(());
    }

    let info_dir = git_dir.join("info");
    fs::create_dir_all(&info_dir)?;

    let exclude_path = info_dir.join("exclude");

    // Check if pattern already exists
    if exclude_path.exists() {
        let content = fs::read_to_string(&exclude_path)?;
        if content.lines().any(|line| line.trim() == pattern) {
            return Ok(()); // Already excluded
        }
    }

    // Append pattern
    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&exclude_path)?;
    writeln!(file, "\n# Added by Checkmate (stealth mode)")?;
    writeln!(file, "{}", pattern)?;

    Ok(())
}

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

    #[test]
    fn test_normalize_url() {
        assert_eq!(
            normalize_url("http://localhost:8080/").unwrap(),
            "http://localhost:8080"
        );
        assert_eq!(
            normalize_url("  https://api.example.com  ").unwrap(),
            "https://api.example.com"
        );
        assert!(normalize_url("").is_err());
        assert!(normalize_url("not-a-url").is_err());
    }

    #[test]
    fn test_project_init() {
        let temp_dir = env::temp_dir().join(format!("checkmate-test-{}", nanoid::nanoid!(6)));
        fs::create_dir_all(&temp_dir).unwrap();

        let project = CheckmateProject::init(&temp_dir, "http://localhost:8080", false).unwrap();

        assert!(project.checkmate_dir.exists());
        assert!(project.tests_dir.exists());
        assert!(project.runs_dir.exists());
        assert!(project.hooks_dir.exists());
        assert!(project.config_path.exists());

        // Cleanup
        fs::remove_dir_all(&temp_dir).ok();
    }

    #[test]
    fn test_already_initialized() {
        let temp_dir = env::temp_dir().join(format!("checkmate-test-{}", nanoid::nanoid!(6)));
        fs::create_dir_all(&temp_dir).unwrap();

        // First init should succeed
        CheckmateProject::init(&temp_dir, "http://localhost:8080", false).unwrap();

        // Second init should fail
        let result = CheckmateProject::init(&temp_dir, "http://localhost:8080", false);
        assert!(matches!(result, Err(ProjectError::AlreadyInitialized(_))));

        // Cleanup
        fs::remove_dir_all(&temp_dir).ok();
    }
}