tokmd-analysis 1.13.1

Analysis logic and enrichers for tokmd receipts.
Documentation
use std::path::{Path, PathBuf};

#[cfg(feature = "walk")]
use std::{collections::BTreeSet, fs};

use crate::grid::PresetPlan;
#[cfg(feature = "walk")]
use anyhow::{Context, Result};
use tokmd_analysis_types::AnalysisSource;

#[cfg(any(feature = "walk", feature = "content"))]
pub(super) const ROOTLESS_FILE_ANALYSIS_WARNING: &str =
    "in-memory analysis has no host root; skipping file-backed enrichers";
#[cfg(feature = "git")]
pub(super) const ROOTLESS_GIT_ANALYSIS_WARNING: &str =
    "in-memory analysis has no host root; skipping git-backed enrichers";

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

#[cfg(any(feature = "walk", feature = "content", feature = "git"))]
pub(super) fn push_warning_once(warnings: &mut Vec<String>, warning: &str) {
    if warnings.iter().all(|existing| existing != warning) {
        warnings.push(warning.to_string());
    }
}

pub(super) fn analysis_roots(source: &AnalysisSource) -> Vec<PathBuf> {
    if !source_uses_ad_hoc_paths(source) {
        return vec![PathBuf::from(".")];
    }

    let roots: Vec<PathBuf> = source
        .inputs
        .iter()
        .map(|input| input.trim())
        .filter(|input| !input.is_empty())
        .map(PathBuf::from)
        .collect();

    if roots.is_empty() {
        vec![PathBuf::from(".")]
    } else {
        roots
    }
}

fn source_uses_ad_hoc_paths(source: &AnalysisSource) -> bool {
    source.export_path.is_none()
        && source.base_receipt_path.is_none()
        && source.export_schema_version.is_none()
}

pub(super) fn collect_required_files(
    root: &Path,
    analysis_roots: &[PathBuf],
    plan: &PresetPlan,
    max_files: Option<usize>,
    has_host_root: bool,
    warnings: &mut Vec<String>,
) -> Option<Vec<PathBuf>> {
    if !plan.needs_files() {
        return None;
    }

    #[cfg(feature = "walk")]
    {
        if has_host_root {
            match collect_scoped_files(root, analysis_roots, max_files) {
                Ok(list) => Some(list),
                Err(err) => {
                    warnings.push(format!("walk failed: {}", err));
                    None
                }
            }
        } else {
            push_warning_once(warnings, ROOTLESS_FILE_ANALYSIS_WARNING);
            None
        }
    }

    #[cfg(not(feature = "walk"))]
    {
        let _ = (root, analysis_roots, max_files, has_host_root);
        warnings.push(
            crate::grid::DisabledFeature::FileInventory
                .warning()
                .to_string(),
        );
        None
    }
}

#[cfg(feature = "walk")]
struct ScopedAnalysisRoot {
    absolute: PathBuf,
    relative: PathBuf,
    is_file: bool,
}

#[cfg(feature = "walk")]
fn collect_scoped_files(
    root: &Path,
    analysis_roots: &[PathBuf],
    max_files: Option<usize>,
) -> Result<Vec<PathBuf>> {
    if max_files == Some(0) {
        return Ok(Vec::new());
    }

    let scopes = if analysis_roots.is_empty() {
        vec![PathBuf::from(".")]
    } else {
        analysis_roots.to_vec()
    };

    let mut files = BTreeSet::new();
    for scope in scopes {
        let scoped = resolve_scope(root, &scope)?;
        if scoped.is_file {
            files.insert(scoped.relative);
        } else {
            let remaining = max_files.map(|limit| limit.saturating_sub(files.len()));
            if remaining == Some(0) {
                break;
            }

            for rel in tokmd_scan::walk::list_files(&scoped.absolute, remaining)? {
                let joined = if scoped.relative.as_os_str().is_empty() {
                    rel
                } else {
                    scoped.relative.join(rel)
                };
                files.insert(joined);
                if max_files.is_some_and(|limit| files.len() >= limit) {
                    break;
                }
            }
        }

        if max_files.is_some_and(|limit| files.len() >= limit) {
            break;
        }
    }

    let mut files: Vec<PathBuf> = files.into_iter().collect();
    if let Some(limit) = max_files {
        files.truncate(limit);
    }
    Ok(files)
}

