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