wsb 0.255.6379

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! The vocabulary's own guarantees: identities that cannot be paths, and
//! states that cannot be half-stated.

use super::*;

/// TEST9926: an identity is not a path, and refuses anything that could be.
///
/// Peer and workspace ids key trust relationships and name directories under
/// the daemon's staging area. An id carrying `/` or `..` is one that escapes
/// that directory, and an id taken from a hostname is one that two machines
/// can both claim.
#[test]
fn test9926_identities_refuse_path_shapes() {
    for hostile in ["../escape", "a/b", "", "UPPER", "with space", "peer.id"] {
        assert!(
            PeerId::parse(hostile).is_err(),
            "{hostile:?} was accepted as a peer id"
        );
        assert!(
            WorkspaceId::parse(hostile).is_err(),
            "{hostile:?} was accepted as a workspace id"
        );
    }
    // A continuity id is a family and a worktree name, and both halves are
    // held to what they can be: hex, and a name `prj` could give.
    for hostile in [
        "9ab3cd7e",
        "9ab3cd7e@",
        "@main",
        "9AB3@main",
        "xyz@main",
        "9ab3cd7e@../up",
        "9ab3cd7e@a/b",
        "9ab3cd7e@my notes",
        "9ab3cd7e@-main",
    ] {
        assert!(WorkspaceId::parse(hostile).is_err(), "{hostile:?} was accepted as a workspace id");
    }
    assert!(PeerId::parse("k7f3q9zx2m").is_ok());
    let id = WorkspaceId::parse("9ab3cd7e@fix-2").expect("a continuity id");
    assert_eq!((id.family(), id.worktree()), ("9ab3cd7e", "fix-2"));
    assert_eq!(WorkspaceId::of("9ab3cd7e", "main"), WorkspaceId::parse("9ab3cd7e@main"));
    // Read from the wire through the same parse: a peer cannot send one the
    // command line would refuse.
    assert!(serde_json::from_str::<WorkspaceId>("\"9ab3cd7e@../up\"").is_err());
    assert_eq!(
        serde_json::to_string(&id).expect("encodes"),
        "\"9ab3cd7e@fix-2\""
    );
    // A length no derivation produces is still refused, because the value can
    // also arrive from a peer.
    assert!(PeerId::parse(&"a".repeat(65)).is_err());
}

/// TEST9927: a repository is identified by its place in the workspace, and the
/// superproject has a name rather than an empty column.
#[test]
fn test9927_repository_ids_are_workspace_relative() {
    let root = RepositoryId::root();
    assert!(root.is_root());
    assert_eq!(root.label(), "superproject");
    assert_eq!(root.as_str(), "");

    let nested = RepositoryId::parse("cartridges/mlxcartridge").expect("a nested repository");
    assert_eq!(nested.label(), "cartridges/mlxcartridge");

    for hostile in ["/absolute", "../up", "a/../../b", "back\\slash"] {
        assert!(
            RepositoryId::parse(hostile).is_err(),
            "{hostile:?} was accepted as a repository path"
        );
    }
}

/// TEST9928: a blocked repository always says why, and an unblocked one never
/// carries a reason.
///
/// This is the invariant every surface depends on: the UI decides what to
/// offer from the variant. "Blocked" with nothing attached is a state a person
/// cannot act on, and a reason attached to a syncing repository is a stale
/// fact that will be rendered as a live one.
#[test]
fn test9928_blocked_always_carries_its_reason() {
    let mut status = RepositoryStatus {
        repository: RepositoryId::root(),
        desired: Desired::Enabled,
        continuity: Continuity::Auto,
        observed: Observed::Blocked,
        blocked: None,
        pending: 0,
        incoming: 0,
        outgoing: 0,
        conflicts: 0,
        undecided: 0,
        branch: None,
        head: None,
    };
    let error = status.check().expect_err("blocked with no reason must be refused");
    assert!(error.contains("blocked with no reason"), "{error}");

    status.blocked = Some(Blocked::BranchMismatch {
        here: "feature/peer".into(),
        there: "main".into(),
    });
    status.check().expect("blocked with a reason is well formed");

    // The reason must go when the block does.
    status.observed = Observed::Syncing;
    let error = status.check().expect_err("a syncing repository has no blocked reason");
    assert!(error.contains("blocked reason"), "{error}");
}

