ferrflow 7.13.5

Universal semantic versioning for monorepos and classic repos
Documentation
use std::path::Path;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct DiscoveredPackage {
    pub name: String,
    pub path: String,
}

const SKIP_DIRS: &[&str] = &[
    "node_modules",
    ".git",
    "target",
    "dist",
    "build",
    ".next",
    ".turbo",
    "coverage",
    "vendor",
];

const MAX_DEPTH: usize = 6;

pub(super) fn discover(root: &Path) -> Vec<DiscoveredPackage> {
    let globs = workspace_globs(root);
    if globs.is_empty() {
        return Vec::new();
    }

    let mut found = Vec::new();
    collect(root, root, 0, &globs, &mut found);
    found.sort_by(|a, b| a.path.cmp(&b.path));
    found.dedup_by(|a, b| a.path == b.path);

    let excluded: Vec<&str> = globs.iter().filter_map(|g| g.strip_prefix('!')).collect();
    found.retain(|p| !excluded.iter().any(|g| glob_match::glob_match(g, &p.path)));
    found
}

fn workspace_globs(root: &Path) -> Vec<String> {
    let mut globs = globs_from_package_json(root);
    globs.extend(globs_from_pnpm_workspace(root));
    globs.sort();
    globs.dedup();
    globs
}

fn globs_from_package_json(root: &Path) -> Vec<String> {
    let Ok(raw) = std::fs::read_to_string(root.join("package.json")) else {
        return Vec::new();
    };
    let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) else {
        return Vec::new();
    };
    match value.get("workspaces") {
        Some(serde_json::Value::Array(items)) => string_list(items),
        Some(serde_json::Value::Object(obj)) => obj
            .get("packages")
            .and_then(|p| p.as_array())
            .map(|items| string_list(items))
            .unwrap_or_default(),
        _ => Vec::new(),
    }
}

fn globs_from_pnpm_workspace(root: &Path) -> Vec<String> {
    let raw = ["pnpm-workspace.yaml", "pnpm-workspace.yml"]
        .iter()
        .find_map(|name| std::fs::read_to_string(root.join(name)).ok());
    let Some(raw) = raw else {
        return Vec::new();
    };
    let Ok(value) = serde_norway::from_str::<serde_json::Value>(&raw) else {
        return Vec::new();
    };
    value
        .get("packages")
        .and_then(|p| p.as_array())
        .map(|items| string_list(items))
        .unwrap_or_default()
}

fn string_list(items: &[serde_json::Value]) -> Vec<String> {
    items
        .iter()
        .filter_map(|v| v.as_str())
        .map(normalise_glob)
        .collect()
}

fn normalise_glob(raw: &str) -> String {
    raw.trim_start_matches("./")
        .trim_end_matches('/')
        .to_string()
}

fn collect(
    root: &Path,
    dir: &Path,
    depth: usize,
    globs: &[String],
    found: &mut Vec<DiscoveredPackage>,
) {
    if depth > MAX_DEPTH {
        return;
    }
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
            continue;
        };
        if name.starts_with('.') || SKIP_DIRS.contains(&name) {
            continue;
        }
        if let Some(rel) = relative_slash_path(root, &path)
            && globs.iter().any(|g| matches_glob(g, &rel))
            && let Some(pkg) = read_manifest(&path, &rel)
        {
            found.push(pkg);
        }
        collect(root, &path, depth + 1, globs, found);
    }
}

fn matches_glob(glob: &str, rel: &str) -> bool {
    !glob.starts_with('!') && glob_match::glob_match(glob, rel)
}

fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
    let rel = path.strip_prefix(root).ok()?;
    Some(
        rel.components()
            .filter_map(|c| c.as_os_str().to_str())
            .collect::<Vec<_>>()
            .join("/"),
    )
}

fn read_manifest(dir: &Path, rel: &str) -> Option<DiscoveredPackage> {
    let manifest = dir.join("package.json");
    let raw = std::fs::read_to_string(&manifest).ok()?;
    let value = serde_json::from_str::<serde_json::Value>(&raw).ok()?;
    let name = value
        .get("name")
        .and_then(|n| n.as_str())
        .map(str::to_string)
        .or_else(|| dir.file_name().and_then(|n| n.to_str()).map(str::to_string))?;
    Some(DiscoveredPackage {
        name,
        path: rel.to_string(),
    })
}

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

    fn write(root: &Path, rel: &str, contents: &str) {
        let path = root.join(rel);
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, contents).unwrap();
    }

    fn pkg(name: &str) -> String {
        format!("{{\"name\": \"{name}\", \"version\": \"1.0.0\"}}")
    }

    #[test]
    fn npm_workspaces_array_is_expanded() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#);
        write(root, "packages/a/package.json", &pkg("@acme/a"));
        write(root, "packages/b/package.json", &pkg("@acme/b"));

        let found = discover(root);
        assert_eq!(
            found,
            vec![
                DiscoveredPackage {
                    name: "@acme/a".into(),
                    path: "packages/a".into()
                },
                DiscoveredPackage {
                    name: "@acme/b".into(),
                    path: "packages/b".into()
                },
            ]
        );
    }

    #[test]
    fn yarn_berry_object_form_is_expanded() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(
            root,
            "package.json",
            r#"{"workspaces": {"packages": ["libs/*"]}}"#,
        );
        write(root, "libs/one/package.json", &pkg("one"));

        let found = discover(root);
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].path, "libs/one");
    }

    #[test]
    fn pnpm_workspace_globs_are_read() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(root, "package.json", r#"{"name": "root"}"#);
        write(root, "pnpm-workspace.yaml", "packages:\n  - 'apps/*'\n");
        write(root, "apps/web/package.json", &pkg("@acme/web"));

        let found = discover(root);
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].name, "@acme/web");
        assert_eq!(found[0].path, "apps/web");
    }

    #[test]
    fn node_modules_is_never_walked() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#);
        write(root, "packages/a/package.json", &pkg("@acme/a"));
        write(
            root,
            "packages/a/node_modules/left-pad/package.json",
            &pkg("left-pad"),
        );
        write(root, "node_modules/react/package.json", &pkg("react"));

        let found = discover(root);
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].name, "@acme/a");
    }

    #[test]
    fn nested_globs_are_matched() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(root, "package.json", r#"{"workspaces": ["apps/*/pkg/*"]}"#);
        write(root, "apps/web/pkg/ui/package.json", &pkg("ui"));

        let found = discover(root);
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].path, "apps/web/pkg/ui");
    }

    #[test]
    fn a_directory_without_a_manifest_is_skipped() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#);
        std::fs::create_dir_all(root.join("packages/empty")).unwrap();
        write(root, "packages/real/package.json", &pkg("real"));

        let found = discover(root);
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].name, "real");
    }

    #[test]
    fn a_nameless_manifest_falls_back_to_its_directory() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#);
        write(root, "packages/tools/package.json", r#"{"private": true}"#);

        let found = discover(root);
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].name, "tools");
    }

    #[test]
    fn no_workspace_declaration_discovers_nothing() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(root, "package.json", r#"{"name": "solo"}"#);
        write(root, "packages/a/package.json", &pkg("@acme/a"));

        assert!(discover(root).is_empty());
    }

    #[test]
    fn negated_globs_exclude_their_matches() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write(
            root,
            "package.json",
            r#"{"workspaces": ["packages/*", "!packages/internal"]}"#,
        );
        write(root, "packages/keep/package.json", &pkg("keep"));
        write(root, "packages/internal/package.json", &pkg("internal"));

        let found = discover(root);
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].name, "keep");
    }
}