cortex-agent 0.5.2

Self-learning AI agent with persistent memory, skills, project awareness, and a beautiful terminal UI
use std::path::{Path, PathBuf};

/// Detected project profile
#[derive(Debug, Clone, Default)]
pub struct ProjectProfile {
    pub language: String,
    pub framework: String,
    pub package_manager: String,
    pub test_framework: String,
    pub ci_type: String,
}

/// Context files discovered in a project directory.
#[derive(Debug, Clone, Default)]
pub struct ProjectContext {
    /// The project root directory
    pub root: Option<PathBuf>,
    /// Contents of AGENTS.md or CLAUDE.md, if found
    pub agent_context: Option<String>,
    /// Per-project .cortex/config.yaml contents, if found
    pub project_config: Option<String>,
    /// The original working directory
    pub cwd: PathBuf,
    /// Smart detected project profile
    pub profile: ProjectProfile,
}

/// Names of project context files to check (in priority order).
const CONTEXT_FILES: &[&str] = &[
    "AGENTS.md",
    "CLAUDE.md",
    "GEMINI.md",
    ".cursorrules",
    ".cortex/config.yaml",
];

/// Walk up from `cwd` looking for project context files.
/// Returns the first directory that contains at least one context file.
pub fn discover_project_root(cwd: &Path) -> Option<PathBuf> {
    let mut current = Some(cwd.to_path_buf());
    while let Some(dir) = current {
        for filename in CONTEXT_FILES {
            let candidate = dir.join(filename);
            if candidate.exists() {
                // If it's the current dir, great. Otherwise prefer the dir that has it.
                return Some(dir.clone());
            }
        }
        current = dir.parent().map(|p| p.to_path_buf());
    }
    None
}

/// Load project context: scan from cwd up to git root for AGENTS.md, CLAUDE.md, etc.
pub fn load_project_context(cwd: &Path) -> ProjectContext {
    let mut ctx = ProjectContext {
        cwd: cwd.to_path_buf(),
        ..Default::default()
    };

    let root = discover_project_root(cwd);
    ctx.root = root.clone();

    if let Some(ref root_dir) = root {
        // Load agent context files in priority order (first found wins for content)
        for filename in &CONTEXT_FILES[..4] {
            let path = root_dir.join(filename);
            if path.exists() {
                if let Ok(content) = std::fs::read_to_string(&path) {
                    let trimmed = content.trim().to_string();
                    if !trimmed.is_empty() {
                        ctx.agent_context = Some(trimmed);
                        break; // first found wins
                    }
                }
            }
        }

        // Check for per-project .cortex/config.yaml
        let proj_config = root_dir.join(".cortex/config.yaml");
        if proj_config.exists() {
            if let Ok(content) = std::fs::read_to_string(&proj_config) {
                ctx.project_config = Some(content);
            }
        }
    }

    // Smart project detection
    ctx.profile = detect_project_profile(root.as_deref().unwrap_or(cwd));

    ctx
}

