| 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(¤t_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 |