vcs-modify-guard 0.1.0

A library for checking whether it is safe to modify files in a VCS worktree.
Documentation
use std::assert_matches;

use super::*;
use crate::{repository::FileChange, testing};

const WORKTREE: &str = "repo";

#[derive(Clone, Debug)]
struct StubRepo {
    worktree: PathBuf,
    resolve_path_result: Option<PathBuf>,
    expected_path_changes_wt_path: Option<PathBuf>,
    path_changes_result: Option<RepositoryChanges>,
    repository_changes_result: Option<RepositoryChanges>,
}

impl Default for StubRepo {
    fn default() -> Self {
        Self {
            worktree: PathBuf::from(WORKTREE),
            resolve_path_result: None,
            expected_path_changes_wt_path: None,
            path_changes_result: None,
            repository_changes_result: None,
        }
    }
}

impl StubRepo {
    fn with_resolve_path_result<P>(mut self, wt_path: P) -> Self
    where
        P: Into<PathBuf>,
    {
        self.resolve_path_result = Some(wt_path.into());
        self
    }

    fn with_expected_path_changes_wt_path<P>(mut self, wt_path: P) -> Self
    where
        P: Into<PathBuf>,
    {
        self.expected_path_changes_wt_path = Some(wt_path.into());
        self
    }

    fn with_path_changes<I>(mut self, files: I) -> Self
    where
        I: IntoIterator<Item = FileChange>,
    {
        self.path_changes_result = RepositoryChanges::new(files);
        self
    }

    fn with_repository_changes<I>(mut self, files: I) -> Self
    where
        I: IntoIterator<Item = FileChange>,
    {
        self.repository_changes_result = RepositoryChanges::new(files);
        self
    }
}

impl AllowOptionsRepository for StubRepo {
    fn worktree(&self) -> &Path {
        &self.worktree
    }

    fn resolve_path(&self, path: &Path) -> Result<PathBuf, ModifyGuardError> {
        Ok(self
            .resolve_path_result
            .clone()
            .unwrap_or_else(|| path.to_path_buf()))
    }

    fn path_changes(&self, wt_path: &Path) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
        if let Some(expected) = &self.expected_path_changes_wt_path {
            assert_eq!(wt_path, expected);
        }
        Ok(self.path_changes_result.clone())
    }

    fn repository_changes(&self) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
        Ok(self.repository_changes_result.clone())
    }
}

#[derive(Debug, Default)]
struct StubBackend {
    repo: Option<StubRepo>,
}

impl StubBackend {
    fn with_repo(mut self, repo: StubRepo) -> Self {
        self.repo = Some(repo);
        self
    }
}

impl AllowOptionsBackend for StubBackend {
    type Repo = StubRepo;

    fn discover(&self, _path: &Path) -> Result<Option<Self::Repo>, ModifyGuardError> {
        Ok(self.repo.clone())
    }
}

#[expect(
    clippy::needless_pass_by_value,
    reason = "Consuming the value keeps call sites concise in these assertion helpers"
)]
#[track_caller]
fn assert_safe(safety: ModificationSafety) {
    assert_matches!(safety, ModificationSafety::Safe);
}

#[expect(
    clippy::needless_pass_by_value,
    reason = "Consuming the value keeps call sites concise in these assertion helpers"
)]
#[track_caller]
fn assert_unsafe_due_to_no_vcs(safety: ModificationSafety) {
    assert_matches!(
        safety,
        ModificationSafety::Unsafe(UnsafeModificationReason::NoVcs)
    );
}

#[track_caller]
fn assert_unsafe_due_to_dirty(
    safety: ModificationSafety,
    expected_dirty_files: &[&str],
    expected_staged_files: &[&str],
) {
    let ModificationSafety::Unsafe(UnsafeModificationReason::Dirty {
        worktree,
        dirty_files,
        staged_files,
    }) = safety
    else {
        panic!("expected Unsafe(Dirty)");
    };
    assert_eq!(worktree, PathBuf::from(WORKTREE));
    assert_eq!(
        dirty_files,
        expected_dirty_files
            .iter()
            .map(PathBuf::from)
            .collect::<Vec<_>>()
    );
    assert_eq!(
        staged_files,
        expected_staged_files
            .iter()
            .map(PathBuf::from)
            .collect::<Vec<_>>()
    );
}

#[track_caller]
fn assert_unsafe_due_to_staged(safety: ModificationSafety, expected_staged_files: &[&str]) {
    let ModificationSafety::Unsafe(UnsafeModificationReason::Staged {
        worktree,
        staged_files,
    }) = safety
    else {
        panic!("expected Unsafe(Staged)");
    };
    assert_eq!(worktree, PathBuf::from(WORKTREE));
    assert_eq!(
        staged_files,
        expected_staged_files
            .iter()
            .map(PathBuf::from)
            .collect::<Vec<_>>()
    );
}

#[test]
fn check_safe_to_modify_returns_safe_when_allow_no_vcs_is_enabled() {
    let backend = StubBackend::default();

    let safety = AllowOptions::new()
        .allow_no_vcs(true)
        .check_safe_to_modify_with_backend("target", &backend)
        .unwrap();

    assert_safe(safety);
}

