stakpak_server/context/
project.rs1use std::fs;
2use std::path::{Path, PathBuf};
3
4const 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 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
85fn 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 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 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 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}