funcvec-core 0.1.3

Core extraction, embedding, scoring, and reporting logic for funcvec.
Documentation
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use cargo_metadata::{Metadata, MetadataCommand};
use walkdir::WalkDir;

#[derive(Debug, Clone)]
pub struct ProjectFiles {
    pub root: PathBuf,
    pub files: Vec<PathBuf>,
}

pub fn discover_project(path: &Path) -> Result<ProjectFiles> {
    let path = path.canonicalize().with_context(|| {
        format!(
            "failed to canonicalize project path {}",
            path.to_string_lossy()
        )
    })?;
    let manifest = manifest_for_path(&path)
        .with_context(|| format!("{} is not inside a Rust project", path.display()))?;
    let root = manifest
        .parent()
        .context("manifest has no parent directory")?
        .to_path_buf();
    let metadata = MetadataCommand::new()
        .manifest_path(&manifest)
        .no_deps()
        .exec()
        .ok();
    let scan_root = scan_root(&path, &root);
    let allowed_roots = workspace_package_roots(metadata.as_ref(), &scan_root);
    let mut files = Vec::new();

    if scan_root.is_file() {
        if scan_root.extension().and_then(|ext| ext.to_str()) == Some("rs") {
            files.push(scan_root);
        }
        return Ok(ProjectFiles { root, files });
    }

    for entry in WalkDir::new(&scan_root) {
        let entry = entry.with_context(|| format!("failed to walk {}", scan_root.display()))?;
        if !entry.file_type().is_file() {
            continue;
        }
        let file = entry.path();
        if file.extension().and_then(|ext| ext.to_str()) != Some("rs") {
            continue;
        }
        if should_skip(file) {
            continue;
        }
        if let Some(allowed_roots) = &allowed_roots
            && !allowed_roots.iter().any(|root| file.starts_with(root))
        {
            continue;
        }
        files.push(file.to_path_buf());
    }

    files.sort();
    Ok(ProjectFiles { root, files })
}

fn manifest_for_path(path: &Path) -> Option<PathBuf> {
    if path.is_file() && path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml") {
        Some(path.to_path_buf())
    } else if path.is_file() {
        find_ancestor_manifest(path)
    } else {
        let direct = path.join("Cargo.toml");
        if direct.exists() {
            Some(direct)
        } else {
            find_ancestor_manifest(path)
        }
    }
}

fn scan_root(path: &Path, manifest_root: &Path) -> PathBuf {
    if path.is_file() && path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml") {
        manifest_root.to_path_buf()
    } else {
        path.to_path_buf()
    }
}

fn workspace_package_roots(metadata: Option<&Metadata>, scan_root: &Path) -> Option<Vec<PathBuf>> {
    let metadata = metadata?;
    let workspace_root: PathBuf = metadata.workspace_root.clone().into();
    if workspace_root != scan_root {
        return None;
    }

    let roots: Vec<PathBuf> = metadata
        .packages
        .iter()
        .filter_map(|package| {
            let manifest_path: PathBuf = package.manifest_path.clone().into();
            manifest_path.parent().map(Path::to_path_buf)
        })
        .collect();
    if roots.is_empty() { None } else { Some(roots) }
}

fn find_ancestor_manifest(path: &Path) -> Option<PathBuf> {
    let mut dir = if path.is_file() { path.parent()? } else { path };
    loop {
        let manifest = dir.join("Cargo.toml");
        if manifest.exists() {
            return Some(manifest);
        }
        dir = dir.parent()?;
    }
}

fn should_skip(path: &Path) -> bool {
    path.components().any(|component| {
        let text = component.as_os_str().to_string_lossy();
        matches!(
            text.as_ref(),
            "target" | ".git" | ".funcvec" | ".rfv" | "node_modules" | "vendor"
        )
    })
}

#[cfg(test)]
mod tests {
    use super::discover_project;
    use std::path::PathBuf;

    #[test]
    fn member_path_does_not_widen_to_workspace() {
        let member = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let project = discover_project(&member).unwrap();

        assert_eq!(project.root, member.canonicalize().unwrap());
        assert!(project.files.iter().all(|file| file.starts_with(&member)));
    }

    #[test]
    fn workspace_root_uses_cargo_members_not_excluded_fixtures() {
        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
        let project = discover_project(&workspace).unwrap();

        assert!(
            project
                .files
                .iter()
                .all(|file| !file.to_string_lossy().contains("fixtures/dupe_lab"))
        );
    }
}