1pub(crate) mod collect;
11pub mod export;
12pub(crate) mod limit;
13pub(crate) mod rank;
14pub(crate) mod render;
15mod report;
16#[cfg(test)]
17mod tests;
18
19use std::collections::{BTreeMap, HashSet};
20use std::fmt::Write as _;
21use std::path::{Path, PathBuf};
22
23use crate::core::ib::intent::{TaskIntent, classify_text};
24
25pub use limit::{Unit, parse_limit};
26use render::Detail;
27
28pub const DEFAULT_LIMIT: usize = 128_000;
29pub const DEFAULT_KNOWLEDGE_CATEGORIES: &[&str] = &["decision", "architecture", "solution"];
30pub const DEFAULT_KNOWLEDGE_LIMIT: usize = 10;
31
32const TREE_PERCENT: usize = 15;
34const KNOWLEDGE_PERCENT: usize = 20;
36const MAX_FILE_PERCENT: usize = 40;
39const MAX_LISTED_WITHHELD: usize = 20;
41const DOCUMENT_OPEN: &str = "<bundle generator=\"lean-ctx\"";
43
44#[derive(Debug, Clone)]
46pub struct BundleOptions {
47 pub root: PathBuf,
48 pub scope: Option<PathBuf>,
50 pub limit: usize,
51 pub unit: Unit,
52 pub intent: Option<String>,
54 pub include: Vec<String>,
55 pub exclude: Vec<String>,
56 pub knowledge: Option<Vec<String>>,
58 pub knowledge_limit: usize,
59 pub knowledge_auto: bool,
61 pub security_check: bool,
63}
64
65impl BundleOptions {
66 pub fn new(root: PathBuf) -> Self {
67 Self {
68 root,
69 scope: None,
70 limit: DEFAULT_LIMIT,
71 unit: Unit::Chars,
72 intent: None,
73 include: Vec::new(),
74 exclude: Vec::new(),
75 knowledge: None,
76 knowledge_limit: DEFAULT_KNOWLEDGE_LIMIT,
77 knowledge_auto: false,
78 security_check: true,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Placement {
86 Full,
87 Signatures,
88 Omitted(&'static str),
90}
91
92impl Placement {
93 pub(crate) fn label(self) -> &'static str {
94 match self {
95 Self::Full => "full",
96 Self::Signatures => "signatures",
97 Self::Omitted(_) => "omitted",
98 }
99 }
100}
101
102#[derive(Debug, Clone)]
104pub struct PlannedFile {
105 pub path: String,
106 pub score: f64,
107 pub placement: Placement,
108 pub size: usize,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct Withheld {
115 pub path: String,
116 pub reason: String,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum IntentSource {
122 Argument,
123 Session,
124 None,
125}
126
127#[derive(Debug, Clone)]
129pub struct Bundle {
130 pub xml: String,
131 pub size: usize,
133 pub limit: usize,
134 pub unit: Unit,
135 pub intent: TaskIntent,
136 pub intent_text: String,
137 pub intent_source: IntentSource,
138 pub files: Vec<PlannedFile>,
140 pub withheld: Vec<Withheld>,
141 pub knowledge_facts: usize,
142 pub walk_truncated: bool,
143}
144
145impl Bundle {
146 pub fn fits(&self) -> bool {
147 self.size <= self.limit
148 }
149
150 pub fn count(&self, label: &str) -> usize {
151 self.files
152 .iter()
153 .filter(|f| f.placement.label() == label)
154 .count()
155 }
156
157 pub fn report(&self) -> String {
159 report::render(self)
160 }
161}
162
163pub fn build(opts: &BundleOptions) -> Result<Bundle, String> {
174 crate::core::archive::authority::mark_unbound();
175 let root = opts
176 .root
177 .canonicalize()
178 .map_err(|e| format!("project root '{}': {e}", opts.root.display()))?;
179 let scope = resolve_scope(&root, opts.scope.as_deref())?;
180 let root_str = root.to_string_lossy().to_string();
181
182 let mut collection = collect::collect(&root, &scope, &opts.include, &opts.exclude)?;
183 let withheld = screen(&mut collection.candidates, opts.security_check);
184
185 let (intent_text, intent, intent_source) = resolve_intent(opts.intent.as_deref(), &root_str);
186 let readable = collection
187 .candidates
188 .iter()
189 .filter(|c| c.content.is_some())
190 .count();
191 let edges: Vec<(String, String)> = if readable > 1 {
192 crate::core::graph_index::load_or_build(&root_str)
193 .edges
194 .into_iter()
195 .map(|e| (e.from, e.to))
196 .collect()
197 } else {
198 Vec::new()
199 };
200 let changed = changed_files(&root);
201 let ranked = rank::rank(&rank::RankInput {
202 candidates: &collection.candidates,
203 intent_text: &intent_text,
204 intent,
205 edges: &edges,
206 changed: &changed,
207 });
208
209 let measure = |text: &str| opts.unit.measure(text);
210 let facts = match &opts.knowledge {
211 Some(categories) => knowledge_facts(
212 &root_str,
213 categories,
214 opts.knowledge_limit,
215 opts.knowledge_auto,
216 ),
217 None => Vec::new(),
218 };
219 let knowledge = fit_knowledge(&facts, opts.limit * KNOWLEDGE_PERCENT / 100, &measure);
220 let paths: Vec<&str> = collection
221 .candidates
222 .iter()
223 .map(|c| c.path.as_str())
224 .collect();
225 let tree = render::directory_tree(&paths, opts.limit * TREE_PERCENT / 100, measure);
226
227 let frame = Frame {
228 limit: opts.limit,
229 unit: opts.unit,
230 intent,
231 intent_text: &intent_text,
232 tree: &tree,
233 knowledge: &knowledge.0,
234 withheld: &withheld,
235 };
236 let mut slots: Vec<Slot> = ranked
237 .iter()
238 .map(|r| {
239 let candidate = &collection.candidates[r.index];
240 Slot {
241 path: candidate.path.clone(),
242 content: candidate.content.as_deref().unwrap_or_default(),
243 score: r.score,
244 placement: Placement::Omitted("over budget"),
245 full: None,
246 signatures: std::cell::OnceCell::new(),
247 }
248 })
249 .collect();
250
251 let xml = allocate(&frame, &mut slots, &measure);
252 let size = measure(&xml);
253 Ok(Bundle {
254 xml,
255 size,
256 limit: opts.limit,
257 unit: opts.unit,
258 intent,
259 intent_text,
260 intent_source,
261 files: slots
262 .iter()
263 .map(|s| PlannedFile {
264 path: s.path.clone(),
265 score: s.score,
266 placement: s.placement,
267 size: s.block().map_or(0, |(_, size)| size),
268 })
269 .collect(),
270 withheld,
271 knowledge_facts: knowledge.1,
272 walk_truncated: collection.truncated,
273 })
274}
275
276fn resolve_scope(root: &Path, scope: Option<&Path>) -> Result<PathBuf, String> {
277 let Some(scope) = scope else {
278 return Ok(root.to_path_buf());
279 };
280 let joined = if scope.is_absolute() {
281 scope.to_path_buf()
282 } else {
283 root.join(scope)
284 };
285 let resolved = joined
286 .canonicalize()
287 .map_err(|e| format!("path '{}': {e}", scope.display()))?;
288 if !resolved.starts_with(root) {
289 return Err(format!(
290 "path '{}' is outside the project root {}",
291 scope.display(),
292 root.display()
293 ));
294 }
295 Ok(resolved)
296}
297
298fn screen(candidates: &mut [collect::Candidate], security_check: bool) -> Vec<Withheld> {
307 let custom = if security_check {
308 crate::core::config::Config::load()
309 .secret_detection
310 .custom_patterns
311 } else {
312 Vec::new()
313 };
314 let mut withheld = Vec::new();
315 for candidate in candidates.iter_mut() {
316 if let Some(reason) = candidate.skipped {
317 withheld.push(Withheld {
318 path: candidate.path.clone(),
319 reason: candidate
320 .refusal
321 .clone()
322 .unwrap_or_else(|| reason.to_string()),
323 });
324 continue;
325 }
326 if !security_check {
327 continue;
328 }
329 let Some(content) = candidate.content.as_deref() else {
330 continue;
331 };
332 let matches = crate::core::secret_detection::detect_secrets_with_custom(content, &custom);
333 if matches.is_empty() {
334 continue;
335 }
336 let mut kinds: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
337 for m in &matches {
338 kinds.entry(m.pattern_name).or_default().push(m.line_number);
339 }
340 let detail = kinds
341 .iter()
342 .map(|(kind, lines)| {
343 let lines: Vec<String> = lines.iter().take(3).map(|l| format!("L{l}")).collect();
344 format!("{kind} {}", lines.join(","))
345 })
346 .collect::<Vec<_>>()
347 .join("; ");
348 candidate.content = None;
349 candidate.skipped = Some("possible secret");
350 withheld.push(Withheld {
351 path: candidate.path.clone(),
352 reason: format!("possible secret ({detail})"),
353 });
354 }
355 withheld.sort_by(|a, b| {
357 let secret = |w: &Withheld| !w.reason.starts_with("possible secret");
358 secret(a).cmp(&secret(b)).then_with(|| a.path.cmp(&b.path))
359 });
360 withheld
361}
362
363fn resolve_intent(arg: Option<&str>, root: &str) -> (String, TaskIntent, IntentSource) {
364 if let Some(text) = arg.map(str::trim)
365 && !text.is_empty()
366 && !text.eq_ignore_ascii_case("auto")
367 {
368 return (
369 text.to_string(),
370 classify_text(text).unwrap_or_default(),
371 IntentSource::Argument,
372 );
373 }
374 let task = crate::core::session::caller::with_for_project(root, |session| session.task.clone())
376 .flatten()
377 .filter(|task| !task.description.trim().is_empty());
378 match task {
379 Some(task) => {
380 let intent = task
381 .intent
382 .as_deref()
383 .and_then(classify_text)
384 .or_else(|| classify_text(&task.description))
385 .unwrap_or_default();
386 (
387 task.description.trim().to_string(),
388 intent,
389 IntentSource::Session,
390 )
391 }
392 None => (String::new(), TaskIntent::Unknown, IntentSource::None),
393 }
394}
395
396fn changed_files(root: &Path) -> HashSet<String> {
398 let mut changed = HashSet::new();
399 for args in [
400 &["diff", "--name-only", "--relative", "HEAD"][..],
401 &["ls-files", "--others", "--exclude-standard"][..],
402 ] {
403 if let Some(out) = crate::core::git_util::git_out(root, args) {
404 changed.extend(out.lines().map(str::to_string));
405 }
406 }
407 changed
408}
409
410fn knowledge_facts(
414 root: &str,
415 categories: &[String],
416 limit: usize,
417 with_auto: bool,
418) -> Vec<String> {
419 let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(root) else {
420 return Vec::new();
421 };
422 let wanted: Vec<String> = categories.iter().map(|c| c.trim().to_lowercase()).collect();
423 let all = wanted.iter().any(|c| c == "all");
424 let matches = |category: &str| {
425 let category = category.to_lowercase();
426 wanted
427 .iter()
428 .any(|w| *w == category || w.strip_suffix('s') == Some(category.as_str()))
429 };
430 let deliverable = knowledge.delivery_filter();
434 let mut facts: Vec<&crate::core::knowledge::KnowledgeFact> = knowledge
435 .facts
436 .iter()
437 .filter(|&f| f.is_current() && deliverable(f))
438 .filter(|f| f.sensitivity == crate::core::sensitivity::SensitivityLevel::Public)
439 .filter(|f| with_auto || !crate::core::knowledge::is_machine_derived(&f.category, &f.key))
440 .filter(|f| all || matches(&f.category))
441 .filter(|f| crate::core::secret_detection::detect_secrets(&f.value).is_empty())
442 .collect();
443 facts.sort_by(|a, b| {
444 b.last_confirmed
445 .cmp(&a.last_confirmed)
446 .then_with(|| a.category.cmp(&b.category))
447 .then_with(|| a.key.cmp(&b.key))
448 });
449 facts
450 .into_iter()
451 .take(limit)
452 .map(|f| render::fact_block(&f.category, &f.key, &f.value))
453 .collect()
454}
455
456fn fit_knowledge(
458 facts: &[String],
459 max: usize,
460 measure: &impl Fn(&str) -> usize,
461) -> (String, usize) {
462 let mut kept = facts.len();
463 while kept > 0 {
464 let section = format!("<knowledge>\n{}</knowledge>\n", facts[..kept].concat());
465 if measure(§ion) <= max {
466 return (section, kept);
467 }
468 kept -= 1;
469 }
470 (String::new(), 0)
471}
472
473struct Frame<'a> {
475 limit: usize,
476 unit: Unit,
477 intent: TaskIntent,
478 intent_text: &'a str,
479 tree: &'a str,
480 knowledge: &'a str,
481 withheld: &'a [Withheld],
482}
483
484struct Slot<'a> {
485 path: String,
486 content: &'a str,
487 score: f64,
488 placement: Placement,
489 full: Option<(String, usize)>,
491 signatures: std::cell::OnceCell<Option<(String, usize)>>,
493}
494
495impl Slot<'_> {
496 fn full(&mut self, measure: &impl Fn(&str) -> usize) -> usize {
497 if self.full.is_none() {
498 let block = render::file_block(&self.path, Detail::Full, self.content);
499 let size = measure(&block);
500 self.full = Some((block, size));
501 }
502 self.full.as_ref().map_or(0, |(_, size)| *size)
503 }
504
505 fn signatures(&self, measure: &impl Fn(&str) -> usize) -> Option<usize> {
506 self.signatures
507 .get_or_init(|| {
508 let ext = Path::new(&self.path)
509 .extension()
510 .and_then(|e| e.to_str())
511 .unwrap_or_default();
512 let sigs = crate::core::signatures::extract_signatures(self.content, ext);
513 (!sigs.is_empty()).then(|| {
514 let body: Vec<String> = sigs
515 .iter()
516 .map(crate::core::signatures::Signature::to_compact_located)
517 .collect();
518 let block =
519 render::file_block(&self.path, Detail::Signatures, &body.join("\n"));
520 let size = measure(&block);
521 (block, size)
522 })
523 })
524 .as_ref()
525 .map(|(_, size)| *size)
526 }
527
528 fn block(&self) -> Option<(&str, usize)> {
529 let rendered = match self.placement {
530 Placement::Full => self.full.as_ref(),
531 Placement::Signatures => self.signatures.get().and_then(Option::as_ref),
532 Placement::Omitted(_) => None,
533 };
534 rendered.map(|(block, size)| (block.as_str(), *size))
535 }
536}
537
538fn allocate(frame: &Frame<'_>, slots: &mut [Slot<'_>], measure: &impl Fn(&str) -> usize) -> String {
540 let overhead = measure(&assemble(frame, slots, slots.len()));
542 let budget = frame.limit.saturating_sub(overhead);
543 let per_file_cap = budget * MAX_FILE_PERCENT / 100;
544 let mut remaining = budget;
545 for (rank, slot) in slots.iter_mut().enumerate() {
546 if remaining == 0 {
547 break;
548 }
549 let full = slot.full(measure);
550 if full <= remaining && (rank == 0 || full <= per_file_cap) {
551 slot.placement = Placement::Full;
552 remaining -= full;
553 continue;
554 }
555 match slot.signatures(measure) {
556 Some(size) if size <= remaining => {
557 slot.placement = Placement::Signatures;
558 remaining -= size;
559 }
560 Some(_) => slot.placement = Placement::Omitted("over budget"),
561 None => slot.placement = Placement::Omitted("over budget; no signatures"),
562 }
563 }
564
565 loop {
568 let xml = assemble(frame, slots, 0);
569 let size = measure(&xml);
570 if size <= frame.limit {
571 return xml;
572 }
573 let mut excess = size - frame.limit;
574 let mut changed = false;
575 for slot in slots.iter_mut().rev() {
576 if excess == 0 {
577 break;
578 }
579 match slot.placement {
580 Placement::Full => {
581 let full = slot.full(measure);
582 match slot.signatures(measure) {
583 Some(sig) if sig < full => {
584 slot.placement = Placement::Signatures;
585 excess = excess.saturating_sub(full - sig);
586 }
587 _ => {
588 slot.placement = Placement::Omitted("over budget");
589 excess = excess.saturating_sub(full);
590 }
591 }
592 changed = true;
593 }
594 Placement::Signatures => {
595 let sig = slot.signatures(measure).unwrap_or(0);
596 slot.placement = Placement::Omitted("over budget");
597 excess = excess.saturating_sub(sig);
598 changed = true;
599 }
600 Placement::Omitted(_) => {}
601 }
602 }
603 if !changed {
604 return xml;
605 }
606 }
607}
608
609fn assemble(frame: &Frame<'_>, slots: &[Slot<'_>], pad_counts: usize) -> String {
612 let mut out = String::new();
613 let _ = writeln!(
614 out,
615 "{DOCUMENT_OPEN} limit=\"{}\" unit=\"{}\" intent=\"{}\">",
616 frame.limit, frame.unit, frame.intent
617 );
618 if !frame.intent_text.is_empty() {
619 let _ = writeln!(out, "<task>{}</task>", render::escape(frame.intent_text));
620 }
621 out.push_str(&summary(frame, slots, pad_counts));
622 out.push_str("<directory_structure>\n");
623 out.push_str(frame.tree);
624 out.push_str("</directory_structure>\n");
625 out.push_str(frame.knowledge);
626 out.push_str("<files>\n");
627 if pad_counts == 0 {
628 for slot in slots {
629 if let Some((block, _)) = slot.block() {
630 out.push_str(block);
631 }
632 }
633 }
634 out.push_str("</files>\n</bundle>\n");
635 out
636}
637
638fn summary(frame: &Frame<'_>, slots: &[Slot<'_>], pad_counts: usize) -> String {
639 let count = |label: &str| {
640 if pad_counts > 0 {
641 pad_counts
642 } else {
643 slots
644 .iter()
645 .filter(|s| s.placement.label() == label)
646 .count()
647 }
648 };
649 let mut out = String::from("<summary>\n");
650 let _ = writeln!(
651 out,
652 "Context bundle for a {} {} input limit. Files are ordered by relevance to the task.",
653 frame.limit, frame.unit
654 );
655 let _ = writeln!(
656 out,
657 "Files: {} full, {} signatures only (declarations with line numbers), {} omitted to fit the limit, {} withheld.",
658 count("full"),
659 count("signatures"),
660 count("omitted"),
661 frame.withheld.len()
662 );
663 out.push_str(
664 "Omitted and withheld files appear only in the directory structure; ask for them by path.\n",
665 );
666 if !frame.withheld.is_empty() {
667 out.push_str("Withheld:\n");
668 for w in frame.withheld.iter().take(MAX_LISTED_WITHHELD) {
669 let _ = writeln!(
670 out,
671 "- {}: {}",
672 render::escape(&w.path),
673 render::escape(&w.reason)
674 );
675 }
676 if frame.withheld.len() > MAX_LISTED_WITHHELD {
677 let _ = writeln!(
678 out,
679 "- and {} more",
680 frame.withheld.len() - MAX_LISTED_WITHHELD
681 );
682 }
683 }
684 out.push_str("</summary>\n");
685 out
686}