use std::path::Path;
use crate::db::models::CodeElement;
use crate::graph::GraphEngine;
#[derive(Debug, Clone, serde::Serialize)]
pub struct FileCost {
pub file: String,
pub lines: usize,
pub sloc: usize,
pub bytes: usize,
pub in_tokens: usize,
pub out_tokens: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CostEstimate {
pub model: &'static str,
pub files: Vec<FileCost>,
pub total_lines: usize,
pub total_sloc: usize,
pub total_bytes: usize,
pub in_tokens: usize,
pub out_tokens: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct ModelRate {
pub tokens_per_sloc: u32,
pub out_tokens_per_file: u32,
}
impl Default for ModelRate {
fn default() -> Self {
Self {
tokens_per_sloc: 13,
out_tokens_per_file: 256,
}
}
}
pub fn files_for_elements(elements: &[CodeElement]) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
for el in elements {
if !el.file_path.is_empty() {
let rel = el.file_path.trim_start_matches("./");
seen.insert(rel.to_string());
}
}
seen.into_iter().collect()
}
fn count_sloc(bytes: &[u8]) -> usize {
let mut sloc = 0usize;
for line in bytes.split(|&b| b == b'\n') {
let trimmed: Vec<u8> = line
.iter()
.skip_while(|&&b| b == b' ' || b == b'\t' || b == b'\r')
.copied()
.collect();
if trimmed.is_empty() {
continue;
}
if trimmed
.iter()
.all(|&b| matches!(b, b'{' | b'}' | b'/' | b' ' | b'\t' | b'\r'))
{
continue;
}
sloc += 1;
}
sloc
}
pub fn estimate(
files: &[String],
base_dir: &Path,
) -> Result<CostEstimate, Box<dyn std::error::Error>> {
estimate_with_model(files, base_dir, &ModelRate::default())
}
pub fn estimate_with_model(
files: &[String],
base_dir: &Path,
rate: &ModelRate,
) -> Result<CostEstimate, Box<dyn std::error::Error>> {
let mut out = CostEstimate {
model: "locomo-default",
files: Vec::with_capacity(files.len()),
total_lines: 0,
total_sloc: 0,
total_bytes: 0,
in_tokens: 0,
out_tokens: 0,
};
for f in files {
let path = if f.starts_with('/') {
Path::new(f).to_path_buf()
} else {
base_dir.join(f)
};
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
if !meta.is_file() {
continue;
}
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => continue,
};
let newline_count = bytes.iter().filter(|&&b| b == b'\n').count();
let lines = newline_count + usize::from(!bytes.is_empty() && !bytes.ends_with(b"\n"));
let sloc = count_sloc(&bytes);
let in_tokens = sloc.saturating_mul(rate.tokens_per_sloc as usize);
let out_tokens = rate.out_tokens_per_file as usize;
out.total_lines += lines;
out.total_sloc += sloc;
out.total_bytes += bytes.len();
out.in_tokens += in_tokens;
out.out_tokens += out_tokens;
out.files.push(FileCost {
file: f.clone(),
lines,
sloc,
bytes: bytes.len(),
in_tokens,
out_tokens,
});
}
Ok(out)
}
pub fn estimate_impact(
start_file: &str,
depth: u32,
max_affected: usize,
db_path: &Path,
base_dir: &Path,
) -> Result<(crate::graph::ImpactResult, CostEstimate), Box<dyn std::error::Error>> {
let db = crate::db::backend::init_db(db_path)?;
let graph_engine = GraphEngine::new(db);
let analyzer = crate::graph::ImpactAnalyzer::new(&graph_engine);
let opts = crate::graph::ImpactScanOptions { max_affected };
let result = analyzer.calculate_impact_radius_with_options(start_file, depth, 0.0, &opts)?;
let files = files_for_elements(&result.affected_elements);
let cost = estimate(&files, base_dir)?;
Ok((result, cost))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write(base: &Path, rel: &str, content: &str) {
let p = base.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, content).unwrap();
}
#[test]
fn counts_lines_sloc_tokens() {
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
"src/a.rs",
"fn a() {}\nfn b() {}\n// comment\n\n",
);
let files = vec!["src/a.rs".to_string()];
let est = estimate(&files, tmp.path()).expect("estimate");
assert_eq!(est.files.len(), 1);
assert_eq!(est.files[0].lines, 4);
assert_eq!(est.files[0].sloc, 3);
assert_eq!(est.files[0].in_tokens, 3 * 13);
assert_eq!(est.files[0].out_tokens, 256);
assert_eq!(est.in_tokens, 3 * 13);
assert_eq!(est.out_tokens, 256);
assert_eq!(est.total_lines, 4);
assert_eq!(est.total_sloc, 3);
}
#[test]
fn in_out_direction_matches_locomo() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "src/a.rs", "fn x() {}\n");
let est = estimate(&["src/a.rs".into()], tmp.path()).expect("est");
assert!(est.in_tokens > 0);
assert_eq!(est.out_tokens, 256);
}
#[test]
fn model_rate_hook_overrides() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "src/a.rs", "fn x() {}\n");
let rate = ModelRate {
tokens_per_sloc: 7,
out_tokens_per_file: 100,
};
let est = estimate_with_model(&["src/a.rs".into()], tmp.path(), &rate).expect("est");
assert_eq!(est.in_tokens, 7);
assert_eq!(est.out_tokens, 100);
assert_eq!(est.model, "locomo-default");
}
#[test]
fn blank_and_brace_only_lines_dropped_from_sloc() {
assert_eq!(count_sloc(b"fn a() {}\n\nfn b() {}\n"), 2);
assert_eq!(count_sloc(b" \n{\n}\n// hi\nfn c() {}\n"), 2);
assert_eq!(count_sloc(b""), 0);
}
#[test]
fn missing_files_are_skipped() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "src/a.rs", "x");
let files = vec!["src/a.rs".to_string(), "src/ghost.rs".to_string()];
let est = estimate(&files, tmp.path()).expect("estimate");
assert_eq!(est.files.len(), 1, "missing file skipped");
}
#[test]
fn files_for_elements_dedups_and_strips_dot_slash() {
let mk = |file: &str| CodeElement {
qualified_name: format!("{file}::f"),
element_type: "function".into(),
name: "f".into(),
file_path: file.into(),
line_start: 1,
line_end: 2,
language: "rust".into(),
parent_qualified: None,
cluster_id: None,
cluster_label: None,
metadata: serde_json::Value::Null,
env: "local".into(),
};
let els = vec![mk("./src/a.rs"), mk("./src/a.rs"), mk("src/b.rs")];
let files = files_for_elements(&els);
assert_eq!(files, vec!["src/a.rs".to_string(), "src/b.rs".to_string()]);
}
#[test]
fn empty_set_is_zero_cost() {
let tmp = TempDir::new().unwrap();
let est = estimate(&[], tmp.path()).expect("estimate");
assert_eq!(est.out_tokens, 0);
assert!(est.files.is_empty());
}
}