offcode 0.1.0

Offline AI coding assistant powered by Ollama
use serde_json::{json, Value};
use std::path::Path;
use std::process::Command;

// ── tool schema definitions ──────────────────────────────────────────────────

pub fn definitions() -> Vec<Value> {
    vec![
        json!({
            "type": "function",
            "function": {
                "name": "read_file",
                "description": "Read the full contents of a file. Adds line numbers for code files.",
                "parameters": {
                    "type": "object",
                    "required": ["path"],
                    "properties": {
                        "path": { "type": "string", "description": "File path to read" }
                    }
                }
            }
        }),
        json!({
            "type": "function",
            "function": {
                "name": "write_file",
                "description": "Write (or overwrite) a file with the given content. Creates parent dirs automatically.",
                "parameters": {
                    "type": "object",
                    "required": ["path", "content"],
                    "properties": {
                        "path":    { "type": "string", "description": "Destination file path" },
                        "content": { "type": "string", "description": "Content to write" }
                    }
                }
            }
        }),
        json!({
            "type": "function",
            "function": {
                "name": "run_command",
                "description": "Execute a shell command (via sh -c). Returns exit code, stdout, and stderr.",
                "parameters": {
                    "type": "object",
                    "required": ["command"],
                    "properties": {
                        "command": { "type": "string", "description": "Shell command to run" }
                    }
                }
            }
        }),
        json!({
            "type": "function",
            "function": {
                "name": "list_dir",
                "description": "List files and subdirectories at a path. Directories are marked with /.",
                "parameters": {
                    "type": "object",
                    "required": ["path"],
                    "properties": {
                        "path": { "type": "string", "description": "Directory to list (use '.' for current)" }
                    }
                }
            }
        }),
        json!({
            "type": "function",
            "function": {
                "name": "search_files",
                "description": "Recursively search for a text pattern inside files. Returns matching lines with paths and line numbers.",
                "parameters": {
                    "type": "object",
                    "required": ["pattern"],
                    "properties": {
                        "pattern":      { "type": "string", "description": "Text to search for (case-insensitive)" },
                        "path":         { "type": "string", "description": "Root directory to search (default: current dir)" },
                        "file_ext":     { "type": "string", "description": "Restrict to files with this extension, e.g. 'rs'" }
                    }
                }
            }
        }),
        json!({
            "type": "function",
            "function": {
                "name": "create_dir",
                "description": "Create a directory tree (like mkdir -p).",
                "parameters": {
                    "type": "object",
                    "required": ["path"],
                    "properties": {
                        "path": { "type": "string", "description": "Directory path to create" }
                    }
                }
            }
        }),
        json!({
            "type": "function",
            "function": {
                "name": "delete_path",
                "description": "Delete a file or an empty directory.",
                "parameters": {
                    "type": "object",
                    "required": ["path"],
                    "properties": {
                        "path": { "type": "string", "description": "Path to delete" }
                    }
                }
            }
        }),
        json!({
            "type": "function",
            "function": {
                "name": "path_info",
                "description": "Get metadata about a file or directory (type, size, modified time).",
                "parameters": {
                    "type": "object",
                    "required": ["path"],
                    "properties": {
                        "path": { "type": "string", "description": "Path to inspect" }
                    }
                }
            }
        }),
    ]
}

// ── tool execution ───────────────────────────────────────────────────────────