/// Detect project language, framework, package manager, test framework, and CI type.
fn detect_project_profile(project_dir: &Path) -> ProjectProfile {
    let mut p = ProjectProfile::default();

    // Rust
    if project_dir.join("Cargo.toml").exists() {
        p.language = "Rust".into();
        p.package_manager = "cargo".into();
        p.test_framework = "cargo test".into();
        // Detect framework from Cargo.toml keywords or deps
        if let Ok(content) = std::fs::read_to_string(project_dir.join("Cargo.toml")) {
            if content.contains("actix") { p.framework = "Actix Web".into(); }
            else if content.contains("axum") { p.framework = "Axum".into(); }
            else if content.contains("rocket") { p.framework = "Rocket".into(); }
            else if content.contains("clap") { p.framework = "CLI (clap)".into(); }
        }
    }
    // Node.js
    else if project_dir.join("package.json").exists() {
        p.language = "JavaScript".into();
        p.package_manager = if project_dir.join("yarn.lock").exists() { "yarn".into() }
            else if project_dir.join("pnpm-lock.yaml").exists() { "pnpm".into() }
            else { "npm".into() };
        // Detect framework from package.json
        if let Ok(content) = std::fs::read_to_string(project_dir.join("package.json")) {
            if content.contains("\"next\"") { p.framework = "Next.js".into(); p.language = "TypeScript".into(); }
            else if content.contains("\"react\"") { p.framework = "React".into(); }
            else if content.contains("\"vue\"") { p.framework = "Vue".into(); }
            else if content.contains("\"express\"") { p.framework = "Express".into(); }
            if content.contains("\"jest\"") { p.test_framework = "Jest".into(); }
            else if content.contains("\"vitest\"") { p.test_framework = "Vitest".into(); }
            else if content.contains("\"mocha\"") { p.test_framework = "Mocha".into(); }
        }
        if p.test_framework.is_empty() { p.test_framework = "npm test".into(); }
    }
    // Python
    else if project_dir.join("pyproject.toml").exists() || project_dir.join("requirements.txt").exists() || project_dir.join("setup.py").exists() {
        p.language = "Python".into();
        p.package_manager = if project_dir.join("uv.lock").exists() || project_dir.join("pyproject.toml").exists() { "uv/pip".into() }
            else if project_dir.join("Pipfile").exists() { "pipenv".into() }
            else { "pip".into() };
        if project_dir.join("pyproject.toml").exists() {
            if let Ok(content) = std::fs::read_to_string(project_dir.join("pyproject.toml")) {
                if content.contains("django") { p.framework = "Django".into(); }
                else if content.contains("flask") { p.framework = "Flask".into(); }
                else if content.contains("fastapi") { p.framework = "FastAPI".into(); }
                if content.contains("pytest") { p.test_framework = "pytest".into(); }
            }
        }
        if p.test_framework.is_empty() { p.test_framework = "pytest".into(); }
    }
    // Go
    else if project_dir.join("go.mod").exists() {
        p.language = "Go".into();
        p.package_manager = "go mod".into();
        p.test_framework = "go test".into();
        if let Ok(content) = std::fs::read_to_string(project_dir.join("go.mod")) {
            if content.contains("gin-gonic") { p.framework = "Gin".into(); }
            else if content.contains("fiber") { p.framework = "Fiber".into(); }
            else if content.contains("echo") { p.framework = "Echo".into(); }
        }
    }
    // Java/Kotlin
    else if project_dir.join("pom.xml").exists() {
        p.language = "Java".into(); p.package_manager = "maven".into(); p.test_framework = "JUnit".into();
    }
    else if project_dir.join("build.gradle").exists() || project_dir.join("build.gradle.kts").exists() {
        p.language = if project_dir.join("build.gradle.kts").exists() { "Kotlin".into() } else { "Java".into() };
        p.package_manager = "gradle".into(); p.test_framework = "JUnit".into();
    }

    // Detect CI/CD
    if project_dir.join(".github/workflows").exists() { p.ci_type = "GitHub Actions".into(); }
    else if project_dir.join(".gitlab-ci.yml").exists() { p.ci_type = "GitLab CI".into(); }
    else if project_dir.join("Jenkinsfile").exists() { p.ci_type = "Jenkins".into(); }
    else if project_dir.join("Dockerfile").exists() { p.ci_type = "Docker".into(); }

    p
}

/// Format a display label for the project profile (for status bar / welcome).
pub fn format_project_label(profile: &ProjectProfile) -> String {
    let mut parts: Vec<&str> = Vec::new();
    if !profile.language.is_empty() { parts.push(&profile.language); }
    if !profile.framework.is_empty() { parts.push(&profile.framework); }
    if !profile.package_manager.is_empty() { parts.push(&profile.package_manager); }
    if parts.is_empty() { return "no project".into(); }
    parts.join(" ยท ")
}

/// Format project context for injection into the system prompt.
pub fn format_project_context(ctx: &ProjectContext) -> String {
    let mut parts: Vec<String> = Vec::new();

    if let Some(ref root) = ctx.root {
        parts.push(format!("## Project Context\n- Project root: {}", root.display()));
    }

    if let Some(ref agent_ctx) = ctx.agent_context {
        parts.push(format!("\n### Project Guidelines\n{}", agent_ctx));
    }

    if ctx.project_config.is_some() {
        parts.push("\n*Project config overrides active*".to_string());
    }

    parts.join("\n")
}