Skip to main content

ralph/
context.rs

1use ignore::WalkBuilder;
2use std::path::Path;
3
4const MAX_FILE_BYTES: usize = 32_000;
5const MAX_TREE_ENTRIES: usize = 200;
6
7/// A snapshot of the workspace state injected into each LLM turn.
8pub struct WorkspaceContext {
9    pub file_tree: String,
10    pub git_status: Option<String>,
11    pub file_contents: Vec<(String, String)>, // (path, content)
12}
13
14impl WorkspaceContext {
15    pub fn build(workspace: &Path) -> Self {
16        let file_tree = build_tree(workspace);
17        let git_status = get_git_status(workspace);
18        Self {
19            file_tree,
20            git_status,
21            file_contents: Vec::new(),
22        }
23    }
24
25    pub fn add_file(&mut self, path: &str, content: &str) {
26        let truncated = if content.len() > MAX_FILE_BYTES {
27            format!(
28                "{}…[truncated at {}KB]",
29                &content[..MAX_FILE_BYTES],
30                MAX_FILE_BYTES / 1024
31            )
32        } else {
33            content.to_string()
34        };
35        self.file_contents.push((path.to_string(), truncated));
36    }
37
38    pub fn to_context_string(&self) -> String {
39        let mut out = String::new();
40
41        out.push_str("## Workspace File Tree\n```\n");
42        out.push_str(&self.file_tree);
43        out.push_str("\n```\n\n");
44
45        if let Some(ref status) = self.git_status {
46            out.push_str("## Git Status\n```\n");
47            out.push_str(status);
48            out.push_str("\n```\n\n");
49        }
50
51        if !self.file_contents.is_empty() {
52            out.push_str("## Referenced Files\n\n");
53            for (path, content) in &self.file_contents {
54                out.push_str(&format!("### {}\n```\n{}\n```\n\n", path, content));
55            }
56        }
57
58        out
59    }
60}
61
62fn build_tree(workspace: &Path) -> String {
63    let mut entries = Vec::new();
64    let walker = WalkBuilder::new(workspace)
65        .hidden(false)
66        .ignore(true)
67        .git_ignore(true)
68        .max_depth(Some(4))
69        .build();
70
71    for entry in walker.flatten() {
72        let rel = entry
73            .path()
74            .strip_prefix(workspace)
75            .unwrap_or(entry.path())
76            .display()
77            .to_string();
78        if rel.is_empty() {
79            continue;
80        }
81        let suffix = if entry.path().is_dir() { "/" } else { "" };
82        entries.push(format!("{}{}", rel, suffix));
83        if entries.len() >= MAX_TREE_ENTRIES {
84            entries.push(format!("[...and more]"));
85            break;
86        }
87    }
88    entries.sort();
89    entries.join("\n")
90}
91
92fn get_git_status(workspace: &Path) -> Option<String> {
93    let output = std::process::Command::new("git")
94        .args(["status", "--short"])
95        .current_dir(workspace)
96        .output()
97        .ok()?;
98
99    if !output.status.success() {
100        return None;
101    }
102
103    let s = String::from_utf8_lossy(&output.stdout).to_string();
104    if s.trim().is_empty() {
105        None
106    } else {
107        Some(s)
108    }
109}