Skip to main content

mdlens/
cli.rs

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    /// Show section hierarchy with token estimates for a file or directory
37    Tree(TreeArgs),
38    /// Extract a section by ID, heading path, or line range
39    Read(ReadArgs),
40    /// Search files and return section-level matches with snippets
41    Search(SearchArgs),
42    /// One-shot agent evidence pack: find files, show section maps, and include likely evidence
43    Scout(ScoutArgs),
44    /// Pack selected sections into a bounded token budget
45    Pack(PackArgs),
46    /// Inspect file sizes, word counts, and token estimates
47    Stats(StatsArgs),
48    /// Read file paths from stdin and output structured section metadata
49    Sections(SectionsArgs),
50    /// Wire mdlens guidance into AI coding harnesses (CLAUDE.md, AGENTS.md, ...)
51    Init(InitArgs),
52    /// Show accumulated token savings from scout/read usage (set MDLENS_NO_GAIN=1 to disable recording)
53    Gain(GainArgs),
54}
55
56#[derive(clap::Args)]
57struct TreeArgs {
58    /// File or directory to analyze
59    path: String,
60    /// Output JSON (machine-readable with schema_version)
61    #[arg(long)]
62    json: bool,
63    /// Limit section depth shown
64    #[arg(long)]
65    max_depth: Option<usize>,
66    /// Show preamble section (content before first heading)
67    #[arg(long)]
68    include_preamble: bool,
69    /// For directory input, include per-file summaries
70    #[arg(long)]
71    files: bool,
72}
73
74#[derive(clap::Args)]
75struct ReadArgs {
76    /// File to read from
77    file: String,
78    /// Section ID to extract (e.g., "1.2.3" — dotted hierarchy)
79    #[arg(long, conflicts_with_all = ["heading_path", "lines"])]
80    id: Option<String>,
81    /// Heading path to extract (e.g., "Usage>Configuration"; escape literal > as \>)
82    #[arg(long, conflicts_with_all = ["id", "lines"])]
83    heading_path: Option<String>,
84    /// Line range to extract (e.g., "120:190")
85    #[arg(long, conflicts_with_all = ["id", "heading_path"])]
86    lines: Option<String>,
87    /// Include parent headings above the section excerpt
88    #[arg(long)]
89    parents: bool,
90    /// Include all child sections (default: true unless --no-children)
91    #[arg(long, conflicts_with = "no_children")]
92    children: bool,
93    /// Only include heading and direct body before first child heading
94    #[arg(long, conflicts_with = "children")]
95    no_children: bool,
96    /// Truncate output to approximate token budget
97    #[arg(long)]
98    max_tokens: Option<usize>,
99    /// Output JSON (machine-readable with schema_version)
100    #[arg(long)]
101    json: bool,
102}
103
104#[derive(clap::Args)]
105struct SearchArgs {
106    /// File or directory to search
107    path: String,
108    /// Search query (plain text or regex with --regex)
109    query: String,
110    /// Output JSON (machine-readable with schema_version)
111    #[arg(long)]
112    json: bool,
113    /// Use regex for the query
114    #[arg(long)]
115    regex: bool,
116    /// Case-sensitive search (default: case-insensitive)
117    #[arg(long)]
118    case_sensitive: bool,
119    /// Maximum number of results (default: 20)
120    #[arg(long, default_value_t = 20)]
121    max_results: usize,
122    /// Context lines around each match (default: 2)
123    #[arg(long, default_value_t = 2)]
124    context_lines: usize,
125    /// Include full section body text for each result
126    #[arg(long)]
127    content: bool,
128    /// Show first N non-empty lines of each matched section inline
129    #[arg(long)]
130    preview: Option<usize>,
131    /// Cap total output tokens across included search results
132    #[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    /// File or directory to scout
145    path: String,
146    /// Natural-language question or retrieval goal
147    question: String,
148    /// Output JSON (machine-readable with schema_version)
149    #[arg(long)]
150    json: bool,
151    /// Approximate evidence-token budget (default: 1000)
152    #[arg(long, default_value_t = 1000)]
153    max_tokens: usize,
154    /// Maximum candidate sections to consider before packing (default: 12)
155    #[arg(long, default_value_t = 12)]
156    max_sections: usize,
157    /// Maximum files to include in the file map (default: 4)
158    #[arg(long, default_value_t = 4)]
159    max_files: usize,
160}
161
162#[derive(clap::Args)]
163struct PackArgs {
164    /// File or directory to pack from
165    path: String,
166    /// Comma-separated section IDs to include
167    #[arg(long, conflicts_with_all = ["paths", "search"])]
168    ids: Option<String>,
169    /// Semicolon-separated heading paths to include
170    #[arg(long, conflicts_with_all = ["ids", "search"])]
171    paths: Option<String>,
172    /// Search query to find sections to pack
173    #[arg(long, conflicts_with_all = ["ids", "paths"])]
174    search: Option<String>,
175    /// Required: maximum token budget
176    #[arg(long)]
177    max_tokens: usize,
178    /// Include parent heading context above selected sections
179    #[arg(long)]
180    parents: bool,
181    /// Avoid duplicate nested sections (default)
182    #[arg(long, conflicts_with = "no_dedupe")]
183    dedupe: bool,
184    /// Allow duplicate sections in the final pack
185    #[arg(long, conflicts_with = "dedupe")]
186    no_dedupe: bool,
187    /// Use regex when selecting sections via --search
188    #[arg(long)]
189    regex: bool,
190    /// Case-sensitive search when selecting sections via --search
191    #[arg(long)]
192    case_sensitive: bool,
193    /// Maximum number of search results to consider for --search (default: 20)
194    #[arg(long, default_value_t = 20)]
195    max_results: usize,
196    /// Context lines when searching via --search (default: 2)
197    #[arg(long, default_value_t = 2)]
198    context_lines: usize,
199    /// Output JSON (machine-readable with schema_version)
200    #[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    /// File or directory to analyze
214    path: String,
215    /// Output JSON (machine-readable with schema_version)
216    #[arg(long)]
217    json: bool,
218    /// Sort by field: path, tokens, or lines (default: path)
219    #[arg(long, value_enum, default_value_t = StatsSort::Path)]
220    sort: StatsSort,
221    /// Show top N results
222    #[arg(long)]
223    top: Option<usize>,
224}
225
226#[derive(clap::Args)]
227struct SectionsArgs {
228    /// File paths to process (alternative or supplement to stdin)
229    #[arg(value_name = "FILE")]
230    files: Vec<String>,
231    /// Include full section body text (default: metadata only)
232    #[arg(long)]
233    content: bool,
234    /// Include descendant subsection text inside each section body
235    #[arg(long)]
236    children: bool,
237    /// Show first N lines of each section body inline (cheaper than --content; helps pick the right section before a full read)
238    #[arg(long)]
239    preview: Option<usize>,
240    /// Limit section hierarchy depth shown (default: unlimited)
241    #[arg(long)]
242    max_depth: Option<usize>,
243    /// Cap total output tokens (truncates last section if exceeded)
244    #[arg(long)]
245    max_tokens: Option<usize>,
246    /// Cap the number of sections emitted after selection/ranking
247    #[arg(long)]
248    max_sections: Option<usize>,
249    /// Reject input if more than N files are piped (prevents accidental large reads; recommended: 5)
250    #[arg(long)]
251    max_files: Option<usize>,
252    /// Machine-readable JSON output
253    #[arg(long)]
254    json: bool,
255    /// Include heading path (e.g. "SGOCR Champion > Candidate Quality")
256    #[arg(long)]
257    heading_paths: bool,
258    /// Include original line numbers (start-end)
259    #[arg(long)]
260    lines: bool,
261    /// Deduplicate sections if same section matches multiple lines (default: true)
262    #[arg(long, default_value_t = true)]
263    dedupe: bool,
264    /// Allow duplicate sections in output
265    #[arg(long, conflicts_with = "dedupe")]
266    no_dedupe: bool,
267}
268
269#[derive(clap::Args)]
270struct InitArgs {
271    /// Write to user-level config files (e.g. ~/.claude/CLAUDE.md) instead of the project
272    #[arg(long, short = 'g')]
273    global: bool,
274    /// Wire into Claude Code (CLAUDE.md)
275    #[arg(long)]
276    claude: bool,
277    /// Wire into Codex / AGENTS.md (also covers opencode)
278    #[arg(long)]
279    codex: bool,
280    /// Wire into Gemini CLI (GEMINI.md)
281    #[arg(long)]
282    gemini: bool,
283    /// Wire into GitHub Copilot (.github/copilot-instructions.md)
284    #[arg(long)]
285    copilot: bool,
286    /// Wire into Cursor (.cursor/rules/mdlens.md)
287    #[arg(long)]
288    cursor: bool,
289    /// Select a harness by name: claude, codex, gemini, copilot, cursor (repeatable)
290    #[arg(long, value_name = "NAME")]
291    agent: Vec<String>,
292    /// Project root to write into (default: current directory)
293    #[arg(long, default_value = ".")]
294    path: String,
295    /// Show what would change without writing
296    #[arg(long)]
297    dry_run: bool,
298}
299
300#[derive(clap::Args)]
301struct GainArgs {
302    /// Output JSON (machine-readable with schema_version)
303    #[arg(long)]
304    json: bool,
305    /// Reset accumulated savings history to zero (requires --yes to confirm)
306    #[arg(long)]
307    reset: bool,
308    /// Confirm a destructive --reset without a prompt
309    #[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    // Collect explicitly-selected harnesses from both flag and --agent forms.
348    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 everything was skipped (e.g. `init -g --cursor`), nothing was done —
408    // surface that as an error rather than a silent success.
409    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        // Multiple files — cap depth at 1 by default to keep directory output manageable
450        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(&section_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        // Record against the JSON the agent actually receives.
629        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(&section_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(&section, &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
681/// Find parent heading line numbers for a section.
682fn 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(&current_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(&section.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    // baseline_tokens: total tokens of the distinct files scout pulled evidence
960    // from (what the agent would otherwise read in full). Filled from the parse
961    // cache render_scout_evidence already builds, so no file is parsed twice.
962    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        // Record against what the agent actually receives (the full JSON wrapper).
983        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(&section.section_path, &section.section_title, question) as f64
1133                / 25.0;
1134        let path_prior = scout_path_quality_score(&section.path) as f64 / 20.0;
1135        let authority_prior =
1136            scout_source_authority_score(&section.path, &section.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(&section.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    // Deterministic iteration: HashMap order is unspecified, so sort by path.
1716    // (Downstream sorting currently masks this, but relying on that is fragile.)
1717    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(&section.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(&section.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, &section.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(&section.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        // Strong head, then a hard fall to a flat tail.
2281        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        // Gentle linear decay has no knee: keep everything (i32::MIN).
2289        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        // Same multiset as the cliff case but in non-monotonic order (as the
2301        // real input is) — the floor must be identical.
2302        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
2681/// Tail-aware adaptive-k cutoff for evidence emission.
2682///
2683/// The candidates handed to `render_scout_evidence` are re-ordered by
2684/// `order_scout_evidence` and are NOT monotonically descending by score, so we
2685/// must never `break` the emission loop on raw order. Instead we look at the
2686/// *distribution* of scores: sort a copy descending, find the sharpest relative
2687/// cliff between consecutive scores (a kneedle-style knee), and return the score
2688/// FLOOR at-or-above which a candidate is "in the head". Emission then filters
2689/// each candidate by `score >= floor` regardless of its position in the list.
2690///
2691/// Model-free and deterministic. The cliff threshold is derived from the score
2692/// distribution itself (relative drops compared against the median relative
2693/// drop), not a hardcoded constant. Returns `i32::MIN` when no clear knee
2694/// exists, which keeps every candidate (no behavioral change on smooth tails).
2695fn scout_adaptive_score_floor(candidates: &[ScoutCandidate]) -> i32 {
2696    // Need enough points for a tail to exist and a knee to be meaningful.
2697    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    // A degenerate (flat or non-positive) head has no cliff to find.
2705    if top <= 0 || scores[scores.len() - 1] == top {
2706        return i32::MIN;
2707    }
2708
2709    // Relative drop across each adjacent pair, normalized by the head score so
2710    // the measure is scale-free. Restrict cut points to the latter portion of
2711    // the curve so we never amputate the genuine head on a single early step.
2712    let n = scores.len();
2713    let min_keep = (n / 4).max(2); // never cut before keeping at least this many
2714    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    // Typical (median) drop magnitude characterises the curve's smooth decay.
2720    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    // The knee is the largest drop occurring after `min_keep`, but only if it is
2725    // a genuine cliff: clearly larger than the typical drop AND a sizeable share
2726    // of the full head score. Both gates are relative to the data, not constants
2727    // tuned to the eval.
2728    let mut best_idx: Option<usize> = None;
2729    let mut best_drop = 0.0_f64;
2730    for (i, &drop) in drops.iter().enumerate() {
2731        // `i` is the gap between kept[i] and kept[i+1]; keeping i+1 sections.
2732        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 // cliff spans >=20% of the head score
2744                && best_drop >= median_drop * 3.0 + 1e-9 // and dwarfs typical decay
2745                && drops.iter().filter(|&&d| d >= best_drop - 1e-9).count() == 1 =>
2746        {
2747            // Keep everything strictly above the cliff: score floor is the value
2748            // just *before* the drop (scores[i]); the next score (scores[i+1])
2749            // and below are tail and get cut. Ties at the floor are kept.
2750            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    // Tail-aware cutoff: filter low-relevance tail candidates by score floor
2769    // derived from the score distribution. NOT a positional break — the input
2770    // order is non-monotonic, so we gate per-candidate by score instead.
2771    // Skip the cutoff for comparative/multi-file questions, where a low-scored
2772    // section can be the sole evidence for one entity and the global floor can't
2773    // see per-file need. Otherwise trim the low-relevance tail (distractors).
2774    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, &section.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        // MMR: skip a section that near-duplicates one already emitted (e.g. a
2817        // block copied across files), reinvesting the budget in new evidence.
2818        // The size floor avoids false-positive dedup on tiny sections that share
2819        // a couple of long tokens.
2820        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            // Signal the omission so the agent knows the pack was de-duplicated.
2833            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 = full-text tokens of the distinct files we opened to build the
2863    // pack. Reuses the parse cache above, so each file is parsed only once.
2864    *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(&section.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    // Sort
3122    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    // Apply top limit
3129    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    // Read from stdin only when it is not a tty (i.e. piped input)
3161    if !args.files.is_empty() {
3162        // Positional args provided — use those, skip stdin
3163        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        // Apply max-tokens cap
3294        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            &section.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(&section.children, line_num) {
3734            return Some(child);
3735        }
3736        return Some(section);
3737    }
3738    None
3739}
3740
3741// --- JSON output types ---
3742
3743#[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
3908// --- Helper functions ---
3909
3910fn 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                    &section.children,
3925                    max_depth,
3926                    include_preamble,
3927                    current_depth + 1,
3928                )
3929            } else {
3930                Vec::new()
3931            }
3932        } else {
3933            serialize_sections(
3934                &section.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}