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}