Skip to main content

agent_runtime/
install.rs

1//! `agent-runtime install` body. Plan 04 Sprint 1 Tasks 1.2 / 1.3.
2//!
3//! Layout:
4//!
5//! - [`link_map`] — parser for `targets/<product>/link-map.yaml` with
6//!   per-entry validation that mirrors the JSON Schema in
7//!   `agent-runtime-kit/core/docs/schemas/link-map.schema.json`.
8//! - [`overlay`] — optional `.private/link-map.overrides.yaml` parser plus
9//!   the per-entry-replace merge function. Applied before plan generation.
10//! - [`plan`] — builder that turns a (post-overlay-merge) `LinkMap` into a
11//!   flat ordered `InstallPlan`, expanding `recursive: true` directory
12//!   entries into one action per file. Non-recursive `symlinked-file`
13//!   entries intentionally accept either file or directory sources and
14//!   become one symlink action at the declared destination.
15//! - [`executor`] — walks the plan and reconciles the runtime home to
16//!   the plan. Re-running on a clean install is a byte-identical no-op.
17//!
18//! The CLI wrapper lives in `commands::install`. Task 1.3 layers
19//! `--live-home`, `--tag`, and `--no-overlay` on top of the Task 1.2
20//! Rust API without rewriting the contract.
21
22pub mod executor;
23pub mod link_map;
24pub mod overlay;
25pub mod plan;
26
27use std::path::{Path, PathBuf};
28use std::time::SystemTime;
29
30pub use executor::{AppliedChange, ApplyError, Mode, is_trusted_tag};
31pub use link_map::{LinkMap, LinkMapError};
32pub use overlay::{LinkMapOverlay, OVERLAY_REL_PATH, OverlaySummary};
33pub use plan::{InstallPlan, PlanError};
34
35/// Top-level error returned by [`run`]. Each variant wraps the typed
36/// error from the contributing module so callers can match on the root
37/// cause if they need to.
38#[derive(Debug, thiserror::Error)]
39pub enum InstallError {
40    #[error("link-map: {0}")]
41    LinkMap(#[from] LinkMapError),
42    #[error("plan: {0}")]
43    Plan(#[from] PlanError),
44    #[error("apply: {0}")]
45    Apply(#[from] ApplyError),
46}
47
48/// Per-run knobs threaded through to the executor. Plan 04 Sprint 1
49/// Task 1.3 introduces these; later sprints (gc-backups, doctor) keep
50/// extending the struct rather than the positional `run` signature.
51#[derive(Debug, Clone)]
52pub struct InstallOptions {
53    /// Optional backup-directory tag. When set and at least one backup
54    /// is created during apply, a `tag-<name>` marker file is written at
55    /// the backup-run root so `gc-backups` (Task 2.4) can preserve the
56    /// directory across retention sweeps.
57    pub tag: Option<String>,
58    /// When `false`, skip the `.private/link-map.overrides.yaml` read.
59    /// Defaults to `true` — overlay merge is the production path; the
60    /// `--no-overlay` flag wires this to `false` for tests and
61    /// reproducible drift baselines.
62    pub overlay_enabled: bool,
63    /// Explicit overlay file location. When `None` and `overlay_enabled`
64    /// is `true`, the conventional `<source_root>/.private/link-map.overrides.yaml`
65    /// is used.
66    pub overlay_path: Option<PathBuf>,
67}
68
69impl Default for InstallOptions {
70    /// Default knobs: overlay merge on, no tag, conventional overlay path.
71    /// Overlay-on is the production path — derive(Default) would land
72    /// `overlay_enabled = false` and silently turn the production behaviour
73    /// off for any caller using `InstallOptions::default()`.
74    fn default() -> Self {
75        Self {
76            tag: None,
77            overlay_enabled: true,
78            overlay_path: None,
79        }
80    }
81}
82
83/// Full outcome of an `install::run` cycle. The CLI consumes the
84/// `overlay` field to print a one-line operator-visible notice when an
85/// overlay was merged (architecture-doc requirement: dry-run must expose
86/// the post-merge effective config).
87#[derive(Debug)]
88pub struct InstallOutcome {
89    pub plan: InstallPlan,
90    pub changes: Vec<AppliedChange>,
91    /// `None` when overlay merge was disabled or the overlay file was
92    /// absent; `Some(summary)` when an overlay was consumed (even if it
93    /// changed zero entries — the operator still wants to know it ran).
94    pub overlay: Option<OverlaySummary>,
95}
96
97/// Execute one install cycle. Builds the plan from the link-map at
98/// `<source_root>/targets/<product>/link-map.yaml`, then either prints
99/// it ([`Mode::DryRun`]) or applies it ([`Mode::Apply`]). `home` and
100/// `state_home` must be absolute. `now` is injected so the backup-dir
101/// timestamp stays deterministic in tests.
102pub fn run(
103    product: &str,
104    source_root: &Path,
105    home: &Path,
106    state_home: &Path,
107    mode: Mode,
108    now: SystemTime,
109    options: &InstallOptions,
110) -> Result<InstallOutcome, InstallError> {
111    let mut link_map = LinkMap::load(source_root, product)?;
112    let mut overlay_summary: Option<OverlaySummary> = None;
113    if options.overlay_enabled {
114        let overlay_opt = match options.overlay_path.as_deref() {
115            Some(path) => LinkMapOverlay::load_from(path)?,
116            None => LinkMapOverlay::load_optional(source_root)?,
117        };
118        if let Some(overlay) = overlay_opt {
119            let summary = overlay::apply(&mut link_map, &overlay)?;
120            overlay_summary = Some(summary);
121        }
122    }
123    let plan = InstallPlan::build(product, source_root, home, state_home, &link_map)?;
124    let changes = executor::run(&plan, mode, now, options.tag.as_deref())?;
125    Ok(InstallOutcome {
126        plan,
127        changes,
128        overlay: overlay_summary,
129    })
130}