tokmd-analysis 1.11.1

Analysis logic and enrichers for tokmd receipts.
Documentation
//! Heuristic effort file classification rules.
//!
//! `.gitattributes` rules are applied by the parent coordinator first. This
//! module owns the fallback path, language, and generated-file sentinel
//! heuristics used when no explicit rule matches.

use std::path::{Path, PathBuf};
use std::{fs, io::BufRead, io::BufReader};

use tokmd_types::FileRow;

use super::FileKind;

pub(super) fn classify_file(root: &Path, path: &str, row: &FileRow) -> FileKind {
    if looks_generated_dir(path) || has_generated_sentinel(root, path) {
        FileKind::Generated
    } else if looks_vendored_dir(path) {
        FileKind::Vendored
    } else if looks_test_path(path) {
        FileKind::Tests
    } else if looks_doc_path(path, row) {
        FileKind::Docs
    } else if looks_api_path(path) {
        FileKind::Api
    } else if looks_ffi_path(path) {
        FileKind::Ffi
    } else if looks_ui_path(path) {
        FileKind::Ui
    } else if looks_data_path(path) {
        FileKind::Data
    } else if looks_build_path(path) {
        FileKind::Build
    } else if looks_infra_path(row) {
        FileKind::Infra
    } else {
        FileKind::Core
    }
}

pub(in crate::effort) fn tag_name(kind: &FileKind) -> &str {
    match kind {
        FileKind::Core => "core",
        FileKind::Infra => "infra",
        FileKind::Build => "build",
        FileKind::Docs => "docs",
        FileKind::Tests => "tests",
        FileKind::Generated => "generated",
        FileKind::Vendored => "vendored",
        FileKind::Api => "api",
        FileKind::Ffi => "ffi",
        FileKind::Ui => "ui",
        FileKind::Data => "data",
    }
}

fn has_host_root(root: &Path) -> bool {
    !root.as_os_str().is_empty()
}

fn has_generated_sentinel(root: &Path, path: &str) -> bool {
    if !has_host_root(root) {
        return false;
    }

    let full = root.join(PathBuf::from(path));
    let file = match fs::File::open(&full) {
        Ok(f) => f,
        Err(_) => return false,
    };

    let reader = BufReader::new(file);
    for line in reader.lines().take(40).flatten() {
        if is_generated_sentinel(&line) {
            return true;
        }
    }
    false
}

fn is_generated_sentinel(text: &str) -> bool {
    let lower = text.to_lowercase();
    lower.contains("generated by")
        || lower.contains("do not edit")
        || lower.contains("auto-generated")
        || lower.contains("autogenerated")
}

fn looks_generated_dir(path: &str) -> bool {
    let lower = path.to_lowercase();
    lower.contains("/generated/")
        || lower.contains("/dist/")
        || lower.contains("/build/")
        || lower.contains("/target/")
        || lower.ends_with(".min.js")
        || lower.ends_with(".map")
}

fn looks_vendored_dir(path: &str) -> bool {
    let lower = path.to_lowercase();
    lower.contains("/vendor/")
        || lower.contains("/third_party/")
        || lower.contains("/node_modules/")
        || lower.contains("/deps/")
}

fn looks_test_path(path: &str) -> bool {
    let lower = path.to_lowercase();
    lower.contains("/test/")
        || lower.contains("/tests/")
        || lower.contains("__tests__")
        || lower.contains("/spec/")
        || lower.contains("/specs/")
        || lower.ends_with("_test.rs")
}

fn looks_doc_path(path: &str, row: &FileRow) -> bool {
    let lower = path.to_lowercase();
    row.lang.to_lowercase() == "markdown"
        || lower.contains("/docs/")
        || lower.ends_with("readme.md")
}

fn looks_api_path(path: &str) -> bool {
    let lower = path.to_lowercase();
    lower.contains("/api/") || lower.ends_with(".proto") || lower.ends_with(".openapi.json")
}

fn looks_ffi_path(path: &str) -> bool {
    let lower = path.to_lowercase();
    lower.contains("/ffi/") || lower.contains("/bindings/") || lower.ends_with("_ffi.rs")
}

fn looks_ui_path(path: &str) -> bool {
    let lower = path.to_lowercase();
    lower.contains("/ui/") || lower.contains("/web/") || lower.contains("/frontend/")
}

fn looks_data_path(path: &str) -> bool {
    let lower = path.to_lowercase();
    lower.contains("/data/") || lower.contains("/resources/") || lower.contains("/assets/")
}

fn looks_build_path(path: &str) -> bool {
    let lower = path.to_lowercase();
    lower.ends_with(".yml")
        || lower.ends_with(".yaml")
        || lower.ends_with(".toml")
        || lower.contains("/ci/")
        || lower.contains("/.github/")
}

fn looks_infra_path(row: &FileRow) -> bool {
    tokmd_analysis_types::is_infra_lang(&row.lang)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn row(path: &str, lang: &str) -> FileRow {
        FileRow {
            path: path.to_string(),
            module: String::new(),
            lang: lang.to_string(),
            kind: tokmd_types::FileKind::Parent,
            code: 1,
            comments: 0,
            blanks: 0,
            lines: 1,
            bytes: 1,
            tokens: 1,
        }
    }

    #[test]
    fn classifies_path_heuristics_before_infra_language_fallback() {
        assert!(matches!(
            classify_file(
                Path::new(""),
                "docs/README.md",
                &row("docs/README.md", "TOML")
            ),
            FileKind::Docs
        ));
        assert!(matches!(
            classify_file(
                Path::new(""),
                "src/api/schema.proto",
                &row("src/api/schema.proto", "Text")
            ),
            FileKind::Api
        ));
        assert!(matches!(
            classify_file(
                Path::new(""),
                "src/web/app.ts",
                &row("src/web/app.ts", "TypeScript")
            ),
            FileKind::Ui
        ));
    }

    #[test]
    fn empty_root_does_not_read_generated_sentinels() {
        assert!(!has_generated_sentinel(Path::new(""), "src/generated.rs"));
    }

    #[test]
    fn tag_names_match_receipt_tags() {
        assert_eq!(tag_name(&FileKind::Core), "core");
        assert_eq!(tag_name(&FileKind::Generated), "generated");
        assert_eq!(tag_name(&FileKind::Vendored), "vendored");
        assert_eq!(tag_name(&FileKind::Ffi), "ffi");
    }
}