ferrflow 7.14.0

Universal semantic versioning for monorepos and classic repos
Documentation
use std::collections::HashSet;

use super::Config;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GroupKind {
    Linked,
    Fixed,
}

impl GroupKind {
    pub fn label(self) -> &'static str {
        match self {
            GroupKind::Linked => "linked",
            GroupKind::Fixed => "fixed",
        }
    }
}

#[derive(Debug, Clone)]
pub struct PackageGroup {
    pub kind: GroupKind,
    pub members: Vec<String>,
}

impl Config {
    pub fn package_groups(&self) -> Vec<PackageGroup> {
        let linked = self.workspace.linked.iter().map(|m| PackageGroup {
            kind: GroupKind::Linked,
            members: m.clone(),
        });
        let fixed = self.workspace.fixed.iter().map(|m| PackageGroup {
            kind: GroupKind::Fixed,
            members: m.clone(),
        });
        linked.chain(fixed).collect()
    }

    pub fn validate_groups(&self) -> Result<(), Vec<String>> {
        let groups = self.package_groups();
        if groups.is_empty() {
            return Ok(());
        }

        let known: HashSet<&str> = self.packages.iter().map(|p| p.name.as_str()).collect();
        let mut errors = Vec::new();
        let mut assigned: HashSet<&str> = HashSet::new();

        for group in &groups {
            let kind = group.kind.label();
            if group.members.len() < 2 {
                errors.push(format!(
                    "{kind} group {:?} must list at least two packages",
                    group.members
                ));
            }
            let mut seen_in_group: HashSet<&str> = HashSet::new();
            for member in &group.members {
                if !known.contains(member.as_str()) {
                    errors.push(format!(
                        "package '{member}' in a {kind} group is not defined in package[]"
                    ));
                }
                if !seen_in_group.insert(member.as_str()) {
                    errors.push(format!(
                        "package '{member}' is listed twice in the same {kind} group"
                    ));
                    continue;
                }
                if !assigned.insert(member.as_str()) {
                    errors.push(format!(
                        "package '{member}' appears in more than one linked/fixed group"
                    ));
                }
            }
        }

        if errors.is_empty() {
            Ok(())
        } else {
            Err(errors)
        }
    }
}

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

    fn config(packages: &[&str], linked: &str, fixed: &str) -> Config {
        let pkgs: Vec<String> = packages
            .iter()
            .map(|n| format!(r#"{{"name":"{n}","path":"{n}"}}"#))
            .collect();
        serde_json::from_str(&format!(
            r#"{{"workspace":{{"linked":{linked},"fixed":{fixed}}},"package":[{}]}}"#,
            pkgs.join(",")
        ))
        .unwrap()
    }

    #[test]
    fn valid_groups_pass() {
        let c = config(&["a", "b", "c"], r#"[["a","b"]]"#, r#"[["c","a"]]"#);
        assert!(c.validate_groups().is_err());

        let c = config(&["a", "b", "c"], r#"[["a","b"]]"#, "[]");
        assert!(c.validate_groups().is_ok());
    }

    #[test]
    fn member_missing_from_packages_is_an_error() {
        let c = config(&["a"], r#"[["a","ghost"]]"#, "[]");
        let err = c.validate_groups().unwrap_err();
        assert!(
            err.iter()
                .any(|e| e.contains("ghost") && e.contains("not defined"))
        );
    }

    #[test]
    fn member_in_two_groups_is_an_error() {
        let c = config(&["a", "b", "c"], r#"[["a","b"]]"#, r#"[["a","c"]]"#);
        let err = c.validate_groups().unwrap_err();
        assert!(err.iter().any(|e| e.contains("more than one")));
    }

    #[test]
    fn singleton_group_is_an_error() {
        let c = config(&["a", "b"], r#"[["a"]]"#, "[]");
        let err = c.validate_groups().unwrap_err();
        assert!(err.iter().any(|e| e.contains("at least two")));
    }
}