/// TEST9929: every blocked reason has a stable code and says something a
/// person can act on, in both directions.
///
/// The direction matters: "this side is detached" and "the peer is detached"
/// need different actions from the person reading it, and a message that says
/// only "detached HEAD" sends them to the wrong machine.
#[test]
fn test9929_every_blocked_reason_reads_in_both_directions() {
    let cases = [
        (
            Blocked::DetachedHead { here: true, there: false },
            "detached_head",
            "this side",
        ),
        (
            Blocked::DetachedHead { here: false, there: true },
            "detached_head",
            "the peer",
        ),
        (
            Blocked::GitOperation { here: false, there: true },
            "git_operation",
            "the peer",
        ),
        (
            Blocked::SubmoduleMissing { here: true, there: false },
            "submodule_missing",
            "the peer",
        ),
        (Blocked::PeerOffline, "peer_offline", "not connected"),
    ];
    for (reason, code, expected) in cases {
        assert_eq!(reason.code(), code);
        let described = reason.describe();
        assert!(
            described.contains(expected),
            "{code} reads {described:?}, which does not say {expected:?}"
        );
    }

    // A head mismatch names both commits, shortened for reading and never for
    // comparing.
    let mismatch = Blocked::HeadMismatch {
        here: "a81f93d2c4e5f6".into(),
        there: "def4567890abcd".into(),
    };
    let described = mismatch.describe();
    assert!(described.contains("a81f93d"), "{described}");
    assert!(described.contains("def4567"), "{described}");
    assert!(!described.contains("a81f93d2c4e5f6"), "the full id is not shown: {described}");
}

/// TEST9930: desired and observed are separate axes.
///
/// The whole point: a relationship the operator enabled and git temporarily
/// blocked must be distinguishable from one the operator paused, or resuming
/// after a branch change would need a human every time — and pausing would not
/// survive one.
#[test]
fn test9930_desired_and_observed_do_not_collapse() {
    let enabled_but_blocked = RepositoryStatus {
        repository: RepositoryId::root(),
        desired: Desired::Enabled,
        continuity: Continuity::Auto,
        observed: Observed::Blocked,
        blocked: Some(Blocked::HeadMismatch {
            here: "abc123".into(),
            there: "def456".into(),
        }),
        pending: 0,
        incoming: 0,
        outgoing: 0,
        conflicts: 0,
        undecided: 0,
        branch: Some("feature/peer".into()),
        head: Some("abc123".into()),
    };
    let paused = RepositoryStatus {
        desired: Desired::Paused,
        continuity: Continuity::Paused,
        observed: Observed::Paused,
        blocked: None,
        ..enabled_but_blocked.clone()
    };

    enabled_but_blocked.check().expect("well formed");
    paused.check().expect("well formed");
    assert_ne!(enabled_but_blocked.desired, paused.desired);
    // Both are "not syncing right now", and only one of them resumes by
    // itself. A single field could not tell them apart.
    assert_ne!(enabled_but_blocked.observed, paused.observed);
}

/// TEST10134: the three switches combine in exactly one place.
///
/// Two machines, one service switch, one relationship, one mode — and four
/// outcomes. Every surface reads the outcome rather than the inputs, because
/// the version of this that lived in the coordinator as a chain of `if`s was
/// the version where the workbench and the command line disagreed about
/// whether a paused relationship was still comparing.
#[test]
fn test10134_continuity_is_the_three_switches_combined() {
    use Continuity as C;

    // The service switch wins over everything else. A machine whose files
    // service is off is not comparing working trees, whatever any workspace's
    // own relationship says — that is what makes "pause file syncing" a pause
    // somebody can rely on.
    for desired in [Desired::Enabled, Desired::Paused, Desired::Disabled] {
        for mode in [SyncMode::Manual, SyncMode::Auto] {
            assert_eq!(
                C::of(Switch::Off, desired, mode),
                C::Off,
                "the files switch must win over desired={desired:?} mode={mode:?}"
            );
        }
    }

    assert_eq!(C::of(Switch::On, Desired::Disabled, SyncMode::Auto), C::Off);
    assert_eq!(C::of(Switch::On, Desired::Paused, SyncMode::Auto), C::Paused);
    assert_eq!(C::of(Switch::On, Desired::Enabled, SyncMode::Manual), C::Manual);
    assert_eq!(C::of(Switch::On, Desired::Enabled, SyncMode::Auto), C::Auto);
}

