tokmd-analysis 1.13.0

Analysis logic and enrichers for tokmd receipts.
Documentation
use std::collections::BTreeMap;
use std::path::Path;

use super::classify::{ClassKind, FileKind, classify_row, load_gitattributes, tag_name};
use tokmd_analysis_types::normalize_path;
use tokmd_analysis_types::{EffortSizeBasis, EffortTagSizeRow};
use tokmd_types::ExportData;

#[derive(Debug)]
pub struct SizeBasisResult {
    pub basis: EffortSizeBasis,
    pub source_confidence: f64,
}

pub fn build_size_basis(root: &Path, export: &ExportData) -> SizeBasisResult {
    let rules = load_gitattributes(root);
    let mut total_lines = 0usize;
    let mut generated_lines = 0usize;
    let mut vendored_lines = 0usize;
    let mut by_tag: BTreeMap<String, (usize, usize)> = BTreeMap::new();
    let mut unknown_lines = 0usize;

    for row in &export.rows {
        let normalized = normalize_path(&row.path, root);
        let (class, tag) = classify_row(root, &normalized, &rules, row);

        let code = row.code;
        total_lines = total_lines.saturating_add(code);

        let mut authored = code;
        let mut warning: Option<String> = None;

        let generated = matches!(class, ClassKind::Generated);
        let vendored = matches!(class, ClassKind::Vendored);
        if generated {
            generated_lines = generated_lines.saturating_add(code);
            authored = 0;
        }
        if vendored {
            vendored_lines = vendored_lines.saturating_add(code);
            authored = 0;
        }

        if class == ClassKind::Unknown {
            unknown_lines = unknown_lines.saturating_add(code);
            if code > 0 {
                warning = Some("heuristic-only classification used".to_string());
            }
        }

        if class == ClassKind::Unknown && matches!(tag, FileKind::Generated | FileKind::Vendored) {
            unknown_lines = unknown_lines.saturating_sub(code);
        }

        if generated {
            accumulate_tag(&mut by_tag, "generated", code, authored);
        } else if vendored {
            accumulate_tag(&mut by_tag, "vendored", code, authored);
        } else {
            let tag_name = tag_name(&tag);
            accumulate_tag(&mut by_tag, tag_name, code, authored);
        }

        let _ = warning;
    }

    let authored_lines = total_lines.saturating_sub(generated_lines + vendored_lines);
    let kloc_total = (total_lines as f64) / 1000.0;
    let kloc_authored = (authored_lines as f64) / 1000.0;

    let generated_pct = ratio(generated_lines, total_lines);
    let vendored_pct = ratio(vendored_lines, total_lines);

    let by_tag_rows = by_tag
        .into_iter()
        .map(|(tag, (lines, authored))| EffortTagSizeRow {
            tag,
            lines,
            authored_lines: authored,
            pct_of_total: ratio(lines, total_lines),
        })
        .collect::<Vec<_>>();

    let confidence_from_rules = if !rules.is_empty() { 0.75 } else { 0.55 };

    let confidence_heuristic = if total_lines == 0 {
        0.0
    } else {
        1.0 - (unknown_lines as f64) / (total_lines as f64)
    };
    let classification_confidence =
        ((0.4 * confidence_from_rules) + (0.6 * confidence_heuristic)).clamp(0.0, 1.0);

    let warnings = if unknown_lines > 0 {
        vec!["heuristic classification used for some files".to_string()]
    } else {
        Vec::new()
    };

    let basis = EffortSizeBasis {
        total_lines,
        authored_lines,
        generated_lines,
        vendored_lines,
        kloc_total,
        kloc_authored,
        generated_pct,
        vendored_pct,
        classification_confidence: if classification_confidence >= 0.75 {
            tokmd_analysis_types::EffortConfidenceLevel::High
        } else if classification_confidence >= 0.55 {
            tokmd_analysis_types::EffortConfidenceLevel::Medium
        } else {
            tokmd_analysis_types::EffortConfidenceLevel::Low
        },
        warnings,
        by_tag: by_tag_rows,
    };

    SizeBasisResult {
        basis,
        source_confidence: classification_confidence,
    }
}

fn accumulate_tag(
    map: &mut BTreeMap<String, (usize, usize)>,
    tag: &str,
    lines: usize,
    authored: usize,
) {
    let entry = map.entry(tag.to_string()).or_insert((0, 0));
    entry.0 = entry.0.saturating_add(lines);
    entry.1 = entry.1.saturating_add(authored);
}

