Skip to main content

agent_runtime/install/
link_map.rs

1//! `targets/<product>/link-map.yaml` parser.
2//!
3//! Schema source-of-truth lives in `agent-runtime-kit`:
4//! `core/docs/schemas/link-map.schema.json`. This module mirrors that
5//! schema with `#[serde(deny_unknown_fields)]` types so unknown keys fail
6//! deserialization loudly instead of being silently dropped. Per-kind
7//! required/forbidden field enforcement happens in [`LinkEntry::validate`]
8//! after the YAML round-trip, because `serde` alone cannot express the
9//! `allOf if/then` shape that the JSON Schema uses.
10//! `kind=symlinked-file` is a compatibility name: with
11//! `recursive: false`, its `source` may be a file or a directory and the
12//! installer creates exactly one symlink at `destination`. With
13//! `recursive: true`, directory sources expand into one file symlink per
14//! source file.
15//!
16//! Source: agent-runtime-kit Plan 04 Sprint 1 Task 1.2.
17//!   `docs/plans/04-installer-doctor-and-bootstrap/...-plan.md`.
18
19use serde::Deserialize;
20use std::path::{Path, PathBuf};
21use thiserror::Error;
22
23pub const SCHEMA_VERSION: u32 = 1;
24
25#[derive(Debug, Error)]
26pub enum LinkMapError {
27    #[error("missing link-map: {path}")]
28    Missing { path: PathBuf },
29    #[error("schema_version mismatch in {file}: expected {expected}, got {found}")]
30    SchemaVersion {
31        file: PathBuf,
32        expected: u32,
33        found: u32,
34    },
35    #[error("parse error in {file}: {source}")]
36    Parse {
37        file: PathBuf,
38        #[source]
39        source: serde_yaml_ng::Error,
40    },
41    #[error("io error reading {file}: {source}")]
42    Io {
43        file: PathBuf,
44        #[source]
45        source: std::io::Error,
46    },
47    #[error("link-map entry `{id}` is invalid: {reason} (file: {file})")]
48    InvalidEntry {
49        file: PathBuf,
50        id: String,
51        reason: String,
52    },
53    #[error("link-map entry id `{id}` is duplicated in {file}")]
54    DuplicateId { file: PathBuf, id: String },
55}
56
57#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
58#[serde(rename_all = "kebab-case")]
59pub enum EntryKind {
60    SymlinkedFile,
61    PluginManifestCopy,
62    ManagedBlock,
63    BackedUpOnReplace,
64}
65
66#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
67#[serde(rename_all = "kebab-case")]
68pub enum CommentStyle {
69    Hash,
70    DoubleSlash,
71}
72
73#[derive(Debug, Deserialize, Clone)]
74#[serde(deny_unknown_fields)]
75pub struct LinkEntry {
76    pub id: String,
77    pub kind: EntryKind,
78    #[serde(default)]
79    pub source: Option<String>,
80    pub destination: String,
81    #[serde(default)]
82    pub recursive: bool,
83    #[serde(default)]
84    pub surface: Option<String>,
85    #[serde(default)]
86    pub comment_style: Option<CommentStyle>,
87    #[serde(default)]
88    pub body_template: Option<String>,
89}
90
91impl LinkEntry {
92    /// Enforce the per-kind required / forbidden field contract that the
93    /// JSON Schema expresses via `allOf if/then`. Returns the first
94    /// violation it finds so error messages stay actionable.
95    pub fn validate(&self) -> Result<(), String> {
96        match self.kind {
97            EntryKind::SymlinkedFile => {
98                if self.source.is_none() {
99                    return Err(
100                        "kind=symlinked-file requires `source` (file or directory)".to_string()
101                    );
102                }
103                self.forbid_managed_block_fields()?;
104            }
105            EntryKind::PluginManifestCopy => {
106                if self.source.is_none() {
107                    return Err("kind=plugin-manifest-copy requires `source`".to_string());
108                }
109                if self.recursive {
110                    return Err("kind=plugin-manifest-copy forbids `recursive`".to_string());
111                }
112                self.forbid_managed_block_fields()?;
113            }
114            EntryKind::BackedUpOnReplace => {
115                if self.source.is_none() {
116                    return Err("kind=backed-up-on-replace requires `source`".to_string());
117                }
118                self.forbid_managed_block_fields()?;
119            }
120            EntryKind::ManagedBlock => {
121                if self.surface.is_none() {
122                    return Err("kind=managed-block requires `surface`".to_string());
123                }
124                if self.comment_style.is_none() {
125                    return Err("kind=managed-block requires `comment_style`".to_string());
126                }
127                if self.body_template.is_none() {
128                    return Err("kind=managed-block requires `body_template`".to_string());
129                }
130                if self.source.is_some() {
131                    return Err("kind=managed-block forbids `source`".to_string());
132                }
133                if self.recursive {
134                    return Err("kind=managed-block forbids `recursive`".to_string());
135                }
136            }
137        }
138        Ok(())
139    }
140
141    fn forbid_managed_block_fields(&self) -> Result<(), String> {
142        if self.surface.is_some() {
143            return Err(format!("kind={:?} forbids `surface`", self.kind));
144        }
145        if self.comment_style.is_some() {
146            return Err(format!("kind={:?} forbids `comment_style`", self.kind));
147        }
148        if self.body_template.is_some() {
149            return Err(format!("kind={:?} forbids `body_template`", self.kind));
150        }
151        Ok(())
152    }
153}
154
155#[derive(Debug, Deserialize, Clone)]
156#[serde(deny_unknown_fields)]
157pub struct LinkMap {
158    pub schema_version: u32,
159    pub entries: Vec<LinkEntry>,
160}
161
162impl LinkMap {
163    /// Load and validate the link-map at the conventional location:
164    /// `<source_root>/targets/<product>/link-map.yaml`.
165    pub fn load(source_root: &Path, product: &str) -> Result<Self, LinkMapError> {
166        let file = source_root
167            .join("targets")
168            .join(product)
169            .join("link-map.yaml");
170        if !file.exists() {
171            return Err(LinkMapError::Missing { path: file });
172        }
173        let raw = std::fs::read_to_string(&file).map_err(|source| LinkMapError::Io {
174            file: file.clone(),
175            source,
176        })?;
177        let parsed: LinkMap =
178            serde_yaml_ng::from_str(&raw).map_err(|source| LinkMapError::Parse {
179                file: file.clone(),
180                source,
181            })?;
182        if parsed.schema_version != SCHEMA_VERSION {
183            return Err(LinkMapError::SchemaVersion {
184                file,
185                expected: SCHEMA_VERSION,
186                found: parsed.schema_version,
187            });
188        }
189
190        // Per-entry validation + duplicate id detection.
191        let mut seen = std::collections::BTreeSet::new();
192        for entry in &parsed.entries {
193            entry
194                .validate()
195                .map_err(|reason| LinkMapError::InvalidEntry {
196                    file: file.clone(),
197                    id: entry.id.clone(),
198                    reason,
199                })?;
200            if !seen.insert(entry.id.clone()) {
201                return Err(LinkMapError::DuplicateId {
202                    file,
203                    id: entry.id.clone(),
204                });
205            }
206        }
207        Ok(parsed)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use std::fs;
215    use tempfile::TempDir;
216
217    fn write_link_map(root: &Path, product: &str, body: &str) -> PathBuf {
218        let dir = root.join("targets").join(product);
219        fs::create_dir_all(&dir).unwrap();
220        let file = dir.join("link-map.yaml");
221        fs::write(&file, body).unwrap();
222        file
223    }
224
225    #[test]
226    fn load_accepts_initial_codex_shape() {
227        let tmp = TempDir::new().unwrap();
228        write_link_map(
229            tmp.path(),
230            "codex",
231            "\
232schema_version: 1
233entries:
234  - id: reporting.plugin-manifest
235    kind: plugin-manifest-copy
236    source: targets/codex/plugins/reporting/.codex-plugin/plugin.json
237    destination: plugins/reporting/.codex-plugin/plugin.json
238  - id: reporting.skills-tree
239    kind: symlinked-file
240    source: build/codex/plugins/reporting/skills
241    destination: plugins/reporting/skills
242    recursive: true
243",
244        );
245        let lm = LinkMap::load(tmp.path(), "codex").unwrap();
246        assert_eq!(lm.schema_version, 1);
247        assert_eq!(lm.entries.len(), 2);
248        assert_eq!(lm.entries[0].kind, EntryKind::PluginManifestCopy);
249        assert_eq!(lm.entries[1].kind, EntryKind::SymlinkedFile);
250        assert!(lm.entries[1].recursive);
251    }
252
253    #[test]
254    fn load_rejects_missing_file() {
255        let tmp = TempDir::new().unwrap();
256        let err = LinkMap::load(tmp.path(), "codex").unwrap_err();
257        assert!(matches!(err, LinkMapError::Missing { .. }));
258    }
259
260    #[test]
261    fn load_rejects_unknown_key() {
262        let tmp = TempDir::new().unwrap();
263        write_link_map(
264            tmp.path(),
265            "claude",
266            "\
267schema_version: 1
268entries:
269  - id: x
270    kind: symlinked-file
271    source: a
272    destination: b
273    bogus_field: 1
274",
275        );
276        let err = LinkMap::load(tmp.path(), "claude").unwrap_err();
277        assert!(matches!(err, LinkMapError::Parse { .. }));
278    }
279
280    #[test]
281    fn load_rejects_schema_version_mismatch() {
282        let tmp = TempDir::new().unwrap();
283        write_link_map(
284            tmp.path(),
285            "codex",
286            "\
287schema_version: 99
288entries:
289  - id: x
290    kind: symlinked-file
291    source: a
292    destination: b
293",
294        );
295        let err = LinkMap::load(tmp.path(), "codex").unwrap_err();
296        assert!(matches!(err, LinkMapError::SchemaVersion { found: 99, .. }));
297    }
298
299    #[test]
300    fn load_rejects_managed_block_without_required_fields() {
301        let tmp = TempDir::new().unwrap();
302        write_link_map(
303            tmp.path(),
304            "codex",
305            "\
306schema_version: 1
307entries:
308  - id: bad
309    kind: managed-block
310    destination: config.toml
311",
312        );
313        let err = LinkMap::load(tmp.path(), "codex").unwrap_err();
314        match err {
315            LinkMapError::InvalidEntry { id, reason, .. } => {
316                assert_eq!(id, "bad");
317                assert!(reason.contains("surface"), "got: {reason}");
318            }
319            other => panic!("expected InvalidEntry, got {other:?}"),
320        }
321    }
322
323    #[test]
324    fn load_rejects_managed_block_with_forbidden_source() {
325        let tmp = TempDir::new().unwrap();
326        write_link_map(
327            tmp.path(),
328            "codex",
329            "\
330schema_version: 1
331entries:
332  - id: bad
333    kind: managed-block
334    destination: config.toml
335    surface: install
336    comment_style: hash
337    body_template: 't = 1'
338    source: somewhere
339",
340        );
341        let err = LinkMap::load(tmp.path(), "codex").unwrap_err();
342        match err {
343            LinkMapError::InvalidEntry { reason, .. } => {
344                assert!(reason.contains("forbids `source`"), "got: {reason}");
345            }
346            other => panic!("expected InvalidEntry, got {other:?}"),
347        }
348    }
349
350    #[test]
351    fn load_rejects_duplicate_ids() {
352        let tmp = TempDir::new().unwrap();
353        write_link_map(
354            tmp.path(),
355            "codex",
356            "\
357schema_version: 1
358entries:
359  - id: same
360    kind: symlinked-file
361    source: a
362    destination: b
363  - id: same
364    kind: symlinked-file
365    source: c
366    destination: d
367",
368        );
369        let err = LinkMap::load(tmp.path(), "codex").unwrap_err();
370        assert!(matches!(err, LinkMapError::DuplicateId { id, .. } if id == "same"));
371    }
372}