tokmd 1.11.1

Tokei-backed repo inventory receipts (Markdown/TSV/JSONL/CSV) for PRs, CI, and LLM workflows.
Documentation
use crate::cli;
use anyhow::{Context, Result, bail};
use tokmd_analysis as analysis;
use tokmd_analysis_types as analysis_types;

use crate::analysis_explain;
use crate::analysis_utils;
use crate::export_bundle;
use crate::progress::Progress;

pub(crate) fn handle(args: cli::CliAnalyzeArgs, global: &cli::GlobalArgs) -> Result<()> {
    if let Some(key) = args.explain.as_deref() {
        let normalized = key.trim().to_ascii_lowercase();
        if normalized == "list" || normalized == "all" || normalized == "keys" {
            print!("{}", analysis_explain::catalog());
            return Ok(());
        }

        if let Some(explanation) = analysis_explain::lookup(key) {
            println!("{}", explanation);
            return Ok(());
        }

        bail!(
            "Unknown metric/finding key '{}'. Use --explain list to see supported keys.",
            key
        );
    }

    let progress = Progress::new(!global.no_progress);

    let preset = args.preset.unwrap_or(cli::AnalysisPreset::Receipt);
    let format = args
        .format
        .map(Into::into)
        .unwrap_or(tokmd_types::AnalysisFormat::Md);
    let git_flag = if args.git {
        Some(true)
    } else if args.no_git {
        Some(false)
    } else {
        None
    };
    let granularity = args.granularity.unwrap_or(cli::ImportGranularity::Module);

    progress.set_message("Loading export data...");
    let bundle = export_bundle::load_export_from_inputs(&args.inputs, global)?;
    let source = analysis_types::AnalysisSource {
        inputs: args
            .inputs
            .iter()
            .map(|p| p.display().to_string())
            .collect(),
        export_path: bundle.export_path.as_ref().map(|p| p.display().to_string()),
        base_receipt_path: bundle.export_path.as_ref().map(|p| p.display().to_string()),
        export_schema_version: bundle.meta.schema_version,
        export_generated_at_ms: bundle.meta.generated_at_ms,
        base_signature: None,
        module_roots: bundle.meta.module_roots.clone(),
        module_depth: bundle.meta.module_depth,
        children: analysis_utils::child_include_to_string(bundle.meta.children),
    };
    let args_meta = analysis_types::AnalysisArgsMeta {
        preset: analysis_utils::preset_to_string(preset),
        format: analysis_utils::format_to_string(format),
        window_tokens: args.window,
        git: git_flag,
        max_files: args.max_files,
        max_bytes: args.max_bytes,
        max_file_bytes: args.max_file_bytes,
        max_commits: args.max_commits,
        max_commit_files: args.max_commit_files,
        import_granularity: analysis_utils::granularity_to_string(granularity),
    };
    let near_dup_scope = match args.near_dup_scope {
        Some(cli::NearDupScope::Module) | None => analysis::NearDupScope::Module,
        Some(cli::NearDupScope::Lang) => analysis::NearDupScope::Lang,
        Some(cli::NearDupScope::Global) => analysis::NearDupScope::Global,
    };
    let effort = parse_effort_request(&args, preset == cli::AnalysisPreset::Estimate)?;

    let request = analysis::AnalysisRequest {
        preset: analysis_utils::map_preset(preset),
        args: args_meta,
        limits: analysis::AnalysisLimits {
            max_files: args.max_files,
            max_bytes: args.max_bytes,
            max_file_bytes: args.max_file_bytes,
            max_commits: args.max_commits,
            max_commit_files: args.max_commit_files,
        },
        window_tokens: args.window,
        git: git_flag,
        import_granularity: analysis_utils::map_granularity(granularity),
        detail_functions: args.detail_functions,
        near_dup: args.near_dup,
        near_dup_threshold: args.near_dup_threshold,
        near_dup_max_files: args.near_dup_max_files,
        near_dup_scope,
        near_dup_max_pairs: Some(args.near_dup_max_pairs),
        near_dup_exclude: args.near_dup_exclude.clone(),
        effort,
    };
    let ctx = analysis::AnalysisContext {
        export: bundle.export,
        root: bundle.root,
        source,
    };
    progress.set_message("Running analysis...");
    let receipt = analysis::analyze(ctx, request)?;

    progress.finish_and_clear();

    if let Some(output_dir) = args.output_dir {
        std::fs::create_dir_all(&output_dir)
            .context("Failed to create analysis output directory")?;
        analysis_utils::write_analysis_output(&receipt, &output_dir, format)?;
    } else {
        analysis_utils::write_analysis_stdout(&receipt, format)?;
    }

    Ok(())
}

fn parse_effort_request(
    args: &cli::CliAnalyzeArgs,
    estimate_preset: bool,
) -> Result<Option<analysis::EffortRequest>> {
    let base_ref = args.effort_base_ref.clone();
    let head_ref = args.effort_head_ref.clone();
    if (base_ref.is_some() && head_ref.is_none()) || (base_ref.is_none() && head_ref.is_some()) {
        bail!("both --effort-base-ref and --effort-head-ref are required together");
    }

    let requested = estimate_preset
        || args.effort_model.is_some()
        || args.effort_layer.is_some()
        || base_ref.is_some()
        || head_ref.is_some()
        || args.monte_carlo
        || args.mc_iterations.is_some()
        || args.mc_seed.is_some();
    if !requested {
        return Ok(None);
    }

    let default = analysis::EffortRequest::default();
    let model = args
        .effort_model
        .map(map_effort_model)
        .transpose()?
        .unwrap_or(default.model);
    let layer = args
        .effort_layer
        .map(map_effort_layer)
        .unwrap_or(default.layer);
    let monte_carlo = args.monte_carlo;
    let mc_iterations = args.mc_iterations.unwrap_or(default.mc_iterations);
    if mc_iterations == 0 {
        bail!("--mc-iterations must be greater than 0");
    }

    Ok(Some(analysis::EffortRequest {
        model,
        layer,
        base_ref,
        head_ref,
        monte_carlo,
        mc_iterations,
        mc_seed: args.mc_seed,
    }))
}

fn map_effort_model(model: cli::EffortModelKind) -> Result<analysis::EffortModelKind> {
    match model {
        cli::EffortModelKind::Cocomo81Basic => Ok(analysis::EffortModelKind::Cocomo81Basic),
        cli::EffortModelKind::Cocomo2Early | cli::EffortModelKind::Ensemble => {
            bail!("only 'cocomo81-basic' is currently supported")
        }
    }
}

fn map_effort_layer(layer: cli::EffortLayer) -> analysis::EffortLayer {
    match layer {
        cli::EffortLayer::Headline => analysis::EffortLayer::Headline,
        cli::EffortLayer::Why => analysis::EffortLayer::Why,
        cli::EffortLayer::Full => analysis::EffortLayer::Full,
    }
}