ferrflow 7.18.0

Universal semantic versioning for monorepos and classic repos
Documentation
use super::report::{Check, Report, Section, Status};

#[test]
fn exit_code_is_zero_when_all_green() {
    let report = Report::build(vec![Section::new(
        "Repo",
        vec![Check::ok("git repository", None), Check::info("tags", None)],
    )]);
    assert_eq!(report.exit_code, 0);
    assert_eq!(report.status, Status::Ok);
}

#[test]
fn a_warning_sets_exit_code_one() {
    let report = Report::build(vec![Section::new(
        "Repo",
        vec![Check::ok("a", None), Check::warn("b", Some("dirty".into()))],
    )]);
    assert_eq!(report.exit_code, 1);
    assert_eq!(report.status, Status::Warn);
}

#[test]
fn an_error_sets_exit_code_two_even_with_warnings() {
    let report = Report::build(vec![Section::new(
        "Repo",
        vec![
            Check::warn("a", None),
            Check::error("b", Some("no repo".into())),
        ],
    )]);
    assert_eq!(report.exit_code, 2);
    assert_eq!(report.status, Status::Error);
}

#[test]
fn json_shape_is_stable_for_fixtures() {
    let report = Report::build(vec![Section::new(
        "Repo",
        vec![Check::ok("git repository", Some("HEAD at abc1234".into()))],
    )]);
    let json: serde_json::Value = serde_json::from_str(&report.to_json().unwrap()).unwrap();
    assert_eq!(json["status"], "ok");
    assert_eq!(json["exit_code"], 0);
    assert_eq!(json["sections"][0]["title"], "Repo");
    assert_eq!(json["sections"][0]["checks"][0]["name"], "git repository");
    assert_eq!(json["sections"][0]["checks"][0]["status"], "ok");
    assert_eq!(
        json["sections"][0]["checks"][0]["detail"],
        "HEAD at abc1234"
    );
}

mod end_to_end {
    use crate::config::Config;
    use crate::doctor::checks;
    use crate::doctor::report::Status;
    use crate::git::{get_repo_root, open_repo};
    use crate::test_utils::{commit_file, git, init_repo, with_cwd};
    use std::path::Path;

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

    fn find<'a>(section: &'a super::Section, name: &str) -> &'a super::Check {
        section
            .checks
            .iter()
            .find(|c| c.name == name)
            .unwrap_or_else(|| panic!("missing check '{name}'"))
    }

    #[test]
    fn fresh_repo_without_commits_flags_the_missing_pieces() {
        let (dir, _repo) = init_repo();
        let root = dir.path();
        with_cwd(root, || {
            let repo = open_repo(root).ok();
            let repo_ref = repo.as_ref();
            let section = checks::repo_section(repo_ref, None, root);
            assert_eq!(find(&section, "commit history").status, Status::Error);
            Ok(())
        })
        .unwrap();
    }

    #[test]
    fn misconfigured_package_path_is_pinpointed() {
        let (dir, repo) = init_repo();
        let root = dir.path();
        write(
            root,
            ".ferrflow",
            r#"{"package":[{"name":"api","path":"packages/api","versionedFiles":[{"path":"packages/api/Cargo.toml","format":"toml"}]}]}"#,
        );
        commit_file(root, "seed.txt", "x", "chore: seed", 1_900_000_000);

        let config = Config::load(&get_repo_root(&repo).unwrap(), None).ok();
        let discovered = Config::discovered_config_paths(root);
        let section = checks::config_section(config.as_ref(), None, &discovered, root);

        assert!(
            section
                .checks
                .iter()
                .any(|c| c.status == Status::Error && c.name.contains("packages/api")),
            "expected an error naming the missing package path, got: {:?}",
            section.checks
        );
    }

    #[test]
    fn multiple_config_files_are_flagged_as_ambiguous() {
        let (dir, _repo) = init_repo();
        let root = dir.path();
        write(root, "ferrflow.json", r#"{"package":[]}"#);
        write(root, "ferrflow.toml", "");

        let discovered = Config::discovered_config_paths(root);
        let section = checks::config_section(None, None, &discovered, root);

        let config_file = find(&section, "config file");
        assert_eq!(config_file.status, Status::Error);
        assert!(config_file.detail.as_deref().unwrap().contains("ambiguous"));
        assert_eq!(section.checks.len(), 1);
    }

    #[test]
    fn ci_section_reports_the_pinned_ferrflow_action() {
        let (dir, _repo) = init_repo();
        let root = dir.path();
        write(
            root,
            ".github/workflows/release.yml",
            "jobs:\n  release:\n    steps:\n      - uses: FerrLabs/FerrFlow@v4\n",
        );
        let section = checks::ci_section(root);
        let action = find(&section, "FerrFlow action");
        assert_eq!(action.status, Status::Ok);
        assert_eq!(action.detail.as_deref(), Some("FerrLabs/FerrFlow@v4"));
    }

    #[test]
    fn clean_configured_repo_is_all_green() {
        let (dir, repo) = init_repo();
        let root = dir.path();
        write(
            root,
            "Cargo.toml",
            "[package]\nname = \"app\"\nversion = \"1.0.0\"\n",
        );
        write(
            root,
            ".ferrflow",
            r#"{"package":[{"name":"app","path":".","versionedFiles":[{"path":"Cargo.toml","format":"toml"}]}]}"#,
        );
        commit_file(root, "seed.txt", "x", "chore: seed", 1_900_000_000);
        git(root, &["tag", "v1.0.0"]);

        let config = Config::load(&get_repo_root(&repo).unwrap(), None).ok();
        let discovered = Config::discovered_config_paths(root);
        let section = checks::config_section(config.as_ref(), None, &discovered, root);

        assert!(
            section.checks.iter().all(|c| c.status != Status::Error),
            "a clean config should raise no errors: {:?}",
            section.checks
        );
        assert_eq!(find(&section, "config parses").status, Status::Ok);
    }
}

