| 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 |