pub fn execute(name: &str, raw_args: &Value) -> String {
    let args = coerce_args(raw_args);

    match name {
        "read_file" => {
            let path = sarg(&args, "path");
            match std::fs::read_to_string(&path) {
                Ok(content) => {
                    if is_code_ext(&path) {
                        content
                            .lines()
                            .enumerate()
                            .map(|(i, l)| format!("{:>4} | {l}", i + 1))
                            .collect::<Vec<_>>()
                            .join("\n")
                    } else {
                        content
                    }
                }
                Err(e) => format!("Error reading '{path}': {e}"),
            }
        }

        "write_file" => {
            let path = sarg(&args, "path");
            let content = sarg(&args, "content");
            if let Some(parent) = Path::new(&path).parent() {
                if !parent.as_os_str().is_empty() {
                    let _ = std::fs::create_dir_all(parent);
                }
            }
            match std::fs::write(&path, &content) {
                Ok(_) => format!("Wrote {} bytes to '{path}'", content.len()),
                Err(e) => format!("Error writing '{path}': {e}"),
            }
        }

        "run_command" => {
            let cmd = sarg(&args, "command");
            match Command::new("sh").arg("-c").arg(&cmd).output() {
                Ok(out) => {
                    let code = out.status.code().unwrap_or(-1);
                    let stdout = String::from_utf8_lossy(&out.stdout);
                    let stderr = String::from_utf8_lossy(&out.stderr);
                    let mut result = format!("exit: {code}\n");
                    if !stdout.is_empty() {
                        result.push_str("stdout:\n");
                        result.push_str(&stdout);
                    }
                    if !stderr.is_empty() {
                        result.push_str("stderr:\n");
                        result.push_str(&stderr);
                    }
                    if stdout.is_empty() && stderr.is_empty() {
                        result.push_str("(no output)");
                    }
                    result
                }
                Err(e) => format!("Failed to run command: {e}"),
            }
        }

        "list_dir" => {
            let path = sarg(&args, "path");
            let path = if path.is_empty() { ".".to_string() } else { path };
            match std::fs::read_dir(&path) {
                Ok(entries) => {
                    let mut items: Vec<String> = entries
                        .filter_map(|e| e.ok())
                        .map(|e| {
                            let name = e.file_name().to_string_lossy().to_string();
                            let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
                            if is_dir { format!("{name}/") } else { name }
                        })
                        .collect();
                    items.sort();
                    if items.is_empty() {
                        "(empty directory)".to_string()
                    } else {
                        items.join("\n")
                    }
                }
                Err(e) => format!("Error listing '{path}': {e}"),
            }
        }

        "search_files" => {
            let pattern = sarg(&args, "pattern");
            let root = args
                .get("path")
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
                .unwrap_or(".");
            let ext = args
                .get("file_ext")
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty());

            if pattern.is_empty() {
                return "Error: pattern is required".to_string();
            }

            let mut results = Vec::new();
            search_recursive(root, &pattern.to_lowercase(), ext, 0, &mut results);

            if results.is_empty() {
                format!("No matches for '{pattern}'")
            } else {
                results.join("\n")
            }
        }

        "create_dir" => {
            let path = sarg(&args, "path");
            match std::fs::create_dir_all(&path) {
                Ok(_) => format!("Created '{path}'"),
                Err(e) => format!("Error: {e}"),
            }
        }

        "delete_path" => {
            let path = sarg(&args, "path");
            let p = Path::new(&path);
            let result = if p.is_dir() {
                std::fs::remove_dir(&path)
            } else {
                std::fs::remove_file(&path)
            };
            match result {
                Ok(_) => format!("Deleted '{path}'"),
                Err(e) => format!("Error: {e}"),
            }
        }

        "path_info" => {
            let path = sarg(&args, "path");
            match std::fs::metadata(&path) {
                Ok(m) => {
                    let kind = if m.is_dir() { "directory" } else { "file" };
                    let size = m.len();
                    let modified = m
                        .modified()
                        .ok()
                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                        .map(|d| d.as_secs().to_string())
                        .unwrap_or_else(|| "unknown".to_string());
                    format!("path: {path}\ntype: {kind}\nsize: {size} bytes\nmodified (unix): {modified}")
                }
                Err(e) => format!("Error: {e}"),
            }
        }

        _ => format!("Unknown tool '{name}'"),
    }
}

pub fn print_list() {
    use crate::ui::*;
    let tools = [
        ("read_file",    "Read file contents with line numbers"),
        ("write_file",   "Write/overwrite a file"),
        ("run_command",  "Run a shell command"),
        ("list_dir",     "List directory contents"),
        ("search_files", "Search pattern in files recursively"),
        ("create_dir",   "Create directories (mkdir -p)"),
        ("delete_path",  "Delete a file or empty directory"),
        ("path_info",    "File/directory metadata"),
    ];
    println!("{BOLD}Available tools:{RESET}");
    for (name, desc) in &tools {
        println!("  {CYAN}{name:<16}{RESET} {DIM}{desc}{RESET}");
    }
}

// ── helpers ──────────────────────────────────────────────────────────────────

fn sarg(args: &Value, key: &str) -> String {
    args.get(key)
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string()
}

fn coerce_args(v: &Value) -> Value {
    if let Some(s) = v.as_str() {
        serde_json::from_str(s).unwrap_or_else(|_| v.clone())
    } else {
        v.clone()
    }
}

fn is_code_ext(path: &str) -> bool {
    const EXTS: &[&str] = &[
        "rs", "py", "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp",
        "h", "hpp", "cs", "rb", "php", "swift", "kt", "scala", "sh", "bash",
        "zsh", "fish", "ps1", "toml", "yaml", "yml", "json", "xml", "html",
        "css", "scss", "sql", "md", "lua", "r", "ex", "exs", "hs",
    ];
    Path::new(path)
        .extension()
        .and_then(|e| e.to_str())
        .map(|e| EXTS.contains(&e))
        .unwrap_or(false)
}

fn search_recursive(
    dir: &str,
    pattern: &str,
    ext_filter: Option<&str>,
    depth: usize,
    results: &mut Vec<String>,
) {
    if depth > 6 || results.len() > 500 {
        return;
    }

    let entries = match std::fs::read_dir(dir) {
        Ok(e) => e,
        Err(_) => return,
    };

    for entry in entries.filter_map(|e| e.ok()) {
        let path = entry.path();
        let name = entry.file_name().to_string_lossy().to_string();

        // Skip common noise
        if name.starts_with('.') || matches!(name.as_str(), "target" | "node_modules" | ".git") {
            continue;
        }

        if path.is_dir() {
            search_recursive(
                &path.to_string_lossy(),
                pattern,
                ext_filter,
                depth + 1,
                results,
            );
        } else {
            if let Some(ext) = ext_filter {
                let file_ext = path
                    .extension()
                    .and_then(|e| e.to_str())
                    .unwrap_or("");
                if file_ext != ext {
                    continue;
                }
            }

            if let Ok(content) = std::fs::read_to_string(&path) {
                for (lineno, line) in content.lines().enumerate() {
                    if line.to_lowercase().contains(pattern) {
                        results.push(format!(
                            "{}:{}: {}",
                            path.display(),
                            lineno + 1,
                            line.trim()
                        ));
                    }
                }
            }
        }
    }
}