Skip to content

Commit 41b39a7

Browse files
feat(engine,cli): apply preflight halts with NIX_NOT_INSTALLED when needed (#58)
Implements ADR-0012 decision 3: when a plan would dispatch a UserEnvSwitch but `nix --version` fails, apply halts at preflight (class 1, exit 2) before any snapshot or db sync. The CLI's hint is the literal `pearlite bootstrap` invocation. - pearlite-userenv::NixInstaller gains a `nix_installed()` probe method. LiveNixInstaller hoists the previously-private `nix_already_installed` body to satisfy it; `install_if_missing` now calls the public method instead. MockNixInstaller returns its `already` flag, so existing test fixtures are unchanged in behavior. - pearlite-engine::ApplyContext gains a `nix_installer` field. The apply_plan body checks `needs_nix(plan)` (any UserEnvSwitch action) and probes via the adapter before pre-snapshot. New ApplyError::NixNotInstalled (preflight) and ApplyError::NixProbe (#[from] InstallerError) variants surface the failure modes. - CLI dispatch's apply_error_payload maps the two variants to typed codes NIX_NOT_INSTALLED and APPLY_NIX_PROBE_FAILED with class "preflight" / exit 2, overriding the recorded-failure-class default since these errors halt before any FailureRef is written. The hint for NIX_NOT_INSTALLED is `pearlite bootstrap --installer-script <path>`. - 20 engine test sites updated via make_ctx to thread the new field; the 3 injected-failure tests update their ApplyContext literals. - Two new engine tests cover the preflight branch (halts before snapshot, no FailureRef) and the no-UserEnvSwitch path (nix probe is skipped entirely). Tests: 316 passing (+2). Clippy clean. fmt clean. audit clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2f83813 commit 41b39a7

5 files changed

Lines changed: 223 additions & 23 deletions

File tree

crates/pearlite-cli/src/dispatch.rs

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ fn dispatch_apply(
279279
systemd: ctx.systemd.as_ref(),
280280
snapper: ctx.snapper.as_ref(),
281281
home_manager: ctx.home_manager.as_ref(),
282+
nix_installer: ctx.nix_installer.as_ref(),
282283
snapper_config: opts.snapper_config,
283284
state_path: &ctx.state_path,
284285
failures_dir: &failures_dir,
@@ -928,24 +929,42 @@ fn apply_error_payload(
928929
"home-manager --version # verify home-manager is reachable for the target user, then retry"
929930
.to_owned(),
930931
),
932+
ApplyError::NixProbe(_) => (
933+
"APPLY_NIX_PROBE_FAILED",
934+
"nix --version # verify the nix binary is healthy on PATH".to_owned(),
935+
),
936+
ApplyError::NixNotInstalled => (
937+
"NIX_NOT_INSTALLED",
938+
"pearlite bootstrap --installer-script <path> # ADR-0012".to_owned(),
939+
),
931940
};
932941

933-
let class_label = match default_class {
934-
2 => "plan",
935-
3 => "apply-recoverable",
936-
4 => "apply-incoherent",
937-
_ => "apply",
942+
// NIX_NOT_INSTALLED and APPLY_NIX_PROBE_FAILED are preflight
943+
// errors: apply_plan returns before any system mutation, so they
944+
// don't write a FailureRef and don't follow the class-3/4
945+
// recoverable taxonomy. Surface as class=preflight, exit=2.
946+
let (class_label, exit_code, surfaced_class) = match err {
947+
ApplyError::NixNotInstalled | ApplyError::NixProbe(_) => ("preflight", 2_u8, 1_u8),
948+
_ => {
949+
let label = match default_class {
950+
2 => "plan",
951+
3 => "apply-recoverable",
952+
4 => "apply-incoherent",
953+
_ => "apply",
954+
};
955+
(label, default_exit_code, default_class)
956+
}
938957
};
939958

940959
ErrorPayload {
941960
code: code.to_owned(),
942961
class: class_label.to_owned(),
943-
exit_code: default_exit_code,
962+
exit_code,
944963
message: format!("{err}"),
945964
hint,
946965
details: serde_json::json!({
947966
"plan_id": plan_id,
948-
"failure_class": default_class,
967+
"failure_class": surfaced_class,
949968
}),
950969
}
951970
}

0 commit comments

Comments
 (0)