Skip to main content

aft/commands/
outline.rs

1use std::collections::{HashMap, VecDeque};
2use std::io::Read as _;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::UNIX_EPOCH;
6
7use serde::Serialize;
8
9use crate::commands::read::{handle_github_outline, is_github_read_target};
10use crate::context::AppContext;
11use crate::edit;
12use crate::error::AftError;
13use crate::inspect::job::is_test_file;
14use crate::parser::{detect_language, LangId};
15use crate::protocol::{RawRequest, Response};
16use crate::symbols::{Range, Symbol};
17use crate::url_fetch::{fetch_url_to_cache, is_http_url, UrlFetchOptions};
18
19const MAX_OUTLINE_FILE_BYTES: u64 = 50 * 1024 * 1024;
20const BINARY_SAMPLE_BYTES: usize = 4 * 1024;
21const OUTLINE_FILE_WALK_CAP: usize = 200;
22const OUTLINE_FILE_COLLECTION_CAP: usize = 10_000;
23
24/// A single entry in the outline tree.
25///
26/// Top-level symbols have an empty `members` vec. Classes/structs contain
27/// their methods and nested types in `members`, forming a recursive tree.
28#[derive(Debug, Clone, Serialize)]
29pub struct OutlineEntry {
30    pub name: String,
31    pub kind: String,
32    pub range: Range,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub signature: Option<String>,
35    pub exported: bool,
36    pub members: Vec<OutlineEntry>,
37}
38
39/// Handle an `outline` request.
40///
41/// Expects `file` or `files` in request params. Calls `list_symbols()` on the provider,
42/// then builds a nested tree and returns compact tree-text output.
43///
44/// - Single-file mode: includes signatures (e.g. `function greet(name: string): void 5:12`,
45///   or `E function greet(...) 5:12` when exported without a visibility keyword in the signature)
46/// - Multi-file mode: no signatures, paths relative to project_root
47///
48/// Output is capped at 30KB; if exceeded, truncates with a narrowing hint.
49pub fn handle_outline(req: &RawRequest, ctx: &AppContext) -> Response {
50    const MAX_OUTPUT_BYTES: usize = 30 * 1024;
51
52    if req
53        .params
54        .get("files")
55        .and_then(|value| value.as_bool())
56        .unwrap_or(false)
57    {
58        return handle_outline_files_mode(req, ctx, MAX_OUTPUT_BYTES);
59    }
60
61    if let Some(directory) = req.params.get("directory").and_then(|v| v.as_str()) {
62        let dir_path = match ctx.validate_path(&req.id, Path::new(directory)) {
63            Ok(path) => path,
64            Err(resp) => return resp,
65        };
66        if !dir_path.is_dir() {
67            return Response::error(
68                &req.id,
69                "file_not_found",
70                format!("directory not found: {}", directory),
71            );
72        }
73
74        let discovery = discover_outline_files(&dir_path);
75        let project_root = ctx.config().project_root.clone();
76        let include_tests = include_tests_param(req);
77        let files = if include_tests {
78            discovery.files.clone()
79        } else {
80            discovery
81                .files
82                .iter()
83                .filter(|file| {
84                    let path = Path::new(file);
85                    let relative = project_root
86                        .as_deref()
87                        .and_then(|root| relative_path_from_root(path, root))
88                        .unwrap_or_else(|| path_to_slash(path));
89                    !is_test_file(&relative)
90                })
91                .cloned()
92                .collect::<Vec<_>>()
93        };
94        let (file_outlines, skipped_files) =
95            match outline_many_files(&files, ctx, &req.id, project_root.as_deref()) {
96                Ok(result) => result,
97                Err(resp) => return resp,
98            };
99
100        let text = format_multi_file_tree(&file_outlines, MAX_OUTPUT_BYTES, files.len());
101        return Response::success(
102            &req.id,
103            serde_json::json!({
104                "text": text,
105                "complete": !discovery.walk_truncated
106                    && !discovery.collection_truncated
107                    && discovery.skipped_foreign_mounts == 0,
108                "walk_truncated": discovery.walk_truncated,
109                "collection_truncated": discovery.collection_truncated,
110                "skipped_foreign_mounts": discovery.skipped_foreign_mounts,
111                "skipped_files": skipped_files,
112            }),
113        );
114    }
115
116    // Multi-file mode: if "files" array is present, outline each file
117    if let Some(files_arr) = req.params.get("files").and_then(|v| v.as_array()) {
118        let project_root = ctx.config().project_root.clone();
119        let files: Vec<String> = files_arr
120            .iter()
121            .filter_map(|file_val| file_val.as_str().map(String::from))
122            .collect();
123        let total_files_requested = files_arr.len();
124        let (file_outlines, skipped_files) =
125            match outline_many_files(&files, ctx, &req.id, project_root.as_deref()) {
126                Ok(result) => result,
127                Err(resp) => return resp,
128            };
129
130        let text = format_multi_file_tree(&file_outlines, MAX_OUTPUT_BYTES, total_files_requested);
131        // Honest reporting: complete only when no requested file was skipped.
132        // skipped_files names the gaps (missing/unreadable/unparseable inputs).
133        return Response::success(
134            &req.id,
135            serde_json::json!({
136                "text": text,
137                "complete": skipped_files.is_empty(),
138                "skipped_files": skipped_files,
139            }),
140        );
141    }
142
143    // Single-file mode (original behavior)
144    let file = match req
145        .params
146        .get("file")
147        .or_else(|| req.params.get("target"))
148        .and_then(|v| v.as_str())
149    {
150        Some(f) => f,
151        None => {
152            return Response::error(
153                &req.id,
154                "invalid_request",
155                "outline: missing required param 'file', 'files', or 'directory'",
156            );
157        }
158    };
159
160    if is_github_read_target(file) {
161        return handle_github_outline(req, ctx, file);
162    }
163
164    let path = match resolve_file_or_url(req, ctx, file) {
165        Ok(path) => path,
166        Err(resp) => return resp,
167    };
168    if !path.exists() {
169        return Response::error(
170            &req.id,
171            "file_not_found",
172            format!("file not found: {}", file),
173        );
174    }
175
176    let symbols = match ctx.provider().list_symbols(&path) {
177        Ok(s) => s,
178        Err(e) => {
179            return Response::error(&req.id, e.code(), e.to_string());
180        }
181    };
182
183    let entries = build_outline_tree(&symbols);
184    let filename = path
185        .file_name()
186        .map(|f| f.to_string_lossy().to_string())
187        .unwrap_or_else(|| file.to_string());
188    let text = format_single_file_tree(&filename, &entries);
189
190    Response::success(
191        &req.id,
192        serde_json::json!({ "text": text, "complete": true }),
193    )
194}
195
196fn include_tests_param(req: &RawRequest) -> bool {
197    req.params
198        .get("includeTests")
199        .or_else(|| req.params.get("include_tests"))
200        .and_then(|value| value.as_bool())
201        .unwrap_or(false)
202}
203
204fn resolve_file_or_url(
205    req: &RawRequest,
206    ctx: &AppContext,
207    file: &str,
208) -> Result<PathBuf, Response> {
209    if is_http_url(file) {
210        let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
211        let allow_private = ctx.config().url_fetch_allow_private
212            || req
213                .params
214                .get("allow_private")
215                .and_then(|value| value.as_bool())
216                .unwrap_or(false);
217        return fetch_url_to_cache(
218            file,
219            &storage_dir,
220            UrlFetchOptions {
221                allow_private,
222                ..UrlFetchOptions::default()
223            },
224        )
225        .map_err(|error| Response::error(&req.id, "url_fetch_failed", error.to_string()));
226    }
227
228    ctx.validate_path(&req.id, Path::new(file))
229}
230
231/// Build a nested outline tree from a flat symbol list.
232///
233/// Strategy: two passes.
234/// 1. Convert every top-level symbol to an `OutlineEntry` and index sibling names.
235/// 2. Walk children (parent.is_some()) and attach them under their parent.
236///    For multi-level nesting (e.g. OuterClass.InnerClass.inner_method),
237///    we use the `scope_chain` to walk the full parent path.
238///
239/// Symbols whose parent can't be found in the list are promoted to top level
240/// (defensive — shouldn't happen with well-formed parser output).
241pub(crate) fn build_outline_tree(symbols: &[Symbol]) -> Vec<OutlineEntry> {
242    let mut top_level = Vec::new();
243    let mut scope_index = OutlineScopeIndex::default();
244    let mut children = Vec::new();
245
246    for sym in symbols {
247        if sym.parent.is_none() {
248            push_indexed_entry(&mut top_level, &mut scope_index, symbol_to_entry(sym));
249        } else {
250            children.push(sym);
251        }
252    }
253
254    for child in children {
255        let entry = symbol_to_entry(child);
256        let scope = &child.scope_chain;
257
258        if scope.is_empty() {
259            push_indexed_entry(&mut top_level, &mut scope_index, entry);
260            continue;
261        }
262
263        // Preserve the established lookup ladder: try the full display scope,
264        // then the direct parent used by languages such as Rust, then promote.
265        let entry = match insert_at_scope_indexed(&mut top_level, &mut scope_index, scope, entry) {
266            Ok(()) => continue,
267            Err(entry) => entry,
268        };
269        let entry = match child.parent.as_ref() {
270            Some(parent) => match insert_at_scope_indexed(
271                &mut top_level,
272                &mut scope_index,
273                std::slice::from_ref(parent),
274                entry,
275            ) {
276                Ok(()) => continue,
277                Err(entry) => entry,
278            },
279            None => entry,
280        };
281        push_indexed_entry(&mut top_level, &mut scope_index, entry);
282    }
283
284    top_level
285}
286
287// Small sibling lists are cheaper to scan than to allocate a map for. Once a
288// level reaches this bound, every later lookup is indexed and the scan cost is
289// capped independently of the file's symbol count.
290const OUTLINE_SCOPE_INDEX_THRESHOLD: usize = 8;
291
292#[derive(Default)]
293struct OutlineScopeIndex {
294    first_by_name: Option<HashMap<String, usize>>,
295    children: Vec<OutlineScopeIndex>,
296}
297
298impl OutlineScopeIndex {
299    fn first_match(&self, entries: &[OutlineEntry], name: &str) -> Option<usize> {
300        if let Some(first_by_name) = &self.first_by_name {
301            return first_by_name.get(name).copied();
302        }
303        entries.iter().position(|entry| entry.name == name)
304    }
305
306    fn note_pushed(&mut self, entries: &[OutlineEntry]) {
307        debug_assert_eq!(self.children.len() + 1, entries.len());
308        self.children.push(Self::default());
309
310        if let Some(first_by_name) = &mut self.first_by_name {
311            let index = entries.len() - 1;
312            let name = &entries[index].name;
313            if !first_by_name.contains_key(name) {
314                first_by_name.insert(name.clone(), index);
315            }
316        } else if entries.len() == OUTLINE_SCOPE_INDEX_THRESHOLD {
317            let mut first_by_name = HashMap::with_capacity(entries.len());
318            for (index, entry) in entries.iter().enumerate() {
319                first_by_name.entry(entry.name.clone()).or_insert(index);
320            }
321            self.first_by_name = Some(first_by_name);
322        }
323    }
324}
325
326fn push_indexed_entry(
327    entries: &mut Vec<OutlineEntry>,
328    scope_index: &mut OutlineScopeIndex,
329    entry: OutlineEntry,
330) {
331    entries.push(entry);
332    scope_index.note_pushed(entries);
333}
334
335fn insert_at_scope_indexed(
336    entries: &mut Vec<OutlineEntry>,
337    scope_index: &mut OutlineScopeIndex,
338    scope_chain: &[String],
339    entry: OutlineEntry,
340) -> Result<(), OutlineEntry> {
341    let Some(target_name) = scope_chain.first() else {
342        return Err(entry);
343    };
344    let Some(target_index) = scope_index.first_match(entries, target_name) else {
345        return Err(entry);
346    };
347
348    let existing = &mut entries[target_index];
349    let child_index = &mut scope_index.children[target_index];
350    if scope_chain.len() == 1 {
351        push_indexed_entry(&mut existing.members, child_index, entry);
352        Ok(())
353    } else {
354        insert_at_scope_indexed(&mut existing.members, child_index, &scope_chain[1..], entry)
355    }
356}
357
358// ── Tree text formatting ──────────────────────────────────────────────
359
360/// Intermediate representation for multi-file tree rendering.
361struct FileOutline {
362    path: String, // relative path
363    entries: Vec<OutlineEntry>,
364}
365
366#[derive(Debug, Clone, Serialize)]
367struct SkippedFile {
368    file: String,
369    reason: String,
370}
371
372impl SkippedFile {
373    fn new(file: impl Into<String>, reason: impl Into<String>) -> Self {
374        Self {
375            file: file.into(),
376            reason: reason.into(),
377        }
378    }
379}
380
381#[derive(Debug, Clone, Serialize)]
382struct OutlineFileEntry {
383    path: String,
384    language: String,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    symbols: Option<usize>,
387    lines: Option<usize>,
388    #[serde(skip)]
389    absolute_path: PathBuf,
390    #[serde(skip)]
391    data_doc: bool,
392}
393
394#[derive(Debug, Clone, Default)]
395struct OutlineDirectoryStats {
396    dirs: usize,
397    files: usize,
398    lines: usize,
399    data_doc_files: usize,
400    code_files: usize,
401    code_lines: usize,
402}
403
404#[derive(Debug, Clone)]
405struct OutlineDirectoryNode {
406    path: String,
407    depth: usize,
408    direct_files: Vec<usize>,
409    children: Vec<usize>,
410    stats: OutlineDirectoryStats,
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414enum OutlineTableRow {
415    File(usize),
416    Rollup(usize),
417}
418
419/// Rendered outline table output wrapping the formatted text with its budget-measured length (R13).
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct OutlineTable {
422    table: String,
423    rendered_len: usize,
424}
425
426impl std::ops::Deref for OutlineTable {
427    type Target = str;
428    fn deref(&self) -> &str {
429        &self.table
430    }
431}
432
433impl AsRef<str> for OutlineTable {
434    fn as_ref(&self) -> &str {
435        &self.table
436    }
437}
438
439impl std::fmt::Display for OutlineTable {
440    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441        write!(f, "{}", self.table)
442    }
443}
444
445impl serde::Serialize for OutlineTable {
446    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
447    where
448        S: serde::Serializer,
449    {
450        serializer.serialize_str(&self.table)
451    }
452}
453
454impl OutlineTable {
455    pub fn len(&self) -> usize {
456        self.rendered_len
457    }
458
459    pub fn is_empty(&self) -> bool {
460        self.rendered_len == 0
461    }
462
463    pub fn as_str(&self) -> &str {
464        &self.table
465    }
466
467    pub fn into_string(self) -> String {
468        self.table
469    }
470}
471
472impl From<OutlineTable> for String {
473    fn from(table: OutlineTable) -> Self {
474        table.table
475    }
476}
477
478#[derive(Debug, Clone, Copy)]
479struct OutlineFileContentStats {
480    binary: bool,
481    lines: Option<usize>,
482}
483
484#[derive(Debug, Clone)]
485struct OutlineWalkOptions {
486    gitignore: Option<Arc<ignore::gitignore::Gitignore>>,
487    gitignore_root: Option<PathBuf>,
488}
489
490#[derive(Debug, Clone)]
491struct OutlineFileDiscovery {
492    files: Vec<String>,
493    directories: Vec<String>,
494    walk_truncated: bool,
495    collection_truncated: bool,
496    skipped_foreign_mounts: usize,
497}
498
499fn handle_outline_files_mode(
500    req: &RawRequest,
501    ctx: &AppContext,
502    max_output_bytes: usize,
503) -> Response {
504    let targets = match outline_files_mode_targets(req) {
505        Ok(targets) => targets,
506        Err(response) => return response,
507    };
508
509    let multiple_targets = targets.len() >= 2;
510    let project_root = ctx.config().project_root.clone();
511    let include_tests = include_tests_param(req);
512
513    let mut file_entries = Vec::new();
514    let mut directory_nodes = Vec::new();
515    let mut tree_roots = Vec::new();
516    let mut walk_truncated = false;
517    let mut collection_truncated = false;
518    let mut skipped_foreign_mounts = 0usize;
519
520    for target in targets {
521        let dir_path = match ctx.validate_path(&req.id, Path::new(&target)) {
522            Ok(path) => path,
523            Err(response) => return response,
524        };
525
526        if !dir_path.exists() {
527            return Response::error(
528                &req.id,
529                "file_not_found",
530                format!("directory not found: {}", target),
531            );
532        }
533        if !dir_path.is_dir() {
534            return Response::error(
535                &req.id,
536                "invalid_request",
537                "files mode requires a directory target",
538            );
539        }
540
541        let display_root = if multiple_targets {
542            project_root.as_deref().unwrap_or(&dir_path)
543        } else {
544            &dir_path
545        };
546        let discovery = discover_outline_files_for_files_mode(&dir_path, ctx);
547        walk_truncated |= discovery.walk_truncated;
548        collection_truncated |= discovery.collection_truncated;
549        skipped_foreign_mounts += discovery.skipped_foreign_mounts;
550
551        let root = append_outline_directory_tree(
552            &dir_path,
553            display_root,
554            discovery,
555            ctx,
556            include_tests,
557            &mut file_entries,
558            &mut directory_nodes,
559        );
560        tree_roots.push(root);
561    }
562
563    for root in &tree_roots {
564        aggregate_outline_directory(*root, &mut directory_nodes, &file_entries);
565    }
566    let rows = plan_outline_file_rows(
567        &tree_roots,
568        &directory_nodes,
569        &file_entries,
570        max_output_bytes,
571    );
572    populate_rendered_file_symbols(&rows, &mut file_entries, ctx);
573    let table = format_files_table(&rows, &directory_nodes, &file_entries, max_output_bytes);
574    let text = table.into_string();
575    let rollup_count = rows
576        .iter()
577        .filter(|row| matches!(row, OutlineTableRow::Rollup(_)))
578        .count();
579
580    let shown = rows
581        .iter()
582        .filter(|row| matches!(row, OutlineTableRow::File(_)))
583        .count();
584    let mut budget_rollup_files = 0;
585    let mut budget_rollups_present = false;
586    for row in &rows {
587        if let OutlineTableRow::Rollup(node_id) = row {
588            if !directory_is_data_heavy(&directory_nodes[*node_id]) {
589                budget_rollups_present = true;
590                budget_rollup_files += directory_nodes[*node_id].stats.files;
591            }
592        }
593    }
594
595    let envelope = crate::list_surfaces::outline::build_outline_files_envelope(
596        shown,
597        budget_rollup_files,
598        budget_rollups_present,
599        collection_truncated,
600        walk_truncated,
601        skipped_foreign_mounts,
602    );
603
604    let mut unchecked_files = Vec::new();
605    if walk_truncated {
606        unchecked_files
607            .push("<additional files not counted: 10000-file walk limit reached>".to_string());
608    }
609    if collection_truncated {
610        unchecked_files.push("<additional files not counted: directory walk failed>".to_string());
611    }
612    if skipped_foreign_mounts > 0 {
613        unchecked_files.push(format!(
614            "<{skipped_foreign_mounts} foreign filesystem mount(s) not traversed>"
615        ));
616    }
617
618    file_entries.sort_by(|a, b| a.path.cmp(&b.path));
619    let mut response_data = serde_json::json!({
620        "text": text,
621        "files": file_entries,
622        "complete": !walk_truncated
623            && !collection_truncated
624            && skipped_foreign_mounts == 0,
625        "walk_truncated": walk_truncated,
626        "walk_limit": OUTLINE_FILE_COLLECTION_CAP,
627        "collection_truncated": collection_truncated,
628        "skipped_foreign_mounts": skipped_foreign_mounts,
629        "unchecked_files": unchecked_files,
630        "rollup_count": rollup_count,
631    });
632    if let Some(env) = envelope {
633        response_data["files_list_envelope"] = serde_json::to_value(&env).unwrap();
634    }
635    Response::success(&req.id, response_data)
636}
637
638fn outline_files_mode_targets(req: &RawRequest) -> Result<Vec<String>, Response> {
639    if let Some(directory) = req.params.get("directory").and_then(|value| value.as_str()) {
640        return Ok(vec![directory.to_string()]);
641    }
642
643    if let Some(directories) = req
644        .params
645        .get("directories")
646        .and_then(|value| value.as_array())
647    {
648        let targets = directories
649            .iter()
650            .filter_map(|value| value.as_str().map(ToOwned::to_owned))
651            .collect::<Vec<_>>();
652        if !targets.is_empty() {
653            return Ok(targets);
654        }
655    }
656
657    if let Some(targets) = req.params.get("targets") {
658        if let Some(target) = targets.as_str() {
659            return Ok(vec![target.to_string()]);
660        }
661        if let Some(targets) = targets.as_array() {
662            let targets = targets
663                .iter()
664                .filter_map(|value| value.as_str().map(ToOwned::to_owned))
665                .collect::<Vec<_>>();
666            if !targets.is_empty() {
667                return Ok(targets);
668            }
669        }
670    }
671
672    if let Some(target) = req.params.get("target") {
673        if let Some(target) = target.as_str() {
674            return Ok(vec![target.to_string()]);
675        }
676        if let Some(targets) = target.as_array() {
677            let targets = targets
678                .iter()
679                .filter_map(|value| value.as_str().map(ToOwned::to_owned))
680                .collect::<Vec<_>>();
681            if !targets.is_empty() {
682                return Ok(targets);
683            }
684        }
685    }
686
687    if let Some(file) = req.params.get("file").and_then(|value| value.as_str()) {
688        return Ok(vec![file.to_string()]);
689    }
690
691    Err(Response::error(
692        &req.id,
693        "invalid_request",
694        "files mode requires a directory target",
695    ))
696}
697
698fn discover_outline_files_for_files_mode(
699    directory: &Path,
700    ctx: &AppContext,
701) -> OutlineFileDiscovery {
702    let gitignore = ctx.gitignore();
703    let gitignore_root = ctx
704        .config()
705        .project_root
706        .as_ref()
707        .and_then(|root| std::fs::canonicalize(root).ok());
708    let options = OutlineWalkOptions {
709        gitignore,
710        gitignore_root,
711    };
712    discover_outline_files_with_options(directory, Some(&options), true)
713}
714
715fn append_outline_directory_tree(
716    target_root: &Path,
717    display_root: &Path,
718    mut discovery: OutlineFileDiscovery,
719    ctx: &AppContext,
720    include_tests: bool,
721    file_entries: &mut Vec<OutlineFileEntry>,
722    directory_nodes: &mut Vec<OutlineDirectoryNode>,
723) -> usize {
724    let root_path = relative_path_from_root(target_root, display_root).unwrap_or_default();
725    let root_id = directory_nodes.len();
726    directory_nodes.push(OutlineDirectoryNode {
727        path: root_path,
728        depth: 0,
729        direct_files: Vec::new(),
730        children: Vec::new(),
731        stats: OutlineDirectoryStats::default(),
732    });
733
734    discovery.directories.sort_by(|a, b| {
735        Path::new(a)
736            .components()
737            .count()
738            .cmp(&Path::new(b).components().count())
739            .then_with(|| a.cmp(b))
740    });
741    let mut directory_ids = HashMap::new();
742    directory_ids.insert(target_root.to_path_buf(), root_id);
743
744    for directory in discovery.directories {
745        let path = PathBuf::from(directory);
746        let Some(parent_id) = path
747            .parent()
748            .and_then(|parent| directory_ids.get(parent).copied())
749        else {
750            continue;
751        };
752        let node_id = directory_nodes.len();
753        let node_path =
754            relative_path_from_root(&path, display_root).unwrap_or_else(|| path_to_slash(&path));
755        let depth = directory_nodes[parent_id].depth + 1;
756        directory_nodes.push(OutlineDirectoryNode {
757            path: node_path,
758            depth,
759            direct_files: Vec::new(),
760            children: Vec::new(),
761            stats: OutlineDirectoryStats::default(),
762        });
763        directory_nodes[parent_id].children.push(node_id);
764        directory_ids.insert(path, node_id);
765    }
766
767    for file in discovery.files {
768        let path = PathBuf::from(file);
769        let test_path = ctx
770            .config()
771            .project_root
772            .as_deref()
773            .and_then(|root| relative_path_from_root(&path, root))
774            .unwrap_or_else(|| path_to_slash(&path));
775        if !include_tests && is_test_file(&test_path) {
776            continue;
777        }
778        let Some(entry) = outline_file_entry(&path, display_root) else {
779            continue;
780        };
781        let Some(parent_id) = path
782            .parent()
783            .and_then(|parent| directory_ids.get(parent).copied())
784        else {
785            continue;
786        };
787        let file_id = file_entries.len();
788        file_entries.push(entry);
789        directory_nodes[parent_id].direct_files.push(file_id);
790    }
791
792    root_id
793}
794
795fn aggregate_outline_directory(
796    node_id: usize,
797    directory_nodes: &mut [OutlineDirectoryNode],
798    file_entries: &[OutlineFileEntry],
799) -> OutlineDirectoryStats {
800    let direct_files = directory_nodes[node_id].direct_files.clone();
801    let children = directory_nodes[node_id].children.clone();
802    let mut stats = OutlineDirectoryStats::default();
803
804    for file_id in direct_files {
805        let entry = &file_entries[file_id];
806        stats.files += 1;
807        stats.lines += entry.lines.unwrap_or(0);
808        if entry.data_doc {
809            stats.data_doc_files += 1;
810        } else {
811            stats.code_files += 1;
812            stats.code_lines += entry.lines.unwrap_or(0);
813        }
814    }
815    for child in children {
816        let child_stats = aggregate_outline_directory(child, directory_nodes, file_entries);
817        stats.dirs += child_stats.dirs + 1;
818        stats.files += child_stats.files;
819        stats.lines += child_stats.lines;
820        stats.data_doc_files += child_stats.data_doc_files;
821        stats.code_files += child_stats.code_files;
822        stats.code_lines += child_stats.code_lines;
823    }
824
825    directory_nodes[node_id].stats = stats.clone();
826    stats
827}
828
829fn outline_rows_for_directory(
830    node_id: usize,
831    directory_nodes: &[OutlineDirectoryNode],
832    file_entries: &[OutlineFileEntry],
833) -> Vec<OutlineTableRow> {
834    let node = &directory_nodes[node_id];
835    let mut code_files = node
836        .direct_files
837        .iter()
838        .copied()
839        .filter(|file_id| !file_entries[*file_id].data_doc)
840        .collect::<Vec<_>>();
841    let mut data_files = node
842        .direct_files
843        .iter()
844        .copied()
845        .filter(|file_id| file_entries[*file_id].data_doc)
846        .collect::<Vec<_>>();
847    let mut directories = node.children.clone();
848    code_files.sort_by(|a, b| file_entries[*a].path.cmp(&file_entries[*b].path));
849    data_files.sort_by(|a, b| file_entries[*a].path.cmp(&file_entries[*b].path));
850    directories.sort_by(|a, b| directory_nodes[*a].path.cmp(&directory_nodes[*b].path));
851
852    code_files
853        .into_iter()
854        .map(OutlineTableRow::File)
855        .chain(directories.into_iter().map(OutlineTableRow::Rollup))
856        .chain(data_files.into_iter().map(OutlineTableRow::File))
857        .collect()
858}
859
860fn directory_is_data_heavy(node: &OutlineDirectoryNode) -> bool {
861    !node.direct_files.is_empty()
862        && node.stats.files > 0
863        && node.stats.data_doc_files * 10 >= node.stats.files * 9
864}
865
866fn outline_expansion_added_rows(node: &OutlineDirectoryNode) -> usize {
867    node.direct_files
868        .len()
869        .saturating_add(node.children.len())
870        .saturating_sub(1)
871}
872
873fn compare_directory_code_share(
874    a: &OutlineDirectoryNode,
875    b: &OutlineDirectoryNode,
876) -> std::cmp::Ordering {
877    let a_total = if a.stats.lines == 0 {
878        a.stats.files.max(1)
879    } else {
880        a.stats.lines
881    };
882    let b_total = if b.stats.lines == 0 {
883        b.stats.files.max(1)
884    } else {
885        b.stats.lines
886    };
887    let a_code = if a.stats.lines == 0 {
888        a.stats.code_files
889    } else {
890        a.stats.code_lines
891    };
892    let b_code = if b.stats.lines == 0 {
893        b.stats.code_files
894    } else {
895        b.stats.code_lines
896    };
897    (a_code as u128 * b_total as u128).cmp(&(b_code as u128 * a_total as u128))
898}
899
900fn plan_outline_file_rows(
901    roots: &[usize],
902    directory_nodes: &[OutlineDirectoryNode],
903    file_entries: &[OutlineFileEntry],
904    max_bytes: usize,
905) -> Vec<OutlineTableRow> {
906    // A depth-first alphabetical list lets a large crate consume the whole
907    // response before later siblings appear. Start with every direct entry,
908    // then prefer cheap same-level expansions so the budget reveals breadth
909    // before flattening a directory with hundreds of direct files. If a level
910    // cannot finish, stop before deeper `src/` trees outrun sibling crates.
911    let mut rows = roots
912        .iter()
913        .flat_map(|root| outline_rows_for_directory(*root, directory_nodes, file_entries))
914        .collect::<Vec<_>>();
915    let mut considered = std::collections::HashSet::new();
916
917    loop {
918        let Some(level) = rows
919            .iter()
920            .filter_map(|row| match row {
921                OutlineTableRow::Rollup(node_id)
922                    if !considered.contains(node_id)
923                        && !directory_is_data_heavy(&directory_nodes[*node_id]) =>
924                {
925                    Some(directory_nodes[*node_id].depth)
926                }
927                _ => None,
928            })
929            .min()
930        else {
931            break;
932        };
933
934        let mut candidates = rows
935            .iter()
936            .filter_map(|row| match row {
937                OutlineTableRow::Rollup(node_id)
938                    if directory_nodes[*node_id].depth == level
939                        && !considered.contains(node_id)
940                        && !directory_is_data_heavy(&directory_nodes[*node_id]) =>
941                {
942                    Some(*node_id)
943                }
944                _ => None,
945            })
946            .collect::<Vec<_>>();
947        candidates.sort_by(|a, b| {
948            let a_node = &directory_nodes[*a];
949            let b_node = &directory_nodes[*b];
950            outline_expansion_added_rows(a_node)
951                .cmp(&outline_expansion_added_rows(b_node))
952                .then_with(|| compare_directory_code_share(a_node, b_node).reverse())
953                .then_with(|| a_node.path.cmp(&b_node.path))
954        });
955
956        let mut level_fully_expanded = true;
957        for node_id in candidates {
958            considered.insert(node_id);
959            let replacement = outline_rows_for_directory(node_id, directory_nodes, file_entries);
960            if replacement.is_empty() {
961                continue;
962            }
963            let Some(position) = rows
964                .iter()
965                .position(|row| *row == OutlineTableRow::Rollup(node_id))
966            else {
967                continue;
968            };
969            let mut candidate_rows = rows.clone();
970            candidate_rows.splice(position..=position, replacement);
971            if format_files_table(&candidate_rows, directory_nodes, file_entries, max_bytes).len()
972                <= max_bytes
973            {
974                rows = candidate_rows;
975            } else {
976                level_fully_expanded = false;
977            }
978        }
979        if !level_fully_expanded {
980            break;
981        }
982    }
983
984    rows
985}
986
987fn outline_file_entry(path: &Path, display_root: &Path) -> Option<OutlineFileEntry> {
988    let rel_path =
989        relative_path_from_root(path, display_root).unwrap_or_else(|| path_to_slash(path));
990    let detected_language = detect_language(path);
991    let content = inspect_outline_file_content(path).unwrap_or(OutlineFileContentStats {
992        binary: false,
993        lines: None,
994    });
995    let language = if content.binary {
996        "binary"
997    } else {
998        outline_file_language(path, detected_language)
999    };
1000
1001    Some(OutlineFileEntry {
1002        path: rel_path,
1003        language: language.to_string(),
1004        symbols: None,
1005        lines: content.lines,
1006        absolute_path: path.to_path_buf(),
1007        data_doc: is_data_doc_outline_file(path, detected_language),
1008    })
1009}
1010
1011fn populate_rendered_file_symbols(
1012    rows: &[OutlineTableRow],
1013    file_entries: &mut [OutlineFileEntry],
1014    ctx: &AppContext,
1015) {
1016    for file_id in rows.iter().filter_map(|row| match row {
1017        OutlineTableRow::File(file_id) => Some(*file_id),
1018        OutlineTableRow::Rollup(_) => None,
1019    }) {
1020        let entry = &mut file_entries[file_id];
1021        if entry.symbols.is_some() {
1022            continue;
1023        }
1024        if entry.language == "binary" {
1025            entry.symbols = Some(0);
1026            continue;
1027        }
1028        let path = entry.absolute_path.clone();
1029        if detect_language(&path).is_none() {
1030            entry.symbols = Some(0);
1031            continue;
1032        }
1033        let Ok(metadata) = std::fs::metadata(&path) else {
1034            entry.symbols = Some(0);
1035            continue;
1036        };
1037        let symbols = cached_symbol_count(ctx, &path, &metadata).unwrap_or_else(|| {
1038            if metadata.len() > MAX_OUTLINE_FILE_BYTES {
1039                0
1040            } else {
1041                ctx.provider()
1042                    .list_symbols(&path)
1043                    .map(|symbols| symbols.len())
1044                    .unwrap_or(0)
1045            }
1046        });
1047        entry.symbols = Some(symbols);
1048    }
1049}
1050
1051fn relative_path_from_root(path: &Path, root: &Path) -> Option<String> {
1052    if let Ok(relative) = path.strip_prefix(root) {
1053        return Some(path_to_slash(relative));
1054    }
1055
1056    let canonical_path = std::fs::canonicalize(path).ok()?;
1057    let canonical_root = std::fs::canonicalize(root).ok()?;
1058    canonical_path
1059        .strip_prefix(canonical_root)
1060        .ok()
1061        .map(path_to_slash)
1062}
1063
1064fn path_to_slash(path: &Path) -> String {
1065    path.to_string_lossy().replace('\\', "/")
1066}
1067
1068fn cached_symbol_count(
1069    ctx: &AppContext,
1070    path: &Path,
1071    metadata: &std::fs::Metadata,
1072) -> Option<usize> {
1073    let mtime = metadata.modified().unwrap_or(UNIX_EPOCH);
1074    let size = metadata.len();
1075    let symbol_cache = ctx.symbol_cache();
1076    let cache = symbol_cache.read().ok()?;
1077    cache
1078        .symbol_count_if_metadata_matches(path, mtime, size)
1079        .or_else(|| cache.get(path, mtime).map(|symbols| symbols.len()))
1080}
1081
1082fn inspect_outline_file_content(path: &Path) -> std::io::Result<OutlineFileContentStats> {
1083    let mut file = std::fs::File::open(path)?;
1084    let mut sample = [0u8; BINARY_SAMPLE_BYTES];
1085    let sample_len = file.read(&mut sample)?;
1086    if sample_len > 0 && content_inspector::inspect(&sample[..sample_len]).is_binary() {
1087        return Ok(OutlineFileContentStats {
1088            binary: true,
1089            lines: None,
1090        });
1091    }
1092
1093    let mut newline_count = sample[..sample_len]
1094        .iter()
1095        .filter(|byte| **byte == b'\n')
1096        .count();
1097    let mut total_bytes = sample_len;
1098    let mut last_byte = sample_len.checked_sub(1).map(|index| sample[index]);
1099    let mut buffer = [0u8; 16 * 1024];
1100    loop {
1101        let read = file.read(&mut buffer)?;
1102        if read == 0 {
1103            break;
1104        }
1105        newline_count += buffer[..read].iter().filter(|byte| **byte == b'\n').count();
1106        total_bytes += read;
1107        last_byte = Some(buffer[read - 1]);
1108    }
1109
1110    let lines = newline_count + usize::from(total_bytes > 0 && last_byte != Some(b'\n'));
1111    Ok(OutlineFileContentStats {
1112        binary: false,
1113        lines: Some(lines),
1114    })
1115}
1116
1117fn outline_file_language(path: &Path, detected_language: Option<LangId>) -> &'static str {
1118    if let Some(language) = detected_language {
1119        return language_id(language);
1120    }
1121    let filename = path
1122        .file_name()
1123        .and_then(|name| name.to_str())
1124        .unwrap_or_default();
1125    let extension = path
1126        .extension()
1127        .and_then(|extension| extension.to_str())
1128        .unwrap_or_default()
1129        .to_ascii_lowercase();
1130    if extension == "toml" {
1131        "toml"
1132    } else if extension == "lock" || filename.ends_with(".lock") {
1133        "lock"
1134    } else if extension == "txt" {
1135        "text"
1136    } else if extension == "bazel" || matches!(filename, "BUILD" | "WORKSPACE" | "MODULE.bazel") {
1137        "bazel"
1138    } else {
1139        "unknown"
1140    }
1141}
1142
1143fn is_data_doc_outline_file(path: &Path, detected_language: Option<LangId>) -> bool {
1144    if matches!(
1145        detected_language,
1146        Some(LangId::Json | LangId::Yaml | LangId::Markdown)
1147    ) {
1148        return true;
1149    }
1150    detected_language.is_none()
1151        || matches!(
1152            outline_file_language(path, detected_language),
1153            "toml" | "lock" | "text" | "bazel" | "unknown"
1154        )
1155}
1156
1157fn language_id(lang: LangId) -> &'static str {
1158    match lang {
1159        LangId::TypeScript => "typescript",
1160        LangId::Tsx => "tsx",
1161        LangId::JavaScript => "javascript",
1162        LangId::Python => "python",
1163        LangId::Rust => "rust",
1164        LangId::Go => "go",
1165        LangId::C => "c",
1166        LangId::Cpp => "cpp",
1167        LangId::Cuda => "cuda",
1168        LangId::Metal => "metal",
1169        LangId::Zig => "zig",
1170        LangId::CSharp => "csharp",
1171        LangId::Bash => "bash",
1172        LangId::Html => "html",
1173        LangId::Markdown => "markdown",
1174        LangId::Yaml => "yaml",
1175        LangId::Solidity => "solidity",
1176        LangId::Scss => "scss",
1177        LangId::Vue => "vue",
1178        LangId::Json => "json",
1179        LangId::Scala => "scala",
1180        LangId::Java => "java",
1181        LangId::Ruby => "ruby",
1182        LangId::Kotlin => "kotlin",
1183        LangId::Swift => "swift",
1184        LangId::Php => "php",
1185        LangId::Lua => "lua",
1186        LangId::Perl => "perl",
1187        LangId::Pascal => "pascal",
1188        LangId::R => "r",
1189        LangId::Groovy => "groovy",
1190        LangId::ObjC => "objc",
1191        LangId::Toml => "toml",
1192    }
1193}
1194
1195fn format_files_table(
1196    rows: &[OutlineTableRow],
1197    directory_nodes: &[OutlineDirectoryNode],
1198    file_entries: &[OutlineFileEntry],
1199    _max_bytes: usize,
1200) -> OutlineTable {
1201    let path_width = rows
1202        .iter()
1203        .map(|row| match row {
1204            OutlineTableRow::File(file_id) => file_entries[*file_id].path.len(),
1205            OutlineTableRow::Rollup(node_id) => directory_nodes[*node_id].path.len() + 1,
1206        })
1207        .max()
1208        .unwrap_or(0);
1209    let language_width = rows
1210        .iter()
1211        .filter_map(|row| match row {
1212            OutlineTableRow::File(file_id) => Some(file_entries[*file_id].language.len()),
1213            OutlineTableRow::Rollup(_) => None,
1214        })
1215        .max()
1216        .unwrap_or("language".len())
1217        .max(8);
1218    let file_middle_width = language_width + 11;
1219    let middle_width = rows
1220        .iter()
1221        .filter_map(|row| match row {
1222            OutlineTableRow::File(_) => None,
1223            OutlineTableRow::Rollup(node_id) => {
1224                Some(directory_rollup_summary(&directory_nodes[*node_id].stats).len())
1225            }
1226        })
1227        .max()
1228        .unwrap_or(0)
1229        .max(file_middle_width);
1230
1231    let mut output = String::new();
1232    for row in rows {
1233        let (path, middle, lines) = match row {
1234            OutlineTableRow::File(file_id) => {
1235                let entry = &file_entries[*file_id];
1236                (
1237                    entry.path.clone(),
1238                    format!(
1239                        "{:<language_width$} {:>5} syms",
1240                        entry.language,
1241                        entry.symbols.unwrap_or(0)
1242                    ),
1243                    entry.lines.map(|lines| lines.to_string()),
1244                )
1245            }
1246            OutlineTableRow::Rollup(node_id) => {
1247                let node = &directory_nodes[*node_id];
1248                (
1249                    format!("{}/", node.path.trim_end_matches('/')),
1250                    directory_rollup_summary(&node.stats),
1251                    Some(node.stats.lines.to_string()),
1252                )
1253            }
1254        };
1255        output.push_str(&format!(
1256            "{path:<path_width$}  {middle:<middle_width$} {lines:>7} lines\n",
1257            lines = lines.as_deref().unwrap_or("-"),
1258        ));
1259    }
1260
1261    let shown = rows
1262        .iter()
1263        .filter(|row| matches!(row, OutlineTableRow::File(_)))
1264        .count();
1265    let mut budget_rollup_files = 0;
1266    let mut budget_rollups_present = false;
1267    for row in rows {
1268        if let OutlineTableRow::Rollup(node_id) = row {
1269            if !directory_is_data_heavy(&directory_nodes[*node_id]) {
1270                budget_rollups_present = true;
1271                budget_rollup_files += directory_nodes[*node_id].stats.files;
1272            }
1273        }
1274    }
1275
1276    let rendered_len = if budget_rollups_present {
1277        let envelope = crate::list_envelope::ListEnvelope::new(
1278            shown,
1279            crate::list_envelope::Total::Exact(shown + budget_rollup_files),
1280            crate::list_envelope::Unit::Files,
1281            vec![crate::list_envelope::Reason::Budget],
1282            &["path"],
1283        );
1284        let trailer_len = crate::list_surfaces::outline::outline_trailer_byte_len(&envelope);
1285        output.len() + 2 + trailer_len
1286    } else {
1287        output.len()
1288    };
1289
1290    OutlineTable {
1291        table: output,
1292        rendered_len,
1293    }
1294}
1295
1296fn directory_rollup_summary(stats: &OutlineDirectoryStats) -> String {
1297    let file_word = if stats.files == 1 { "file" } else { "files" };
1298    let dir_word = if stats.dirs == 1 { "dir" } else { "dirs" };
1299    if stats.dirs == 0 {
1300        format!("{} {file_word}", stats.files)
1301    } else {
1302        format!("{} {file_word}, {} {dir_word}", stats.files, stats.dirs)
1303    }
1304}
1305
1306fn outline_many_files(
1307    files: &[String],
1308    ctx: &AppContext,
1309    req_id: &str,
1310    project_root: Option<&Path>,
1311) -> Result<(Vec<FileOutline>, Vec<SkippedFile>), Response> {
1312    let mut file_outlines: Vec<FileOutline> = Vec::with_capacity(files.len());
1313    let mut skipped_files: Vec<SkippedFile> = Vec::new();
1314
1315    for file in files {
1316        let path = match ctx.validate_path(req_id, Path::new(file)) {
1317            Ok(path) => path,
1318            Err(resp) => return Err(resp),
1319        };
1320        if !path.exists() {
1321            skipped_files.push(SkippedFile::new(file, "file_not_found"));
1322            continue;
1323        }
1324
1325        let rel_path = display_path(&path, file, project_root);
1326        if let Some(reason) = outline_skip_reason(&path) {
1327            skipped_files.push(SkippedFile::new(rel_path, reason));
1328            continue;
1329        }
1330
1331        match ctx.provider().list_symbols(&path) {
1332            Ok(symbols) => {
1333                let entries = build_outline_tree(&symbols);
1334                file_outlines.push(FileOutline {
1335                    path: rel_path,
1336                    entries,
1337                });
1338            }
1339            Err(e) => skipped_files.push(SkippedFile::new(rel_path, outline_error_reason(&e))),
1340        }
1341    }
1342
1343    Ok((file_outlines, skipped_files))
1344}
1345
1346fn discover_outline_files(directory: &Path) -> OutlineFileDiscovery {
1347    let mut discovery = discover_outline_files_with_options(directory, None, false);
1348    if discovery.files.len() > OUTLINE_FILE_WALK_CAP {
1349        discovery.files.truncate(OUTLINE_FILE_WALK_CAP);
1350        discovery.walk_truncated = true;
1351    }
1352    discovery
1353}
1354
1355fn discover_outline_files_with_options(
1356    directory: &Path,
1357    options: Option<&OutlineWalkOptions>,
1358    breadth_first: bool,
1359) -> OutlineFileDiscovery {
1360    let mut files = Vec::new();
1361    let mut directories = Vec::new();
1362    let mut walk_truncated = false;
1363    let mut collection_truncated = false;
1364    let mut skipped_foreign_mounts = 0usize;
1365    // A vanished mounted child can make std::fs::ReadDir::drop panic after
1366    // closedir returns ENXIO, aborting the daemon. Fence recursion before opening
1367    // such a child instead of trying to catch the uncatchable destructor panic.
1368    let boundary = crate::walk_boundary::DeviceBoundary::for_root(directory);
1369    if let Ok(boundary) = boundary {
1370        let mut device_lookup = crate::walk_boundary::filesystem_device_id;
1371        if breadth_first {
1372            collect_outline_files_breadth_first_with_device_lookup(
1373                directory,
1374                &mut files,
1375                &mut directories,
1376                &mut walk_truncated,
1377                &mut collection_truncated,
1378                &mut skipped_foreign_mounts,
1379                options,
1380                &boundary,
1381                &mut device_lookup,
1382            );
1383        } else {
1384            collect_outline_files_with_device_lookup(
1385                directory,
1386                &mut files,
1387                &mut directories,
1388                &mut walk_truncated,
1389                &mut collection_truncated,
1390                &mut skipped_foreign_mounts,
1391                options,
1392                &boundary,
1393                &mut device_lookup,
1394            );
1395        }
1396    } else {
1397        collection_truncated = true;
1398    }
1399    files.sort();
1400    directories.sort();
1401
1402    OutlineFileDiscovery {
1403        files,
1404        directories,
1405        walk_truncated,
1406        collection_truncated,
1407        skipped_foreign_mounts,
1408    }
1409}
1410
1411fn collect_outline_files_with_device_lookup<F>(
1412    directory: &Path,
1413    files: &mut Vec<String>,
1414    directories: &mut Vec<String>,
1415    walk_truncated: &mut bool,
1416    collection_truncated: &mut bool,
1417    skipped_foreign_mounts: &mut usize,
1418    options: Option<&OutlineWalkOptions>,
1419    boundary: &crate::walk_boundary::DeviceBoundary,
1420    device_lookup: &mut F,
1421) where
1422    F: FnMut(&Path) -> std::io::Result<Option<u64>>,
1423{
1424    if files.len() >= OUTLINE_FILE_COLLECTION_CAP {
1425        *walk_truncated = true;
1426        return;
1427    }
1428    let Ok(entries) = std::fs::read_dir(directory) else {
1429        return;
1430    };
1431    let mut entries = entries.flatten().collect::<Vec<_>>();
1432    entries.sort_by_key(|entry| entry.path());
1433
1434    for entry in entries {
1435        if files.len() >= OUTLINE_FILE_COLLECTION_CAP {
1436            *walk_truncated = true;
1437            return;
1438        }
1439        let Ok(file_type) = entry.file_type() else {
1440            continue;
1441        };
1442        if file_type.is_symlink() {
1443            continue;
1444        }
1445        let path = entry.path();
1446        if file_type.is_dir() {
1447            if should_skip_directory(&path) || is_ignored_outline_path(&path, true, options) {
1448                continue;
1449            }
1450            match boundary.should_descend_with(&path, |child| device_lookup(child)) {
1451                Ok(true) => {}
1452                Ok(false) => {
1453                    *skipped_foreign_mounts += 1;
1454                    continue;
1455                }
1456                Err(_) => {
1457                    *collection_truncated = true;
1458                    return;
1459                }
1460            }
1461            directories.push(path.to_string_lossy().to_string());
1462            collect_outline_files_with_device_lookup(
1463                &path,
1464                files,
1465                directories,
1466                walk_truncated,
1467                collection_truncated,
1468                skipped_foreign_mounts,
1469                options,
1470                boundary,
1471                device_lookup,
1472            );
1473            if *walk_truncated || *collection_truncated {
1474                return;
1475            }
1476        } else if file_type.is_file() {
1477            if is_ignored_outline_path(&path, false, options) {
1478                continue;
1479            }
1480            files.push(path.to_string_lossy().to_string());
1481        }
1482    }
1483}
1484
1485fn collect_outline_files_breadth_first_with_device_lookup<F>(
1486    directory: &Path,
1487    files: &mut Vec<String>,
1488    directories: &mut Vec<String>,
1489    walk_truncated: &mut bool,
1490    collection_truncated: &mut bool,
1491    skipped_foreign_mounts: &mut usize,
1492    options: Option<&OutlineWalkOptions>,
1493    boundary: &crate::walk_boundary::DeviceBoundary,
1494    device_lookup: &mut F,
1495) where
1496    F: FnMut(&Path) -> std::io::Result<Option<u64>>,
1497{
1498    let mut pending = VecDeque::from([directory.to_path_buf()]);
1499
1500    while let Some(current) = pending.pop_front() {
1501        if files.len() >= OUTLINE_FILE_COLLECTION_CAP {
1502            *walk_truncated = true;
1503            return;
1504        }
1505        let Ok(entries) = std::fs::read_dir(&current) else {
1506            continue;
1507        };
1508        let mut entries = entries.flatten().collect::<Vec<_>>();
1509        entries.sort_by_key(|entry| entry.path());
1510        let mut child_directories = Vec::new();
1511        let mut child_files = Vec::new();
1512        for entry in entries {
1513            // DirEntry::file_type is one metadata lookup; avoid separate is_dir,
1514            // is_file, and symlink_metadata calls for every repository entry.
1515            let Ok(file_type) = entry.file_type() else {
1516                continue;
1517            };
1518            if file_type.is_symlink() {
1519                continue;
1520            }
1521            if file_type.is_dir() {
1522                child_directories.push(entry.path());
1523            } else if file_type.is_file() {
1524                child_files.push(entry.path());
1525            }
1526        }
1527
1528        // Discover child directories before counting files. A repository with
1529        // one huge schema directory must still expose every sibling at this
1530        // breadth level even when the 10k-file safety fence is reached.
1531        for path in child_directories {
1532            if should_skip_directory(&path) || is_ignored_outline_path(&path, true, options) {
1533                continue;
1534            }
1535            match boundary.should_descend_with(&path, |child| device_lookup(child)) {
1536                Ok(true) => {
1537                    directories.push(path.to_string_lossy().to_string());
1538                    pending.push_back(path);
1539                }
1540                Ok(false) => *skipped_foreign_mounts += 1,
1541                Err(_) => {
1542                    *collection_truncated = true;
1543                    return;
1544                }
1545            }
1546        }
1547
1548        for path in child_files {
1549            if files.len() >= OUTLINE_FILE_COLLECTION_CAP {
1550                *walk_truncated = true;
1551                return;
1552            }
1553            if is_ignored_outline_path(&path, false, options) {
1554                continue;
1555            }
1556            files.push(path.to_string_lossy().to_string());
1557        }
1558    }
1559}
1560
1561fn is_ignored_outline_path(
1562    path: &Path,
1563    is_dir: bool,
1564    options: Option<&OutlineWalkOptions>,
1565) -> bool {
1566    let Some(options) = options else {
1567        return false;
1568    };
1569    let Some(gitignore) = options.gitignore.as_ref() else {
1570        return false;
1571    };
1572
1573    let candidate = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1574    if let Some(root) = options.gitignore_root.as_ref() {
1575        if !candidate.starts_with(root) {
1576            return false;
1577        }
1578    }
1579
1580    gitignore
1581        .matched_path_or_any_parents(candidate, is_dir)
1582        .is_ignore()
1583}
1584
1585fn should_skip_directory(path: &Path) -> bool {
1586    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1587        return false;
1588    };
1589    matches!(
1590        name,
1591        "node_modules"
1592            | ".git"
1593            | "dist"
1594            | "build"
1595            | "out"
1596            | ".next"
1597            | ".nuxt"
1598            | "target"
1599            | "__pycache__"
1600            | ".venv"
1601            | "venv"
1602            | "vendor"
1603            | ".turbo"
1604            | "coverage"
1605            | ".nyc_output"
1606            | ".cache"
1607    ) || name.starts_with('.')
1608}
1609
1610fn display_path(path: &Path, fallback: &str, project_root: Option<&Path>) -> String {
1611    project_root
1612        .and_then(|root| path.strip_prefix(root).ok())
1613        .map(|p| p.to_string_lossy().to_string())
1614        .unwrap_or_else(|| fallback.to_string())
1615}
1616
1617fn outline_skip_reason(path: &Path) -> Option<&'static str> {
1618    if !path.is_file() {
1619        return Some("file_not_found");
1620    }
1621
1622    let metadata = match std::fs::metadata(path) {
1623        Ok(metadata) => metadata,
1624        Err(_) => return Some("file_not_found"),
1625    };
1626    if metadata.len() > MAX_OUTLINE_FILE_BYTES {
1627        return Some("too_large");
1628    }
1629
1630    if detect_language(path).is_none() {
1631        return Some("unsupported_language");
1632    }
1633
1634    // Honest reporting: tree-sitter is fault-tolerant and `list_symbols()` will
1635    // return whatever symbols it can recover from a partially-broken file rather
1636    // than surfacing a parse error. To honor the contract that parse-error files
1637    // land in `skipped_files` (not the rendered outline), we still run
1638    // `validate_syntax()` here. The cost is one extra parse per file, but
1639    // Track 0's parser cache (per-language reused `Parser`, global compiled
1640    // `Query`) makes that parse cheap relative to the full symbol-extraction
1641    // pass that follows.
1642    match edit::validate_syntax(path) {
1643        Ok(Some(false)) => Some("parse_error"),
1644        Ok(Some(true)) | Ok(None) => None,
1645        Err(e) => Some(outline_error_reason(&e)),
1646    }
1647}
1648
1649fn outline_error_reason(error: &AftError) -> &'static str {
1650    match error.code() {
1651        "invalid_request" => "unsupported_language",
1652        "parse_error" => "parse_error",
1653        "file_not_found" => "file_not_found",
1654        "project_too_large" => "too_large",
1655        _ => "error",
1656    }
1657}
1658
1659/// Short kind abbreviation for compact display.
1660fn kind_abbrev(kind: &str) -> &str {
1661    match kind {
1662        "function" => "fn",
1663        "variable" => "var",
1664        "class" => "cls",
1665        "interface" => "ifc",
1666        "type_alias" => "type",
1667        "enum" => "enum",
1668        "method" => "mth",
1669        "property" => "prop",
1670        "struct" => "st",
1671        "heading" => "h",
1672        _ => &kind[..kind.len().min(4)],
1673    }
1674}
1675
1676/// Format a single entry line for multi-file mode (no signature).
1677fn format_entry_compact(entry: &OutlineEntry) -> String {
1678    let vis = if entry.exported { 'E' } else { '-' };
1679    let kind = kind_abbrev(&entry.kind);
1680    // Range is serialized 1-based, but internal Range is 0-based.
1681    // Add 1 to match agent-facing convention.
1682    let sl = entry.range.start_line + 1;
1683    let el = entry.range.end_line + 1;
1684    format!("{} {:<4} {} {}:{}", vis, kind, entry.name, sl, el)
1685}
1686
1687/// Visibility / export keywords that, when present in a signature line, already
1688/// tell the reader whether the symbol is exported: Rust `pub`, Java/C#/Kotlin
1689/// `public`, Solidity `external`, TypeScript `export`, and friends. Used to
1690/// decide whether the exported-ness marker still has to be prefixed to a line.
1691const SIGNATURE_VISIBILITY_KEYWORDS: &[&str] = &[
1692    "pub",
1693    "public",
1694    "export",
1695    "open",
1696    "external",
1697    "internal",
1698    "private",
1699    "protected",
1700];
1701
1702/// True when the signature text already carries a visibility/export keyword, so
1703/// the exported-ness marker need not be repeated as a prefix on the line.
1704fn signature_has_visibility(sig: &str) -> bool {
1705    sig.split_whitespace().any(|token| {
1706        // A modifier may carry a qualifier, e.g. Rust `pub(crate)`; judge the
1707        // keyword ahead of any `(`.
1708        let head = token.split('(').next().unwrap_or(token);
1709        SIGNATURE_VISIBILITY_KEYWORDS.contains(&head)
1710    })
1711}
1712
1713/// Format a single entry line for single-file mode (with signature).
1714///
1715/// When a signature is present it already names the kind (`fn`, `class`, `def`,
1716/// ...) and, for languages such as Rust, the visibility (`pub`). Prefixing the
1717/// line with `{vis} {kind}` would duplicate information already on the line, so
1718/// the prefix is dropped. The one exception is exported-ness the signature text
1719/// does not reveal: a TypeScript `export function f()` parses with `export` on
1720/// the wrapping export_statement (the captured signature is just `function f()`),
1721/// `export { f }` lists and default-export bindings export a symbol whose
1722/// declaration has no `export` at all, and Go marks exports by an uppercase first
1723/// letter rather than a keyword. For exactly those entries — exported, with no
1724/// visibility keyword in the signature — a minimal `E ` marker is kept so
1725/// exported-ness is not silently lost.
1726///
1727/// When there is no signature (the fallback below), the prefix is the only thing
1728/// carrying visibility and kind, so it is retained unchanged.
1729pub(crate) fn format_entry_with_sig(entry: &OutlineEntry) -> String {
1730    let sl = entry.range.start_line + 1;
1731    let el = entry.range.end_line + 1;
1732    if let Some(ref sig) = entry.signature {
1733        if entry.exported && !signature_has_visibility(sig) {
1734            format!("E {} {}:{}", sig, sl, el)
1735        } else {
1736            format!("{} {}:{}", sig, sl, el)
1737        }
1738    } else {
1739        let vis = if entry.exported { 'E' } else { '-' };
1740        let kind = kind_abbrev(&entry.kind);
1741        format!("{} {:<4} {} {}:{}", vis, kind, entry.name, sl, el)
1742    }
1743}
1744
1745/// Render entries recursively with indentation.
1746fn render_entries(entries: &[OutlineEntry], indent: usize, output: &mut String, with_sig: bool) {
1747    let prefix = "  ".repeat(indent);
1748    let member_prefix = "  ".repeat(indent + 1);
1749    for entry in entries {
1750        if with_sig {
1751            output.push_str(&format!("{}{}\n", prefix, format_entry_with_sig(entry)));
1752        } else {
1753            output.push_str(&format!("{}{}\n", prefix, format_entry_compact(entry)));
1754        }
1755        if !entry.members.is_empty() {
1756            for member in &entry.members {
1757                if with_sig {
1758                    output.push_str(&format!(
1759                        "{}.{}\n",
1760                        member_prefix,
1761                        format_entry_with_sig(member)
1762                    ));
1763                } else {
1764                    output.push_str(&format!(
1765                        "{}.{}\n",
1766                        member_prefix,
1767                        format_entry_compact(member)
1768                    ));
1769                }
1770                // Recurse for deeply nested members
1771                if !member.members.is_empty() {
1772                    render_entries(&member.members, indent + 2, output, with_sig);
1773                }
1774            }
1775        }
1776    }
1777}
1778
1779/// Render only top-level entries. Directory/multi-file outlines are a structure
1780/// map; callers can request a specific file when they need methods or fields.
1781fn render_top_level_entries(
1782    entries: &[OutlineEntry],
1783    indent: usize,
1784    output: &mut String,
1785    with_sig: bool,
1786) {
1787    let prefix = "  ".repeat(indent);
1788    for entry in entries {
1789        if with_sig {
1790            output.push_str(&format!("{}{}\n", prefix, format_entry_with_sig(entry)));
1791        } else {
1792            output.push_str(&format!("{}{}\n", prefix, format_entry_compact(entry)));
1793        }
1794    }
1795}
1796
1797/// Format single-file outline as tree text with signatures.
1798fn format_single_file_tree(filename: &str, entries: &[OutlineEntry]) -> String {
1799    let mut output = format!("{}\n", filename);
1800    render_entries(entries, 1, &mut output, true);
1801    output
1802}
1803
1804/// Build a directory tree structure from file paths and render as text.
1805///
1806/// Groups files by directory hierarchy and renders symbols under each file.
1807/// If output exceeds `max_bytes`, truncates with a narrowing hint.
1808fn format_multi_file_tree(
1809    file_outlines: &[FileOutline],
1810    max_bytes: usize,
1811    total_requested: usize,
1812) -> String {
1813    // Build a tree of directories → files → symbols
1814    // Using a simple sorted-path approach with indentation
1815    let mut output = String::new();
1816    let mut truncated = false;
1817    let mut files_shown = 0;
1818
1819    // Sort by path for clean directory grouping
1820    let mut sorted: Vec<&FileOutline> = file_outlines.iter().collect();
1821    sorted.sort_by(|a, b| a.path.cmp(&b.path));
1822
1823    // Track directory nesting via path components
1824    let mut prev_parts: Vec<&str> = Vec::new();
1825
1826    for fo in &sorted {
1827        let parts: Vec<&str> = fo.path.split('/').collect();
1828        let file_name = parts.last().copied().unwrap_or(&fo.path);
1829        let dir_parts = &parts[..parts.len().saturating_sub(1)];
1830
1831        // Find common prefix with previous path
1832        let common = prev_parts
1833            .iter()
1834            .zip(dir_parts.iter())
1835            .take_while(|(a, b)| a == b)
1836            .count();
1837
1838        // Emit new directory levels
1839        for (i, part) in dir_parts.iter().enumerate().skip(common) {
1840            let indent = "  ".repeat(i);
1841            output.push_str(&format!("{}{}/\n", indent, part));
1842        }
1843
1844        // Emit file name
1845        let file_indent = "  ".repeat(dir_parts.len());
1846        output.push_str(&format!("{}{}\n", file_indent, file_name));
1847
1848        // Emit only top-level symbols under each file. Full nested members stay
1849        // available via the single-file outline path.
1850        render_top_level_entries(&fo.entries, dir_parts.len() + 1, &mut output, false);
1851
1852        files_shown += 1;
1853        prev_parts = parts.iter().map(|s| *s).collect();
1854
1855        // Check size cap
1856        if output.len() > max_bytes {
1857            truncated = true;
1858            break;
1859        }
1860    }
1861
1862    if truncated {
1863        output.push_str(&format!(
1864            "\n... truncated ({}/{} files shown, {}KB limit)\n\
1865             Narrow scope with a more specific directory path, or pass a single file as target.\n",
1866            files_shown,
1867            total_requested,
1868            max_bytes / 1024,
1869        ));
1870    }
1871
1872    output
1873}
1874
1875pub(crate) fn symbol_to_entry(sym: &Symbol) -> OutlineEntry {
1876    OutlineEntry {
1877        name: sym.name.clone(),
1878        kind: serde_json::to_value(&sym.kind)
1879            .ok()
1880            .and_then(|v| v.as_str().map(String::from))
1881            .unwrap_or_else(|| format!("{:?}", sym.kind).to_lowercase()),
1882        range: sym.range.clone(),
1883        signature: sym.signature.clone(),
1884        exported: sym.exported,
1885        members: Vec::new(),
1886    }
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891    use super::*;
1892    use crate::symbols::SymbolKind;
1893
1894    #[test]
1895    fn outline_walk_skips_and_reports_injected_foreign_mount() {
1896        let temp = tempfile::tempdir().expect("tempdir");
1897        let root = temp.path().join("root");
1898        let local = root.join("local");
1899        let foreign = root.join("foreign");
1900        std::fs::create_dir_all(&local).expect("create local directory");
1901        std::fs::create_dir_all(&foreign).expect("create foreign directory");
1902        std::fs::write(local.join("keep.rs"), "pub fn keep() {}\n").expect("write local file");
1903        std::fs::write(foreign.join("skip.rs"), "pub fn skip() {}\n").expect("write foreign file");
1904
1905        let boundary = crate::walk_boundary::DeviceBoundary::from_device_for_test(41);
1906        let mut files = Vec::new();
1907        let mut directories = Vec::new();
1908        let mut walk_truncated = false;
1909        let mut collection_truncated = false;
1910        let mut skipped_foreign_mounts = 0usize;
1911        let mut lookup = |path: &Path| {
1912            Ok(Some(
1913                if path.file_name().is_some_and(|name| name == "foreign") {
1914                    99
1915                } else {
1916                    41
1917                },
1918            ))
1919        };
1920
1921        collect_outline_files_with_device_lookup(
1922            &root,
1923            &mut files,
1924            &mut directories,
1925            &mut walk_truncated,
1926            &mut collection_truncated,
1927            &mut skipped_foreign_mounts,
1928            None,
1929            &boundary,
1930            &mut lookup,
1931        );
1932
1933        assert_eq!(skipped_foreign_mounts, 1, "foreign mount is disclosed");
1934        assert!(
1935            !walk_truncated,
1936            "a foreign mount is not the file-count fence"
1937        );
1938        assert!(
1939            !collection_truncated,
1940            "a known foreign mount is not an I/O failure"
1941        );
1942        // Component-based comparison: `files` holds native path strings, so a
1943        // str::ends_with("local/keep.rs") literal would never match Windows
1944        // backslash separators.
1945        let keep = Path::new("local").join("keep.rs");
1946        let skip = Path::new("foreign").join("skip.rs");
1947        assert!(files.iter().any(|path| Path::new(path).ends_with(&keep)));
1948        assert!(
1949            !files.iter().any(|path| Path::new(path).ends_with(&skip)),
1950            "foreign-mount contents must not be traversed"
1951        );
1952    }
1953
1954    fn make_symbol(
1955        name: &str,
1956        kind: SymbolKind,
1957        parent: Option<&str>,
1958        scope_chain: Vec<&str>,
1959        exported: bool,
1960    ) -> Symbol {
1961        Symbol {
1962            name: name.to_string(),
1963            kind,
1964            range: Range {
1965                start_line: 0,
1966                start_col: 0,
1967                end_line: 0,
1968                end_col: 0,
1969            },
1970            signature: None,
1971            scope_chain: scope_chain.into_iter().map(String::from).collect(),
1972            exported,
1973            parent: parent.map(String::from),
1974        }
1975    }
1976
1977    fn build_outline_tree_reference(symbols: &[Symbol]) -> Vec<OutlineEntry> {
1978        let mut top_level = Vec::new();
1979        let mut children = Vec::new();
1980
1981        for sym in symbols {
1982            if sym.parent.is_none() {
1983                top_level.push(symbol_to_entry(sym));
1984            } else {
1985                children.push(sym);
1986            }
1987        }
1988
1989        for child in children {
1990            let entry = symbol_to_entry(child);
1991            let scope = &child.scope_chain;
1992            if scope.is_empty() {
1993                top_level.push(entry);
1994                continue;
1995            }
1996            if !insert_at_scope_reference(&mut top_level, scope, entry.clone()) {
1997                let parent_scope = child.parent.as_ref().map(std::slice::from_ref);
1998                if !parent_scope.is_some_and(|scope| {
1999                    insert_at_scope_reference(&mut top_level, scope, entry.clone())
2000                }) {
2001                    top_level.push(entry);
2002                }
2003            }
2004        }
2005
2006        top_level
2007    }
2008
2009    // Frozen pre-index implementation used as the differential oracle.
2010    fn insert_at_scope_reference(
2011        entries: &mut Vec<OutlineEntry>,
2012        scope_chain: &[String],
2013        entry: OutlineEntry,
2014    ) -> bool {
2015        if scope_chain.is_empty() {
2016            return false;
2017        }
2018
2019        let target_name = &scope_chain[0];
2020        for existing in entries {
2021            if existing.name == *target_name {
2022                if scope_chain.len() == 1 {
2023                    existing.members.push(entry);
2024                    return true;
2025                }
2026                return insert_at_scope_reference(&mut existing.members, &scope_chain[1..], entry);
2027            }
2028        }
2029        false
2030    }
2031
2032    fn assert_indexed_matches_reference(case: &str, symbols: &[Symbol]) -> Vec<OutlineEntry> {
2033        let actual = build_outline_tree(symbols);
2034        let expected = build_outline_tree_reference(symbols);
2035        assert_eq!(
2036            serde_json::to_vec(&actual).expect("serialize indexed outline"),
2037            serde_json::to_vec(&expected).expect("serialize reference outline"),
2038            "indexed outline diverged for {case}"
2039        );
2040        actual
2041    }
2042
2043    fn parsed_symbols(extension: &str, source: &str) -> Vec<Symbol> {
2044        use crate::parser::FileParser;
2045
2046        let dir = tempfile::tempdir().expect("tempdir");
2047        let path = dir.path().join(format!("fixture.{extension}"));
2048        std::fs::write(&path, source).expect("write parser fixture");
2049        FileParser::new()
2050            .extract_symbols(&path)
2051            .expect("extract fixture symbols")
2052    }
2053
2054    #[test]
2055    fn indexed_outline_matches_reference_for_first_match_and_insertion_order() {
2056        let mut symbols = vec![make_symbol(
2057            "Duplicate",
2058            SymbolKind::Class,
2059            None,
2060            vec![],
2061            false,
2062        )];
2063        for index in 0..OUTLINE_SCOPE_INDEX_THRESHOLD {
2064            symbols.push(make_symbol(
2065                &format!("Filler{index}"),
2066                SymbolKind::Class,
2067                None,
2068                vec![],
2069                false,
2070            ));
2071        }
2072        symbols.extend([
2073            make_symbol("Duplicate", SymbolKind::Class, None, vec![], true),
2074            make_symbol(
2075                "firstChild",
2076                SymbolKind::Method,
2077                Some("Duplicate"),
2078                vec!["Duplicate"],
2079                false,
2080            ),
2081            make_symbol(
2082                "secondChild",
2083                SymbolKind::Method,
2084                Some("Duplicate"),
2085                vec!["Duplicate"],
2086                false,
2087            ),
2088        ]);
2089
2090        let tree = assert_indexed_matches_reference("duplicate siblings", &symbols);
2091        let duplicates = tree
2092            .iter()
2093            .filter(|entry| entry.name == "Duplicate")
2094            .collect::<Vec<_>>();
2095        assert_eq!(
2096            duplicates[0]
2097                .members
2098                .iter()
2099                .map(|entry| entry.name.as_str())
2100                .collect::<Vec<_>>(),
2101            ["firstChild", "secondChild"]
2102        );
2103        assert!(
2104            duplicates[1].members.is_empty(),
2105            "second duplicate stays unused"
2106        );
2107    }
2108
2109    #[test]
2110    fn indexed_outline_matches_reference_for_dynamic_deep_parents() {
2111        let symbols = vec![
2112            make_symbol("Outer", SymbolKind::Class, None, vec![], false),
2113            // The parent does not exist yet, so established ordering semantics
2114            // promote this entry and never reparent it later.
2115            make_symbol(
2116                "earlyLeaf",
2117                SymbolKind::Method,
2118                Some("Inner"),
2119                vec!["Outer", "Inner"],
2120                false,
2121            ),
2122            make_symbol(
2123                "Inner",
2124                SymbolKind::Class,
2125                Some("Outer"),
2126                vec!["Outer"],
2127                false,
2128            ),
2129            make_symbol(
2130                "lateLeaf",
2131                SymbolKind::Method,
2132                Some("Inner"),
2133                vec!["Outer", "Inner"],
2134                false,
2135            ),
2136            make_symbol(
2137                "Deep",
2138                SymbolKind::Class,
2139                Some("Inner"),
2140                vec!["Outer", "Inner"],
2141                false,
2142            ),
2143            make_symbol(
2144                "deepLeaf",
2145                SymbolKind::Method,
2146                Some("Deep"),
2147                vec!["Outer", "Inner", "Deep"],
2148                false,
2149            ),
2150        ];
2151
2152        let tree = assert_indexed_matches_reference("deep dynamic parents", &symbols);
2153        assert_eq!(tree[1].name, "earlyLeaf");
2154        let inner = &tree[0].members[0];
2155        assert_eq!(inner.name, "Inner");
2156        assert_eq!(inner.members[0].name, "lateLeaf");
2157        assert_eq!(inner.members[1].members[0].name, "deepLeaf");
2158    }
2159
2160    #[test]
2161    fn indexed_outline_matches_reference_for_fallbacks_and_orphans() {
2162        let symbols = vec![
2163            make_symbol("Widget", SymbolKind::Struct, None, vec![], true),
2164            make_symbol(
2165                "fmt",
2166                SymbolKind::Method,
2167                Some("Widget"),
2168                vec!["Display for Widget"],
2169                true,
2170            ),
2171            make_symbol(
2172                "Orphan",
2173                SymbolKind::Class,
2174                Some("Missing"),
2175                vec!["Missing"],
2176                false,
2177            ),
2178            make_symbol(
2179                "adoptedLater",
2180                SymbolKind::Method,
2181                Some("Orphan"),
2182                vec!["Orphan"],
2183                false,
2184            ),
2185        ];
2186
2187        let tree = assert_indexed_matches_reference("fallback and orphan ladder", &symbols);
2188        assert_eq!(tree[0].members[0].name, "fmt");
2189        assert_eq!(tree[1].name, "Orphan");
2190        assert_eq!(tree[1].members[0].name, "adoptedLater");
2191    }
2192
2193    #[test]
2194    fn indexed_outline_matches_reference_at_scale() {
2195        const PARENTS: usize = 2_048;
2196        let mut symbols = Vec::with_capacity(PARENTS * 2);
2197        for index in 0..PARENTS {
2198            symbols.push(make_symbol(
2199                &format!("Container{index:04}"),
2200                SymbolKind::Class,
2201                None,
2202                vec![],
2203                true,
2204            ));
2205        }
2206        for index in 0..PARENTS {
2207            let parent = format!("Container{index:04}");
2208            symbols.push(make_symbol(
2209                &format!("method{index:04}"),
2210                SymbolKind::Method,
2211                Some(&parent),
2212                vec![&parent],
2213                false,
2214            ));
2215        }
2216
2217        assert_indexed_matches_reference("one child per parent at scale", &symbols);
2218    }
2219
2220    #[test]
2221    fn indexed_outline_matches_reference_for_typescript_and_python() {
2222        let typescript = parsed_symbols(
2223            "ts",
2224            "class Outer {\n  method(): void {}\n  classField = 1;\n}\n",
2225        );
2226        assert!(
2227            typescript.iter().any(|symbol| symbol.parent.is_some()),
2228            "TypeScript fixture must exercise child insertion"
2229        );
2230        assert_indexed_matches_reference("TypeScript parser output", &typescript);
2231
2232        let python = parsed_symbols(
2233            "py",
2234            "class Outer:\n    class Inner:\n        def leaf(self):\n            pass\n\n    def outer(self):\n        pass\n",
2235        );
2236        assert!(
2237            python.iter().any(|symbol| symbol.parent.is_some()),
2238            "Python fixture must exercise child insertion"
2239        );
2240        assert_indexed_matches_reference("Python parser output", &python);
2241    }
2242
2243    #[test]
2244    fn flat_symbols_stay_flat() {
2245        let symbols = vec![
2246            make_symbol("greet", SymbolKind::Function, None, vec![], true),
2247            make_symbol("Config", SymbolKind::Interface, None, vec![], true),
2248        ];
2249        let tree = build_outline_tree(&symbols);
2250        assert_eq!(tree.len(), 2);
2251        assert!(tree[0].members.is_empty());
2252        assert!(tree[1].members.is_empty());
2253    }
2254
2255    #[test]
2256    fn methods_nest_under_class() {
2257        let symbols = vec![
2258            make_symbol("UserService", SymbolKind::Class, None, vec![], true),
2259            make_symbol(
2260                "getUser",
2261                SymbolKind::Method,
2262                Some("UserService"),
2263                vec!["UserService"],
2264                false,
2265            ),
2266            make_symbol(
2267                "addUser",
2268                SymbolKind::Method,
2269                Some("UserService"),
2270                vec!["UserService"],
2271                false,
2272            ),
2273        ];
2274        let tree = build_outline_tree(&symbols);
2275        assert_eq!(tree.len(), 1, "methods should not appear at top level");
2276        assert_eq!(tree[0].name, "UserService");
2277        assert_eq!(tree[0].members.len(), 2);
2278        assert_eq!(tree[0].members[0].name, "getUser");
2279        assert_eq!(tree[0].members[1].name, "addUser");
2280    }
2281
2282    #[test]
2283    fn parent_fallback_nests_trait_impl_methods_under_type() {
2284        let symbols = vec![
2285            make_symbol("Widget", SymbolKind::Struct, None, vec![], true),
2286            make_symbol(
2287                "fmt",
2288                SymbolKind::Method,
2289                Some("Widget"),
2290                vec!["Display for Widget"],
2291                true,
2292            ),
2293        ];
2294        let tree = build_outline_tree(&symbols);
2295        assert_eq!(
2296            tree.len(),
2297            1,
2298            "trait impl method should nest under parent type"
2299        );
2300        assert_eq!(tree[0].name, "Widget");
2301        assert_eq!(tree[0].members.len(), 1);
2302        assert_eq!(tree[0].members[0].name, "fmt");
2303    }
2304
2305    #[test]
2306    fn methods_not_duplicated_at_top_level() {
2307        let symbols = vec![
2308            make_symbol("Foo", SymbolKind::Class, None, vec![], false),
2309            make_symbol("bar", SymbolKind::Method, Some("Foo"), vec!["Foo"], false),
2310        ];
2311        let tree = build_outline_tree(&symbols);
2312        // "bar" must NOT appear at top level
2313        assert!(
2314            tree.iter().all(|e| e.name != "bar"),
2315            "method should not be at top level"
2316        );
2317        assert_eq!(tree[0].members.len(), 1);
2318    }
2319
2320    #[test]
2321    fn multi_level_nesting_python() {
2322        // OuterClass → InnerClass → inner_method
2323        let symbols = vec![
2324            make_symbol("OuterClass", SymbolKind::Class, None, vec![], false),
2325            make_symbol(
2326                "InnerClass",
2327                SymbolKind::Class,
2328                Some("OuterClass"),
2329                vec!["OuterClass"],
2330                false,
2331            ),
2332            make_symbol(
2333                "inner_method",
2334                SymbolKind::Method,
2335                Some("InnerClass"),
2336                vec!["OuterClass", "InnerClass"],
2337                false,
2338            ),
2339            make_symbol(
2340                "outer_method",
2341                SymbolKind::Method,
2342                Some("OuterClass"),
2343                vec!["OuterClass"],
2344                false,
2345            ),
2346        ];
2347        let tree = build_outline_tree(&symbols);
2348        assert_eq!(tree.len(), 1, "only OuterClass at top level");
2349
2350        let outer = &tree[0];
2351        assert_eq!(outer.name, "OuterClass");
2352        assert_eq!(outer.members.len(), 2, "InnerClass + outer_method");
2353
2354        let inner = outer
2355            .members
2356            .iter()
2357            .find(|m| m.name == "InnerClass")
2358            .unwrap();
2359        assert_eq!(inner.members.len(), 1);
2360        assert_eq!(inner.members[0].name, "inner_method");
2361    }
2362
2363    #[test]
2364    fn all_symbol_kinds_handled() {
2365        let symbols = vec![
2366            make_symbol("f", SymbolKind::Function, None, vec![], false),
2367            make_symbol("C", SymbolKind::Class, None, vec![], false),
2368            make_symbol("m", SymbolKind::Method, Some("C"), vec!["C"], false),
2369            make_symbol("S", SymbolKind::Struct, None, vec![], false),
2370            make_symbol("I", SymbolKind::Interface, None, vec![], false),
2371            make_symbol("E", SymbolKind::Enum, None, vec![], false),
2372            make_symbol("T", SymbolKind::TypeAlias, None, vec![], false),
2373        ];
2374        let tree = build_outline_tree(&symbols);
2375
2376        // 6 top-level (method is nested under class)
2377        assert_eq!(tree.len(), 6);
2378
2379        let kinds: Vec<&str> = tree.iter().map(|e| e.kind.as_str()).collect();
2380        assert!(kinds.contains(&"function"));
2381        assert!(kinds.contains(&"class"));
2382        assert!(kinds.contains(&"struct"));
2383        assert!(kinds.contains(&"interface"));
2384        assert!(kinds.contains(&"enum"));
2385        assert!(kinds.contains(&"type_alias"));
2386
2387        // Method under class
2388        let class_entry = tree.iter().find(|e| e.name == "C").unwrap();
2389        assert_eq!(class_entry.members.len(), 1);
2390        assert_eq!(class_entry.members[0].kind, "method");
2391    }
2392
2393    #[test]
2394    fn exported_flag_preserved() {
2395        let symbols = vec![
2396            make_symbol("exported_fn", SymbolKind::Function, None, vec![], true),
2397            make_symbol("internal_fn", SymbolKind::Function, None, vec![], false),
2398        ];
2399        let tree = build_outline_tree(&symbols);
2400        let exported = tree.iter().find(|e| e.name == "exported_fn").unwrap();
2401        let internal = tree.iter().find(|e| e.name == "internal_fn").unwrap();
2402        assert!(exported.exported);
2403        assert!(!internal.exported);
2404    }
2405
2406    #[test]
2407    fn orphan_child_promoted_to_top_level() {
2408        // A method whose parent doesn't exist in the list
2409        let symbols = vec![make_symbol(
2410            "orphan",
2411            SymbolKind::Method,
2412            Some("MissingParent"),
2413            vec!["MissingParent"],
2414            false,
2415        )];
2416        let tree = build_outline_tree(&symbols);
2417        assert_eq!(tree.len(), 1, "orphan should be promoted to top level");
2418        assert_eq!(tree[0].name, "orphan");
2419    }
2420
2421    fn sig_entry(
2422        name: &str,
2423        kind: &str,
2424        signature: Option<&str>,
2425        exported: bool,
2426        start_line: u32,
2427        end_line: u32,
2428    ) -> OutlineEntry {
2429        OutlineEntry {
2430            name: name.to_string(),
2431            kind: kind.to_string(),
2432            range: Range {
2433                start_line,
2434                start_col: 0,
2435                end_line,
2436                end_col: 0,
2437            },
2438            signature: signature.map(String::from),
2439            exported,
2440            members: Vec::new(),
2441        }
2442    }
2443
2444    #[test]
2445    fn signature_lines_drop_the_redundant_vis_kind_prefix() {
2446        // Rust `pub fn`: visibility and kind are both in the signature, so no prefix.
2447        assert_eq!(
2448            format_entry_with_sig(&sig_entry(
2449                "resolve",
2450                "function",
2451                Some("pub fn resolve(id: u64) -> Result<()>"),
2452                true,
2453                9,
2454                20,
2455            )),
2456            "pub fn resolve(id: u64) -> Result<()> 10:21"
2457        );
2458        // Rust private `fn`: not exported, signature carries the kind; no prefix.
2459        assert_eq!(
2460            format_entry_with_sig(&sig_entry(
2461                "helper",
2462                "function",
2463                Some("fn helper()"),
2464                false,
2465                0,
2466                2
2467            )),
2468            "fn helper() 1:3"
2469        );
2470        // Python `def`: no export concept, so no marker at all.
2471        assert_eq!(
2472            format_entry_with_sig(&sig_entry(
2473                "compute",
2474                "function",
2475                Some("def compute(value):"),
2476                false,
2477                4,
2478                7,
2479            )),
2480            "def compute(value): 5:8"
2481        );
2482    }
2483
2484    #[test]
2485    fn exported_without_visibility_keyword_keeps_a_minimal_marker() {
2486        // TypeScript `export function greet()` parses with `export` on the wrapping
2487        // export_statement, so the captured signature is just `function greet()`;
2488        // the `E` marker is the only signal of exported-ness and must be kept.
2489        assert_eq!(
2490            format_entry_with_sig(&sig_entry(
2491                "greet",
2492                "function",
2493                Some("function greet(name: string): string"),
2494                true,
2495                0,
2496                2,
2497            )),
2498            "E function greet(name: string): string 1:3"
2499        );
2500        // Go exports by an uppercase first letter, with no keyword in the signature.
2501        assert_eq!(
2502            format_entry_with_sig(&sig_entry(
2503                "Parse",
2504                "function",
2505                Some("func Parse(input string) (*Tree, error)"),
2506                true,
2507                0,
2508                5,
2509            )),
2510            "E func Parse(input string) (*Tree, error) 1:6"
2511        );
2512        // A signature that already carries a visibility keyword needs no marker,
2513        // even when exported.
2514        assert_eq!(
2515            format_entry_with_sig(&sig_entry(
2516                "run",
2517                "method",
2518                Some("public void run()"),
2519                true,
2520                0,
2521                1
2522            )),
2523            "public void run() 1:2"
2524        );
2525    }
2526
2527    #[test]
2528    fn no_signature_fallback_keeps_the_vis_kind_prefix() {
2529        // Without a signature the prefix is the only carrier of visibility and
2530        // kind, so it is retained verbatim.
2531        assert_eq!(
2532            format_entry_with_sig(&sig_entry("answer", "variable", None, true, 0, 0)),
2533            "E var  answer 1:1"
2534        );
2535        assert_eq!(
2536            format_entry_with_sig(&sig_entry("local", "variable", None, false, 1, 1)),
2537            "- var  local 2:2"
2538        );
2539    }
2540
2541    #[test]
2542    fn dropping_the_prefix_removes_exactly_the_prefix_bytes() {
2543        // Golden fixture: the new line must be the old line minus the
2544        // `{vis} {kind:<4} ` prefix, with the signature and range bytes — and
2545        // therefore the line positions — left untouched.
2546        let entry = sig_entry(
2547            "resolve",
2548            "function",
2549            Some("pub fn resolve(id: u64)"),
2550            true,
2551            9,
2552            20,
2553        );
2554        let new = format_entry_with_sig(&entry);
2555        let old = format!("E {:<4} {} {}:{}", "fn", "pub fn resolve(id: u64)", 10, 21);
2556        assert_eq!(old, "E fn   pub fn resolve(id: u64) 10:21");
2557        assert_eq!(new, "pub fn resolve(id: u64) 10:21");
2558        // Exactly the prefix bytes are removed; nothing else moves. Reintroducing
2559        // the unconditional prefix makes new == old and this delta drops to zero.
2560        assert_eq!(old.len() - new.len(), "E fn   ".len());
2561        assert!(
2562            old.ends_with(&new),
2563            "only the prefix may change: {old:?} -> {new:?}"
2564        );
2565    }
2566
2567    #[test]
2568    fn signature_visibility_detection() {
2569        assert!(signature_has_visibility("pub fn f()"));
2570        assert!(signature_has_visibility("pub(crate) fn f()"));
2571        assert!(signature_has_visibility("public void f()"));
2572        assert!(signature_has_visibility("export function f()"));
2573        assert!(signature_has_visibility("external function f()"));
2574        // A symbol name that merely contains a keyword substring is not a marker.
2575        assert!(!signature_has_visibility("fn publish()"));
2576        assert!(!signature_has_visibility(
2577            "function greet(name: string): string"
2578        ));
2579        assert!(!signature_has_visibility("def compute(value):"));
2580        assert!(!signature_has_visibility("func Parse(input string)"));
2581    }
2582
2583    fn outline_file_entry_for_test(
2584        path: &str,
2585        language: &str,
2586        symbols: usize,
2587        lines: Option<usize>,
2588        data_doc: bool,
2589    ) -> OutlineFileEntry {
2590        OutlineFileEntry {
2591            path: path.to_string(),
2592            language: language.to_string(),
2593            symbols: Some(symbols),
2594            lines,
2595            absolute_path: PathBuf::from(path),
2596            data_doc,
2597        }
2598    }
2599
2600    #[test]
2601    fn outline_file_line_count_matches_text_and_binary_contract() {
2602        let temp = tempfile::tempdir().expect("tempdir");
2603        let terminated = temp.path().join("terminated.txt");
2604        let unterminated = temp.path().join("unterminated.txt");
2605        let empty = temp.path().join("empty.txt");
2606        let binary = temp.path().join("binary.dat");
2607        std::fs::write(&terminated, b"a\nb\nc\n").expect("write terminated");
2608        std::fs::write(&unterminated, b"a\nb\nc").expect("write unterminated");
2609        std::fs::write(&empty, b"").expect("write empty");
2610        std::fs::write(&binary, [0, 159, 146, 150, 0, 1]).expect("write binary");
2611
2612        assert_eq!(
2613            inspect_outline_file_content(&terminated)
2614                .expect("inspect terminated")
2615                .lines,
2616            Some(3)
2617        );
2618        assert_eq!(
2619            inspect_outline_file_content(&unterminated)
2620                .expect("inspect unterminated")
2621                .lines,
2622            Some(3)
2623        );
2624        assert_eq!(
2625            inspect_outline_file_content(&empty)
2626                .expect("inspect empty")
2627                .lines,
2628            Some(0)
2629        );
2630        let binary_stats = inspect_outline_file_content(&binary).expect("inspect binary");
2631        assert!(binary_stats.binary);
2632        assert_eq!(binary_stats.lines, None);
2633    }
2634
2635    #[test]
2636    fn outline_rows_put_code_before_data_files() {
2637        let files = vec![
2638            outline_file_entry_for_test("docs/readme.md", "markdown", 1, Some(4), true),
2639            outline_file_entry_for_test("docs/lib.rs", "rust", 2, Some(8), false),
2640        ];
2641        let mut directories = vec![OutlineDirectoryNode {
2642            path: String::new(),
2643            depth: 0,
2644            direct_files: vec![0, 1],
2645            children: Vec::new(),
2646            stats: OutlineDirectoryStats::default(),
2647        }];
2648        aggregate_outline_directory(0, &mut directories, &files);
2649
2650        assert_eq!(
2651            plan_outline_file_rows(&[0], &directories, &files, 30 * 1024),
2652            vec![OutlineTableRow::File(1), OutlineTableRow::File(0)]
2653        );
2654    }
2655
2656    #[test]
2657    fn data_only_leaf_stays_one_rollup_with_summed_lines() {
2658        let files = (0..3)
2659            .map(|index| {
2660                outline_file_entry_for_test(
2661                    &format!("schema/json/{index}.json"),
2662                    "json",
2663                    0,
2664                    Some(2),
2665                    true,
2666                )
2667            })
2668            .collect::<Vec<_>>();
2669        let mut directories = vec![
2670            OutlineDirectoryNode {
2671                path: String::new(),
2672                depth: 0,
2673                direct_files: Vec::new(),
2674                children: vec![1],
2675                stats: OutlineDirectoryStats::default(),
2676            },
2677            OutlineDirectoryNode {
2678                path: "schema".to_string(),
2679                depth: 1,
2680                direct_files: Vec::new(),
2681                children: vec![2],
2682                stats: OutlineDirectoryStats::default(),
2683            },
2684            OutlineDirectoryNode {
2685                path: "schema/json".to_string(),
2686                depth: 2,
2687                direct_files: vec![0, 1, 2],
2688                children: Vec::new(),
2689                stats: OutlineDirectoryStats::default(),
2690            },
2691        ];
2692        aggregate_outline_directory(0, &mut directories, &files);
2693
2694        let rows = plan_outline_file_rows(&[0], &directories, &files, 30 * 1024);
2695        assert_eq!(rows, vec![OutlineTableRow::Rollup(2)]);
2696        let text = format_files_table(&rows, &directories, &files, 30 * 1024);
2697        assert!(text.contains("schema/json/"));
2698        assert!(text.contains("3 files"));
2699        assert!(text.contains("6 lines"));
2700        assert!(!text.contains("syms"), "rollup row: {text}");
2701        assert!(!text.contains("shown as a rollup"));
2702        assert!(!text.contains(".json  "));
2703    }
2704
2705    #[test]
2706    fn cheapest_same_level_expansion_buys_breadth_before_large_directory() {
2707        let files = vec![
2708            outline_file_entry_for_test(
2709                "z-small/src/long-breadth-marker/lib.rs",
2710                "rust",
2711                1,
2712                Some(1),
2713                false,
2714            ),
2715            outline_file_entry_for_test("a-large/a.rs", "rust", 1, Some(1), false),
2716            outline_file_entry_for_test("a-large/b.rs", "rust", 1, Some(1), false),
2717            outline_file_entry_for_test("docs/readme.md", "markdown", 0, Some(1), true),
2718        ];
2719        let mut directories = vec![
2720            OutlineDirectoryNode {
2721                path: String::new(),
2722                depth: 0,
2723                direct_files: Vec::new(),
2724                children: vec![1, 2, 3],
2725                stats: OutlineDirectoryStats::default(),
2726            },
2727            OutlineDirectoryNode {
2728                path: "z-small".to_string(),
2729                depth: 1,
2730                direct_files: Vec::new(),
2731                children: vec![4],
2732                stats: OutlineDirectoryStats::default(),
2733            },
2734            OutlineDirectoryNode {
2735                path: "a-large".to_string(),
2736                depth: 1,
2737                direct_files: vec![1, 2],
2738                children: Vec::new(),
2739                stats: OutlineDirectoryStats::default(),
2740            },
2741            OutlineDirectoryNode {
2742                path: "docs".to_string(),
2743                depth: 1,
2744                direct_files: vec![3],
2745                children: Vec::new(),
2746                stats: OutlineDirectoryStats::default(),
2747            },
2748            OutlineDirectoryNode {
2749                path: "z-small/src/long-breadth-marker".to_string(),
2750                depth: 2,
2751                direct_files: vec![0],
2752                children: Vec::new(),
2753                stats: OutlineDirectoryStats::default(),
2754            },
2755        ];
2756        aggregate_outline_directory(0, &mut directories, &files);
2757        let small_only = vec![
2758            OutlineTableRow::Rollup(2),
2759            OutlineTableRow::Rollup(3),
2760            OutlineTableRow::Rollup(4),
2761        ];
2762        let large_only = vec![
2763            OutlineTableRow::File(1),
2764            OutlineTableRow::File(2),
2765            OutlineTableRow::Rollup(3),
2766            OutlineTableRow::Rollup(1),
2767        ];
2768        let both = vec![
2769            OutlineTableRow::File(1),
2770            OutlineTableRow::File(2),
2771            OutlineTableRow::Rollup(3),
2772            OutlineTableRow::Rollup(4),
2773        ];
2774        let mut budget = 0;
2775        for _ in 0..8 {
2776            let required = format_files_table(&small_only, &directories, &files, budget)
2777                .len()
2778                .max(format_files_table(&large_only, &directories, &files, budget).len());
2779            if required == budget {
2780                break;
2781            }
2782            budget = required;
2783        }
2784        assert!(format_files_table(&small_only, &directories, &files, budget).len() <= budget);
2785        assert!(format_files_table(&large_only, &directories, &files, budget).len() <= budget);
2786        assert!(format_files_table(&both, &directories, &files, budget).len() > budget);
2787
2788        assert_eq!(
2789            plan_outline_file_rows(&[0], &directories, &files, budget),
2790            small_only
2791        );
2792    }
2793
2794    #[test]
2795    fn top_level_rows_are_never_cut_by_the_budget() {
2796        let files = vec![
2797            outline_file_entry_for_test("a/lib.rs", "rust", 1, Some(1), false),
2798            outline_file_entry_for_test("b/lib.rs", "rust", 1, Some(1), false),
2799        ];
2800        let mut directories = vec![
2801            OutlineDirectoryNode {
2802                path: String::new(),
2803                depth: 0,
2804                direct_files: Vec::new(),
2805                children: vec![1, 2],
2806                stats: OutlineDirectoryStats::default(),
2807            },
2808            OutlineDirectoryNode {
2809                path: "a".to_string(),
2810                depth: 1,
2811                direct_files: vec![0],
2812                children: Vec::new(),
2813                stats: OutlineDirectoryStats::default(),
2814            },
2815            OutlineDirectoryNode {
2816                path: "b".to_string(),
2817                depth: 1,
2818                direct_files: vec![1],
2819                children: Vec::new(),
2820                stats: OutlineDirectoryStats::default(),
2821            },
2822        ];
2823        aggregate_outline_directory(0, &mut directories, &files);
2824
2825        let rows = plan_outline_file_rows(&[0], &directories, &files, 1);
2826        assert_eq!(
2827            rows,
2828            vec![OutlineTableRow::Rollup(1), OutlineTableRow::Rollup(2)]
2829        );
2830        let text = format_files_table(&rows, &directories, &files, 1);
2831        assert!(text.contains("a/"));
2832        assert!(text.contains("b/"));
2833        assert!(
2834            text.len() > 1,
2835            "level zero deliberately exceeds a tiny budget"
2836        );
2837    }
2838
2839    #[test]
2840    fn binary_file_row_uses_a_dash_for_lines() {
2841        let files = vec![outline_file_entry_for_test(
2842            "assets/blob.dat",
2843            "binary",
2844            0,
2845            None,
2846            true,
2847        )];
2848        let text = format_files_table(&[OutlineTableRow::File(0)], &[], &files, 30 * 1024);
2849        assert!(text.contains("      - lines"), "binary row: {text}");
2850    }
2851
2852    /// Manual release-mode probe for the directory walk paid by one outline
2853    /// `files: true` request on a realistic 10k-file monorepo.
2854    #[test]
2855    #[ignore = "manual release-mode outline files performance probe"]
2856    fn outline_files_walk_perf_probe() {
2857        const DIRECTORIES: usize = 100;
2858        const FILES_PER_DIRECTORY: usize = 100;
2859        const SAMPLES: usize = 9;
2860        const ITERATIONS: usize = 3;
2861
2862        let temp = tempfile::tempdir().expect("tempdir");
2863        for directory in 0..DIRECTORIES {
2864            let path = temp.path().join(format!("package-{directory:03}/src"));
2865            std::fs::create_dir_all(&path).expect("create package directory");
2866            for file in 0..FILES_PER_DIRECTORY {
2867                std::fs::write(
2868                    path.join(format!("module-{file:03}.ts")),
2869                    b"export const value = 1;\n",
2870                )
2871                .expect("write source fixture");
2872            }
2873        }
2874
2875        let discovery = discover_outline_files(temp.path());
2876        assert_eq!(discovery.files.len(), OUTLINE_FILE_WALK_CAP);
2877        assert!(discovery.walk_truncated);
2878
2879        let mut micros_per_operation = Vec::with_capacity(SAMPLES);
2880        for _ in 0..SAMPLES {
2881            let started = std::time::Instant::now();
2882            for _ in 0..ITERATIONS {
2883                let discovery = discover_outline_files(std::hint::black_box(temp.path()));
2884                std::hint::black_box(discovery);
2885            }
2886            micros_per_operation.push(started.elapsed().as_micros() / ITERATIONS as u128);
2887        }
2888        micros_per_operation.sort_unstable();
2889        let median = micros_per_operation[SAMPLES / 2];
2890
2891        eprintln!(
2892            "outline files walk: files={} samples={SAMPLES} iterations={ITERATIONS}",
2893            DIRECTORIES * FILES_PER_DIRECTORY
2894        );
2895        eprintln!("microseconds per outline operation: {micros_per_operation:?}");
2896        eprintln!("median: {median}us per outline operation");
2897    }
2898}