ralph-coder 0.2.1

An agentic code generation CLI powered by multiple LLM backends
Documentation
use ignore::WalkBuilder;
use std::path::Path;

const MAX_FILE_BYTES: usize = 32_000;
const MAX_TREE_ENTRIES: usize = 200;

/// A snapshot of the workspace state injected into each LLM turn.
pub struct WorkspaceContext {
    pub file_tree: String,
    pub git_status: Option<String>,
    pub file_contents: Vec<(String, String)>, // (path, content)
}

impl WorkspaceContext {
    pub fn build(workspace: &Path) -> Self {
        let file_tree = build_tree(workspace);
        let git_status = get_git_status(workspace);
        Self {
            file_tree,
            git_status,
            file_contents: Vec::new(),
        }
    }

    pub fn add_file(&mut self, path: &str, content: &str) {
        let truncated = if content.len() > MAX_FILE_BYTES {
            format!(
                "{}…[truncated at {}KB]",
                &content[..MAX_FILE_BYTES],
                MAX_FILE_BYTES / 1024
            )
        } else {
            content.to_string()
        };
        self.file_contents.push((path.to_string(), truncated));
    }

    pub fn to_context_string(&self) -> String {
        let mut out = String::new();

        out.push_str("## Workspace File Tree\n```\n");
        out.push_str(&self.file_tree);
        out.push_str("\n```\n\n");

        if let Some(ref status) = self.git_status {
            out.push_str("## Git Status\n```\n");
            out.push_str(status);
            out.push_str("\n```\n\n");
        }

        if !self.file_contents.is_empty() {
            out.push_str("## Referenced Files\n\n");
            for (path, content) in &self.file_contents {
                out.push_str(&format!("### {}\n```\n{}\n```\n\n", path, content));
            }
        }

        out
    }
}

fn build_tree(workspace: &Path) -> String {
    let mut entries = Vec::new();
    let walker = WalkBuilder::new(workspace)
        .hidden(false)
        .ignore(true)
        .git_ignore(true)
        .max_depth(Some(4))
        .build();

    for entry in walker.flatten() {
        let rel = entry
            .path()
            .strip_prefix(workspace)
            .unwrap_or(entry.path())
            .display()
            .to_string();
        if rel.is_empty() {
            continue;
        }
        let suffix = if entry.path().is_dir() { "/" } else { "" };
        entries.push(format!("{}{}", rel, suffix));
        if entries.len() >= MAX_TREE_ENTRIES {
            entries.push(format!("[...and more]"));
            break;
        }
    }
    entries.sort();
    entries.join("\n")
}

fn get_git_status(workspace: &Path) -> Option<String> {
    let output = std::process::Command::new("git")
        .args(["status", "--short"])
        .current_dir(workspace)
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

    let s = String::from_utf8_lossy(&output.stdout).to_string();
    if s.trim().is_empty() {
        None
    } else {
        Some(s)
    }
}