Skip to main content

stakpak_server/context/
project.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4/// Maximum number of parent directories to traverse when searching for project
5/// files (AGENTS.md, APPS.md). 5 levels covers most monorepo nesting depths
6/// without accidentally picking up unrelated files from distant ancestors.
7const MAX_TRAVERSAL_DEPTH: usize = 5;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
10pub enum ContextPriority {
11    Critical = 0,
12    High = 1,
13    Normal = 2,
14    CallerSupplied = 3,
15}
16
17#[derive(Debug, Clone)]
18pub struct ContextFile {
19    pub name: String,
20    pub path: String,
21    pub content: String,
22    /// Character count at construction time, before any budget truncation.
23    /// Used for telemetry and logging to track how much content was trimmed.
24    pub original_size: usize,
25    pub truncated: bool,
26    pub priority: ContextPriority,
27}
28
29impl ContextFile {
30    pub fn new(
31        name: impl Into<String>,
32        path: impl Into<String>,
33        content: impl Into<String>,
34        priority: ContextPriority,
35    ) -> Self {
36        let content = content.into();
37        Self {
38            name: name.into(),
39            path: path.into(),
40            original_size: content.chars().count(),
41            content,
42            truncated: false,
43            priority,
44        }
45    }
46}
47
48#[derive(Debug, Clone, Default)]
49pub struct ProjectContext {
50    pub files: Vec<ContextFile>,
51}
52
53impl ProjectContext {
54    pub fn discover(start_dir: &Path) -> Self {
55        let mut files = Vec::new();
56
57        if let Some(file) = discover_agents_md(start_dir) {
58            files.push(file);
59        }
60
61        if let Some(file) = discover_apps_md(start_dir) {
62            files.push(file);
63        }
64
65        Self { files }
66    }
67
68    pub fn with_caller_context(mut self, caller_files: Vec<ContextFile>) -> Self {
69        self.files.extend(caller_files);
70        self
71    }
72}
73
74fn discover_agents_md(start_dir: &Path) -> Option<ContextFile> {
75    let discovered = discover_nearest_file(start_dir, &["AGENTS.md", "agents.md"])?;
76
77    Some(ContextFile::new(
78        "AGENTS.md",
79        discovered.path.display().to_string(),
80        discovered.content,
81        ContextPriority::Critical,
82    ))
83}
84
85/// Discover APPS.md with a global fallback at `~/.stakpak/APPS.md`.
86///
87/// Unlike AGENTS.md (which is always project-specific), APPS.md can describe
88/// globally-managed applications and infrastructure, so a user-level fallback
89/// is supported when no project-local file is found.
90fn discover_apps_md(start_dir: &Path) -> Option<ContextFile> {
91    if let Some(discovered) = discover_nearest_file(start_dir, &["APPS.md", "apps.md"]) {
92        return Some(ContextFile::new(
93            "APPS.md",
94            discovered.path.display().to_string(),
95            discovered.content,
96            ContextPriority::High,
97        ));
98    }
99
100    // Global fallback: ~/.stakpak/APPS.md
101    let home = dirs::home_dir()?;
102    let global_apps = home.join(".stakpak").join("APPS.md");
103    let content = fs::read_to_string(&global_apps).ok()?;
104
105    let path = canonical_or_original(&global_apps);
106    Some(ContextFile::new(
107        "APPS.md",
108        path.display().to_string(),
109        content,
110        ContextPriority::High,
111    ))
112}
113
114struct DiscoveredFile {
115    path: PathBuf,
116    content: String,
117}
118
119fn discover_nearest_file(start_dir: &Path, file_names: &[&str]) -> Option<DiscoveredFile> {
120    let mut current = start_dir.to_path_buf();
121
122    for _ in 0..=MAX_TRAVERSAL_DEPTH {
123        for file_name in file_names {
124            let candidate = current.join(file_name);
125            if !candidate.exists() {
126                continue;
127            }
128
129            let content = match fs::read_to_string(&candidate) {
130                Ok(content) => content,
131                Err(_) => continue,
132            };
133
134            return Some(DiscoveredFile {
135                path: canonical_or_original(&candidate),
136                content,
137            });
138        }
139
140        if !current.pop() {
141            break;
142        }
143    }
144
145    None
146}
147
148fn canonical_or_original(path: &Path) -> PathBuf {
149    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn discovers_nearest_agents_file() {
158        let temp = tempfile::TempDir::new().expect("temp dir");
159        let root_agents = temp.path().join("AGENTS.md");
160        std::fs::write(&root_agents, "root").expect("write root agents");
161
162        let nested = temp.path().join("a").join("b");
163        std::fs::create_dir_all(&nested).expect("create nested");
164
165        let nested_agents = nested.join("AGENTS.md");
166        std::fs::write(&nested_agents, "nested").expect("write nested agents");
167
168        let context = ProjectContext::discover(&nested);
169        let agents = context.files.iter().find(|file| file.name == "AGENTS.md");
170
171        assert!(agents.is_some());
172        assert!(
173            agents
174                .map(|file| file.content.contains("nested"))
175                .unwrap_or(false)
176        );
177    }
178
179    #[test]
180    fn discovers_apps_file() {
181        let temp = tempfile::TempDir::new().expect("temp dir");
182        let apps = temp.path().join("APPS.md");
183        std::fs::write(&apps, "apps data").expect("write apps");
184
185        let context = ProjectContext::discover(temp.path());
186        let apps_file = context.files.iter().find(|file| file.name == "APPS.md");
187
188        assert!(apps_file.is_some());
189        assert!(
190            apps_file
191                .map(|file| file.content.contains("apps data"))
192                .unwrap_or(false)
193        );
194    }
195
196    #[test]
197    fn caller_context_is_appended() {
198        let context = ProjectContext::default().with_caller_context(vec![ContextFile::new(
199            "gateway_delivery",
200            "/tmp/context.txt",
201            "hello",
202            ContextPriority::CallerSupplied,
203        )]);
204
205        assert_eq!(context.files.len(), 1);
206        assert_eq!(context.files[0].name, "gateway_delivery");
207    }
208
209    #[test]
210    fn discovers_agents_md_from_parent_directory() {
211        let temp = tempfile::TempDir::new().expect("temp dir");
212        let root_agents = temp.path().join("AGENTS.md");
213        std::fs::write(&root_agents, "root config").expect("write root agents");
214
215        let nested = temp.path().join("src").join("lib");
216        std::fs::create_dir_all(&nested).expect("create nested");
217
218        // No AGENTS.md in nested, should find root
219        let context = ProjectContext::discover(&nested);
220        let agents = context.files.iter().find(|file| file.name == "AGENTS.md");
221
222        assert!(agents.is_some(), "should discover AGENTS.md from ancestor");
223        assert!(
224            agents
225                .map(|file| file.content.contains("root config"))
226                .unwrap_or(false)
227        );
228    }
229
230    #[test]
231    fn prefers_nearest_agents_md() {
232        let temp = tempfile::TempDir::new().expect("temp dir");
233        let root_agents = temp.path().join("AGENTS.md");
234        std::fs::write(&root_agents, "root").expect("write root");
235
236        let nested = temp.path().join("sub");
237        std::fs::create_dir_all(&nested).expect("create nested");
238        let nested_agents = nested.join("AGENTS.md");
239        std::fs::write(&nested_agents, "nested").expect("write nested");
240
241        let context = ProjectContext::discover(&nested);
242        let agents = context.files.iter().find(|file| file.name == "AGENTS.md");
243
244        assert!(
245            agents.map(|file| file.content == "nested").unwrap_or(false),
246            "should prefer the nearest AGENTS.md"
247        );
248    }
249
250    #[test]
251    fn empty_directory_discovers_nothing() {
252        let temp = tempfile::TempDir::new().expect("temp dir");
253        let context = ProjectContext::discover(temp.path());
254        // May or may not find global APPS.md from home dir — that's OK
255        let agents = context.files.iter().find(|file| file.name == "AGENTS.md");
256        assert!(agents.is_none(), "empty dir should not have AGENTS.md");
257    }
258
259    #[test]
260    fn context_file_tracks_original_size() {
261        let content = "x".repeat(500);
262        let file = ContextFile::new("test", "/test", content.clone(), ContextPriority::Normal);
263
264        assert_eq!(file.original_size, 500);
265        assert!(!file.truncated);
266    }
267
268    #[test]
269    fn caller_context_appended_after_discovered_files() {
270        let temp = tempfile::TempDir::new().expect("temp dir");
271        let agents = temp.path().join("AGENTS.md");
272        std::fs::write(&agents, "project config").expect("write agents");
273
274        let context =
275            ProjectContext::discover(temp.path()).with_caller_context(vec![ContextFile::new(
276                "watch_result",
277                "caller://watch_result",
278                "health ok",
279                ContextPriority::CallerSupplied,
280            )]);
281
282        assert!(context.files.len() >= 2, "should have agents + caller file");
283        assert_eq!(
284            context.files.last().map(|file| file.name.as_str()),
285            Some("watch_result"),
286            "caller context should come after discovered files"
287        );
288    }
289}