cortex-agent 0.3.1

Self-learning AI agent with persistent memory, tools, plugins, and a beautiful terminal UI
use std::sync::Arc;

use crate::tool::{param_string, ToolSpec};

pub fn self_tools() -> Vec<ToolSpec> {
    vec![
        self_read_tool(),
        self_patch_tool(),
        self_inspect_tool(),
        self_run_tests_tool(),
    ]
}

/// Find the project root (where Cargo.toml lives).
fn find_project_root() -> String {
    let cwd = std::env::current_dir().unwrap_or_default();
    let mut path = Some(cwd.as_path());
    while let Some(p) = path {
        if p.join("Cargo.toml").exists() {
            return p.to_string_lossy().to_string();
        }
        path = p.parent();
    }
    cwd.to_string_lossy().to_string()
}

lazy_static::lazy_static! {
    static ref CORTEX_ROOT: String = find_project_root();
}

fn is_allowed(path: &std::path::Path) -> bool {
    let canonical = match path.canonicalize() {
        Ok(p) => p,
        Err(_) => return false,
    };
    let canonical_str = canonical.to_string_lossy().to_string();

    let root = std::path::Path::new(&*CORTEX_ROOT);
    let src_dir = root.join("src").canonicalize().ok();
    let root_config = root.join("config.yaml").canonicalize().ok();
    let root_cargo = root.join("Cargo.toml").canonicalize().ok();

    // Check source tree
    if let Some(ref src) = src_dir {
        if canonical_str.starts_with(&src.to_string_lossy().to_string()) {
            return true;
        }
    }
    // Check root-level files
    if let Some(ref f) = root_config {
        if &canonical == f { return true; }
    }
    if let Some(ref f) = root_cargo {
        if &canonical == f { return true; }
    }
    false
}

fn resolve_path(path_str: &str) -> Option<String> {
    let path = if path_str.starts_with('~') {
        let home = std::env::var("HOME").unwrap_or_default();
        path_str.replacen('~', &home, 1)
    } else if !std::path::Path::new(path_str).is_absolute() {
        std::path::Path::new(&*CORTEX_ROOT).join(path_str).to_string_lossy().to_string()
    } else {
        path_str.to_string()
    };

    let p = std::path::Path::new(&path);
    if p.exists() && p.is_file() && is_allowed(p) {
        p.canonicalize().ok().map(|c| c.to_string_lossy().to_string())
    } else {
        None
    }
}

fn self_read_tool() -> ToolSpec {
    let (params, _) = crate::tool::required_params(&[
        ("path", param_string("Path to the file (relative to project root, or absolute)")),
        ("limit", serde_json::json!({"type": "integer", "description": "Max lines to read", "default": 50})),
    ]);
    ToolSpec::new("self_read", "Read lines from a file in the Cortex project", params,
        Arc::new(|args| {
            let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
            let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(50).min(200) as usize;
            let resolved = resolve_path(path).ok_or_else(|| format!("Path not allowed or not found: {}. Must be within the Cortex project.", path))?;
            let content = std::fs::read_to_string(&resolved).map_err(|e| format!("Error reading file: {}", e))?;
            let lines: Vec<&str> = content.lines().collect();
            let total = lines.len();
            let selected = &lines[..std::cmp::min(limit, total)];
            let mut result = format!("File: {} ({} lines)\n", resolved, total);
            for (i, line) in selected.iter().enumerate() { result.push_str(&format!("{:4}|{}\n", i + 1, line)); }
            if selected.len() < total { result.push_str(&format!("... ({} more lines)\n", total - selected.len())); }
            Ok(result)
        }),
    )
}

fn self_patch_tool() -> ToolSpec {
    let (params, _) = crate::tool::required_params(&[
        ("path", param_string("Path to the file (relative to project root)")),
        ("old_string", param_string("Exact text to find and replace")),
        ("new_string", param_string("Replacement text")),
    ]);
    ToolSpec::new("self_patch", "Make a targeted edit to a file in the Cortex project", params,
        Arc::new(|args| {
            let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
            let old_str = args.get("old_string").and_then(|v| v.as_str()).unwrap_or("");
            let new_str = args.get("new_string").and_then(|v| v.as_str()).unwrap_or("");
            let resolved = resolve_path(path).ok_or_else(|| format!("Path not allowed: {}. Must be within the Cortex project.", path))?;
            if old_str.trim().is_empty() { return Err("old_string cannot be empty.".into()); }
            let content = std::fs::read_to_string(&resolved).map_err(|e| format!("Error reading {}: {}", path, e))?;
            if !content.contains(old_str) { return Err(format!("Could not find the specified text in {}. Use self_read to verify.", path)); }
            let new_content = content.replacen(old_str, new_str, 1);
            if new_content == content { return Ok("No changes made (old_string not found).".into()); }
            std::fs::write(&resolved, &new_content).map_err(|e| format!("Error writing {}: {}", path, e))?;
            Ok("Patched successfully.".into())
        }),
    )
}

fn self_inspect_tool() -> ToolSpec {
    let (params, _) = crate::tool::required_params(&[("module", serde_json::json!({"type": "string", "description": "Module path to inspect", "default": "cortex"}))]);
    ToolSpec::new("self_inspect", "List the modules and files in the Cortex project", params,
        Arc::new(|args| {
            let module = args.get("module").and_then(|v| v.as_str()).unwrap_or("cortex");
            let module_path = std::path::Path::new(&*CORTEX_ROOT).join("src").join(module.replace('.', "/"));
            if !module_path.exists() || !module_path.is_dir() { return Err(format!("Module path not found: {}", module)); }
            let mut result = vec![format!("Module: {}", module)];
            let mut entries: Vec<_> = std::fs::read_dir(&module_path).map_err(|e| format!("Error: {}", e))?.filter_map(|e| e.ok()).collect();
            entries.sort_by_key(|e| e.file_name());
            for entry in &entries {
                let name = entry.file_name().to_string_lossy().to_string();
                if name.starts_with('.') { continue; }
                if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
                    let rs_count = std::fs::read_dir(entry.path()).map(|d| d.filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().ends_with(".rs")).count()).unwrap_or(0);
                    result.push(format!("  📁 {}/ ({} files)", name, rs_count));
                } else if name.ends_with(".rs") {
                    let size = std::fs::metadata(entry.path()).map(|m| m.len()).unwrap_or(0);
                    result.push(format!("  📄 {} ({} bytes)", name, size));
                }
            }
            Ok(result.join("\n"))
        }),
    )
}

fn self_run_tests_tool() -> ToolSpec {
    let (params, _) = crate::tool::required_params(&[]);
    ToolSpec::new("self_run_tests", "Run the Cortex test suite using cargo test", params,
        Arc::new(|_args| {
            let manifest = std::path::Path::new(&*CORTEX_ROOT).join("Cargo.toml");
            let output = std::process::Command::new("cargo")
                .args(["test", "--manifest-path"])
                .arg(&manifest)
                .output()
                .map_err(|e| format!("Failed to run tests: {}", e))?;
            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
            let mut out = String::new();
            for line in stdout.lines().chain(stderr.lines()) {
                if line.contains("test result") || line.contains("running") || line.contains("FAILED") || line.contains("error") {
                    out.push_str(line); out.push('\n');
                }
            }
            if out.is_empty() { out = stdout; }
            Ok(format!("Test results:\n{}", out))
        }),
    )
}