outpost-core 0.1.3

Core library for Git Outpost, a clone-backed alternative to git worktree workflows.
Documentation
#[allow(dead_code)]
mod common;

use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

use common::fixture::AbcFixture;
use outpost_core::selector::{OutpostSelector, resolve_entry, resolve_live_entry};
use outpost_core::{OutpostError, OutpostId, RegistryEntry, RemoteName, SourceRepo};

#[test]
fn selector_resolves_unique_id_prefix() {
    let fixture = AbcFixture::new();
    fixture.add_outpost("C").expect("add C");
    let source = fixture.source_repo().expect("source repo");
    let entry = single_entry(&source);
    let id = OutpostId::derive(source.work_tree(), &entry.path);
    let selector = OutpostSelector::from_cli_arg(&fixture.root, id.as_str()[..5].into());

    let resolved = resolve_live_entry(&source, &selector).expect("resolve prefix");

    assert_eq!(resolved.path, entry.path);
}

#[test]
fn selector_rejects_ambiguous_id_prefix() {
    let fixture = AbcFixture::new();
    let source = fixture.source_repo().expect("source repo");
    let (first, second, prefix) = colliding_paths(&fixture, &source);
    fs::create_dir(&first).expect("first dir");
    fs::create_dir(&second).expect("second dir");
    replace_registry(&source, [entry_at(&first), entry_at(&second)]);
    let selector = OutpostSelector::from_cli_arg(&fixture.root, prefix.clone().into());

    let err = resolve_entry(&source, &selector).expect_err("ambiguous prefix should fail");

    assert!(matches!(err, OutpostError::OutpostIdPrefixAmbiguous(actual) if actual == prefix));
}

#[test]
fn selector_rejects_missing_id_prefix() {
    let fixture = AbcFixture::new();
    fixture.add_outpost("C").expect("add C");
    let source = fixture.source_repo().expect("source repo");
    let missing = missing_prefix(source.work_tree(), &[single_entry(&source).path]);
    let selector = OutpostSelector::from_cli_arg(&fixture.root, missing.clone().into());

    let err = resolve_entry(&source, &selector).expect_err("missing prefix should fail");

    assert!(matches!(err, OutpostError::OutpostIdPrefixNotFound(actual) if actual == missing));
}

#[test]
fn selector_fails_closed_when_bare_hex_path_and_id_match_different_entries() {
    let fixture = AbcFixture::new();
    let source = fixture.source_repo().expect("source repo");
    let other = fixture.root.join("other");
    fs::create_dir(&other).expect("other dir");
    let prefix = OutpostId::derive(source.work_tree(), &canonical(&other)).as_str()[..5].to_owned();
    let hex_path = fixture.root.join(&prefix);
    fs::create_dir(&hex_path).expect("hex path dir");
    replace_registry(&source, [entry_at(&hex_path), entry_at(&other)]);
    let selector = OutpostSelector::from_cli_arg(&fixture.root, prefix.clone().into());

    let err = resolve_entry(&source, &selector).expect_err("selector should be ambiguous");

    assert!(matches!(err, OutpostError::OutpostSelectorAmbiguous(value) if value == prefix));
}

#[test]
fn selector_treats_trailing_separator_as_path_only() {
    let fixture = AbcFixture::new();
    let source = fixture.source_repo().expect("source repo");
    let other = fixture.root.join("other");
    fs::create_dir(&other).expect("other dir");
    let prefix = OutpostId::derive(source.work_tree(), &canonical(&other)).as_str()[..5].to_owned();
    let hex_path = fixture.root.join(&prefix);
    fs::create_dir(&hex_path).expect("hex path dir");
    replace_registry(&source, [entry_at(&hex_path), entry_at(&other)]);
    let selector = OutpostSelector::from_cli_arg(&fixture.root, format!("{prefix}/").into());

    let resolved = resolve_entry(&source, &selector).expect("resolve path with separator");

    assert_eq!(resolved.entry.path, canonical(&hex_path));
}

#[test]
fn selector_entry_resolution_allows_missing_registered_path_by_id() {
    let fixture = AbcFixture::new();
    let outpost = fixture.add_outpost("C").expect("add C");
    let source = fixture.source_repo().expect("source repo");
    let entry = single_entry(&source);
    fs::remove_dir_all(&outpost).expect("remove outpost dir");
    let id = OutpostId::derive(source.work_tree(), &entry.path);
    let selector = OutpostSelector::from_cli_arg(&fixture.root, id.as_str()[..5].into());

    let resolved = resolve_entry(&source, &selector).expect("resolve missing registered path");

    assert_eq!(resolved.path, entry.path);
}

fn single_entry(source: &SourceRepo) -> RegistryEntry {
    let registry = source.registry().expect("registry");
    assert_eq!(registry.entries().len(), 1);
    registry.entries()[0].clone()
}

fn replace_registry<const N: usize>(source: &SourceRepo, entries: [RegistryEntry; N]) {
    let mut registry = source.registry_mut().expect("registry mut");
    for entry in entries {
        registry.add(entry).expect("add entry");
    }
    registry.save().expect("save registry");
}

fn entry_at(path: &Path) -> RegistryEntry {
    RegistryEntry::new(path.to_path_buf(), RemoteName::parse("local").unwrap()).expect("entry")
}

fn colliding_paths(fixture: &AbcFixture, source: &SourceRepo) -> (PathBuf, PathBuf, String) {
    let mut prefixes = HashMap::<String, PathBuf>::new();
    for index in 0..5000 {
        let path = canonical_missing(&fixture.root.join(format!("candidate-{index}")));
        let id = OutpostId::derive(source.work_tree(), &path);
        let prefix = id.as_str()[..5].to_owned();
        if let Some(first) = prefixes.insert(prefix.clone(), path.clone()) {
            return (first, path, prefix);
        }
    }
    panic!("could not find derived ID prefix collision");
}

fn missing_prefix(source_path: &Path, paths: &[PathBuf]) -> String {
    ["fffff", "eeeee", "ddddd", "ccccc"]
        .into_iter()
        .find(|prefix| {
            !paths.iter().any(|path| {
                OutpostId::derive(source_path, path)
                    .as_str()
                    .starts_with(prefix)
            })
        })
        .expect("missing prefix")
        .to_owned()
}

fn canonical(path: &Path) -> PathBuf {
    fs::canonicalize(path).expect("canonical path")
}

fn canonical_missing(path: &Path) -> PathBuf {
    let parent = path.parent().expect("candidate parent");
    let name = path.file_name().expect("candidate name");
    let mut canonical = canonical(parent);
    canonical.push(name);
    canonical
}