返回 CodeWhale
fragments.rs
根目录 / crates / core / src / fragments.rs
1 //! Bounded context-fragment system with hard caps (issue #5264).
2 //!
3 //! Every context injection goes through a typed fragment with a
4 //! `matches_text` recognizer, collected in one `crates/core` module.
5 //! Hard caps: per-fragment byte cap, 10K-token ceiling, injected-item count.
6 //! Project-instruction import (#3978, #4079) is a typed fragment.
7
8 use std::collections::hash_map::DefaultHasher;
9 use std::hash::{Hash, Hasher};
10 use std::path::{Path, PathBuf};
11
12 // Caps
13 pub const MAX_FRAGMENT_TOKENS: usize = 10_000;
14 pub const MAX_FRAGMENT_BYTES: usize = MAX_FRAGMENT_TOKENS * 4; // 40_000
15 pub const DEFAULT_FRAGMENT_MAX_BYTES: usize = 4 * 1024;
16 pub const MAX_FRAGMENTS_PER_CONTEXT: usize = 16;
17 pub const INSTRUCTIONS_FILE_MAX_BYTES: usize = 100 * 1024;
18 pub const MAX_INSTRUCTION_FILES: usize = 32;
19
20 /// Stable fragment identities. Markers are public contract.
21 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
22 pub enum FragmentId {
23 Workspace,
24 Permissions,
25 Route,
26 AgentTopology,
27 SkillsTools,
28 TokenBudget,
29 ProjectInstructions,
30 Constitution,
31 }
32
33 impl FragmentId {
34 #[must_use]
35 pub fn as_str(self) -> &'static str {
36 match self {
37 Self::Workspace => "workspace",
38 Self::Permissions => "permissions",
39 Self::Route => "route",
40 Self::AgentTopology => "agent_topology",
41 Self::SkillsTools => "skills_tools",
42 Self::TokenBudget => "token_budget",
43 Self::ProjectInstructions => "project_instructions",
44 Self::Constitution => "constitution",
45 }
46 }
47 #[must_use]
48 pub fn marker(self) -> &'static str {
49 match self {
50 Self::Workspace => "<!-- cw:ctx:workspace -->",
51 Self::Permissions => "<!-- cw:ctx:permissions -->",
52 Self::Route => "<!-- cw:ctx:route -->",
53 Self::AgentTopology => "<!-- cw:ctx:agent_topology -->",
54 Self::SkillsTools => "<!-- cw:ctx:skills_tools -->",
55 Self::TokenBudget => "<!-- cw:ctx:token_budget -->",
56 Self::ProjectInstructions => "<!-- cw:ctx:project_instructions -->",
57 Self::Constitution => "<!-- cw:ctx:constitution -->",
58 }
59 }
60 #[must_use]
61 pub fn role(self) -> FragmentRole {
62 match self {
63 Self::Workspace => FragmentRole::Workspace,
64 Self::Permissions => FragmentRole::Permissions,
65 Self::Route => FragmentRole::Route,
66 Self::AgentTopology => FragmentRole::AgentTopology,
67 Self::SkillsTools => FragmentRole::SkillsTools,
68 Self::TokenBudget => FragmentRole::TokenBudget,
69 Self::ProjectInstructions => FragmentRole::ProjectInstructions,
70 Self::Constitution => FragmentRole::Constitution,
71 }
72 }
73 #[must_use]
74 pub fn all() -> &'static [FragmentId] {
75 &[
76 Self::Workspace,
77 Self::Permissions,
78 Self::Route,
79 Self::AgentTopology,
80 Self::SkillsTools,
81 Self::TokenBudget,
82 Self::ProjectInstructions,
83 Self::Constitution,
84 ]
85 }
86 }
87
88 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89 pub enum FragmentRole {
90 Workspace,
91 Permissions,
92 Route,
93 AgentTopology,
94 SkillsTools,
95 TokenBudget,
96 ProjectInstructions,
97 Constitution,
98 }
99
100 impl FragmentRole {
101 #[must_use]
102 pub fn as_str(self) -> &'static str {
103 match self {
104 Self::Workspace => "workspace",
105 Self::Permissions => "permissions",
106 Self::Route => "route",
107 Self::AgentTopology => "agent_topology",
108 Self::SkillsTools => "skills_tools",
109 Self::TokenBudget => "token_budget",
110 Self::ProjectInstructions => "project_instructions",
111 Self::Constitution => "constitution",
112 }
113 }
114 }
115
116 #[must_use]
117 pub fn estimate_tokens(text: &str) -> usize {
118 text.len().div_ceil(4)
119 }
120
121 /// Typed fragment trait with `matches_text` recognizer.
122 pub trait ContextFragment {
123 fn fragment_id(&self) -> FragmentId;
124 fn marker(&self) -> &'static str;
125 fn content(&self) -> &str;
126 fn matches_text(&self, haystack: &str) -> bool {
127 haystack.contains(self.marker())
128 }
129 fn tokens_est(&self) -> usize {
130 estimate_tokens(self.content())
131 }
132 fn max_bytes(&self) -> usize;
133 fn is_within_token_ceiling(&self) -> bool {
134 self.tokens_est() <= MAX_FRAGMENT_TOKENS
135 }
136 fn is_within_byte_ceiling(&self) -> bool {
137 self.content().len() <= MAX_FRAGMENT_BYTES
138 }
139 }
140
141 #[derive(Debug, Clone, PartialEq, Eq)]
142 pub struct BoundedFragment {
143 pub id: FragmentId,
144 pub role: FragmentRole,
145 pub marker: &'static str,
146 pub max_bytes: usize,
147 pub content: String,
148 pub content_hash: u64,
149 }
150
151 impl BoundedFragment {
152 #[must_use]
153 pub fn new(id: FragmentId, raw: impl Into<String>) -> Self {
154 Self::with_max_bytes(id, raw, DEFAULT_FRAGMENT_MAX_BYTES)
155 }
156 #[must_use]
157 pub fn with_max_bytes(id: FragmentId, raw: impl Into<String>, max_bytes: usize) -> Self {
158 let clamped_max = max_bytes.min(MAX_FRAGMENT_BYTES);
159 let mut content = enforce_byte_cap(raw.into(), clamped_max);
160 if estimate_tokens(&content) > MAX_FRAGMENT_TOKENS {
161 content = enforce_byte_cap(content, MAX_FRAGMENT_BYTES);
162 }
163 let content_hash = hash_content(&content);
164 Self {
165 id,
166 role: id.role(),
167 marker: id.marker(),
168 max_bytes: clamped_max,
169 content,
170 content_hash,
171 }
172 }
173 #[must_use]
174 pub fn project_instructions(raw: impl Into<String>) -> Self {
175 Self::with_max_bytes(FragmentId::ProjectInstructions, raw, MAX_FRAGMENT_BYTES)
176 }
177 #[must_use]
178 pub fn constitution(raw: impl Into<String>) -> Self {
179 Self::with_max_bytes(FragmentId::Constitution, raw, MAX_FRAGMENT_BYTES)
180 }
181 #[must_use]
182 pub fn render_marked(&self) -> String {
183 format!("{}\n{}", self.marker, self.content.trim_end())
184 }
185 }
186
187 impl ContextFragment for BoundedFragment {
188 fn fragment_id(&self) -> FragmentId {
189 self.id
190 }
191 fn marker(&self) -> &'static str {
192 self.marker
193 }
194 fn content(&self) -> &str {
195 &self.content
196 }
197 fn max_bytes(&self) -> usize {
198 self.max_bytes
199 }
200 }
201
202 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
203 pub enum FragmentCapError {
204 #[error("fragment {id:?} exceeds 10K-token ceiling: {tokens} tokens ({bytes} bytes)")]
205 TokenCeiling {
206 id: FragmentId,
207 tokens: usize,
208 bytes: usize,
209 },
210 #[error("fragment {id:?} exceeds byte ceiling: {bytes} > {max} bytes")]
211 ByteCeiling {
212 id: FragmentId,
213 bytes: usize,
214 max: usize,
215 },
216 #[error("context has too many fragments: {count} > {max}")]
217 TooManyFragments { count: usize, max: usize },
218 }
219
220 pub fn validate_fragment(fragment: &BoundedFragment) -> Result<(), FragmentCapError> {
221 if fragment.content.len() > MAX_FRAGMENT_BYTES {
222 return Err(FragmentCapError::ByteCeiling {
223 id: fragment.id,
224 bytes: fragment.content.len(),
225 max: MAX_FRAGMENT_BYTES,
226 });
227 }
228 let tokens = estimate_tokens(&fragment.content);
229 if tokens > MAX_FRAGMENT_TOKENS {
230 return Err(FragmentCapError::TokenCeiling {
231 id: fragment.id,
232 bytes: fragment.content.len(),
233 tokens,
234 });
235 }
236 Ok(())
237 }
238
239 pub fn validate_fragment_set(fragments: &[BoundedFragment]) -> Result<(), FragmentCapError> {
240 if fragments.len() > MAX_FRAGMENTS_PER_CONTEXT {
241 return Err(FragmentCapError::TooManyFragments {
242 count: fragments.len(),
243 max: MAX_FRAGMENTS_PER_CONTEXT,
244 });
245 }
246 for f in fragments {
247 validate_fragment(f)?;
248 }
249 Ok(())
250 }
251
252 // Project-instruction import (#3978)
253 pub const PROJECT_INSTRUCTION_CANDIDATES: &[&str] = &[
254 "AGENTS.md",
255 ".agents/AGENTS.md",
256 "CLAUDE.md",
257 ".claude/instructions.md",
258 ".codewhale/instructions.md",
259 ".deepseek/instructions.md",
260 ".cursorrules",
261 ".cursor/rules",
262 ".clinerules",
263 ".windsurf/rules",
264 ".gemini",
265 ".github/copilot-instructions.md",
266 ".github/muse-instructions.md",
267 ];
268
269 /// Workspace instruction formats not already owned by Codewhale's canonical
270 /// project-context loader. The TUI uses this subset to avoid injecting
271 /// `AGENTS.md` / `CLAUDE.md` / `instructions.md` twice while still importing
272 /// additional agent rule formats through the typed fragment boundary.
273 pub const ADDITIONAL_PROJECT_INSTRUCTION_CANDIDATES: &[&str] = &[
274 ".agents/AGENTS.md",
275 ".cursorrules",
276 ".cursor/rules",
277 ".clinerules",
278 ".windsurf/rules",
279 ".gemini",
280 ".github/copilot-instructions.md",
281 ".github/muse-instructions.md",
282 ];
283
284 fn is_symlink(p: &Path) -> bool {
285 std::fs::symlink_metadata(p)
286 .map(|m| m.file_type().is_symlink())
287 .unwrap_or(false)
288 }
289 fn read_capped(p: &Path) -> Option<String> {
290 let meta = std::fs::metadata(p).ok()?;
291 if !meta.is_file() {
292 return None;
293 }
294 if meta.len() > INSTRUCTIONS_FILE_MAX_BYTES as u64 {
295 let mut file = std::fs::File::open(p).ok()?;
296 let mut buf = vec![0u8; INSTRUCTIONS_FILE_MAX_BYTES];
297 use std::io::Read as _;
298 let n = file.read(&mut buf).ok()?;
299 buf.truncate(n);
300 let mut text = String::from_utf8_lossy(&buf).into_owned();
301 let mut end = INSTRUCTIONS_FILE_MAX_BYTES.min(text.len());
302 while end > 0 && !text.is_char_boundary(end) {
303 end -= 1;
304 }
305 text.truncate(end);
306 let omitted = meta
307 .len()
308 .saturating_sub(INSTRUCTIONS_FILE_MAX_BYTES as u64);
309 text.push_str(&format!("\n[…truncated: {omitted} bytes omitted]"));
310 return Some(text);
311 }
312 let raw = std::fs::read_to_string(p).ok()?;
313 let trimmed = raw.trim();
314 if trimmed.is_empty() {
315 None
316 } else {
317 Some(trimmed.to_string())
318 }
319 }
320 fn collect_candidate_files(workspace: &Path, candidates: &[&str]) -> Vec<PathBuf> {
321 let mut files = Vec::new();
322 for candidate in candidates {
323 let path = workspace.join(candidate);
324 // `is_dir()` follows symlinks, so a symlinked `.cursor/rules` pointing
325 // outside the workspace used to be traversed: every file behind it is a
326 // real file, so the per-entry symlink checks below all passed and the
327 // escape succeeded. `project_context.rs` already refuses symlinked
328 // rules directories for exactly this reason; the two loaders now agree.
329 if path.is_dir() && !is_symlink(&path) {
330 let mut dir_files = Vec::new();
331 if let Ok(entries) = std::fs::read_dir(&path) {
332 for e in entries.flatten() {
333 let p = e.path();
334 if p.is_file() && p.extension().is_some_and(|e| e == "md") && !is_symlink(&p) {
335 dir_files.push(p);
336 }
337 }
338 }
339 if let Ok(entries) = std::fs::read_dir(&path) {
340 for e in entries.flatten() {
341 let p = e.path();
342 if p.is_dir()
343 && !is_symlink(&p)
344 && let Ok(sub) = std::fs::read_dir(&p)
345 {
346 for se in sub.flatten() {
347 let sp = se.path();
348 if sp.is_file()
349 && sp.extension().is_some_and(|e| e == "md")
350 && !is_symlink(&sp)
351 {
352 dir_files.push(sp);
353 }
354 }
355 }
356 }
357 }
358 dir_files.sort();
359 let remaining = MAX_INSTRUCTION_FILES.saturating_sub(files.len());
360 dir_files.truncate(remaining);
361 files.extend(dir_files);
362 } else if path.is_file() && !is_symlink(&path) {
363 files.push(path);
364 }
365 if files.len() >= MAX_INSTRUCTION_FILES {
366 break;
367 }
368 }
369 files.truncate(MAX_INSTRUCTION_FILES);
370 files.sort();
371 files.dedup();
372 files
373 }
374
375 /// Enumerate the safe, bounded file paths selected for a caller-provided set
376 /// of project-instruction candidates.
377 ///
378 /// This is the same traversal used by
379 /// [`load_selected_project_instruction_fragment`]. Callers that cache the
380 /// loader's result can fingerprint these paths without maintaining a second,
381 /// subtly different directory walk. Content validation still happens in the
382 /// loader: an empty, unreadable, or non-UTF-8 file may be selected here and
383 /// then contribute no fragment.
384 #[must_use]
385 pub fn selected_project_instruction_candidate_files(
386 workspace: &Path,
387 candidates: &[&str],
388 ) -> Vec<PathBuf> {
389 collect_candidate_files(workspace, candidates)
390 }
391
392 fn load_project_instruction_fragment_from_candidates(
393 workspace: &Path,
394 candidates: &[&str],
395 ) -> Option<BoundedFragment> {
396 let files = collect_candidate_files(workspace, candidates);
397 if files.is_empty() {
398 return None;
399 }
400 let mut sections = Vec::new();
401 for path in files {
402 if let Some(content) = read_capped(&path) {
403 let rel = path
404 .strip_prefix(workspace)
405 .unwrap_or(&path)
406 .display()
407 .to_string();
408 sections.push(format!(
409 "<project_instructions source=\"{rel}\">\n{content}\n</project_instructions>"
410 ));
411 }
412 }
413 if sections.is_empty() {
414 return None;
415 }
416 let merged = sections.join("\n\n");
417 let fragment = BoundedFragment::project_instructions(merged);
418 debug_assert!(validate_fragment(&fragment).is_ok());
419 Some(fragment)
420 }
421
422 pub fn load_project_instruction_fragment(workspace: &Path) -> Option<BoundedFragment> {
423 load_project_instruction_fragment_from_candidates(workspace, PROJECT_INSTRUCTION_CANDIDATES)
424 }
425
426 /// Load only instruction formats that the canonical TUI project-context path
427 /// does not already render. This prevents duplicate authority while retaining
428 /// the broader compatibility import added by the bounded fragment system.
429 ///
430 /// Prefer [`load_selected_project_instruction_fragment`]: importing every
431 /// foreign format unconditionally makes another tool's instruction file
432 /// standing authority here without anyone asking for it.
433 pub fn load_additional_project_instruction_fragment(workspace: &Path) -> Option<BoundedFragment> {
434 load_project_instruction_fragment_from_candidates(
435 workspace,
436 ADDITIONAL_PROJECT_INSTRUCTION_CANDIDATES,
437 )
438 }
439
440 /// Load exactly the instruction candidates the caller names.
441 ///
442 /// The caller owns the opt-in decision; an empty list imports nothing and
443 /// yields `None` rather than silently falling back to the full candidate set.
444 pub fn load_selected_project_instruction_fragment(
445 workspace: &Path,
446 candidates: &[&str],
447 ) -> Option<BoundedFragment> {
448 if candidates.is_empty() {
449 return None;
450 }
451 load_project_instruction_fragment_from_candidates(workspace, candidates)
452 }
453
454 pub fn project_instructions_from_sources(
455 sources: impl IntoIterator<Item = (String, String)>,
456 ) -> Option<BoundedFragment> {
457 let mut sections = Vec::new();
458 for (name, content) in sources {
459 let trimmed = content.trim();
460 if trimmed.is_empty() {
461 continue;
462 }
463 let body = if trimmed.len() > INSTRUCTIONS_FILE_MAX_BYTES {
464 let mut end = INSTRUCTIONS_FILE_MAX_BYTES;
465 while end > 0 && !trimmed.is_char_boundary(end) {
466 end -= 1;
467 }
468 let omitted = trimmed.len() - end;
469 format!("{}\n[…truncated: {omitted} bytes omitted]", &trimmed[..end])
470 } else {
471 trimmed.to_string()
472 };
473 sections.push(format!(
474 "<project_instructions source=\"{name}\">\n{body}\n</project_instructions>"
475 ));
476 if sections.len() >= MAX_INSTRUCTION_FILES {
477 break;
478 }
479 }
480 if sections.is_empty() {
481 return None;
482 }
483 Some(BoundedFragment::project_instructions(sections.join("\n\n")))
484 }
485
486 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
487 pub struct FragmentBudgetSnapshot {
488 pub fragment_ids: Vec<String>,
489 pub fragment_markers: Vec<String>,
490 pub max_fragment_bytes: usize,
491 pub max_fragment_tokens: usize,
492 pub default_fragment_max_bytes: usize,
493 pub max_fragments_per_context: usize,
494 pub instructions_file_max_bytes: usize,
495 pub max_instruction_files: usize,
496 pub project_instruction_candidates: Vec<String>,
497 }
498
499 #[must_use]
500 pub fn fragment_budget_snapshot() -> FragmentBudgetSnapshot {
501 FragmentBudgetSnapshot {
502 fragment_ids: FragmentId::all()
503 .iter()
504 .map(|id| id.as_str().to_string())
505 .collect(),
506 fragment_markers: FragmentId::all()
507 .iter()
508 .map(|id| id.marker().to_string())
509 .collect(),
510 max_fragment_bytes: MAX_FRAGMENT_BYTES,
511 max_fragment_tokens: MAX_FRAGMENT_TOKENS,
512 default_fragment_max_bytes: DEFAULT_FRAGMENT_MAX_BYTES,
513 max_fragments_per_context: MAX_FRAGMENTS_PER_CONTEXT,
514 instructions_file_max_bytes: INSTRUCTIONS_FILE_MAX_BYTES,
515 max_instruction_files: MAX_INSTRUCTION_FILES,
516 project_instruction_candidates: PROJECT_INSTRUCTION_CANDIDATES
517 .iter()
518 .map(|s| s.to_string())
519 .collect(),
520 }
521 }
522
523 fn hash_content(content: &str) -> u64 {
524 let mut hasher = DefaultHasher::new();
525 content.hash(&mut hasher);
526 hasher.finish()
527 }
528 fn enforce_byte_cap(raw: String, max_bytes: usize) -> String {
529 if max_bytes == 0 {
530 return String::new();
531 }
532 if raw.len() <= max_bytes {
533 return raw;
534 }
535 let omitted = raw.len().saturating_sub(max_bytes);
536 let marker = format!("\n[…truncated: {omitted} bytes omitted]");
537 if marker.len() >= max_bytes {
538 return marker.chars().take(max_bytes).collect();
539 }
540 let keep = max_bytes.saturating_sub(marker.len());
541 let mut end = keep;
542 while end > 0 && !raw.is_char_boundary(end) {
543 end -= 1;
544 }
545 let mut out = raw[..end].to_string();
546 out.push_str(&marker);
547 out
548 }
549
550 #[cfg(test)]
551 mod tests {
552 use super::*;
553 use std::fs;
554 use tempfile::tempdir;
555
556 #[test]
557 fn fragment_has_matches_text_recognizer() {
558 let fragment = BoundedFragment::new(FragmentId::Workspace, "repo: /tmp/demo");
559 let rendered = fragment.render_marked();
560 assert!(fragment.matches_text(&rendered));
561 assert!(!fragment.matches_text("no marker here"));
562 assert_eq!(FragmentId::Workspace.marker(), "<!-- cw:ctx:workspace -->");
563 assert_eq!(
564 FragmentId::ProjectInstructions.marker(),
565 "<!-- cw:ctx:project_instructions -->"
566 );
567 assert_eq!(
568 FragmentId::Constitution.marker(),
569 "<!-- cw:ctx:constitution -->"
570 );
571 }
572 #[test]
573 fn all_fragment_types_go_through_bounded_module() {
574 for id in FragmentId::all() {
575 let fragment = BoundedFragment::new(*id, "hello");
576 assert_eq!(fragment.marker, id.marker());
577 assert_eq!(fragment.id, *id);
578 validate_fragment(&fragment).expect("small fragment must pass caps");
579 assert!(fragment.is_within_token_ceiling());
580 assert!(fragment.is_within_byte_ceiling());
581 }
582 }
583 #[test]
584 fn per_fragment_byte_cap_truncates_with_marker() {
585 let oversized = "x".repeat(DEFAULT_FRAGMENT_MAX_BYTES + 64);
586 let fragment = BoundedFragment::new(FragmentId::AgentTopology, oversized);
587 assert!(fragment.content.len() <= DEFAULT_FRAGMENT_MAX_BYTES);
588 assert!(fragment.content.contains("[…truncated:"));
589 validate_fragment(&fragment).expect("truncated fragment must pass caps");
590 }
591 #[test]
592 fn ten_k_token_ceiling_is_enforced() {
593 let huge = "a".repeat(MAX_FRAGMENT_BYTES + 1_000);
594 let fragment = BoundedFragment::project_instructions(huge);
595 assert!(fragment.content.len() <= MAX_FRAGMENT_BYTES);
596 assert!(estimate_tokens(&fragment.content) <= MAX_FRAGMENT_TOKENS);
597 validate_fragment(&fragment).expect("capped fragment must satisfy token ceiling");
598 let also_huge = "b".repeat(MAX_FRAGMENT_BYTES + 5000);
599 let fragment = BoundedFragment::with_max_bytes(FragmentId::Workspace, also_huge, 100_000);
600 assert!(fragment.max_bytes <= MAX_FRAGMENT_BYTES);
601 assert!(fragment.content.len() <= MAX_FRAGMENT_BYTES);
602 assert!(fragment.is_within_token_ceiling());
603 }
604 #[test]
605 fn injected_item_count_cap_is_enforced() {
606 let fragments: Vec<BoundedFragment> = (0..MAX_FRAGMENTS_PER_CONTEXT)
607 .map(|i| BoundedFragment::new(FragmentId::Workspace, format!("item {i}")))
608 .collect();
609 validate_fragment_set(&fragments).expect("exactly MAX_FRAGMENTS must pass");
610 let mut too_many = fragments.clone();
611 too_many.push(BoundedFragment::new(FragmentId::Route, "one too many"));
612 let err = validate_fragment_set(&too_many).expect_err("one over cap must fail");
613 assert!(matches!(err, FragmentCapError::TooManyFragments { .. }));
614 }
615 #[test]
616 #[cfg(unix)]
617 fn symlinked_candidate_directory_cannot_escape_the_workspace() {
618 // `Path::is_dir()` follows symlinks, so a symlinked candidate dir used
619 // to be traversed: every file behind it is a real file, so the
620 // per-entry symlink checks passed and content outside the workspace was
621 // imported as project instruction authority.
622 let outside = tempdir().expect("outside");
623 fs::write(outside.path().join("leaked.md"), "SECRET-OUTSIDE-WORKSPACE")
624 .expect("write outside");
625
626 let dir = tempdir().expect("workspace");
627 let ws = dir.path();
628 fs::create_dir_all(ws.join(".cursor")).expect("mkdir .cursor");
629 std::os::unix::fs::symlink(outside.path(), ws.join(".cursor").join("rules"))
630 .expect("symlink rules dir");
631
632 let fragment = load_selected_project_instruction_fragment(ws, &[".cursor/rules"]);
633 assert!(
634 fragment.is_none(),
635 "a symlinked candidate directory must not be traversed: {fragment:?}"
636 );
637 }
638
639 #[test]
640 fn selected_candidates_import_nothing_when_the_list_is_empty() {
641 let dir = tempdir().expect("tempdir");
642 let ws = dir.path();
643 fs::write(ws.join(".cursorrules"), "cursor: always use tabs").expect("write cursor");
644 assert!(
645 load_selected_project_instruction_fragment(ws, &[]).is_none(),
646 "an empty opt-in list must import nothing, not fall back to every format"
647 );
648 assert!(
649 load_selected_project_instruction_fragment(ws, &[".cursorrules"]).is_some(),
650 "an explicitly named candidate is still imported"
651 );
652 }
653
654 #[test]
655 fn project_instruction_import_is_a_typed_fragment() {
656 let dir = tempdir().expect("tempdir");
657 let ws = dir.path();
658 fs::write(ws.join(".cursorrules"), "cursor: always use tabs").expect("write cursor");
659 fs::write(ws.join(".clinerules"), "cline: prefer functional style").expect("write cline");
660 fs::create_dir_all(ws.join(".windsurf").join("rules")).expect("mkdir windsurf");
661 fs::write(
662 ws.join(".windsurf").join("rules").join("extra.md"),
663 "# windsurf extra",
664 )
665 .expect("write windsurf");
666 fs::create_dir_all(ws.join(".github")).expect("mkdir github");
667 fs::write(
668 ws.join(".github").join("copilot-instructions.md"),
669 "# copilot says hello",
670 )
671 .expect("write copilot");
672 let fragment =
673 load_project_instruction_fragment(ws).expect("must find imported instructions");
674 assert_eq!(fragment.id, FragmentId::ProjectInstructions);
675 assert!(fragment.matches_text(&fragment.render_marked()));
676 assert!(
677 fragment.content.contains(".cursorrules") || fragment.content.contains(".clinerules")
678 );
679 validate_fragment(&fragment).expect("project-instructions fragment must satisfy caps");
680 let from_sources = project_instructions_from_sources(vec![
681 ("AGENTS.md".to_string(), "# AGENTS\nbe helpful".to_string()),
682 (
683 ".cursorrules".to_string(),
684 "cursor: do the thing".to_string(),
685 ),
686 ])
687 .expect("sources");
688 assert_eq!(from_sources.id, FragmentId::ProjectInstructions);
689 assert!(from_sources.content.contains("AGENTS.md"));
690 assert!(from_sources.content.contains(".cursorrules"));
691 validate_fragment(&from_sources).expect("explicit sources must also satisfy caps");
692 }
693 #[test]
694 fn additional_project_instruction_import_does_not_duplicate_canonical_authority() {
695 let dir = tempdir().expect("tempdir");
696 let ws = dir.path();
697 fs::write(ws.join("AGENTS.md"), "canonical authority marker").expect("write agents");
698
699 assert!(
700 load_additional_project_instruction_fragment(ws).is_none(),
701 "AGENTS.md is already owned by the canonical project-context loader"
702 );
703
704 fs::write(ws.join(".cursorrules"), "additional cursor marker").expect("write cursor rules");
705 let additional = load_additional_project_instruction_fragment(ws)
706 .expect("additional rules must produce a typed fragment");
707 assert!(additional.content.contains("additional cursor marker"));
708 assert!(!additional.content.contains("canonical authority marker"));
709
710 let complete = load_project_instruction_fragment(ws)
711 .expect("complete importer must retain every supported source");
712 assert!(complete.content.contains("canonical authority marker"));
713 assert!(complete.content.contains("additional cursor marker"));
714 }
715 #[test]
716 fn fragment_budget_snapshot_is_stable() {
717 let snap = fragment_budget_snapshot();
718 assert_eq!(snap.max_fragment_tokens, 10_000);
719 assert_eq!(snap.max_fragment_bytes, 40_000);
720 assert_eq!(snap.max_fragments_per_context, 16);
721 assert_eq!(snap.default_fragment_max_bytes, 4 * 1024);
722 assert!(
723 snap.fragment_ids
724 .contains(&"project_instructions".to_string())
725 );
726 assert!(snap.fragment_ids.contains(&"constitution".to_string()));
727 assert!(
728 snap.project_instruction_candidates
729 .contains(&".cursorrules".to_string())
730 );
731 assert!(
732 snap.project_instruction_candidates
733 .contains(&".github/copilot-instructions.md".to_string())
734 );
735 assert!(
736 snap.fragment_markers
737 .contains(&"<!-- cw:ctx:project_instructions -->".to_string())
738 );
739 }
740 }
741
741 lines RUST