fn ratio(num: usize, den: usize) -> f64 {
    if den == 0 {
        0.0
    } else {
        (num as f64) / (den as f64)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::env;
    use std::fs::{self, File};
    use std::io::Write;
    use std::path::{Path, PathBuf};
    use std::sync::{Mutex, OnceLock};
    use tempfile::tempdir;

    static CWD_LOCK: OnceLock<Mutex<()>> = OnceLock::new();

    struct RestoreCurrentDir(PathBuf);

    impl Drop for RestoreCurrentDir {
        fn drop(&mut self) {
            let _ = env::set_current_dir(&self.0);
        }
    }

    fn with_current_dir<T>(path: &Path, f: impl FnOnce() -> T) -> T {
        let _lock = CWD_LOCK
            .get_or_init(|| Mutex::new(()))
            .lock()
            .expect("cwd lock");
        let original = env::current_dir().expect("current dir");
        env::set_current_dir(path).expect("set current dir");
        let _restore = RestoreCurrentDir(original);
        f()
    }

    #[test]
    fn size_basis_detects_generated_sentinels() {
        let dir = tempdir().unwrap();
        let src = dir.path().join("src");
        fs::create_dir_all(&src).unwrap();
        let generated = src.join("gen.min.js");
        let mut f = File::create(&generated).unwrap();
        writeln!(f, "// Generated by build pipeline").unwrap();
        writeln!(f, "const x = 1;").unwrap();

        let export = ExportData {
            rows: vec![tokmd_types::FileRow {
                path: "src/gen.min.js".to_string(),
                module: "src".to_string(),
                lang: "JavaScript".to_string(),
                kind: tokmd_types::FileKind::Parent,
                code: 12,
                comments: 0,
                blanks: 0,
                lines: 12,
                bytes: 120,
                tokens: 30,
            }],
            module_roots: vec!["src".to_string()],
            module_depth: 1,
            children: tokmd_types::ChildIncludeMode::Separate,
        };

        let res = build_size_basis(dir.path(), &export);
        assert_eq!(res.basis.generated_lines, 12);
        assert_eq!(res.basis.authored_lines, 0);
    }

    #[test]
    fn size_basis_uses_gitattributes_over_heuristics() {
        let dir = tempdir().unwrap();
        let mut ga = File::create(dir.path().join(".gitattributes")).unwrap();
        writeln!(ga, "src/lib.rs linguist-generated").unwrap();

        let export = ExportData {
            rows: vec![tokmd_types::FileRow {
                path: "src/lib.rs".to_string(),
                module: "src".to_string(),
                lang: "Rust".to_string(),
                kind: tokmd_types::FileKind::Parent,
                code: 40,
                comments: 0,
                blanks: 0,
                lines: 40,
                bytes: 300,
                tokens: 20,
            }],
            module_roots: vec!["src".to_string()],
            module_depth: 1,
            children: tokmd_types::ChildIncludeMode::Separate,
        };

        let res = build_size_basis(dir.path(), &export);
        assert_eq!(res.basis.generated_lines, 40);
    }

    #[test]
    fn size_basis_with_empty_root_does_not_read_current_dir_metadata() {
        let dir = tempdir().unwrap();
        fs::create_dir_all(dir.path().join("src")).unwrap();
        let mut ga = File::create(dir.path().join(".gitattributes")).unwrap();
        writeln!(ga, "src/lib.rs linguist-generated").unwrap();
        let mut source = File::create(dir.path().join("src/lib.rs")).unwrap();
        writeln!(source, "// Generated by host workspace").unwrap();
        writeln!(source, "pub fn host_only() {{}}").unwrap();

        let export = ExportData {
            rows: vec![tokmd_types::FileRow {
                path: "src/lib.rs".to_string(),
                module: "src".to_string(),
                lang: "Rust".to_string(),
                kind: tokmd_types::FileKind::Parent,
                code: 40,
                comments: 0,
                blanks: 0,
                lines: 40,
                bytes: 300,
                tokens: 20,
            }],
            module_roots: vec!["src".to_string()],
            module_depth: 1,
            children: tokmd_types::ChildIncludeMode::Separate,
        };

        let res = with_current_dir(dir.path(), || build_size_basis(Path::new(""), &export));

        assert_eq!(res.basis.generated_lines, 0);
        assert_eq!(res.basis.vendored_lines, 0);
        assert_eq!(res.basis.authored_lines, 40);
    }
}