返回 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 mod constitution;
18 mod pack;
19 mod types;
20
21 use std::fs;
22 use std::io::Read;
23 use std::path::{Path, PathBuf};
24
25 pub(crate) use self::constitution::{RepoLawAction, RepoLawRule, load_repo_law_rules};
26 use self::constitution::{load_repo_constitution_block, repo_constitution_candidate_paths};
27 use self::pack::generate_bounded_project_overview;
28 pub use self::pack::generate_project_context_pack;
29 pub use self::types::ProjectContext;
30 use self::types::ProjectContextError;
31
32 /// Names of project context files to look for, in priority order.
33 ///
34 /// `AGENTS.md` is the canonical cross-agent project-instructions file.
35 /// `WHALE.md` is no longer an active context surface; when present, Codewhale
36 /// reports a migration warning but ignores it. Codewhale-specific repo
37 /// authority now lives in `.codewhale/constitution.json`, not a bespoke
38 /// markdown file. `CLAUDE.md` and the `*/instructions.md` variants are
39 /// read-only compatibility fallbacks; Codewhale never creates or recommends
40 /// them.
41 const PROJECT_CONTEXT_FILES: &[&str] = &[
42 "AGENTS.md",
43 ".claude/instructions.md",
44 "CLAUDE.md",
45 ".codewhale/instructions.md",
46 ".deepseek/instructions.md",
47 ];
48
49 /// A foreign agent's instruction format. These are read only when the user
50 /// opts in by name, because a file written as law for a different tool is not
51 /// automatically law for this one — and because silently treating a file the
52 /// user never pointed at us as standing authority is an injection surface,
53 /// not a convenience.
54 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
55 pub(crate) enum ForeignInstructionFormat {
56 Claude,
57 Cursor,
58 Cline,
59 Windsurf,
60 Gemini,
61 Copilot,
62 Muse,
63 }
64
65 impl ForeignInstructionFormat {
66 pub(crate) const ALL: &'static [Self] = &[
67 Self::Claude,
68 Self::Cursor,
69 Self::Cline,
70 Self::Windsurf,
71 Self::Gemini,
72 Self::Copilot,
73 Self::Muse,
74 ];
75
76 /// Configuration spelling, as written in `project_instruction_imports`.
77 pub(crate) fn key(self) -> &'static str {
78 match self {
79 Self::Claude => "claude",
80 Self::Cursor => "cursor",
81 Self::Cline => "cline",
82 Self::Windsurf => "windsurf",
83 Self::Gemini => "gemini",
84 Self::Copilot => "copilot",
85 Self::Muse => "muse",
86 }
87 }
88
89 pub(crate) fn parse(value: &str) -> Option<Self> {
90 let value = value.trim().to_ascii_lowercase();
91 Self::ALL.iter().copied().find(|f| f.key() == value)
92 }
93
94 /// Workspace-relative instruction files this format contributes.
95 fn context_files(self) -> &'static [&'static str] {
96 match self {
97 Self::Claude => &[".claude/instructions.md", "CLAUDE.md"],
98 // The remaining formats are imported through the bounded-fragment
99 // loader in `codewhale_core::fragments`, not through this chain.
100 _ => &[],
101 }
102 }
103
104 /// Workspace-relative rules directories this format contributes.
105 fn rules_dirs(self) -> &'static [&'static str] {
106 match self {
107 Self::Claude => &[".claude/rules"],
108 _ => &[],
109 }
110 }
111
112 /// Candidates handled by the bounded-fragment loader in
113 /// `codewhale_core::fragments` rather than by the instruction chain.
114 fn fragment_candidates(self) -> &'static [&'static str] {
115 match self {
116 Self::Cursor => &[".cursorrules", ".cursor/rules"],
117 Self::Cline => &[".clinerules"],
118 Self::Windsurf => &[".windsurf/rules"],
119 Self::Gemini => &[".gemini"],
120 Self::Copilot => &[".github/copilot-instructions.md"],
121 Self::Muse => &[".github/muse-instructions.md"],
122 // Claude's files are read by the instruction chain above, so
123 // importing them here too would inject the same bytes twice.
124 Self::Claude => &[],
125 }
126 }
127 }
128
129 /// The set of foreign formats the user has explicitly opted into.
130 ///
131 /// Resolved from config once and read by the loader. Empty by default: a fresh
132 /// checkout containing a `CLAUDE.md` written for another tool contributes
133 /// nothing to Codewhale's standing instructions until someone says so.
134 #[derive(Debug, Clone, Default, PartialEq, Eq)]
135 pub struct ForeignInstructionImports {
136 enabled: std::collections::BTreeSet<&'static str>,
137 }
138
139 impl ForeignInstructionImports {
140 #[cfg(test)]
141 #[must_use]
142 pub fn none() -> Self {
143 Self::default()
144 }
145
146 /// Parse configured names, returning the set plus any unrecognized names
147 /// so the caller can warn instead of silently ignoring a typo.
148 #[must_use]
149 pub fn from_config(values: &[String]) -> (Self, Vec<String>) {
150 let mut enabled = std::collections::BTreeSet::new();
151 let mut unknown = Vec::new();
152 for value in values {
153 let trimmed = value.trim();
154 if trimmed.is_empty() {
155 continue;
156 }
157 if trimmed.eq_ignore_ascii_case("all") {
158 enabled.extend(ForeignInstructionFormat::ALL.iter().map(|f| f.key()));
159 continue;
160 }
161 match ForeignInstructionFormat::parse(trimmed) {
162 Some(format) => {
163 enabled.insert(format.key());
164 }
165 None => unknown.push(trimmed.to_string()),
166 }
167 }
168 (Self { enabled }, unknown)
169 }
170
171 #[must_use]
172 pub(crate) fn is_enabled(&self, format: ForeignInstructionFormat) -> bool {
173 self.enabled.contains(format.key())
174 }
175
176 #[cfg(test)]
177 #[must_use]
178 pub(crate) fn is_empty(&self) -> bool {
179 self.enabled.is_empty()
180 }
181
182 /// Enabled format keys, for provenance and diagnostics.
183 #[must_use]
184 pub fn keys(&self) -> Vec<&'static str> {
185 self.enabled.iter().copied().collect()
186 }
187 }
188
189 /// Foreign fragment candidates enabled by the active opt-in set.
190 ///
191 /// `.agents/AGENTS.md` is always included: it is the cross-agent `AGENTS.md`
192 /// standard Codewhale already follows, not another vendor's format.
193 #[must_use]
194 pub(crate) fn active_fragment_candidates() -> Vec<&'static str> {
195 let imports = foreign_instruction_imports();
196 fragment_candidates_for(&imports)
197 }
198
199 #[must_use]
200 pub(crate) fn fragment_candidates_for(imports: &ForeignInstructionImports) -> Vec<&'static str> {
201 let mut candidates: Vec<&'static str> = vec![".agents/AGENTS.md"];
202 for format in ForeignInstructionFormat::ALL {
203 if imports.is_enabled(*format) {
204 candidates.extend(format.fragment_candidates());
205 }
206 }
207 candidates
208 }
209
210 static FOREIGN_IMPORTS: std::sync::RwLock<Option<ForeignInstructionImports>> =
211 std::sync::RwLock::new(None);
212
213 /// Install the resolved opt-in set. Called once while config is applied.
214 pub fn set_foreign_instruction_imports(imports: ForeignInstructionImports) {
215 if let Ok(mut guard) = FOREIGN_IMPORTS.write() {
216 *guard = Some(imports);
217 }
218 crate::project_context_cache::clear();
219 }
220
221 /// The active opt-in set. Defaults to "import nothing".
222 #[must_use]
223 pub(crate) fn foreign_instruction_imports() -> ForeignInstructionImports {
224 FOREIGN_IMPORTS
225 .read()
226 .ok()
227 .and_then(|guard| guard.clone())
228 .unwrap_or_default()
229 }
230
231 /// Instruction file candidates in priority order for the active opt-in set.
232 fn context_files_for(imports: &ForeignInstructionImports) -> Vec<&'static str> {
233 // Order is priority: `load_dir_instructions` takes the first match in a
234 // directory. AGENTS.md leads, then Codewhale's own files, and only then
235 // anything imported from another tool — an imported CLAUDE.md must never
236 // outrank .codewhale/instructions.md the way the old flat list let it.
237 let mut files: Vec<&'static str> = vec![
238 "AGENTS.md",
239 ".codewhale/instructions.md",
240 ".deepseek/instructions.md",
241 ];
242 for format in ForeignInstructionFormat::ALL {
243 if imports.is_enabled(*format) {
244 files.extend(format.context_files());
245 }
246 }
247 files
248 }
249
250 /// Rules directories for the active opt-in set.
251 fn rules_dirs_for(imports: &ForeignInstructionImports) -> Vec<&'static str> {
252 let mut dirs: Vec<&'static str> = vec![".codewhale/rules"];
253 for format in ForeignInstructionFormat::ALL {
254 if imports.is_enabled(*format) {
255 dirs.extend(format.rules_dirs());
256 }
257 }
258 dirs
259 }
260
261 fn rules_dir_has_loadable_content(workspace: &Path, rules_dir_name: &str) -> bool {
262 let rules_dir = workspace.join(rules_dir_name);
263 if fs::symlink_metadata(&rules_dir).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
264 return false;
265 }
266 let Ok(entries) = fs::read_dir(rules_dir) else {
267 return false;
268 };
269 let mut candidates = entries
270 .flatten()
271 .map(|entry| entry.path())
272 .filter(|path| {
273 path.extension().is_some_and(|extension| extension == "md")
274 && context_candidate_exists(path)
275 })
276 .collect::<Vec<_>>();
277 candidates.sort();
278 candidates
279 .into_iter()
280 .take(MAX_RULES_FILES)
281 .any(|path| load_context_file(&path).is_ok())
282 }
283
284 /// Foreign instruction files that exist in the workspace but were not
285 /// imported, so the user can discover the opt-in instead of wondering why
286 /// their `CLAUDE.md` is being ignored.
287 fn unimported_foreign_warnings(
288 workspace: &Path,
289 imports: &ForeignInstructionImports,
290 ) -> Vec<String> {
291 let mut seen: Vec<&'static str> = Vec::new();
292 for format in ForeignInstructionFormat::ALL {
293 if imports.is_enabled(*format) {
294 continue;
295 }
296 let direct_context_present = format
297 .context_files()
298 .iter()
299 .any(|relative| load_context_file(&workspace.join(relative)).is_ok());
300 let rules_present = format
301 .rules_dirs()
302 .iter()
303 .any(|relative| rules_dir_has_loadable_content(workspace, relative));
304 let fragment_present =
305 codewhale_core::fragments::load_selected_project_instruction_fragment(
306 workspace,
307 format.fragment_candidates(),
308 )
309 .is_some();
310 // Keep discovery aligned with the loaders. A path can exist without
311 // being importable (for example an empty or unreadable file, a rules
312 // directory containing no Markdown, an oversized file, or a symlink
313 // that the loaders deliberately refuse to follow). Warning for those
314 // paths would claim there is content available to import when there is
315 // not.
316 let present = direct_context_present || rules_present || fragment_present;
317 if present {
318 seen.push(format.key());
319 }
320 }
321 if seen.is_empty() {
322 return Vec::new();
323 }
324 vec![format!(
325 "Found instruction files for {} in this workspace; they are not loaded. Codewhale reads AGENTS.md and its own instruction files by default. To import them, set project_instruction_imports = [{}] in config.",
326 seen.join(", "),
327 seen.iter()
328 .map(|key| format!("\"{key}\""))
329 .collect::<Vec<_>>()
330 .join(", ")
331 )]
332 }
333
334 /// Rules directories auto-discovered at workspace level, in priority order.
335 /// `.codewhale/rules/` is Codewhale-native; `.claude/rules/` is Claude compatibility.
336 /// All `.md` files in these directories are loaded as project rules in filename order.
337 /// Security model: same trust class as AGENTS.md — workspace-contained content only,
338 /// no absolute-path escape. Does not require #417 project-config relaxation.
339 const RULES_DIRS: &[&str] = &[".codewhale/rules", ".claude/rules"];
340
341 /// File name of the deprecated Codewhale-native instructions file.
342 const DEPRECATED_WHALE_FILENAME: &str = "WHALE.md";
343
344 /// Warning surfaced when an ignored `WHALE.md` is present.
345 const WHALE_IGNORED_WARNING: &str = "WHALE.md is ignored; move project instructions to AGENTS.md, or Codewhale-specific authority policy to .codewhale/constitution.json.";
346
347 /// User-level project instructions loaded as a fallback when the workspace and
348 /// its parents do not define project context. Any global AGENTS.md takes
349 /// priority over a global instructions.md (#3012). Within each file name,
350 /// `.codewhale/` takes priority over vendor-neutral `.agents/`, which takes
351 /// priority over legacy `.deepseek/`. Global `WHALE.md` files are ignored and
352 /// reported as migration-only diagnostics.
353 const GLOBAL_AGENTS_RELATIVE_PATH: &[&str] = &[".codewhale", "AGENTS.md"];
354 const GLOBAL_AGENTS_VENDOR_NEUTRAL_PATH: &[&str] = &[".agents", "AGENTS.md"];
355 const GLOBAL_AGENTS_LEGACY_PATH: &[&str] = &[".deepseek", "AGENTS.md"];
356 const GLOBAL_WHALE_RELATIVE_PATH: &[&str] = &[".codewhale", "WHALE.md"];
357 const GLOBAL_WHALE_VENDOR_NEUTRAL_PATH: &[&str] = &[".agents", "WHALE.md"];
358 const GLOBAL_WHALE_LEGACY_PATH: &[&str] = &[".deepseek", "WHALE.md"];
359 /// Global `instructions.md` (#3012): auto-loaded as a fallback context layer,
360 /// ranked below AGENTS.md, mirroring the project-level precedence.
361 const GLOBAL_INSTRUCTIONS_RELATIVE_PATH: &[&str] = &[".codewhale", "instructions.md"];
362 const GLOBAL_INSTRUCTIONS_VENDOR_NEUTRAL_PATH: &[&str] = &[".agents", "instructions.md"];
363 const GLOBAL_INSTRUCTIONS_LEGACY_PATH: &[&str] = &[".deepseek", "instructions.md"];
364
365 /// Maximum size for project context files (to prevent loading huge files)
366 const MAX_CONTEXT_SIZE: usize = 100 * 1024; // 100KB
367
368 /// One aggregate budget for everything that reaches the model as project
369 /// instruction authority: the repository-root → workspace instruction chain,
370 /// the global fallback layer merged into it, the assembled rules block, and
371 /// any opted-in foreign-agent rule files.
372 ///
373 /// Previously each of those had its own cap — 200 KiB for the chain, 500 KiB
374 /// for the rules block, 40 KiB for the imported-fragment path — so a workspace
375 /// could put roughly three quarters of a megabyte of standing instructions in
376 /// front of the model before skills, memory, history, or tool schemas were
377 /// counted, and no single number described the ceiling. One budget, checked
378 /// once, is the number that can actually be reasoned about.
379 ///
380 /// Authority decides what survives: `instructions` claims the budget first and
381 /// is trimmed from the *front* (the broadest scope) so the nearest-scope file
382 /// is the last thing dropped; the rules block takes what is left. Both trims
383 /// leave an explicit marker in the text.
384 pub(crate) const MAX_PROJECT_INSTRUCTION_BYTES: usize = 48 * 1024; // 48 KiB
385
386 /// Maximum number of rule files loaded per rules directory.
387 /// Prevents a project from silently injecting hundreds of rule files.
388 const MAX_RULES_FILES: usize = 50;
389
390 const CHAIN_TRUNCATION_MARKER: &str =
391 "[…broader-scope project instructions dropped at the aggregate budget…]\n\n";
392 const RULES_TRUNCATION_MARKER: &str = "\n\n[…rules block truncated at the aggregate budget…]";
393
394 /// Trim `text` to at most `max` bytes, keeping the tail and marking the cut.
395 fn keep_tail_within(text: &str, max: usize) -> String {
396 if text.len() <= max {
397 return text.to_string();
398 }
399 let keep = max.saturating_sub(CHAIN_TRUNCATION_MARKER.len());
400 if keep == 0 {
401 return CHAIN_TRUNCATION_MARKER.to_string();
402 }
403 let mut start = text.len() - keep;
404 while start < text.len() && !text.is_char_boundary(start) {
405 start += 1;
406 }
407 format!("{CHAIN_TRUNCATION_MARKER}{}", &text[start..])
408 }
409
410 /// Trim `text` to at most `max` bytes, keeping the head and marking the cut.
411 fn keep_head_within(text: &str, max: usize) -> String {
412 if text.len() <= max {
413 return text.to_string();
414 }
415 let keep = max.saturating_sub(RULES_TRUNCATION_MARKER.len());
416 if keep == 0 {
417 return String::new();
418 }
419 let mut end = keep;
420 while end > 0 && !text.is_char_boundary(end) {
421 end -= 1;
422 }
423 format!("{}{RULES_TRUNCATION_MARKER}", &text[..end])
424 }
425
426 /// Apply the single aggregate project-instruction budget.
427 ///
428 /// Called once, after every source has been assembled, so the ceiling holds
429 /// across the chain, the global layer, and the rules block together rather
430 /// than per-loader. Nothing here re-reads the filesystem.
431 pub(crate) fn enforce_project_instruction_budget(ctx: &mut ProjectContext) {
432 let instructions_len = ctx.instructions.as_ref().map_or(0, String::len);
433 let rules_len = ctx.rules_block.as_ref().map_or(0, String::len);
434 if instructions_len + rules_len <= MAX_PROJECT_INSTRUCTION_BYTES {
435 return;
436 }
437
438 // Instructions are the higher authority and claim the budget first.
439 if instructions_len > MAX_PROJECT_INSTRUCTION_BYTES
440 && let Some(text) = ctx.instructions.as_ref()
441 {
442 let trimmed = keep_tail_within(text, MAX_PROJECT_INSTRUCTION_BYTES);
443 tracing::warn!(
444 target: "project_context",
445 was = instructions_len,
446 now = trimmed.len(),
447 cap = MAX_PROJECT_INSTRUCTION_BYTES,
448 "Dropping broadest-scope project instructions at the aggregate budget"
449 );
450 ctx.instructions = Some(trimmed);
451 }
452
453 let used = ctx.instructions.as_ref().map_or(0, String::len);
454 let remaining = MAX_PROJECT_INSTRUCTION_BYTES.saturating_sub(used);
455 if rules_len > remaining
456 && let Some(text) = ctx.rules_block.as_ref()
457 {
458 let trimmed = keep_head_within(text, remaining);
459 tracing::warn!(
460 target: "project_context",
461 was = rules_len,
462 now = trimmed.len(),
463 remaining,
464 "Truncating rules block to the aggregate project-instruction budget"
465 );
466 ctx.rules_block = if trimmed.is_empty() {
467 None
468 } else {
469 Some(trimmed)
470 };
471 }
472 }
473 /// Load project context from the workspace directory.
474 ///
475 /// This searches for known project context files and loads the first one found.
476 /// Convenience wrapper that reads the process-wide opt-in set.
477 ///
478 /// Production goes through [`load_project_context_with_imports`] so the import
479 /// set is explicit at the call site; this exists for tests that only care about
480 /// default behaviour.
481 #[cfg(test)]
482 pub fn load_project_context(workspace: &Path) -> ProjectContext {
483 load_project_context_with_imports(workspace, &foreign_instruction_imports())
484 }
485
486 /// Load workspace project context under an explicit foreign-import set.
487 ///
488 /// The set is a parameter rather than a global read so a caller — and every
489 /// test — states which foreign formats are in play instead of depending on
490 /// process-wide state that parallel tests would race on.
491 pub(crate) fn load_project_context_with_imports(
492 workspace: &Path,
493 imports: &ForeignInstructionImports,
494 ) -> ProjectContext {
495 let mut ctx = ProjectContext::empty(workspace.to_path_buf());
496
497 // Search for active project context files.
498 let (instructions, source_path, warnings) = load_dir_instructions(workspace, imports);
499 ctx.instructions = instructions;
500 ctx.source_path = source_path;
501 ctx.warnings.extend(warnings);
502
503 ctx.warnings
504 .extend(ignored_project_whale_warnings(workspace));
505 ctx.warnings
506 .extend(unimported_foreign_warnings(workspace, imports));
507
508 // Load rules from auto-discovered directories (.codewhale/rules/, .claude/rules/)
509 // Each rule file is wrapped in a <project_rule> block and appended after
510 // the main instructions content. Security model: same as AGENTS.md —
511 // workspace-contained content only, no absolute-path escape.
512 let mut rules_content = String::new();
513 for rules_dir in rules_dirs_for(imports) {
514 let rules = load_rules_from_dir(workspace, rules_dir);
515 for (path, content) in rules {
516 if !rules_content.is_empty() {
517 rules_content.push('\n');
518 }
519 rules_content.push_str(&format!(
520 "<project_rule source=\"{}\">\n{}\n</project_rule>",
521 path.display(),
522 content.trim()
523 ));
524 }
525 }
526
527 if !rules_content.is_empty() {
528 // No private cap here: `enforce_project_instruction_budget` applies the
529 // one aggregate ceiling across instructions and rules together, after
530 // every source has been assembled.
531 ctx.rules_block = Some(rules_content);
532 }
533
534 // Check for trust file
535 ctx.is_trusted = check_trust_status(workspace);
536
537 enforce_project_instruction_budget(&mut ctx);
538
539 ctx
540 }
541
542 /// Load the highest-priority instruction file from one directory.
543 ///
544 /// Returns the content, its path, and any warnings from failed candidates.
545 /// A directory with no candidate file yields `(None, None, warnings)`.
546 fn load_dir_instructions(
547 dir: &Path,
548 imports: &ForeignInstructionImports,
549 ) -> (Option<String>, Option<PathBuf>, Vec<String>) {
550 let mut warnings = Vec::new();
551
552 for filename in context_files_for(imports) {
553 let file_path = dir.join(filename);
554
555 if context_candidate_exists(&file_path) {
556 match load_context_file(&file_path) {
557 Ok(content) => {
558 tracing::info!(
559 "Loaded project context from {} ({} bytes)",
560 file_path.display(),
561 content.len()
562 );
563 return (Some(content), Some(file_path), warnings);
564 }
565 Err(error) => warnings.push(error.to_string()),
566 }
567 }
568 }
569
570 (None, None, warnings)
571 }
572
573 /// Load project context from the containing repository as well.
574 ///
575 /// Applicable instruction files resolve from the repository root down to the
576 /// workspace (inclusive) and are assembled in that order under one aggregate
577 /// byte budget, so wider scopes read first and the workspace keeps the last
578 /// word. Repository identity comes from the containing checkout itself
579 /// (Git dir/worktree traversal, [`find_git_root`]) — never from branch names
580 /// or paths mentioned in conversation — and the chain never crosses the
581 /// repository boundary. Outside any repository only the workspace itself is
582 /// searched.
583 pub fn load_project_context_with_parents(workspace: &Path) -> ProjectContext {
584 load_project_context_with_parents_cached_and_home(
585 workspace,
586 crate::config::effective_home_dir().as_deref(),
587 )
588 }
589
590 fn load_project_context_with_parents_cached_and_home(
591 workspace: &Path,
592 home_dir: Option<&Path>,
593 ) -> ProjectContext {
594 let workspace = canonicalize_workspace_or_keep(workspace);
595 let pre_load_key = crate::project_context_cache::compute_cache_key(&workspace, home_dir);
596 if let Some(ctx) = crate::project_context_cache::lookup(&pre_load_key) {
597 return ctx;
598 }
599
600 let ctx = load_project_context_with_parents_and_home(&workspace, home_dir);
601 let post_load_key = crate::project_context_cache::compute_cache_key(&workspace, home_dir);
602 crate::project_context_cache::store(post_load_key, ctx.clone());
603 ctx
604 }
605
606 fn load_project_context_with_parents_and_home(
607 workspace: &Path,
608 home_dir: Option<&Path>,
609 ) -> ProjectContext {
610 let imports = &foreign_instruction_imports();
611 load_project_context_with_parents_and_home_imports(workspace, home_dir, imports)
612 }
613
614 fn load_project_context_with_parents_and_home_imports(
615 workspace: &Path,
616 home_dir: Option<&Path>,
617 imports: &ForeignInstructionImports,
618 ) -> ProjectContext {
619 let workspace_canonical = canonicalize_workspace_or_keep(workspace);
620 let mut ctx = load_project_context_with_imports(&workspace_canonical, imports);
621
622 // Assemble the repository-root → workspace instruction chain. The chain
623 // directories come from Git traversal of the containing checkout, so a
624 // linked worktree contributes its own root and files above the root —
625 // other checkouts, unrelated parents — stay out of scope.
626 let chain_dirs = context_chain_dirs(&workspace_canonical, home_dir);
627 // `chain_dirs` is ordered root → workspace; the workspace itself is the
628 // last entry and was already loaded above.
629 let ancestor_dirs = &chain_dirs[..chain_dirs.len().saturating_sub(1)];
630
631 let mut ancestor_docs: Vec<(PathBuf, String)> = Vec::new();
632 for dir in ancestor_dirs {
633 ctx.warnings.extend(ignored_project_whale_warnings(dir));
634 let (content, path, warnings) = load_dir_instructions(dir, imports);
635 ctx.warnings.extend(warnings);
636 if let (Some(content), Some(path)) = (content, path) {
637 ancestor_docs.push((path, content));
638 }
639 }
640
641 if !ancestor_docs.is_empty() {
642 // Assemble root → workspace so the nearest scope has the last word.
643 // The byte ceiling is applied once at the end of this function by
644 // `enforce_project_instruction_budget`, which trims from the front —
645 // i.e. drops the broadest scope first and never strands the workspace's
646 // own file behind an exhausted budget the way per-segment accounting
647 // did.
648 let mut assembled = String::new();
649
650 for (path, content) in &ancestor_docs {
651 append_chain_segment(&mut assembled, path, content);
652 }
653
654 // The workspace's own file is the most specific link: it reads last,
655 // and `source_path` keeps pointing at it so the user knows where the
656 // workspace-level override lives.
657 if let Some(content) = ctx.instructions.take() {
658 let path = ctx
659 .source_path
660 .clone()
661 .unwrap_or_else(|| workspace_canonical.clone());
662 append_chain_segment(&mut assembled, &path, &content);
663 } else if let Some((path, _)) = ancestor_docs.last() {
664 // No workspace-level file: the nearest ancestor is the most
665 // specific source.
666 ctx.source_path = Some(path.clone());
667 }
668
669 ctx.instructions = Some(assembled);
670 }
671
672 // Always check global instruction files so user-wide preferences
673 // travel into every session (#1157). When both global and project
674 // instructions exist, the global block prepends the project's so
675 // workspace overrides win the last word; when only global exists,
676 // it continues to serve as the fallback. `source_path` keeps
677 // pointing at the more-specific source (project > global) for
678 // display purposes.
679 if let Some(global_ctx) = load_global_agents_context(workspace, home_dir) {
680 ctx.warnings.extend(global_ctx.warnings.iter().cloned());
681 if let Some(global_text) = global_ctx.instructions {
682 match ctx.instructions.take() {
683 Some(project_text) => {
684 ctx.instructions = Some(merge_global_and_project_instructions(
685 &global_text,
686 global_ctx.source_path.as_deref(),
687 &project_text,
688 ));
689 // Leave `ctx.source_path` pointing at the project /
690 // parent file — that's the location the user might
691 // want to edit when something looks wrong.
692 }
693 None => {
694 ctx.instructions = Some(global_text);
695 ctx.source_path = global_ctx.source_path;
696 }
697 }
698 }
699 }
700
701 // Generate a bounded in-memory fallback when no context file exists
702 // anywhere. This keeps prompt shape stable without creating project-local
703 // `.codewhale/` files merely because Codewhale was opened in a directory.
704 if !ctx.has_instructions()
705 && let Some(generated) = generate_ephemeral_context(workspace)
706 {
707 ctx.instructions = Some(generated);
708 ctx.source_path = None;
709 }
710
711 // Load the Codewhale-specific repo authority policy
712 // (.codewhale/constitution.json) independently of the prose instructions —
713 // it is a distinct, higher-authority artifact and may exist with or without
714 // an AGENTS.md. Legacy WHALE.md files are ignored and reported as
715 // migration-only diagnostics.
716 // Loaded last so the auto-generate fallback above (which rebuilds `ctx`)
717 // cannot clobber it.
718 let (constitution_block, constitution_source_path, constitution_warnings) =
719 load_repo_constitution_block(workspace);
720 ctx.warnings.extend(constitution_warnings);
721 ctx.constitution_block = constitution_block;
722 ctx.constitution_source_path = constitution_source_path;
723
724 // The chain and the global layer were both rebuilt above, so re-apply the
725 // one ceiling here. Without this the merged global block was entirely
726 // unbudgeted: the old per-chain accounting closed before the merge.
727 enforce_project_instruction_budget(&mut ctx);
728
729 ctx
730 }
731
732 pub(crate) fn project_context_cache_candidate_paths(
733 workspace: &Path,
734 home_dir: Option<&Path>,
735 ) -> Vec<PathBuf> {
736 let workspace = canonicalize_workspace_or_keep(workspace);
737 let mut paths = Vec::new();
738
739 // Enumerate the superset of instruction candidates, not just the ones the
740 // active opt-in set loads: a `CLAUDE.md` that is *not* imported still
741 // decides whether the "not loaded" warning fires, so its content has to
742 // invalidate the cache too. Changing the opt-in set clears the cache
743 // outright (`set_foreign_instruction_imports`), so over-enumerating here
744 // only ever costs an extra reload.
745 for dir in context_chain_dirs(&workspace, home_dir) {
746 for filename in PROJECT_CONTEXT_FILES {
747 paths.push(dir.join(filename));
748 }
749 paths.push(dir.join(DEPRECATED_WHALE_FILENAME));
750 }
751
752 if let Some(home) = home_dir {
753 for candidate in global_context_relative_paths() {
754 paths.push(join_relative_components(home, candidate));
755 }
756 for candidate in legacy_global_whale_relative_paths() {
757 paths.push(join_relative_components(home, candidate));
758 }
759 }
760
761 paths.extend(repo_constitution_candidate_paths(&workspace));
762 paths.push(workspace.join(".deepseek").join("trusted"));
763 paths.push(workspace.join(".deepseek").join("trust.json"));
764 paths.extend(crate::config::workspace_trust_config_candidate_paths());
765
766 // Include auto-discovered rules directory files so cache invalidates
767 // when rules change (not just when AGENTS.md changes).
768 for rules_dir in RULES_DIRS {
769 let dir_path = workspace.join(rules_dir);
770 // Skip symlinked rules directories (same guard as load_rules_from_dir)
771 if fs::symlink_metadata(&dir_path)
772 .map(|m| m.file_type().is_symlink())
773 .unwrap_or(false)
774 {
775 continue;
776 }
777 if let Ok(entries) = std::fs::read_dir(&dir_path) {
778 for entry in entries.flatten() {
779 let path = entry.path();
780 if path.extension().is_some_and(|ext| ext == "md") {
781 paths.push(path);
782 }
783 }
784 }
785 }
786
787 // The warning for unimported foreign formats is part of the cached
788 // ProjectContext. Fingerprint exactly the safe, capped fragment files the
789 // bounded loader can select so creating, changing, removing, or making one
790 // unusable invalidates a previously cached warning decision. Enumerating
791 // every format is intentional: changing the opt-in set clears the cache,
792 // and the superset keeps disabled-format discovery correct.
793 for format in ForeignInstructionFormat::ALL {
794 paths.extend(
795 codewhale_core::fragments::selected_project_instruction_candidate_files(
796 &workspace,
797 format.fragment_candidates(),
798 ),
799 );
800 }
801
802 paths
803 }
804
805 fn global_context_relative_paths() -> [&'static [&'static str]; 6] {
806 [
807 GLOBAL_AGENTS_RELATIVE_PATH,
808 GLOBAL_AGENTS_VENDOR_NEUTRAL_PATH,
809 GLOBAL_AGENTS_LEGACY_PATH,
810 GLOBAL_INSTRUCTIONS_RELATIVE_PATH,
811 GLOBAL_INSTRUCTIONS_VENDOR_NEUTRAL_PATH,
812 GLOBAL_INSTRUCTIONS_LEGACY_PATH,
813 ]
814 }
815
816 fn legacy_global_whale_relative_paths() -> [&'static [&'static str]; 3] {
817 [
818 GLOBAL_WHALE_RELATIVE_PATH,
819 GLOBAL_WHALE_VENDOR_NEUTRAL_PATH,
820 GLOBAL_WHALE_LEGACY_PATH,
821 ]
822 }
823
824 fn join_relative_components(base: &Path, relative: &[&str]) -> PathBuf {
825 let mut path = base.to_path_buf();
826 for component in relative {
827 path.push(component);
828 }
829 path
830 }
831
832 fn ignored_project_whale_warnings(dir: &Path) -> Vec<String> {
833 let path = dir.join(DEPRECATED_WHALE_FILENAME);
834 ignored_whale_warning_for_path(&path).into_iter().collect()
835 }
836
837 fn ignored_global_whale_warnings(home: &Path) -> Vec<String> {
838 legacy_global_whale_relative_paths()
839 .iter()
840 .filter_map(|candidate| {
841 let path = join_relative_components(home, candidate);
842 ignored_whale_warning_for_path(&path)
843 })
844 .collect()
845 }
846
847 fn ignored_whale_warning_for_path(path: &Path) -> Option<String> {
848 context_candidate_exists(path)
849 .then(|| format!("{WHALE_IGNORED_WARNING} Ignored file: {}", path.display()))
850 }
851
852 fn canonicalize_workspace_or_keep(workspace: &Path) -> PathBuf {
853 fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf())
854 }
855
856 /// Find the root of the checkout that contains `dir`.
857 ///
858 /// Walks upward looking for a `.git` entry, following Git's own discovery
859 /// semantics: a `.git` directory must hold `HEAD`, and a `.git` file must be
860 /// a `gitdir:` pointer (a linked worktree). A linked worktree is therefore
861 /// its own root — the main checkout is reachable only through that pointer,
862 /// never through directory heuristics, branch names, or paths mentioned in
863 /// conversation. This is the single source of truth for repository identity
864 /// in project-context scope resolution.
865 pub(crate) fn find_git_root(dir: &Path) -> Option<PathBuf> {
866 let mut current = dir.to_path_buf();
867 loop {
868 let git_entry = current.join(".git");
869 if is_git_metadata_entry(&git_entry) {
870 return Some(current);
871 }
872 match current.parent() {
873 Some(parent) if parent != current => current = parent.to_path_buf(),
874 _ => return None,
875 }
876 }
877 }
878
879 fn is_git_metadata_entry(path: &Path) -> bool {
880 if path.is_dir() {
881 return path.join("HEAD").is_file();
882 }
883
884 fs::read_to_string(path)
885 .map(|content| content.trim_start().starts_with("gitdir:"))
886 .unwrap_or(false)
887 }
888
889 /// Directories whose instruction files apply to `workspace`, ordered from the
890 /// repository root down to the workspace (inclusive).
891 ///
892 /// Repository identity comes from the containing checkout itself
893 /// ([`find_git_root`]); the chain never crosses the repository boundary, so
894 /// sibling checkouts and unrelated parents stay out of scope. Outside any
895 /// repository only the workspace itself is searched. When `home_dir` is an
896 /// ancestor it remains an outer boundary the walk never leaves.
897 fn context_chain_dirs(workspace: &Path, home_dir: Option<&Path>) -> Vec<PathBuf> {
898 let mut stop = find_git_root(workspace).unwrap_or_else(|| workspace.to_path_buf());
899
900 if let Some(home) = home_dir {
901 let home = canonicalize_workspace_or_keep(home);
902 // Clamp only when the walk would otherwise leave the user's home
903 // (home sits between the workspace and the repository root).
904 if workspace.starts_with(&home) && home.starts_with(&stop) {
905 stop = home;
906 }
907 }
908
909 let mut dirs = Vec::new();
910 let mut cursor = workspace.to_path_buf();
911 loop {
912 dirs.push(cursor.clone());
913 if cursor == stop {
914 break;
915 }
916 match cursor.parent() {
917 Some(parent) if parent != cursor => cursor = parent.to_path_buf(),
918 _ => break,
919 }
920 }
921 dirs.reverse();
922 dirs
923 }
924
925 /// Append one chain segment to the assembled instruction text.
926 ///
927 /// The first segment is the file's raw content (a single-file chain stays
928 /// byte-identical to a plain load); every later segment is prefixed with a
929 /// provenance label so the model can tell the scopes apart, wider scopes
930 /// first and the workspace last.
931 fn append_chain_segment(assembled: &mut String, path: &Path, content: &str) {
932 if !assembled.is_empty() {
933 assembled.push_str(&format!(
934 "\n\n<!-- scoped instructions: {} (overrides wider scopes where they conflict) -->\n",
935 path.display()
936 ));
937 }
938 assembled.push_str(content);
939 }
940
941 /// Combine global user-wide preferences with a project-local
942 /// AGENTS.md/CLAUDE.md/instructions.md. Global comes first so
943 /// workspace-specific rules can override it — the model reads in declared
944 /// order. Each block is wrapped in a labelled fence so the model can tell
945 /// which level any rule comes from when the two sets disagree (#1157).
946 fn merge_global_and_project_instructions(
947 global: &str,
948 global_source: Option<&Path>,
949 project: &str,
950 ) -> String {
951 let global_label = global_source
952 .map(|p| format!("<!-- global: {} -->", p.display()))
953 .unwrap_or_else(|| "<!-- global -->".to_string());
954 format!(
955 "{global_label}\n{}\n\n<!-- project (overrides global where they conflict) -->\n{}",
956 global.trim_end(),
957 project.trim_start(),
958 )
959 }
960
961 fn load_global_agents_context(workspace: &Path, home_dir: Option<&Path>) -> Option<ProjectContext> {
962 let home = home_dir?;
963
964 // Priority order (AGENTS.md preferred; instructions.md next, #3012):
965 // 1. ~/.codewhale/AGENTS.md (canonical)
966 // 2. ~/.agents/AGENTS.md (vendor-neutral fallback)
967 // 3. ~/.deepseek/AGENTS.md (legacy fallback)
968 // 4. ~/.codewhale/instructions.md (canonical)
969 // 5. ~/.agents/instructions.md (vendor-neutral fallback)
970 // 6. ~/.deepseek/instructions.md (legacy fallback)
971 // Global WHALE.md files are ignored and reported as migration-only
972 // diagnostics, never loaded as fallback law.
973 let mut warnings = ignored_global_whale_warnings(home);
974
975 for candidate in global_context_relative_paths() {
976 let path = join_relative_components(home, candidate);
977
978 if context_candidate_exists(&path) {
979 match load_global_context_file(&path) {
980 Ok(content) => {
981 let mut ctx = ProjectContext::empty(workspace.to_path_buf());
982 ctx.instructions = Some(content);
983 ctx.source_path = Some(path);
984 ctx.warnings = warnings;
985 return Some(ctx);
986 }
987 Err(error) => warnings.push(error.to_string()),
988 }
989 }
990 }
991
992 if !warnings.is_empty() {
993 let mut ctx = ProjectContext::empty(workspace.to_path_buf());
994 ctx.warnings = warnings;
995 return Some(ctx);
996 }
997
998 None
999 }
1000
1001 /// Generate ephemeral context from the project tree. Returns the generated
1002 /// content on success without writing workspace files.
1003 fn generate_ephemeral_context(workspace: &Path) -> Option<String> {
1004 let overview = generate_bounded_project_overview(workspace)?;
1005
1006 Some(format!(
1007 "# Project Context (Auto-generated, ephemeral)\n\n\
1008 > This context was generated in memory by Codewhale.\n\
1009 > No .codewhale/instructions.md file was written.\n\n\
1010 {overview}"
1011 ))
1012 }
1013
1014 /// Load a context file with size checking
1015 fn load_context_file(path: &Path) -> Result<String, ProjectContextError> {
1016 load_context_file_with_symlink_policy(path, false)
1017 }
1018
1019 /// Load a user-level context file, following a symlink to its target.
1020 ///
1021 /// The refusal in [`load_context_file`] protects checkouts: a link planted in
1022 /// an untrusted repository could point the loader at anything on the machine.
1023 /// The user-level layer is the operator's own file in `$HOME`, where that
1024 /// escape does not apply and a symlink is the ordinary way to share one
1025 /// instruction set between agents (`~/.deepseek/AGENTS.md` -> `~/AGENTS.md`).
1026 /// Refusing it there failed silently: the refusal is collected as a warning,
1027 /// so the whole user-level layer was dropped without any visible error.
1028 fn load_global_context_file(path: &Path) -> Result<String, ProjectContextError> {
1029 load_context_file_with_symlink_policy(path, true)
1030 }
1031
1032 /// Resolve a symlinked context file to its target.
1033 ///
1034 /// `open_context_file` opens with `O_NOFOLLOW`, so the resolved target is what
1035 /// must be opened, never the link itself.
1036 fn resolve_symlinked_context_path(path: &Path) -> Result<PathBuf, ProjectContextError> {
1037 let metadata = fs::symlink_metadata(path).map_err(|source| ProjectContextError::Metadata {
1038 path: path.to_path_buf(),
1039 source,
1040 })?;
1041 if !metadata.file_type().is_symlink() {
1042 return Ok(path.to_path_buf());
1043 }
1044 fs::canonicalize(path).map_err(|source| ProjectContextError::Metadata {
1045 path: path.to_path_buf(),
1046 source,
1047 })
1048 }
1049
1050 fn load_context_file_with_symlink_policy(
1051 path: &Path,
1052 follow_symlinks: bool,
1053 ) -> Result<String, ProjectContextError> {
1054 let resolved = follow_symlinks
1055 .then(|| resolve_symlinked_context_path(path))
1056 .transpose()?;
1057 let path = resolved.as_deref().unwrap_or(path);
1058 let metadata = fs::symlink_metadata(path).map_err(|source| ProjectContextError::Metadata {
1059 path: path.to_path_buf(),
1060 source,
1061 })?;
1062
1063 let file_type = metadata.file_type();
1064 if file_type.is_symlink() {
1065 return Err(ProjectContextError::Symlink {
1066 path: path.to_path_buf(),
1067 });
1068 }
1069
1070 if !file_type.is_file() {
1071 return Err(ProjectContextError::NotFile {
1072 path: path.to_path_buf(),
1073 });
1074 }
1075
1076 let mut file = open_context_file(path)?;
1077 let metadata = file
1078 .metadata()
1079 .map_err(|source| ProjectContextError::Metadata {
1080 path: path.to_path_buf(),
1081 source,
1082 })?;
1083 if metadata.len() > MAX_CONTEXT_SIZE as u64 {
1084 return Err(ProjectContextError::TooLarge {
1085 path: path.to_path_buf(),
1086 size: metadata.len(),
1087 max: MAX_CONTEXT_SIZE,
1088 });
1089 }
1090
1091 let mut content = String::new();
1092 file.read_to_string(&mut content)
1093 .map_err(|source| ProjectContextError::Read {
1094 path: path.to_path_buf(),
1095 source,
1096 })?;
1097
1098 // Basic validation
1099 if content.trim().is_empty() {
1100 return Err(ProjectContextError::Empty {
1101 path: path.to_path_buf(),
1102 });
1103 }
1104
1105 Ok(content)
1106 }
1107
1108 fn context_candidate_exists(path: &Path) -> bool {
1109 fs::symlink_metadata(path).is_ok_and(|metadata| {
1110 let file_type = metadata.file_type();
1111 file_type.is_file() || file_type.is_symlink()
1112 })
1113 }
1114
1115 /// Scan a rules directory for `.md` files and load them in filename order.
1116 /// Missing or unreadable directories return an empty vec (no error).
1117 /// Each file is verified through `load_context_file` (size check, symlink safety).
1118 fn load_rules_from_dir(workspace: &Path, rules_dir_name: &str) -> Vec<(PathBuf, String)> {
1119 let rules_dir = workspace.join(rules_dir_name);
1120 let mut entries: Vec<(PathBuf, String)> = Vec::new();
1121
1122 // Refuse a symlinked rules directory: the real .md files behind it
1123 // would pass per-file is_symlink checks and be read from outside the
1124 // workspace subtree — same escape class as #417.
1125 if fs::symlink_metadata(&rules_dir)
1126 .map(|m| m.file_type().is_symlink())
1127 .unwrap_or(false)
1128 {
1129 tracing::warn!(
1130 target: "project_context",
1131 dir = %rules_dir.display(),
1132 "Refusing symlinked rules directory"
1133 );
1134 return entries;
1135 }
1136
1137 let dir_iter = match fs::read_dir(&rules_dir) {
1138 Ok(iter) => iter,
1139 Err(_) => return entries,
1140 };
1141
1142 let mut file_paths: Vec<PathBuf> = Vec::new();
1143 for entry in dir_iter.flatten() {
1144 let path = entry.path();
1145 if path.extension().is_some_and(|ext| ext == "md") && context_candidate_exists(&path) {
1146 file_paths.push(path);
1147 }
1148 }
1149
1150 // Sort by filename for deterministic order
1151 file_paths.sort_by(|a, b| {
1152 a.file_name()
1153 .unwrap_or_default()
1154 .cmp(b.file_name().unwrap_or_default())
1155 });
1156
1157 // Enforce per-directory cap
1158 let total = file_paths.len();
1159 if total > MAX_RULES_FILES {
1160 tracing::warn!(
1161 target: "project_context",
1162 dir = %rules_dir.display(),
1163 total,
1164 cap = MAX_RULES_FILES,
1165 "Truncating rules directory to cap"
1166 );
1167 file_paths.truncate(MAX_RULES_FILES);
1168 }
1169
1170 for path in file_paths {
1171 match load_context_file(&path) {
1172 Ok(content) => {
1173 tracing::info!(
1174 "Loaded project rule from {} ({} bytes)",
1175 path.display(),
1176 content.len()
1177 );
1178 entries.push((path, content));
1179 }
1180 Err(error) => {
1181 tracing::warn!(
1182 target: "project_context",
1183 ?error,
1184 ?path,
1185 "Skipping unreadable rules file"
1186 );
1187 }
1188 }
1189 }
1190
1191 entries
1192 }
1193
1194 #[cfg(unix)]
1195 fn open_context_file(path: &Path) -> Result<fs::File, ProjectContextError> {
1196 use std::os::unix::fs::OpenOptionsExt;
1197
1198 fs::OpenOptions::new()
1199 .read(true)
1200 .custom_flags(libc::O_NOFOLLOW)
1201 .open(path)
1202 .map_err(|source| ProjectContextError::Read {
1203 path: path.to_path_buf(),
1204 source,
1205 })
1206 }
1207
1208 #[cfg(not(unix))]
1209 fn open_context_file(path: &Path) -> Result<fs::File, ProjectContextError> {
1210 fs::File::open(path).map_err(|source| ProjectContextError::Read {
1211 path: path.to_path_buf(),
1212 source,
1213 })
1214 }
1215
1216 /// Check if this project is marked as trusted
1217 fn check_trust_status(workspace: &Path) -> bool {
1218 if crate::config::is_workspace_trusted(workspace) {
1219 return true;
1220 }
1221
1222 // Check for trust markers
1223 let trust_markers = [
1224 workspace.join(".deepseek").join("trusted"),
1225 workspace.join(".deepseek").join("trust.json"),
1226 ];
1227
1228 for marker in &trust_markers {
1229 if marker.exists() {
1230 return true;
1231 }
1232 }
1233
1234 false
1235 }
1236
1237 /// Create a default AGENTS.md file for a project
1238 pub fn create_default_agents_md(workspace: &Path) -> std::io::Result<PathBuf> {
1239 let agents_path = workspace.join("AGENTS.md");
1240
1241 let default_content = r#"# Project Agent Instructions
1242
1243 This file provides guidance to AI agents (Codewhale, Claude Code, etc.) when working with code in this repository.
1244
1245 ## File Location
1246
1247 Save this file as `AGENTS.md` in your project root so the CLI can load it automatically.
1248
1249 ## Build and Development Commands
1250
1251 ```bash
1252 # Build
1253 # cargo build # Rust projects
1254 # npm run build # Node.js projects
1255 # python -m build # Python projects
1256
1257 # Test
1258 # cargo test # Rust
1259 # npm test # Node.js
1260 # pytest # Python
1261
1262 # Lint and Format
1263 # cargo fmt && cargo clippy # Rust
1264 # npm run lint # Node.js
1265 # ruff check . # Python
1266 ```
1267
1268 ## Architecture Overview
1269
1270 <!-- Describe your project's high-level architecture here -->
1271 <!-- Focus on the "big picture" that requires reading multiple files to understand -->
1272
1273 ### Key Components
1274
1275 <!-- List and describe the main components/modules -->
1276
1277 ### Data Flow
1278
1279 <!-- Describe how data flows through the system -->
1280
1281 ## Configuration Files
1282
1283 <!-- List important configuration files and their purposes -->
1284
1285 ## Extension Points
1286
1287 <!-- Describe how to extend the codebase (add new features, tools, etc.) -->
1288
1289 ## Commit Messages
1290
1291 Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`
1292 "#;
1293
1294 fs::write(&agents_path, default_content)?;
1295 Ok(agents_path)
1296 }
1297
1298 // === Effective instruction-source listing (#6168) ===
1299
1300 /// Which layer of the instruction stack a source belongs to.
1301 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1302 pub enum InstructionSourceKind {
1303 /// Chain candidate (`AGENTS.md`, `.codewhale/instructions.md`, opted-in
1304 /// foreign files) in a repository-root → workspace scope directory.
1305 Project,
1306 /// `.md` file inside a rules directory (`.codewhale/rules`, opted-in
1307 /// foreign rules dirs) at workspace scope.
1308 Rule,
1309 /// User-level fallback (`~/.codewhale/AGENTS.md` and friends).
1310 Global,
1311 /// File selected by the bounded foreign-fragment loader
1312 /// (`codewhale_core::fragments`) for an opted-in format.
1313 Fragment,
1314 /// Configured `instructions = [...]` file (#454).
1315 Configured,
1316 /// `.codewhale/constitution.json` authority policy.
1317 Constitution,
1318 /// Present but deliberately never loaded (deprecated `WHALE.md`).
1319 Ignored,
1320 }
1321
1322 /// Whether the prompt assembly actually consumed the file.
1323 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1324 pub enum InstructionSourceStatus {
1325 /// Read and merged into the assembled instructions.
1326 Loaded,
1327 /// Exists, but a higher-priority candidate in the same scope won.
1328 Shadowed,
1329 /// Exists but the loader refused, could not read, or skipped it
1330 /// (empty, oversized, over the per-directory rules cap).
1331 Skipped,
1332 /// Candidate path the loader checks; nothing is there.
1333 Missing,
1334 }
1335
1336 /// One instruction-source candidate or loaded file, for the
1337 /// workspace-instructions listing route.
1338 #[derive(Debug, Clone)]
1339 pub struct InstructionSourceInfo {
1340 pub kind: InstructionSourceKind,
1341 /// Scope directory the candidate belongs to: the chain directory for
1342 /// `Project`, the workspace for `Rule`/`Fragment`/`Configured`, the home
1343 /// directory for `Global`.
1344 pub scope_dir: PathBuf,
1345 /// Candidate or actual file path, as the loader resolves it.
1346 pub path: PathBuf,
1347 /// `symlink_metadata` saw a file or symlink (same check the loader runs).
1348 pub exists: bool,
1349 pub status: InstructionSourceStatus,
1350 /// File size in bytes when it could be stat'd.
1351 pub bytes: Option<u64>,
1352 /// Loader warning produced for this path, when one exists.
1353 pub warning: Option<String>,
1354 }
1355
1356 fn file_len(path: &Path) -> Option<u64> {
1357 fs::metadata(path).ok().map(|m| m.len())
1358 }
1359
1360 fn push_candidate_entry(
1361 sources: &mut Vec<InstructionSourceInfo>,
1362 kind: InstructionSourceKind,
1363 scope_dir: &Path,
1364 path: PathBuf,
1365 scope_loaded: &mut bool,
1366 load: impl FnOnce(&Path) -> Result<String, ProjectContextError>,
1367 ) {
1368 let exists = context_candidate_exists(&path);
1369 let (status, warning) = if !exists {
1370 (InstructionSourceStatus::Missing, None)
1371 } else if *scope_loaded {
1372 (InstructionSourceStatus::Shadowed, None)
1373 } else {
1374 match load(&path) {
1375 Ok(_) => {
1376 *scope_loaded = true;
1377 (InstructionSourceStatus::Loaded, None)
1378 }
1379 Err(error) => (InstructionSourceStatus::Skipped, Some(error.to_string())),
1380 }
1381 };
1382 sources.push(InstructionSourceInfo {
1383 kind,
1384 scope_dir: scope_dir.to_path_buf(),
1385 exists,
1386 bytes: file_len(&path),
1387 path,
1388 status,
1389 warning,
1390 });
1391 }
1392
1393 /// Enumerate the effective instruction sources for `workspace` (#6168).
1394 ///
1395 /// Mirrors the loaders above — same candidate order, same existence and
1396 /// readability checks — so the listing reports what prompt assembly actually
1397 /// picks up: precedence, shadowing, opt-in imports, and refusal warnings.
1398 /// Read-only; it never creates or modifies files. `configured` is the
1399 /// resolved `instructions = [...]` array (`Config::instructions_paths`);
1400 /// `home_dir` gates the global layer exactly as
1401 /// `load_project_context_with_parents_and_home` does.
1402 ///
1403 /// Known limit: statuses describe file selection, not the aggregate byte
1404 /// budget — `enforce_project_instruction_budget` can still trim loaded
1405 /// content at render time without changing a source's `Loaded` status.
1406 #[must_use]
1407 pub fn project_instruction_sources(
1408 workspace: &Path,
1409 home_dir: Option<&Path>,
1410 configured: &[PathBuf],
1411 ) -> Vec<InstructionSourceInfo> {
1412 let imports = &foreign_instruction_imports();
1413 let workspace = canonicalize_workspace_or_keep(workspace);
1414 let mut sources = Vec::new();
1415
1416 // Repository-root → workspace instruction chain. Same dir order the
1417 // loader assembles, so wider scopes list first.
1418 for dir in context_chain_dirs(&workspace, home_dir) {
1419 let whale = dir.join(DEPRECATED_WHALE_FILENAME);
1420 if context_candidate_exists(&whale) {
1421 sources.push(InstructionSourceInfo {
1422 kind: InstructionSourceKind::Ignored,
1423 scope_dir: dir.clone(),
1424 path: whale.clone(),
1425 exists: true,
1426 status: InstructionSourceStatus::Skipped,
1427 bytes: file_len(&whale),
1428 warning: Some(WHALE_IGNORED_WARNING.to_string()),
1429 });
1430 }
1431 let mut scope_loaded = false;
1432 for filename in context_files_for(imports) {
1433 push_candidate_entry(
1434 &mut sources,
1435 InstructionSourceKind::Project,
1436 &dir,
1437 dir.join(filename),
1438 &mut scope_loaded,
1439 load_context_file,
1440 );
1441 }
1442 }
1443
1444 // Workspace-scope rules directories — the chain loader never reads
1445 // ancestor rules, so only the workspace contributes them.
1446 for rules_dir_name in rules_dirs_for(imports) {
1447 let rules_dir = workspace.join(rules_dir_name);
1448 if fs::symlink_metadata(&rules_dir)
1449 .map(|m| m.file_type().is_symlink())
1450 .unwrap_or(false)
1451 {
1452 // Same refusal as `load_rules_from_dir`, surfaced rather than
1453 // logged-and-dropped.
1454 sources.push(InstructionSourceInfo {
1455 kind: InstructionSourceKind::Rule,
1456 scope_dir: workspace.clone(),
1457 path: rules_dir,
1458 exists: true,
1459 status: InstructionSourceStatus::Skipped,
1460 bytes: None,
1461 warning: Some("refusing symlinked rules directory".to_string()),
1462 });
1463 continue;
1464 }
1465 let Ok(dir_entries) = fs::read_dir(&rules_dir) else {
1466 continue;
1467 };
1468 let mut file_paths: Vec<PathBuf> = dir_entries
1469 .flatten()
1470 .map(|entry| entry.path())
1471 .filter(|path| {
1472 path.extension().is_some_and(|ext| ext == "md") && context_candidate_exists(path)
1473 })
1474 .collect();
1475 file_paths.sort_by(|a, b| {
1476 a.file_name()
1477 .unwrap_or_default()
1478 .cmp(b.file_name().unwrap_or_default())
1479 });
1480 for (index, path) in file_paths.into_iter().enumerate() {
1481 let (status, warning) = if index >= MAX_RULES_FILES {
1482 (
1483 InstructionSourceStatus::Skipped,
1484 Some(format!(
1485 "beyond the per-directory rules cap of {MAX_RULES_FILES} files"
1486 )),
1487 )
1488 } else {
1489 match load_context_file(&path) {
1490 Ok(_) => (InstructionSourceStatus::Loaded, None),
1491 Err(error) => (InstructionSourceStatus::Skipped, Some(error.to_string())),
1492 }
1493 };
1494 sources.push(InstructionSourceInfo {
1495 kind: InstructionSourceKind::Rule,
1496 scope_dir: workspace.clone(),
1497 path: path.clone(),
1498 exists: true,
1499 status,
1500 bytes: file_len(&path),
1501 warning,
1502 });
1503 }
1504 }
1505
1506 // User-level fallback layer.
1507 if let Some(home) = home_dir {
1508 for relative in legacy_global_whale_relative_paths() {
1509 let path = join_relative_components(home, relative);
1510 if context_candidate_exists(&path) {
1511 sources.push(InstructionSourceInfo {
1512 kind: InstructionSourceKind::Ignored,
1513 scope_dir: home.to_path_buf(),
1514 path: path.clone(),
1515 exists: true,
1516 status: InstructionSourceStatus::Skipped,
1517 bytes: file_len(&path),
1518 warning: Some(WHALE_IGNORED_WARNING.to_string()),
1519 });
1520 }
1521 }
1522 let mut scope_loaded = false;
1523 for relative in global_context_relative_paths() {
1524 push_candidate_entry(
1525 &mut sources,
1526 InstructionSourceKind::Global,
1527 home,
1528 join_relative_components(home, relative),
1529 &mut scope_loaded,
1530 load_global_context_file,
1531 );
1532 }
1533 }
1534
1535 // Opted-in foreign fragments — enumerate the declared candidates, then
1536 // mark the files the bounded loader's own selection walk picks.
1537 let fragment_candidates = fragment_candidates_for(imports);
1538 let selected: std::collections::BTreeSet<PathBuf> =
1539 codewhale_core::fragments::selected_project_instruction_candidate_files(
1540 &workspace,
1541 &fragment_candidates,
1542 )
1543 .into_iter()
1544 .collect();
1545 let mut emitted = std::collections::BTreeSet::new();
1546 for candidate in &fragment_candidates {
1547 let path = workspace.join(candidate);
1548 if !path.exists() {
1549 sources.push(InstructionSourceInfo {
1550 kind: InstructionSourceKind::Fragment,
1551 scope_dir: workspace.clone(),
1552 path,
1553 exists: false,
1554 status: InstructionSourceStatus::Missing,
1555 bytes: None,
1556 warning: None,
1557 });
1558 continue;
1559 }
1560 // A directory candidate's loadable files are the selected entries
1561 // underneath it; report those rather than the directory itself.
1562 let members: Vec<PathBuf> = selected
1563 .iter()
1564 .filter(|file| file.starts_with(&path))
1565 .cloned()
1566 .collect();
1567 if path.is_dir() {
1568 if members.is_empty() {
1569 sources.push(InstructionSourceInfo {
1570 kind: InstructionSourceKind::Fragment,
1571 scope_dir: workspace.clone(),
1572 path,
1573 exists: true,
1574 status: InstructionSourceStatus::Skipped,
1575 bytes: None,
1576 warning: Some(
1577 "no loadable .md files selected under this directory".to_string(),
1578 ),
1579 });
1580 }
1581 for file in members {
1582 emitted.insert(file.clone());
1583 let loaded = fs::read_to_string(&file)
1584 .map(|content| !content.trim().is_empty())
1585 .unwrap_or(false);
1586 sources.push(InstructionSourceInfo {
1587 kind: InstructionSourceKind::Fragment,
1588 scope_dir: workspace.clone(),
1589 path: file.clone(),
1590 exists: true,
1591 status: if loaded {
1592 InstructionSourceStatus::Loaded
1593 } else {
1594 InstructionSourceStatus::Skipped
1595 },
1596 bytes: file_len(&file),
1597 warning: None,
1598 });
1599 }
1600 } else {
1601 emitted.insert(path.clone());
1602 let selected_file = selected.contains(&path);
1603 let loaded = selected_file
1604 && fs::read_to_string(&path)
1605 .map(|content| !content.trim().is_empty())
1606 .unwrap_or(false);
1607 sources.push(InstructionSourceInfo {
1608 kind: InstructionSourceKind::Fragment,
1609 scope_dir: workspace.clone(),
1610 path: path.clone(),
1611 exists: true,
1612 status: if loaded {
1613 InstructionSourceStatus::Loaded
1614 } else if selected_file {
1615 InstructionSourceStatus::Skipped
1616 } else {
1617 // Exists but not selected — e.g. a symlink the walk refuses.
1618 InstructionSourceStatus::Skipped
1619 },
1620 bytes: file_len(&path),
1621 warning: if selected_file && !loaded {
1622 Some("selected but empty or unreadable".to_string())
1623 } else {
1624 None
1625 },
1626 });
1627 }
1628 }
1629 // Selected files not attributed to a listed candidate (nested rules dir
1630 // members) still get reported.
1631 for file in selected.iter().filter(|file| !emitted.contains(*file)) {
1632 let loaded = fs::read_to_string(file)
1633 .map(|content| !content.trim().is_empty())
1634 .unwrap_or(false);
1635 sources.push(InstructionSourceInfo {
1636 kind: InstructionSourceKind::Fragment,
1637 scope_dir: workspace.clone(),
1638 path: file.clone(),
1639 exists: true,
1640 status: if loaded {
1641 InstructionSourceStatus::Loaded
1642 } else {
1643 InstructionSourceStatus::Skipped
1644 },
1645 bytes: file_len(file),
1646 warning: None,
1647 });
1648 }
1649
1650 // Configured `instructions = [...]` files (#454) — resolved paths, in
1651 // declared order. The renderer skips missing/empty files with a warning.
1652 for path in configured {
1653 let exists = context_candidate_exists(path);
1654 let (status, warning) = match fs::read_to_string(path) {
1655 Ok(content) if !content.trim().is_empty() => (InstructionSourceStatus::Loaded, None),
1656 Ok(_) => (
1657 InstructionSourceStatus::Skipped,
1658 Some("empty instructions file".to_string()),
1659 ),
1660 Err(error) => (
1661 InstructionSourceStatus::Skipped,
1662 Some(format!("unreadable instructions file: {error}")),
1663 ),
1664 };
1665 sources.push(InstructionSourceInfo {
1666 kind: InstructionSourceKind::Configured,
1667 scope_dir: workspace.clone(),
1668 path: path.clone(),
1669 exists,
1670 status,
1671 bytes: file_len(path),
1672 warning,
1673 });
1674 }
1675
1676 // `.codewhale/constitution.json`, workspace → repository root. The loader
1677 // stops at the first existing candidate whether it parses or not, so
1678 // later candidates — even ones that exist — are never evaluated.
1679 let (_block, constitution_source, _warnings) = load_repo_constitution_block(&workspace);
1680 let mut seen_existing = false;
1681 for path in repo_constitution_candidate_paths(&workspace) {
1682 let exists = context_candidate_exists(&path);
1683 let status = if !exists {
1684 InstructionSourceStatus::Missing
1685 } else if seen_existing {
1686 InstructionSourceStatus::Shadowed
1687 } else {
1688 seen_existing = true;
1689 if constitution_source.as_deref() == Some(path.as_path()) {
1690 InstructionSourceStatus::Loaded
1691 } else {
1692 InstructionSourceStatus::Skipped
1693 }
1694 };
1695 sources.push(InstructionSourceInfo {
1696 kind: InstructionSourceKind::Constitution,
1697 scope_dir: workspace.clone(),
1698 path: path.clone(),
1699 exists,
1700 status,
1701 bytes: file_len(&path),
1702 warning: None,
1703 });
1704 }
1705
1706 sources
1707 }
1708
1709 // === Unit Tests ===
1710
1711 #[cfg(test)]
1712 mod tests {
1713 use super::*;
1714 use tempfile::tempdir;
1715
1716 #[test]
1717 fn test_load_project_context_empty() {
1718 let tmp = tempdir().expect("tempdir");
1719 let ctx = load_project_context(tmp.path());
1720
1721 assert!(!ctx.has_instructions());
1722 assert!(ctx.source_path.is_none());
1723 }
1724
1725 #[test]
1726 fn test_load_project_context_agents_md() {
1727 let tmp = tempdir().expect("tempdir");
1728 let agents_path = tmp.path().join("AGENTS.md");
1729 fs::write(&agents_path, "# Test Instructions\n\nFollow these rules.").expect("write");
1730
1731 let ctx = load_project_context(tmp.path());
1732
1733 assert!(ctx.has_instructions());
1734 assert!(
1735 ctx.instructions
1736 .as_ref()
1737 .unwrap()
1738 .contains("Test Instructions")
1739 );
1740 assert_eq!(ctx.source_path, Some(agents_path));
1741 }
1742
1743 #[cfg(unix)]
1744 #[test]
1745 fn project_context_rejects_symlinked_agents_md() {
1746 let workspace = tempdir().expect("workspace tempdir");
1747 let outside = tempdir().expect("outside tempdir");
1748 let outside_agents = outside.path().join("AGENTS.md");
1749 fs::write(&outside_agents, "outside instructions").expect("write outside agents");
1750 std::os::unix::fs::symlink(&outside_agents, workspace.path().join("AGENTS.md"))
1751 .expect("symlink agents");
1752
1753 let ctx = load_project_context(workspace.path());
1754
1755 assert!(
1756 !ctx.has_instructions(),
1757 "symlinked project instructions must not be loaded: {:?}",
1758 ctx.instructions
1759 );
1760 assert!(
1761 ctx.warnings.iter().any(|w| w.contains("symlinked")),
1762 "expected symlink warning, got {:?}",
1763 ctx.warnings
1764 );
1765 }
1766
1767 #[test]
1768 fn test_load_project_context_priority() {
1769 let tmp = tempdir().expect("tempdir");
1770
1771 // Create both files - AGENTS.md should take priority
1772 fs::write(tmp.path().join("AGENTS.md"), "AGENTS content").expect("write");
1773 let claude_dir = tmp.path().join(".claude");
1774 fs::create_dir(&claude_dir).expect("mkdir");
1775 fs::write(claude_dir.join("instructions.md"), "CLAUDE content").expect("write");
1776
1777 let ctx = load_project_context(tmp.path());
1778
1779 assert!(ctx.has_instructions());
1780 assert!(
1781 ctx.instructions
1782 .as_ref()
1783 .unwrap()
1784 .contains("AGENTS content")
1785 );
1786 }
1787
1788 #[test]
1789 fn test_load_project_context_hidden_dir() {
1790 let tmp = tempdir().expect("tempdir");
1791 let hidden_dir = tmp.path().join(".deepseek");
1792 fs::create_dir(&hidden_dir).expect("mkdir");
1793 fs::write(hidden_dir.join("instructions.md"), "Hidden instructions").expect("write");
1794
1795 let ctx = load_project_context(tmp.path());
1796
1797 assert!(ctx.has_instructions());
1798 assert!(
1799 ctx.instructions
1800 .as_ref()
1801 .unwrap()
1802 .contains("Hidden instructions")
1803 );
1804 }
1805
1806 #[test]
1807 fn test_as_system_block() {
1808 let tmp = tempdir().expect("tempdir");
1809 let agents_path = tmp.path().join("AGENTS.md");
1810 fs::write(&agents_path, "Test content").expect("write");
1811
1812 let ctx = load_project_context(tmp.path());
1813 let block = ctx.as_system_block().expect("block");
1814
1815 assert!(block.contains("<project_instructions"));
1816 assert!(block.contains("Test content"));
1817 assert!(block.contains("</project_instructions>"));
1818 }
1819
1820 #[test]
1821 fn test_empty_file_warning() {
1822 let tmp = tempdir().expect("tempdir");
1823 let agents_path = tmp.path().join("AGENTS.md");
1824 fs::write(&agents_path, " \n \n ").expect("write"); // Only whitespace
1825
1826 let ctx = load_project_context(tmp.path());
1827
1828 assert!(!ctx.has_instructions());
1829 assert!(!ctx.warnings.is_empty());
1830 }
1831
1832 #[test]
1833 fn test_check_trust_status() {
1834 let tmp = tempdir().expect("tempdir");
1835
1836 // Not trusted by default
1837 assert!(!check_trust_status(tmp.path()));
1838
1839 // Create trust marker
1840 let deepseek_dir = tmp.path().join(".deepseek");
1841 fs::create_dir(&deepseek_dir).expect("mkdir");
1842 fs::write(deepseek_dir.join("trusted"), "").expect("write");
1843
1844 assert!(check_trust_status(tmp.path()));
1845 }
1846
1847 #[test]
1848 fn test_create_default_agents_md() {
1849 let tmp = tempdir().expect("tempdir");
1850 let path = create_default_agents_md(tmp.path()).expect("create");
1851
1852 assert!(path.exists());
1853 let content = fs::read_to_string(&path).expect("read");
1854 assert!(content.contains("Project Agent Instructions"));
1855 }
1856
1857 #[test]
1858 fn test_load_with_parents() {
1859 let tmp = tempdir().expect("tempdir");
1860 let home = tempdir().expect("home tempdir");
1861
1862 // Create a nested structure
1863 let subdir = tmp.path().join("subproject");
1864 fs::create_dir(&subdir).expect("mkdir");
1865
1866 // Put AGENTS.md in parent
1867 fs::write(tmp.path().join("AGENTS.md"), "Parent instructions").expect("write");
1868 // Also create a real .git marker to make the parent the repo root
1869 let git_dir = tmp.path().join(".git");
1870 fs::create_dir(&git_dir).expect("mkdir .git");
1871 fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1872
1873 // Load from subdir should find parent's AGENTS.md
1874 let ctx = load_project_context_with_parents_and_home(&subdir, Some(home.path()));
1875
1876 assert!(ctx.has_instructions());
1877 assert!(
1878 ctx.instructions
1879 .as_ref()
1880 .unwrap()
1881 .contains("Parent instructions")
1882 );
1883 }
1884
1885 #[test]
1886 fn parent_search_stops_at_the_repository_root() {
1887 let tmp = tempdir().expect("tempdir");
1888 let home = tempdir().expect("home tempdir");
1889
1890 // AGENTS.md exists above the repository root. It belongs to another
1891 // scope (often another checkout) and must not leak into this one.
1892 fs::write(tmp.path().join("AGENTS.md"), "Organization instructions").expect("write");
1893
1894 // Mark repository root one level below.
1895 let repo_root = tmp.path().join("repo");
1896 fs::create_dir(&repo_root).expect("mkdir repo");
1897 let git_dir = repo_root.join(".git");
1898 fs::create_dir(&git_dir).expect("mkdir .git");
1899 fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1900
1901 let workspace = repo_root.join("apps").join("client");
1902 fs::create_dir_all(&workspace).expect("mkdir workspace");
1903
1904 let ctx = load_project_context_with_parents_and_home(&workspace, Some(home.path()));
1905 assert!(
1906 !ctx.instructions
1907 .as_deref()
1908 .is_some_and(|text| text.contains("Organization instructions")),
1909 "instruction files above the repository root must not be loaded: {:?}",
1910 ctx.instructions
1911 );
1912 assert_eq!(
1913 ctx.source_path, None,
1914 "no in-repo instruction file exists, so no source may be claimed"
1915 );
1916 assert!(
1917 !project_context_cache_candidate_paths(&workspace, Some(home.path()))
1918 .iter()
1919 .any(|candidate| candidate == &tmp.path().join("AGENTS.md")),
1920 "cache candidates must respect the repository boundary too"
1921 );
1922 }
1923
1924 #[test]
1925 fn instruction_chain_assembles_worktree_root_to_cwd_in_order_within_budget() {
1926 let tmp = tempdir().expect("tempdir");
1927 let home = tempdir().expect("home tempdir");
1928
1929 // A main checkout with its own AGENTS.md — it must stay out of the
1930 // nested worktree's instruction chain.
1931 let main = tmp.path().join("main-checkout");
1932 fs::create_dir_all(main.join(".git")).expect("mkdir main .git");
1933 fs::write(main.join(".git").join("HEAD"), "ref: refs/heads/main\n")
1934 .expect("write main HEAD");
1935 fs::write(main.join("AGENTS.md"), "MAIN-CHECKOUT-ONLY instructions")
1936 .expect("write main agents");
1937
1938 // A linked worktree: `.git` is a `gitdir:` pointer file, so the
1939 // worktree is its own repository root for instruction assembly.
1940 let lane = tmp.path().join("worktrees").join("lane");
1941 fs::create_dir_all(&lane).expect("mkdir lane");
1942 fs::write(
1943 lane.join(".git"),
1944 format!("gitdir: {}/.git/worktrees/lane\n", main.display()),
1945 )
1946 .expect("write lane gitdir pointer");
1947 fs::write(lane.join("AGENTS.md"), "WORKTREE-ROOT instructions").expect("write lane agents");
1948
1949 let nested = lane.join("crates").join("tui");
1950 fs::create_dir_all(&nested).expect("mkdir nested");
1951 fs::write(nested.join("AGENTS.md"), "NESTED-DIR instructions")
1952 .expect("write nested agents");
1953
1954 let ctx = load_project_context_with_parents_and_home(&nested, Some(home.path()));
1955
1956 let instructions = ctx.instructions.as_deref().unwrap_or("");
1957 assert!(
1958 instructions.contains("WORKTREE-ROOT instructions")
1959 && instructions.contains("NESTED-DIR instructions"),
1960 "worktree root and nested AGENTS.md must both assemble:\n{instructions}"
1961 );
1962 let root_at = instructions
1963 .find("WORKTREE-ROOT instructions")
1964 .expect("root");
1965 let nested_at = instructions
1966 .find("NESTED-DIR instructions")
1967 .expect("nested");
1968 assert!(
1969 root_at < nested_at,
1970 "repository root must read before the current directory (root={root_at}, nested={nested_at})"
1971 );
1972 assert!(
1973 !instructions.contains("MAIN-CHECKOUT-ONLY instructions"),
1974 "the main checkout's file is outside the worktree's chain:\n{instructions}"
1975 );
1976 let expected_source = fs::canonicalize(&nested)
1977 .expect("canonicalize nested")
1978 .join("AGENTS.md");
1979 assert_eq!(
1980 ctx.source_path.as_deref(),
1981 Some(expected_source.as_path()),
1982 "source_path points at the most specific (current-directory) file"
1983 );
1984 assert!(
1985 instructions.len() <= MAX_PROJECT_INSTRUCTION_BYTES,
1986 "assembled chain must stay within the aggregate budget ({} > {})",
1987 instructions.len(),
1988 MAX_PROJECT_INSTRUCTION_BYTES
1989 );
1990 }
1991
1992 #[test]
1993 fn directory_outside_any_repository_loads_no_instruction_chain() {
1994 let tmp = tempdir().expect("tempdir");
1995 let home = tempdir().expect("home tempdir");
1996
1997 // No `.git` anywhere: the parent's AGENTS.md is outside any chain.
1998 fs::write(
1999 tmp.path().join("AGENTS.md"),
2000 "PARENT-OUTSIDE-REPO instructions",
2001 )
2002 .expect("write parent agents");
2003 let child = tmp.path().join("project");
2004 fs::create_dir_all(&child).expect("mkdir child");
2005 fs::write(child.join("AGENTS.md"), "CHILD-ONLY instructions").expect("write child agents");
2006
2007 let ctx = load_project_context_with_parents_and_home(&child, Some(home.path()));
2008
2009 let instructions = ctx.instructions.as_deref().unwrap_or("");
2010 assert!(
2011 instructions.contains("CHILD-ONLY instructions"),
2012 "the workspace's own file still loads outside a repository:\n{instructions}"
2013 );
2014 assert!(
2015 !instructions.contains("PARENT-OUTSIDE-REPO instructions"),
2016 "no ancestor chain may be assembled outside a repository:\n{instructions}"
2017 );
2018 let expected_source = fs::canonicalize(&child)
2019 .expect("canonicalize child")
2020 .join("AGENTS.md");
2021 assert_eq!(ctx.source_path.as_deref(), Some(expected_source.as_path()));
2022 }
2023
2024 #[test]
2025 fn agents_md_used_while_whale_md_is_ignored() {
2026 let tmp = tempdir().expect("tempdir");
2027 fs::write(tmp.path().join("AGENTS.md"), "AGENTS canonical").expect("write agents");
2028 fs::write(tmp.path().join("WHALE.md"), "WHALE legacy").expect("write whale");
2029
2030 let ctx = load_project_context(tmp.path());
2031 let instructions = ctx.instructions.expect("instructions loaded");
2032 assert!(instructions.contains("AGENTS canonical"), "{instructions}");
2033 assert!(!instructions.contains("WHALE legacy"), "{instructions}");
2034 assert!(
2035 ctx.warnings
2036 .iter()
2037 .any(|w| w.contains("WHALE.md is ignored")),
2038 "{:?}",
2039 ctx.warnings
2040 );
2041 }
2042
2043 #[test]
2044 fn whale_md_alone_is_ignored_with_migration_warning() {
2045 let tmp = tempdir().expect("tempdir");
2046 fs::write(tmp.path().join("WHALE.md"), "WHALE legacy body").expect("write whale");
2047
2048 let ctx = load_project_context(tmp.path());
2049 assert!(
2050 ctx.instructions.is_none(),
2051 "legacy WHALE.md must not be read"
2052 );
2053 assert!(
2054 ctx.warnings
2055 .iter()
2056 .any(|w| w.contains("WHALE.md is ignored")),
2057 "expected ignored-file warning, got {:?}",
2058 ctx.warnings
2059 );
2060 }
2061
2062 #[test]
2063 fn constitution_json_renders_authority_block() {
2064 let tmp = tempdir().expect("tempdir");
2065 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
2066 fs::create_dir(tmp.path().join(".codewhale")).expect("mkdir .codewhale");
2067 fs::write(
2068 tmp.path().join(".codewhale").join("constitution.json"),
2069 r#"{
2070 "schema_version": 1,
2071 "authority": ["current user request", "live code and tests", "AGENTS.md"],
2072 "protected_invariants": ["keep the tool-catalog head byte-stable"],
2073 "branch_policy": "Start from live branch truth; open PRs into main",
2074 "verification_policy": { "before_claiming_done": ["run focused tests"] },
2075 "escalate_when": ["a destructive action was not authorized"]
2076 }"#,
2077 )
2078 .expect("write constitution");
2079
2080 let ctx = load_project_context_with_parents(tmp.path());
2081 let block = ctx
2082 .constitution_block
2083 .as_deref()
2084 .expect("constitution block rendered");
2085 assert!(block.contains("<codewhale_repo_constitution"));
2086 assert!(block.contains("current user request"));
2087 assert!(block.contains("run focused tests"));
2088 assert!(block.contains("keep the tool-catalog head byte-stable"));
2089 assert!(block.contains("Start from live branch truth"));
2090 assert!(block.contains("a destructive action was not authorized"));
2091 assert!(block.contains("WHALE.md is ignored and should be migrated"));
2092 assert!(
2093 ctx.constitution_source_path
2094 .as_ref()
2095 .is_some_and(|path| path.ends_with(".codewhale/constitution.json")),
2096 "constitution source path should be visible: {:?}",
2097 ctx.constitution_source_path
2098 );
2099 // It also surfaces through the system block.
2100 assert!(
2101 ctx.as_system_block()
2102 .expect("system block")
2103 .contains("codewhale_repo_constitution")
2104 );
2105 }
2106
2107 #[test]
2108 fn stale_constitution_branch_policy_warns() {
2109 let tmp = tempdir().expect("tempdir");
2110 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
2111 fs::create_dir(tmp.path().join(".codewhale")).expect("mkdir .codewhale");
2112 fs::write(
2113 tmp.path().join(".codewhale").join("constitution.json"),
2114 r#"{
2115 "schema_version": 1,
2116 "authority": ["current user request"],
2117 "branch_policy": "v0.8.53 work targets the codex/v0.8.53 integration branch, not main"
2118 }"#,
2119 )
2120 .expect("write constitution");
2121
2122 let ctx = load_project_context_with_parents(tmp.path());
2123 assert!(
2124 ctx.constitution_block.is_some(),
2125 "stale policy should warn but still render"
2126 );
2127 assert!(
2128 ctx.warnings
2129 .iter()
2130 .any(|warning| warning.contains("branch_policy appears stale")),
2131 "expected stale branch_policy warning, got {:?}",
2132 ctx.warnings
2133 );
2134 }
2135
2136 #[test]
2137 fn malformed_constitution_warns_without_crashing() {
2138 let tmp = tempdir().expect("tempdir");
2139 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
2140 fs::create_dir(tmp.path().join(".codewhale")).expect("mkdir .codewhale");
2141 fs::write(
2142 tmp.path().join(".codewhale").join("constitution.json"),
2143 "{ not valid json",
2144 )
2145 .expect("write bad constitution");
2146
2147 let ctx = load_project_context_with_parents(tmp.path());
2148 assert!(
2149 ctx.constitution_block.is_none(),
2150 "no block for invalid JSON"
2151 );
2152 assert!(
2153 ctx.warnings.iter().any(|w| w.contains("Failed to parse")),
2154 "expected parse warning, got {:?}",
2155 ctx.warnings
2156 );
2157 }
2158
2159 #[cfg(unix)]
2160 #[test]
2161 fn constitution_json_rejects_symlinked_file() {
2162 let workspace = tempdir().expect("workspace tempdir");
2163 let outside = tempdir().expect("outside tempdir");
2164 fs::create_dir(workspace.path().join(".git")).expect("mkdir .git");
2165 fs::create_dir(workspace.path().join(".codewhale")).expect("mkdir .codewhale");
2166 let outside_constitution = outside.path().join("constitution.json");
2167 fs::write(
2168 &outside_constitution,
2169 r#"{"schema_version":1,"authority":["outside authority"]}"#,
2170 )
2171 .expect("write outside constitution");
2172 std::os::unix::fs::symlink(
2173 &outside_constitution,
2174 workspace
2175 .path()
2176 .join(".codewhale")
2177 .join("constitution.json"),
2178 )
2179 .expect("symlink constitution");
2180
2181 let ctx =
2182 load_project_context_with_parents_and_home(workspace.path(), Some(outside.path()));
2183
2184 assert!(
2185 ctx.constitution_block.is_none(),
2186 "symlinked constitution must not be loaded: {:?}",
2187 ctx.constitution_block
2188 );
2189 assert!(
2190 !ctx.as_system_block()
2191 .unwrap_or_default()
2192 .contains("outside authority"),
2193 "symlink target content must not reach the system block"
2194 );
2195 assert!(
2196 ctx.warnings.iter().any(|w| w.contains("symlinked")),
2197 "expected symlink warning, got {:?}",
2198 ctx.warnings
2199 );
2200 }
2201
2202 #[test]
2203 fn generated_context_is_bounded_and_ephemeral_for_many_file_workspace() {
2204 let workspace = tempdir().expect("workspace tempdir");
2205 let home = tempdir().expect("home tempdir");
2206 let noisy = workspace.path().join("aaa-many-files");
2207 fs::create_dir_all(&noisy).expect("mkdir noisy");
2208 for i in 0..1000 {
2209 fs::write(noisy.join(format!("file-{i:04}.rs")), "fn noisy() {}").expect("write noisy");
2210 }
2211 fs::create_dir_all(workspace.path().join("zzz-important")).expect("mkdir important");
2212 fs::write(
2213 workspace.path().join("zzz-important").join("main.rs"),
2214 "fn important() {}",
2215 )
2216 .expect("write important");
2217
2218 // Boundedness is a structural contract below; wall-clock time depends
2219 // on host load and is not a reliable assertion in the full suite.
2220 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2221 assert!(ctx.has_instructions());
2222
2223 let generated_path = workspace.path().join(".codewhale").join("instructions.md");
2224 assert_eq!(ctx.source_path, None);
2225 assert!(
2226 !generated_path.exists(),
2227 "generated project context should stay ephemeral"
2228 );
2229 assert!(
2230 !workspace.path().join(".codewhale").exists(),
2231 "loading context should not create a .codewhale directory"
2232 );
2233 let generated = ctx.instructions.as_ref().expect("generated instructions");
2234 assert!(generated.contains("Project Context (Auto-generated, ephemeral)"));
2235 assert!(generated.contains("Bounded Project Overview"));
2236 assert!(!generated.contains("<project_context_pack>"));
2237 assert!(
2238 generated.contains("\"zzz-important/\""),
2239 "later top-level project areas should remain visible:\n{generated}"
2240 );
2241 let noisy_count = generated.matches("aaa-many-files/file-").count();
2242 assert!(
2243 noisy_count < 300,
2244 "generated context should not list the whole noisy directory; saw {noisy_count}"
2245 );
2246 assert!(
2247 !generated.contains("file-0999.rs"),
2248 "bounded context should omit the tail of the noisy directory"
2249 );
2250 }
2251
2252 #[test]
2253 fn explicit_home_bounds_parent_search_without_process_environment() {
2254 let home = tempdir().expect("home tempdir");
2255 fs::write(
2256 home.path().join("AGENTS.md"),
2257 "must not be loaded as project context",
2258 )
2259 .expect("write home AGENTS.md");
2260 let workspace = home.path().join("projects").join("demo");
2261 fs::create_dir_all(&workspace).expect("mkdir workspace");
2262
2263 let ctx = load_project_context_with_parents_and_home(&workspace, Some(home.path()));
2264
2265 assert_eq!(
2266 ctx.source_path, None,
2267 "the explicit home is the parent-search boundary, not project context"
2268 );
2269 assert!(
2270 ctx.instructions
2271 .as_deref()
2272 .is_some_and(|text| text.contains("Project Context (Auto-generated, ephemeral)")),
2273 "expected generated context, got {:?}",
2274 ctx.instructions
2275 );
2276 assert!(
2277 project_context_cache_candidate_paths(&workspace, Some(home.path()))
2278 .iter()
2279 .all(|candidate| candidate != &home.path().join("AGENTS.md")),
2280 "cache candidates must use the same explicit parent-search boundary"
2281 );
2282 }
2283
2284 #[test]
2285 fn cached_context_reflects_overwritten_agents_md() {
2286 crate::project_context_cache::clear();
2287 let workspace = tempdir().expect("workspace tempdir");
2288 let home = tempdir().expect("home tempdir");
2289 let agents = workspace.path().join("AGENTS.md");
2290 fs::write(&agents, "alpha").expect("write alpha");
2291
2292 let first =
2293 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2294 assert!(
2295 first
2296 .instructions
2297 .as_deref()
2298 .is_some_and(|s| s.contains("alpha")),
2299 "expected alpha instructions: {:?}",
2300 first.instructions
2301 );
2302
2303 fs::write(&agents, "bravo").expect("write bravo");
2304 let second =
2305 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2306
2307 assert!(
2308 second
2309 .instructions
2310 .as_deref()
2311 .is_some_and(|s| s.contains("bravo")),
2312 "cache must invalidate on same-length content overwrite: {:?}",
2313 second.instructions
2314 );
2315 }
2316
2317 #[test]
2318 fn cached_context_reflects_constitution_json_change() {
2319 crate::project_context_cache::clear();
2320 let workspace = tempdir().expect("workspace tempdir");
2321 let home = tempdir().expect("home tempdir");
2322 fs::create_dir(workspace.path().join(".git")).expect("mkdir git");
2323 fs::create_dir(workspace.path().join(".codewhale")).expect("mkdir codewhale");
2324 let constitution = workspace
2325 .path()
2326 .join(".codewhale")
2327 .join("constitution.json");
2328 fs::write(
2329 &constitution,
2330 r#"{"schema_version":1,"authority":["alpha authority"]}"#,
2331 )
2332 .expect("write alpha constitution");
2333
2334 let first =
2335 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2336 assert!(
2337 first
2338 .constitution_block
2339 .as_deref()
2340 .is_some_and(|s| s.contains("alpha authority")),
2341 "expected alpha constitution block: {:?}",
2342 first.constitution_block
2343 );
2344
2345 fs::write(
2346 &constitution,
2347 r#"{"schema_version":1,"authority":["bravo authority"]}"#,
2348 )
2349 .expect("write bravo constitution");
2350 let second =
2351 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2352
2353 assert!(
2354 second
2355 .constitution_block
2356 .as_deref()
2357 .is_some_and(|s| s.contains("bravo authority")),
2358 "cache must invalidate when constitution changes: {:?}",
2359 second.constitution_block
2360 );
2361 }
2362
2363 #[test]
2364 fn cached_generated_context_stays_ephemeral() {
2365 crate::project_context_cache::clear();
2366 let workspace = tempdir().expect("workspace tempdir");
2367 let home = tempdir().expect("home tempdir");
2368
2369 let first =
2370 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2371 assert!(first.has_instructions());
2372 let generated_path = workspace.path().join(".codewhale").join("instructions.md");
2373 assert!(
2374 !generated_path.exists(),
2375 "first load should not write generated instructions"
2376 );
2377
2378 let second =
2379 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2380 assert!(second.has_instructions());
2381 assert!(
2382 !generated_path.exists(),
2383 "cached generated context should remain in memory-only state"
2384 );
2385 }
2386
2387 #[test]
2388 fn cached_context_reflects_trust_marker_created() {
2389 crate::project_context_cache::clear();
2390 let workspace = tempdir().expect("workspace tempdir");
2391 let home = tempdir().expect("home tempdir");
2392 fs::write(workspace.path().join("AGENTS.md"), "instructions").expect("write agents");
2393
2394 let first =
2395 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2396 assert!(!first.is_trusted);
2397
2398 let trust_dir = workspace.path().join(".deepseek");
2399 fs::create_dir(&trust_dir).expect("mkdir trust dir");
2400 fs::write(trust_dir.join("trusted"), "").expect("write trust marker");
2401
2402 let second =
2403 load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path()));
2404 assert!(
2405 second.is_trusted,
2406 "cache must invalidate when trust marker appears"
2407 );
2408 }
2409
2410 #[test]
2411 fn test_load_global_agents_when_project_has_no_context() {
2412 let workspace = tempdir().expect("workspace tempdir");
2413 let home = tempdir().expect("home tempdir");
2414 let global_dir = home.path().join(".deepseek");
2415 fs::create_dir(&global_dir).expect("mkdir .deepseek");
2416 let global_agents = global_dir.join("AGENTS.md");
2417 fs::write(&global_agents, "Global instructions").expect("write global agents");
2418
2419 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2420
2421 assert!(ctx.has_instructions());
2422 assert!(
2423 ctx.instructions
2424 .as_ref()
2425 .unwrap()
2426 .contains("Global instructions")
2427 );
2428 assert_eq!(ctx.source_path, Some(global_agents));
2429 }
2430
2431 #[test]
2432 fn test_load_global_agents_falls_back_to_vendor_neutral_path() {
2433 let workspace = tempdir().expect("workspace tempdir");
2434 let home = tempdir().expect("home tempdir");
2435 let global_dir = home.path().join(".agents");
2436 fs::create_dir(&global_dir).expect("mkdir .agents");
2437 let global_agents = global_dir.join("AGENTS.md");
2438 fs::write(&global_agents, "Vendor-neutral instructions").expect("write global agents");
2439
2440 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2441
2442 assert!(ctx.has_instructions());
2443 assert!(
2444 ctx.instructions
2445 .as_ref()
2446 .unwrap()
2447 .contains("Vendor-neutral instructions")
2448 );
2449 assert_eq!(ctx.source_path, Some(global_agents));
2450 }
2451
2452 #[cfg(unix)]
2453 #[test]
2454 fn test_symlinked_global_agents_is_followed() {
2455 let workspace = tempdir().expect("workspace tempdir");
2456 let home = tempdir().expect("home tempdir");
2457 let shared = home.path().join("AGENTS.md");
2458 fs::write(&shared, "Shared global instructions").expect("write shared agents");
2459 let global_dir = home.path().join(".deepseek");
2460 fs::create_dir(&global_dir).expect("mkdir .deepseek");
2461 let link = global_dir.join("AGENTS.md");
2462 std::os::unix::fs::symlink(&shared, &link).expect("symlink global agents");
2463
2464 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2465
2466 assert!(ctx.has_instructions());
2467 assert!(
2468 ctx.instructions
2469 .as_ref()
2470 .unwrap()
2471 .contains("Shared global instructions"),
2472 "a symlinked user-level AGENTS.md must be read: {:?}",
2473 ctx.warnings
2474 );
2475 assert_eq!(ctx.source_path, Some(link));
2476 assert!(
2477 !ctx.warnings.iter().any(|w| w.contains("symlink")),
2478 "following the user-level link must not warn: {:?}",
2479 ctx.warnings
2480 );
2481 }
2482
2483 #[cfg(unix)]
2484 #[test]
2485 fn test_symlinked_workspace_agents_is_still_refused() {
2486 let workspace = tempdir().expect("workspace tempdir");
2487 let home = tempdir().expect("home tempdir");
2488 let outside = tempdir().expect("outside tempdir");
2489 let secret = outside.path().join("secret.md");
2490 fs::write(&secret, "outside content").expect("write outside file");
2491 std::os::unix::fs::symlink(&secret, workspace.path().join("AGENTS.md"))
2492 .expect("symlink workspace agents");
2493
2494 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2495
2496 assert!(
2497 ctx.instructions.is_none()
2498 || !ctx
2499 .instructions
2500 .as_ref()
2501 .unwrap()
2502 .contains("outside content"),
2503 "a workspace AGENTS.md symlink must not be followed"
2504 );
2505 }
2506
2507 #[test]
2508 fn test_codewhale_specific_path_wins_over_agents_path() {
2509 let workspace = tempdir().expect("workspace tempdir");
2510 let home = tempdir().expect("home tempdir");
2511
2512 let codewhale_dir = home.path().join(".codewhale");
2513 fs::create_dir(&codewhale_dir).expect("mkdir .codewhale");
2514 let codewhale_agents = codewhale_dir.join("AGENTS.md");
2515 fs::write(&codewhale_agents, "Codewhale-specific instructions")
2516 .expect("write codewhale agents");
2517
2518 let agents_dir = home.path().join(".agents");
2519 fs::create_dir(&agents_dir).expect("mkdir .agents");
2520 fs::write(agents_dir.join("AGENTS.md"), "Vendor-neutral instructions")
2521 .expect("write vendor-neutral agents");
2522
2523 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2524
2525 assert!(ctx.has_instructions());
2526 let instructions = ctx.instructions.as_ref().unwrap();
2527 assert!(
2528 instructions.contains("Codewhale-specific instructions"),
2529 "Codewhale-specific global file should win:\n{instructions}"
2530 );
2531 assert!(
2532 !instructions.contains("Vendor-neutral instructions"),
2533 "lower-priority .agents file should be skipped:\n{instructions}"
2534 );
2535 assert_eq!(ctx.source_path, Some(codewhale_agents));
2536 }
2537
2538 #[test]
2539 fn test_global_agents_wins_over_global_whale_across_paths() {
2540 let workspace = tempdir().expect("workspace tempdir");
2541 let home = tempdir().expect("home tempdir");
2542
2543 let codewhale_dir = home.path().join(".codewhale");
2544 fs::create_dir(&codewhale_dir).expect("mkdir .codewhale");
2545 fs::write(codewhale_dir.join("WHALE.md"), "Global WHALE legacy")
2546 .expect("write codewhale whale");
2547
2548 let agents_dir = home.path().join(".agents");
2549 fs::create_dir(&agents_dir).expect("mkdir .agents");
2550 let global_agents = agents_dir.join("AGENTS.md");
2551 fs::write(&global_agents, "Global AGENTS canonical").expect("write global agents");
2552
2553 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2554
2555 assert!(ctx.has_instructions());
2556 let instructions = ctx.instructions.as_ref().unwrap();
2557 assert!(
2558 instructions.contains("Global AGENTS canonical"),
2559 "global AGENTS.md should win:\n{instructions}"
2560 );
2561 assert!(
2562 !instructions.contains("Global WHALE legacy"),
2563 "global WHALE.md content should be skipped when any global AGENTS.md exists:\n{instructions}"
2564 );
2565 assert!(
2566 ctx.warnings
2567 .iter()
2568 .any(|warning| warning.contains("WHALE.md is ignored")),
2569 "ignored WHALE.md should emit migration warning: {:?}",
2570 ctx.warnings
2571 );
2572 assert_eq!(ctx.source_path, Some(global_agents));
2573 }
2574
2575 #[test]
2576 fn test_global_whale_is_ignored_when_no_global_agents_exists() {
2577 let workspace = tempdir().expect("workspace tempdir");
2578 let home = tempdir().expect("home tempdir");
2579
2580 let codewhale_dir = home.path().join(".codewhale");
2581 fs::create_dir(&codewhale_dir).expect("mkdir .codewhale");
2582 let global_whale = codewhale_dir.join("WHALE.md");
2583 fs::write(&global_whale, "Global WHALE legacy").expect("write codewhale whale");
2584
2585 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2586
2587 let instructions = ctx.instructions.as_deref().unwrap_or("");
2588 assert!(
2589 !instructions.contains("Global WHALE legacy"),
2590 "legacy WHALE.md must not be read when no global AGENTS.md exists:\n{instructions}"
2591 );
2592 assert!(
2593 ctx.warnings
2594 .iter()
2595 .any(|warning| warning.contains("WHALE.md is ignored")),
2596 "expected global WHALE.md ignored warning, got {:?}",
2597 ctx.warnings
2598 );
2599 assert_ne!(ctx.source_path, Some(global_whale));
2600 }
2601
2602 #[test]
2603 fn test_global_instructions_md_is_autoloaded_while_whale_is_ignored() {
2604 // #3012: a global ~/.codewhale/instructions.md should be auto-loaded as
2605 // a fallback context layer while legacy WHALE.md remains ignored.
2606 let workspace = tempdir().expect("workspace tempdir");
2607 let home = tempdir().expect("home tempdir");
2608
2609 let codewhale_dir = home.path().join(".codewhale");
2610 fs::create_dir(&codewhale_dir).expect("mkdir .codewhale");
2611 fs::write(codewhale_dir.join("WHALE.md"), "Global WHALE legacy")
2612 .expect("write codewhale whale");
2613 let global_instructions = codewhale_dir.join("instructions.md");
2614 fs::write(&global_instructions, "Global instructions body")
2615 .expect("write global instructions");
2616
2617 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2618
2619 assert!(ctx.has_instructions());
2620 let instructions = ctx.instructions.as_ref().unwrap();
2621 assert!(
2622 instructions.contains("Global instructions body"),
2623 "global instructions.md should be auto-loaded:\n{instructions}"
2624 );
2625 assert!(
2626 !instructions.contains("Global WHALE legacy"),
2627 "instructions.md should load without reading ignored WHALE.md:\n{instructions}"
2628 );
2629 assert!(
2630 ctx.warnings
2631 .iter()
2632 .any(|warning| warning.contains("WHALE.md is ignored")),
2633 "ignored WHALE.md should emit migration warning: {:?}",
2634 ctx.warnings
2635 );
2636 assert_eq!(ctx.source_path, Some(global_instructions));
2637 }
2638
2639 #[test]
2640 fn test_global_agents_outranks_global_instructions() {
2641 // #3012 precedence: AGENTS.md > instructions.md.
2642 let workspace = tempdir().expect("workspace tempdir");
2643 let home = tempdir().expect("home tempdir");
2644
2645 let codewhale_dir = home.path().join(".codewhale");
2646 fs::create_dir(&codewhale_dir).expect("mkdir .codewhale");
2647 let global_agents = codewhale_dir.join("AGENTS.md");
2648 fs::write(&global_agents, "Global AGENTS canonical").expect("write global agents");
2649 fs::write(
2650 codewhale_dir.join("instructions.md"),
2651 "Global instructions body",
2652 )
2653 .expect("write global instructions");
2654
2655 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2656
2657 assert!(ctx.has_instructions());
2658 let instructions = ctx.instructions.as_ref().unwrap();
2659 assert!(
2660 instructions.contains("Global AGENTS canonical"),
2661 "global AGENTS.md should outrank instructions.md:\n{instructions}"
2662 );
2663 assert!(
2664 !instructions.contains("Global instructions body"),
2665 "instructions.md should be skipped when a global AGENTS.md exists:\n{instructions}"
2666 );
2667 assert_eq!(ctx.source_path, Some(global_agents));
2668 }
2669
2670 #[test]
2671 fn test_local_and_global_agents_merge_when_both_exist() {
2672 // #1157: when both `~/.deepseek/AGENTS.md` and a project AGENTS.md
2673 // exist, the prompt should carry user-wide preferences AND the
2674 // project's overrides — not silently drop the global file.
2675 let workspace = tempdir().expect("workspace tempdir");
2676 fs::write(workspace.path().join("AGENTS.md"), "Local instructions")
2677 .expect("write local agents");
2678
2679 let home = tempdir().expect("home tempdir");
2680 let global_dir = home.path().join(".deepseek");
2681 fs::create_dir(&global_dir).expect("mkdir .deepseek");
2682 fs::write(global_dir.join("AGENTS.md"), "Global instructions")
2683 .expect("write global agents");
2684
2685 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2686
2687 assert!(ctx.has_instructions());
2688 let instructions = ctx.instructions.as_ref().unwrap();
2689 assert!(
2690 instructions.contains("Global instructions"),
2691 "global block missing from merged instructions:\n{instructions}"
2692 );
2693 assert!(
2694 instructions.contains("Local instructions"),
2695 "project block missing from merged instructions:\n{instructions}"
2696 );
2697 // Global block precedes the project block so project rules read
2698 // last and win "last word" precedence with the model.
2699 let global_at = instructions.find("Global instructions").unwrap();
2700 let local_at = instructions.find("Local instructions").unwrap();
2701 assert!(
2702 global_at < local_at,
2703 "global block must come before project block, got global={global_at} local={local_at}"
2704 );
2705 // The merged block is labelled so the model can tell the layers
2706 // apart when it needs to explain which rule it followed.
2707 assert!(
2708 instructions.contains("project (overrides global where they conflict)"),
2709 "expected labelled separator between global and project blocks"
2710 );
2711 // `source_path` keeps pointing at the more-specific file so the
2712 // user knows where to edit the workspace-level override.
2713 assert_eq!(
2714 ctx.source_path,
2715 Some(canonicalize_workspace_or_keep(workspace.path()).join("AGENTS.md"))
2716 );
2717 }
2718
2719 #[test]
2720 fn test_global_agents_only_no_project_unchanged_fallback() {
2721 // Sanity: when only the global file exists, the historical
2722 // fallback behaviour is preserved — no merge framing leaks in.
2723 let workspace = tempdir().expect("workspace tempdir");
2724 let home = tempdir().expect("home tempdir");
2725 let global_dir = home.path().join(".deepseek");
2726 fs::create_dir(&global_dir).expect("mkdir .deepseek");
2727 let global_agents = global_dir.join("AGENTS.md");
2728 fs::write(&global_agents, "Just the global instructions").expect("write global agents");
2729
2730 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2731
2732 assert!(ctx.has_instructions());
2733 let instructions = ctx.instructions.as_ref().unwrap();
2734 assert!(instructions.contains("Just the global instructions"));
2735 assert!(
2736 !instructions.contains("project (overrides global"),
2737 "merge-framing label should not appear when there's nothing to merge"
2738 );
2739 assert_eq!(ctx.source_path, Some(global_agents));
2740 }
2741
2742 #[test]
2743 fn test_invalid_global_agents_warns_and_falls_back_to_generated_context() {
2744 let workspace = tempdir().expect("workspace tempdir");
2745 let home = tempdir().expect("home tempdir");
2746 let global_dir = home.path().join(".deepseek");
2747 fs::create_dir(&global_dir).expect("mkdir .deepseek");
2748 fs::write(global_dir.join("AGENTS.md"), " \n ").expect("write empty global agents");
2749
2750 let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
2751
2752 assert!(
2753 ctx.warnings
2754 .iter()
2755 .any(|warning| warning.contains("Context file") && warning.contains("is empty")),
2756 "expected empty global AGENTS.md warning, got {:?}",
2757 ctx.warnings
2758 );
2759 assert!(ctx.has_instructions());
2760 assert!(
2761 ctx.instructions
2762 .as_ref()
2763 .unwrap()
2764 .contains("Project Context (Auto-generated, ephemeral)")
2765 );
2766 }
2767
2768 // ── Rules directory auto-discovery tests ──
2769
2770 #[test]
2771 fn rules_from_codewhale_dir_are_loaded_as_project_context() {
2772 let tmp = tempdir().expect("tempdir");
2773 let rules_dir = tmp.path().join(".codewhale/rules");
2774 fs::create_dir_all(&rules_dir).expect("mkdir rules");
2775 fs::write(
2776 rules_dir.join("security.md"),
2777 "# Security\nNo hardcoded secrets.",
2778 )
2779 .expect("write");
2780
2781 let ctx = load_project_context(tmp.path());
2782
2783 let rules = ctx.rules_block.as_ref().expect("rules_block should be set");
2784 assert!(
2785 rules.contains("Security"),
2786 "expected rules content, got: {rules}"
2787 );
2788 assert!(
2789 rules.contains("<project_rule source="),
2790 "expected <project_rule> wrapper, got: {rules}"
2791 );
2792 }
2793
2794 #[test]
2795 fn rules_are_loaded_in_filename_order() {
2796 let tmp = tempdir().expect("tempdir");
2797 let rules_dir = tmp.path().join(".codewhale/rules");
2798 fs::create_dir_all(&rules_dir).expect("mkdir rules");
2799 fs::write(rules_dir.join("zzz.md"), "last").expect("write");
2800 fs::write(rules_dir.join("aaa.md"), "first").expect("write");
2801 fs::write(rules_dir.join("mmm.md"), "middle").expect("write");
2802
2803 let ctx = load_project_context(tmp.path());
2804 let rules = ctx.rules_block.as_ref().unwrap();
2805
2806 let pos_aaa = rules.find("first").unwrap();
2807 let pos_mmm = rules.find("middle").unwrap();
2808 let pos_zzz = rules.find("last").unwrap();
2809 assert!(pos_aaa < pos_mmm, "aaa should come before mmm");
2810 assert!(pos_mmm < pos_zzz, "mmm should come before zzz");
2811 }
2812
2813 #[test]
2814 fn claude_rules_load_only_when_explicitly_imported() {
2815 let tmp = tempdir().expect("tempdir");
2816 let rules_dir = tmp.path().join(".claude/rules");
2817 fs::create_dir_all(&rules_dir).expect("mkdir rules");
2818 fs::write(rules_dir.join("style.md"), "Use tabs").expect("write");
2819
2820 // Default: another tool's rules directory is not Codewhale's law.
2821 let ctx = load_project_context_with_imports(tmp.path(), &ForeignInstructionImports::none());
2822 assert!(
2823 ctx.rules_block.is_none(),
2824 "an un-imported .claude/rules must contribute nothing: {:?}",
2825 ctx.rules_block
2826 );
2827 assert!(
2828 ctx.warnings
2829 .iter()
2830 .any(|w| w.contains("claude") && w.contains("project_instruction_imports")),
2831 "the user must be told the files exist and how to import them: {:?}",
2832 ctx.warnings
2833 );
2834
2835 // Opted in by name: loaded, and ranked after Codewhale's own.
2836 let (imports, unknown) = ForeignInstructionImports::from_config(&["claude".to_string()]);
2837 assert!(unknown.is_empty());
2838 let ctx = load_project_context_with_imports(tmp.path(), &imports);
2839 let rules = ctx.rules_block.as_ref().expect("rules should be loaded");
2840 assert!(rules.contains("Use tabs"), "expected .claude/rules/ import");
2841 }
2842
2843 #[test]
2844 fn fragment_backed_foreign_instructions_warn_until_imported() {
2845 let tmp = tempdir().expect("tempdir");
2846 fs::write(tmp.path().join(".cursorrules"), "Cursor-only law").expect("write cursor");
2847 let copilot_dir = tmp.path().join(".github");
2848 fs::create_dir_all(&copilot_dir).expect("mkdir github");
2849 fs::write(
2850 copilot_dir.join("copilot-instructions.md"),
2851 "Copilot-only law",
2852 )
2853 .expect("write copilot");
2854
2855 let ctx = load_project_context_with_imports(tmp.path(), &ForeignInstructionImports::none());
2856 let warning = ctx
2857 .warnings
2858 .iter()
2859 .find(|warning| warning.contains("project_instruction_imports"))
2860 .expect("foreign fragment warning");
2861 assert!(warning.contains("cursor"), "{warning}");
2862 assert!(warning.contains("copilot"), "{warning}");
2863
2864 let (imports, unknown) =
2865 ForeignInstructionImports::from_config(&["cursor".to_string(), "copilot".to_string()]);
2866 assert!(unknown.is_empty());
2867 let ctx = load_project_context_with_imports(tmp.path(), &imports);
2868 assert!(
2869 ctx.warnings
2870 .iter()
2871 .all(|warning| !warning.contains("project_instruction_imports")),
2872 "opted-in formats must not keep warning: {:?}",
2873 ctx.warnings
2874 );
2875 }
2876
2877 #[test]
2878 fn unusable_foreign_fragments_do_not_claim_importable_instructions() {
2879 let tmp = tempdir().expect("tempdir");
2880 fs::write(tmp.path().join(".cursorrules"), " \n").expect("write empty cursor file");
2881 let cursor_rules = tmp.path().join(".cursor/rules");
2882 fs::create_dir_all(&cursor_rules).expect("mkdir cursor rules");
2883 fs::write(cursor_rules.join("settings.json"), "{}").expect("write non-markdown file");
2884
2885 let ctx = load_project_context_with_imports(tmp.path(), &ForeignInstructionImports::none());
2886 assert!(
2887 ctx.warnings
2888 .iter()
2889 .all(|warning| !warning.contains("project_instruction_imports")),
2890 "empty and non-Markdown fragments are not importable: {:?}",
2891 ctx.warnings
2892 );
2893 }
2894
2895 #[test]
2896 fn unusable_direct_foreign_instructions_do_not_claim_importable_content() {
2897 let tmp = tempdir().expect("tempdir");
2898 fs::write(tmp.path().join("CLAUDE.md"), "\n\t ").expect("write empty claude file");
2899 let claude_rules = tmp.path().join(".claude/rules");
2900 fs::create_dir_all(&claude_rules).expect("mkdir claude rules");
2901 fs::write(claude_rules.join("settings.json"), "{}").expect("write non-markdown file");
2902
2903 let ctx = load_project_context_with_imports(tmp.path(), &ForeignInstructionImports::none());
2904 assert!(
2905 ctx.warnings
2906 .iter()
2907 .all(|warning| !warning.contains("project_instruction_imports")),
2908 "empty files and rules directories without Markdown are not importable: {:?}",
2909 ctx.warnings
2910 );
2911 }
2912
2913 #[cfg(unix)]
2914 #[test]
2915 fn symlinked_foreign_fragment_does_not_claim_importable_instructions() {
2916 use std::os::unix::fs::symlink;
2917
2918 let tmp = tempdir().expect("tempdir");
2919 let outside = tempdir().expect("outside tempdir");
2920 let outside_claude = outside.path().join("CLAUDE.md");
2921 fs::write(&outside_claude, "outside Claude law").expect("write outside Claude file");
2922 let outside_rules = outside.path().join("rules");
2923 fs::create_dir_all(&outside_rules).expect("mkdir outside rules");
2924 fs::write(outside_rules.join("law.md"), "outside law").expect("write outside rule");
2925 fs::create_dir_all(tmp.path().join(".cursor")).expect("mkdir cursor");
2926 fs::create_dir_all(tmp.path().join(".claude")).expect("mkdir claude");
2927 symlink(&outside_rules, tmp.path().join(".cursor/rules")).expect("symlink cursor rules");
2928 symlink(&outside_rules, tmp.path().join(".claude/rules")).expect("symlink claude rules");
2929 symlink(&outside_claude, tmp.path().join("CLAUDE.md")).expect("symlink Claude file");
2930
2931 let ctx = load_project_context_with_imports(tmp.path(), &ForeignInstructionImports::none());
2932 assert!(
2933 ctx.warnings
2934 .iter()
2935 .all(|warning| !warning.contains("project_instruction_imports")),
2936 "the bounded loader rejects symlinked foreign fragments: {:?}",
2937 ctx.warnings
2938 );
2939 }
2940
2941 #[test]
2942 fn foreign_instruction_imports_parse_names_and_report_typos() {
2943 let (imports, unknown) = ForeignInstructionImports::from_config(&[]);
2944 assert!(imports.is_empty(), "default imports nothing");
2945 assert!(unknown.is_empty());
2946
2947 let (imports, unknown) = ForeignInstructionImports::from_config(&[
2948 "Claude".to_string(),
2949 " cursor ".to_string(),
2950 "clawed".to_string(),
2951 ]);
2952 assert!(imports.is_enabled(ForeignInstructionFormat::Claude));
2953 assert!(imports.is_enabled(ForeignInstructionFormat::Cursor));
2954 assert!(!imports.is_enabled(ForeignInstructionFormat::Gemini));
2955 assert_eq!(unknown, vec!["clawed".to_string()], "typos are reported");
2956
2957 let (all, _) = ForeignInstructionImports::from_config(&["all".to_string()]);
2958 for format in ForeignInstructionFormat::ALL {
2959 assert!(
2960 all.is_enabled(*format),
2961 "{} missing from `all`",
2962 format.key()
2963 );
2964 }
2965 assert_eq!(all.keys().len(), ForeignInstructionFormat::ALL.len());
2966 }
2967
2968 #[test]
2969 fn codewhale_instructions_outrank_an_imported_claude_file() {
2970 // On the previous default list CLAUDE.md sat at rank 3 and
2971 // .codewhale/instructions.md at rank 4, so another tool's file won.
2972 let tmp = tempdir().expect("tempdir");
2973 fs::create_dir_all(tmp.path().join(".codewhale")).expect("mkdir codewhale");
2974 fs::write(
2975 tmp.path().join(".codewhale/instructions.md"),
2976 "CODEWHALE-OWN",
2977 )
2978 .expect("write codewhale");
2979 fs::write(tmp.path().join("CLAUDE.md"), "CLAUDE-FILE").expect("write claude");
2980
2981 let (imports, _) = ForeignInstructionImports::from_config(&["claude".to_string()]);
2982 let files = context_files_for(&imports);
2983 let cw = files
2984 .iter()
2985 .position(|f| *f == ".codewhale/instructions.md")
2986 .expect("codewhale file in list");
2987 let cl = files
2988 .iter()
2989 .position(|f| *f == "CLAUDE.md")
2990 .expect("claude file in list");
2991 assert!(
2992 cl > cw,
2993 "Codewhale's own instruction file must outrank an imported CLAUDE.md: {files:?}"
2994 );
2995 }
2996
2997 #[test]
2998 fn rules_directory_missing_does_not_crash() {
2999 let tmp = tempdir().expect("tempdir");
3000 // No .codewhale/rules/ or .claude/rules/ directories exist
3001 let ctx = load_project_context(tmp.path());
3002 // Rules block should be None when no rules directories exist
3003 assert!(
3004 ctx.rules_block.is_none(),
3005 "rules_block should be None when no rules exist"
3006 );
3007 }
3008
3009 #[test]
3010 fn rules_coexist_with_agents_md() {
3011 let tmp = tempdir().expect("tempdir");
3012 fs::write(tmp.path().join("AGENTS.md"), "Main project instructions").expect("write");
3013 let rules_dir = tmp.path().join(".codewhale/rules");
3014 fs::create_dir_all(&rules_dir).expect("mkdir rules");
3015 fs::write(rules_dir.join("extra.md"), "Extra rule").expect("write");
3016
3017 let ctx = load_project_context(tmp.path());
3018 let instructions = ctx.instructions.as_ref().unwrap();
3019 let rules = ctx.rules_block.as_ref().unwrap();
3020
3021 assert!(
3022 instructions.contains("Main project instructions"),
3023 "AGENTS.md content missing"
3024 );
3025 assert!(rules.contains("Extra rule"), "rules content missing");
3026 // AGENTS.md should come first in system block
3027 let block = ctx.as_system_block().unwrap();
3028 let pos_agents = block.find("Main project instructions").unwrap();
3029 let pos_rule = block.find("Extra rule").unwrap();
3030 assert!(pos_agents < pos_rule, "AGENTS.md should precede rules");
3031 }
3032
3033 #[test]
3034 fn non_md_files_in_rules_dir_are_ignored() {
3035 let tmp = tempdir().expect("tempdir");
3036 let rules_dir = tmp.path().join(".codewhale/rules");
3037 fs::create_dir_all(&rules_dir).expect("mkdir rules");
3038 fs::write(rules_dir.join("notes.txt"), "should be ignored").expect("write");
3039 fs::write(rules_dir.join("valid.md"), "loaded").expect("write");
3040
3041 let ctx = load_project_context(tmp.path());
3042 let rules = ctx.rules_block.as_ref().unwrap();
3043
3044 assert!(rules.contains("loaded"), "valid .md should be loaded");
3045 assert!(
3046 !rules.contains("should be ignored"),
3047 ".txt should be ignored"
3048 );
3049 }
3050
3051 #[test]
3052 fn rules_cap_truncates_excess_files() {
3053 let tmp = tempdir().expect("tempdir");
3054 let rules_dir = tmp.path().join(".codewhale/rules");
3055 fs::create_dir_all(&rules_dir).expect("mkdir rules");
3056
3057 // Create more files than the cap
3058 for i in 0..60 {
3059 fs::write(
3060 rules_dir.join(format!("rule_{i:04}.md")),
3061 format!("content {i}"),
3062 )
3063 .expect("write");
3064 }
3065
3066 let ctx = load_project_context(tmp.path());
3067 let rules = ctx.rules_block.as_ref().unwrap();
3068
3069 // The last file (by sorted name) should NOT be present
3070 assert!(
3071 !rules.contains("content 59"),
3072 "rule_0059 should be above cap"
3073 );
3074 // The first file should be present
3075 assert!(
3076 rules.contains("content 0"),
3077 "rule_0000 should be within cap"
3078 );
3079 // Count <project_rule> blocks
3080 let count = rules.matches("<project_rule source=").count();
3081 assert_eq!(
3082 count, MAX_RULES_FILES,
3083 "exactly {MAX_RULES_FILES} rules should be loaded"
3084 );
3085 }
3086
3087 #[cfg(unix)]
3088 #[test]
3089 fn rules_rejects_symlinked_files() {
3090 let workspace = tempdir().expect("workspace tempdir");
3091 let outside = tempdir().expect("outside tempdir");
3092 let rules_dir = workspace.path().join(".codewhale/rules");
3093 fs::create_dir_all(&rules_dir).expect("mkdir rules");
3094
3095 let outside_rule = outside.path().join("outside.md");
3096 fs::write(&outside_rule, "outside content").expect("write outside");
3097 std::os::unix::fs::symlink(&outside_rule, rules_dir.join("outside.md"))
3098 .expect("symlink rule");
3099
3100 let ctx = load_project_context(workspace.path());
3101
3102 // Symlinked rules must not be loaded
3103 assert!(
3104 ctx.rules_block.is_none()
3105 || !ctx
3106 .rules_block
3107 .as_ref()
3108 .unwrap()
3109 .contains("outside content"),
3110 "symlinked rules must not be loaded"
3111 );
3112 }
3113
3114 #[cfg(unix)]
3115 #[test]
3116 fn rules_rejects_symlinked_directory() {
3117 let workspace = tempdir().expect("workspace tempdir");
3118 let outside = tempdir().expect("outside tempdir");
3119 let outside_dir = outside.path().join("real_rules");
3120 fs::create_dir_all(&outside_dir).expect("mkdir outside dir");
3121 fs::write(outside_dir.join("secret.md"), "outside content").expect("write outside");
3122 fs::create_dir_all(workspace.path().join(".codewhale")).expect("mkdir codewhale");
3123
3124 // Symlink the directory itself, not individual files
3125 std::os::unix::fs::symlink(&outside_dir, workspace.path().join(".codewhale/rules"))
3126 .expect("symlink rules dir");
3127
3128 let ctx = load_project_context(workspace.path());
3129
3130 // Symlinked rules directory must be refused at the directory level
3131 assert!(
3132 ctx.rules_block.is_none()
3133 || !ctx
3134 .rules_block
3135 .as_ref()
3136 .unwrap()
3137 .contains("outside content"),
3138 "symlinked rules directory must be refused"
3139 );
3140 }
3141
3142 #[test]
3143 fn rules_from_both_dirs_are_loaded_together() {
3144 let tmp = tempdir().expect("tempdir");
3145 let codewhale_rules = tmp.path().join(".codewhale/rules");
3146 let claude_rules = tmp.path().join(".claude/rules");
3147 fs::create_dir_all(&codewhale_rules).expect("mkdir codewhale rules");
3148 fs::create_dir_all(&claude_rules).expect("mkdir claude rules");
3149 fs::write(codewhale_rules.join("cw.md"), "codewhale-rule").expect("write");
3150 fs::write(claude_rules.join("claude.md"), "claude-rule").expect("write");
3151
3152 let (imports, _) = ForeignInstructionImports::from_config(&["claude".to_string()]);
3153 let ctx = load_project_context_with_imports(tmp.path(), &imports);
3154 let rules = ctx.rules_block.as_ref().unwrap();
3155
3156 assert!(
3157 rules.contains("codewhale-rule"),
3158 ".codewhale/rules/ should be loaded"
3159 );
3160 assert!(
3161 rules.contains("claude-rule"),
3162 "an imported .claude/rules/ should be loaded"
3163 );
3164 // .codewhale/rules/ content should precede an imported foreign dir
3165 let pos_cw = rules.find("codewhale-rule").unwrap();
3166 let pos_claude = rules.find("claude-rule").unwrap();
3167 assert!(
3168 pos_cw < pos_claude,
3169 ".codewhale/rules/ should precede .claude/rules/"
3170 );
3171 }
3172
3173 #[test]
3174 fn rules_block_truncated_at_the_aggregate_budget() {
3175 let tmp = tempdir().expect("tempdir");
3176 let rules_dir = tmp.path().join(".codewhale/rules");
3177 fs::create_dir_all(&rules_dir).expect("mkdir rules");
3178
3179 let per_file = "X".repeat(20 * 1024); // 20 KB each
3180 for i in 0..30 {
3181 fs::write(rules_dir.join(format!("rule_{i:04}.md")), &per_file).expect("write");
3182 }
3183
3184 let ctx = load_project_context(tmp.path());
3185 let rules = ctx.rules_block.as_ref().unwrap();
3186
3187 assert!(
3188 rules.len() <= MAX_PROJECT_INSTRUCTION_BYTES,
3189 "rules block should be truncated to the aggregate budget: {} > {}",
3190 rules.len(),
3191 MAX_PROJECT_INSTRUCTION_BYTES
3192 );
3193 assert!(
3194 rules.contains("truncated at the aggregate budget"),
3195 "truncation marker missing:\n{}",
3196 &rules[rules.len().saturating_sub(200)..]
3197 );
3198 }
3199
3200 #[test]
3201 fn instructions_and_rules_share_one_aggregate_budget() {
3202 // The point of the change: three separate caps (200 KiB chain,
3203 // 500 KiB rules, 40 KiB fragments) meant no single number described
3204 // how much standing instruction text could precede the conversation.
3205 let tmp = tempdir().expect("tempdir");
3206 fs::write(tmp.path().join("AGENTS.md"), "A".repeat(40 * 1024)).expect("write agents");
3207 let rules_dir = tmp.path().join(".codewhale/rules");
3208 fs::create_dir_all(&rules_dir).expect("mkdir rules");
3209 for i in 0..10 {
3210 fs::write(rules_dir.join(format!("r{i}.md")), "B".repeat(20 * 1024)).expect("write");
3211 }
3212
3213 let ctx = load_project_context(tmp.path());
3214 let total = ctx.instructions.as_ref().map_or(0, String::len)
3215 + ctx.rules_block.as_ref().map_or(0, String::len);
3216 assert!(
3217 total <= MAX_PROJECT_INSTRUCTION_BYTES,
3218 "instructions + rules must share one ceiling: {total} > {MAX_PROJECT_INSTRUCTION_BYTES}"
3219 );
3220 // Instructions are the higher authority and are not starved by rules.
3221 assert!(
3222 ctx.instructions
3223 .as_ref()
3224 .is_some_and(|i| i.len() > 8 * 1024),
3225 "the instruction file must keep its claim on the budget"
3226 );
3227 }
3228
3229 #[test]
3230 fn nearest_scope_survives_when_the_budget_forces_a_cut() {
3231 // Trimming from the front means the broadest scope is dropped first,
3232 // so the workspace's own file is the last thing to go. The previous
3233 // per-segment accounting spent the budget root-first and could strand
3234 // the most specific file entirely.
3235 let tmp = tempdir().expect("tempdir");
3236 let root = tmp.path();
3237 fs::create_dir_all(root.join(".git")).expect("mkdir git");
3238 fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
3239 fs::write(
3240 root.join("AGENTS.md"),
3241 format!("ROOT-MARKER {}", "R".repeat(60 * 1024)),
3242 )
3243 .expect("root agents");
3244 let nested = root.join("crates").join("tui");
3245 fs::create_dir_all(&nested).expect("mkdir nested");
3246 fs::write(nested.join("AGENTS.md"), "NEAREST-MARKER stays").expect("nested agents");
3247
3248 let ctx = load_project_context_with_parents_and_home(&nested, None);
3249 let instructions = ctx.instructions.as_deref().unwrap_or("");
3250
3251 assert!(
3252 instructions.contains("NEAREST-MARKER stays"),
3253 "the nearest-scope file must survive the cut"
3254 );
3255 assert!(
3256 instructions.contains(CHAIN_TRUNCATION_MARKER.trim()),
3257 "an explicit marker must record that broader scopes were dropped"
3258 );
3259 assert!(
3260 instructions.len() <= MAX_PROJECT_INSTRUCTION_BYTES,
3261 "budget not enforced: {}",
3262 instructions.len()
3263 );
3264 }
3265
3266 #[test]
3267 fn instruction_sources_report_precedence_shadowing_and_ignored_files() {
3268 let tmp = tempdir().expect("tempdir");
3269 let root = tmp.path();
3270 fs::create_dir_all(root.join(".git")).expect("mkdir git");
3271 fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
3272 fs::write(root.join("AGENTS.md"), "primary\n").expect("agents");
3273 fs::create_dir_all(root.join(".codewhale")).expect("mkdir codewhale");
3274 fs::write(
3275 root.join(".codewhale").join("instructions.md"),
3276 "secondary\n",
3277 )
3278 .expect("workspace instructions");
3279 fs::write(root.join("WHALE.md"), "deprecated\n").expect("whale");
3280 fs::create_dir_all(root.join(".codewhale").join("rules")).expect("mkdir rules");
3281 fs::write(
3282 root.join(".codewhale").join("rules").join("style.md"),
3283 "keep it small\n",
3284 )
3285 .expect("rule");
3286
3287 let home = tmp.path().join("home");
3288 fs::create_dir_all(&home).expect("mkdir home");
3289 let configured = vec![root.join("team").join("extra.md")];
3290 fs::create_dir_all(root.join("team")).expect("mkdir team");
3291 fs::write(root.join("team").join("extra.md"), "configured\n").expect("configured");
3292
3293 let sources = project_instruction_sources(root, Some(&home), &configured);
3294 let find = |path_suffix: &str| {
3295 sources
3296 .iter()
3297 .find(|source| source.path.ends_with(path_suffix))
3298 .unwrap_or_else(|| panic!("missing entry for {path_suffix}: {sources:?}"))
3299 };
3300
3301 let agents = find("AGENTS.md");
3302 assert_eq!(agents.kind, InstructionSourceKind::Project);
3303 assert_eq!(agents.status, InstructionSourceStatus::Loaded);
3304 assert_eq!(agents.bytes, Some(8));
3305
3306 // The lower-priority candidate in the same scope exists but is
3307 // shadowed, not loaded.
3308 let secondary = find(".codewhale/instructions.md");
3309 assert_eq!(secondary.status, InstructionSourceStatus::Shadowed);
3310
3311 let whale = find("WHALE.md");
3312 assert_eq!(whale.kind, InstructionSourceKind::Ignored);
3313 assert_eq!(whale.status, InstructionSourceStatus::Skipped);
3314 assert!(whale.warning.is_some());
3315
3316 let rule = find("style.md");
3317 assert_eq!(rule.kind, InstructionSourceKind::Rule);
3318 assert_eq!(rule.status, InstructionSourceStatus::Loaded);
3319
3320 let configured_entry = find("extra.md");
3321 assert_eq!(configured_entry.kind, InstructionSourceKind::Configured);
3322 assert_eq!(configured_entry.status, InstructionSourceStatus::Loaded);
3323
3324 // Unchecked-but-real candidates are enumerated so clients can render
3325 // the full precedence map.
3326 assert!(
3327 sources
3328 .iter()
3329 .any(|s| s.status == InstructionSourceStatus::Missing),
3330 "missing candidates must appear: {sources:?}"
3331 );
3332 // Global candidates under the (empty) home are reported as missing.
3333 assert!(
3334 sources
3335 .iter()
3336 .any(|s| s.kind == InstructionSourceKind::Global
3337 && s.status == InstructionSourceStatus::Missing),
3338 "global candidates must appear: {sources:?}"
3339 );
3340 }
3341
3342 #[test]
3343 fn instruction_sources_mark_unreadable_winner_as_skipped_not_loaded() {
3344 let tmp = tempdir().expect("tempdir");
3345 let root = tmp.path();
3346 fs::create_dir_all(root.join(".git")).expect("mkdir git");
3347 fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
3348 // An empty AGENTS.md fails the load check, so the next candidate wins.
3349 fs::write(root.join("AGENTS.md"), " \n").expect("empty agents");
3350 fs::create_dir_all(root.join(".codewhale")).expect("mkdir codewhale");
3351 fs::write(root.join(".codewhale").join("instructions.md"), "wins\n")
3352 .expect("workspace instructions");
3353
3354 let sources = project_instruction_sources(root, None, &[]);
3355 let agents = sources
3356 .iter()
3357 .find(|s| s.path.ends_with("AGENTS.md"))
3358 .expect("agents entry");
3359 assert_eq!(agents.status, InstructionSourceStatus::Skipped);
3360 assert!(agents.warning.is_some(), "refusal must carry a warning");
3361
3362 let winner = sources
3363 .iter()
3364 .find(|s| s.path.ends_with(".codewhale/instructions.md"))
3365 .expect("instructions entry");
3366 assert_eq!(winner.status, InstructionSourceStatus::Loaded);
3367 }
3368 }
3369
3369 lines RUST