返回 CodeWhale
project_context.rs
根目录 / crates / tui / src / project_context.rs
1 //! Project context loading for Codewhale.
2 //!
3 //! This module handles loading project-specific context files that provide
4 //! instructions and context to the AI agent. These include:
5 //!
6 //! - `AGENTS.md` - Cross-agent project instructions (canonical, highest priority)
7 //! - `.claude/instructions.md` - Claude-style hidden instructions (compat)
8 //! - `CLAUDE.md` - Claude-style instructions (compat)
9 //! - `.codewhale/instructions.md` - Hidden instructions file (compat)
10 //! - `.deepseek/instructions.md` - Hidden instructions file (legacy)
11 //!
12 //! Codewhale-specific repo authority/prioritization policy lives separately in
13 //! `.codewhale/constitution.json` and is rendered as its own higher-authority
14 //! block. The loaded content is injected into the system prompt to give the
15 //! agent context about the project's conventions, structure, and requirements.
16
17 use std::collections::{BTreeMap, VecDeque};
18 use std::fs;
19 use std::io::Read;
20 use std::path::{Path, PathBuf};
21
22 use serde::{Deserialize, Serialize};
23 use thiserror::Error;
24
25 /// Names of project context files to look for, in priority order.
26 ///
27 /// `AGENTS.md` is the canonical cross-agent project-instructions file.
28 /// `WHALE.md` is no longer an active context surface; when present, Codewhale
29 /// reports a migration warning but ignores it. Codewhale-specific repo
30 /// authority now lives in `.codewhale/constitution.json`, not a bespoke
31 /// markdown file. `CLAUDE.md` and the `*/instructions.md` variants are
32 /// read-only compatibility fallbacks; Codewhale never creates or recommends
33 /// them.
34 const PROJECT_CONTEXT_FILES: &[&str] = &[
35 "AGENTS.md",
36 ".claude/instructions.md",
37 "CLAUDE.md",
38 ".codewhale/instructions.md",
39 ".deepseek/instructions.md",
40 ];
41
42 /// Rules directories auto-discovered at workspace level, in priority order.
43 /// `.codewhale/rules/` is Codewhale-native; `.claude/rules/` is Claude compatibility.
44 /// All `.md` files in these directories are loaded as project rules in filename order.
45 /// Security model: same trust class as AGENTS.md — workspace-contained content only,
46 /// no absolute-path escape. Does not require #417 project-config relaxation.
47 const RULES_DIRS: &[&str] = &[".codewhale/rules", ".claude/rules"];
48
49 /// File name of the deprecated Codewhale-native instructions file.
50 const DEPRECATED_WHALE_FILENAME: &str = "WHALE.md";
51
52 /// Warning surfaced when an ignored `WHALE.md` is present.
53 const WHALE_IGNORED_WARNING: &str = "WHALE.md is ignored; move project instructions to AGENTS.md, or Codewhale-specific authority policy to .codewhale/constitution.json.";
54
55 /// Relative path (within a workspace or one of its parents) to the
56 /// Codewhale-specific repo authority/prioritization policy.
57 const REPO_CONSTITUTION_RELATIVE_PATH: &[&str] = &[".codewhale", "constitution.json"];
58
59 /// `schema_version` understood by this build of the constitution loader.
60 const SUPPORTED_CONSTITUTION_SCHEMA: u32 = 1;
61
62 /// User-level project instructions loaded as a fallback when the workspace and
63 /// its parents do not define project context. Any global AGENTS.md takes
64 /// priority over a global instructions.md (#3012). Within each file name,
65 /// `.codewhale/` takes priority over vendor-neutral `.agents/`, which takes
66 /// priority over legacy `.deepseek/`. Global `WHALE.md` files are ignored and
67 /// reported as migration-only diagnostics.
68 const GLOBAL_AGENTS_RELATIVE_PATH: &[&str] = &[".codewhale", "AGENTS.md"];
69 const GLOBAL_AGENTS_VENDOR_NEUTRAL_PATH: &[&str] = &[".agents", "AGENTS.md"];
70 const GLOBAL_AGENTS_LEGACY_PATH: &[&str] = &[".deepseek", "AGENTS.md"];
71 const GLOBAL_WHALE_RELATIVE_PATH: &[&str] = &[".codewhale", "WHALE.md"];
72 const GLOBAL_WHALE_VENDOR_NEUTRAL_PATH: &[&str] = &[".agents", "WHALE.md"];
73 const GLOBAL_WHALE_LEGACY_PATH: &[&str] = &[".deepseek", "WHALE.md"];
74 /// Global `instructions.md` (#3012): auto-loaded as a fallback context layer,
75 /// ranked below AGENTS.md, mirroring the project-level precedence.
76 const GLOBAL_INSTRUCTIONS_RELATIVE_PATH: &[&str] = &[".codewhale", "instructions.md"];
77 const GLOBAL_INSTRUCTIONS_VENDOR_NEUTRAL_PATH: &[&str] = &[".agents", "instructions.md"];
78 const GLOBAL_INSTRUCTIONS_LEGACY_PATH: &[&str] = &[".deepseek", "instructions.md"];
79
80 /// Maximum size for project context files (to prevent loading huge files)
81 const MAX_CONTEXT_SIZE: usize = 100 * 1024; // 100KB
82
83 /// Maximum number of rule files loaded per rules directory.
84 /// Prevents a project from silently injecting hundreds of rule files.
85 const MAX_RULES_FILES: usize = 50;
86
87 /// Maximum total bytes across the assembled rules_block.
88 /// 50 files × 100 KB per file could reach ~5 MB; this caps the
89 /// cumulative injected content so a large rules directory can't
90 /// dominate the context window. Exceeded bytes are truncated with
91 /// an explicit marker.
92 const MAX_RULES_BLOCK_BYTES: usize = 500 * 1024; // 500 KB
93 const PACK_README_MAX_CHARS: usize = 4_000;
94 const PACK_MAX_ENTRIES: usize = 220;
95 const PACK_MAX_SOURCE_FILES: usize = 60;
96 const PACK_MAX_CONFIG_FILES: usize = 60;
97 const PACK_MAX_DEPTH: usize = 4;
98 const PACK_IGNORED_DIRS: &[&str] = &[
99 ".git",
100 ".worktrees",
101 "node_modules",
102 ".venv",
103 "venv",
104 "__pycache__",
105 "dist",
106 "build",
107 "target",
108 ".idea",
109 ".vscode",
110 ".pytest_cache",
111 ".DS_Store",
112 ];
113 const PACK_ALLOWED_HIDDEN_DIRS: &[&str] = &[".github"];
114 const PACK_ALLOWED_HIDDEN_FILES: &[&str] = &[".editorconfig", ".gitattributes", ".gitignore"];
115 const PACK_IGNORED_FILE_NAMES: &[&str] = &[".DS_Store"];
116 const PACK_IGNORED_FILE_EXTENSIONS: &[&str] = &[
117 "7z", "avif", "db", "gif", "gz", "ico", "jpeg", "jpg", "log", "mov", "mp3", "mp4", "pdf",
118 "png", "sqlite", "tar", "tgz", "wav", "webp", "zip",
119 ];
120
121 // === Errors ===
122
123 #[derive(Debug, Error)]
124 enum ProjectContextError {
125 #[error("Failed to read context metadata for {path}: {source}")]
126 Metadata {
127 path: PathBuf,
128 source: std::io::Error,
129 },
130 #[error("Refusing symlinked context file {path}")]
131 Symlink { path: PathBuf },
132 #[error("Context path {path} is not a regular file")]
133 NotFile { path: PathBuf },
134 #[error("Context file {path} is too large ({size} bytes, max {max})")]
135 TooLarge {
136 path: PathBuf,
137 size: u64,
138 max: usize,
139 },
140 #[error("Failed to read context file {path}: {source}")]
141 Read {
142 path: PathBuf,
143 source: std::io::Error,
144 },
145 #[error("Context file {path} is empty")]
146 Empty { path: PathBuf },
147 }
148
149 /// Result of loading project context
150 #[derive(Debug, Clone)]
151 pub struct ProjectContext {
152 /// The loaded instructions content
153 pub instructions: Option<String>,
154 /// Auto-discovered rules from `.codewhale/rules/` / `.claude/rules/`.
155 /// Kept separate from `instructions` so rules alone don't block
156 /// parent-directory AGENTS.md discovery via `has_instructions()`.
157 pub rules_block: Option<String>,
158 /// Path to the loaded file (for display)
159 pub source_path: Option<PathBuf>,
160 /// Any warnings during loading
161 pub warnings: Vec<String>,
162 /// Rendered `.codewhale/constitution.json` authority block, if present.
163 /// Codewhale-specific repo authority/prioritization policy — distinct from
164 /// the cross-agent prose in `instructions`.
165 pub constitution_block: Option<String>,
166 /// Path to the repo constitution file that produced `constitution_block`.
167 pub constitution_source_path: Option<PathBuf>,
168 /// Project root directory
169 #[allow(dead_code)] // Part of ProjectContext public interface
170 pub project_root: PathBuf,
171 /// Whether this is a trusted project
172 pub is_trusted: bool,
173 }
174
175 impl ProjectContext {
176 /// Create an empty project context
177 pub fn empty(project_root: PathBuf) -> Self {
178 Self {
179 instructions: None,
180 rules_block: None,
181 source_path: None,
182 warnings: Vec::new(),
183 constitution_block: None,
184 constitution_source_path: None,
185 project_root,
186 is_trusted: false,
187 }
188 }
189
190 /// Check if any instructions were loaded
191 pub fn has_instructions(&self) -> bool {
192 self.instructions.is_some()
193 }
194
195 /// Get the instructions as a formatted block for system prompt.
196 ///
197 /// The Codewhale repo constitution (`.codewhale/constitution.json`), when
198 /// present, is emitted first as a higher-authority block, followed by the
199 /// cross-agent `<project_instructions>` prose. Either may be absent.
200 pub fn as_system_block(&self) -> Option<String> {
201 let instructions_block = self.instructions.as_ref().map(|content| {
202 let source = self
203 .source_path
204 .as_ref()
205 .map_or_else(|| "project".to_string(), |p| p.display().to_string());
206
207 let mut block = format!(
208 "<project_instructions source=\"{source}\">\n{content}\n</project_instructions>"
209 );
210 // Append rules after instructions, inside the same logical block.
211 // Rules are kept separate from `instructions` so they don't block
212 // parent-directory AGENTS.md discovery via `has_instructions()`.
213 if let Some(rules) = &self.rules_block {
214 block.push('\n');
215 block.push_str(rules);
216 }
217 block
218 });
219
220 match (self.constitution_block.as_ref(), instructions_block) {
221 (Some(constitution), Some(instructions)) => {
222 Some(format!("{constitution}\n\n{instructions}"))
223 }
224 (Some(constitution), None) => {
225 // Constitution present but no main instructions — still emit rules if any
226 if let Some(rules) = &self.rules_block {
227 Some(format!("{constitution}\n\n{rules}"))
228 } else {
229 Some(constitution.clone())
230 }
231 }
232 (None, Some(instructions)) => Some(instructions),
233 (None, None) => {
234 // No main instructions, but rules may exist on their own
235 self.rules_block.clone()
236 }
237 }
238 }
239 }
240
241 /// Codewhale-specific repo authority/prioritization policy, loaded from
242 /// `.codewhale/constitution.json`. All fields are optional so a minimal file
243 /// (or a future schema) still parses; unknown fields are ignored.
244 #[derive(Debug, Clone, Default, Deserialize)]
245 struct RepoConstitution {
246 #[serde(default)]
247 schema_version: Option<u32>,
248 /// Ordered list of sources to trust when local sources conflict
249 /// (highest authority first).
250 #[serde(default)]
251 authority: Option<Vec<String>>,
252 /// Repo invariants the agent must not break. Plain strings are advisory
253 /// prose (rendered into the prompt only); object entries with `paths`
254 /// are additionally compiled into mechanical write holds (see
255 /// `crate::repo_law`). Law can only tighten — there is no allow shape.
256 #[serde(default)]
257 protected_invariants: Option<Vec<ProtectedInvariant>>,
258 /// Branch / release policy in effect (e.g. "PRs target codex/v0.8.53").
259 #[serde(default)]
260 branch_policy: Option<String>,
261 /// Conditions under which the agent should stop and escalate to the user.
262 #[serde(default)]
263 escalate_when: Option<Vec<String>>,
264 #[serde(default)]
265 verification_policy: Option<VerificationPolicy>,
266 }
267
268 #[derive(Debug, Clone, Default, Deserialize)]
269 struct VerificationPolicy {
270 /// Steps to perform before claiming a task is done.
271 #[serde(default)]
272 before_claiming_done: Option<Vec<String>>,
273 }
274
275 /// One protected invariant: either advisory prose (the historical shape) or
276 /// an enforced entry carrying path globs. Untagged so existing files keep
277 /// parsing unchanged.
278 #[derive(Debug, Clone, Deserialize)]
279 #[serde(untagged)]
280 enum ProtectedInvariant {
281 Advisory(String),
282 Enforced(EnforcedInvariant),
283 }
284
285 #[derive(Debug, Clone, Deserialize)]
286 struct EnforcedInvariant {
287 text: String,
288 /// Workspace-relative path globs this invariant protects (e.g.
289 /// `crates/protocol/**`). Empty means advisory-only despite the shape.
290 #[serde(default)]
291 paths: Vec<String>,
292 /// What the harness does when a write targets a protected path.
293 #[serde(default)]
294 action: RepoLawAction,
295 }
296
297 /// Enforcement level for a protected path. `Ask` force-prompts in
298 /// approval-gated postures and fails closed without a modal in Full Access;
299 /// `Block` denies outright in every posture.
300 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
301 #[serde(rename_all = "snake_case")]
302 pub(crate) enum RepoLawAction {
303 #[default]
304 Ask,
305 Block,
306 }
307
308 /// A compiled, mechanically-enforceable repo-law rule.
309 pub(crate) struct RepoLawRule {
310 pub(crate) text: String,
311 pub(crate) patterns: Vec<String>,
312 pub(crate) globs: globset::GlobSet,
313 pub(crate) action: RepoLawAction,
314 }
315
316 /// Load and compile the enforceable rules from the workspace's repo
317 /// constitution. Any failure — missing file, parse error, invalid glob —
318 /// degrades to fewer (or zero) rules: enforcement can silently do less,
319 /// never more, and never poisons the tool gate. Parse warnings still reach
320 /// the user through the prompt-side load path, which reads the same file.
321 pub(crate) fn load_repo_law_rules(workspace: &Path) -> Vec<RepoLawRule> {
322 let Some((_, constitution)) = discover_repo_constitution(workspace) else {
323 return Vec::new();
324 };
325 let mut rules = Vec::new();
326 for invariant in constitution.protected_invariants.into_iter().flatten() {
327 let ProtectedInvariant::Enforced(enforced) = invariant else {
328 continue;
329 };
330 if enforced.text.trim().is_empty() {
331 continue;
332 }
333 let mut builder = globset::GlobSetBuilder::new();
334 let mut patterns = Vec::new();
335 for pattern in &enforced.paths {
336 let trimmed = pattern.trim();
337 if trimmed.is_empty() {
338 continue;
339 }
340 if let Ok(glob) = globset::Glob::new(trimmed) {
341 builder.add(glob);
342 patterns.push(trimmed.to_string());
343 }
344 }
345 if patterns.is_empty() {
346 continue;
347 }
348 let Ok(globs) = builder.build() else {
349 continue;
350 };
351 rules.push(RepoLawRule {
352 text: enforced.text.trim().to_string(),
353 patterns,
354 globs,
355 action: enforced.action,
356 });
357 }
358 rules
359 }
360
361 /// Walk from `workspace` toward the git root looking for the repo
362 /// constitution; parse best-effort. Shared by the enforcement loader; the
363 /// prompt-side loader keeps its richer warning handling.
364 fn discover_repo_constitution(workspace: &Path) -> Option<(PathBuf, RepoConstitution)> {
365 let git_root = find_git_root(workspace);
366 let mut current = workspace.to_path_buf();
367 loop {
368 let mut path = current.clone();
369 for component in REPO_CONSTITUTION_RELATIVE_PATH {
370 path.push(component);
371 }
372 if context_candidate_exists(&path) {
373 let constitution = load_context_file(&path)
374 .ok()
375 .and_then(|raw| serde_json::from_str::<RepoConstitution>(&raw).ok())?;
376 return Some((path, constitution));
377 }
378 if let Some(ref root) = git_root
379 && current == *root
380 {
381 break;
382 }
383 match current.parent() {
384 Some(parent) if parent != current => current = parent.to_path_buf(),
385 _ => break,
386 }
387 }
388 None
389 }
390
391 impl RepoConstitution {
392 /// True when the file carried no usable policy (so we can skip emitting an
393 /// empty block).
394 fn is_empty(&self) -> bool {
395 let list_empty = |l: &Option<Vec<String>>| l.as_ref().is_none_or(Vec::is_empty);
396 list_empty(&self.authority)
397 && self.protected_invariants.as_ref().is_none_or(Vec::is_empty)
398 && list_empty(&self.escalate_when)
399 && self
400 .branch_policy
401 .as_ref()
402 .is_none_or(|s| s.trim().is_empty())
403 && self
404 .verification_policy
405 .as_ref()
406 .and_then(|p| p.before_claiming_done.as_ref())
407 .is_none_or(Vec::is_empty)
408 }
409
410 /// Render a model-facing authority block (concise prose, per the layered
411 /// model: base myth → global constitution → repo constitution = local law).
412 fn render_block(&self, source: &Path) -> String {
413 let mut body = String::new();
414 if let Some(authority) = self.authority.as_ref().filter(|a| !a.is_empty()) {
415 body.push_str(
416 "When local sources conflict, trust them in this order (highest first):\n",
417 );
418 for (idx, item) in authority.iter().enumerate() {
419 body.push_str(&format!("{}. {item}\n", idx + 1));
420 }
421 }
422 if let Some(invariants) = self.protected_invariants.as_ref().filter(|i| !i.is_empty()) {
423 body.push_str("\nProtected invariants — do not break:\n");
424 for item in invariants {
425 match item {
426 ProtectedInvariant::Advisory(text) => {
427 body.push_str(&format!("- {text}\n"));
428 }
429 ProtectedInvariant::Enforced(enforced) => {
430 let paths = enforced
431 .paths
432 .iter()
433 .map(String::as_str)
434 .collect::<Vec<_>>()
435 .join(", ");
436 if paths.is_empty() {
437 body.push_str(&format!("- {}\n", enforced.text));
438 } else {
439 body.push_str(&format!(
440 "- {} (mechanically enforced for: {paths})\n",
441 enforced.text
442 ));
443 }
444 }
445 }
446 }
447 }
448 if let Some(policy) = self.branch_policy.as_ref().filter(|s| !s.trim().is_empty()) {
449 body.push_str(&format!("\nBranch / release policy: {}\n", policy.trim()));
450 }
451 if let Some(steps) = self
452 .verification_policy
453 .as_ref()
454 .and_then(|p| p.before_claiming_done.as_ref())
455 .filter(|s| !s.is_empty())
456 {
457 body.push_str("\nBefore claiming a task is done:\n");
458 for step in steps {
459 body.push_str(&format!("- {step}\n"));
460 }
461 }
462 if let Some(conditions) = self.escalate_when.as_ref().filter(|c| !c.is_empty()) {
463 body.push_str("\nStop and escalate to the user when:\n");
464 for item in conditions {
465 body.push_str(&format!("- {item}\n"));
466 }
467 }
468 format!(
469 "<codewhale_repo_constitution source=\"{}\">\nCodewhale-specific repo authority policy (local law: subordinate to the global Constitution and the current user request, but above memory and old handoffs; WHALE.md is ignored and should be migrated, not treated as law).\n\n{}</codewhale_repo_constitution>",
470 source.display(),
471 body.trim_end()
472 )
473 }
474
475 fn policy_warnings(&self, source: &Path) -> Vec<String> {
476 let mut warnings = Vec::new();
477 if let Some(policy) = self.branch_policy.as_deref()
478 && branch_policy_looks_stale(policy)
479 {
480 warnings.push(format!(
481 "{} branch_policy appears stale: hard-coded release branch guidance (`{}`). Use live branch/handoff truth and AGENTS.md instead of versioned integration-lane text.",
482 source.display(),
483 policy.trim()
484 ));
485 }
486 warnings
487 }
488 }
489
490 fn branch_policy_looks_stale(policy: &str) -> bool {
491 let lower = policy.to_ascii_lowercase();
492 lower.contains("codex/v")
493 || ((lower.contains("integration branch") || lower.contains("not main"))
494 && contains_release_version_token(policy))
495 }
496
497 fn contains_release_version_token(value: &str) -> bool {
498 value
499 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '.'))
500 .any(|token| {
501 let token = token.trim_start_matches(['v', 'V']);
502 let mut parts = token.split('.');
503 matches!(
504 (parts.next(), parts.next(), parts.next(), parts.next()),
505 (Some(major), Some(minor), Some(patch), None)
506 if major.chars().all(|ch| ch.is_ascii_digit())
507 && minor.chars().all(|ch| ch.is_ascii_digit())
508 && patch.chars().all(|ch| ch.is_ascii_digit())
509 )
510 })
511 }
512
513 /// Discover and render `.codewhale/constitution.json` from `workspace` or, if
514 /// absent, its parent directories up to the git root. Returns the rendered
515 /// authority block plus any parse warnings.
516 fn load_repo_constitution_block(
517 workspace: &Path,
518 ) -> (Option<String>, Option<PathBuf>, Vec<String>) {
519 let mut warnings = Vec::new();
520 let git_root = find_git_root(workspace);
521 let mut current = workspace.to_path_buf();
522 loop {
523 let mut path = current.clone();
524 for component in REPO_CONSTITUTION_RELATIVE_PATH {
525 path.push(component);
526 }
527 if context_candidate_exists(&path) {
528 match load_context_file(&path) {
529 Ok(raw) => match serde_json::from_str::<RepoConstitution>(&raw) {
530 Ok(constitution) if !constitution.is_empty() => {
531 if let Some(version) = constitution.schema_version
532 && version != SUPPORTED_CONSTITUTION_SCHEMA
533 {
534 warnings.push(format!(
535 "{} declares schema_version {version}; this build supports {SUPPORTED_CONSTITUTION_SCHEMA}. Reading it on a best-effort basis.",
536 path.display()
537 ));
538 }
539 warnings.extend(constitution.policy_warnings(&path));
540 return (Some(constitution.render_block(&path)), Some(path), warnings);
541 }
542 Ok(_) => {
543 warnings.push(format!(
544 "{} has no authority/verification policy; ignoring.",
545 path.display()
546 ));
547 return (None, None, warnings);
548 }
549 Err(e) => {
550 warnings.push(format!("Failed to parse {}: {e}", path.display()));
551 return (None, None, warnings);
552 }
553 },
554 Err(e) => {
555 warnings.push(format!("Failed to read {}: {e}", path.display()));
556 return (None, None, warnings);
557 }
558 }
559 }
560 if let Some(ref root) = git_root
561 && current == *root
562 {
563 break;
564 }
565 match current.parent() {
566 Some(parent) if parent != current => current = parent.to_path_buf(),
567 _ => break,
568 }
569 }
570 (None, None, warnings)
571 }
572
573 #[derive(Debug, Serialize)]
574 struct ProjectContextPack {
575 project_name: String,
576 directory_structure: Vec<String>,
577 readme: Option<ReadmePack>,
578 config_files: Vec<String>,
579 key_source_files: Vec<String>,
580 counts: BTreeMap<String, usize>,
581 }
582
583 #[derive(Debug, Serialize)]
584 struct ReadmePack {
585 path: String,
586 excerpt: String,
587 }
588
589 /// Generate a deterministic, cache-friendly project context pack.
590 ///
591 /// The pack intentionally uses only stable workspace facts: relative paths,
592 /// sorted entries, bounded README text, and sorted JSON object fields. It does
593 /// not include timestamps, random ids, absolute temp paths, or live git state.
594 pub fn generate_project_context_pack(workspace: &Path) -> Option<String> {
595 let pack = build_project_context_pack(workspace)?;
596 let json = serde_json::to_string_pretty(&pack).ok()?;
597 Some(format!(
598 "## Project Context Pack\n\n<project_context_pack>\n{json}\n</project_context_pack>"
599 ))
600 }
601
602 fn generate_bounded_project_overview(workspace: &Path) -> Option<String> {
603 let pack = build_project_context_pack(workspace)?;
604 let json = serde_json::to_string_pretty(&pack).ok()?;
605 Some(format!(
606 "## Bounded Project Overview\n\n```json\n{json}\n```"
607 ))
608 }
609
610 fn build_project_context_pack(workspace: &Path) -> Option<ProjectContextPack> {
611 let mut entries = Vec::new();
612 collect_pack_entries(workspace, workspace, 0, &mut entries);
613 sort_pack_paths(&mut entries);
614 entries.truncate(PACK_MAX_ENTRIES);
615
616 let mut config_files = entries
617 .iter()
618 .filter(|path| is_config_file(path))
619 .take(PACK_MAX_CONFIG_FILES)
620 .cloned()
621 .collect::<Vec<_>>();
622 sort_pack_paths(&mut config_files);
623
624 let mut key_source_files = entries
625 .iter()
626 .filter(|path| is_source_file(path))
627 .take(PACK_MAX_SOURCE_FILES)
628 .cloned()
629 .collect::<Vec<_>>();
630 sort_pack_paths(&mut key_source_files);
631
632 let readme = read_readme_excerpt(workspace, &entries);
633 let mut counts = BTreeMap::new();
634 counts.insert("config_files".to_string(), config_files.len());
635 counts.insert("directory_entries".to_string(), entries.len());
636 counts.insert("key_source_files".to_string(), key_source_files.len());
637
638 Some(ProjectContextPack {
639 project_name: workspace
640 .file_name()
641 .and_then(|name| name.to_str())
642 .unwrap_or("workspace")
643 .to_string(),
644 directory_structure: entries,
645 readme,
646 config_files,
647 key_source_files,
648 counts,
649 })
650 }
651
652 fn collect_pack_entries(root: &Path, dir: &Path, depth: usize, out: &mut Vec<String>) {
653 if depth > PACK_MAX_DEPTH || out.len() >= PACK_MAX_ENTRIES {
654 return;
655 }
656
657 let mut queue = VecDeque::new();
658 queue.push_back((dir.to_path_buf(), depth));
659
660 while let Some((current_dir, current_depth)) = queue.pop_front() {
661 if current_depth > PACK_MAX_DEPTH || out.len() >= PACK_MAX_ENTRIES {
662 continue;
663 }
664
665 let Ok(read_dir) = fs::read_dir(&current_dir) else {
666 continue;
667 };
668 let mut children = read_dir.filter_map(Result::ok).collect::<Vec<_>>();
669 children.sort_by_key(|entry| entry.path());
670
671 for entry in children {
672 if out.len() >= PACK_MAX_ENTRIES {
673 break;
674 }
675 let path = entry.path();
676 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
677 continue;
678 };
679 let Ok(file_type) = entry.file_type() else {
680 continue;
681 };
682 if file_type.is_dir() && should_ignore_pack_dir(name) {
683 continue;
684 }
685 if file_type.is_file() && should_ignore_pack_file(name) {
686 continue;
687 }
688
689 if let Some(relative) = relative_slash_path(root, &path) {
690 if file_type.is_dir() {
691 out.push(format!("{relative}/"));
692 if current_depth < PACK_MAX_DEPTH {
693 queue.push_back((path, current_depth + 1));
694 }
695 } else if file_type.is_file() {
696 out.push(relative);
697 }
698 }
699 }
700 }
701 }
702
703 fn should_ignore_pack_dir(name: &str) -> bool {
704 PACK_IGNORED_DIRS.contains(&name)
705 || (name.starts_with('.') && !PACK_ALLOWED_HIDDEN_DIRS.contains(&name))
706 }
707
708 fn should_ignore_pack_file(name: &str) -> bool {
709 if name.starts_with('.') && !PACK_ALLOWED_HIDDEN_FILES.contains(&name) {
710 return true;
711 }
712 if PACK_IGNORED_FILE_NAMES.contains(&name) {
713 return true;
714 }
715 let Some((_, ext)) = name.rsplit_once('.') else {
716 return false;
717 };
718 PACK_IGNORED_FILE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())
719 }
720
721 fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
722 let relative = path.strip_prefix(root).ok()?;
723 let mut parts = Vec::new();
724 for component in relative.components() {
725 parts.push(component.as_os_str().to_string_lossy().to_string());
726 }
727 normalize_pack_relative_path(&parts.join("/"))
728 }
729
730 fn normalize_pack_relative_path(path: &str) -> Option<String> {
731 let normalized = path.replace('\\', "/");
732 let mut parts = Vec::new();
733 for part in normalized.split('/') {
734 if part.is_empty() || part == "." {
735 continue;
736 }
737 if part == ".." {
738 return None;
739 }
740 parts.push(part);
741 }
742 (!parts.is_empty()).then(|| parts.join("/"))
743 }
744
745 fn sort_pack_paths(paths: &mut [String]) {
746 paths.sort_by(|a, b| {
747 pack_path_priority(a)
748 .cmp(&pack_path_priority(b))
749 .then_with(|| pack_path_sort_key(a).cmp(&pack_path_sort_key(b)))
750 .then_with(|| a.cmp(b))
751 });
752 }
753
754 fn pack_path_sort_key(path: &str) -> String {
755 path.replace('\\', "/").to_ascii_lowercase()
756 }
757
758 fn pack_path_priority(path: &str) -> u8 {
759 let lower = pack_path_sort_key(path);
760 let name = lower.trim_end_matches('/').rsplit('/').next().unwrap_or("");
761 if matches!(name, "readme.md" | "readme.txt" | "readme") {
762 0
763 } else if is_config_file(&lower) {
764 1
765 } else if is_source_file(&lower) {
766 2
767 } else if lower.ends_with('/') {
768 3
769 } else {
770 4
771 }
772 }
773
774 fn read_readme_excerpt(workspace: &Path, entries: &[String]) -> Option<ReadmePack> {
775 let path = entries
776 .iter()
777 .find(|path| {
778 let lower = path.to_ascii_lowercase();
779 lower == "readme.md" || lower == "readme.txt" || lower == "readme"
780 })?
781 .clone();
782 let raw = fs::read_to_string(workspace.join(&path)).ok()?;
783 let excerpt = truncate_chars(raw.trim(), PACK_README_MAX_CHARS);
784 if excerpt.is_empty() {
785 None
786 } else {
787 Some(ReadmePack { path, excerpt })
788 }
789 }
790
791 fn truncate_chars(value: &str, max_chars: usize) -> String {
792 if value.chars().count() <= max_chars {
793 return value.to_string();
794 }
795 value.chars().take(max_chars).collect::<String>()
796 }
797
798 fn is_config_file(path: &str) -> bool {
799 let lower = path.to_ascii_lowercase();
800 let name = lower.rsplit('/').next().unwrap_or(lower.as_str());
801 matches!(
802 name,
803 "cargo.toml"
804 | "package.json"
805 | "tsconfig.json"
806 | "pyproject.toml"
807 | "requirements.txt"
808 | "go.mod"
809 | "config.toml"
810 | "deepseek.toml"
811 | "dockerfile"
812 | "compose.yaml"
813 | "compose.yml"
814 | "docker-compose.yaml"
815 | "docker-compose.yml"
816 | "makefile"
817 ) || lower.ends_with(".config.js")
818 || lower.ends_with(".config.ts")
819 || lower.ends_with(".toml")
820 || lower.ends_with(".yaml")
821 || lower.ends_with(".yml")
822 }
823
824 fn is_source_file(path: &str) -> bool {
825 let lower = path.to_ascii_lowercase();
826 matches!(
827 lower.rsplit('.').next(),
828 Some(
829 "rs" | "py"
830 | "js"
831 | "jsx"
832 | "ts"
833 | "tsx"
834 | "go"
835 | "java"
836 | "kt"
837 | "c"
838 | "cc"
839 | "cpp"
840 | "h"
841 | "hpp"
842 | "cs"
843 | "rb"
844 | "php"
845 | "swift"
846 | "sql"
847 | "sh"
848 | "bash"
849 )
850 )
851 }
852
853 /// Load project context from the workspace directory.
854 ///
855 /// This searches for known project context files and loads the first one found.
856 pub fn load_project_context(workspace: &Path) -> ProjectContext {
857 let mut ctx = ProjectContext::empty(workspace.to_path_buf());
858
859 // Search for active project context files.
860 for filename in PROJECT_CONTEXT_FILES {
861 let file_path = workspace.join(filename);
862
863 if context_candidate_exists(&file_path) {
864 match load_context_file(&file_path) {
865 Ok(content) => {
866 tracing::info!(
867 "Loaded project context from {} ({} bytes)",
868 file_path.display(),
869 content.len()
870 );
871 ctx.instructions = Some(content);
872 ctx.source_path = Some(file_path);
873 break;
874 }
875 Err(error) => {
876 ctx.warnings.push(error.to_string());
877 }
878 }
879 }
880 }
881
882 ctx.warnings
883 .extend(ignored_project_whale_warnings(workspace));
884
885 // Load rules from auto-discovered directories (.codewhale/rules/, .claude/rules/)
886 // Each rule file is wrapped in a <project_rule> block and appended after
887 // the main instructions content. Security model: same as AGENTS.md —
888 // workspace-contained content only, no absolute-path escape.
889 let mut rules_content = String::new();
890 for rules_dir in RULES_DIRS {
891 let rules = load_rules_from_dir(workspace, rules_dir);
892 for (path, content) in rules {
893 if !rules_content.is_empty() {
894 rules_content.push('\n');
895 }
896 rules_content.push_str(&format!(
897 "<project_rule source=\"{}\">\n{}\n</project_rule>",
898 path.display(),
899 content.trim()
900 ));
901 }
902 }
903
904 if !rules_content.is_empty() {
905 // Cap total rules bytes so a large rules dir can't dominate the context window
906 if rules_content.len() > MAX_RULES_BLOCK_BYTES {
907 let mut end = MAX_RULES_BLOCK_BYTES;
908 while !rules_content.is_char_boundary(end) {
909 end -= 1;
910 }
911 rules_content.truncate(end);
912 rules_content.push_str("\n\n[…rules block truncated at 500 KB…]");
913 tracing::warn!(
914 target: "project_context",
915 total_bytes = rules_content.len(),
916 cap = MAX_RULES_BLOCK_BYTES,
917 "Truncating rules block to total byte budget"
918 );
919 }
920 ctx.rules_block = Some(rules_content);
921 }
922
923 // Check for trust file
924 ctx.is_trusted = check_trust_status(workspace);
925
926 ctx
927 }
928
929 /// Load project context from parent directories as well.
930 ///
931 /// This allows for monorepo setups where a root AGENTS.md applies to all subdirectories.
932 pub fn load_project_context_with_parents(workspace: &Path) -> ProjectContext {
933 load_project_context_with_parents_cached_and_home(
934 workspace,
935 crate::config::effective_home_dir().as_deref(),
936 )
937 }
938
939 fn load_project_context_with_parents_cached_and_home(
940 workspace: &Path,
941 home_dir: Option<&Path>,
942 ) -> ProjectContext {
943 let workspace = canonicalize_workspace_or_keep(workspace);
944 let pre_load_key = crate::project_context_cache::compute_cache_key(&workspace, home_dir);
945 if let Some(ctx) = crate::project_context_cache::lookup(&pre_load_key) {
946 return ctx;
947 }
948
949 let ctx = load_project_context_with_parents_and_home(&workspace, home_dir);
950 let post_load_key = crate::project_context_cache::compute_cache_key(&workspace, home_dir);
951 crate::project_context_cache::store(post_load_key, ctx.clone());
952 ctx
953 }
954
955 fn load_project_context_with_parents_and_home(
956 workspace: &Path,
957 home_dir: Option<&Path>,
958 ) -> ProjectContext {
959 let workspace_canonical = canonicalize_workspace_or_keep(workspace);
960 let mut ctx = load_project_context(workspace);
961 let parent_search_stop = project_context_parent_search_stop_dir();
962
963 // If no context found in workspace, check parent directories
964 if !ctx.has_instructions() {
965 let mut current = workspace_canonical.parent();
966
967 while let Some(parent) = current {
968 if parent_search_stop
969 .as_deref()
970 .is_some_and(|stop| parent == stop)
971 {
972 break;
973 }
974
975 let parent_ctx = load_project_context(parent);
976 ctx.warnings.extend(parent_ctx.warnings.iter().cloned());
977 if parent_ctx.has_instructions() {
978 ctx.instructions = parent_ctx.instructions;
979 ctx.source_path = parent_ctx.source_path;
980 break;
981 }
982
983 current = parent.parent();
984 }
985 }
986
987 // Always check global instruction files so user-wide preferences
988 // travel into every session (#1157). When both global and project
989 // instructions exist, the global block prepends the project's so
990 // workspace overrides win the last word; when only global exists,
991 // it continues to serve as the fallback. `source_path` keeps
992 // pointing at the more-specific source (project > global) for
993 // display purposes.
994 if let Some(global_ctx) = load_global_agents_context(workspace, home_dir) {
995 ctx.warnings.extend(global_ctx.warnings.iter().cloned());
996 if let Some(global_text) = global_ctx.instructions {
997 match ctx.instructions.take() {
998 Some(project_text) => {
999 ctx.instructions = Some(merge_global_and_project_instructions(
1000 &global_text,
1001 global_ctx.source_path.as_deref(),
1002 &project_text,
1003 ));
1004 // Leave `ctx.source_path` pointing at the project /
1005 // parent file — that's the location the user might
1006 // want to edit when something looks wrong.
1007 }
1008 None => {
1009 ctx.instructions = Some(global_text);
1010 ctx.source_path = global_ctx.source_path;
1011 }
1012 }
1013 }
1014 }
1015
1016 // Generate a bounded in-memory fallback when no context file exists
1017 // anywhere. This keeps prompt shape stable without creating project-local
1018 // `.codewhale/` files merely because Codewhale was opened in a directory.
1019 if !ctx.has_instructions()
1020 && let Some(generated) = generate_ephemeral_context(workspace)
1021 {
1022 ctx.instructions = Some(generated);
1023 ctx.source_path = None;
1024 }
1025
1026 // Load the Codewhale-specific repo authority policy
1027 // (.codewhale/constitution.json) independently of the prose instructions —
1028 // it is a distinct, higher-authority artifact and may exist with or without
1029 // an AGENTS.md. Legacy WHALE.md files are ignored and reported as
1030 // migration-only diagnostics.
1031 // Loaded last so the auto-generate fallback above (which rebuilds `ctx`)
1032 // cannot clobber it.
1033 let (constitution_block, constitution_source_path, constitution_warnings) =
1034 load_repo_constitution_block(workspace);
1035 ctx.warnings.extend(constitution_warnings);
1036 ctx.constitution_block = constitution_block;
1037 ctx.constitution_source_path = constitution_source_path;
1038
1039 ctx
1040 }
1041
1042 pub(crate) fn project_context_cache_candidate_paths(
1043 workspace: &Path,
1044 home_dir: Option<&Path>,
1045 ) -> Vec<PathBuf> {
1046 let workspace = canonicalize_workspace_or_keep(workspace);
1047 let mut paths = Vec::new();
1048 let parent_search_stop = project_context_parent_search_stop_dir();
1049
1050 let mut current = Some(workspace.as_path());
1051 while let Some(dir) = current {
1052 if parent_search_stop
1053 .as_deref()
1054 .is_some_and(|stop| dir == stop)
1055 {
1056 break;
1057 }
1058
1059 for filename in PROJECT_CONTEXT_FILES {
1060 paths.push(dir.join(filename));
1061 }
1062 paths.push(dir.join(DEPRECATED_WHALE_FILENAME));
1063 current = dir.parent();
1064 }
1065
1066 if let Some(home) = home_dir {
1067 for candidate in global_context_relative_paths() {
1068 paths.push(join_relative_components(home, candidate));
1069 }
1070 for candidate in legacy_global_whale_relative_paths() {
1071 paths.push(join_relative_components(home, candidate));
1072 }
1073 }
1074
1075 paths.extend(repo_constitution_candidate_paths(&workspace));
1076 paths.push(workspace.join(".deepseek").join("trusted"));
1077 paths.push(workspace.join(".deepseek").join("trust.json"));
1078 paths.extend(crate::config::workspace_trust_config_candidate_paths());
1079
1080 // Include auto-discovered rules directory files so cache invalidates
1081 // when rules change (not just when AGENTS.md changes).
1082 for rules_dir in RULES_DIRS {
1083 let dir_path = workspace.join(rules_dir);
1084 // Skip symlinked rules directories (same guard as load_rules_from_dir)
1085 if fs::symlink_metadata(&dir_path)
1086 .map(|m| m.file_type().is_symlink())
1087 .unwrap_or(false)
1088 {
1089 continue;
1090 }
1091 if let Ok(entries) = std::fs::read_dir(&dir_path) {
1092 for entry in entries.flatten() {
1093 let path = entry.path();
1094 if path.extension().is_some_and(|ext| ext == "md") {
1095 paths.push(path);
1096 }
1097 }
1098 }
1099 }
1100
1101 paths
1102 }
1103
1104 fn repo_constitution_candidate_paths(workspace: &Path) -> Vec<PathBuf> {
1105 let git_root = find_git_root(workspace);
1106 let mut current = workspace.to_path_buf();
1107 let mut paths = Vec::new();
1108 loop {
1109 paths.push(join_relative_components(
1110 &current,
1111 REPO_CONSTITUTION_RELATIVE_PATH,
1112 ));
1113 if let Some(ref root) = git_root
1114 && current == *root
1115 {
1116 break;
1117 }
1118 match current.parent() {
1119 Some(parent) if parent != current => current = parent.to_path_buf(),
1120 _ => break,
1121 }
1122 }
1123 paths
1124 }
1125
1126 fn global_context_relative_paths() -> [&'static [&'static str]; 6] {
1127 [
1128 GLOBAL_AGENTS_RELATIVE_PATH,
1129 GLOBAL_AGENTS_VENDOR_NEUTRAL_PATH,
1130 GLOBAL_AGENTS_LEGACY_PATH,
1131 GLOBAL_INSTRUCTIONS_RELATIVE_PATH,
1132 GLOBAL_INSTRUCTIONS_VENDOR_NEUTRAL_PATH,
1133 GLOBAL_INSTRUCTIONS_LEGACY_PATH,
1134 ]
1135 }
1136
1137 fn legacy_global_whale_relative_paths() -> [&'static [&'static str]; 3] {
1138 [
1139 GLOBAL_WHALE_RELATIVE_PATH,
1140 GLOBAL_WHALE_VENDOR_NEUTRAL_PATH,
1141 GLOBAL_WHALE_LEGACY_PATH,
1142 ]
1143 }
1144
1145 fn join_relative_components(base: &Path, relative: &[&str]) -> PathBuf {
1146 let mut path = base.to_path_buf();
1147 for component in relative {
1148 path.push(component);
1149 }
1150 path
1151 }
1152
1153 fn ignored_project_whale_warnings(dir: &Path) -> Vec<String> {
1154 let path = dir.join(DEPRECATED_WHALE_FILENAME);
1155 ignored_whale_warning_for_path(&path).into_iter().collect()
1156 }
1157
1158 fn ignored_global_whale_warnings(home: &Path) -> Vec<String> {
1159 legacy_global_whale_relative_paths()
1160 .iter()
1161 .filter_map(|candidate| {
1162 let path = join_relative_components(home, candidate);
1163 ignored_whale_warning_for_path(&path)
1164 })
1165 .collect()
1166 }
1167
1168 fn ignored_whale_warning_for_path(path: &Path) -> Option<String> {
1169 context_candidate_exists(path)
1170 .then(|| format!("{WHALE_IGNORED_WARNING} Ignored file: {}", path.display()))
1171 }
1172
1173 fn canonicalize_workspace_or_keep(workspace: &Path) -> PathBuf {
1174 fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf())
1175 }
1176
1177 fn find_git_root(cwd: &Path) -> Option<PathBuf> {
1178 let mut current = cwd.to_path_buf();
1179 loop {
1180 if current.join(".git").exists() {
1181 return Some(current);
1182 }
1183 match current.parent() {
1184 Some(parent) if parent != current => {
1185 current = parent.to_path_buf();
1186 }
1187 _ => return None,
1188 }
1189 }
1190 }
1191
1192 fn project_context_parent_search_stop_dir() -> Option<PathBuf> {
1193 crate::config::effective_home_dir().map(|home| canonicalize_workspace_or_keep(&home))
1194 }
1195
1196 /// Combine global user-wide preferences with a project-local
1197 /// AGENTS.md/CLAUDE.md/instructions.md. Global comes first so
1198 /// workspace-specific rules can override it — the model reads in declared
1199 /// order. Each block is wrapped in a labelled fence so the model can tell
1200 /// which level any rule comes from when the two sets disagree (#1157).
1201 fn merge_global_and_project_instructions(
1202 global: &str,
1203 global_source: Option<&Path>,
1204 project: &str,
1205 ) -> String {
1206 let global_label = global_source
1207 .map(|p| format!("<!-- global: {} -->", p.display()))
1208 .unwrap_or_else(|| "<!-- global -->".to_string());
1209 format!(
1210 "{global_label}\n{}\n\n<!-- project (overrides global where they conflict) -->\n{}",
1211 global.trim_end(),
1212 project.trim_start(),
1213 )
1214 }
1215
1216 fn load_global_agents_context(workspace: &Path, home_dir: Option<&Path>) -> Option<ProjectContext> {
1217 let home = home_dir?;
1218
1219 // Priority order (AGENTS.md preferred; instructions.md next, #3012):
1220 // 1. ~/.codewhale/AGENTS.md (canonical)
1221 // 2. ~/.agents/AGENTS.md (vendor-neutral fallback)
1222 // 3. ~/.deepseek/AGENTS.md (legacy fallback)
1223 // 4. ~/.codewhale/instructions.md (canonical)
1224 // 5. ~/.agents/instructions.md (vendor-neutral fallback)
1225 // 6. ~/.deepseek/instructions.md (legacy fallback)
1226 // Global WHALE.md files are ignored and reported as migration-only
1227 // diagnostics, never loaded as fallback law.
1228 let mut warnings = ignored_global_whale_warnings(home);
1229
1230 for candidate in global_context_relative_paths() {
1231 let path = join_relative_components(home, candidate);
1232
1233 if context_candidate_exists(&path) {
1234 match load_context_file(&path) {
1235 Ok(content) => {
1236 let mut ctx = ProjectContext::empty(workspace.to_path_buf());
1237 ctx.instructions = Some(content);
1238 ctx.source_path = Some(path);
1239 ctx.warnings = warnings;
1240 return Some(ctx);
1241 }
1242 Err(error) => warnings.push(error.to_string()),
1243 }
1244 }
1245 }
1246
1247 if !warnings.is_empty() {
1248 let mut ctx = ProjectContext::empty(workspace.to_path_buf());
1249 ctx.warnings = warnings;
1250 return Some(ctx);
1251 }
1252
1253 None
1254 }
1255
1256 /// Generate ephemeral context from the project tree. Returns the generated
1257 /// content on success without writing workspace files.
1258 fn generate_ephemeral_context(workspace: &Path) -> Option<String> {
1259 let overview = generate_bounded_project_overview(workspace)?;
1260
1261 Some(format!(
1262 "# Project Context (Auto-generated, ephemeral)\n\n\
1263 > This context was generated in memory by Codewhale.\n\
1264 > No .codewhale/instructions.md file was written.\n\n\
1265 {overview}"
1266 ))
1267 }
1268
1269 /// Load a context file with size checking
1270 fn load_context_file(path: &Path) -> Result<String, ProjectContextError> {
1271 let metadata = fs::symlink_metadata(path).map_err(|source| ProjectContextError::Metadata {
1272 path: path.to_path_buf(),
1273 source,
1274 })?;
1275
1276 let file_type = metadata.file_type();
1277 if file_type.is_symlink() {
1278 return Err(ProjectContextError::Symlink {
1279 path: path.to_path_buf(),
1280 });
1281 }
1282
1283 if !file_type.is_file() {
1284 return Err(ProjectContextError::NotFile {
1285 path: path.to_path_buf(),
1286 });
1287 }
1288
1289 let mut file = open_context_file(path)?;
1290 let metadata = file
1291 .metadata()
1292 .map_err(|source| ProjectContextError::Metadata {
1293 path: path.to_path_buf(),
1294 source,
1295 })?;
1296 if metadata.len() > MAX_CONTEXT_SIZE as u64 {
1297 return Err(ProjectContextError::TooLarge {
1298 path: path.to_path_buf(),
1299 size: metadata.len(),
1300 max: MAX_CONTEXT_SIZE,
1301 });
1302 }
1303
1304 let mut content = String::new();
1305 file.read_to_string(&mut content)
1306 .map_err(|source| ProjectContextError::Read {
1307 path: path.to_path_buf(),
1308 source,
1309 })?;
1310
1311 // Basic validation
1312 if content.trim().is_empty() {
1313 return Err(ProjectContextError::Empty {
1314 path: path.to_path_buf(),
1315 });
1316 }
1317
1318 Ok(content)
1319 }
1320
1321 fn context_candidate_exists(path: &Path) -> bool {
1322 fs::symlink_metadata(path).is_ok_and(|metadata| {
1323 let file_type = metadata.file_type();
1324 file_type.is_file() || file_type.is_symlink()
1325 })
1326 }
1327
1328 /// Scan a rules directory for `.md` files and load them in filename order.
1329 /// Missing or unreadable directories return an empty vec (no error).
1330 /// Each file is verified through `load_context_file` (size check, symlink safety).
1331 fn load_rules_from_dir(workspace: &Path, rules_dir_name: &str) -> Vec<(PathBuf, String)> {
1332 let rules_dir = workspace.join(rules_dir_name);
1333 let mut entries: Vec<(PathBuf, String)> = Vec::new();
1334
1335 // Refuse a symlinked rules directory: the real .md files behind it
1336 // would pass per-file is_symlink checks and be read from outside the
1337 // workspace subtree — same escape class as #417.
1338 if fs::symlink_metadata(&rules_dir)
1339 .map(|m| m.file_type().is_symlink())
1340 .unwrap_or(false)
1341 {
1342 tracing::warn!(
1343 target: "project_context",
1344 dir = %rules_dir.display(),
1345 "Refusing symlinked rules directory"
1346 );
1347 return entries;
1348 }
1349
1350 let dir_iter = match fs::read_dir(&rules_dir) {
1351 Ok(iter) => iter,
1352 Err(_) => return entries,
1353 };
1354
1355 let mut file_paths: Vec<PathBuf> = Vec::new();
1356 for entry in dir_iter.flatten() {
1357 let path = entry.path();
1358 if path.extension().is_some_and(|ext| ext == "md") && context_candidate_exists(&path) {
1359 file_paths.push(path);
1360 }
1361 }
1362
1363 // Sort by filename for deterministic order
1364 file_paths.sort_by(|a, b| {
1365 a.file_name()
1366 .unwrap_or_default()
1367 .cmp(b.file_name().unwrap_or_default())
1368 });
1369
1370 // Enforce per-directory cap
1371 let total = file_paths.len();
1372 if total > MAX_RULES_FILES {
1373 tracing::warn!(
1374 target: "project_context",
1375 dir = %rules_dir.display(),
1376 total,
1377 cap = MAX_RULES_FILES,
1378 "Truncating rules directory to cap"
1379 );
1380 file_paths.truncate(MAX_RULES_FILES);
1381 }
1382
1383 for path in file_paths {
1384 match load_context_file(&path) {
1385 Ok(content) => {
1386 tracing::info!(
1387 "Loaded project rule from {} ({} bytes)",
1388 path.display(),
1389 content.len()
1390 );
1391 entries.push((path, content));
1392 }
1393 Err(error) => {
1394 tracing::warn!(
1395 target: "project_context",
1396 ?error,
1397 ?path,
1398 "Skipping unreadable rules file"
1399 );
1400 }
1401 }
1402 }
1403
1404 entries
1405 }
1406
1407 #[cfg(unix)]
1408 fn open_context_file(path: &Path) -> Result<fs::File, ProjectContextError> {
1409 use std::os::unix::fs::OpenOptionsExt;
1410
1411 fs::OpenOptions::new()
1412 .read(true)
1413 .custom_flags(libc::O_NOFOLLOW)
1414 .open(path)
1415 .map_err(|source| ProjectContextError::Read {
1416 path: path.to_path_buf(),
1417 source,
1418 })
1419 }
1420
1421 #[cfg(not(unix))]
1422 fn open_context_file(path: &Path) -> Result<fs::File, ProjectContextError> {
1423 fs::File::open(path).map_err(|source| ProjectContextError::Read {
1424 path: path.to_path_buf(),
1425 source,
1426 })
1427 }
1428
1429 /// Check if this project is marked as trusted
1430 fn check_trust_status(workspace: &Path) -> bool {
1431 if crate::config::is_workspace_trusted(workspace) {
1432 return true;
1433 }
1434
1435 // Check for trust markers
1436 let trust_markers = [
1437 workspace.join(".deepseek").join("trusted"),
1438 workspace.join(".deepseek").join("trust.json"),
1439 ];
1440
1441 for marker in &trust_markers {
1442 if marker.exists() {
1443 return true;
1444 }
1445 }
1446
1447 false
1448 }
1449
1450 /// Create a default AGENTS.md file for a project
1451 pub fn create_default_agents_md(workspace: &Path) -> std::io::Result<PathBuf> {
1452 let agents_path = workspace.join("AGENTS.md");
1453
1454 let default_content = r#"# Project Agent Instructions
1455
1456 This file provides guidance to AI agents (Codewhale, Claude Code, etc.) when working with code in this repository.
1457
1458 ## File Location
1459
1460 Save this file as `AGENTS.md` in your project root so the CLI can load it automatically.
1461
1462 ## Build and Development Commands
1463
1464 ```bash
1465 # Build
1466 # cargo build # Rust projects
1467 # npm run build # Node.js projects
1468 # python -m build # Python projects
1469
1470 # Test
1471 # cargo test # Rust
1472 # npm test # Node.js
1473 # pytest # Python
1474
1475 # Lint and Format
1476 # cargo fmt && cargo clippy # Rust
1477 # npm run lint # Node.js
1478 # ruff check . # Python
1479 ```
1480
1481 ## Architecture Overview
1482
1483 <!-- Describe your project's high-level architecture here -->
1484 <!-- Focus on the "big picture" that requires reading multiple files to understand -->
1485
1486 ### Key Components
1487
1488 <!-- List and describe the main components/modules -->
1489
1490 ### Data Flow
1491
1492 <!-- Describe how data flows through the system -->
1493
1494 ## Configuration Files
1495
1496 <!-- List important configuration files and their purposes -->
1497
1498 ## Extension Points
1499
1500 <!-- Describe how to extend the codebase (add new features, tools, etc.) -->
1501
1502 ## Commit Messages
1503
1504 Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`
1505 "#;
1506
1507 fs::write(&agents_path, default_content)?;
1508 Ok(agents_path)
1509 }
1510
1511 /// Merge multiple project contexts (e.g., from nested directories)
1512 #[allow(dead_code)] // Public API for monorepo context merging
1513 pub fn merge_contexts(contexts: &[ProjectContext]) -> Option<String> {
1514 let non_empty: Vec<_> = contexts
1515 .iter()
1516 .filter_map(ProjectContext::as_system_block)
1517 .collect();
1518
1519 if non_empty.is_empty() {
1520 None
1521 } else {
1522 Some(non_empty.join("\n\n"))
1523 }
1524 }
1525
1526 // === Unit Tests ===
1527
1528 #[cfg(test)]
1529 mod tests {
1530 use super::*;
1531 use tempfile::tempdir;
1532
1533 #[test]
1534 fn mixed_advisory_and_enforced_invariants_render_and_back_compat_holds() {
1535 let tmp = tempdir().expect("tempdir");
1536 let dir = tmp.path().join(".codewhale");
1537 fs::create_dir_all(&dir).expect("law dir");
1538 fs::write(
1539 dir.join("constitution.json"),
1540 r#"{
1541 "protected_invariants": [
1542 "Plain advisory prose.",
1543 { "text": "The wire format is frozen", "paths": ["crates/protocol/**"], "action": "block" }
1544 ]
1545 }"#,
1546 )
1547 .expect("write law");
1548
1549 let (block, path, warnings) = load_repo_constitution_block(tmp.path());
1550 let block = block.expect("law renders");
1551 assert!(path.is_some());
1552 assert!(warnings.is_empty(), "{warnings:?}");
1553 assert!(block.contains("- Plain advisory prose."), "{block}");
1554 assert!(
1555 block.contains(
1556 "- The wire format is frozen (mechanically enforced for: crates/protocol/**)"
1557 ),
1558 "{block}"
1559 );
1560
1561 // The enforcement loader compiles only the enforced entry.
1562 let rules = load_repo_law_rules(tmp.path());
1563 assert_eq!(rules.len(), 1);
1564 assert_eq!(rules[0].text, "The wire format is frozen");
1565 assert_eq!(rules[0].action, RepoLawAction::Block);
1566 assert!(rules[0].globs.is_match("crates/protocol/wire.rs"));
1567 }
1568
1569 #[test]
1570 fn legacy_string_only_invariants_render_unchanged_and_compile_nothing() {
1571 let tmp = tempdir().expect("tempdir");
1572 let dir = tmp.path().join(".codewhale");
1573 fs::create_dir_all(&dir).expect("law dir");
1574 fs::write(
1575 dir.join("constitution.json"),
1576 r#"{"protected_invariants": ["Keep DeepSeek support first-class."]}"#,
1577 )
1578 .expect("write law");
1579
1580 let (block, _, warnings) = load_repo_constitution_block(tmp.path());
1581 let block = block.expect("law renders");
1582 assert!(warnings.is_empty(), "{warnings:?}");
1583 assert!(
1584 block.contains("- Keep DeepSeek support first-class."),
1585 "{block}"
1586 );
1587 assert!(!block.contains("mechanically enforced"), "{block}");
1588 assert!(load_repo_law_rules(tmp.path()).is_empty());
1589 }
1590
1591 #[test]
1592 fn test_load_project_context_empty() {
1593 let tmp = tempdir().expect("tempdir");
1594 let ctx = load_project_context(tmp.path());
1595
1596 assert!(!ctx.has_instructions());
1597 assert!(ctx.source_path.is_none());
1598 }
1599
1600 #[test]
1601 fn test_load_project_context_agents_md() {
1602 let tmp = tempdir().expect("tempdir");
1603 let agents_path = tmp.path().join("AGENTS.md");
1604 fs::write(&agents_path, "# Test Instructions\n\nFollow these rules.").expect("write");
1605
1606 let ctx = load_project_context(tmp.path());
1607
1608 assert!(ctx.has_instructions());
1609 assert!(
1610 ctx.instructions
1611 .as_ref()
1612 .unwrap()
1613 .contains("Test Instructions")
1614 );
1615 assert_eq!(ctx.source_path, Some(agents_path));
1616 }
1617
1618 #[cfg(unix)]
1619 #[test]
1620 fn project_context_rejects_symlinked_agents_md() {
1621 let workspace = tempdir().expect("workspace tempdir");
1622 let outside = tempdir().expect("outside tempdir");
1623 let outside_agents = outside.path().join("AGENTS.md");
1624 fs::write(&outside_agents, "outside instructions").expect("write outside agents");
1625 std::os::unix::fs::symlink(&outside_agents, workspace.path().join("AGENTS.md"))
1626 .expect("symlink agents");
1627
1628 let ctx = load_project_context(workspace.path());
1629
1630 assert!(
1631 !ctx.has_instructions(),
1632 "symlinked project instructions must not be loaded: {:?}",
1633 ctx.instructions
1634 );
1635 assert!(
1636 ctx.warnings.iter().any(|w| w.contains("symlinked")),
1637 "expected symlink warning, got {:?}",
1638 ctx.warnings
1639 );
1640 }
1641
1642 #[test]
1643 fn test_load_project_context_priority() {
1644 let tmp = tempdir().expect("tempdir");
1645
1646 // Create both files - AGENTS.md should take priority
1647 fs::write(tmp.path().join("AGENTS.md"), "AGENTS content").expect("write");
1648 let claude_dir = tmp.path().join(".claude");
1649 fs::create_dir(&claude_dir).expect("mkdir");
1650 fs::write(claude_dir.join("instructions.md"), "CLAUDE content").expect("write");
1651
1652 let ctx = load_project_context(tmp.path());
1653
1654 assert!(ctx.has_instructions());
1655 assert!(
1656 ctx.instructions
1657 .as_ref()
1658 .unwrap()
1659 .contains("AGENTS content")
1660 );
1661 }
1662
1663 #[test]
1664 fn test_load_project_context_hidden_dir() {
1665 let tmp = tempdir().expect("tempdir");
1666 let hidden_dir = tmp.path().join(".deepseek");
1667 fs::create_dir(&hidden_dir).expect("mkdir");
1668 fs::write(hidden_dir.join("instructions.md"), "Hidden instructions").expect("write");
1669
1670 let ctx = load_project_context(tmp.path());
1671
1672 assert!(ctx.has_instructions());
1673 assert!(
1674 ctx.instructions
1675 .as_ref()
1676 .unwrap()
1677 .contains("Hidden instructions")
1678 );
1679 }
1680
1681 #[test]
1682 fn test_as_system_block() {
1683 let tmp = tempdir().expect("tempdir");
1684 let agents_path = tmp.path().join("AGENTS.md");
1685 fs::write(&agents_path, "Test content").expect("write");
1686
1687 let ctx = load_project_context(tmp.path());
1688 let block = ctx.as_system_block().expect("block");
1689
1690 assert!(block.contains("<project_instructions"));
1691 assert!(block.contains("Test content"));
1692 assert!(block.contains("</project_instructions>"));
1693 }
1694
1695 #[test]
1696 fn test_empty_file_warning() {
1697 let tmp = tempdir().expect("tempdir");
1698 let agents_path = tmp.path().join("AGENTS.md");
1699 fs::write(&agents_path, " \n \n ").expect("write"); // Only whitespace
1700
1701 let ctx = load_project_context(tmp.path());
1702
1703 assert!(!ctx.has_instructions());
1704 assert!(!ctx.warnings.is_empty());
1705 }
1706
1707 #[test]
1708 fn test_check_trust_status() {
1709 let tmp = tempdir().expect("tempdir");
1710
1711 // Not trusted by default
1712 assert!(!check_trust_status(tmp.path()));
1713
1714 // Create trust marker
1715 let deepseek_dir = tmp.path().join(".deepseek");
1716 fs::create_dir(&deepseek_dir).expect("mkdir");
1717 fs::write(deepseek_dir.join("trusted"), "").expect("write");
1718
1719 assert!(check_trust_status(tmp.path()));
1720 }
1721
1722 #[test]
1723 fn test_create_default_agents_md() {
1724 let tmp = tempdir().expect("tempdir");
1725 let path = create_default_agents_md(tmp.path()).expect("create");
1726
1727 assert!(path.exists());
1728 let content = fs::read_to_string(&path).expect("read");
1729 assert!(content.contains("Project Agent Instructions"));
1730 }
1731
1732 #[test]
1733 fn test_load_with_parents() {
1734 let tmp = tempdir().expect("tempdir");
1735
1736 // Create a nested structure
1737 let subdir = tmp.path().join("subproject");
1738 fs::create_dir(&subdir).expect("mkdir");
1739
1740 // Put AGENTS.md in parent
1741 fs::write(tmp.path().join("AGENTS.md"), "Parent instructions").expect("write");
1742 // Also create .git to mark as repo root
1743 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
1744
1745 // Load from subdir should find parent's AGENTS.md
1746 let ctx = load_project_context_with_parents(&subdir);
1747
1748 assert!(ctx.has_instructions());
1749 assert!(
1750 ctx.instructions
1751 .as_ref()
1752 .unwrap()
1753 .contains("Parent instructions")
1754 );
1755 }
1756
1757 #[test]
1758 fn test_merge_contexts() {
1759 let mut ctx1 = ProjectContext::empty(PathBuf::from("/a"));
1760 ctx1.instructions = Some("Instructions A".to_string());
1761 ctx1.source_path = Some(PathBuf::from("/a/AGENTS.md"));
1762
1763 let mut ctx2 = ProjectContext::empty(PathBuf::from("/b"));
1764 ctx2.instructions = Some("Instructions B".to_string());
1765 ctx2.source_path = Some(PathBuf::from("/b/AGENTS.md"));
1766
1767 let merged = merge_contexts(&[ctx1, ctx2]).expect("merge");
1768
1769 assert!(merged.contains("Instructions A"));
1770 assert!(merged.contains("Instructions B"));
1771 }
1772
1773 #[test]
1774 fn test_load_with_parents_searches_above_git_root_when_needed() {
1775 let tmp = tempdir().expect("tempdir");
1776
1777 // AGENTS.md exists above repository root.
1778 fs::write(tmp.path().join("AGENTS.md"), "Organization instructions").expect("write");
1779
1780 // Mark repository root one level below.
1781 let repo_root = tmp.path().join("repo");
1782 fs::create_dir(&repo_root).expect("mkdir repo");
1783 fs::create_dir(repo_root.join(".git")).expect("mkdir .git");
1784
1785 let workspace = repo_root.join("apps").join("client");
1786 fs::create_dir_all(&workspace).expect("mkdir workspace");
1787
1788 let ctx = load_project_context_with_parents(&workspace);
1789 assert!(ctx.has_instructions());
1790 assert!(
1791 ctx.instructions
1792 .as_ref()
1793 .unwrap()
1794 .contains("Organization instructions")
1795 );
1796 }
1797
1798 #[test]
1799 fn agents_md_used_while_whale_md_is_ignored() {
1800 let tmp = tempdir().expect("tempdir");
1801 fs::write(tmp.path().join("AGENTS.md"), "AGENTS canonical").expect("write agents");
1802 fs::write(tmp.path().join("WHALE.md"), "WHALE legacy").expect("write whale");
1803
1804 let ctx = load_project_context(tmp.path());
1805 let instructions = ctx.instructions.expect("instructions loaded");
1806 assert!(instructions.contains("AGENTS canonical"), "{instructions}");
1807 assert!(!instructions.contains("WHALE legacy"), "{instructions}");
1808 assert!(
1809 ctx.warnings
1810 .iter()
1811 .any(|w| w.contains("WHALE.md is ignored")),
1812 "{:?}",
1813 ctx.warnings
1814 );
1815 }
1816
1817 #[test]
1818 fn whale_md_alone_is_ignored_with_migration_warning() {
1819 let tmp = tempdir().expect("tempdir");
1820 fs::write(tmp.path().join("WHALE.md"), "WHALE legacy body").expect("write whale");
1821
1822 let ctx = load_project_context(tmp.path());
1823 assert!(
1824 ctx.instructions.is_none(),
1825 "legacy WHALE.md must not be read"
1826 );
1827 assert!(
1828 ctx.warnings
1829 .iter()
1830 .any(|w| w.contains("WHALE.md is ignored")),
1831 "expected ignored-file warning, got {:?}",
1832 ctx.warnings
1833 );
1834 }
1835
1836 #[test]
1837 fn constitution_json_renders_authority_block() {
1838 let tmp = tempdir().expect("tempdir");
1839 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
1840 fs::create_dir(tmp.path().join(".codewhale")).expect("mkdir .codewhale");
1841 fs::write(
1842 tmp.path().join(".codewhale").join("constitution.json"),
1843 r#"{
1844 "schema_version": 1,
1845 "authority": ["current user request", "live code and tests", "AGENTS.md"],
1846 "protected_invariants": ["keep the tool-catalog head byte-stable"],
1847 "branch_policy": "Start from live branch truth; open PRs into main",
1848 "verification_policy": { "before_claiming_done": ["run focused tests"] },
1849 "escalate_when": ["a destructive action was not authorized"]
1850 }"#,
1851 )
1852 .expect("write constitution");
1853
1854 let ctx = load_project_context_with_parents(tmp.path());
1855 let block = ctx
1856 .constitution_block
1857 .as_deref()
1858 .expect("constitution block rendered");
1859 assert!(block.contains("<codewhale_repo_constitution"));
1860 assert!(block.contains("current user request"));
1861 assert!(block.contains("run focused tests"));
1862 assert!(block.contains("keep the tool-catalog head byte-stable"));
1863 assert!(block.contains("Start from live branch truth"));
1864 assert!(block.contains("a destructive action was not authorized"));
1865 assert!(block.contains("WHALE.md is ignored and should be migrated"));
1866 assert!(
1867 ctx.constitution_source_path
1868 .as_ref()
1869 .is_some_and(|path| path.ends_with(".codewhale/constitution.json")),
1870 "constitution source path should be visible: {:?}",
1871 ctx.constitution_source_path
1872 );
1873 // It also surfaces through the system block.
1874 assert!(
1875 ctx.as_system_block()
1876 .expect("system block")
1877 .contains("codewhale_repo_constitution")
1878 );
1879 }
1880
1881 #[test]
1882 fn stale_constitution_branch_policy_warns() {
1883 let tmp = tempdir().expect("tempdir");
1884 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
1885 fs::create_dir(tmp.path().join(".codewhale")).expect("mkdir .codewhale");
1886 fs::write(
1887 tmp.path().join(".codewhale").join("constitution.json"),
1888 r#"{
1889 "schema_version": 1,
1890 "authority": ["current user request"],
1891 "branch_policy": "v0.8.53 work targets the codex/v0.8.53 integration branch, not main"
1892 }"#,
1893 )
1894 .expect("write constitution");
1895
1896 let ctx = load_project_context_with_parents(tmp.path());
1897 assert!(
1898 ctx.constitution_block.is_some(),
1899 "stale policy should warn but still render"
1900 );
1901 assert!(
1902 ctx.warnings
1903 .iter()
1904 .any(|warning| warning.contains("branch_policy appears stale")),
1905 "expected stale branch_policy warning, got {:?}",
1906 ctx.warnings
1907 );
1908 }
1909
1910 #[test]
1911 fn repository_constitution_avoids_hard_coded_release_lane_policy() {
1912 let repo_constitution = Path::new(env!("CARGO_MANIFEST_DIR"))
1913 .join("../..")
1914 .join(".codewhale")
1915 .join("constitution.json");
1916 let raw = fs::read_to_string(&repo_constitution).expect("read repo constitution");
1917 let constitution: RepoConstitution =
1918 serde_json::from_str(&raw).expect("parse repo constitution");
1919 let warnings = constitution.policy_warnings(&repo_constitution);
1920 assert!(
1921 warnings.is_empty(),
1922 "repo constitution should not carry stale release-lane policy: {:?}",
1923 warnings
1924 );
1925 }
1926
1927 #[test]
1928 fn malformed_constitution_warns_without_crashing() {
1929 let tmp = tempdir().expect("tempdir");
1930 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
1931 fs::create_dir(tmp.path().join(".codewhale")).expect("mkdir .codewhale");
1932 fs::write(
1933 tmp.path().join(".codewhale").join("constitution.json"),
1934 "{ not valid json",
1935 )
1936 .expect("write bad constitution");
1937
1938 let ctx = load_project_context_with_parents(tmp.path());
1939 assert!(
1940 ctx.constitution_block.is_none(),
1941 "no block for invalid JSON"
1942 );
1943 assert!(
1944 ctx.warnings.iter().any(|w| w.contains("Failed to parse")),
1945 "expected parse warning, got {:?}",
1946 ctx.warnings
1947 );
1948 }
1949
1950 #[cfg(unix)]
1951 #[test]
1952 fn constitution_json_rejects_symlinked_file() {
1953 let workspace = tempdir().expect("workspace tempdir");
1954 let outside = tempdir().expect("outside tempdir");
1955 fs::create_dir(workspace.path().join(".git")).expect("mkdir .git");
1956 fs::create_dir(workspace.path().join(".codewhale")).expect("mkdir .codewhale");
1957 let outside_constitution = outside.path().join("constitution.json");
1958 fs::write(
1959 &outside_constitution,
1960 r#"{"schema_version":1,"authority":["outside authority"]}"#,
1961 )
1962 .expect("write outside constitution");
1963 std::os::unix::fs::symlink(
1964 &outside_constitution,
1965 workspace
1966 .path()
1967 .join(".codewhale")
1968 .join("constitution.json"),
1969 )
1970 .expect("symlink constitution");
1971
1972 let ctx =
1973 load_project_context_with_parents_and_home(workspace.path(), Some(outside.path()));
1974
1975 assert!(
1976 ctx.constitution_block.is_none(),
1977 "symlinked constitution must not be loaded: {:?}",
1978 ctx.constitution_block
1979 );
1980 assert!(
1981 !ctx.as_system_block()
1982 .unwrap_or_default()
1983 .contains("outside authority"),
1984 "symlink target content must not reach the system block"
1985 );
1986 assert!(
1987 ctx.warnings.iter().any(|w| w.contains("symlinked")),
1988 "expected symlink warning, got {:?}",
1989 ctx.warnings
1990 );
1991 }
1992
1993 #[test]
1994 fn project_context_pack_is_stable_and_sorted() {
1995 let tmp = tempdir().expect("tempdir");
1996 fs::write(tmp.path().join("README.md"), "# Demo\n\nReadme body").expect("write");
1997 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"demo\"").expect("write");
1998 fs::create_dir_all(tmp.path().join("src")).expect("mkdir src");
1999 fs::write(tmp.path().join("src").join("z.rs"), "mod z;").expect("write z");
2000 fs::write(tmp.path().join("src").join("a.rs"), "mod a;").expect("write a");
2001 fs::create_dir_all(tmp.path().join("node_modules").join("pkg")).expect("mkdir ignored");
2002 fs::write(
2003 tmp.path().join("node_modules").join("pkg").join("index.js"),
2004 "ignored",
2005 )
2006 .expect("write ignored");
2007
2008 let first = generate_project_context_pack(tmp.path()).expect("pack");
2009 let second = generate_project_context_pack(tmp.path()).expect("pack again");
2010
2011 assert_eq!(first, second);
2012 assert!(first.contains("\"project_name\""));
2013 assert!(first.contains("\"directory_structure\""));
2014 assert!(first.contains("\"README.md\""));
2015 assert!(first.contains("\"Cargo.toml\""));
2016 assert!(first.contains("\"src/a.rs\""));
2017 assert!(first.contains("\"src/z.rs\""));
2018 assert!(!first.contains("node_modules"));
2019 assert!(
2020 first.find("\"src/a.rs\"").expect("a before z")
2021 < first.find("\"src/z.rs\"").expect("z")
2022 );
2023 }
2024
2025 #[test]
2026 fn project_context_pack_ignores_agent_state_and_binary_noise() {
2027 let tmp = tempdir().expect("tempdir");
2028 fs::create_dir_all(tmp.path().join("src")).expect("mkdir src");
2029 fs::write(tmp.path().join("src").join("main.rs"), "fn main() {}").expect("write src");
2030 fs::write(tmp.path().join(".DS_Store"), "noise").expect("write ds store");
2031 fs::write(tmp.path().join("paper.pdf"), "not a real pdf").expect("write pdf");
2032 fs::create_dir_all(tmp.path().join(".codewhale").join("state")).expect("mkdir state");
2033 fs::write(
2034 tmp.path()
2035 .join(".codewhale")
2036 .join("state")
2037 .join("subagents.v1.json"),
2038 "{}",
2039 )
2040 .expect("write state");
2041 fs::create_dir_all(tmp.path().join(".playwright-mcp")).expect("mkdir playwright");
2042 fs::write(
2043 tmp.path().join(".playwright-mcp").join("trace.log"),
2044 "noise",
2045 )
2046 .expect("write log");
2047 fs::create_dir_all(tmp.path().join(".agents").join("skills").join("demo"))
2048 .expect("mkdir skills");
2049 fs::write(
2050 tmp.path()
2051 .join(".agents")
2052 .join("skills")
2053 .join("demo")
2054 .join("SKILL.md"),
2055 "skill body",
2056 )
2057 .expect("write skill");
2058 fs::create_dir_all(tmp.path().join(".github").join("workflows")).expect("mkdir workflows");
2059 fs::write(
2060 tmp.path().join(".github").join("workflows").join("ci.yml"),
2061 "name: ci",
2062 )
2063 .expect("write workflow");
2064
2065 let pack = generate_project_context_pack(tmp.path()).expect("pack");
2066
2067 assert!(pack.contains("\"src/main.rs\""), "{pack}");
2068 assert!(pack.contains("\".github/\""), "{pack}");
2069 assert!(pack.contains("\".github/workflows/ci.yml\""), "{pack}");
2070 assert!(!pack.contains(".deepseek"), "{pack}");
2071 assert!(!pack.contains(".playwright-mcp"), "{pack}");
2072 assert!(!pack.contains(".agents"), "{pack}");
2073 assert!(!pack.contains(".DS_Store"), "{pack}");
2074 assert!(!pack.contains("paper.pdf"), "{pack}");
2075 assert!(!pack.contains("trace.log"), "{pack}");
2076 }
2077
2078 #[test]
2079 fn project_context_pack_keeps_later_top_level_dirs_under_budget() {
2080 let tmp = tempdir().expect("tempdir");
2081 let noisy = tmp.path().join("aaa-many-files");
2082 fs::create_dir_all(&noisy).expect("mkdir noisy");
2083 for i in 0..(PACK_MAX_ENTRIES + 20) {
2084 fs::write(noisy.join(format!("file-{i:03}.rs")), "fn f() {}").expect("write noisy");
2085 }
2086 fs::create_dir_all(tmp.path().join("zzz-important")).expect("mkdir important");
2087 fs::write(
2088 tmp.path().join("zzz-important").join("main.rs"),
2089 "fn important() {}",
2090 )
2091 .expect("write important");
2092
2093 let pack = generate_project_context_pack(tmp.path()).expect("pack");
2094
2095 assert!(
2096 pack.contains("\"zzz-important/\""),
2097 "breadth-first packing should keep later top-level directories visible:\n{pack}"
2098 );
2099 }
2100
2101 #[test]
2102 fn generated_context_is_bounded_and_ephemeral_for_many_file_workspace() {
2103 let workspace = tempdir().expect("workspace tempdir");
2104 let home = tempdir().expect("home tempdir");
2105 let noisy = workspace.path().join("aaa-many-files");
2106 fs::create_dir_all(&noisy).expect("mkdir noisy");
2107 for i in 0..1000 {
2108 fs::write(noisy.join(format!("file-{i:04}.rs")), "fn noisy() {}").expect("write noisy");
2109 }
2110 fs::create_dir_all(workspace.path().join("zzz-important")).expect("mkdir important");
2111 fs::write(
2112 workspace.path().join("zzz-important").join("main.rs"),
2113 "fn important() {}",
2114 )
2115 .expect("write important");
2116
2117 let start = std::time::Instant::now();
2118 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2119 let elapsed = start.elapsed();
2120 assert!(
2121 elapsed < std::time::Duration::from_secs(2),
2122 "auto-generated context should stay bounded, took {elapsed:?}"
2123 );
2124 assert!(ctx.has_instructions());
2125
2126 let generated_path = workspace.path().join(".codewhale").join("instructions.md");
2127 assert_eq!(ctx.source_path, None);
2128 assert!(
2129 !generated_path.exists(),
2130 "generated project context should stay ephemeral"
2131 );
2132 assert!(
2133 !workspace.path().join(".codewhale").exists(),
2134 "loading context should not create a .codewhale directory"
2135 );
2136 let generated = ctx.instructions.as_ref().expect("generated instructions");
2137 assert!(generated.contains("Project Context (Auto-generated, ephemeral)"));
2138 assert!(generated.contains("Bounded Project Overview"));
2139 assert!(!generated.contains("<project_context_pack>"));
2140 assert!(
2141 generated.contains("\"zzz-important/\""),
2142 "later top-level project areas should remain visible:\n{generated}"
2143 );
2144 let noisy_count = generated.matches("aaa-many-files/file-").count();
2145 assert!(
2146 noisy_count < 300,
2147 "generated context should not list the whole noisy directory; saw {noisy_count}"
2148 );
2149 assert!(
2150 !generated.contains("file-0999.rs"),
2151 "bounded context should omit the tail of the noisy directory"
2152 );
2153 }
2154
2155 #[test]
2156 fn cached_context_reflects_overwritten_agents_md() {
2157 crate::project_context_cache::clear();
2158 let workspace = tempdir().expect("workspace tempdir");
2159 let home = tempdir().expect("home tempdir");
2160 let agents = workspace.path().join("AGENTS.md");
2161 fs::write(&agents, "alpha").expect("write alpha");
2162
2163 let first =
2164 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2165 assert!(
2166 first
2167 .instructions
2168 .as_deref()
2169 .is_some_and(|s| s.contains("alpha")),
2170 "expected alpha instructions: {:?}",
2171 first.instructions
2172 );
2173
2174 fs::write(&agents, "bravo").expect("write bravo");
2175 let second =
2176 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2177
2178 assert!(
2179 second
2180 .instructions
2181 .as_deref()
2182 .is_some_and(|s| s.contains("bravo")),
2183 "cache must invalidate on same-length content overwrite: {:?}",
2184 second.instructions
2185 );
2186 }
2187
2188 #[test]
2189 fn cached_context_reflects_constitution_json_change() {
2190 crate::project_context_cache::clear();
2191 let workspace = tempdir().expect("workspace tempdir");
2192 let home = tempdir().expect("home tempdir");
2193 fs::create_dir(workspace.path().join(".git")).expect("mkdir git");
2194 fs::create_dir(workspace.path().join(".codewhale")).expect("mkdir codewhale");
2195 let constitution = workspace
2196 .path()
2197 .join(".codewhale")
2198 .join("constitution.json");
2199 fs::write(
2200 &constitution,
2201 r#"{"schema_version":1,"authority":["alpha authority"]}"#,
2202 )
2203 .expect("write alpha constitution");
2204
2205 let first =
2206 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2207 assert!(
2208 first
2209 .constitution_block
2210 .as_deref()
2211 .is_some_and(|s| s.contains("alpha authority")),
2212 "expected alpha constitution block: {:?}",
2213 first.constitution_block
2214 );
2215
2216 fs::write(
2217 &constitution,
2218 r#"{"schema_version":1,"authority":["bravo authority"]}"#,
2219 )
2220 .expect("write bravo constitution");
2221 let second =
2222 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2223
2224 assert!(
2225 second
2226 .constitution_block
2227 .as_deref()
2228 .is_some_and(|s| s.contains("bravo authority")),
2229 "cache must invalidate when constitution changes: {:?}",
2230 second.constitution_block
2231 );
2232 }
2233
2234 #[test]
2235 fn cached_generated_context_stays_ephemeral() {
2236 crate::project_context_cache::clear();
2237 let workspace = tempdir().expect("workspace tempdir");
2238 let home = tempdir().expect("home tempdir");
2239
2240 let first =
2241 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2242 assert!(first.has_instructions());
2243 let generated_path = workspace.path().join(".codewhale").join("instructions.md");
2244 assert!(
2245 !generated_path.exists(),
2246 "first load should not write generated instructions"
2247 );
2248
2249 let second =
2250 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2251 assert!(second.has_instructions());
2252 assert!(
2253 !generated_path.exists(),
2254 "cached generated context should remain in memory-only state"
2255 );
2256 }
2257
2258 #[test]
2259 fn cached_context_reflects_trust_marker_created() {
2260 crate::project_context_cache::clear();
2261 let workspace = tempdir().expect("workspace tempdir");
2262 let home = tempdir().expect("home tempdir");
2263 fs::write(workspace.path().join("AGENTS.md"), "instructions").expect("write agents");
2264
2265 let first =
2266 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2267 assert!(!first.is_trusted);
2268
2269 let trust_dir = workspace.path().join(".deepseek");
2270 fs::create_dir(&trust_dir).expect("mkdir trust dir");
2271 fs::write(trust_dir.join("trusted"), "").expect("write trust marker");
2272
2273 let second =
2274 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2275 assert!(
2276 second.is_trusted,
2277 "cache must invalidate when trust marker appears"
2278 );
2279 }
2280
2281 #[test]
2282 fn project_context_pack_sort_is_cross_platform_and_priority_aware() {
2283 let mut unix_paths = vec![
2284 "src/z.rs".to_string(),
2285 "docs/".to_string(),
2286 "README.md".to_string(),
2287 "Cargo.toml".to_string(),
2288 "src/a.rs".to_string(),
2289 "notes.txt".to_string(),
2290 ];
2291 let mut windows_paths = vec![
2292 "src\\z.rs".to_string(),
2293 "docs\\".to_string(),
2294 "README.md".to_string(),
2295 "Cargo.toml".to_string(),
2296 "src\\a.rs".to_string(),
2297 "notes.txt".to_string(),
2298 ];
2299
2300 sort_pack_paths(&mut unix_paths);
2301 sort_pack_paths(&mut windows_paths);
2302
2303 let normalized_windows = windows_paths
2304 .iter()
2305 .map(|path| path.replace('\\', "/"))
2306 .collect::<Vec<_>>();
2307 assert_eq!(unix_paths, normalized_windows);
2308 assert_eq!(
2309 unix_paths,
2310 vec![
2311 "README.md",
2312 "Cargo.toml",
2313 "src/a.rs",
2314 "src/z.rs",
2315 "docs/",
2316 "notes.txt",
2317 ]
2318 );
2319 }
2320
2321 #[test]
2322 fn normalize_pack_relative_path_rejects_parent_segments() {
2323 assert_eq!(
2324 normalize_pack_relative_path(".\\src\\main.rs"),
2325 Some("src/main.rs".to_string())
2326 );
2327 assert_eq!(normalize_pack_relative_path("../secret.txt"), None);
2328 }
2329
2330 #[test]
2331 fn test_load_global_agents_when_project_has_no_context() {
2332 let workspace = tempdir().expect("workspace tempdir");
2333 let home = tempdir().expect("home tempdir");
2334 let global_dir = home.path().join(".deepseek");
2335 fs::create_dir(&global_dir).expect("mkdir .deepseek");
2336 let global_agents = global_dir.join("AGENTS.md");
2337 fs::write(&global_agents, "Global instructions").expect("write global agents");
2338
2339 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2340
2341 assert!(ctx.has_instructions());
2342 assert!(
2343 ctx.instructions
2344 .as_ref()
2345 .unwrap()
2346 .contains("Global instructions")
2347 );
2348 assert_eq!(ctx.source_path, Some(global_agents));
2349 }
2350
2351 #[test]
2352 fn test_load_global_agents_falls_back_to_vendor_neutral_path() {
2353 let workspace = tempdir().expect("workspace tempdir");
2354 let home = tempdir().expect("home tempdir");
2355 let global_dir = home.path().join(".agents");
2356 fs::create_dir(&global_dir).expect("mkdir .agents");
2357 let global_agents = global_dir.join("AGENTS.md");
2358 fs::write(&global_agents, "Vendor-neutral instructions").expect("write global agents");
2359
2360 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2361
2362 assert!(ctx.has_instructions());
2363 assert!(
2364 ctx.instructions
2365 .as_ref()
2366 .unwrap()
2367 .contains("Vendor-neutral instructions")
2368 );
2369 assert_eq!(ctx.source_path, Some(global_agents));
2370 }
2371
2372 #[test]
2373 fn test_codewhale_specific_path_wins_over_agents_path() {
2374 let workspace = tempdir().expect("workspace tempdir");
2375 let home = tempdir().expect("home tempdir");
2376
2377 let codewhale_dir = home.path().join(".codewhale");
2378 fs::create_dir(&codewhale_dir).expect("mkdir .codewhale");
2379 let codewhale_agents = codewhale_dir.join("AGENTS.md");
2380 fs::write(&codewhale_agents, "Codewhale-specific instructions")
2381 .expect("write codewhale agents");
2382
2383 let agents_dir = home.path().join(".agents");
2384 fs::create_dir(&agents_dir).expect("mkdir .agents");
2385 fs::write(agents_dir.join("AGENTS.md"), "Vendor-neutral instructions")
2386 .expect("write vendor-neutral agents");
2387
2388 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2389
2390 assert!(ctx.has_instructions());
2391 let instructions = ctx.instructions.as_ref().unwrap();
2392 assert!(
2393 instructions.contains("Codewhale-specific instructions"),
2394 "Codewhale-specific global file should win:\n{instructions}"
2395 );
2396 assert!(
2397 !instructions.contains("Vendor-neutral instructions"),
2398 "lower-priority .agents file should be skipped:\n{instructions}"
2399 );
2400 assert_eq!(ctx.source_path, Some(codewhale_agents));
2401 }
2402
2403 #[test]
2404 fn test_global_agents_wins_over_global_whale_across_paths() {
2405 let workspace = tempdir().expect("workspace tempdir");
2406 let home = tempdir().expect("home tempdir");
2407
2408 let codewhale_dir = home.path().join(".codewhale");
2409 fs::create_dir(&codewhale_dir).expect("mkdir .codewhale");
2410 fs::write(codewhale_dir.join("WHALE.md"), "Global WHALE legacy")
2411 .expect("write codewhale whale");
2412
2413 let agents_dir = home.path().join(".agents");
2414 fs::create_dir(&agents_dir).expect("mkdir .agents");
2415 let global_agents = agents_dir.join("AGENTS.md");
2416 fs::write(&global_agents, "Global AGENTS canonical").expect("write global agents");
2417
2418 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2419
2420 assert!(ctx.has_instructions());
2421 let instructions = ctx.instructions.as_ref().unwrap();
2422 assert!(
2423 instructions.contains("Global AGENTS canonical"),
2424 "global AGENTS.md should win:\n{instructions}"
2425 );
2426 assert!(
2427 !instructions.contains("Global WHALE legacy"),
2428 "global WHALE.md content should be skipped when any global AGENTS.md exists:\n{instructions}"
2429 );
2430 assert!(
2431 ctx.warnings
2432 .iter()
2433 .any(|warning| warning.contains("WHALE.md is ignored")),
2434 "ignored WHALE.md should emit migration warning: {:?}",
2435 ctx.warnings
2436 );
2437 assert_eq!(ctx.source_path, Some(global_agents));
2438 }
2439
2440 #[test]
2441 fn test_global_whale_is_ignored_when_no_global_agents_exists() {
2442 let workspace = tempdir().expect("workspace tempdir");
2443 let home = tempdir().expect("home tempdir");
2444
2445 let codewhale_dir = home.path().join(".codewhale");
2446 fs::create_dir(&codewhale_dir).expect("mkdir .codewhale");
2447 let global_whale = codewhale_dir.join("WHALE.md");
2448 fs::write(&global_whale, "Global WHALE legacy").expect("write codewhale whale");
2449
2450 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2451
2452 let instructions = ctx.instructions.as_deref().unwrap_or("");
2453 assert!(
2454 !instructions.contains("Global WHALE legacy"),
2455 "legacy WHALE.md must not be read when no global AGENTS.md exists:\n{instructions}"
2456 );
2457 assert!(
2458 ctx.warnings
2459 .iter()
2460 .any(|warning| warning.contains("WHALE.md is ignored")),
2461 "expected global WHALE.md ignored warning, got {:?}",
2462 ctx.warnings
2463 );
2464 assert_ne!(ctx.source_path, Some(global_whale));
2465 }
2466
2467 #[test]
2468 fn test_global_instructions_md_is_autoloaded_while_whale_is_ignored() {
2469 // #3012: a global ~/.codewhale/instructions.md should be auto-loaded as
2470 // a fallback context layer while legacy WHALE.md remains ignored.
2471 let workspace = tempdir().expect("workspace tempdir");
2472 let home = tempdir().expect("home tempdir");
2473
2474 let codewhale_dir = home.path().join(".codewhale");
2475 fs::create_dir(&codewhale_dir).expect("mkdir .codewhale");
2476 fs::write(codewhale_dir.join("WHALE.md"), "Global WHALE legacy")
2477 .expect("write codewhale whale");
2478 let global_instructions = codewhale_dir.join("instructions.md");
2479 fs::write(&global_instructions, "Global instructions body")
2480 .expect("write global instructions");
2481
2482 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2483
2484 assert!(ctx.has_instructions());
2485 let instructions = ctx.instructions.as_ref().unwrap();
2486 assert!(
2487 instructions.contains("Global instructions body"),
2488 "global instructions.md should be auto-loaded:\n{instructions}"
2489 );
2490 assert!(
2491 !instructions.contains("Global WHALE legacy"),
2492 "instructions.md should load without reading ignored WHALE.md:\n{instructions}"
2493 );
2494 assert!(
2495 ctx.warnings
2496 .iter()
2497 .any(|warning| warning.contains("WHALE.md is ignored")),
2498 "ignored WHALE.md should emit migration warning: {:?}",
2499 ctx.warnings
2500 );
2501 assert_eq!(ctx.source_path, Some(global_instructions));
2502 }
2503
2504 #[test]
2505 fn test_global_agents_outranks_global_instructions() {
2506 // #3012 precedence: AGENTS.md > instructions.md.
2507 let workspace = tempdir().expect("workspace tempdir");
2508 let home = tempdir().expect("home tempdir");
2509
2510 let codewhale_dir = home.path().join(".codewhale");
2511 fs::create_dir(&codewhale_dir).expect("mkdir .codewhale");
2512 let global_agents = codewhale_dir.join("AGENTS.md");
2513 fs::write(&global_agents, "Global AGENTS canonical").expect("write global agents");
2514 fs::write(
2515 codewhale_dir.join("instructions.md"),
2516 "Global instructions body",
2517 )
2518 .expect("write global instructions");
2519
2520 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2521
2522 assert!(ctx.has_instructions());
2523 let instructions = ctx.instructions.as_ref().unwrap();
2524 assert!(
2525 instructions.contains("Global AGENTS canonical"),
2526 "global AGENTS.md should outrank instructions.md:\n{instructions}"
2527 );
2528 assert!(
2529 !instructions.contains("Global instructions body"),
2530 "instructions.md should be skipped when a global AGENTS.md exists:\n{instructions}"
2531 );
2532 assert_eq!(ctx.source_path, Some(global_agents));
2533 }
2534
2535 #[test]
2536 fn test_local_and_global_agents_merge_when_both_exist() {
2537 // #1157: when both `~/.deepseek/AGENTS.md` and a project AGENTS.md
2538 // exist, the prompt should carry user-wide preferences AND the
2539 // project's overrides — not silently drop the global file.
2540 let workspace = tempdir().expect("workspace tempdir");
2541 fs::write(workspace.path().join("AGENTS.md"), "Local instructions")
2542 .expect("write local agents");
2543
2544 let home = tempdir().expect("home tempdir");
2545 let global_dir = home.path().join(".deepseek");
2546 fs::create_dir(&global_dir).expect("mkdir .deepseek");
2547 fs::write(global_dir.join("AGENTS.md"), "Global instructions")
2548 .expect("write global agents");
2549
2550 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2551
2552 assert!(ctx.has_instructions());
2553 let instructions = ctx.instructions.as_ref().unwrap();
2554 assert!(
2555 instructions.contains("Global instructions"),
2556 "global block missing from merged instructions:\n{instructions}"
2557 );
2558 assert!(
2559 instructions.contains("Local instructions"),
2560 "project block missing from merged instructions:\n{instructions}"
2561 );
2562 // Global block precedes the project block so project rules read
2563 // last and win "last word" precedence with the model.
2564 let global_at = instructions.find("Global instructions").unwrap();
2565 let local_at = instructions.find("Local instructions").unwrap();
2566 assert!(
2567 global_at < local_at,
2568 "global block must come before project block, got global={global_at} local={local_at}"
2569 );
2570 // The merged block is labelled so the model can tell the layers
2571 // apart when it needs to explain which rule it followed.
2572 assert!(
2573 instructions.contains("project (overrides global where they conflict)"),
2574 "expected labelled separator between global and project blocks"
2575 );
2576 // `source_path` keeps pointing at the more-specific file so the
2577 // user knows where to edit the workspace-level override.
2578 assert_eq!(ctx.source_path, Some(workspace.path().join("AGENTS.md")));
2579 }
2580
2581 #[test]
2582 fn test_global_agents_only_no_project_unchanged_fallback() {
2583 // Sanity: when only the global file exists, the historical
2584 // fallback behaviour is preserved — no merge framing leaks in.
2585 let workspace = tempdir().expect("workspace tempdir");
2586 let home = tempdir().expect("home tempdir");
2587 let global_dir = home.path().join(".deepseek");
2588 fs::create_dir(&global_dir).expect("mkdir .deepseek");
2589 let global_agents = global_dir.join("AGENTS.md");
2590 fs::write(&global_agents, "Just the global instructions").expect("write global agents");
2591
2592 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2593
2594 assert!(ctx.has_instructions());
2595 let instructions = ctx.instructions.as_ref().unwrap();
2596 assert!(instructions.contains("Just the global instructions"));
2597 assert!(
2598 !instructions.contains("project (overrides global"),
2599 "merge-framing label should not appear when there's nothing to merge"
2600 );
2601 assert_eq!(ctx.source_path, Some(global_agents));
2602 }
2603
2604 #[test]
2605 fn test_invalid_global_agents_warns_and_falls_back_to_generated_context() {
2606 let workspace = tempdir().expect("workspace tempdir");
2607 let home = tempdir().expect("home tempdir");
2608 let global_dir = home.path().join(".deepseek");
2609 fs::create_dir(&global_dir).expect("mkdir .deepseek");
2610 fs::write(global_dir.join("AGENTS.md"), " \n ").expect("write empty global agents");
2611
2612 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2613
2614 assert!(
2615 ctx.warnings
2616 .iter()
2617 .any(|warning| warning.contains("Context file") && warning.contains("is empty")),
2618 "expected empty global AGENTS.md warning, got {:?}",
2619 ctx.warnings
2620 );
2621 assert!(ctx.has_instructions());
2622 assert!(
2623 ctx.instructions
2624 .as_ref()
2625 .unwrap()
2626 .contains("Project Context (Auto-generated, ephemeral)")
2627 );
2628 }
2629
2630 // ── Rules directory auto-discovery tests ──
2631
2632 #[test]
2633 fn rules_from_codewhale_dir_are_loaded_as_project_context() {
2634 let tmp = tempdir().expect("tempdir");
2635 let rules_dir = tmp.path().join(".codewhale/rules");
2636 fs::create_dir_all(&rules_dir).expect("mkdir rules");
2637 fs::write(
2638 rules_dir.join("security.md"),
2639 "# Security\nNo hardcoded secrets.",
2640 )
2641 .expect("write");
2642
2643 let ctx = load_project_context(tmp.path());
2644
2645 let rules = ctx.rules_block.as_ref().expect("rules_block should be set");
2646 assert!(
2647 rules.contains("Security"),
2648 "expected rules content, got: {rules}"
2649 );
2650 assert!(
2651 rules.contains("<project_rule source="),
2652 "expected <project_rule> wrapper, got: {rules}"
2653 );
2654 }
2655
2656 #[test]
2657 fn rules_are_loaded_in_filename_order() {
2658 let tmp = tempdir().expect("tempdir");
2659 let rules_dir = tmp.path().join(".codewhale/rules");
2660 fs::create_dir_all(&rules_dir).expect("mkdir rules");
2661 fs::write(rules_dir.join("zzz.md"), "last").expect("write");
2662 fs::write(rules_dir.join("aaa.md"), "first").expect("write");
2663 fs::write(rules_dir.join("mmm.md"), "middle").expect("write");
2664
2665 let ctx = load_project_context(tmp.path());
2666 let rules = ctx.rules_block.as_ref().unwrap();
2667
2668 let pos_aaa = rules.find("first").unwrap();
2669 let pos_mmm = rules.find("middle").unwrap();
2670 let pos_zzz = rules.find("last").unwrap();
2671 assert!(pos_aaa < pos_mmm, "aaa should come before mmm");
2672 assert!(pos_mmm < pos_zzz, "mmm should come before zzz");
2673 }
2674
2675 #[test]
2676 fn rules_from_claude_dir_are_compat_loaded() {
2677 let tmp = tempdir().expect("tempdir");
2678 let rules_dir = tmp.path().join(".claude/rules");
2679 fs::create_dir_all(&rules_dir).expect("mkdir rules");
2680 fs::write(rules_dir.join("style.md"), "Use tabs").expect("write");
2681
2682 let ctx = load_project_context(tmp.path());
2683
2684 let rules = ctx.rules_block.as_ref().expect("rules should be loaded");
2685 assert!(
2686 rules.contains("Use tabs"),
2687 "expected .claude/rules/ compat loading"
2688 );
2689 }
2690
2691 #[test]
2692 fn rules_directory_missing_does_not_crash() {
2693 let tmp = tempdir().expect("tempdir");
2694 // No .codewhale/rules/ or .claude/rules/ directories exist
2695 let ctx = load_project_context(tmp.path());
2696 // Rules block should be None when no rules directories exist
2697 assert!(
2698 ctx.rules_block.is_none(),
2699 "rules_block should be None when no rules exist"
2700 );
2701 }
2702
2703 #[test]
2704 fn rules_coexist_with_agents_md() {
2705 let tmp = tempdir().expect("tempdir");
2706 fs::write(tmp.path().join("AGENTS.md"), "Main project instructions").expect("write");
2707 let rules_dir = tmp.path().join(".codewhale/rules");
2708 fs::create_dir_all(&rules_dir).expect("mkdir rules");
2709 fs::write(rules_dir.join("extra.md"), "Extra rule").expect("write");
2710
2711 let ctx = load_project_context(tmp.path());
2712 let instructions = ctx.instructions.as_ref().unwrap();
2713 let rules = ctx.rules_block.as_ref().unwrap();
2714
2715 assert!(
2716 instructions.contains("Main project instructions"),
2717 "AGENTS.md content missing"
2718 );
2719 assert!(rules.contains("Extra rule"), "rules content missing");
2720 // AGENTS.md should come first in system block
2721 let block = ctx.as_system_block().unwrap();
2722 let pos_agents = block.find("Main project instructions").unwrap();
2723 let pos_rule = block.find("Extra rule").unwrap();
2724 assert!(pos_agents < pos_rule, "AGENTS.md should precede rules");
2725 }
2726
2727 #[test]
2728 fn non_md_files_in_rules_dir_are_ignored() {
2729 let tmp = tempdir().expect("tempdir");
2730 let rules_dir = tmp.path().join(".codewhale/rules");
2731 fs::create_dir_all(&rules_dir).expect("mkdir rules");
2732 fs::write(rules_dir.join("notes.txt"), "should be ignored").expect("write");
2733 fs::write(rules_dir.join("valid.md"), "loaded").expect("write");
2734
2735 let ctx = load_project_context(tmp.path());
2736 let rules = ctx.rules_block.as_ref().unwrap();
2737
2738 assert!(rules.contains("loaded"), "valid .md should be loaded");
2739 assert!(
2740 !rules.contains("should be ignored"),
2741 ".txt should be ignored"
2742 );
2743 }
2744
2745 #[test]
2746 fn rules_cap_truncates_excess_files() {
2747 let tmp = tempdir().expect("tempdir");
2748 let rules_dir = tmp.path().join(".codewhale/rules");
2749 fs::create_dir_all(&rules_dir).expect("mkdir rules");
2750
2751 // Create more files than the cap
2752 for i in 0..60 {
2753 fs::write(
2754 rules_dir.join(format!("rule_{i:04}.md")),
2755 format!("content {i}"),
2756 )
2757 .expect("write");
2758 }
2759
2760 let ctx = load_project_context(tmp.path());
2761 let rules = ctx.rules_block.as_ref().unwrap();
2762
2763 // The last file (by sorted name) should NOT be present
2764 assert!(
2765 !rules.contains("content 59"),
2766 "rule_0059 should be above cap"
2767 );
2768 // The first file should be present
2769 assert!(
2770 rules.contains("content 0"),
2771 "rule_0000 should be within cap"
2772 );
2773 // Count <project_rule> blocks
2774 let count = rules.matches("<project_rule source=").count();
2775 assert_eq!(
2776 count, MAX_RULES_FILES,
2777 "exactly {MAX_RULES_FILES} rules should be loaded"
2778 );
2779 }
2780
2781 #[cfg(unix)]
2782 #[test]
2783 fn rules_rejects_symlinked_files() {
2784 let workspace = tempdir().expect("workspace tempdir");
2785 let outside = tempdir().expect("outside tempdir");
2786 let rules_dir = workspace.path().join(".codewhale/rules");
2787 fs::create_dir_all(&rules_dir).expect("mkdir rules");
2788
2789 let outside_rule = outside.path().join("outside.md");
2790 fs::write(&outside_rule, "outside content").expect("write outside");
2791 std::os::unix::fs::symlink(&outside_rule, rules_dir.join("outside.md"))
2792 .expect("symlink rule");
2793
2794 let ctx = load_project_context(workspace.path());
2795
2796 // Symlinked rules must not be loaded
2797 assert!(
2798 ctx.rules_block.is_none()
2799 || !ctx
2800 .rules_block
2801 .as_ref()
2802 .unwrap()
2803 .contains("outside content"),
2804 "symlinked rules must not be loaded"
2805 );
2806 }
2807
2808 #[cfg(unix)]
2809 #[test]
2810 fn rules_rejects_symlinked_directory() {
2811 let workspace = tempdir().expect("workspace tempdir");
2812 let outside = tempdir().expect("outside tempdir");
2813 let outside_dir = outside.path().join("real_rules");
2814 fs::create_dir_all(&outside_dir).expect("mkdir outside dir");
2815 fs::write(outside_dir.join("secret.md"), "outside content").expect("write outside");
2816 fs::create_dir_all(workspace.path().join(".codewhale")).expect("mkdir codewhale");
2817
2818 // Symlink the directory itself, not individual files
2819 std::os::unix::fs::symlink(&outside_dir, workspace.path().join(".codewhale/rules"))
2820 .expect("symlink rules dir");
2821
2822 let ctx = load_project_context(workspace.path());
2823
2824 // Symlinked rules directory must be refused at the directory level
2825 assert!(
2826 ctx.rules_block.is_none()
2827 || !ctx
2828 .rules_block
2829 .as_ref()
2830 .unwrap()
2831 .contains("outside content"),
2832 "symlinked rules directory must be refused"
2833 );
2834 }
2835
2836 #[test]
2837 fn rules_from_both_dirs_are_loaded_together() {
2838 let tmp = tempdir().expect("tempdir");
2839 let codewhale_rules = tmp.path().join(".codewhale/rules");
2840 let claude_rules = tmp.path().join(".claude/rules");
2841 fs::create_dir_all(&codewhale_rules).expect("mkdir codewhale rules");
2842 fs::create_dir_all(&claude_rules).expect("mkdir claude rules");
2843 fs::write(codewhale_rules.join("cw.md"), "codewhale-rule").expect("write");
2844 fs::write(claude_rules.join("claude.md"), "claude-rule").expect("write");
2845
2846 let ctx = load_project_context(tmp.path());
2847 let rules = ctx.rules_block.as_ref().unwrap();
2848
2849 assert!(
2850 rules.contains("codewhale-rule"),
2851 ".codewhale/rules/ should be loaded"
2852 );
2853 assert!(
2854 rules.contains("claude-rule"),
2855 ".claude/rules/ should be loaded"
2856 );
2857 // .codewhale/rules/ content should appear before .claude/rules/ (RULES_DIRS order)
2858 let pos_cw = rules.find("codewhale-rule").unwrap();
2859 let pos_claude = rules.find("claude-rule").unwrap();
2860 assert!(
2861 pos_cw < pos_claude,
2862 ".codewhale/rules/ should precede .claude/rules/"
2863 );
2864 }
2865
2866 #[test]
2867 fn rules_block_truncated_at_total_byte_budget() {
2868 let tmp = tempdir().expect("tempdir");
2869 let rules_dir = tmp.path().join(".codewhale/rules");
2870 fs::create_dir_all(&rules_dir).expect("mkdir rules");
2871
2872 // Create files whose combined content exceeds MAX_RULES_BLOCK_BYTES
2873 let per_file = "X".repeat(20 * 1024); // 20 KB each
2874 for i in 0..30 {
2875 fs::write(rules_dir.join(format!("rule_{i:04}.md")), &per_file).expect("write");
2876 }
2877
2878 let ctx = load_project_context(tmp.path());
2879 let rules = ctx.rules_block.as_ref().unwrap();
2880
2881 assert!(
2882 rules.len() <= MAX_RULES_BLOCK_BYTES + 200, // + marker overhead
2883 "rules block should be truncated to budget: {} > {}",
2884 rules.len(),
2885 MAX_RULES_BLOCK_BYTES
2886 );
2887 assert!(
2888 rules.contains("truncated at 500 KB"),
2889 "truncation marker missing"
2890 );
2891 }
2892 }
2893
2893 lines RUST