repopilot 0.7.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::frameworks::react_native::detect_react_native_architecture;
use crate::frameworks::types::{DetectedFramework, FrameworkProject};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

pub fn detect_frameworks(root: &Path) -> Vec<DetectedFramework> {
    let pkg_path = root.join("package.json");
    let content = match std::fs::read_to_string(&pkg_path) {
        Ok(c) => c,
        Err(_) => return vec![],
    };
    let value: serde_json::Value = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(_) => return vec![],
    };

    let mut deps = serde_json::Map::new();
    for key in ["dependencies", "devDependencies"] {
        if let Some(obj) = value.get(key).and_then(|v| v.as_object()) {
            for (k, v) in obj {
                deps.entry(k.clone()).or_insert_with(|| v.clone());
            }
        }
    }

    let version_of = |pkg: &str| -> Option<String> {
        deps.get(pkg)
            .and_then(|v| v.as_str())
            .and_then(extract_version)
    };

    let mut frameworks = Vec::new();

    if deps.contains_key("react-native") {
        frameworks.push(DetectedFramework::ReactNative {
            version: version_of("react-native"),
        });
    }
    if deps.contains_key("expo") {
        frameworks.push(DetectedFramework::Expo {
            version: version_of("expo"),
        });
    }
    if deps.contains_key("next") {
        frameworks.push(DetectedFramework::NextJs {
            version: version_of("next"),
        });
    }
    if deps.contains_key("react") {
        frameworks.push(DetectedFramework::React {
            version: version_of("react"),
        });
    }
    if deps.contains_key("vue") {
        frameworks.push(DetectedFramework::Vue {
            version: version_of("vue"),
        });
    }
    if deps.contains_key("@angular/core") {
        frameworks.push(DetectedFramework::Angular {
            version: version_of("@angular/core"),
        });
    }
    if deps.contains_key("svelte") {
        frameworks.push(DetectedFramework::Svelte {
            version: version_of("svelte"),
        });
    }
    if deps.contains_key("@nestjs/core") {
        frameworks.push(DetectedFramework::NestJs {
            version: version_of("@nestjs/core"),
        });
    }
    if deps.contains_key("express") {
        frameworks.push(DetectedFramework::Express {
            version: version_of("express"),
        });
    }

    frameworks
}

pub fn detect_framework_projects(root: &Path) -> Vec<FrameworkProject> {
    let mut projects = Vec::new();
    let mut seen = BTreeSet::new();

    push_framework_project(root, root, &mut projects, &mut seen);

    for workspace in workspace_package_paths(root) {
        push_framework_project(root, &workspace, &mut projects, &mut seen);
    }

    projects
}

fn push_framework_project(
    root: &Path,
    project_path: &Path,
    projects: &mut Vec<FrameworkProject>,
    seen: &mut BTreeSet<PathBuf>,
) {
    let normalized = project_path
        .strip_prefix(root)
        .map(|p| {
            if p.as_os_str().is_empty() {
                PathBuf::from(".")
            } else {
                p.to_path_buf()
            }
        })
        .unwrap_or_else(|_| project_path.to_path_buf());

    if !seen.insert(normalized.clone()) {
        return;
    }

    let frameworks = detect_frameworks(project_path);
    if frameworks.is_empty() {
        return;
    }

    let react_native = frameworks
        .iter()
        .any(|f| matches!(f, DetectedFramework::ReactNative { .. }))
        .then(|| detect_react_native_architecture(project_path))
        .filter(|profile| profile.detected);

    projects.push(FrameworkProject {
        path: normalized,
        frameworks,
        react_native,
    });
}

fn workspace_package_paths(root: &Path) -> Vec<PathBuf> {
    let pkg_path = root.join("package.json");
    let content = match std::fs::read_to_string(&pkg_path) {
        Ok(c) => c,
        Err(_) => return Vec::new(),
    };
    let value: serde_json::Value = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(_) => return Vec::new(),
    };

    workspace_patterns(&value)
        .into_iter()
        .flat_map(|pattern| expand_workspace_pattern(root, &pattern))
        .filter(|path| path.join("package.json").is_file())
        .collect()
}

fn workspace_patterns(value: &serde_json::Value) -> Vec<String> {
    match value.get("workspaces") {
        Some(serde_json::Value::Array(items)) => items
            .iter()
            .filter_map(|item| item.as_str().map(str::to_string))
            .collect(),
        Some(serde_json::Value::Object(obj)) => obj
            .get("packages")
            .and_then(|v| v.as_array())
            .map(|items| {
                items
                    .iter()
                    .filter_map(|item| item.as_str().map(str::to_string))
                    .collect()
            })
            .unwrap_or_default(),
        _ => Vec::new(),
    }
}

