switchyard/adapters/
smoke.rs

1//! Smoke test adapter used to validate post-apply health.
2//!
3//! Minimal expectations for integrators:
4//! - Implement `SmokeTestRunner` and inject it via `Switchyard::with_smoke_runner(...)`.
5//! - Ensure the runner is deterministic and safe to execute repeatedly.
6//! - At minimum, validate that each `EnsureSymlink` target resolves to the intended source.
7//! - Optionally perform additional invariant checks (e.g., permissions, executable bits).
8//!
9//! Behavior in `ApplyMode::Commit` when `policy.require_smoke_in_commit=true`:
10//! - If no runner is configured, apply fails with `E_SMOKE` and auto-rollback (unless `disable_auto_rollback`).
11//! - If the runner returns `Err(SmokeFailure)`, apply fails with `E_SMOKE` and auto-rollback (unless disabled).
12//!
13use crate::types::plan::Plan;
14
15#[derive(Debug, Copy, Clone)]
16pub struct SmokeFailure;
17
18pub trait SmokeTestRunner: Send + Sync {
19    /// Run smoke tests for the given plan.
20    /// # Errors
21    /// Returns `SmokeFailure` if smoke tests fail.
22    fn run(&self, plan: &Plan) -> Result<(), SmokeFailure>;
23}
24
25/// `DefaultSmokeRunner` implements a minimal, no-op smoke suite.
26/// In Sprint 2, the adapter is made available and can be enabled by integrators.
27/// Future iterations will implement the SPEC ยง11 command set.
28#[derive(Debug, Default, Copy, Clone)]
29pub struct DefaultSmokeRunner;
30
31impl SmokeTestRunner for DefaultSmokeRunner {
32    fn run(&self, plan: &Plan) -> Result<(), SmokeFailure> {
33        // Deterministic subset: validate that each EnsureSymlink target points to the source.
34        for act in &plan.actions {
35            if let crate::types::Action::EnsureSymlink { source, target } = act {
36                let Ok(md) = std::fs::symlink_metadata(target.as_path()) else {
37                    return Err(SmokeFailure);
38                };
39                if !md.file_type().is_symlink() {
40                    return Err(SmokeFailure);
41                }
42                let Ok(link) = std::fs::read_link(target.as_path()) else {
43                    return Err(SmokeFailure);
44                };
45                // Resolve relative link against target parent
46                let resolved = if link.is_relative() {
47                    match target.as_path().parent() {
48                        Some(parent) => parent.join(link),
49                        None => link,
50                    }
51                } else {
52                    link
53                };
54                // Compare canonicalized paths where possible
55                let want = std::fs::canonicalize(source.as_path())
56                    .unwrap_or_else(|_| source.as_path().clone());
57                let got = std::fs::canonicalize(&resolved).unwrap_or(resolved);
58                if want != got {
59                    return Err(SmokeFailure);
60                }
61            }
62        }
63        Ok(())
64    }
65}