ferrflow 5.12.0

Universal semantic versioning for monorepos and classic repos
Documentation
use super::util::*;
use crate::config::PackageConfig;

mod cache_flow {
    use crate::cache;
    use crate::git::open_repo;
    use crate::test_utils::{commit_file, init_repo, with_cwd};
    use crate::timing::Timing;

    fn setup(dir: &std::path::Path) {
        std::fs::write(
            dir.join("Cargo.toml"),
            "[package]\nname = \"app\"\nversion = \"1.0.0\"\n",
        )
        .unwrap();
        std::fs::write(
            dir.join(".ferrflow"),
            r#"{"package":[{"name":"app","path":".","versionedFiles":[{"path":"Cargo.toml","format":"toml"}]}]}"#,
        )
        .unwrap();
    }

    fn run_check(dir: &std::path::Path) {
        let config = dir.join(".ferrflow");
        with_cwd(dir, || {
            super::super::check(
                Some(&config),
                false,
                false,
                None,
                false,
                &mut Timing::new(false),
            )
        })
        .unwrap();
    }

    #[test]
    fn first_check_writes_cache_second_hits_with_same_key() {
        let (dir, _repo) = init_repo();
        setup(dir.path());
        commit_file(dir.path(), "a.txt", "x", "feat: a", 1_950_000_000);

        run_check(dir.path());

        let repo = open_repo(dir.path()).unwrap();
        let cache_dir = cache::cache_dir(&repo);
        let key = cache::compute_key(
            &repo,
            dir.path(),
            Some(&dir.path().join(".ferrflow")),
            "text",
        )
        .expect("key");
        let first = cache::read(&cache_dir, &key).expect("cache written on miss");

        run_check(dir.path());
        let second = cache::read(&cache_dir, &key).expect("still a hit");
        assert_eq!(first.text_lines, second.text_lines);
        assert!(!first.text_lines.is_empty());
    }

    #[test]
    fn cache_busts_after_a_new_commit() {
        let (dir, _repo) = init_repo();
        setup(dir.path());
        commit_file(dir.path(), "a.txt", "x", "feat: a", 1_950_000_100);
        run_check(dir.path());

        let repo = open_repo(dir.path()).unwrap();
        let cache_dir = cache::cache_dir(&repo);
        let old_key = cache::compute_key(
            &repo,
            dir.path(),
            Some(&dir.path().join(".ferrflow")),
            "text",
        )
        .unwrap();

        commit_file(dir.path(), "b.txt", "y", "feat: b", 1_950_000_200);
        let repo2 = open_repo(dir.path()).unwrap();
        let new_key = cache::compute_key(
            &repo2,
            dir.path(),
            Some(&dir.path().join(".ferrflow")),
            "text",
        )
        .unwrap();
        assert!(cache::read(&cache_dir, &new_key).is_none());
        assert!(cache::read(&cache_dir, &old_key).is_some());
    }
}

#[test]
fn pick_higher_semver_prefers_tag_when_tag_is_higher() {
    assert_eq!(pick_higher_semver("2.0.0", "3.0.0"), "3.0.0");
}

#[test]
fn pick_higher_semver_prefers_file_when_file_is_higher() {
    assert_eq!(pick_higher_semver("5.0.0", "2.0.0"), "5.0.0");
}

#[test]
fn pick_higher_semver_returns_tag_on_equality() {
    assert_eq!(pick_higher_semver("2.1.0", "2.1.0"), "2.1.0");
}

#[test]
fn pick_higher_semver_falls_back_to_tag_when_file_is_invalid() {
    assert_eq!(pick_higher_semver("garbage", "2.0.0"), "2.0.0");
}

#[test]
fn pick_higher_semver_falls_back_to_file_when_tag_is_invalid() {
    assert_eq!(pick_higher_semver("2.0.0", "garbage"), "2.0.0");
}

#[test]
fn pick_higher_semver_strips_leading_v() {
    assert_eq!(pick_higher_semver("v2.0.0", "v3.0.0"), "v3.0.0");
}

fn make_pkg(name: &str, path: &str, shared: &[&str]) -> PackageConfig {
    PackageConfig {
        name: name.into(),
        path: path.into(),
        versioned_files: vec![],
        changelog: None,
        shared_paths: shared.iter().map(|s| s.to_string()).collect(),
        depends_on: vec![],
        versioning: None,
        tag_template: None,
        hooks: None,
        floating_tags: None,
        publishers: vec![],
    }
}

#[test]
fn single_package_always_touched() {
    let pkg = make_pkg("app", ".", &[]);
    let files = vec!["README.md".to_string()];
    assert!(is_package_touched(&pkg, &files, false));
}

