1use 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 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 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 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}