mod lockfiles {
    use super::super::checks::versioning_section;
    use super::{Check, Status};
    use crate::config::Config;
    use std::path::Path;

    struct Fixture {
        dir: tempfile::TempDir,
    }

    impl Fixture {
        fn new(workspace: &str, manifest_version: &str, lockfile: Option<&str>) -> Self {
            let dir = tempfile::tempdir().unwrap();
            let root = dir.path();
            std::fs::create_dir_all(root.join("api")).unwrap();
            std::fs::write(
                root.join("api/Cargo.toml"),
                format!("[package]\nname = \"ferrflow-api\"\nversion = \"{manifest_version}\"\n"),
            )
            .unwrap();
            if let Some(recorded) = lockfile {
                std::fs::write(
                    root.join("api/Cargo.lock"),
                    format!("[[package]]\nname = \"ferrflow-api\"\nversion = \"{recorded}\"\n"),
                )
                .unwrap();
            }
            std::fs::write(
                root.join(".ferrflow"),
                format!(
                    r#"{{"workspace":{{{workspace}}},"package":[{{"name":"api","path":"api","versionedFiles":[{{"path":"api/Cargo.toml","format":"toml"}}]}}]}}"#
                ),
            )
            .unwrap();
            Self { dir }
        }

        fn root(&self) -> &Path {
            self.dir.path()
        }

        fn lockfile_check(&self) -> Option<Check> {
            let config = Config::load(self.root(), Some(&self.root().join(".ferrflow"))).unwrap();
            versioning_section(Some(&config), self.root())
                .checks
                .into_iter()
                .find(|c| c.name.contains("lockfile"))
        }
    }

    #[test]
    fn a_lockfile_that_disagrees_with_its_manifest_is_reported() {
        let fx = Fixture::new(r#""updateLockfiles":true"#, "2026.8.1", Some("6.1.0"));

        let check = fx.lockfile_check().expect("a lockfile check");

        assert_eq!(check.status, Status::Warn);
        let detail = check.detail.unwrap_or_default();
        assert!(
            detail.contains("6.1.0") && detail.contains("2026.8.1"),
            "{detail}"
        );
        assert!(
            detail.contains("--locked"),
            "the detail should say what actually breaks: {detail}"
        );
    }

    #[test]
    fn a_lockfile_left_out_of_releases_is_flagged_before_it_drifts() {
        let fx = Fixture::new("", "1.0.0", Some("1.0.0"));

        let check = fx.lockfile_check().expect("a lockfile check");

        assert_eq!(
            check.status,
            Status::Warn,
            "versions agree today, but nothing keeps them agreeing on the next release"
        );
        assert!(
            check.detail.unwrap_or_default().contains("updateLockfiles"),
            "the warning has to name the setting that fixes it"
        );
    }

    #[test]
    fn a_synced_lockfile_that_agrees_is_not_a_warning() {
        let fx = Fixture::new(r#""updateLockfiles":true"#, "1.0.0", Some("1.0.0"));

        let check = fx.lockfile_check().expect("a lockfile check");

        assert_eq!(check.status, Status::Ok);
    }

    #[test]
    fn a_package_without_a_lockfile_is_not_mentioned() {
        let fx = Fixture::new(r#""updateLockfiles":true"#, "1.0.0", None);

        assert!(
            fx.lockfile_check().is_none(),
            "nothing to say about a lockfile that does not exist"
        );
    }
}