/// TEST10135: what each outcome permits, stated as the coordinator asks it.
///
/// The distinctions that matter, and every one of them was a bug before it was
/// a method: `Off` must not even exchange a report (or "paused" would still be
/// scanning two working trees every three seconds); `Paused` must not act on a
/// decision (or resuming would replay a choice made about a file that has
/// since changed); and only `Auto` may move something nobody looked at.
#[test]
fn test10135_each_continuity_permits_exactly_what_it_says() {
    use Continuity as C;

    assert!(!C::Off.compares(), "off must not exchange reports");
    assert!(C::Paused.compares(), "a pause is something you watch");
    assert!(C::Manual.compares());
    assert!(C::Auto.compares());

    assert!(!C::Off.carries_decided());
    assert!(
        !C::Paused.carries_decided(),
        "a decision made before a pause is not consent to act during it"
    );
    assert!(C::Manual.carries_decided());
    assert!(C::Auto.carries_decided());

    for state in [C::Off, C::Paused, C::Manual] {
        assert!(
            !state.takes_undecided(),
            "{} must never take a file nobody decided about",
            state.as_str()
        );
    }
    assert!(
        C::Auto.takes_undecided(),
        "auto is exactly the state in which what arrives is applied unasked"
    );
}

/// TEST10136: the default is manual, wsb's own services are on, and a
/// plugin's are off.
///
/// The whole point of the mode: pairing two machines must not start moving
/// files between them — the SERVICE is on while the CARRYING defaults to
/// nothing crossing unasked. A plugin's service is another matter: pairing
/// was a decision about working trees, and what a plugin answers (a secret,
/// say) is switched on for each machine by somebody who means it.
#[test]
fn test10136_pairing_starts_listed_not_carried() {
    assert_eq!(SyncMode::default(), SyncMode::Manual);
    for service in Service::BUILT_IN {
        assert_eq!(
            Switch::default_for(&service),
            Switch::On,
            "{service} must be available to a machine somebody paired"
        );
    }
    let plugin = Service::parse("sdx.secrets").expect("a plugin service");
    assert_eq!(
        Switch::default_for(&plugin),
        Switch::Off,
        "a plugin's service must not be answered for a machine nobody switched it on for"
    );
    assert_eq!(
        Continuity::of(
            Switch::default_for(&Service::Files),
            Desired::Enabled,
            SyncMode::default()
        ),
        Continuity::Manual,
        "a freshly enabled workspace lists differences and moves nothing"
    );
}

/// TEST10137: every service and switch round-trips through its own words.
///
/// These strings are the wire format, the database column and the CLI
/// argument. A parse that accepted something `as_str` never emits is a state
/// one surface can store and another cannot read.
#[test]
fn test10137_service_vocabulary_round_trips() {
    let plugin = Service::parse("sdx.secrets").expect("a plugin service");
    for service in Service::BUILT_IN.into_iter().chain([plugin]) {
        assert_eq!(Service::parse(service.as_str()), Ok(service.clone()));
        assert!(!service.describe().is_empty());
    }
    assert!(Service::parse("secrets").is_err(), "a bare word that is not wsb's is no service");
    for switch in [Switch::On, Switch::Off] {
        assert_eq!(Switch::parse(switch.as_str()), Ok(switch));
    }
    for mode in [SyncMode::Manual, SyncMode::Auto] {
        assert_eq!(SyncMode::parse(mode.as_str()), Ok(mode));
    }
    assert!(Service::parse("Files").is_err(), "case is not a service name");
    assert!(Service::parse("sessions").is_err());
    assert!(Switch::parse("true").is_err(), "a switch is on or off, not a bool");
    assert!(SyncMode::parse("automatic").is_err());
}