use crate::lsp::symbols::{SymbolInfo, SymbolKind};
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Defect {
OverBudgetBody,
UnMappableFile,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Tier {
BitingNow,
Latent,
}
impl Tier {
pub fn rank(self) -> u8 {
match self {
Tier::BitingNow => 1,
Tier::Latent => 2,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Friction {
pub truncations: u32,
pub retries: u32,
pub code_class_edit_fails: u32,
pub other: u32,
pub sessions: u32,
}
impl Friction {
pub fn is_empty(&self) -> bool {
self.truncations == 0
&& self.retries == 0
&& self.code_class_edit_fails == 0
&& self.other == 0
}
pub fn score(&self) -> u32 {
3 * self.truncations + 2 * self.retries + 2 * self.code_class_edit_fails + self.other
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StructuralDefect {
pub rel_file: String,
pub name_path: String, pub defect: Defect,
pub tokens: usize,
pub lines: u32,
}
pub struct FileSymbols {
pub rel_file: String,
pub lines: Vec<String>,
pub symbols: Vec<SymbolInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Candidate {
pub key: String, pub rel_file: String,
pub name_path: String,
pub defect: Defect,
pub tier: Tier,
pub tokens: usize,
pub budget: usize,
pub lines: u32,
pub friction: Friction,
pub score: u32,
}
fn is_body_bearing(kind: &SymbolKind) -> bool {
matches!(
kind,
SymbolKind::Function | SymbolKind::Method | SymbolKind::Constructor
)
}
fn collect_bodies<'a>(syms: &'a [SymbolInfo], out: &mut Vec<&'a SymbolInfo>) {
for s in syms {
if is_body_bearing(&s.kind) {
out.push(s);
}
collect_bodies(&s.children, out);
}
}
fn body_text(lines: &[String], sym: &SymbolInfo) -> (String, u32) {
if lines.is_empty() {
return (String::new(), 0);
}
let start = sym.range_start_line.unwrap_or(sym.start_line) as usize;
let end = (sym.end_line as usize).min(lines.len() - 1);
if start > end {
return (String::new(), 0);
}
(lines[start..=end].join("\n"), (end - start + 1) as u32)
}
pub fn over_budget_bodies(files: &[FileSymbols]) -> Vec<StructuralDefect> {
let mut out = Vec::new();
for f in files {
let mut bodies = Vec::new();
collect_bodies(&f.symbols, &mut bodies);
for sym in bodies {
let (body, lines) = body_text(&f.lines, sym);
if !body.is_empty() && crate::tools::exceeds_inline_limit(&body) {
out.push(StructuralDefect {
rel_file: f.rel_file.clone(),
name_path: sym.name_path.clone(),
defect: Defect::OverBudgetBody,
tokens: body.len() / 4,
lines,
});
}
}
}
out
}
fn collect_all<'a>(syms: &'a [SymbolInfo], out: &mut Vec<&'a SymbolInfo>) {
for s in syms {
out.push(s);
collect_all(&s.children, out);
}
}
fn overview_bytes(files_syms: &[&SymbolInfo]) -> usize {
const PER_SYMBOL_OVERHEAD: usize = 24;
files_syms
.iter()
.map(|s| PER_SYMBOL_OVERHEAD + s.name_path.len() + s.detail.as_deref().map_or(0, str::len))
.sum()
}
pub fn un_mappable_files(files: &[FileSymbols]) -> Vec<StructuralDefect> {
let mut out = Vec::new();
for f in files {
let mut all = Vec::new();
collect_all(&f.symbols, &mut all);
let bytes = overview_bytes(&all);
if bytes > crate::tools::MAX_INLINE_TOKENS * 4 {
out.push(StructuralDefect {
rel_file: f.rel_file.clone(),
name_path: "(file)".to_string(),
defect: Defect::UnMappableFile,
tokens: bytes / 4,
lines: f.lines.len() as u32,
});
}
}
out
}
pub fn parse_project(root: &Path) -> Vec<FileSymbols> {
let mut out = Vec::new();
for entry in ignore::WalkBuilder::new(root)
.hidden(true)
.git_ignore(true)
.build()
.flatten()
{
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let path = entry.path();
let Some(lang) = crate::ast::detect_language(path) else {
continue;
};
let Ok(source) = std::fs::read_to_string(path) else {
continue;
};
let Ok(symbols) =
crate::ast::parser::extract_symbols_from_source(&source, Some(lang), path)
else {
continue; };
let rel_file = path
.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.to_string();
let lines = source.lines().map(str::to_string).collect();
out.push(FileSymbols {
rel_file,
lines,
symbols,
});
}
out
}
pub fn index_lane(root: &Path) -> Vec<StructuralDefect> {
let files = parse_project(root);
let mut defects = over_budget_bodies(&files);
defects.extend(un_mappable_files(&files));
defects
}
const CODE_FAMILIES: &str = "'ast_extent_fail','ambiguous_name_path','replace_dropped_sibling'";
const INFRA_FAMILIES: &str =
"'lsp_disconnect','lsp_index_locked','mux_startup_fail','lsp_not_running'";
pub fn recorder_lane(
conn: &rusqlite::Connection,
project_root: &str,
) -> rusqlite::Result<HashMap<String, Friction>> {
let sql = format!(
"SELECT friction_target,
SUM(CASE WHEN overflowed = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN err_family IN ({code}) THEN 1 ELSE 0 END),
SUM(CASE WHEN outcome != 'success'
AND (err_family IS NULL OR err_family NOT IN ({code}, {infra}))
THEN 1 ELSE 0 END),
COUNT(DISTINCT cc_session_id)
FROM tool_calls
WHERE project_root = ?1 AND friction_target IS NOT NULL AND friction_target != ''
GROUP BY friction_target",
code = CODE_FAMILIES,
infra = INFRA_FAMILIES,
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([project_root], |r| {
Ok((
r.get::<_, String>(0)?,
Friction {
truncations: r.get::<_, i64>(1)? as u32,
retries: 0,
code_class_edit_fails: r.get::<_, i64>(2)? as u32,
other: r.get::<_, i64>(3)? as u32,
sessions: r.get::<_, i64>(4)? as u32,
},
))
})?;
let mut map = HashMap::new();
for row in rows {
let (k, fr) = row?;
map.insert(k, fr);
}
Ok(map)
}
pub fn score_and_rank(
structural: Vec<StructuralDefect>,
friction: &HashMap<String, Friction>,
) -> Vec<Candidate> {
let mut cands: Vec<Candidate> = structural
.into_iter()
.map(|d| {
let fr = friction
.get(&d.name_path)
.or_else(|| friction.get(&d.rel_file))
.cloned()
.unwrap_or_default();
let tier = if fr.is_empty() {
Tier::Latent
} else {
Tier::BitingNow
};
let score = fr.score();
Candidate {
key: format!("{}::{}", d.rel_file, d.name_path),
rel_file: d.rel_file,
name_path: d.name_path,
defect: d.defect,
tier,
tokens: d.tokens,
budget: crate::tools::MAX_INLINE_TOKENS,
lines: d.lines,
friction: fr,
score,
}
})
.collect();
cands.sort_by(|a, b| {
a.tier
.rank()
.cmp(&b.tier.rank())
.then(b.score.cmp(&a.score))
.then(b.tokens.cmp(&a.tokens))
.then(a.key.cmp(&b.key))
});
cands
}
pub fn scan(
conn: &rusqlite::Connection,
root: &Path,
project_root: &str,
) -> anyhow::Result<Vec<Candidate>> {
let structural = index_lane(root);
let friction = recorder_lane(conn, project_root)?;
Ok(score_and_rank(structural, &friction))
}
pub fn measure_target(
files: &[FileSymbols],
rel_file: &str,
name_path: &str,
) -> Option<(usize, u32)> {
let f = files.iter().find(|f| f.rel_file == rel_file)?;
if name_path == "(file)" {
let mut all = Vec::new();
collect_all(&f.symbols, &mut all);
return Some((overview_bytes(&all) / 4, f.lines.len() as u32));
}
let mut all = Vec::new();
collect_all(&f.symbols, &mut all);
let sym = all.iter().find(|s| s.name_path == name_path)?;
let (body, lines) = body_text(&f.lines, sym);
Some((body.len() / 4, lines))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn friction_score_and_emptiness() {
let empty = Friction::default();
assert!(empty.is_empty());
assert_eq!(empty.score(), 0);
let f = Friction {
truncations: 14,
retries: 0,
code_class_edit_fails: 1,
other: 2,
sessions: 2,
};
assert!(!f.is_empty());
assert_eq!(f.score(), 46);
}
fn sym(name_path: &str, kind: SymbolKind, start: u32, end: u32) -> SymbolInfo {
SymbolInfo {
name: name_path
.rsplit('/')
.next()
.unwrap_or(name_path)
.to_string(),
name_path: name_path.to_string(),
kind,
file: std::path::PathBuf::from("x.rs"),
start_line: start,
end_line: end,
range_start_line: None,
start_col: 0,
children: vec![],
detail: None,
}
}
fn file_with(rel: &str, body_lines: usize, syms: Vec<SymbolInfo>) -> FileSymbols {
FileSymbols {
rel_file: rel.to_string(),
lines: (0..body_lines).map(|_| "x".repeat(200)).collect(),
symbols: syms,
}
}
#[test]
fn over_budget_bodies_flags_only_over_budget_functions() {
let big = sym("Foo/big", SymbolKind::Method, 0, 70);
let small = sym("Foo/small", SymbolKind::Method, 0, 5);
let files = vec![file_with("src/foo.rs", 71, vec![big, small])];
let defects = over_budget_bodies(&files);
assert_eq!(defects.len(), 1, "only the big body");
assert_eq!(defects[0].name_path, "Foo/big");
assert_eq!(defects[0].defect, Defect::OverBudgetBody);
assert!(defects[0].tokens > crate::tools::MAX_INLINE_TOKENS);
}
#[test]
fn over_budget_ignores_non_body_kinds() {
let s = sym("BigStruct", SymbolKind::Struct, 0, 70);
let files = vec![file_with("src/foo.rs", 71, vec![s])];
assert!(over_budget_bodies(&files).is_empty());
}
#[test]
fn un_mappable_files_flags_overview_over_budget_not_line_count() {
let many: Vec<SymbolInfo> = (0..400)
.map(|i| {
sym(
&format!("Mod/sym_{i:04}_with_a_longish_name"),
SymbolKind::Function,
i,
i,
)
})
.collect();
let big_map = file_with("src/huge.rs", 400, many);
let long_clean = file_with(
"src/long_clean.rs",
1500,
vec![
sym("A/f", SymbolKind::Function, 0, 700),
sym("A/g", SymbolKind::Function, 701, 1499),
],
);
let defects = un_mappable_files(&[big_map, long_clean]);
assert_eq!(defects.len(), 1, "only the many-symbol file");
assert_eq!(defects[0].rel_file, "src/huge.rs");
assert_eq!(defects[0].name_path, "(file)");
assert_eq!(defects[0].defect, Defect::UnMappableFile);
}
#[test]
fn index_lane_finds_over_budget_body_in_real_file() {
let dir = tempfile::tempdir().unwrap();
let mut src = String::from("fn huge() {\n");
for i in 0..200 {
src.push_str(&format!(" let v{i} = \"{}\";\n", "x".repeat(80)));
}
src.push_str("}\n");
std::fs::write(dir.path().join("huge.rs"), src).unwrap();
let defects = index_lane(dir.path());
assert!(
defects
.iter()
.any(|d| d.defect == Defect::OverBudgetBody && d.name_path.contains("huge")),
"expected an over-budget-body defect for `huge`, got: {defects:?}"
);
}
#[test]
fn index_lane_does_not_flag_name_collisions() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("s.rs"),
"struct S;\n\
impl std::fmt::Debug for S {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n Ok(())\n }\n}\n\
impl std::fmt::Display for S {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n Ok(())\n }\n}\n",
)
.unwrap();
let defects = index_lane(tmp.path());
assert!(
defects.is_empty(),
"a name collision must not be flagged after NameCollision was retired: {defects:?}"
);
}
#[test]
fn recorder_lane_aggregates_friction_and_filters_by_project_root() {
use crate::usage::db::{open_db, write_record};
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let conn = open_db(dir.path()).unwrap();
for _ in 0..2 {
write_record(
&conn,
"symbols",
1,
"success",
true,
None,
"cs",
None,
"s1",
None,
None,
Some("ccs1"),
Some("Foo/bar"),
Some(1000),
None,
Some("/repo"),
)
.unwrap();
}
write_record(
&conn,
"edit_code",
1,
"error",
false,
Some("ambiguous name_path \"Foo/bar\" matches 2 symbols"),
"cs",
None,
"s1",
None,
None,
Some("ccs1"),
Some("Foo/bar"),
None,
Some("ambiguous_name_path"),
Some("/repo"),
)
.unwrap();
write_record(
&conn,
"symbols",
1,
"success",
true,
None,
"cs",
None,
"s9",
None,
None,
Some("ccs9"),
Some("Foo/bar"),
Some(9999),
None,
Some("/other-repo"),
)
.unwrap();
let map = recorder_lane(&conn, "/repo").unwrap();
let fr = map.get("Foo/bar").expect("Foo/bar present");
assert_eq!(
fr.truncations, 2,
"foreign-repo truncation must be excluded"
);
assert_eq!(fr.code_class_edit_fails, 1);
assert_eq!(fr.sessions, 1);
assert_eq!(fr.score(), 3 * 2 + 2); assert!(!map.contains_key(""), "empty friction_target excluded");
}
#[test]
fn score_and_rank_tiers_and_orders() {
let structural = vec![
StructuralDefect {
rel_file: "src/a.rs".into(),
name_path: "A/hot".into(),
defect: Defect::OverBudgetBody,
tokens: 4000,
lines: 242,
},
StructuralDefect {
rel_file: "src/b.rs".into(),
name_path: "B/cold".into(),
defect: Defect::OverBudgetBody,
tokens: 6000,
lines: 331,
},
];
let mut friction = HashMap::new();
friction.insert(
"A/hot".to_string(),
Friction {
truncations: 5,
..Default::default()
},
);
let ranked = score_and_rank(structural, &friction);
assert_eq!(ranked.len(), 2);
assert_eq!(ranked[0].name_path, "A/hot");
assert_eq!(ranked[0].tier, Tier::BitingNow);
assert_eq!(ranked[0].key, "src/a.rs::A/hot");
assert_eq!(ranked[0].score, 15); assert_eq!(ranked[1].name_path, "B/cold");
assert_eq!(ranked[1].tier, Tier::Latent);
}
#[test]
fn scan_end_to_end_ranks_a_real_over_budget_body() {
use crate::usage::db::{open_db, write_record};
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let mut src = String::from("fn huge() {\n");
for i in 0..200 {
src.push_str(&format!(" let v{i} = \"{}\";\n", "x".repeat(80)));
}
src.push_str("}\n");
std::fs::write(dir.path().join("huge.rs"), src).unwrap();
let conn = open_db(dir.path()).unwrap();
write_record(
&conn,
"symbols",
1,
"success",
true,
None,
"cs",
None,
"s1",
None,
None,
Some("ccs1"),
Some("huge"),
Some(3500),
None,
Some(&dir.path().to_string_lossy()),
)
.unwrap();
let cands = scan(&conn, dir.path(), &dir.path().to_string_lossy()).unwrap();
assert!(
cands
.iter()
.any(|c| c.defect == Defect::OverBudgetBody && c.name_path.contains("huge")),
"expected ranked over-budget candidate for huge: {cands:?}"
);
}
#[test]
fn measure_target_returns_body_size_for_a_symbol() {
let big = sym("Foo/big", SymbolKind::Method, 0, 70);
let files = vec![file_with("src/foo.rs", 71, vec![big])];
let (tokens, lines) = measure_target(&files, "src/foo.rs", "Foo/big").unwrap();
assert!(tokens > crate::tools::MAX_INLINE_TOKENS);
assert_eq!(lines, 71);
assert!(measure_target(&files, "src/foo.rs", "Foo/missing").is_none());
}
}