返回 CodeWhale
pack.rs
根目录 / crates / tui / src / project_context / pack.rs
1 //! Project context pack: a deterministic, bounded snapshot of the workspace
2 //! tree (sorted entries, README excerpt, config/source classification) that is
3 //! injected as `<project_context_pack>` and reused for the ephemeral
4 //! auto-generated context fallback.
5
6 use std::collections::{BTreeMap, VecDeque};
7 use std::fs;
8 use std::path::Path;
9
10 use serde::Serialize;
11
12 const PACK_README_MAX_CHARS: usize = 4_000;
13 const PACK_MAX_ENTRIES: usize = 220;
14 const PACK_MAX_SOURCE_FILES: usize = 60;
15 const PACK_MAX_CONFIG_FILES: usize = 60;
16 const PACK_MAX_DEPTH: usize = 4;
17 const PACK_IGNORED_DIRS: &[&str] = &[
18 ".git",
19 ".worktrees",
20 "node_modules",
21 ".venv",
22 "venv",
23 "__pycache__",
24 "dist",
25 "build",
26 "target",
27 ".idea",
28 ".vscode",
29 ".pytest_cache",
30 ".DS_Store",
31 ];
32 const PACK_ALLOWED_HIDDEN_DIRS: &[&str] = &[".github"];
33 const PACK_ALLOWED_HIDDEN_FILES: &[&str] = &[".editorconfig", ".gitattributes", ".gitignore"];
34 const PACK_IGNORED_FILE_NAMES: &[&str] = &[".DS_Store"];
35 const PACK_IGNORED_FILE_EXTENSIONS: &[&str] = &[
36 "7z", "avif", "db", "gif", "gz", "ico", "jpeg", "jpg", "log", "mov", "mp3", "mp4", "pdf",
37 "png", "sqlite", "tar", "tgz", "wav", "webp", "zip",
38 ];
39
40 #[derive(Debug, Serialize)]
41 struct ProjectContextPack {
42 project_name: String,
43 directory_structure: Vec<String>,
44 readme: Option<ReadmePack>,
45 config_files: Vec<String>,
46 key_source_files: Vec<String>,
47 counts: BTreeMap<String, usize>,
48 }
49
50 #[derive(Debug, Serialize)]
51 struct ReadmePack {
52 path: String,
53 excerpt: String,
54 }
55
56 /// Generate a deterministic, cache-friendly project context pack.
57 ///
58 /// The pack intentionally uses only stable workspace facts: relative paths,
59 /// sorted entries, bounded README text, and sorted JSON object fields. It does
60 /// not include timestamps, random ids, absolute temp paths, or live git state.
61 pub fn generate_project_context_pack(workspace: &Path) -> Option<String> {
62 let pack = build_project_context_pack(workspace)?;
63 let json = serde_json::to_string_pretty(&pack).ok()?;
64 Some(format!(
65 "## Project Context Pack\n\n<project_context_pack>\n{json}\n</project_context_pack>"
66 ))
67 }
68
69 pub(crate) fn generate_bounded_project_overview(workspace: &Path) -> Option<String> {
70 let pack = build_project_context_pack(workspace)?;
71 let json = serde_json::to_string_pretty(&pack).ok()?;
72 Some(format!(
73 "## Bounded Project Overview\n\n```json\n{json}\n```"
74 ))
75 }
76
77 fn build_project_context_pack(workspace: &Path) -> Option<ProjectContextPack> {
78 let mut entries = Vec::new();
79 collect_pack_entries(workspace, workspace, 0, &mut entries);
80 sort_pack_paths(&mut entries);
81 entries.truncate(PACK_MAX_ENTRIES);
82
83 let mut config_files = entries
84 .iter()
85 .filter(|path| is_config_file(path))
86 .take(PACK_MAX_CONFIG_FILES)
87 .cloned()
88 .collect::<Vec<_>>();
89 sort_pack_paths(&mut config_files);
90
91 let mut key_source_files = entries
92 .iter()
93 .filter(|path| is_source_file(path))
94 .take(PACK_MAX_SOURCE_FILES)
95 .cloned()
96 .collect::<Vec<_>>();
97 sort_pack_paths(&mut key_source_files);
98
99 let readme = read_readme_excerpt(workspace, &entries);
100 let mut counts = BTreeMap::new();
101 counts.insert("config_files".to_string(), config_files.len());
102 counts.insert("directory_entries".to_string(), entries.len());
103 counts.insert("key_source_files".to_string(), key_source_files.len());
104
105 Some(ProjectContextPack {
106 project_name: workspace
107 .file_name()
108 .and_then(|name| name.to_str())
109 .unwrap_or("workspace")
110 .to_string(),
111 directory_structure: entries,
112 readme,
113 config_files,
114 key_source_files,
115 counts,
116 })
117 }
118
119 fn collect_pack_entries(root: &Path, dir: &Path, depth: usize, out: &mut Vec<String>) {
120 if depth > PACK_MAX_DEPTH || out.len() >= PACK_MAX_ENTRIES {
121 return;
122 }
123
124 let mut queue = VecDeque::new();
125 queue.push_back((dir.to_path_buf(), depth));
126
127 while let Some((current_dir, current_depth)) = queue.pop_front() {
128 if current_depth > PACK_MAX_DEPTH || out.len() >= PACK_MAX_ENTRIES {
129 continue;
130 }
131
132 let Ok(read_dir) = fs::read_dir(&current_dir) else {
133 continue;
134 };
135 let mut children = read_dir.filter_map(Result::ok).collect::<Vec<_>>();
136 children.sort_by_key(|entry| entry.path());
137
138 for entry in children {
139 if out.len() >= PACK_MAX_ENTRIES {
140 break;
141 }
142 let path = entry.path();
143 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
144 continue;
145 };
146 let Ok(file_type) = entry.file_type() else {
147 continue;
148 };
149 if file_type.is_dir() && should_ignore_pack_dir(name) {
150 continue;
151 }
152 if file_type.is_file() && should_ignore_pack_file(name) {
153 continue;
154 }
155
156 if let Some(relative) = relative_slash_path(root, &path) {
157 if file_type.is_dir() {
158 out.push(format!("{relative}/"));
159 if current_depth < PACK_MAX_DEPTH {
160 queue.push_back((path, current_depth + 1));
161 }
162 } else if file_type.is_file() {
163 out.push(relative);
164 }
165 }
166 }
167 }
168 }
169
170 fn should_ignore_pack_dir(name: &str) -> bool {
171 PACK_IGNORED_DIRS.contains(&name)
172 || (name.starts_with('.') && !PACK_ALLOWED_HIDDEN_DIRS.contains(&name))
173 }
174
175 fn should_ignore_pack_file(name: &str) -> bool {
176 if name.starts_with('.') && !PACK_ALLOWED_HIDDEN_FILES.contains(&name) {
177 return true;
178 }
179 if PACK_IGNORED_FILE_NAMES.contains(&name) {
180 return true;
181 }
182 let Some((_, ext)) = name.rsplit_once('.') else {
183 return false;
184 };
185 PACK_IGNORED_FILE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())
186 }
187
188 fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
189 let relative = path.strip_prefix(root).ok()?;
190 let mut parts = Vec::new();
191 for component in relative.components() {
192 parts.push(component.as_os_str().to_string_lossy().to_string());
193 }
194 normalize_pack_relative_path(&parts.join("/"))
195 }
196
197 fn normalize_pack_relative_path(path: &str) -> Option<String> {
198 let normalized = path.replace('\\', "/");
199 let mut parts = Vec::new();
200 for part in normalized.split('/') {
201 if part.is_empty() || part == "." {
202 continue;
203 }
204 if part == ".." {
205 return None;
206 }
207 parts.push(part);
208 }
209 (!parts.is_empty()).then(|| parts.join("/"))
210 }
211
212 fn sort_pack_paths(paths: &mut [String]) {
213 paths.sort_by(|a, b| {
214 pack_path_priority(a)
215 .cmp(&pack_path_priority(b))
216 .then_with(|| pack_path_sort_key(a).cmp(&pack_path_sort_key(b)))
217 .then_with(|| a.cmp(b))
218 });
219 }
220
221 fn pack_path_sort_key(path: &str) -> String {
222 path.replace('\\', "/").to_ascii_lowercase()
223 }
224
225 fn pack_path_priority(path: &str) -> u8 {
226 let lower = pack_path_sort_key(path);
227 let name = lower.trim_end_matches('/').rsplit('/').next().unwrap_or("");
228 if matches!(name, "readme.md" | "readme.txt" | "readme") {
229 0
230 } else if is_config_file(&lower) {
231 1
232 } else if is_source_file(&lower) {
233 2
234 } else if lower.ends_with('/') {
235 3
236 } else {
237 4
238 }
239 }
240
241 fn read_readme_excerpt(workspace: &Path, entries: &[String]) -> Option<ReadmePack> {
242 let path = entries
243 .iter()
244 .find(|path| {
245 let lower = path.to_ascii_lowercase();
246 lower == "readme.md" || lower == "readme.txt" || lower == "readme"
247 })?
248 .clone();
249 let raw = fs::read_to_string(workspace.join(&path)).ok()?;
250 let excerpt = truncate_chars(raw.trim(), PACK_README_MAX_CHARS);
251 if excerpt.is_empty() {
252 None
253 } else {
254 Some(ReadmePack { path, excerpt })
255 }
256 }
257
258 fn truncate_chars(value: &str, max_chars: usize) -> String {
259 if value.chars().count() <= max_chars {
260 return value.to_string();
261 }
262 value.chars().take(max_chars).collect::<String>()
263 }
264
265 fn is_config_file(path: &str) -> bool {
266 let lower = path.to_ascii_lowercase();
267 let name = lower.rsplit('/').next().unwrap_or(lower.as_str());
268 matches!(
269 name,
270 "cargo.toml"
271 | "package.json"
272 | "tsconfig.json"
273 | "pyproject.toml"
274 | "requirements.txt"
275 | "go.mod"
276 | "config.toml"
277 | "deepseek.toml"
278 | "dockerfile"
279 | "compose.yaml"
280 | "compose.yml"
281 | "docker-compose.yaml"
282 | "docker-compose.yml"
283 | "makefile"
284 ) || lower.ends_with(".config.js")
285 || lower.ends_with(".config.ts")
286 || lower.ends_with(".toml")
287 || lower.ends_with(".yaml")
288 || lower.ends_with(".yml")
289 }
290
291 fn is_source_file(path: &str) -> bool {
292 let lower = path.to_ascii_lowercase();
293 matches!(
294 lower.rsplit('.').next(),
295 Some(
296 "rs" | "py"
297 | "js"
298 | "jsx"
299 | "ts"
300 | "tsx"
301 | "go"
302 | "java"
303 | "kt"
304 | "c"
305 | "cc"
306 | "cpp"
307 | "h"
308 | "hpp"
309 | "cs"
310 | "rb"
311 | "php"
312 | "swift"
313 | "sql"
314 | "sh"
315 | "bash"
316 )
317 )
318 }
319
320 #[cfg(test)]
321 mod tests {
322 use super::*;
323 use tempfile::tempdir;
324
325 #[test]
326 fn project_context_pack_is_stable_and_sorted() {
327 let tmp = tempdir().expect("tempdir");
328 fs::write(tmp.path().join("README.md"), "# Demo\n\nReadme body").expect("write");
329 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"demo\"").expect("write");
330 fs::create_dir_all(tmp.path().join("src")).expect("mkdir src");
331 fs::write(tmp.path().join("src").join("z.rs"), "mod z;").expect("write z");
332 fs::write(tmp.path().join("src").join("a.rs"), "mod a;").expect("write a");
333 fs::create_dir_all(tmp.path().join("node_modules").join("pkg")).expect("mkdir ignored");
334 fs::write(
335 tmp.path().join("node_modules").join("pkg").join("index.js"),
336 "ignored",
337 )
338 .expect("write ignored");
339
340 let first = generate_project_context_pack(tmp.path()).expect("pack");
341 let second = generate_project_context_pack(tmp.path()).expect("pack again");
342
343 assert_eq!(first, second);
344 assert!(first.contains("\"project_name\""));
345 assert!(first.contains("\"directory_structure\""));
346 assert!(first.contains("\"README.md\""));
347 assert!(first.contains("\"Cargo.toml\""));
348 assert!(first.contains("\"src/a.rs\""));
349 assert!(first.contains("\"src/z.rs\""));
350 assert!(!first.contains("node_modules"));
351 assert!(
352 first.find("\"src/a.rs\"").expect("a before z")
353 < first.find("\"src/z.rs\"").expect("z")
354 );
355 }
356
357 #[test]
358 fn project_context_pack_ignores_agent_state_and_binary_noise() {
359 let tmp = tempdir().expect("tempdir");
360 fs::create_dir_all(tmp.path().join("src")).expect("mkdir src");
361 fs::write(tmp.path().join("src").join("main.rs"), "fn main() {}").expect("write src");
362 fs::write(tmp.path().join(".DS_Store"), "noise").expect("write ds store");
363 fs::write(tmp.path().join("paper.pdf"), "not a real pdf").expect("write pdf");
364 fs::create_dir_all(tmp.path().join(".codewhale").join("state")).expect("mkdir state");
365 fs::write(
366 tmp.path()
367 .join(".codewhale")
368 .join("state")
369 .join("subagents.v1.json"),
370 "{}",
371 )
372 .expect("write state");
373 fs::create_dir_all(tmp.path().join(".playwright-mcp")).expect("mkdir playwright");
374 fs::write(
375 tmp.path().join(".playwright-mcp").join("trace.log"),
376 "noise",
377 )
378 .expect("write log");
379 fs::create_dir_all(tmp.path().join(".agents").join("skills").join("demo"))
380 .expect("mkdir skills");
381 fs::write(
382 tmp.path()
383 .join(".agents")
384 .join("skills")
385 .join("demo")
386 .join("SKILL.md"),
387 "skill body",
388 )
389 .expect("write skill");
390 fs::create_dir_all(tmp.path().join(".github").join("workflows")).expect("mkdir workflows");
391 fs::write(
392 tmp.path().join(".github").join("workflows").join("ci.yml"),
393 "name: ci",
394 )
395 .expect("write workflow");
396
397 let pack = generate_project_context_pack(tmp.path()).expect("pack");
398
399 assert!(pack.contains("\"src/main.rs\""), "{pack}");
400 assert!(pack.contains("\".github/\""), "{pack}");
401 assert!(pack.contains("\".github/workflows/ci.yml\""), "{pack}");
402 assert!(!pack.contains(".deepseek"), "{pack}");
403 assert!(!pack.contains(".playwright-mcp"), "{pack}");
404 assert!(!pack.contains(".agents"), "{pack}");
405 assert!(!pack.contains(".DS_Store"), "{pack}");
406 assert!(!pack.contains("paper.pdf"), "{pack}");
407 assert!(!pack.contains("trace.log"), "{pack}");
408 }
409
410 #[test]
411 fn project_context_pack_keeps_later_top_level_dirs_under_budget() {
412 let tmp = tempdir().expect("tempdir");
413 let noisy = tmp.path().join("aaa-many-files");
414 fs::create_dir_all(&noisy).expect("mkdir noisy");
415 for i in 0..(PACK_MAX_ENTRIES + 20) {
416 fs::write(noisy.join(format!("file-{i:03}.rs")), "fn f() {}").expect("write noisy");
417 }
418 fs::create_dir_all(tmp.path().join("zzz-important")).expect("mkdir important");
419 fs::write(
420 tmp.path().join("zzz-important").join("main.rs"),
421 "fn important() {}",
422 )
423 .expect("write important");
424
425 let pack = generate_project_context_pack(tmp.path()).expect("pack");
426
427 assert!(
428 pack.contains("\"zzz-important/\""),
429 "breadth-first packing should keep later top-level directories visible:\n{pack}"
430 );
431 }
432
433 #[test]
434 fn project_context_pack_sort_is_cross_platform_and_priority_aware() {
435 let mut unix_paths = vec![
436 "src/z.rs".to_string(),
437 "docs/".to_string(),
438 "README.md".to_string(),
439 "Cargo.toml".to_string(),
440 "src/a.rs".to_string(),
441 "notes.txt".to_string(),
442 ];
443 let mut windows_paths = vec![
444 "src\\z.rs".to_string(),
445 "docs\\".to_string(),
446 "README.md".to_string(),
447 "Cargo.toml".to_string(),
448 "src\\a.rs".to_string(),
449 "notes.txt".to_string(),
450 ];
451
452 sort_pack_paths(&mut unix_paths);
453 sort_pack_paths(&mut windows_paths);
454
455 let normalized_windows = windows_paths
456 .iter()
457 .map(|path| path.replace('\\', "/"))
458 .collect::<Vec<_>>();
459 assert_eq!(unix_paths, normalized_windows);
460 assert_eq!(
461 unix_paths,
462 vec![
463 "README.md",
464 "Cargo.toml",
465 "src/a.rs",
466 "src/z.rs",
467 "docs/",
468 "notes.txt",
469 ]
470 );
471 }
472
473 #[test]
474 fn normalize_pack_relative_path_rejects_parent_segments() {
475 assert_eq!(
476 normalize_pack_relative_path(".\\src\\main.rs"),
477 Some("src/main.rs".to_string())
478 );
479 assert_eq!(normalize_pack_relative_path("../secret.txt"), None);
480 }
481 }
482
482 lines RUST