use crate::tools::CrpMode;
use lean_ctx::core::signatures::extract_signatures_with_backend;
use lean_ctx::core::tokens::count_tokens;
use super::{FileSymbols, OutlineOpts, filter_signatures, render_one};
const MAX_FILES: usize = 600;
const MAX_FILE_BYTES: u64 = 1_500_000;
const MAX_TOTAL_BYTES: usize = 256 * 1024 * 1024;
struct Collected {
files: Vec<FileSymbols>,
total_tokens: usize,
truncated: bool,
budget_exhausted: bool,
withheld: usize,
}
fn withheld_note(withheld: usize) -> String {
format!("[{withheld} file(s) withheld by policy — not outlined]")
}
pub(super) fn outline_dir(dir: &str, root: &str, opts: &OutlineOpts) -> (String, usize) {
let Collected {
files,
total_tokens,
truncated,
budget_exhausted,
withheld,
} = collect(dir, root, opts);
if opts.as_json {
return (
super::json::dir_json(dir, &files, truncated || budget_exhausted, withheld),
total_tokens,
);
}
if files.is_empty() {
let message = super::no_match_message(dir, opts);
if withheld > 0 {
return (format!("{message}\n{}", withheld_note(withheld)), 0);
}
return (message, 0);
}
let crp = CrpMode::effective();
let mut out = String::new();
for f in &files {
out.push_str(&f.rel);
out.push('\n');
for s in &f.sigs {
out.push_str(" ");
out.push_str(&render_one(s, crp));
out.push('\n');
}
}
if truncated {
out.push_str(&format!(
"[truncated at {MAX_FILES} files — narrow the path for the rest]\n"
));
}
if budget_exhausted {
out.push_str("[read budget exhausted — narrow the path for the rest]\n");
}
if withheld > 0 {
out.push_str(&withheld_note(withheld));
out.push('\n');
}
out.push_str(lean_ctx::core::handle::USAGE_HINT);
out.push('\n');
let sent = count_tokens(&out);
let savings = lean_ctx::core::protocol::format_savings(total_tokens, sent);
(format!("{out}{savings}"), total_tokens)
}
fn collect(dir: &str, read_root: &str, opts: &OutlineOpts) -> Collected {
use crate::tools::admitted_sources::SourceRead;
let mut paths: Vec<std::path::PathBuf> = ignore::WalkBuilder::new(dir)
.hidden(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.require_git(false)
.filter_entry(lean_ctx::core::walk_filter::keep_entry)
.build()
.flatten()
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
.map(|e| e.path().to_path_buf())
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.is_some_and(lean_ctx::core::language_capabilities::is_indexable_ext)
})
.collect();
paths.sort();
let truncated = paths.len() > MAX_FILES;
paths.truncate(MAX_FILES);
let root = std::path::Path::new(dir);
let mut files = Vec::new();
let mut total_tokens = 0usize;
let mut withheld = 0usize;
let mut budget_exhausted = false;
let mut remaining = MAX_TOTAL_BYTES;
for path in paths {
if std::fs::metadata(&path).is_ok_and(|m| m.len() > MAX_FILE_BYTES) {
continue;
}
if remaining == 0 {
budget_exhausted = true;
break;
}
let content = match crate::tools::admitted_sources::read(
&path.to_string_lossy(),
read_root,
super::TOOL,
&mut remaining,
) {
SourceRead::Admitted(read) => read.content,
SourceRead::Withheld(_) => {
withheld += 1;
continue;
}
SourceRead::Unavailable(_) => continue,
};
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_string();
let (sigs, backend) = extract_signatures_with_backend(&content, &ext);
let filtered: Vec<_> = filter_signatures(&sigs, opts)
.into_iter()
.cloned()
.collect();
if filtered.is_empty() {
continue;
}
total_tokens += count_tokens(&content);
files.push(FileSymbols {
rel: rel_path(root, &path),
ext,
backend,
sigs: filtered,
});
}
Collected {
files,
total_tokens,
truncated,
budget_exhausted,
withheld,
}
}
fn rel_path(root: &std::path::Path, path: &std::path::Path) -> String {
let rel = path.strip_prefix(root).unwrap_or(path);
let s = rel.to_string_lossy();
if std::path::MAIN_SEPARATOR == '/' {
s.into_owned()
} else {
s.replace(std::path::MAIN_SEPARATOR, "/")
}
}