fn expand_workspace_pattern(root: &Path, pattern: &str) -> Vec<PathBuf> {
    if pattern.contains("node_modules") || pattern.starts_with('!') {
        return Vec::new();
    }

    let trimmed = pattern.trim_end_matches('/');
    if let Some(prefix) = trimmed.strip_suffix("/*") {
        let base = root.join(prefix);
        return child_dirs(&base);
    }
    if let Some(prefix) = trimmed.strip_suffix("/**") {
        let base = root.join(prefix);
        return child_dirs(&base);
    }
    if trimmed.contains('*') {
        return Vec::new();
    }

    vec![root.join(trimmed)]
}

fn child_dirs(path: &Path) -> Vec<PathBuf> {
    let Ok(entries) = std::fs::read_dir(path) else {
        return Vec::new();
    };

    entries
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| path.is_dir())
        .collect()
}

/// Extracts a displayable version string from a package.json version field.
/// Returns None for non-version specifiers: workspace:*, file:…, *, or empty.
pub(crate) fn extract_version(s: &str) -> Option<String> {
    if s.is_empty()
        || s == "*"
        || s.starts_with("workspace:")
        || s.starts_with("file:")
        || s.starts_with("link:")
        || s.starts_with("git+")
        || s.starts_with("github:")
        || s.starts_with("http")
    {
        return None;
    }
    let stripped = s.trim_start_matches(['^', '~', '=', '>']);
    Some(stripped.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::tempdir;

    #[test]
    fn detects_react_native_and_expo() {
        let dir = tempdir().unwrap();
        let pkg = dir.path().join("package.json");
        let mut f = std::fs::File::create(&pkg).unwrap();
        write!(
            f,
            r#"{{"dependencies": {{"react-native": "^0.74.0", "expo": "~51.0.0", "react": "18.2.0"}}}}"#
        )
        .unwrap();

        let frameworks = detect_frameworks(dir.path());
        assert!(frameworks.iter().any(
            |f| matches!(f, DetectedFramework::ReactNative { version: Some(v) } if v == "0.74.0")
        ));
        assert!(
            frameworks.iter().any(
                |f| matches!(f, DetectedFramework::Expo { version: Some(v) } if v == "51.0.0")
            )
        );
        assert!(
            frameworks
                .iter()
                .any(|f| matches!(f, DetectedFramework::React { .. }))
        );
    }

    #[test]
    fn returns_empty_without_package_json() {
        let dir = tempdir().unwrap();
        assert!(detect_frameworks(dir.path()).is_empty());
    }

    #[test]
    fn workspace_and_file_refs_produce_no_version() {
        let dir = tempdir().unwrap();
        let pkg = dir.path().join("package.json");
        let mut f = std::fs::File::create(&pkg).unwrap();
        write!(
            f,
            r#"{{"dependencies": {{"react-native": "workspace:*", "react": "file:../react"}}}}"#
        )
        .unwrap();

        let frameworks = detect_frameworks(dir.path());
        assert!(
            frameworks
                .iter()
                .any(|f| matches!(f, DetectedFramework::ReactNative { version: None }))
        );
        assert!(
            frameworks
                .iter()
                .any(|f| matches!(f, DetectedFramework::React { version: None }))
        );
    }

    #[test]
    fn detects_nextjs() {
        let dir = tempdir().unwrap();
        let pkg = dir.path().join("package.json");
        let mut f = std::fs::File::create(&pkg).unwrap();
        write!(
            f,
            r#"{{"dependencies": {{"next": "14.0.0", "react": "18.0.0"}}}}"#
        )
        .unwrap();

        let frameworks = detect_frameworks(dir.path());
        assert!(
            frameworks
                .iter()
                .any(|f| matches!(f, DetectedFramework::NextJs { .. }))
        );
        assert!(
            frameworks
                .iter()
                .any(|f| matches!(f, DetectedFramework::React { .. }))
        );
    }

    #[test]
    fn detects_framework_projects_from_workspaces() {
        let dir = tempdir().unwrap();
        let pkg = dir.path().join("package.json");
        let mut f = std::fs::File::create(&pkg).unwrap();
        write!(f, r#"{{"workspaces": ["apps/*"]}}"#).unwrap();

        std::fs::create_dir_all(dir.path().join("apps/mobile")).unwrap();
        let mut mobile =
            std::fs::File::create(dir.path().join("apps/mobile/package.json")).unwrap();
        write!(
            mobile,
            r#"{{"dependencies": {{"react-native": "0.76.0", "expo": "53.0.0"}}}}"#
        )
        .unwrap();

        let projects = detect_framework_projects(dir.path());

        assert_eq!(projects.len(), 1);
        assert_eq!(projects[0].path, PathBuf::from("apps/mobile"));
        assert!(
            projects[0]
                .frameworks
                .iter()
                .any(|f| matches!(f, DetectedFramework::ReactNative { .. }))
        );
        assert!(projects[0].react_native.is_some());
    }
}