1use anyhow::Result;
2use clap::{Parser, Subcommand, ValueEnum};
3use serde::Serialize;
4use std::cmp::Reverse;
5
6use std::collections::{BTreeSet, HashMap, HashSet};
7use std::io::{self, BufRead};
8use std::path::Path;
9
10use crate::errors;
11use crate::model::Section;
12use crate::pack::{pack_by_ids, PackSearchOptions};
13use crate::parse::{load_markdown, parse_markdown};
14use crate::render::{
15 render_pack, render_read, render_search, render_sections, render_stats, render_tree,
16 FileSectionsMap, PackIncluded, SectionsEntry, StatsEntry,
17};
18use crate::search::{discover_markdown_files, get_doc_section_summaries, search_files};
19use crate::tokens::{estimate_tokens, truncate_to_tokens};
20
21const TRUNCATION_NOTICE: &str = "\n\n<!-- mdlens: truncated at token budget -->";
22
23#[derive(Parser)]
24#[command(name = "mdlens")]
25#[command(about = "Token-efficient Markdown structure CLI for AI agents")]
26#[command(
27 long_about = "mdlens parses Markdown files into a hierarchical section tree with\ndotted IDs, token estimates, and bounded-context packing.\n\nDesigned for AI agents that need to navigate, search, and pack\nMarkdown documentation into context windows efficiently.\n\nAgent quickstart:\n 1. For question answering over a Markdown directory, start with:\n mdlens scout <dir> \"<question>\" --max-tokens 1000\n 2. Answer from scout when [highlights] and [evidence] are sufficient.\n 3. If one detail is missing, use a listed section id:\n mdlens read <file> --id <N.N> --max-tokens 1200\n 4. Use search/tree/sections only when scout points at the wrong file or you\n need broader navigation.\n\nScout is the recommended first command for arbitrary messy English markdown.\nIt returns query expansion, a compact file map, ranked highlights, and bounded\nevidence sections with parent heading/status context.\n\nAnswering from scout:\n - Read [highlights] first, then [evidence].\n - Preserve distinctive evidence terms: flags, IDs, metrics, option names,\n labels, row values, and short policy/risk phrases.\n - Copy short source phrases exactly when they are likely answer terms; avoid\n changing singular/plural or rewriting concise labels into paraphrases.\n - If scout already names the answer plus its rule, risk, command, or policy,\n answer directly instead of continuing broad retrieval.\n - For current-vs-stale questions, prefer current/current loader sections and\n treat Do Not Use, copied tables, stale notes, and old runbooks as\n distractors.\n - For table questions, keep the table header with the selected row; do not\n average unrelated rows unless the document says to.\n - For why, policy, safety, privacy, negative, or tradeoff questions, include\n the compact rule/risk/rationale bullets, not only the command or metric.\n - For multi-file comparisons, answer each named entity separately, then\n summarize the shared pattern.\n - If evidence is missing, say the corpus does not specify the fact.\n\nRun `mdlens scout --help` for detailed scout-specific guidance."
28)]
29struct Cli {
30 #[command(subcommand)]
31 command: Commands,
32}
33
34#[derive(Subcommand)]
35enum Commands {
36 Tree(TreeArgs),
38 Read(ReadArgs),
40 Search(SearchArgs),
42 Scout(ScoutArgs),
44 Pack(PackArgs),
46 Stats(StatsArgs),
48 Sections(SectionsArgs),
50 Init(InitArgs),
52 Gain(GainArgs),
54}
55
56#[derive(clap::Args)]
57struct TreeArgs {
58 path: String,
60 #[arg(long)]
62 json: bool,
63 #[arg(long)]
65 max_depth: Option<usize>,
66 #[arg(long)]
68 include_preamble: bool,
69 #[arg(long)]
71 files: bool,
72}
73
74#[derive(clap::Args)]
75struct ReadArgs {
76 file: String,
78 #[arg(long, conflicts_with_all = ["heading_path", "lines"])]
80 id: Option<String>,
81 #[arg(long, conflicts_with_all = ["id", "lines"])]
83 heading_path: Option<String>,
84 #[arg(long, conflicts_with_all = ["id", "heading_path"])]
86 lines: Option<String>,
87 #[arg(long)]
89 parents: bool,
90 #[arg(long, conflicts_with = "no_children")]
92 children: bool,
93 #[arg(long, conflicts_with = "children")]
95 no_children: bool,
96 #[arg(long)]
98 max_tokens: Option<usize>,
99 #[arg(long)]
101 json: bool,
102}
103
104#[derive(clap::Args)]
105struct SearchArgs {
106 path: String,
108 query: String,
110 #[arg(long)]
112 json: bool,
113 #[arg(long)]
115 regex: bool,
116 #[arg(long)]
118 case_sensitive: bool,
119 #[arg(long, default_value_t = 20)]
121 max_results: usize,
122 #[arg(long, default_value_t = 2)]
124 context_lines: usize,
125 #[arg(long)]
127 content: bool,
128 #[arg(long)]
130 preview: Option<usize>,
131 #[arg(long)]
133 max_tokens: Option<usize>,
134}
135
136#[derive(clap::Args)]
137#[command(
138 long_about = "One-shot agent evidence pack for answering a natural-language question over Markdown.\n\n`scout` is optimized for agent workflows: fewer shell calls, bounded output,\nand enough section context to answer without dumping whole files. It searches\nsection text, headings, paths, parent context, and table rows; ranks likely\nevidence; then emits a compact pack."
139)]
140#[command(
141 after_help = "Agent workflow:\n - Use scout as the first retrieval call for QA over a directory:\n mdlens scout docs/ \"What policy changed between the old and current loader?\" --max-tokens 1000\n - Use --json when a harness wants structured metadata plus the same rendered evidence pack.\n - Read [highlights] first. They are globally ranked compact evidence lines.\n - Then read [evidence]. Each block names file, section id, heading path, line\n span, token estimate, and ranking reason.\n - If the answer is present, stop and answer directly. Preserve distinctive\n terms: flags, IDs, metrics, option names, row values, labels, and short\n policy phrases.\n - Copy short source phrases exactly when they are likely answer terms; avoid\n changing singular/plural or rewriting concise labels into paraphrases.\n - If exactly one fact is missing, use the section map from [files] and read\n one section:\n mdlens read <file> --id <section-id> --max-tokens 1200\n - Use `mdlens search` only when scout clearly found the wrong file or when\n you need a second independent query.\n\nHow to interpret scout output:\n [queries] Search expansions derived from the question.\n [files] Candidate files, picked section ids, and nearby unread sections.\n [focus] Dominant file when the question appears single-file.\n [highlights] Globally ranked lines/table rows likely to answer the question.\n [evidence] Bounded excerpts from the selected sections.\n\nQuestion-shape guidance:\n - Current-vs-stale questions: prefer sections marked current/current loader;\n treat Do Not Use, stale notes, copied tables, and old runbooks as distractors.\n - Table questions: keep the table header with the selected row; do not average\n unrelated rows unless the document says to.\n - Why, policy, safety, privacy, negative, or tradeoff questions: include the\n compact rule/risk/rationale bullets, not only the command or metric.\n - Multi-file comparison: answer each named entity separately, then summarize\n the shared pattern.\n - Missing evidence: say the corpus does not specify the fact rather than\n guessing from file names.\n\nUseful defaults:\n --max-tokens 1000 keeps scout cheap for most agent turns.\n --max-sections 12 gives enough diversity before packing.\n --max-files 4 keeps the file map readable."
142)]
143struct ScoutArgs {
144 path: String,
146 question: String,
148 #[arg(long)]
150 json: bool,
151 #[arg(long, default_value_t = 1000)]
153 max_tokens: usize,
154 #[arg(long, default_value_t = 12)]
156 max_sections: usize,
157 #[arg(long, default_value_t = 4)]
159 max_files: usize,
160}
161
162#[derive(clap::Args)]
163struct PackArgs {
164 path: String,
166 #[arg(long, conflicts_with_all = ["paths", "search"])]
168 ids: Option<String>,
169 #[arg(long, conflicts_with_all = ["ids", "search"])]
171 paths: Option<String>,
172 #[arg(long, conflicts_with_all = ["ids", "paths"])]
174 search: Option<String>,
175 #[arg(long)]
177 max_tokens: usize,
178 #[arg(long)]
180 parents: bool,
181 #[arg(long, conflicts_with = "no_dedupe")]
183 dedupe: bool,
184 #[arg(long, conflicts_with = "dedupe")]
186 no_dedupe: bool,
187 #[arg(long)]
189 regex: bool,
190 #[arg(long)]
192 case_sensitive: bool,
193 #[arg(long, default_value_t = 20)]
195 max_results: usize,
196 #[arg(long, default_value_t = 2)]
198 context_lines: usize,
199 #[arg(long)]
201 json: bool,
202}
203
204#[derive(Clone, ValueEnum)]
205enum StatsSort {
206 Path,
207 Tokens,
208 Lines,
209}
210
211#[derive(clap::Args)]
212struct StatsArgs {
213 path: String,
215 #[arg(long)]
217 json: bool,
218 #[arg(long, value_enum, default_value_t = StatsSort::Path)]
220 sort: StatsSort,
221 #[arg(long)]
223 top: Option<usize>,
224}
225
226#[derive(clap::Args)]
227struct SectionsArgs {
228 #[arg(value_name = "FILE")]
230 files: Vec<String>,
231 #[arg(long)]
233 content: bool,
234 #[arg(long)]
236 children: bool,
237 #[arg(long)]
239 preview: Option<usize>,
240 #[arg(long)]
242 max_depth: Option<usize>,
243 #[arg(long)]
245 max_tokens: Option<usize>,
246 #[arg(long)]
248 max_sections: Option<usize>,
249 #[arg(long)]
251 max_files: Option<usize>,
252 #[arg(long)]
254 json: bool,
255 #[arg(long)]
257 heading_paths: bool,
258 #[arg(long)]
260 lines: bool,
261 #[arg(long, default_value_t = true)]
263 dedupe: bool,
264 #[arg(long, conflicts_with = "dedupe")]
266 no_dedupe: bool,
267}
268
269#[derive(clap::Args)]
270struct InitArgs {
271 #[arg(long, short = 'g')]
273 global: bool,
274 #[arg(long)]
276 claude: bool,
277 #[arg(long)]
279 codex: bool,
280 #[arg(long)]
282 gemini: bool,
283 #[arg(long)]
285 copilot: bool,
286 #[arg(long)]
288 cursor: bool,
289 #[arg(long, value_name = "NAME")]
291 agent: Vec<String>,
292 #[arg(long, default_value = ".")]
294 path: String,
295 #[arg(long)]
297 dry_run: bool,
298}
299
300#[derive(clap::Args)]
301struct GainArgs {
302 #[arg(long)]
304 json: bool,
305 #[arg(long)]
307 reset: bool,
308 #[arg(long)]
310 yes: bool,
311}
312
313#[derive(Clone)]
314struct SectionHit {
315 path: String,
316 line: usize,
317}
318
319enum SectionInput {
320 File(String),
321 Hit(SectionHit),
322}
323
324pub fn run() -> Result<()> {
325 let cli = Cli::parse();
326
327 match cli.command {
328 Commands::Tree(args) => cmd_tree(args),
329 Commands::Read(args) => cmd_read(args),
330 Commands::Search(args) => cmd_search(args),
331 Commands::Scout(args) => cmd_scout(args),
332 Commands::Pack(args) => cmd_pack(args),
333 Commands::Stats(args) => cmd_stats(args),
334 Commands::Sections(args) => cmd_sections(args),
335 Commands::Init(args) => cmd_init(args),
336 Commands::Gain(args) => cmd_gain(args),
337 }
338}
339
340fn cmd_gain(args: GainArgs) -> Result<()> {
341 crate::gain::run_gain(args.json, args.reset, args.yes)
342}
343
344fn cmd_init(args: InitArgs) -> Result<()> {
345 use crate::init::{self, Change, Harness};
346
347 let mut selected: Vec<Harness> = Vec::new();
349 let push = |h: Harness, v: &mut Vec<Harness>| {
350 if !v.contains(&h) {
351 v.push(h);
352 }
353 };
354 if args.claude {
355 push(Harness::Claude, &mut selected);
356 }
357 if args.codex {
358 push(Harness::Codex, &mut selected);
359 }
360 if args.gemini {
361 push(Harness::Gemini, &mut selected);
362 }
363 if args.copilot {
364 push(Harness::Copilot, &mut selected);
365 }
366 if args.cursor {
367 push(Harness::Cursor, &mut selected);
368 }
369 for name in &args.agent {
370 match Harness::from_name(name) {
371 Some(h) => push(h, &mut selected),
372 None => {
373 return Err(anyhow::anyhow!(
374 "unknown harness '{}' (expected: claude, codex, gemini, copilot, cursor)",
375 name
376 ))
377 }
378 }
379 }
380 if selected.is_empty() {
381 selected = init::default_harnesses();
382 }
383
384 let root = std::path::PathBuf::from(&args.path);
385 let outcomes = init::run_init(&selected, args.global, args.dry_run, root)?;
386
387 if args.dry_run {
388 println!("mdlens init (dry run — no files written)");
389 } else {
390 println!("mdlens init");
391 }
392 for o in &outcomes {
393 let target = o
394 .path
395 .as_ref()
396 .map(|p| p.display().to_string())
397 .unwrap_or_else(|| "(no global config — run without -g for this harness)".to_string());
398 let status = match o.change {
399 Change::Created => "created",
400 Change::UpdatedBlock => "updated",
401 Change::AlreadyCurrent => "up to date",
402 Change::SkippedNoGlobal => "skipped",
403 };
404 println!(" [{}] {} -> {}", status, o.harness.label(), target);
405 }
406
407 if outcomes
410 .iter()
411 .all(|o| matches!(o.change, Change::SkippedNoGlobal))
412 {
413 return Err(anyhow::anyhow!(
414 "nothing to do: the selected harness(es) have no global config file — re-run without -g"
415 ));
416 }
417 Ok(())
418}
419
420fn cmd_tree(args: TreeArgs) -> Result<()> {
421 let files = crate::search::discover_markdown_files(&args.path)?;
422
423 if files.len() == 1 {
424 let doc = parse_markdown(&files[0])?;
425 if args.json {
426 let output = TreeJsonOutput {
427 schema_version: 1,
428 path: doc.path.clone(),
429 line_count: doc.line_count,
430 byte_count: doc.byte_count,
431 char_count: doc.char_count,
432 word_count: doc.word_count,
433 token_estimate: doc.token_estimate,
434 sections: serialize_sections(
435 &doc.sections,
436 args.max_depth,
437 args.include_preamble,
438 0,
439 ),
440 };
441 println!("{}", serde_json::to_string_pretty(&output)?);
442 } else {
443 println!(
444 "{}",
445 render_tree(&doc, args.max_depth, args.include_preamble)
446 );
447 }
448 } else {
449 let depth_capped = args.max_depth.is_none();
451 let effective_depth = args.max_depth.or(Some(1));
452
453 if args.json {
454 let mut file_outputs = Vec::new();
455 for file in &files {
456 let doc = parse_markdown(file)?;
457 file_outputs.push(TreeFileJsonOutput {
458 path: doc.path.clone(),
459 line_count: doc.line_count,
460 byte_count: doc.byte_count,
461 char_count: doc.char_count,
462 word_count: doc.word_count,
463 token_estimate: doc.token_estimate,
464 sections: serialize_sections(
465 &doc.sections,
466 effective_depth,
467 args.include_preamble,
468 0,
469 ),
470 });
471 }
472 let output = TreeMultiJsonOutput {
473 schema_version: 1,
474 files: file_outputs,
475 };
476 println!("{}", serde_json::to_string_pretty(&output)?);
477 } else {
478 for file in &files {
479 let doc = parse_markdown(file)?;
480 println!(
481 "\n{}",
482 render_tree(&doc, effective_depth, args.include_preamble)
483 );
484 }
485 if depth_capped {
486 eprintln!("[tree] directory mode: showing depth ≤1 by default; use --max-depth N for more");
487 }
488 }
489 }
490
491 Ok(())
492}
493
494fn cmd_read(args: ReadArgs) -> Result<()> {
495 let parsed = load_markdown(&args.file)?;
496 let doc = &parsed.doc;
497 let lines = &parsed.lines;
498 let include_children = !args.no_children || args.children;
499
500 let (section_text, section_meta, selector_type, selector_value, section_ref) =
501 if let Some(ref id) = args.id {
502 let section = doc
503 .find_section_by_id(id)
504 .ok_or_else(|| anyhow::anyhow!("section id not found: {id}"))?;
505 let content = if include_children {
506 section.extract_content(lines)
507 } else {
508 section.extract_direct_content(lines)
509 }
510 .join("\n");
511 (
512 content,
513 SectionMeta::from(section),
514 "id",
515 id.clone(),
516 Some(section),
517 )
518 } else if let Some(ref path_str) = args.heading_path {
519 let section = find_unique_section_by_path(doc, path_str)?;
520 let content = if include_children {
521 section.extract_content(lines)
522 } else {
523 section.extract_direct_content(lines)
524 }
525 .join("\n");
526 (
527 content,
528 SectionMeta::from(section),
529 "path",
530 path_str.clone(),
531 Some(section),
532 )
533 } else if let Some(ref lines_str) = args.lines {
534 let parts: Vec<&str> = lines_str.split(':').collect();
535 if parts.len() != 2 {
536 return Err(anyhow::anyhow!(
537 "invalid line range: {}; expected format START:END",
538 lines_str
539 ));
540 }
541 let start: usize = parts[0].trim().parse()?;
542 let end: usize = parts[1].trim().parse()?;
543 if start > end {
544 return Err(errors::invalid_line_range(start, end));
545 }
546 if start < 1 || end > lines.len() {
547 return Err(anyhow::anyhow!(
548 "line range {}:{} out of bounds (file has {} lines)",
549 start,
550 end,
551 lines.len()
552 ));
553 }
554 let content = lines[(start - 1)..end].join("\n");
555 let token_est = estimate_tokens(&content);
556 (
557 content,
558 SectionMeta {
559 id: format!("lines:{}:{}", start, end),
560 title: format!("Lines {}-{}", start, end),
561 level: 0,
562 path: vec![format!("Lines {}-{}", start, end)],
563 line_start: start,
564 line_end: end,
565 token_estimate: token_est,
566 },
567 "lines",
568 format!("{}:{}", start, end),
569 None,
570 )
571 } else {
572 return Err(anyhow::anyhow!(
573 "exactly one of --id, --heading-path, or --lines is required"
574 ));
575 };
576
577 let mut full_content = String::new();
578
579 if args.parents {
580 if let Some(sec) = section_ref {
581 let parents = find_parent_headings(doc, sec);
582 for line_idx in parents {
583 if !full_content.is_empty() {
584 full_content.push_str("\n\n");
585 }
586 full_content.push_str(&lines[line_idx - 1]);
587 }
588 }
589 }
590
591 if !full_content.is_empty() && !section_text.is_empty() {
592 full_content.push_str("\n\n");
593 }
594 full_content.push_str(§ion_text);
595
596 let truncated = if let Some(max_tokens) = args.max_tokens {
597 if estimate_tokens(&full_content) > max_tokens {
598 full_content = truncate_content_to_tokens(&full_content, max_tokens);
599 true
600 } else {
601 false
602 }
603 } else {
604 false
605 };
606
607 if args.json {
608 let output = ReadJsonOutput {
609 schema_version: 1,
610 path: doc.path.clone(),
611 selector: ReadSelector {
612 r#type: selector_type.to_string(),
613 value: selector_value.to_string(),
614 },
615 section: SectionJsonOutput {
616 id: section_meta.id.clone(),
617 title: section_meta.title.clone(),
618 level: section_meta.level,
619 path: section_meta.path.clone(),
620 line_start: section_meta.line_start,
621 line_end: section_meta.line_end,
622 token_estimate: section_meta.token_estimate,
623 children: Vec::new(),
624 },
625 content: full_content,
626 truncated,
627 };
628 let json = serde_json::to_string_pretty(&output)?;
630 crate::gain::record("read", doc.token_estimate, estimate_tokens(&json));
631 println!("{json}");
632 } else {
633 let section = Section {
634 id: section_meta.id.clone(),
635 slug: Section::slugify(§ion_meta.title),
636 title: section_meta.title.clone(),
637 level: section_meta.level,
638 path: section_meta.path.clone(),
639 line_start: section_meta.line_start,
640 line_end: section_meta.line_end,
641 content_line_start: section_meta.line_start,
642 byte_start: 0,
643 byte_end: 0,
644 char_count: 0,
645 word_count: 0,
646 token_estimate: section_meta.token_estimate,
647 children: Vec::new(),
648 };
649 let rendered = render_read(§ion, &full_content, truncated);
650 crate::gain::record("read", doc.token_estimate, estimate_tokens(&rendered));
651 println!("{rendered}");
652 }
653
654 Ok(())
655}
656
657struct SectionMeta {
658 id: String,
659 title: String,
660 level: u8,
661 path: Vec<String>,
662 line_start: usize,
663 line_end: usize,
664 token_estimate: usize,
665}
666
667impl From<&Section> for SectionMeta {
668 fn from(s: &Section) -> Self {
669 SectionMeta {
670 id: s.id.clone(),
671 title: s.title.clone(),
672 level: s.level,
673 path: s.path.clone(),
674 line_start: s.line_start,
675 line_end: s.line_end,
676 token_estimate: s.token_estimate,
677 }
678 }
679}
680
681fn find_parent_headings(doc: &crate::model::Document, section: &Section) -> Vec<usize> {
683 let mut parent_map: std::collections::HashMap<String, Option<String>> =
684 std::collections::HashMap::new();
685 build_parent_map(&doc.sections, None, &mut parent_map);
686 let mut chain = Vec::new();
687 let mut current_id = section.id.clone();
688 while let Some(Some(pid)) = parent_map.get(¤t_id) {
689 if let Some(parent_sec) = doc.find_section_by_id(pid) {
690 chain.push(parent_sec.line_start);
691 }
692 current_id = pid.clone();
693 }
694 chain.reverse();
695 chain
696}
697
698fn find_unique_section_by_path<'a>(
699 doc: &'a crate::model::Document,
700 path_str: &str,
701) -> Result<&'a Section> {
702 let path = parse_heading_path(path_str);
703 let matches = doc.find_sections_by_path(&path);
704 match matches.len() {
705 0 => Err(anyhow::anyhow!("path not found: {path_str}")),
706 1 => Ok(matches[0]),
707 _ => Err(errors::ambiguous_path(path_str, &matches)),
708 }
709}
710
711fn parse_heading_path(path: &str) -> Vec<String> {
712 let mut parts = Vec::new();
713 let mut current = String::new();
714 let mut escaped = false;
715
716 for ch in path.chars() {
717 if escaped {
718 current.push(ch);
719 escaped = false;
720 continue;
721 }
722
723 match ch {
724 '\\' => escaped = true,
725 '>' => {
726 let part = current.trim();
727 if !part.is_empty() {
728 parts.push(part.to_string());
729 }
730 current.clear();
731 }
732 _ => current.push(ch),
733 }
734 }
735
736 let part = current.trim();
737 if !part.is_empty() {
738 parts.push(part.to_string());
739 }
740
741 parts
742}
743
744fn build_parent_map(
745 sections: &[Section],
746 parent_id: Option<String>,
747 map: &mut std::collections::HashMap<String, Option<String>>,
748) {
749 for section in sections {
750 map.insert(section.id.clone(), parent_id.clone());
751 build_parent_map(§ion.children, Some(section.id.clone()), map);
752 }
753}
754
755fn cmd_search(args: SearchArgs) -> Result<()> {
756 let mut results = search_files(
757 &args.path,
758 &args.query,
759 args.case_sensitive,
760 args.regex,
761 args.max_results,
762 args.context_lines,
763 )?;
764
765 if args.content || args.preview.is_some() || args.max_tokens.is_some() {
766 enrich_search_results(&mut results, args.content, args.preview)?;
767 }
768
769 if let Some(max_tokens) = args.max_tokens {
770 let mut kept = Vec::new();
771 let mut total_tokens = 0usize;
772 for result in results {
773 let item_tokens = if args.content {
774 result
775 .body
776 .as_ref()
777 .map(|body| estimate_tokens(body))
778 .unwrap_or(result.token_estimate)
779 } else if let Some(preview) = &result.preview {
780 estimate_tokens(preview)
781 } else {
782 result.token_estimate
783 };
784 if total_tokens + item_tokens > max_tokens {
785 break;
786 }
787 total_tokens += item_tokens;
788 kept.push(result);
789 }
790 results = kept;
791 }
792
793 if args.json {
794 let output = SearchJsonOutput {
795 schema_version: 1,
796 query: args.query,
797 root: args.path,
798 results: results
799 .iter()
800 .map(|r| SearchJsonResult {
801 path: r.path.clone(),
802 section_id: r.section_id.clone(),
803 section_title: r.section_title.clone(),
804 section_path: r.section_path.clone(),
805 line_start: r.line_start,
806 line_end: r.line_end,
807 token_estimate: r.token_estimate,
808 match_count: r.match_count,
809 body: r.body.clone(),
810 preview: r.preview.clone(),
811 snippets: r
812 .snippets
813 .iter()
814 .map(|s| SearchJsonSnippet {
815 line_start: s.line_start,
816 line_end: s.line_end,
817 text: s.text.clone(),
818 })
819 .collect(),
820 })
821 .collect(),
822 };
823 println!("{}", serde_json::to_string_pretty(&output)?);
824 } else {
825 let file_sections = build_file_sections_map(&results);
826 println!("{}", render_search(&results, args.content, &file_sections));
827 }
828
829 Ok(())
830}
831
832fn build_file_sections_map(results: &[crate::render::SearchResult]) -> FileSectionsMap {
833 let unique_files: std::collections::HashSet<&str> =
834 results.iter().map(|r| r.path.as_str()).collect();
835 let mut map = FileSectionsMap::new();
836 for path in unique_files {
837 if let Ok(summaries) = get_doc_section_summaries(path) {
838 map.insert(path.to_string(), summaries);
839 }
840 }
841 map
842}
843
844#[derive(Clone, Serialize)]
845struct ScoutCandidate {
846 path: String,
847 section_id: String,
848 score: i32,
849 reason: String,
850}
851
852struct ScoutHighlight {
853 score: i32,
854 path: String,
855 section_id: String,
856 line_no: usize,
857 line: String,
858}
859
860fn cmd_scout(args: ScoutArgs) -> Result<()> {
861 let queries = scout_queries(&args.question);
862 let mut candidates: Vec<ScoutCandidate> = Vec::new();
863 let per_query_results = (args.max_sections * 3).max(args.max_sections).min(60);
864
865 for query in &queries {
866 let results = search_files(&args.path, query, false, false, per_query_results, 2)?;
867 for result in results {
868 let query_tokens = signal_tokens(query);
869 let normalized_path = normalize_for_match(&result.path);
870 let path_quality_score = scout_path_quality_score(&result.path);
871 let path_hits = query_tokens
872 .iter()
873 .filter(|token| normalized_path.contains(&normalize_for_match(token)))
874 .count() as i32;
875 let path_boost = if path_hits > 0 {
876 180 + path_hits * 45
877 } else {
878 0
879 };
880 let broad_penalty = if path_hits == 0 && query_tokens.len() <= 1 {
881 60
882 } else {
883 0
884 };
885 candidates.push(ScoutCandidate {
886 path: result.path,
887 section_id: result.section_id,
888 score: 100
889 + path_boost
890 + path_quality_score
891 + result.match_count as i32 * 5
892 + scout_heading_score(
893 &result.section_path,
894 &result.section_title,
895 &args.question,
896 )
897 - result.token_estimate as i32 / 250
898 - broad_penalty,
899 reason: format!("content match: {query}"),
900 });
901 }
902 }
903
904 add_lexical_scout_candidates(
905 &args.path,
906 &args.question,
907 &mut candidates,
908 args.max_sections * 4,
909 )?;
910 add_path_match_candidates(&args.path, &args.question, &mut candidates)?;
911 add_named_target_candidates(&args.path, &args.question, &mut candidates)?;
912 add_neighbor_candidates(&mut candidates)?;
913
914 candidates.sort_by(|lhs, rhs| {
915 rhs.score
916 .cmp(&lhs.score)
917 .then(lhs.path.cmp(&rhs.path))
918 .then(lhs.section_id.cmp(&rhs.section_id))
919 });
920 dedupe_scout_candidates(&mut candidates);
921 prune_parent_scout_candidates(&mut candidates);
922 let candidate_pool = candidates.clone();
923 diversify_scout_candidates(&mut candidates, args.max_sections, &args.question);
924 ensure_named_target_coverage(
925 &mut candidates,
926 &candidate_pool,
927 args.max_sections,
928 &args.question,
929 )?;
930 candidates.truncate(args.max_sections);
931
932 let mut out = String::new();
933 out.push_str(&format!(
934 "[scout] question=\"{}\" budget=~{}t candidates={}\n",
935 args.question,
936 args.max_tokens,
937 candidates.len()
938 ));
939 if !queries.is_empty() {
940 out.push_str(&format!("[queries] {}\n", queries.join(" | ")));
941 }
942 out.push('\n');
943 let evidence_candidates = order_scout_evidence(
944 focused_scout_candidates(&candidates, &args.question),
945 &args.question,
946 )?;
947 let map_candidates = if evidence_candidates.len() < candidates.len() {
948 &evidence_candidates
949 } else {
950 &candidates
951 };
952 render_scout_file_maps(&mut out, map_candidates, args.max_files)?;
953 if !evidence_candidates.is_empty() && evidence_candidates.len() < candidates.len() {
954 out.push_str(&format!("\n[focus] {}\n", evidence_candidates[0].path));
955 }
956 out.push_str("\n[highlights]\n");
957 render_scout_highlights(&mut out, &evidence_candidates, &args.question, 7)?;
958 out.push_str("\n[evidence]\n");
959 let mut baseline_tokens = 0usize;
963 render_scout_evidence(
964 &mut out,
965 &evidence_candidates,
966 &args.question,
967 args.max_tokens,
968 &mut baseline_tokens,
969 )?;
970
971 if args.json {
972 let output = ScoutJsonOutput {
973 schema_version: 1,
974 root: args.path,
975 question: args.question,
976 token_budget: args.max_tokens,
977 candidate_count: candidates.len(),
978 queries,
979 candidates: evidence_candidates,
980 rendered_text: out,
981 };
982 let json = serde_json::to_string_pretty(&output)?;
984 crate::gain::record("scout", baseline_tokens, estimate_tokens(&json));
985 println!("{json}");
986 } else {
987 crate::gain::record("scout", baseline_tokens, estimate_tokens(&out));
988 print!("{out}");
989 }
990 Ok(())
991}
992
993fn scout_queries(question: &str) -> Vec<String> {
994 let mut queries = Vec::new();
995 let phrases = extract_capitalized_phrases(question);
996 for phrase in phrases {
997 let cleaned = clean_query_phrase(&phrase);
998 push_unique_query(&mut queries, cleaned.clone());
999 if cleaned.contains('-') {
1000 push_unique_query(&mut queries, cleaned.replace('-', " "));
1001 }
1002 }
1003
1004 let signal_tokens = signal_tokens(question);
1005 for token in signal_tokens.into_iter().take(8) {
1006 if token.len() >= 8
1007 || token.contains('-')
1008 || token.contains('_')
1009 || token.chars().any(|c| c.is_ascii_digit())
1010 {
1011 push_unique_query(&mut queries, token);
1012 }
1013 }
1014
1015 if queries.is_empty() {
1016 push_unique_query(&mut queries, question.to_string());
1017 }
1018 queries.truncate(12);
1019 queries
1020}
1021
1022fn add_lexical_scout_candidates(
1023 root: &str,
1024 question: &str,
1025 candidates: &mut Vec<ScoutCandidate>,
1026 limit: usize,
1027) -> Result<()> {
1028 let query_terms = lexical_query_terms(question);
1029 if query_terms.is_empty() {
1030 return Ok(());
1031 }
1032
1033 struct LexicalSection {
1034 path: String,
1035 section_id: String,
1036 section_path: Vec<String>,
1037 section_title: String,
1038 token_estimate: usize,
1039 len: usize,
1040 terms: HashMap<String, usize>,
1041 title_terms: HashSet<String>,
1042 path_terms: HashSet<String>,
1043 }
1044
1045 let files = discover_markdown_files(root)?;
1046 let mut sections = Vec::new();
1047 let mut df: HashMap<String, usize> = HashMap::new();
1048 let mut total_len = 0usize;
1049
1050 for file in files {
1051 let parsed = load_markdown(&file)?;
1052 let path_terms = lexical_terms(&file).into_iter().collect::<HashSet<_>>();
1053 for section in flatten_doc_sections(&parsed.doc.sections) {
1054 if section.title == "<preamble>" {
1055 continue;
1056 }
1057 let content = section.extract_content(&parsed.lines).join("\n");
1058 let title_text = section.path.join(" ");
1059 let mut terms = lexical_terms(&format!("{title_text}\n{content}"));
1060 if terms.is_empty() {
1061 continue;
1062 }
1063 let title_terms = lexical_terms(&title_text)
1064 .into_iter()
1065 .collect::<HashSet<_>>();
1066 let mut tf = HashMap::new();
1067 let mut unique = HashSet::new();
1068 for term in terms.drain(..) {
1069 *tf.entry(term.clone()).or_insert(0) += 1;
1070 unique.insert(term);
1071 }
1072 for term in unique {
1073 *df.entry(term).or_insert(0) += 1;
1074 }
1075 let len = tf.values().sum::<usize>().max(1);
1076 total_len += len;
1077 sections.push(LexicalSection {
1078 path: file.clone(),
1079 section_id: section.id.clone(),
1080 section_path: section.path.clone(),
1081 section_title: section.title.clone(),
1082 token_estimate: section.token_estimate,
1083 len,
1084 terms: tf,
1085 title_terms,
1086 path_terms: path_terms.clone(),
1087 });
1088 }
1089 }
1090
1091 let n = sections.len();
1092 if n == 0 {
1093 return Ok(());
1094 }
1095 let avg_len = total_len as f64 / n as f64;
1096 let unique_query_terms = query_terms.into_iter().collect::<BTreeSet<_>>();
1097 let mut scored = Vec::new();
1098
1099 for section in sections {
1100 let mut score = 0.0f64;
1101 let mut matched = 0usize;
1102 for term in &unique_query_terms {
1103 let tf = section.terms.get(term).copied().unwrap_or(0) as f64;
1104 let title_hit = section.title_terms.contains(term);
1105 let path_hit = section.path_terms.contains(term);
1106 if tf == 0.0 && !title_hit && !path_hit {
1107 continue;
1108 }
1109 matched += 1;
1110 let doc_freq = df.get(term).copied().unwrap_or(1) as f64;
1111 let idf = ((n as f64 - doc_freq + 0.5) / (doc_freq + 0.5) + 1.0).ln();
1112 let k1 = 1.2;
1113 let b = 0.75;
1114 let bm25 = if tf > 0.0 {
1115 idf * (tf * (k1 + 1.0)) / (tf + k1 * (1.0 - b + b * section.len as f64 / avg_len))
1116 } else {
1117 0.0
1118 };
1119 score += bm25;
1120 if title_hit {
1121 score += idf * 1.8;
1122 }
1123 if path_hit {
1124 score += idf * 1.1;
1125 }
1126 }
1127 if matched == 0 {
1128 continue;
1129 }
1130 let coverage = matched as f64 / unique_query_terms.len().max(1) as f64;
1131 let structural_prior =
1132 scout_heading_score(§ion.section_path, §ion.section_title, question) as f64
1133 / 25.0;
1134 let path_prior = scout_path_quality_score(§ion.path) as f64 / 20.0;
1135 let authority_prior =
1136 scout_source_authority_score(§ion.path, §ion.section_path, "", question) as f64
1137 / 15.0;
1138 let compactness = -(section.token_estimate as f64 / 900.0);
1139 let final_score = (score * (0.75 + coverage)
1140 + structural_prior
1141 + path_prior
1142 + authority_prior
1143 + compactness)
1144 * 100.0;
1145 scored.push((
1146 final_score.round() as i32,
1147 section.path,
1148 section.section_id,
1149 matched,
1150 ));
1151 }
1152
1153 scored.sort_by(|lhs, rhs| {
1154 rhs.0
1155 .cmp(&lhs.0)
1156 .then(rhs.3.cmp(&lhs.3))
1157 .then(lhs.1.cmp(&rhs.1))
1158 .then(lhs.2.cmp(&rhs.2))
1159 });
1160 for (score, path, section_id, matched) in scored.into_iter().take(limit.max(1)) {
1161 candidates.push(ScoutCandidate {
1162 path,
1163 section_id,
1164 score,
1165 reason: format!("lexical relevance: {matched} query terms"),
1166 });
1167 }
1168 Ok(())
1169}
1170
1171fn lexical_query_terms(text: &str) -> Vec<String> {
1172 let mut out = Vec::new();
1173 for token in lexical_terms(text) {
1174 if token.len() >= 3
1175 && !matches!(
1176 token.as_str(),
1177 "answer" | "doc" | "docs" | "file" | "markdown" | "readme" | "section"
1178 )
1179 && !out.contains(&token)
1180 {
1181 out.push(token);
1182 }
1183 }
1184 out
1185}
1186
1187fn lexical_terms(text: &str) -> Vec<String> {
1188 text.split(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
1189 .filter_map(normalize_lexical_term)
1190 .collect()
1191}
1192
1193fn normalize_lexical_term(raw: &str) -> Option<String> {
1194 let mut token = raw.trim().trim_matches('-').to_ascii_lowercase();
1195 if token.len() < 3 || is_stopword(&token) {
1196 return None;
1197 }
1198 if token.chars().all(|c| c.is_ascii_digit()) {
1199 return Some(token);
1200 }
1201 for suffix in ["ing", "edly", "edly", "ed", "es", "s"] {
1202 if token.len() > suffix.len() + 3 && token.ends_with(suffix) {
1203 token.truncate(token.len() - suffix.len());
1204 break;
1205 }
1206 }
1207 Some(token)
1208}
1209
1210fn scout_heading_score(section_path: &[String], section_title: &str, question: &str) -> i32 {
1211 let question_l = question.to_ascii_lowercase();
1212 let heading_l = format!("{} {}", section_path.join(" "), section_title).to_ascii_lowercase();
1213 let mut score = 0;
1214
1215 for token in signal_tokens(question).iter().take(8) {
1216 if heading_l.contains(&token.to_ascii_lowercase()) {
1217 score += 20;
1218 }
1219 }
1220 for (needle, heading, weight) in [
1221 ("install", "install", 90),
1222 ("command", "install", 45),
1223 ("usage", "usage", 70),
1224 ("example", "example", 55),
1225 ("configure", "configuration", 70),
1226 ("config", "configuration", 70),
1227 ("option", "option", 65),
1228 ("hyperparameter", "hyperparameter", 75),
1229 ("limitation", "limitation", 90),
1230 ("caveat", "caveat", 90),
1231 ("external", "external", 45),
1232 ("conclude", "conclude", 70),
1233 ("why", "conclude", 35),
1234 ("analysis", "analysis", 45),
1235 ("failure", "failure", 55),
1236 ("recommend", "recommendation", 95),
1237 ("policy", "recommendation", 65),
1238 ("policy", "policy", 95),
1239 ("privacy", "privacy", 95),
1240 ("mask", "privacy", 75),
1241 ("masking", "privacy", 75),
1242 ("rule", "rule", 90),
1243 ("rules", "rule", 90),
1244 ("counting", "counting", 100),
1245 ("safety", "safety", 100),
1246 ("hazard", "safety", 75),
1247 ("hazard", "hazard", 85),
1248 ("risk", "risk", 80),
1249 ("why", "policy", 70),
1250 ("why", "rule", 70),
1251 ("why", "risk", 65),
1252 ("treat", "policy", 70),
1253 ("treat", "rule", 70),
1254 ("treat", "risk", 65),
1255 ("direction", "recommendation", 45),
1256 ] {
1257 if question_l.contains(needle) && heading_l.contains(heading) {
1258 score += weight;
1259 }
1260 }
1261 for (low_value, penalty) in [
1262 ("license", 70),
1263 ("citation", 80),
1264 ("cite", 80),
1265 ("contact", 55),
1266 ("contribute", 55),
1267 ("acknowledg", 55),
1268 ] {
1269 if heading_l.contains(low_value) && !question_l.contains(low_value) {
1270 score -= penalty;
1271 }
1272 }
1273 score
1274}
1275
1276fn scout_path_quality_score(path: &str) -> i32 {
1277 let stem = Path::new(path)
1278 .file_stem()
1279 .and_then(|name| name.to_str())
1280 .unwrap_or(path)
1281 .to_ascii_lowercase();
1282 let mut score = 0;
1283 for marker in [
1284 "policy",
1285 "runbook",
1286 "guide",
1287 "manual",
1288 "spec",
1289 "reference",
1290 "card",
1291 "schema",
1292 "protocol",
1293 ] {
1294 if stem.contains(marker) {
1295 score += 45;
1296 }
1297 }
1298 for marker in [
1299 "scratch",
1300 "tmp",
1301 "temp",
1302 "draft",
1303 "random",
1304 "copied",
1305 "copy",
1306 "chat",
1307 "conversation",
1308 ] {
1309 if stem.contains(marker) {
1310 score -= 180;
1311 }
1312 }
1313 score
1314}
1315
1316fn scout_source_authority_score(
1317 path: &str,
1318 section_path: &[String],
1319 content: &str,
1320 question: &str,
1321) -> i32 {
1322 let mut score = scout_path_quality_score(path);
1323 let question_l = question.to_ascii_lowercase();
1324 let heading_l = section_path.join(" ").to_ascii_lowercase();
1325 let content_l = content.to_ascii_lowercase();
1326 let combined = format!("{heading_l}\n{content_l}");
1327
1328 for marker in [
1329 "source of truth",
1330 "current",
1331 "locked",
1332 "policy",
1333 "rule",
1334 "spec",
1335 "reference",
1336 "runbook",
1337 "known risk",
1338 ] {
1339 if combined.contains(marker) {
1340 score += 28;
1341 }
1342 }
1343
1344 let asks_for_informal = [
1345 "scratch",
1346 "draft",
1347 "old note",
1348 "old notes",
1349 "stale",
1350 "historical",
1351 "outdated",
1352 "do not use",
1353 ]
1354 .iter()
1355 .any(|needle| question_l.contains(needle));
1356 let low_authority_multiplier = if asks_for_informal { 1 } else { 2 };
1357 for (marker, penalty) in [
1358 ("not authoritative", 180),
1359 ("maybe stale", 140),
1360 ("random copied", 120),
1361 ("todo maybe", 110),
1362 ("scratch note", 100),
1363 ("copied wrong", 80),
1364 ("old notes disagree", 75),
1365 ] {
1366 if combined.contains(marker) {
1367 score -= penalty * low_authority_multiplier;
1368 }
1369 }
1370
1371 score
1372}
1373
1374fn wants_multi_file_evidence(question: &str) -> bool {
1375 let question_l = question.to_ascii_lowercase();
1376 [
1377 " across ",
1378 " between ",
1379 " compare ",
1380 " compares ",
1381 " comparing ",
1382 " contrast ",
1383 " both ",
1384 " each ",
1385 " multiple ",
1386 " multi-file ",
1387 ]
1388 .iter()
1389 .any(|needle| format!(" {question_l} ").contains(needle))
1390}
1391
1392fn push_unique_query(queries: &mut Vec<String>, query: String) {
1393 let query = query
1394 .trim()
1395 .trim_matches(|c: char| !c.is_alphanumeric())
1396 .to_string();
1397 if query.len() < 3 {
1398 return;
1399 }
1400 if is_stopword(&query) {
1401 return;
1402 }
1403 if !queries
1404 .iter()
1405 .any(|existing| existing.eq_ignore_ascii_case(&query))
1406 {
1407 queries.push(query);
1408 }
1409}
1410
1411fn clean_query_phrase(phrase: &str) -> String {
1412 phrase
1413 .split_whitespace()
1414 .filter_map(|token| {
1415 let cleaned =
1416 token.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '/');
1417 if cleaned.eq_ignore_ascii_case("readme") || is_stopword(cleaned) {
1418 None
1419 } else {
1420 Some(cleaned.to_string())
1421 }
1422 })
1423 .collect::<Vec<_>>()
1424 .join(" ")
1425}
1426
1427fn extract_capitalized_phrases(text: &str) -> Vec<String> {
1428 let mut phrases = Vec::new();
1429 let mut current: Vec<String> = Vec::new();
1430 for raw in text.split_whitespace() {
1431 let word = raw.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '/');
1432 let is_signal = word
1433 .chars()
1434 .next()
1435 .is_some_and(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
1436 || word.chars().any(|c| c.is_ascii_digit())
1437 || word.contains('-')
1438 || word.contains('/');
1439 if is_signal && word.len() > 1 {
1440 current.push(word.to_string());
1441 if raw.ends_with(',') || raw.ends_with(';') {
1442 if current.len() >= 2 || current[0].len() >= 5 {
1443 phrases.push(current.join(" "));
1444 }
1445 current.clear();
1446 }
1447 } else if !current.is_empty() {
1448 if current.len() >= 2 || current[0].len() >= 5 {
1449 phrases.push(current.join(" "));
1450 }
1451 current.clear();
1452 }
1453 }
1454 if !current.is_empty() && (current.len() >= 2 || current[0].len() >= 5) {
1455 phrases.push(current.join(" "));
1456 }
1457 phrases
1458}
1459
1460fn signal_tokens(text: &str) -> Vec<String> {
1461 let mut out = Vec::new();
1462 for raw in text.split(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-') {
1463 let token = raw.trim().trim_matches('-');
1464 if token.len() < 3 {
1465 continue;
1466 }
1467 if is_stopword(token) {
1468 continue;
1469 }
1470 if !out
1471 .iter()
1472 .any(|existing: &String| existing.eq_ignore_ascii_case(token))
1473 {
1474 out.push(token.to_string());
1475 }
1476 }
1477 out
1478}
1479
1480fn is_stopword(token: &str) -> bool {
1481 matches!(
1482 token.to_ascii_lowercase().as_str(),
1483 "about"
1484 | "according"
1485 | "added"
1486 | "after"
1487 | "against"
1488 | "answer"
1489 | "are"
1490 | "across"
1491 | "before"
1492 | "between"
1493 | "can"
1494 | "compared"
1495 | "complete"
1496 | "does"
1497 | "during"
1498 | "explain"
1499 | "fit"
1500 | "for"
1501 | "from"
1502 | "given"
1503 | "good"
1504 | "has"
1505 | "have"
1506 | "how"
1507 | "in"
1508 | "instead"
1509 | "into"
1510 | "its"
1511 | "list"
1512 | "provide"
1513 | "readme"
1514 | "row"
1515 | "run"
1516 | "should"
1517 | "than"
1518 | "that"
1519 | "the"
1520 | "their"
1521 | "there"
1522 | "these"
1523 | "they"
1524 | "this"
1525 | "toolbox"
1526 | "using"
1527 | "user"
1528 | "wants"
1529 | "what"
1530 | "when"
1531 | "where"
1532 | "which"
1533 | "while"
1534 | "with"
1535 | "without"
1536 | "would"
1537 | "yourself"
1538 | "and"
1539 )
1540}
1541
1542fn add_path_match_candidates(
1543 root: &str,
1544 question: &str,
1545 candidates: &mut Vec<ScoutCandidate>,
1546) -> Result<()> {
1547 let files = discover_markdown_files(root)?;
1548 let question_tokens = signal_tokens(question);
1549 if question_tokens.is_empty() {
1550 return Ok(());
1551 }
1552 for path in files {
1553 let normalized = normalize_for_match(&path);
1554 let mut hits = 0;
1555 for token in &question_tokens {
1556 if normalized.contains(&normalize_for_match(token)) {
1557 hits += 1;
1558 }
1559 }
1560 let source_like_path = scout_path_quality_score(&path) > 0;
1561 let policy_or_multi_question = wants_multi_file_evidence(question)
1562 || question.to_ascii_lowercase().contains("why")
1563 || question.to_ascii_lowercase().contains("rule")
1564 || question.to_ascii_lowercase().contains("policy")
1565 || question.to_ascii_lowercase().contains("safety")
1566 || question.to_ascii_lowercase().contains("privacy");
1567 let required_hits = if source_like_path && policy_or_multi_question {
1568 1
1569 } else {
1570 2
1571 };
1572 if hits < required_hits {
1573 continue;
1574 }
1575 let parsed = load_markdown(&path)?;
1576 for section in parsed.doc.sections.iter().take(2) {
1577 candidates.push(ScoutCandidate {
1578 path: path.clone(),
1579 section_id: section.id.clone(),
1580 score: 240 + hits * 30,
1581 reason: "path/name match".to_string(),
1582 });
1583 }
1584 if let Some(best) = best_named_section(&parsed.doc.sections, question) {
1585 candidates.push(ScoutCandidate {
1586 path: path.clone(),
1587 section_id: best.id.clone(),
1588 score: 300
1589 + hits * 45
1590 + scout_path_quality_score(&path)
1591 + scout_heading_score(&best.path, &best.title, question),
1592 reason: "path/name match + relevant heading".to_string(),
1593 });
1594 }
1595 }
1596 Ok(())
1597}
1598
1599fn add_named_target_candidates(
1600 root: &str,
1601 question: &str,
1602 candidates: &mut Vec<ScoutCandidate>,
1603) -> Result<()> {
1604 let targets = target_phrases_from_question(question);
1605 if targets.len() < 2 {
1606 return Ok(());
1607 }
1608
1609 for target in targets {
1610 let results = search_files(root, &target, false, false, 12, 2)?;
1611 let mut seen_files = HashSet::new();
1612 for result in results.into_iter().take(8) {
1613 let content_authority =
1614 scout_source_authority_score(&result.path, &result.section_path, "", question);
1615 candidates.push(ScoutCandidate {
1616 path: result.path.clone(),
1617 section_id: result.section_id.clone(),
1618 score: 620
1619 + content_authority
1620 + result.match_count as i32 * 20
1621 + scout_heading_score(&result.section_path, &result.section_title, question),
1622 reason: format!("named target: {target}"),
1623 });
1624
1625 if seen_files.insert(result.path.clone()) {
1626 let parsed = load_markdown(&result.path)?;
1627 if let Some(best) = best_named_section(&parsed.doc.sections, question) {
1628 candidates.push(ScoutCandidate {
1629 path: result.path.clone(),
1630 section_id: best.id.clone(),
1631 score: 760
1632 + scout_source_authority_score(&result.path, &best.path, "", question)
1633 + scout_heading_score(&best.path, &best.title, question),
1634 reason: format!("named target + relevant heading: {target}"),
1635 });
1636 }
1637 }
1638 }
1639 }
1640 Ok(())
1641}
1642
1643fn normalize_for_match(text: &str) -> String {
1644 text.chars()
1645 .map(|c| {
1646 if c.is_ascii_alphanumeric() {
1647 c.to_ascii_lowercase()
1648 } else {
1649 ' '
1650 }
1651 })
1652 .collect::<String>()
1653}
1654
1655fn best_named_section<'a>(sections: &'a [Section], question: &str) -> Option<&'a Section> {
1656 let mut best: Option<(&Section, i32)> = None;
1657 score_named_sections(sections, question, &mut best);
1658 best.map(|(section, _)| section)
1659}
1660
1661fn score_named_sections<'a>(
1662 sections: &'a [Section],
1663 question: &str,
1664 best: &mut Option<(&'a Section, i32)>,
1665) {
1666 for section in sections {
1667 let title = section.title.to_ascii_lowercase();
1668 let mut score = 0;
1669 for (needle, weight) in [
1670 ("usage", 30),
1671 ("install", 30),
1672 ("quick", 20),
1673 ("example", 20),
1674 ("configuration", 20),
1675 ("training", 20),
1676 ("preprocess", 20),
1677 ("limitation", 25),
1678 ("caveat", 25),
1679 ("documentation", 10),
1680 ("overview", 10),
1681 ("policy", 120),
1682 ("privacy", 110),
1683 ("rule", 115),
1684 ("counting", 110),
1685 ("safety", 115),
1686 ("risk", 90),
1687 ("current", 75),
1688 ("stale", 75),
1689 ] {
1690 if title.contains(needle) {
1691 score += weight;
1692 }
1693 }
1694 for token in signal_tokens(question).iter().take(8) {
1695 if title.contains(&token.to_ascii_lowercase()) {
1696 score += 25;
1697 }
1698 }
1699 if score > 0 && best.is_none_or(|(_, best_score)| score > best_score) {
1700 *best = Some((section, score));
1701 }
1702 score_named_sections(§ion.children, question, best);
1703 }
1704}
1705
1706fn add_neighbor_candidates(candidates: &mut Vec<ScoutCandidate>) -> Result<()> {
1707 let originals = candidates.to_vec();
1708 let mut by_file: HashMap<String, HashSet<String>> = HashMap::new();
1709 for candidate in &originals {
1710 by_file
1711 .entry(candidate.path.clone())
1712 .or_default()
1713 .insert(candidate.section_id.clone());
1714 }
1715 let mut by_file: Vec<(String, HashSet<String>)> = by_file.into_iter().collect();
1718 by_file.sort_by(|a, b| a.0.cmp(&b.0));
1719 for (path, ids) in by_file {
1720 let parsed = load_markdown(&path)?;
1721 let flat = flatten_doc_sections(&parsed.doc.sections);
1722 for (idx, section) in flat.iter().enumerate() {
1723 if !ids.contains(§ion.id) {
1724 continue;
1725 }
1726 let start = idx.saturating_sub(1);
1727 let end = (idx + 1).min(flat.len().saturating_sub(1));
1728 for neighbor in flat.iter().take(end + 1).skip(start) {
1729 if neighbor.id == section.id {
1730 continue;
1731 }
1732 candidates.push(ScoutCandidate {
1733 path: path.clone(),
1734 section_id: neighbor.id.clone(),
1735 score: 70,
1736 reason: format!("neighbor of §{}", section.id),
1737 });
1738 }
1739 }
1740 }
1741 Ok(())
1742}
1743
1744fn flatten_doc_sections(sections: &[Section]) -> Vec<&Section> {
1745 let mut out = Vec::new();
1746 collect_flat_sections(sections, &mut out);
1747 out.sort_by_key(|section| section.line_start);
1748 out
1749}
1750
1751fn collect_flat_sections<'a>(sections: &'a [Section], out: &mut Vec<&'a Section>) {
1752 for section in sections {
1753 out.push(section);
1754 collect_flat_sections(§ion.children, out);
1755 }
1756}
1757
1758fn dedupe_scout_candidates(candidates: &mut Vec<ScoutCandidate>) {
1759 let mut seen = HashSet::new();
1760 candidates
1761 .retain(|candidate| seen.insert(format!("{}::{}", candidate.path, candidate.section_id)));
1762}
1763
1764fn prune_parent_scout_candidates(candidates: &mut Vec<ScoutCandidate>) {
1765 let ids_by_file: HashMap<String, Vec<String>> =
1766 candidates
1767 .iter()
1768 .fold(HashMap::new(), |mut by_file, candidate| {
1769 by_file
1770 .entry(candidate.path.clone())
1771 .or_default()
1772 .push(candidate.section_id.clone());
1773 by_file
1774 });
1775
1776 candidates.retain(|candidate| {
1777 !ids_by_file.get(&candidate.path).is_some_and(|ids| {
1778 ids.iter()
1779 .any(|id| is_child_section_id(&candidate.section_id, id))
1780 })
1781 });
1782}
1783
1784fn diversify_scout_candidates(
1785 candidates: &mut Vec<ScoutCandidate>,
1786 max_sections: usize,
1787 question: &str,
1788) {
1789 if !wants_multi_file_evidence(question) || candidates.len() <= max_sections {
1790 return;
1791 }
1792
1793 let mut targets = target_phrases_from_question(question);
1794 if targets.len() < 2 {
1795 targets = target_tokens_from_question(question);
1796 }
1797 if let Some(selected) =
1798 target_coverage_scout_candidates(candidates, max_sections, &targets, question)
1799 {
1800 *candidates = selected;
1801 return;
1802 }
1803
1804 let mut selected = Vec::new();
1805 let mut selected_keys = HashSet::new();
1806 let mut per_file_count: HashMap<String, usize> = HashMap::new();
1807
1808 for candidate in candidates.iter() {
1809 if selected.len() >= max_sections {
1810 break;
1811 }
1812 let count = per_file_count.get(&candidate.path).copied().unwrap_or(0);
1813 if count >= 2 {
1814 continue;
1815 }
1816 let key = format!("{}::{}", candidate.path, candidate.section_id);
1817 if selected_keys.insert(key) {
1818 selected.push(candidate.clone());
1819 *per_file_count.entry(candidate.path.clone()).or_default() += 1;
1820 }
1821 }
1822
1823 for candidate in candidates.iter() {
1824 if selected.len() >= max_sections {
1825 break;
1826 }
1827 let key = format!("{}::{}", candidate.path, candidate.section_id);
1828 if selected_keys.insert(key) {
1829 selected.push(candidate.clone());
1830 }
1831 }
1832
1833 if selected.len() >= 2 {
1834 *candidates = selected;
1835 }
1836}
1837
1838fn target_coverage_scout_candidates(
1839 candidates: &[ScoutCandidate],
1840 max_sections: usize,
1841 targets: &[String],
1842 question: &str,
1843) -> Option<Vec<ScoutCandidate>> {
1844 if targets.len() < 2 || max_sections == 0 {
1845 return None;
1846 }
1847
1848 let mut cache: HashMap<String, crate::parse::ParsedMarkdown> = HashMap::new();
1849 let mut selected = Vec::new();
1850 let mut selected_keys = HashSet::new();
1851 let mut covered_targets: HashSet<String> = HashSet::new();
1852 let mut per_file_count: HashMap<String, usize> = HashMap::new();
1853
1854 while selected.len() < max_sections {
1855 let mut best_idx = None;
1856 let mut best_score = i32::MIN;
1857 let mut best_new_targets = HashSet::new();
1858
1859 for (idx, candidate) in candidates.iter().enumerate() {
1860 let key = format!("{}::{}", candidate.path, candidate.section_id);
1861 if selected_keys.contains(&key) {
1862 continue;
1863 }
1864 let Ok((target_hits, authority)) =
1865 scout_candidate_target_hits(candidate, targets, question, &mut cache)
1866 else {
1867 continue;
1868 };
1869 let new_targets = target_hits
1870 .difference(&covered_targets)
1871 .cloned()
1872 .collect::<HashSet<_>>();
1873 if new_targets.is_empty() && covered_targets.len() < targets.len() {
1874 continue;
1875 }
1876 let same_file_penalty =
1877 per_file_count.get(&candidate.path).copied().unwrap_or(0) as i32 * 160;
1878 let coverage_gain = new_targets.len() as i32 * 420 + target_hits.len() as i32 * 35;
1879 let score = candidate.score + authority + coverage_gain - same_file_penalty;
1880 if score > best_score {
1881 best_score = score;
1882 best_idx = Some(idx);
1883 best_new_targets = new_targets;
1884 }
1885 }
1886
1887 let Some(idx) = best_idx else {
1888 break;
1889 };
1890 let candidate = candidates[idx].clone();
1891 let key = format!("{}::{}", candidate.path, candidate.section_id);
1892 selected_keys.insert(key);
1893 for target in best_new_targets {
1894 covered_targets.insert(target);
1895 }
1896 *per_file_count.entry(candidate.path.clone()).or_default() += 1;
1897 selected.push(candidate);
1898
1899 if covered_targets.len() >= targets.len() {
1900 break;
1901 }
1902 }
1903
1904 if selected.len() < 2 {
1905 return None;
1906 }
1907
1908 for candidate in candidates {
1909 if selected.len() >= max_sections {
1910 break;
1911 }
1912 let key = format!("{}::{}", candidate.path, candidate.section_id);
1913 if selected_keys.contains(&key) {
1914 continue;
1915 }
1916 let Ok((_, authority)) =
1917 scout_candidate_target_hits(candidate, targets, question, &mut cache)
1918 else {
1919 continue;
1920 };
1921 if authority < -250 && selected.len() >= 2 {
1922 continue;
1923 }
1924 selected_keys.insert(key);
1925 selected.push(candidate.clone());
1926 }
1927
1928 Some(selected)
1929}
1930
1931fn ensure_named_target_coverage(
1932 selected: &mut Vec<ScoutCandidate>,
1933 pool: &[ScoutCandidate],
1934 max_sections: usize,
1935 question: &str,
1936) -> Result<()> {
1937 let targets = target_phrases_from_question(question);
1938 if targets.len() < 2 || max_sections == 0 {
1939 return Ok(());
1940 }
1941
1942 let mut cache: HashMap<String, crate::parse::ParsedMarkdown> = HashMap::new();
1943 let mut selected_keys = selected
1944 .iter()
1945 .map(|candidate| format!("{}::{}", candidate.path, candidate.section_id))
1946 .collect::<HashSet<_>>();
1947 let mut covered = HashSet::new();
1948 for candidate in selected.iter() {
1949 let (hits, _) = scout_candidate_target_hits(candidate, &targets, question, &mut cache)?;
1950 covered.extend(hits);
1951 }
1952
1953 for target in targets {
1954 if covered.contains(&target) {
1955 continue;
1956 }
1957
1958 let mut best: Option<(ScoutCandidate, i32)> = None;
1959 for candidate in pool {
1960 let key = format!("{}::{}", candidate.path, candidate.section_id);
1961 if selected_keys.contains(&key) {
1962 continue;
1963 }
1964 let (hits, authority) = scout_candidate_target_hits(
1965 candidate,
1966 std::slice::from_ref(&target),
1967 question,
1968 &mut cache,
1969 )?;
1970 if hits.is_empty() {
1971 continue;
1972 }
1973 let score = candidate.score + authority;
1974 if best
1975 .as_ref()
1976 .is_none_or(|(_, best_score)| score > *best_score)
1977 {
1978 best = Some((candidate.clone(), score));
1979 }
1980 }
1981
1982 let Some((candidate, _)) = best else {
1983 continue;
1984 };
1985 let key = format!("{}::{}", candidate.path, candidate.section_id);
1986 if selected.len() >= max_sections {
1987 selected.pop();
1988 }
1989 selected_keys.insert(key);
1990 covered.insert(target);
1991 selected.push(candidate);
1992 }
1993
1994 Ok(())
1995}
1996
1997fn scout_candidate_target_hits(
1998 candidate: &ScoutCandidate,
1999 targets: &[String],
2000 question: &str,
2001 cache: &mut HashMap<String, crate::parse::ParsedMarkdown>,
2002) -> Result<(HashSet<String>, i32)> {
2003 if !cache.contains_key(&candidate.path) {
2004 cache.insert(candidate.path.clone(), load_markdown(&candidate.path)?);
2005 }
2006 let parsed = cache.get(&candidate.path).expect("cached parsed markdown");
2007 let Some(section) = parsed.doc.find_section_by_id(&candidate.section_id) else {
2008 return Ok((HashSet::new(), scout_path_quality_score(&candidate.path)));
2009 };
2010 let content = section.extract_content(&parsed.lines).join("\n");
2011 let source_haystack =
2012 normalize_compact(&format!("{}\n{}", candidate.path, section.path.join(" ")));
2013 let haystack = normalize_compact(&format!(
2014 "{}\n{}\n{}",
2015 candidate.path,
2016 section.path.join(" "),
2017 content
2018 ));
2019 let hits = targets
2020 .iter()
2021 .filter(|target| haystack.contains(&normalize_compact(target)))
2022 .cloned()
2023 .collect::<HashSet<_>>();
2024 let source_hit_count = targets
2025 .iter()
2026 .filter(|target| source_haystack.contains(&normalize_compact(target)))
2027 .count() as i32;
2028 let mut authority =
2029 scout_source_authority_score(&candidate.path, §ion.path, &content, question);
2030 authority += source_hit_count * 360;
2031 if source_hit_count == 0 && !hits.is_empty() {
2032 authority -= 120;
2033 }
2034 Ok((hits, authority))
2035}
2036
2037fn is_child_section_id(parent: &str, child: &str) -> bool {
2038 child.len() > parent.len()
2039 && child.starts_with(parent)
2040 && child[parent.len()..].starts_with('.')
2041}
2042
2043fn focused_scout_candidates(candidates: &[ScoutCandidate], question: &str) -> Vec<ScoutCandidate> {
2044 let Some(top) = candidates.first() else {
2045 return Vec::new();
2046 };
2047 if wants_multi_file_evidence(question) {
2048 let targets = target_tokens_from_question(question);
2049 if !targets.is_empty() {
2050 let focused = candidates
2051 .iter()
2052 .filter(|candidate| path_matches_any_target(&candidate.path, &targets))
2053 .cloned()
2054 .collect::<Vec<_>>();
2055 if focused.len() >= 2 {
2056 return focused;
2057 }
2058 }
2059 return candidates.to_vec();
2060 }
2061 let top_path_tokens = distinctive_path_tokens(&top.path);
2062 if scout_path_quality_score(&top.path) > 0 && !top_path_tokens.is_empty() {
2063 let focused = candidates
2064 .iter()
2065 .filter(|candidate| {
2066 candidate.path == top.path
2067 || distinctive_path_tokens(&candidate.path)
2068 .iter()
2069 .any(|token| top_path_tokens.contains(token))
2070 })
2071 .cloned()
2072 .collect::<Vec<_>>();
2073 if focused.len() >= 2 {
2074 return focused;
2075 }
2076 }
2077 let best_other_score = candidates
2078 .iter()
2079 .find(|candidate| candidate.path != top.path)
2080 .map(|candidate| candidate.score);
2081 let dominant_file =
2082 top.score >= 280 && best_other_score.is_none_or(|score| top.score - score >= 80);
2083 if dominant_file {
2084 candidates
2085 .iter()
2086 .filter(|candidate| candidate.path == top.path)
2087 .cloned()
2088 .collect()
2089 } else {
2090 candidates.to_vec()
2091 }
2092}
2093
2094fn order_scout_evidence(
2095 mut candidates: Vec<ScoutCandidate>,
2096 question: &str,
2097) -> Result<Vec<ScoutCandidate>> {
2098 let question_l = question.to_ascii_lowercase();
2099 if !wants_rationale_or_policy_evidence(&question_l) {
2100 return Ok(candidates);
2101 }
2102
2103 let mut cache: HashMap<String, crate::parse::ParsedMarkdown> = HashMap::new();
2104 let mut scored = Vec::new();
2105 for (idx, candidate) in candidates.drain(..).enumerate() {
2106 if !cache.contains_key(&candidate.path) {
2107 cache.insert(candidate.path.clone(), load_markdown(&candidate.path)?);
2108 }
2109 let parsed = cache.get(&candidate.path).expect("cached parsed markdown");
2110 let score = parsed
2111 .doc
2112 .find_section_by_id(&candidate.section_id)
2113 .map(|section| {
2114 let content = section.extract_content(&parsed.lines).join("\n");
2115 candidate.score
2116 + scout_rationale_evidence_score(§ion.path, &content, &question_l)
2117 })
2118 .unwrap_or(candidate.score);
2119 scored.push((score, idx, candidate));
2120 }
2121 scored.sort_by(|lhs, rhs| rhs.0.cmp(&lhs.0).then(lhs.1.cmp(&rhs.1)));
2122 Ok(scored
2123 .into_iter()
2124 .map(|(_, _, candidate)| candidate)
2125 .collect())
2126}
2127
2128fn wants_rationale_or_policy_evidence(question_l: &str) -> bool {
2129 [
2130 "why",
2131 "what makes",
2132 "rather than",
2133 "policy",
2134 "privacy",
2135 "safety",
2136 "allow",
2137 "allows",
2138 "exporting",
2139 "mask",
2140 "masking",
2141 "rationale",
2142 "reason",
2143 ]
2144 .iter()
2145 .any(|needle| question_l.contains(needle))
2146}
2147
2148fn asks_for_metric_or_table(question_l: &str) -> bool {
2149 [
2150 "metric",
2151 "score",
2152 "baseline",
2153 "table",
2154 "row",
2155 "0.",
2156 "current score",
2157 ]
2158 .iter()
2159 .any(|needle| question_l.contains(needle))
2160}
2161
2162fn scout_rationale_evidence_score(section_path: &[String], content: &str, question_l: &str) -> i32 {
2163 let text = format!("{}\n{}", section_path.join(" "), content).to_ascii_lowercase();
2164 let mut score = 0;
2165 score += scout_rationale_marker_score(&text);
2166 score += scout_question_token_overlap_score(&text, question_l, 28, 220);
2167 if !asks_for_metric_or_table(question_l) {
2168 for needle in [
2169 "metric | score",
2170 "| score |",
2171 "baseline",
2172 "current metric",
2173 "benchmark",
2174 "leaderboard",
2175 ] {
2176 if text.contains(needle) {
2177 score -= 220;
2178 }
2179 }
2180 }
2181 score
2182}
2183
2184fn distinctive_path_tokens(path: &str) -> HashSet<String> {
2185 let stem = Path::new(path)
2186 .file_stem()
2187 .and_then(|name| name.to_str())
2188 .unwrap_or(path);
2189 stem.split(|c: char| !c.is_ascii_alphanumeric())
2190 .map(str::to_ascii_lowercase)
2191 .filter(|token| {
2192 token.len() >= 4
2193 && !matches!(
2194 token.as_str(),
2195 "readme"
2196 | "index"
2197 | "docs"
2198 | "doc"
2199 | "notes"
2200 | "note"
2201 | "eval"
2202 | "scene"
2203 | "card"
2204 | "annotation"
2205 | "policy"
2206 | "scratch"
2207 | "draft"
2208 | "copy"
2209 | "copied"
2210 | "tmp"
2211 | "temp"
2212 | "anchor"
2213 )
2214 })
2215 .collect()
2216}
2217
2218fn target_tokens_from_question(question: &str) -> Vec<String> {
2219 let mut out = Vec::new();
2220 for phrase in extract_capitalized_phrases(question) {
2221 for token in signal_tokens(&phrase) {
2222 for part in token.split('-') {
2223 let part = part.to_ascii_lowercase();
2224 if part.len() >= 4 && !is_stopword(&part) && !out.contains(&part) {
2225 out.push(part);
2226 }
2227 }
2228 }
2229 }
2230 out
2231}
2232
2233fn target_phrases_from_question(question: &str) -> Vec<String> {
2234 let mut out = Vec::new();
2235 for phrase in extract_capitalized_phrases(question) {
2236 if !phrase
2237 .chars()
2238 .any(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit())
2239 {
2240 continue;
2241 }
2242 let tokens = signal_tokens(&phrase)
2243 .into_iter()
2244 .filter(|token| {
2245 !matches!(
2246 token.to_ascii_lowercase().as_str(),
2247 "compare" | "contrast" | "across" | "between" | "which"
2248 )
2249 })
2250 .collect::<Vec<_>>();
2251 if tokens.is_empty() {
2252 continue;
2253 }
2254 let phrase = tokens.join(" ");
2255 if phrase.len() >= 4 && !out.iter().any(|existing| existing == &phrase) {
2256 out.push(phrase);
2257 }
2258 }
2259 out
2260}
2261
2262#[cfg(test)]
2263mod scout_tests {
2264 use super::{scout_adaptive_score_floor, target_phrases_from_question, ScoutCandidate};
2265
2266 fn cands(scores: &[i32]) -> Vec<ScoutCandidate> {
2267 scores
2268 .iter()
2269 .map(|&s| ScoutCandidate {
2270 path: "p.md".into(),
2271 section_id: "s".into(),
2272 score: s,
2273 reason: "r".into(),
2274 })
2275 .collect()
2276 }
2277
2278 #[test]
2279 fn adaptive_floor_cuts_a_clear_cliff() {
2280 let floor = scout_adaptive_score_floor(&cands(&[800, 760, 740, 120, 100, 90, 80]));
2282 assert!(floor > 120, "should cut the tail at the cliff, got {floor}");
2283 assert!(floor <= 740, "should keep the head, got {floor}");
2284 }
2285
2286 #[test]
2287 fn adaptive_floor_keeps_smooth_tail() {
2288 let floor = scout_adaptive_score_floor(&cands(&[300, 280, 260, 240, 220, 200, 180]));
2290 assert_eq!(floor, i32::MIN, "smooth decay should not be cut");
2291 }
2292
2293 #[test]
2294 fn adaptive_floor_no_cut_when_few_candidates() {
2295 assert_eq!(scout_adaptive_score_floor(&cands(&[900, 100])), i32::MIN);
2296 }
2297
2298 #[test]
2299 fn adaptive_floor_handles_unsorted_input() {
2300 let floor = scout_adaptive_score_floor(&cands(&[100, 800, 90, 740, 120, 760, 80]));
2303 assert!(floor > 120 && floor <= 740, "got {floor}");
2304 }
2305
2306 #[test]
2307 fn target_phrases_keep_hyphenated_entities() {
2308 let targets = target_phrases_from_question(
2309 "Across Harbor-17, Rainy Rail Depot, and Night Bus Stop, how do the docs treat reflected or glare-corrupted text?",
2310 );
2311 assert!(targets.contains(&"Harbor-17".to_string()), "{targets:?}");
2312 assert!(
2313 targets.contains(&"Rainy Rail Depot".to_string()),
2314 "{targets:?}"
2315 );
2316 assert!(
2317 targets.contains(&"Night Bus Stop".to_string()),
2318 "{targets:?}"
2319 );
2320 }
2321}
2322
2323fn path_matches_any_target(path: &str, targets: &[String]) -> bool {
2324 let path_l = normalize_compact(path);
2325 targets
2326 .iter()
2327 .any(|target| path_l.contains(&normalize_compact(target)))
2328}
2329
2330fn normalize_compact(text: &str) -> String {
2331 text.chars()
2332 .filter(|c| c.is_ascii_alphanumeric())
2333 .map(|c| c.to_ascii_lowercase())
2334 .collect()
2335}
2336
2337fn render_scout_file_maps(
2338 out: &mut String,
2339 candidates: &[ScoutCandidate],
2340 max_files: usize,
2341) -> Result<()> {
2342 let mut files = Vec::new();
2343 let mut seen = HashSet::new();
2344 for candidate in candidates {
2345 if seen.insert(candidate.path.clone()) {
2346 files.push(candidate.path.clone());
2347 }
2348 if files.len() >= max_files {
2349 break;
2350 }
2351 }
2352 out.push_str("[files]\n");
2353 for path in files {
2354 let summaries = get_doc_section_summaries(&path)?;
2355 let picked: HashSet<&str> = candidates
2356 .iter()
2357 .filter(|c| c.path == path)
2358 .map(|c| c.section_id.as_str())
2359 .collect();
2360 let sections = summaries
2361 .iter()
2362 .filter(|(id, title)| title != "<preamble>" && picked.contains(id.as_str()))
2363 .map(|(id, title)| format!("§{} {}", id, title))
2364 .take(6)
2365 .collect::<Vec<_>>();
2366 let also = summaries
2367 .iter()
2368 .filter(|(id, title)| title != "<preamble>" && !picked.contains(id.as_str()))
2369 .take(6)
2370 .map(|(id, title)| format!("§{} {}", id, title))
2371 .collect::<Vec<_>>();
2372 out.push_str(&format!("- {}\n", path));
2373 if !sections.is_empty() {
2374 out.push_str(&format!(" picked: {}\n", sections.join(" · ")));
2375 }
2376 if !also.is_empty() {
2377 out.push_str(&format!(" also: {}\n", also.join(" · ")));
2378 }
2379 }
2380 Ok(())
2381}
2382
2383fn render_scout_highlights(
2384 out: &mut String,
2385 candidates: &[ScoutCandidate],
2386 question: &str,
2387 max_lines: usize,
2388) -> Result<()> {
2389 let tokens: Vec<String> = signal_tokens(question)
2390 .into_iter()
2391 .map(|token| token.to_ascii_lowercase())
2392 .collect();
2393 let question_l = question.to_ascii_lowercase();
2394 let wants_code = ["cli", "command", "install", "invoke"]
2395 .iter()
2396 .any(|needle| question_l.contains(needle));
2397 let mut emitted = 0usize;
2398 let mut seen = HashSet::new();
2399 let mut highlights = Vec::new();
2400 let mut cache: HashMap<String, crate::parse::ParsedMarkdown> = HashMap::new();
2401
2402 for candidate in candidates {
2403 if !cache.contains_key(&candidate.path) {
2404 cache.insert(candidate.path.clone(), load_markdown(&candidate.path)?);
2405 }
2406 let parsed = cache.get(&candidate.path).expect("cached parsed markdown");
2407 let Some(section) = parsed.doc.find_section_by_id(&candidate.section_id) else {
2408 continue;
2409 };
2410 if is_low_value_section_for_question(section, &question_l) {
2411 continue;
2412 }
2413 let lines = section.extract_content(&parsed.lines);
2414 for (idx, line) in lines.iter().enumerate() {
2415 if emitted >= max_lines {
2416 break;
2417 }
2418 let trimmed = line.trim();
2419 let lower = trimmed.to_ascii_lowercase();
2420 if is_noisy_highlight_line(trimmed) && !is_relevant_table_line(trimmed, &tokens) {
2421 continue;
2422 }
2423 let token_hits = tokens.iter().filter(|token| lower.contains(*token)).count();
2424 let useful_code_line = trimmed.contains("--")
2425 || (wants_code
2426 && (trimmed.contains('`')
2427 || trimmed.starts_with("pip ")
2428 || trimmed.starts_with("conda ")
2429 || trimmed.starts_with("python ")
2430 || trimmed.starts_with("git ")
2431 || trimmed.starts_with("cmake ")
2432 || trimmed.starts_with("make ")));
2433 let useful_table_line = is_relevant_table_line(trimmed, &tokens);
2434 if token_hits == 0 && !useful_code_line && !useful_table_line {
2435 continue;
2436 }
2437 let mut score = token_hits as i32 * 20;
2438 if useful_table_line {
2439 score += 80;
2440 }
2441 if wants_rationale_or_policy_evidence(&question_l) {
2442 score += scout_rationale_highlight_score(&lower, &question_l);
2443 }
2444 for (needle, weight) in [
2445 ("--", 70),
2446 ("cpu", 45),
2447 ("gpu", 45),
2448 ("warning", 45),
2449 ("disable", 45),
2450 ("configuration", 30),
2451 ("header", 30),
2452 ("human-readable", 30),
2453 ("supported formats", 30),
2454 ("convert", 30),
2455 ] {
2456 if lower.contains(needle) {
2457 score += weight;
2458 }
2459 }
2460 highlights.push(ScoutHighlight {
2461 score,
2462 path: candidate.path.clone(),
2463 section_id: section.id.clone(),
2464 line_no: section.line_start + idx,
2465 line: if useful_table_line {
2466 scout_table_context(lines, idx)
2467 } else {
2468 scout_highlight_context(lines, idx, &lower)
2469 },
2470 });
2471 }
2472 }
2473
2474 highlights.sort_by(|lhs, rhs| {
2475 rhs.score
2476 .cmp(&lhs.score)
2477 .then(lhs.path.cmp(&rhs.path))
2478 .then(lhs.line_no.cmp(&rhs.line_no))
2479 });
2480 for highlight in highlights {
2481 if emitted >= max_lines {
2482 break;
2483 }
2484 emit_scout_highlight(out, &mut seen, &mut emitted, &highlight);
2485 }
2486
2487 if emitted == 0 {
2488 out.push_str("- no compact highlights; read evidence sections below\n");
2489 }
2490 Ok(())
2491}
2492
2493fn scout_rationale_highlight_score(lower: &str, question_l: &str) -> i32 {
2494 let mut score = 0;
2495 score += scout_rationale_marker_score(lower) / 2;
2496 score += scout_question_token_overlap_score(lower, question_l, 18, 120);
2497 if !asks_for_metric_or_table(question_l) {
2498 for needle in ["| score |", "baseline", "current metric", "benchmark", "0."] {
2499 if lower.contains(needle) {
2500 score -= 140;
2501 }
2502 }
2503 }
2504 score
2505}
2506
2507fn scout_rationale_marker_score(lower: &str) -> i32 {
2508 let mut score = 0;
2509 for (needles, weight) in [
2510 (
2511 &["rule:", "rule ", "policy", "guideline", "standard"][..],
2512 180,
2513 ),
2514 (
2515 &[
2516 "known risk",
2517 "risk",
2518 "unsafe",
2519 "wrong answer",
2520 "misread",
2521 "confus",
2522 "ambiguous",
2523 ][..],
2524 160,
2525 ),
2526 (
2527 &[
2528 "privacy",
2529 "personal data",
2530 "identifiable",
2531 "redact",
2532 "mask",
2533 "export",
2534 "leak",
2535 ][..],
2536 150,
2537 ),
2538 (
2539 &[
2540 "must",
2541 "should",
2542 "requires",
2543 "allow",
2544 "not enough",
2545 "do not",
2546 "rather than",
2547 ][..],
2548 100,
2549 ),
2550 (
2551 &["because", "reason", "rationale", "therefore", "so that"][..],
2552 80,
2553 ),
2554 ] {
2555 if needles.iter().any(|needle| lower.contains(needle)) {
2556 score += weight;
2557 }
2558 }
2559 score
2560}
2561
2562fn scout_question_token_overlap_score(
2563 lower: &str,
2564 question_l: &str,
2565 per_token: i32,
2566 cap: i32,
2567) -> i32 {
2568 let hits = signal_tokens(question_l)
2569 .into_iter()
2570 .map(|token| token.to_ascii_lowercase())
2571 .filter(|token| lower.contains(token))
2572 .count() as i32;
2573 (hits * per_token).min(cap)
2574}
2575
2576fn is_noisy_highlight_line(line: &str) -> bool {
2577 line.is_empty()
2578 || line.starts_with('|')
2579 || line == "```"
2580 || line == "```shell"
2581 || line.trim_matches('~') == "```"
2582 || line.trim_matches('~') == "```shell"
2583 || line.starts_with("<!--")
2584 || line.starts_with("[!")
2585 || line.starts_with("![")
2586 || line.starts_with("[![")
2587 || line.starts_with("@article")
2588 || line.starts_with("@inproceedings")
2589 || (line.starts_with('[') && line.contains("]: "))
2590 || line.len() > 1000
2591}
2592
2593fn is_relevant_table_line(line: &str, tokens: &[String]) -> bool {
2594 line.starts_with('|')
2595 && line.matches('|').count() >= 3
2596 && !is_table_separator_line(line)
2597 && tokens
2598 .iter()
2599 .any(|token| line.to_ascii_lowercase().contains(token))
2600}
2601
2602fn is_table_separator_line(line: &str) -> bool {
2603 line.chars()
2604 .all(|ch| ch == '|' || ch == '-' || ch == ':' || ch.is_whitespace())
2605}
2606
2607fn scout_table_context(lines: &[String], idx: usize) -> String {
2608 let row = lines[idx].trim();
2609 let header = (1..idx).rev().find_map(|candidate_idx| {
2610 let separator = lines[candidate_idx].trim();
2611 if !separator.starts_with('|') || !is_table_separator_line(separator) {
2612 return None;
2613 }
2614 let header = lines[candidate_idx - 1].trim();
2615 header.starts_with('|').then_some(header)
2616 });
2617
2618 match header {
2619 Some(header) if header != row => format!("{header} => {row}"),
2620 _ => row.to_string(),
2621 }
2622}
2623
2624fn scout_highlight_context(lines: &[String], idx: usize, lower: &str) -> String {
2625 let radius = if lower.contains("disable") || lower.contains("warning") {
2626 5
2627 } else if lines[idx].trim().len() < 300 {
2628 2
2629 } else {
2630 0
2631 };
2632 let start = idx.saturating_sub(radius);
2633 let end = (idx + radius).min(lines.len().saturating_sub(1));
2634 let mut parts = Vec::new();
2635 for line in &lines[start..=end] {
2636 let trimmed = line.trim();
2637 if is_noisy_highlight_line(trimmed) && !trimmed.starts_with('|') {
2638 continue;
2639 }
2640 parts.push(trimmed);
2641 }
2642 let mut joined = parts.join(" ");
2643 if joined.len() > 900 {
2644 joined.truncate(900);
2645 joined.push_str("...");
2646 }
2647 joined
2648}
2649
2650fn is_low_value_section_for_question(section: &Section, question_l: &str) -> bool {
2651 let section_path = section.path.join(" ").to_ascii_lowercase();
2652 let citation_section = section_path.contains("citation")
2653 || section_path.contains("cite")
2654 || section_path.contains("references");
2655 citation_section
2656 && !["citation", "cite", "doi", "reference", "paper"]
2657 .iter()
2658 .any(|needle| question_l.contains(needle))
2659}
2660
2661fn emit_scout_highlight(
2662 out: &mut String,
2663 seen: &mut HashSet<String>,
2664 emitted: &mut usize,
2665 highlight: &ScoutHighlight,
2666) {
2667 let key = format!(
2668 "{}:{}:{}",
2669 highlight.path, highlight.line_no, highlight.line
2670 );
2671 if !seen.insert(key) {
2672 return;
2673 }
2674 out.push_str(&format!(
2675 "- {} §{} l{}: {}\n",
2676 highlight.path, highlight.section_id, highlight.line_no, highlight.line
2677 ));
2678 *emitted += 1;
2679}
2680
2681fn scout_adaptive_score_floor(candidates: &[ScoutCandidate]) -> i32 {
2696 if candidates.len() < 4 {
2698 return i32::MIN;
2699 }
2700 let mut scores: Vec<i32> = candidates.iter().map(|c| c.score).collect();
2701 scores.sort_unstable_by(|a, b| b.cmp(a));
2702
2703 let top = scores[0];
2704 if top <= 0 || scores[scores.len() - 1] == top {
2706 return i32::MIN;
2707 }
2708
2709 let n = scores.len();
2713 let min_keep = (n / 4).max(2); let mut drops: Vec<f64> = Vec::with_capacity(n - 1);
2715 for w in scores.windows(2) {
2716 drops.push((w[0] - w[1]) as f64 / top as f64);
2717 }
2718
2719 let mut sorted_drops = drops.clone();
2721 sorted_drops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2722 let median_drop = sorted_drops[sorted_drops.len() / 2];
2723
2724 let mut best_idx: Option<usize> = None;
2729 let mut best_drop = 0.0_f64;
2730 for (i, &drop) in drops.iter().enumerate() {
2731 if i + 1 < min_keep {
2733 continue;
2734 }
2735 if drop > best_drop {
2736 best_drop = drop;
2737 best_idx = Some(i);
2738 }
2739 }
2740
2741 match best_idx {
2742 Some(i)
2743 if best_drop >= 0.20 && best_drop >= median_drop * 3.0 + 1e-9 && drops.iter().filter(|&&d| d >= best_drop - 1e-9).count() == 1 =>
2746 {
2747 scores[i]
2751 }
2752 _ => i32::MIN,
2753 }
2754}
2755
2756fn render_scout_evidence(
2757 out: &mut String,
2758 candidates: &[ScoutCandidate],
2759 question: &str,
2760 max_tokens: usize,
2761 baseline_out: &mut usize,
2762) -> Result<()> {
2763 let mut total_tokens = 0usize;
2764 let mut emitted_sigs: Vec<HashSet<String>> = Vec::new();
2765 let mut cache: HashMap<String, crate::parse::ParsedMarkdown> = HashMap::new();
2766 let mut emitted_ranges: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
2767 let question_l = question.to_ascii_lowercase();
2768 let score_floor = if wants_multi_file_evidence(question) {
2775 i32::MIN
2776 } else {
2777 scout_adaptive_score_floor(candidates)
2778 };
2779 let mut tail_cut = 0usize;
2780 for candidate in candidates {
2781 if total_tokens >= max_tokens {
2782 out.push_str("\n<!-- mdlens: scout budget exhausted -->\n");
2783 break;
2784 }
2785 if candidate.score < score_floor {
2786 tail_cut += 1;
2787 continue;
2788 }
2789 if !cache.contains_key(&candidate.path) {
2790 cache.insert(candidate.path.clone(), load_markdown(&candidate.path)?);
2791 }
2792 let parsed = cache.get(&candidate.path).expect("cached parsed markdown");
2793 let Some(section) = parsed.doc.find_section_by_id(&candidate.section_id) else {
2794 continue;
2795 };
2796 if is_low_value_section_for_question(section, &question_l) {
2797 continue;
2798 }
2799 let ranges = emitted_ranges.entry(candidate.path.clone()).or_default();
2800 if ranges.iter().any(|(start, end)| {
2801 section.line_start <= *start
2802 && section.line_end >= *end
2803 && (section.line_end - section.line_start) > (*end - *start)
2804 }) {
2805 continue;
2806 }
2807 let remaining = max_tokens.saturating_sub(total_tokens);
2808 let section_budget = remaining.min(650);
2809 let ancestors = section_ancestors(&parsed.doc.sections, §ion.id);
2810 let (content, truncated) =
2811 scout_section_content(section, &ancestors, &parsed.lines, question, section_budget);
2812 let emitted_tokens = estimate_tokens(&content);
2813 if emitted_tokens == 0 {
2814 continue;
2815 }
2816 let sig: HashSet<String> = content
2821 .split_whitespace()
2822 .filter(|w| w.len() >= 4)
2823 .map(|w| w.to_ascii_lowercase())
2824 .collect();
2825 if sig.len() >= 12
2826 && emitted_sigs.iter().any(|e| {
2827 let inter = sig.intersection(e).count();
2828 let uni = (sig.len() + e.len()).saturating_sub(inter).max(1);
2829 inter as f64 / uni as f64 > 0.6
2830 })
2831 {
2832 out.push_str("\n<!-- mdlens: omitted a near-duplicate section -->\n");
2834 continue;
2835 }
2836 out.push_str(&format!(
2837 "\n--- {} §{} {} l{}-{} ~{}t reason={} ---\n",
2838 candidate.path,
2839 section.id,
2840 section.path.join(" > "),
2841 section.line_start,
2842 section.line_end,
2843 section.token_estimate,
2844 candidate.reason
2845 ));
2846 out.push_str(&content);
2847 if !content.ends_with('\n') {
2848 out.push('\n');
2849 }
2850 ranges.push((section.line_start, section.line_end));
2851 total_tokens += emitted_tokens;
2852 emitted_sigs.push(sig);
2853 if truncated {
2854 continue;
2855 }
2856 }
2857 if tail_cut > 0 {
2858 out.push_str(&format!(
2859 "\n<!-- mdlens: tail-aware cutoff dropped {tail_cut} low-relevance section(s) -->\n"
2860 ));
2861 }
2862 *baseline_out = cache.values().map(|p| p.doc.token_estimate).sum();
2865 Ok(())
2866}
2867
2868fn scout_section_content(
2869 section: &Section,
2870 ancestors: &[&Section],
2871 lines: &[String],
2872 question: &str,
2873 max_tokens: usize,
2874) -> (String, bool) {
2875 let parent_context = scout_parent_context(ancestors, lines, max_tokens.min(220));
2876 let content_lines = section.extract_content(lines);
2877 let full = content_lines.join("\n");
2878 let full_with_context = if parent_context.trim().is_empty() {
2879 full.clone()
2880 } else {
2881 format!("{parent_context}\n...\n{full}")
2882 };
2883 let full_tokens = estimate_tokens(&full_with_context);
2884 if full_tokens <= max_tokens {
2885 return (full_with_context, false);
2886 }
2887
2888 let focused_budget = max_tokens
2889 .saturating_sub(estimate_tokens(&parent_context))
2890 .max(max_tokens / 2);
2891 let focused = scout_focused_excerpt(content_lines, question, focused_budget);
2892 if !focused.trim().is_empty() {
2893 if parent_context.trim().is_empty() {
2894 return (focused, true);
2895 }
2896 return (format!("{parent_context}\n...\n{focused}"), true);
2897 }
2898
2899 (
2900 truncate_to_tokens(&full_with_context, max_tokens, TRUNCATION_NOTICE),
2901 true,
2902 )
2903}
2904
2905fn section_ancestors<'a>(sections: &'a [Section], target_id: &str) -> Vec<&'a Section> {
2906 let mut path = Vec::new();
2907 collect_section_ancestors(sections, target_id, &mut path);
2908 path
2909}
2910
2911fn collect_section_ancestors<'a>(
2912 sections: &'a [Section],
2913 target_id: &str,
2914 path: &mut Vec<&'a Section>,
2915) -> bool {
2916 for section in sections {
2917 if section.id == target_id {
2918 return true;
2919 }
2920 path.push(section);
2921 if collect_section_ancestors(§ion.children, target_id, path) {
2922 return true;
2923 }
2924 path.pop();
2925 }
2926 false
2927}
2928
2929fn scout_parent_context(ancestors: &[&Section], lines: &[String], max_tokens: usize) -> String {
2930 if ancestors.is_empty() || max_tokens == 0 {
2931 return String::new();
2932 }
2933
2934 let mut parts = Vec::new();
2935 for ancestor in ancestors {
2936 let direct = ancestor.extract_direct_content(lines);
2937 let cleaned = direct
2938 .iter()
2939 .map(|line| line.trim_end())
2940 .filter(|line| !line.trim().is_empty() && !is_noisy_highlight_line(line.trim()))
2941 .collect::<Vec<_>>()
2942 .join("\n");
2943 if cleaned.trim().is_empty() {
2944 continue;
2945 }
2946 parts.push(cleaned);
2947 }
2948
2949 let joined = parts.join("\n");
2950 if estimate_tokens(&joined) <= max_tokens {
2951 joined
2952 } else {
2953 truncate_to_tokens(&joined, max_tokens, TRUNCATION_NOTICE)
2954 }
2955}
2956
2957fn scout_focused_excerpt(lines: &[String], question: &str, max_tokens: usize) -> String {
2958 let tokens: Vec<String> = signal_tokens(question)
2959 .into_iter()
2960 .map(|token| token.to_ascii_lowercase())
2961 .collect();
2962 let question_l = question.to_ascii_lowercase();
2963 let wants_code = ["cli", "command", "install", "invoke"]
2964 .iter()
2965 .any(|needle| question_l.contains(needle));
2966
2967 let mut selected = BTreeSet::new();
2968 for (idx, line) in lines.iter().enumerate() {
2969 let trimmed = line.trim();
2970 let lower = trimmed.to_ascii_lowercase();
2971 if is_noisy_highlight_line(trimmed) && !is_relevant_table_line(trimmed, &tokens) {
2972 continue;
2973 }
2974 let token_hits = tokens.iter().filter(|token| lower.contains(*token)).count();
2975 let code_hit = trimmed.contains("--")
2976 || (wants_code
2977 && (trimmed.contains('`')
2978 || trimmed.starts_with("pip ")
2979 || trimmed.starts_with("conda ")
2980 || trimmed.starts_with("python ")
2981 || trimmed.starts_with("git ")
2982 || trimmed.starts_with("cmake ")
2983 || trimmed.starts_with("make ")));
2984 let table_hit = is_relevant_table_line(trimmed, &tokens);
2985 if token_hits == 0 && !code_hit && !table_hit {
2986 continue;
2987 }
2988 let radius = if table_hit {
2989 2
2990 } else if lower.contains("disable") || lower.contains("warning") || code_hit {
2991 5
2992 } else if token_hits >= 2 {
2993 2
2994 } else {
2995 1
2996 };
2997 for context_idx in idx.saturating_sub(radius)..=(idx + radius).min(lines.len() - 1) {
2998 selected.insert(context_idx);
2999 }
3000 }
3001
3002 let mut out = String::new();
3003 let mut last_idx = None;
3004 for idx in selected {
3005 let line = lines[idx].trim_end();
3006 if line.trim().is_empty() {
3007 continue;
3008 }
3009 if let Some(last) = last_idx {
3010 if idx > last + 1 && !out.ends_with("\n...\n") {
3011 out.push_str("...\n");
3012 }
3013 }
3014 let candidate = format!("{out}{line}\n");
3015 if estimate_tokens(&candidate) > max_tokens {
3016 out.push_str(TRUNCATION_NOTICE);
3017 break;
3018 }
3019 out = candidate;
3020 last_idx = Some(idx);
3021 }
3022
3023 out
3024}
3025
3026fn cmd_pack(args: PackArgs) -> Result<()> {
3027 let dedupe = args.dedupe && !args.no_dedupe;
3028 let result = if let Some(ref ids_str) = args.ids {
3029 let ids: Vec<String> = ids_str.split(',').map(|s| s.trim().to_string()).collect();
3030 pack_by_ids(&args.path, &ids, args.max_tokens, args.parents, dedupe)?
3031 } else if let Some(ref paths_str) = args.paths {
3032 let doc = parse_markdown(&args.path)?;
3033 let path_list: Vec<&str> = paths_str.split(';').collect();
3034 let mut ids = Vec::new();
3035 for p in path_list {
3036 ids.push(find_unique_section_by_path(&doc, p)?.id.clone());
3037 }
3038 pack_by_ids(&args.path, &ids, args.max_tokens, args.parents, dedupe)?
3039 } else if let Some(ref query) = args.search {
3040 crate::pack::pack_by_search(
3041 &args.path,
3042 query,
3043 args.max_tokens,
3044 PackSearchOptions {
3045 include_parents: args.parents,
3046 dedupe,
3047 case_sensitive: args.case_sensitive,
3048 use_regex: args.regex,
3049 max_results: args.max_results,
3050 context_lines: args.context_lines,
3051 },
3052 )?
3053 } else {
3054 return Err(anyhow::anyhow!(
3055 "exactly one of --ids, --paths, or --search is required"
3056 ));
3057 };
3058
3059 if args.json {
3060 let output = PackJsonOutput {
3061 schema_version: 1,
3062 token_budget: result.token_budget,
3063 token_estimate: result.token_estimate,
3064 truncated: result.truncated,
3065 included: result
3066 .included
3067 .iter()
3068 .map(|inc| PackJsonIncluded {
3069 path: inc.path.clone(),
3070 section_id: inc.section_id.clone(),
3071 section_path: inc.section_path.clone(),
3072 line_start: inc.line_start,
3073 line_end: inc.line_end,
3074 token_estimate: inc.token_estimate,
3075 truncated: inc.truncated,
3076 })
3077 .collect(),
3078 content: result.content.clone(),
3079 };
3080 println!("{}", serde_json::to_string_pretty(&output)?);
3081 } else {
3082 let included_render: Vec<PackIncluded> = result
3083 .included
3084 .iter()
3085 .map(|inc| PackIncluded {
3086 section_id: inc.section_id.clone(),
3087 section_title: inc.section_path.last().cloned().unwrap_or_default(),
3088 line_range: format!("{}-{}", inc.line_start, inc.line_end),
3089 token_estimate: inc.token_estimate,
3090 })
3091 .collect();
3092 println!(
3093 "{}",
3094 render_pack(
3095 &args.path,
3096 result.token_budget,
3097 &included_render,
3098 &result.content,
3099 result.truncated
3100 )
3101 );
3102 }
3103
3104 Ok(())
3105}
3106
3107fn cmd_stats(args: StatsArgs) -> Result<()> {
3108 let files = crate::search::discover_markdown_files(&args.path)?;
3109 let mut entries = Vec::new();
3110
3111 for file in &files {
3112 let doc = parse_markdown(file)?;
3113 entries.push(StatsEntry {
3114 path: doc.path,
3115 lines: doc.line_count,
3116 words: doc.word_count,
3117 tokens: doc.token_estimate,
3118 });
3119 }
3120
3121 match args.sort {
3123 StatsSort::Tokens => entries.sort_by_key(|entry| Reverse(entry.tokens)),
3124 StatsSort::Lines => entries.sort_by_key(|entry| Reverse(entry.lines)),
3125 StatsSort::Path => entries.sort_by(|lhs, rhs| lhs.path.cmp(&rhs.path)),
3126 }
3127
3128 let entries = if let Some(top) = args.top {
3130 &entries[..std::cmp::min(top, entries.len())]
3131 } else {
3132 &entries
3133 };
3134
3135 if args.json {
3136 let output = StatsJsonOutput {
3137 schema_version: 1,
3138 entries: entries
3139 .iter()
3140 .map(|e| StatsJsonEntry {
3141 path: e.path.clone(),
3142 lines: e.lines,
3143 words: e.words,
3144 tokens: e.tokens,
3145 })
3146 .collect(),
3147 };
3148 println!("{}", serde_json::to_string_pretty(&output)?);
3149 } else {
3150 println!("{}", render_stats(entries));
3151 }
3152
3153 Ok(())
3154}
3155
3156fn cmd_sections(args: SectionsArgs) -> Result<()> {
3157 let stdin = io::stdin();
3158 let mut inputs: Vec<SectionInput> = Vec::new();
3159
3160 if !args.files.is_empty() {
3162 for f in &args.files {
3164 let trimmed = f.trim().to_string();
3165 if !trimmed.is_empty() {
3166 inputs.push(SectionInput::File(trimmed));
3167 }
3168 }
3169 } else {
3170 for line in stdin.lock().lines() {
3171 let line = line?;
3172 if let Some(input) = parse_sections_input_line(&line) {
3173 inputs.push(input);
3174 }
3175 }
3176 }
3177
3178 if inputs.is_empty() {
3179 return Ok(());
3180 }
3181
3182 let dedupe = args.dedupe && !args.no_dedupe;
3183 let has_hit_input = inputs
3184 .iter()
3185 .any(|input| matches!(input, SectionInput::Hit(_)));
3186
3187 if !has_hit_input {
3188 let mut paths: Vec<String> = inputs
3189 .into_iter()
3190 .filter_map(|input| match input {
3191 SectionInput::File(path) => Some(path),
3192 SectionInput::Hit(_) => None,
3193 })
3194 .collect();
3195
3196 if dedupe {
3197 let mut seen = HashSet::new();
3198 paths.retain(|p| seen.insert(p.clone()));
3199 }
3200
3201 return render_sections_from_paths(args, paths);
3202 }
3203
3204 let mut file_order: Vec<String> = Vec::new();
3205 let mut file_hits: HashMap<String, Vec<usize>> = HashMap::new();
3206
3207 for input in inputs {
3208 match input {
3209 SectionInput::File(path) => {
3210 if !file_order.iter().any(|existing| existing == &path) {
3211 file_order.push(path.clone());
3212 }
3213 file_hits.entry(path).or_default();
3214 }
3215 SectionInput::Hit(hit) => {
3216 let entry = file_hits.entry(hit.path.clone()).or_default();
3217 if !dedupe || !entry.contains(&hit.line) {
3218 entry.push(hit.line);
3219 }
3220 if !file_order.iter().any(|existing| existing == &hit.path) {
3221 file_order.push(hit.path);
3222 }
3223 }
3224 }
3225 }
3226
3227 if let Some(max_files) = args.max_files {
3228 if file_order.len() > max_files {
3229 anyhow::bail!(
3230 "[error] {} files exceed --max-files {}; narrow with a more specific grep or raise the limit",
3231 file_order.len(),
3232 max_files
3233 );
3234 }
3235 } else if args.max_tokens.is_none() && file_order.len() > 8 {
3236 eprintln!(
3237 "[warn] {} files piped without --max-tokens or --max-files; output may be large",
3238 file_order.len()
3239 );
3240 }
3241
3242 let mut file_outputs: Vec<SectionsFileOutput> = Vec::new();
3243 let mut total_tokens: usize = 0;
3244 let mut omitted: usize = 0;
3245
3246 for path in &file_order {
3247 let parsed = match load_markdown(path) {
3248 Ok(p) => p,
3249 Err(e) => {
3250 eprintln!("Warning: could not read {}: {}", path, e);
3251 continue;
3252 }
3253 };
3254
3255 let doc = &parsed.doc;
3256 let lines = &parsed.lines;
3257
3258 let mut sections: Vec<SectionsSectionOutput> =
3259 if let Some(hit_lines) = file_hits.get(path).filter(|lines| !lines.is_empty()) {
3260 collect_hit_sections(
3261 &doc.sections,
3262 lines,
3263 hit_lines,
3264 args.children,
3265 args.preview,
3266 dedupe,
3267 )
3268 } else {
3269 let mut collected = Vec::new();
3270 collect_all_sections(
3271 &doc.sections,
3272 lines,
3273 args.children,
3274 args.preview,
3275 args.max_depth,
3276 0,
3277 &mut collected,
3278 );
3279 collected
3280 };
3281
3282 if sections.is_empty() {
3283 continue;
3284 }
3285
3286 if let Some(max_sections) = args.max_sections {
3287 if sections.len() > max_sections {
3288 omitted += sections.len() - max_sections;
3289 sections.truncate(max_sections);
3290 }
3291 }
3292
3293 if let Some(max_tokens) = args.max_tokens {
3295 let mut kept: Vec<SectionsSectionOutput> = Vec::new();
3296 for sec in sections {
3297 if total_tokens + sec.token_estimate > max_tokens {
3298 omitted += 1;
3299 } else {
3300 total_tokens += sec.token_estimate;
3301 kept.push(sec);
3302 }
3303 }
3304 sections = kept;
3305 }
3306
3307 if !sections.is_empty() {
3308 file_outputs.push(SectionsFileOutput {
3309 path: path.clone(),
3310 sections,
3311 });
3312 }
3313 }
3314
3315 emit_sections_output(&args, file_outputs, omitted)
3316}
3317
3318fn render_sections_from_paths(args: SectionsArgs, paths: Vec<String>) -> Result<()> {
3319 if paths.is_empty() {
3320 return Ok(());
3321 }
3322
3323 let depth_capped = args.max_depth.is_none() && (!args.content || args.preview.is_some());
3324 let effective_depth = if depth_capped {
3325 Some(2)
3326 } else {
3327 args.max_depth
3328 };
3329
3330 if let Some(max_files) = args.max_files {
3331 if paths.len() > max_files {
3332 anyhow::bail!(
3333 "[error] {} files exceed --max-files {}; narrow with a more specific grep or raise the limit",
3334 paths.len(),
3335 max_files
3336 );
3337 }
3338 } else if args.max_tokens.is_none() && paths.len() > 8 {
3339 eprintln!(
3340 "[warn] {} files piped without --max-tokens or --max-files; output may be large",
3341 paths.len()
3342 );
3343 }
3344
3345 let mut file_outputs: Vec<SectionsFileOutput> = Vec::new();
3346 let mut total_tokens: usize = 0;
3347 let mut omitted: usize = 0;
3348
3349 for path in &paths {
3350 let parsed = match load_markdown(path) {
3351 Ok(p) => p,
3352 Err(e) => {
3353 eprintln!("Warning: could not read {}: {}", path, e);
3354 continue;
3355 }
3356 };
3357
3358 let doc = &parsed.doc;
3359 let lines = &parsed.lines;
3360 let mut sections: Vec<SectionsSectionOutput> = Vec::new();
3361 collect_all_sections(
3362 &doc.sections,
3363 lines,
3364 args.children,
3365 args.preview,
3366 effective_depth,
3367 0,
3368 &mut sections,
3369 );
3370
3371 if sections.is_empty() {
3372 continue;
3373 }
3374
3375 if let Some(max_sections) = args.max_sections {
3376 if sections.len() > max_sections {
3377 omitted += sections.len() - max_sections;
3378 sections.truncate(max_sections);
3379 }
3380 }
3381
3382 if let Some(max_tokens) = args.max_tokens {
3383 let mut kept: Vec<SectionsSectionOutput> = Vec::new();
3384 for sec in sections {
3385 if total_tokens + sec.token_estimate > max_tokens {
3386 omitted += 1;
3387 } else {
3388 total_tokens += sec.token_estimate;
3389 kept.push(sec);
3390 }
3391 }
3392 sections = kept;
3393 }
3394
3395 if !sections.is_empty() {
3396 file_outputs.push(SectionsFileOutput {
3397 path: path.clone(),
3398 sections,
3399 });
3400 }
3401 }
3402
3403 if depth_capped {
3404 eprintln!(
3405 "[sections] whole-file mode: showing depth ≤2 by default; use --max-depth N for more"
3406 );
3407 }
3408
3409 emit_sections_output(&args, file_outputs, omitted)
3410}
3411
3412fn emit_sections_output(
3413 args: &SectionsArgs,
3414 file_outputs: Vec<SectionsFileOutput>,
3415 omitted: usize,
3416) -> Result<()> {
3417 if omitted > 0 {
3418 if let Some(max_tokens) = args.max_tokens {
3419 eprintln!(
3420 "[warn] {} sections omitted by limits (budget ~{}t)",
3421 omitted, max_tokens
3422 );
3423 } else {
3424 eprintln!("[warn] {} sections omitted by limits", omitted);
3425 }
3426 }
3427
3428 if file_outputs.is_empty() {
3429 return Ok(());
3430 }
3431
3432 if args.json {
3433 let output = SectionsJsonOutput {
3434 schema_version: 1,
3435 files: file_outputs
3436 .iter()
3437 .map(|fo| SectionsJsonFile {
3438 path: fo.path.clone(),
3439 sections: fo
3440 .sections
3441 .iter()
3442 .map(|s| SectionsJsonSection {
3443 id: s.id.clone(),
3444 title: s.title.clone(),
3445 heading_path: if args.heading_paths {
3446 Some(s.heading_path.clone())
3447 } else {
3448 None
3449 },
3450 line_start: if args.lines { Some(s.line_start) } else { None },
3451 line_end: if args.lines { Some(s.line_end) } else { None },
3452 token_estimate: s.token_estimate,
3453 body: if args.content {
3454 Some(s.body.clone())
3455 } else {
3456 None
3457 },
3458 preview: s.preview.clone(),
3459 })
3460 .collect(),
3461 })
3462 .collect(),
3463 };
3464 println!("{}", serde_json::to_string_pretty(&output)?);
3465 } else {
3466 let entries: Vec<SectionsEntry> = file_outputs
3467 .iter()
3468 .flat_map(|fo| {
3469 fo.sections.iter().map(|s| SectionsEntry {
3470 file_path: fo.path.clone(),
3471 id: s.id.clone(),
3472 title: s.title.clone(),
3473 heading_path: if args.heading_paths {
3474 Some(s.heading_path.clone())
3475 } else {
3476 None
3477 },
3478 line_start: if args.lines { Some(s.line_start) } else { None },
3479 line_end: if args.lines { Some(s.line_end) } else { None },
3480 token_estimate: s.token_estimate,
3481 body: if args.content {
3482 Some(s.body.clone())
3483 } else {
3484 None
3485 },
3486 preview: s.preview.clone(),
3487 })
3488 })
3489 .collect();
3490 println!("{}", render_sections(&entries, args.content));
3491 }
3492
3493 Ok(())
3494}
3495
3496struct SectionsSectionOutput {
3497 id: String,
3498 title: String,
3499 heading_path: Vec<String>,
3500 line_start: usize,
3501 line_end: usize,
3502 token_estimate: usize,
3503 body: String,
3504 preview: Option<String>,
3505}
3506
3507struct SectionsFileOutput {
3508 path: String,
3509 sections: Vec<SectionsSectionOutput>,
3510}
3511
3512#[derive(Clone)]
3513struct HitSectionAggregate<'a> {
3514 section: &'a Section,
3515 hit_count: usize,
3516 first_line: usize,
3517}
3518
3519fn parse_sections_input_line(line: &str) -> Option<SectionInput> {
3520 let trimmed = line.trim();
3521 if trimmed.is_empty() {
3522 return None;
3523 }
3524
3525 if let Some((path, line_num)) = parse_grep_hit(trimmed) {
3526 return Some(SectionInput::Hit(SectionHit {
3527 path: path.to_string(),
3528 line: line_num,
3529 }));
3530 }
3531
3532 Some(SectionInput::File(trimmed.to_string()))
3533}
3534
3535fn parse_grep_hit(line: &str) -> Option<(&str, usize)> {
3536 let first = line.find(':')?;
3537 let rest = &line[(first + 1)..];
3538 let second = rest.find(':')?;
3539 let path = &line[..first];
3540 let line_num = rest[..second].parse().ok()?;
3541 Some((path, line_num))
3542}
3543
3544fn collect_hit_sections(
3545 sections: &[Section],
3546 lines: &[String],
3547 hit_lines: &[usize],
3548 include_children: bool,
3549 preview_lines: Option<usize>,
3550 dedupe: bool,
3551) -> Vec<SectionsSectionOutput> {
3552 let mut by_section: HashMap<String, HitSectionAggregate<'_>> = HashMap::new();
3553 let mut ordered_hits: Vec<(usize, &Section)> = Vec::new();
3554
3555 for line_num in hit_lines {
3556 let Some(section) = find_deepest_section_for_line(sections, *line_num) else {
3557 continue;
3558 };
3559 if dedupe {
3560 by_section
3561 .entry(section.id.clone())
3562 .and_modify(|entry| entry.hit_count += 1)
3563 .or_insert(HitSectionAggregate {
3564 section,
3565 hit_count: 1,
3566 first_line: *line_num,
3567 });
3568 } else {
3569 ordered_hits.push((*line_num, section));
3570 }
3571 }
3572
3573 let aggregates: Vec<HitSectionAggregate<'_>> = if dedupe {
3574 let mut ranked: Vec<HitSectionAggregate<'_>> = by_section.into_values().collect();
3575 ranked.sort_by(|lhs, rhs| {
3576 rhs.hit_count
3577 .cmp(&lhs.hit_count)
3578 .then(lhs.section.token_estimate.cmp(&rhs.section.token_estimate))
3579 .then(lhs.first_line.cmp(&rhs.first_line))
3580 .then(lhs.section.line_start.cmp(&rhs.section.line_start))
3581 });
3582 ranked
3583 } else {
3584 ordered_hits.sort_by(|lhs, rhs| {
3585 lhs.0
3586 .cmp(&rhs.0)
3587 .then(lhs.1.line_start.cmp(&rhs.1.line_start))
3588 .then(lhs.1.id.cmp(&rhs.1.id))
3589 });
3590 ordered_hits
3591 .into_iter()
3592 .map(|(first_line, section)| HitSectionAggregate {
3593 section,
3594 hit_count: 1,
3595 first_line,
3596 })
3597 .collect()
3598 };
3599
3600 let mut collected = Vec::new();
3601 for aggregate in aggregates {
3602 let section = aggregate.section;
3603 let body_lines = if include_children {
3604 section.extract_content(lines)
3605 } else {
3606 section.extract_direct_content(lines)
3607 };
3608 let body = body_lines.join("\n");
3609 let preview = preview_lines.map(|n| {
3610 body_lines
3611 .iter()
3612 .filter(|l| !l.trim().is_empty())
3613 .take(n)
3614 .cloned()
3615 .collect::<Vec<_>>()
3616 .join("\n")
3617 });
3618
3619 collected.push(SectionsSectionOutput {
3620 id: section.id.clone(),
3621 title: section.title.clone(),
3622 heading_path: section.path.clone(),
3623 line_start: section.line_start,
3624 line_end: section.line_end,
3625 token_estimate: estimate_tokens(&body),
3626 body,
3627 preview,
3628 });
3629 }
3630
3631 collected
3632}
3633
3634fn collect_all_sections(
3635 sections: &[Section],
3636 lines: &[String],
3637 include_children: bool,
3638 preview_lines: Option<usize>,
3639 max_depth: Option<usize>,
3640 current_depth: usize,
3641 result: &mut Vec<SectionsSectionOutput>,
3642) {
3643 for section in sections {
3644 if section.title == "<preamble>" {
3645 continue;
3646 }
3647 if let Some(max) = max_depth {
3648 if current_depth >= max {
3649 continue;
3650 }
3651 }
3652 let body_lines = if include_children {
3653 section.extract_content(lines)
3654 } else {
3655 section.extract_direct_content(lines)
3656 };
3657 let body = body_lines.join("\n");
3658 let preview = preview_lines.map(|n| {
3659 body_lines
3660 .iter()
3661 .filter(|l| !l.trim().is_empty())
3662 .take(n)
3663 .cloned()
3664 .collect::<Vec<_>>()
3665 .join("\n")
3666 });
3667 result.push(SectionsSectionOutput {
3668 id: section.id.clone(),
3669 title: section.title.clone(),
3670 heading_path: section.path.clone(),
3671 line_start: section.line_start,
3672 line_end: section.line_end,
3673 token_estimate: estimate_tokens(&body),
3674 body,
3675 preview,
3676 });
3677 collect_all_sections(
3678 §ion.children,
3679 lines,
3680 include_children,
3681 preview_lines,
3682 max_depth,
3683 current_depth + 1,
3684 result,
3685 );
3686 }
3687}
3688
3689fn enrich_search_results(
3690 results: &mut [crate::render::SearchResult],
3691 with_content: bool,
3692 preview_lines: Option<usize>,
3693) -> Result<()> {
3694 let mut docs: HashMap<String, crate::parse::ParsedMarkdown> = HashMap::new();
3695
3696 for result in results.iter_mut() {
3697 let parsed = if let Some(parsed) = docs.get(&result.path) {
3698 parsed
3699 } else {
3700 let loaded = load_markdown(&result.path)?;
3701 docs.insert(result.path.clone(), loaded);
3702 docs.get(&result.path).expect("inserted parsed markdown")
3703 };
3704
3705 let Some(section) = parsed.doc.find_section_by_id(&result.section_id) else {
3706 continue;
3707 };
3708 let body_lines = section.extract_direct_content(&parsed.lines);
3709 if with_content {
3710 result.body = Some(body_lines.join("\n"));
3711 }
3712 if let Some(n) = preview_lines {
3713 result.preview = Some(
3714 body_lines
3715 .iter()
3716 .filter(|line| !line.trim().is_empty())
3717 .take(n)
3718 .cloned()
3719 .collect::<Vec<_>>()
3720 .join("\n"),
3721 );
3722 }
3723 }
3724
3725 Ok(())
3726}
3727
3728fn find_deepest_section_for_line(sections: &[Section], line_num: usize) -> Option<&Section> {
3729 for section in sections {
3730 if line_num < section.line_start || line_num > section.line_end {
3731 continue;
3732 }
3733 if let Some(child) = find_deepest_section_for_line(§ion.children, line_num) {
3734 return Some(child);
3735 }
3736 return Some(section);
3737 }
3738 None
3739}
3740
3741#[derive(Serialize)]
3744struct TreeJsonOutput {
3745 schema_version: u32,
3746 path: String,
3747 line_count: usize,
3748 byte_count: usize,
3749 char_count: usize,
3750 word_count: usize,
3751 token_estimate: usize,
3752 sections: Vec<SectionJsonOutput>,
3753}
3754
3755#[derive(Serialize)]
3756struct TreeFileJsonOutput {
3757 path: String,
3758 line_count: usize,
3759 byte_count: usize,
3760 char_count: usize,
3761 word_count: usize,
3762 token_estimate: usize,
3763 sections: Vec<SectionJsonOutput>,
3764}
3765
3766#[derive(Serialize)]
3767struct TreeMultiJsonOutput {
3768 schema_version: u32,
3769 files: Vec<TreeFileJsonOutput>,
3770}
3771
3772#[derive(Serialize)]
3773struct SectionJsonOutput {
3774 id: String,
3775 title: String,
3776 level: u8,
3777 path: Vec<String>,
3778 line_start: usize,
3779 line_end: usize,
3780 token_estimate: usize,
3781 #[serde(skip_serializing_if = "Vec::is_empty")]
3782 children: Vec<SectionJsonOutput>,
3783}
3784
3785#[derive(Serialize)]
3786struct ReadJsonOutput {
3787 schema_version: u32,
3788 path: String,
3789 selector: ReadSelector,
3790 section: SectionJsonOutput,
3791 content: String,
3792 truncated: bool,
3793}
3794
3795#[derive(Serialize)]
3796struct ReadSelector {
3797 #[serde(rename = "type")]
3798 r#type: String,
3799 value: String,
3800}
3801
3802#[derive(Serialize)]
3803struct SearchJsonOutput {
3804 schema_version: u32,
3805 query: String,
3806 root: String,
3807 results: Vec<SearchJsonResult>,
3808}
3809
3810#[derive(Serialize)]
3811struct SearchJsonResult {
3812 path: String,
3813 section_id: String,
3814 section_title: String,
3815 section_path: Vec<String>,
3816 line_start: usize,
3817 line_end: usize,
3818 token_estimate: usize,
3819 match_count: usize,
3820 body: Option<String>,
3821 preview: Option<String>,
3822 snippets: Vec<SearchJsonSnippet>,
3823}
3824
3825#[derive(Serialize)]
3826struct SearchJsonSnippet {
3827 line_start: usize,
3828 line_end: usize,
3829 text: String,
3830}
3831
3832#[derive(Serialize)]
3833struct ScoutJsonOutput {
3834 schema_version: u32,
3835 root: String,
3836 question: String,
3837 token_budget: usize,
3838 candidate_count: usize,
3839 queries: Vec<String>,
3840 candidates: Vec<ScoutCandidate>,
3841 rendered_text: String,
3842}
3843
3844#[derive(Serialize)]
3845struct PackJsonOutput {
3846 schema_version: u32,
3847 token_budget: usize,
3848 token_estimate: usize,
3849 truncated: bool,
3850 included: Vec<PackJsonIncluded>,
3851 content: String,
3852}
3853
3854#[derive(Serialize)]
3855struct PackJsonIncluded {
3856 path: String,
3857 section_id: String,
3858 section_path: Vec<String>,
3859 line_start: usize,
3860 line_end: usize,
3861 token_estimate: usize,
3862 truncated: bool,
3863}
3864
3865#[derive(Serialize)]
3866struct StatsJsonOutput {
3867 schema_version: u32,
3868 entries: Vec<StatsJsonEntry>,
3869}
3870
3871#[derive(Serialize)]
3872struct StatsJsonEntry {
3873 path: String,
3874 lines: usize,
3875 words: usize,
3876 tokens: usize,
3877}
3878
3879#[derive(Serialize)]
3880struct SectionsJsonOutput {
3881 schema_version: u32,
3882 files: Vec<SectionsJsonFile>,
3883}
3884
3885#[derive(Serialize)]
3886struct SectionsJsonFile {
3887 path: String,
3888 sections: Vec<SectionsJsonSection>,
3889}
3890
3891#[derive(Serialize)]
3892struct SectionsJsonSection {
3893 id: String,
3894 title: String,
3895 #[serde(skip_serializing_if = "Option::is_none")]
3896 heading_path: Option<Vec<String>>,
3897 #[serde(skip_serializing_if = "Option::is_none")]
3898 line_start: Option<usize>,
3899 #[serde(skip_serializing_if = "Option::is_none")]
3900 line_end: Option<usize>,
3901 token_estimate: usize,
3902 #[serde(skip_serializing_if = "Option::is_none")]
3903 body: Option<String>,
3904 #[serde(skip_serializing_if = "Option::is_none")]
3905 preview: Option<String>,
3906}
3907
3908fn serialize_sections(
3911 sections: &[Section],
3912 max_depth: Option<usize>,
3913 include_preamble: bool,
3914 current_depth: usize,
3915) -> Vec<SectionJsonOutput> {
3916 let mut result = Vec::new();
3917 for section in sections {
3918 if section.title == "<preamble>" && !include_preamble {
3919 continue;
3920 }
3921 let children = if let Some(max) = max_depth {
3922 if current_depth + 1 < max {
3923 serialize_sections(
3924 §ion.children,
3925 max_depth,
3926 include_preamble,
3927 current_depth + 1,
3928 )
3929 } else {
3930 Vec::new()
3931 }
3932 } else {
3933 serialize_sections(
3934 §ion.children,
3935 max_depth,
3936 include_preamble,
3937 current_depth + 1,
3938 )
3939 };
3940
3941 result.push(SectionJsonOutput {
3942 id: section.id.clone(),
3943 title: section.title.clone(),
3944 level: section.level,
3945 path: section.path.clone(),
3946 line_start: section.line_start,
3947 line_end: section.line_end,
3948 token_estimate: section.token_estimate,
3949 children,
3950 });
3951 }
3952 result
3953}
3954
3955fn truncate_content_to_tokens(content: &str, max_tokens: usize) -> String {
3956 truncate_to_tokens(content, max_tokens, TRUNCATION_NOTICE)
3957}