#[test]
fn monorepo_root_package_always_touched() {
    let pkg = make_pkg("root", ".", &[]);
    let files = vec!["something.rs".to_string()];
    assert!(is_package_touched(&pkg, &files, true));
}

#[test]
fn monorepo_package_touched_by_own_files() {
    let pkg = make_pkg("api", "packages/api", &[]);
    let files = vec!["packages/api/src/main.rs".to_string()];
    assert!(is_package_touched(&pkg, &files, true));
}

#[test]
fn monorepo_package_not_touched_by_other_files() {
    let pkg = make_pkg("api", "packages/api", &[]);
    let files = vec!["packages/site/index.ts".to_string()];
    assert!(!is_package_touched(&pkg, &files, true));
}

#[test]
fn monorepo_package_touched_by_shared_path() {
    let pkg = make_pkg("api", "packages/api", &["packages/shared/"]);
    let files = vec!["packages/shared/types.ts".to_string()];
    assert!(is_package_touched(&pkg, &files, true));
}

#[test]
fn monorepo_shared_path_trailing_slash_trimmed() {
    let pkg = make_pkg("api", "packages/api", &["lib/"]);
    let files = vec!["lib/utils.rs".to_string()];
    assert!(is_package_touched(&pkg, &files, true));
}

#[test]
fn monorepo_no_changed_files() {
    let pkg = make_pkg("api", "packages/api", &[]);
    let files: Vec<String> = vec![];
    assert!(!is_package_touched(&pkg, &files, true));
}

#[test]
fn monorepo_path_with_dot_slash_prefix() {
    let pkg = make_pkg("api", "./packages/api", &[]);
    let files = vec!["packages/api/src/main.rs".to_string()];
    assert!(is_package_touched(&pkg, &files, true));
}

#[test]
fn single_package_mode_always_touched() {
    let pkg = make_pkg("api", "packages/api", &[]);
    let files = vec!["unrelated/file.rs".to_string()];
    assert!(is_package_touched(&pkg, &files, false));
}

#[test]
fn monorepo_empty_path_is_root() {
    let pkg = make_pkg("root", "", &[]);
    let files = vec!["anything.rs".to_string()];
    assert!(is_package_touched(&pkg, &files, true));
}

#[test]
fn monorepo_exact_shared_path_file() {
    let pkg = make_pkg("api", "packages/api", &["shared-config.json"]);
    let files = vec!["shared-config.json".to_string()];
    assert!(is_package_touched(&pkg, &files, true));
}

#[test]
fn monorepo_multiple_shared_paths() {
    let pkg = make_pkg("api", "packages/api", &["lib/", "proto/"]);
    let files = vec!["proto/schema.proto".to_string()];
    assert!(is_package_touched(&pkg, &files, true));
}

#[test]
fn monorepo_similar_prefix_no_false_positive() {
    let pkg = make_pkg("api", "packages/api", &[]);
    let files = vec!["packages/api-docs/README.md".to_string()];
    assert!(!is_package_touched(&pkg, &files, true));
}

#[test]
fn monorepo_shared_path_with_trailing_slash() {
    let pkg = make_pkg("api", "packages/api", &["packages/shared/"]);
    let files = vec!["packages/shared/types.ts".to_string()];
    assert!(is_package_touched(&pkg, &files, true));
}

#[test]
fn monorepo_empty_changed_files_single_package() {
    let pkg = make_pkg("app", "packages/app", &[]);
    let files: Vec<String> = vec![];
    assert!(is_package_touched(&pkg, &files, false));
}

#[test]
fn parse_force_version_single_repo() {
    let fv = "1.2.3";
    let result: Option<(Option<&str>, &str)> = if let Some(at_pos) = fv.find('@') {
        let name = &fv[..at_pos];
        let version = &fv[at_pos + 1..];
        Some((Some(name), version))
    } else {
        Some((None, fv))
    };
    assert_eq!(result, Some((None, "1.2.3")));
}

#[test]
fn parse_force_version_monorepo() {
    let fv = "api@2.0.0";
    let result: Option<(Option<&str>, &str)> = if let Some(at_pos) = fv.find('@') {
        let name = &fv[..at_pos];
        let version = &fv[at_pos + 1..];
        Some((Some(name), version))
    } else {
        Some((None, fv))
    };
    assert_eq!(result, Some((Some("api"), "2.0.0")));
}

#[test]
fn parse_force_version_with_v_prefix() {
    let fv = "v3.0.0";
    let clean = fv.strip_prefix('v').unwrap_or(fv);
    assert!(semver::Version::parse(clean).is_ok());
}

#[test]
fn parse_force_version_invalid_semver() {
    let fv = "not-a-version";
    let clean = fv.strip_prefix('v').unwrap_or(fv);
    assert!(semver::Version::parse(clean).is_err());
}