#[cfg(feature = "walk")]
fn resolve_scope(root: &Path, scope: &Path) -> Result<ScopedAnalysisRoot> {
    let root = fs::canonicalize(root)
        .with_context(|| format!("failed to canonicalize analysis root {}", root.display()))?;
    let candidate = if scope.as_os_str().is_empty() || scope == Path::new(".") {
        root.clone()
    } else if scope.is_absolute() {
        scope.to_path_buf()
    } else {
        root.join(scope)
    };
    let absolute = fs::canonicalize(&candidate).with_context(|| {
        format!(
            "failed to canonicalize analysis scope {}",
            candidate.display()
        )
    })?;
    let relative = absolute
        .strip_prefix(&root)
        .with_context(|| {
            format!(
                "analysis scope {} is outside analysis root {}",
                candidate.display(),
                root.display()
            )
        })?
        .to_path_buf();
    let is_file = fs::metadata(&absolute)
        .with_context(|| format!("failed to stat analysis scope {}", absolute.display()))?
        .is_file();

    Ok(ScopedAnalysisRoot {
        absolute,
        relative,
        is_file,
    })
}

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

    fn source_with_inputs(inputs: &[&str]) -> AnalysisSource {
        AnalysisSource {
            inputs: inputs.iter().map(|input| input.to_string()).collect(),
            export_path: None,
            base_receipt_path: None,
            export_schema_version: None,
            export_generated_at_ms: None,
            base_signature: None,
            module_roots: vec![],
            module_depth: 1,
            children: "separate".to_string(),
        }
    }

    #[test]
    fn analysis_roots_use_source_inputs_for_ad_hoc_scans() {
        let source = source_with_inputs(&["src", "README.md"]);

        assert_eq!(
            analysis_roots(&source),
            vec![PathBuf::from("src"), PathBuf::from("README.md")]
        );
    }

    #[test]
    fn analysis_roots_keep_export_backed_sources_on_host_root() {
        let mut source = source_with_inputs(&["src"]);
        source.export_schema_version = Some(2);

        assert_eq!(analysis_roots(&source), vec![PathBuf::from(".")]);
    }

    #[cfg(feature = "walk")]
    fn write_file(path: &Path, content: &str) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, content).unwrap();
    }

    #[cfg(feature = "walk")]
    fn plan_needing_files() -> PresetPlan {
        PresetPlan {
            assets: false,
            deps: false,
            todo: true,
            dup: false,
            imports: false,
            git: false,
            fun: false,
            archetype: false,
            topics: false,
            entropy: false,
            license: false,
            complexity: false,
            api_surface: false,
            #[cfg(all(feature = "halstead", feature = "content", feature = "walk"))]
            halstead: false,
            #[cfg(feature = "git")]
            churn: false,
            #[cfg(feature = "git")]
            fingerprint: false,
        }
    }

    #[cfg(feature = "walk")]
    #[test]
    fn collect_required_files_limits_ad_hoc_directory_scope() {
        let dir = tempfile::tempdir().unwrap();
        write_file(&dir.path().join("src/main.rs"), "fn main() {}\n");
        write_file(&dir.path().join("test/leak.rs"), "// TODO unrelated\n");
        fs::create_dir_all(dir.path().join(".git")).unwrap();

        let mut warnings = Vec::new();
        let files = collect_required_files(
            dir.path(),
            &[PathBuf::from("src")],
            &plan_needing_files(),
            None,
            true,
            &mut warnings,
        )
        .unwrap();

        assert_eq!(files, vec![PathBuf::from("src/main.rs")]);
        assert!(warnings.is_empty());
    }

    #[cfg(feature = "walk")]
    #[test]
    fn collect_required_files_limits_ad_hoc_single_file_scope() {
        let dir = tempfile::tempdir().unwrap();
        write_file(&dir.path().join("src/main.rs"), "fn main() {}\n");
        write_file(&dir.path().join("test/leak.rs"), "// TODO unrelated\n");
        fs::create_dir_all(dir.path().join(".git")).unwrap();

        let mut warnings = Vec::new();
        let files = collect_required_files(
            dir.path(),
            &[PathBuf::from("src/main.rs")],
            &plan_needing_files(),
            None,
            true,
            &mut warnings,
        )
        .unwrap();

        assert_eq!(files, vec![PathBuf::from("src/main.rs")]);
        assert!(warnings.is_empty());
    }

    #[cfg(feature = "walk")]
    #[test]
    fn collect_required_files_dot_scope_preserves_whole_root() {
        let dir = tempfile::tempdir().unwrap();
        write_file(&dir.path().join("src/main.rs"), "fn main() {}\n");
        write_file(
            &dir.path().join("test/leak.rs"),
            "// TODO related for dot\n",
        );
        fs::create_dir_all(dir.path().join(".git")).unwrap();

        let mut warnings = Vec::new();
        let files = collect_required_files(
            dir.path(),
            &[PathBuf::from(".")],
            &plan_needing_files(),
            None,
            true,
            &mut warnings,
        )
        .unwrap();

        assert_eq!(
            files,
            vec![PathBuf::from("src/main.rs"), PathBuf::from("test/leak.rs")]
        );
        assert!(warnings.is_empty());
    }
}