#[test]
fn check_safe_to_modify_returns_unsafe_due_to_no_vcs_when_repository_is_not_found() {
    let backend = StubBackend::default();

    let safety = AllowOptions::new()
        .check_safe_to_modify_with_backend("target", &backend)
        .unwrap();

    assert_unsafe_due_to_no_vcs(safety);
}

#[test]
fn check_safe_to_modify_returns_safe_for_clean_path() {
    let backend = StubBackend::default().with_repo(StubRepo::default());

    let safety = AllowOptions::new()
        .check_safe_to_modify_with_backend("target", &backend)
        .unwrap();

    assert_safe(safety);
}

#[test]
fn check_safe_to_modify_returns_unsafe_due_to_staged_for_staged_only_path() {
    let backend = StubBackend::default()
        .with_repo(StubRepo::default().with_path_changes([testing::staged_file("staged.txt")]));

    let safety = AllowOptions::new()
        .check_safe_to_modify_with_backend("target", &backend)
        .unwrap();

    assert_unsafe_due_to_staged(safety, &["staged.txt"]);
}

#[test]
fn check_safe_to_modify_returns_safe_for_staged_only_path_when_allow_staged_is_enabled() {
    let backend = StubBackend::default()
        .with_repo(StubRepo::default().with_path_changes([testing::staged_file("staged.txt")]));

    let safety = AllowOptions::new()
        .allow_staged(true)
        .check_safe_to_modify_with_backend("target", &backend)
        .unwrap();

    assert_safe(safety);
}

#[test]
fn check_safe_to_modify_returns_unsafe_due_to_dirty_for_dirty_only_path() {
    let backend = StubBackend::default().with_repo(StubRepo::default().with_path_changes([
        testing::dirty_file("a-dirty.txt"),
        testing::dirty_file("b-dirty.txt"),
    ]));

    let safety = AllowOptions::new()
        .check_safe_to_modify_with_backend("target", &backend)
        .unwrap();

    assert_unsafe_due_to_dirty(safety, &["a-dirty.txt", "b-dirty.txt"], &[]);
}

#[test]
fn check_safe_to_modify_returns_unsafe_due_to_dirty_with_dirty_and_staged_files() {
    let backend = StubBackend::default().with_repo(StubRepo::default().with_path_changes([
        testing::dirty_file("a-dirty.txt"),
        testing::staged_file("b-staged.txt"),
        testing::dirty_and_staged_file("c-dirty-and-staged.txt"),
        testing::dirty_file("d-dirty.txt"),
    ]));

    let safety = AllowOptions::new()
        .check_safe_to_modify_with_backend("target", &backend)
        .unwrap();

    assert_unsafe_due_to_dirty(
        safety,
        &["a-dirty.txt", "c-dirty-and-staged.txt", "d-dirty.txt"],
        &["b-staged.txt", "c-dirty-and-staged.txt"],
    );
}

#[test]
fn check_safe_to_modify_returns_unsafe_due_to_dirty_when_allow_staged_is_enabled() {
    let backend = StubBackend::default().with_repo(StubRepo::default().with_path_changes([
        testing::dirty_file("a-dirty.txt"),
        testing::staged_file("b-staged.txt"),
        testing::dirty_and_staged_file("c-dirty-and-staged.txt"),
    ]));

    let safety = AllowOptions::new()
        .allow_staged(true)
        .check_safe_to_modify_with_backend("target", &backend)
        .unwrap();

    assert_unsafe_due_to_dirty(safety, &["a-dirty.txt", "c-dirty-and-staged.txt"], &[]);
}

#[test]
fn check_safe_to_modify_checks_only_the_queried_path_by_default() {
    let backend = StubBackend::default().with_repo(
        StubRepo::default().with_repository_changes([testing::dirty_file("root-dirty.txt")]),
    );

    let safety = AllowOptions::new()
        .check_safe_to_modify_with_backend("subdir", &backend)
        .unwrap();

    assert_safe(safety);
}

#[test]
fn check_safe_to_modify_resolves_path_before_querying_path_changes() {
    let backend = StubBackend::default().with_repo(
        StubRepo::default()
            .with_resolve_path_result("resolved/subdir")
            .with_expected_path_changes_wt_path("resolved/subdir"),
    );

    let safety = AllowOptions::new()
        .check_safe_to_modify_with_backend("input/subdir", &backend)
        .unwrap();

    assert_safe(safety);
}

#[test]
fn check_safe_to_modify_checks_the_entire_repository_when_enabled() {
    let backend = StubBackend::default().with_repo(
        StubRepo::default().with_repository_changes([testing::dirty_file("root-dirty.txt")]),
    );

    let safety = AllowOptions::new()
        .check_entire_repository(true)
        .check_safe_to_modify_with_backend("subdir", &backend)
        .unwrap();

    assert_unsafe_due_to_dirty(safety, &["root-dirty.txt"], &[]);
}