Skip to main content

mars_agents/lock/
mod.rs

1use std::collections::BTreeMap;
2use std::collections::{HashMap, HashSet};
3use std::path::Path;
4
5use indexmap::IndexMap;
6use serde::{Deserialize, Serialize};
7
8use crate::diagnostic::Diagnostic;
9use crate::error::{LockError, MarsError};
10use crate::models::ModelAlias;
11use crate::types::{
12    CommitHash, ContentHash, DestPath, SourceId, SourceName, SourceOrigin, SourceSubpath, SourceUrl,
13};
14
15/// The complete lock file — ownership registry for all managed items.
16///
17/// Schema version 2: items are keyed by logical identity ("kind/name"), and each item
18/// carries a list of per-output records (one per target root materialization).
19///
20/// TOML format, deterministically ordered (sorted keys) for clean git diffs.
21#[derive(Debug, Clone, Serialize, PartialEq)]
22pub struct LockFile {
23    /// Schema version. Current version is 2.
24    pub version: u32,
25    #[serde(default)]
26    pub dependencies: IndexMap<SourceName, LockedSource>,
27    /// V2: logical items keyed by "kind/name" identity string.
28    #[serde(default)]
29    pub items: IndexMap<String, LockedItemV2>,
30    /// Config entries installed by mars sync, keyed by target root and entry key.
31    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
32    pub config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
33    /// Dependency model alias winners (declaration-order merged, dependency-only).
34    #[serde(default)]
35    pub dependency_model_aliases: IndexMap<String, ModelAlias>,
36}
37
38/// Custom `Deserialize` for `LockFile`: delegates to the v2 wire type.
39///
40/// For reading v1 lock files, always go through [`load()`] which handles
41/// the v1→v2 promotion. Direct deserialization via `toml::from_str::<LockFile>`
42/// only supports v2 format.
43impl<'de> serde::Deserialize<'de> for LockFile {
44    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
45        let wire = LockFileV2Wire::deserialize(deserializer)?;
46        Ok(LockFile {
47            version: wire.version,
48            dependencies: wire.dependencies,
49            items: wire.items,
50            config_entries: wire.config_entries,
51            dependency_model_aliases: wire.dependency_model_aliases,
52        })
53    }
54}
55
56impl LockFile {
57    /// Create a new empty lock file with the current schema version.
58    pub fn empty() -> Self {
59        LockFile {
60            version: LOCK_VERSION,
61            dependencies: IndexMap::new(),
62            items: IndexMap::new(),
63            config_entries: BTreeMap::new(),
64            dependency_model_aliases: IndexMap::new(),
65        }
66    }
67
68    /// Look up a locked item by its output dest_path, returning a flat [`LockedItem`] view.
69    ///
70    /// Searches across all items and their output records. Returns the first match.
71    pub fn find_by_dest_path(&self, dest_path: &DestPath) -> Option<LockedItem> {
72        for item_v2 in self.items.values() {
73            for output in &item_v2.outputs {
74                if crate::target::dest_paths_equivalent(
75                    output.dest_path.as_str(),
76                    dest_path.as_str(),
77                ) {
78                    return Some(LockedItem {
79                        source: item_v2.source.clone(),
80                        kind: item_v2.kind,
81                        version: item_v2.version.clone(),
82                        source_checksum: item_v2.source_checksum.clone(),
83                        installed_checksum: output.installed_checksum.clone(),
84                        dest_path: output.dest_path.clone(),
85                    });
86                }
87            }
88        }
89        None
90    }
91
92    /// Check if any output record has the given dest_path.
93    pub fn contains_dest_path(&self, dest_path: &DestPath) -> bool {
94        self.items.values().any(|item| {
95            item.outputs.iter().any(|o| {
96                crate::target::dest_paths_equivalent(o.dest_path.as_str(), dest_path.as_str())
97            })
98        })
99    }
100
101    /// Iterate all output dest_paths across all items.
102    pub fn all_output_dest_paths(&self) -> impl Iterator<Item = &DestPath> {
103        self.items
104            .values()
105            .flat_map(|item| item.outputs.iter().map(|o| &o.dest_path))
106    }
107
108    /// Dest paths previously managed under a specific target root.
109    pub fn output_dest_paths_for_target(&self, target_root: &str) -> HashSet<String> {
110        self.items
111            .values()
112            .flat_map(|item| item.outputs.iter())
113            .filter(|output| output.target_root == target_root)
114            .map(|output| output.dest_path.to_string())
115            .collect()
116    }
117
118    /// Whether the lock records ownership of `dest_path` under `target_root`.
119    pub fn contains_output(&self, target_root: &str, dest_path: &str) -> bool {
120        self.items.values().any(|item| {
121            item.outputs.iter().any(|output| {
122                output.target_root == target_root
123                    && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
124            })
125        })
126    }
127
128    /// Flat view of canonical `.mars` outputs only.
129    pub fn canonical_flat_items(&self) -> Vec<(DestPath, LockedItem)> {
130        self.flat_items_for_target(CANONICAL_TARGET_ROOT)
131    }
132
133    /// Flat view of outputs materialized under `target_root`.
134    pub fn flat_items_for_target(&self, target_root: &str) -> Vec<(DestPath, LockedItem)> {
135        self.items
136            .values()
137            .flat_map(|item_v2| {
138                item_v2.outputs.iter().filter_map(|output| {
139                    if output.target_root != target_root {
140                        return None;
141                    }
142                    Some((
143                        output.dest_path.clone(),
144                        LockedItem {
145                            source: item_v2.source.clone(),
146                            kind: item_v2.kind,
147                            version: item_v2.version.clone(),
148                            source_checksum: item_v2.source_checksum.clone(),
149                            installed_checksum: output.installed_checksum.clone(),
150                            dest_path: output.dest_path.clone(),
151                        },
152                    ))
153                })
154            })
155            .collect()
156    }
157
158    /// Flat view of all items as owned `(dest_path, LockedItem)` pairs.
159    ///
160    /// Used by diff, orphan scan, and CLI commands that need a per-output view.
161    pub fn flat_items(&self) -> Vec<(DestPath, LockedItem)> {
162        self.items
163            .values()
164            .flat_map(|item_v2| {
165                item_v2.outputs.iter().map(|output| {
166                    (
167                        output.dest_path.clone(),
168                        LockedItem {
169                            source: item_v2.source.clone(),
170                            kind: item_v2.kind,
171                            version: item_v2.version.clone(),
172                            source_checksum: item_v2.source_checksum.clone(),
173                            installed_checksum: output.installed_checksum.clone(),
174                            dest_path: output.dest_path.clone(),
175                        },
176                    )
177                })
178            })
179            .collect()
180    }
181}
182
183/// Ephemeral lookup index for lock files.
184///
185/// `LockFile` preserves the persisted v2 shape. Build this short-lived index
186/// at hot call sites that need repeated output-path lookups.
187pub struct LockIndex<'a> {
188    lock: &'a LockFile,
189    by_output: HashMap<(String, String), (&'a str, usize)>,
190    by_dest_path: HashMap<String, (&'a str, usize)>,
191}
192
193impl<'a> LockIndex<'a> {
194    pub fn new(lock: &'a LockFile) -> Self {
195        let mut by_output = HashMap::new();
196        let mut by_dest_path = HashMap::new();
197
198        for (key, item) in &lock.items {
199            for (idx, output) in item.outputs.iter().enumerate() {
200                let normalized_dest = normalize_dest_path(output.dest_path.as_str());
201                by_dest_path
202                    .entry(normalized_dest.clone())
203                    .or_insert((key.as_str(), idx));
204                by_output.insert(
205                    (output.target_root.clone(), normalized_dest),
206                    (key.as_str(), idx),
207                );
208            }
209        }
210
211        Self {
212            lock,
213            by_output,
214            by_dest_path,
215        }
216    }
217
218    /// Look up a locked item by output dest_path, returning a flat [`LockedItem`] view.
219    pub fn find_by_dest_path(&self, dest_path: &DestPath) -> Option<LockedItem> {
220        let (item_key, output_idx) = *self
221            .by_dest_path
222            .get(&normalize_dest_path(dest_path.as_str()))?;
223        self.locked_item_for(item_key, output_idx)
224    }
225
226    /// Look up a locked output by target root + dest_path, returning a flat [`LockedItem`] view.
227    pub fn find_output(&self, target_root: &str, dest_path: &DestPath) -> Option<LockedItem> {
228        let (item_key, output_idx) = *self.by_output.get(&(
229            target_root.to_string(),
230            normalize_dest_path(dest_path.as_str()),
231        ))?;
232        self.locked_item_for(item_key, output_idx)
233    }
234
235    /// Whether any output is recorded for `target_root + dest_path`.
236    pub fn contains_output(&self, target_root: &str, dest_path: &DestPath) -> bool {
237        self.by_output.contains_key(&(
238            target_root.to_string(),
239            normalize_dest_path(dest_path.as_str()),
240        ))
241    }
242
243    fn locked_item_for(&self, item_key: &str, output_idx: usize) -> Option<LockedItem> {
244        let item_v2 = self.lock.items.get(item_key)?;
245        let output = item_v2.outputs.get(output_idx)?;
246        Some(LockedItem {
247            source: item_v2.source.clone(),
248            kind: item_v2.kind,
249            version: item_v2.version.clone(),
250            source_checksum: item_v2.source_checksum.clone(),
251            installed_checksum: output.installed_checksum.clone(),
252            dest_path: output.dest_path.clone(),
253        })
254    }
255
256    /// Check if any output record has the given dest_path.
257    pub fn contains_dest_path(&self, dest_path: &DestPath) -> bool {
258        self.by_dest_path
259            .contains_key(&normalize_dest_path(dest_path.as_str()))
260    }
261}
262
263fn normalize_dest_path(s: &str) -> String {
264    if cfg!(windows) {
265        s.replace('\\', "/")
266    } else {
267        s.to_string()
268    }
269}
270
271/// One resolved source in the lock.
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
273pub struct LockedSource {
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub url: Option<SourceUrl>,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub path: Option<String>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub subpath: Option<SourceSubpath>,
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub version: Option<String>,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub commit: Option<CommitHash>,
284    /// Reserved for future content verification of fetched source trees.
285    /// TODO: populate during fetch/build once deterministic tree hashing is implemented.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub tree_hash: Option<String>,
288}
289
290/// V2 locked item: one logical item with per-output records.
291///
292/// `source_checksum` is shared across all outputs (same source content).
293/// Each `OutputRecord` has its own `installed_checksum` for divergence detection.
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
295pub struct LockedItemV2 {
296    pub source: SourceName,
297    pub kind: ItemKind,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub version: Option<String>,
300    pub source_checksum: ContentHash,
301    /// Per-output records: one per target root this item was materialized to.
302    pub outputs: Vec<OutputRecord>,
303}
304
305/// A single materialized output of a logical item.
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
307pub struct OutputRecord {
308    /// Target root this output belongs to (e.g., ".mars", ".claude").
309    pub target_root: String,
310    /// Relative path under the target root (e.g., "agents/coder.md").
311    pub dest_path: DestPath,
312    /// Checksum of the installed content at this output location.
313    pub installed_checksum: ContentHash,
314}
315
316/// Ownership record for one target-native config entry.
317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
318pub struct ConfigEntryRecord {
319    pub source: String,
320}
321
322/// Flat view of a single installed item — used by diff, plan, and apply stages.
323///
324/// Constructed from [`LockedItemV2`] + one [`OutputRecord`]; preserves backward
325/// compat with code that operates on per-dest-path records.
326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
327pub struct LockedItem {
328    pub source: SourceName,
329    pub kind: ItemKind,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub version: Option<String>,
332    pub source_checksum: ContentHash,
333    pub installed_checksum: ContentHash,
334    pub dest_path: DestPath,
335}
336
337// Re-export ItemKind and ItemId from types — they're shared vocabulary,
338// not lock-specific. This preserves `use crate::lock::ItemKind` compatibility.
339pub use crate::types::{ItemId, ItemKind};
340
341const LOCK_FILE: &str = "mars.lock";
342/// Current lock file schema version.
343const LOCK_VERSION: u32 = 2;
344/// Canonical materialization root for `.mars/` apply outcomes.
345pub const CANONICAL_TARGET_ROOT: &str = ".mars";
346
347// ---------------------------------------------------------------------------
348// V1 wire type — used only for reading legacy lock files.
349// ---------------------------------------------------------------------------
350
351/// V1 wire format for reading legacy lock files.
352#[derive(Deserialize)]
353struct LockFileV1 {
354    #[allow(dead_code)]
355    version: u32,
356    #[serde(default)]
357    dependencies: IndexMap<SourceName, LockedSource>,
358    #[serde(default)]
359    items: IndexMap<DestPath, LockedItem>,
360}
361
362/// V2 wire format for Deserialize (mirrors `LockFile` but derives `Deserialize`).
363#[derive(Deserialize)]
364struct LockFileV2Wire {
365    version: u32,
366    #[serde(default)]
367    dependencies: IndexMap<SourceName, LockedSource>,
368    #[serde(default)]
369    items: IndexMap<String, LockedItemV2>,
370    #[serde(default)]
371    config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
372    #[serde(default)]
373    dependency_model_aliases: IndexMap<String, ModelAlias>,
374}
375
376// ---------------------------------------------------------------------------
377// Load / write
378// ---------------------------------------------------------------------------
379
380/// Load the lock file from the given root directory.
381///
382/// Returns an empty LockFile (v2) if the file is absent.
383/// V1 lock files are transparently promoted to the v2 in-memory shape (D19):
384/// the lock is only written as v2 after a successful sync.
385pub fn load(root: &Path) -> Result<LockFile, MarsError> {
386    let (lock, _) = load_with_diagnostics(root)?;
387    Ok(lock)
388}
389
390/// Load lock for runtime alias commands (`models list/resolve`, launch bundle routing).
391///
392/// Legacy v2 lock files created before dependency aliases were moved into `mars.lock`
393/// may omit `dependency_model_aliases` entirely. When dependency entries exist, runtime
394/// alias consumers must fail closed so dependency alias authority is not silently treated
395/// as empty.
396pub fn load_for_runtime_aliases(root: &Path) -> Result<LockFile, MarsError> {
397    let path = root.join(LOCK_FILE);
398    let content = match std::fs::read_to_string(&path) {
399        Ok(c) => c,
400        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(LockFile::empty()),
401        Err(e) => return Err(LockError::Io(e).into()),
402    };
403
404    let value: toml::Value = toml::from_str(&content).map_err(|e| LockError::Corrupt {
405        message: format!("failed to parse {}: {e}", path.display()),
406    })?;
407
408    let has_dependency_alias_field = value
409        .as_table()
410        .map(|table| table.contains_key("dependency_model_aliases"))
411        .unwrap_or(false);
412
413    let (lock, _) = load_with_diagnostics(root)?;
414
415    if !has_dependency_alias_field && !lock.dependencies.is_empty() {
416        return Err(LockError::Corrupt {
417            message: format!(
418                "legacy {} is missing `dependency_model_aliases` for dependency alias authority; run `{}` to update it",
419                LOCK_FILE,
420                crate::types::managed_cmd("mars sync")
421            ),
422        }
423        .into());
424    }
425
426    Ok(lock)
427}
428
429/// Load the lock file and return any diagnostics produced while reading it.
430///
431/// This preserves legacy v1→v2 in-memory promotion while routing promotion
432/// warnings through the normal diagnostic flow for sync callers.
433pub fn load_with_diagnostics(root: &Path) -> Result<(LockFile, Vec<Diagnostic>), MarsError> {
434    let path = root.join(LOCK_FILE);
435    let content = match std::fs::read_to_string(&path) {
436        Ok(c) => c,
437        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
438            return Ok((LockFile::empty(), Vec::new()));
439        }
440        Err(e) => return Err(LockError::Io(e).into()),
441    };
442
443    let value: toml::Value = toml::from_str(&content).map_err(|e| LockError::Corrupt {
444        message: format!("failed to parse {}: {e}", path.display()),
445    })?;
446
447    match value.clone().try_into::<LockFileV2Wire>() {
448        Ok(wire) if wire.version >= 2 => Ok((
449            LockFile {
450                version: wire.version,
451                dependencies: wire.dependencies,
452                items: wire.items,
453                config_entries: wire.config_entries,
454                dependency_model_aliases: wire.dependency_model_aliases,
455            },
456            Vec::new(),
457        )),
458        v2_result => {
459            // V1 → V2 promotion (D19): map each DestPath key to a logical identity.
460            let wire: LockFileV1 = value.clone().try_into().map_err(|v1_error| {
461                let parse_error = match v2_result {
462                    Ok(wire) => format!("unsupported lock version {}", wire.version),
463                    Err(v2_error) => {
464                        format!("v2 parse failed: {v2_error}; v1 parse failed: {v1_error}")
465                    }
466                };
467                LockError::Corrupt {
468                    message: format!("failed to parse {}: {parse_error}", path.display()),
469                }
470            })?;
471            let (items, diagnostics) = promote_v1_items(wire.items);
472            Ok((
473                LockFile {
474                    version: LOCK_VERSION,
475                    dependencies: wire.dependencies,
476                    items,
477                    config_entries: BTreeMap::new(),
478                    dependency_model_aliases: IndexMap::new(),
479                },
480                diagnostics,
481            ))
482        }
483    }
484}
485
486/// Write the lock file atomically to the given root directory (always v2 format).
487pub fn write(root: &Path, lock: &LockFile) -> Result<(), MarsError> {
488    let path = root.join(LOCK_FILE);
489    let mut normalized = lock.clone();
490    normalized.dependencies.sort_keys();
491    normalized.items.sort_keys();
492    normalized.dependency_model_aliases.sort_keys();
493
494    let content = toml::to_string_pretty(&normalized).map_err(|e| LockError::Corrupt {
495        message: format!("failed to serialize lock file: {e}"),
496    })?;
497    crate::fs::atomic_write(&path, content.as_bytes())
498}
499
500/// Convert v1 `IndexMap<DestPath, LockedItem>` to v2 `IndexMap<String, LockedItemV2>`.
501///
502/// Each v1 entry becomes one `LockedItemV2` with exactly one `OutputRecord`
503/// using `target_root = ".mars"` (the only output root in v1).
504///
505/// Key collision: two v1 entries with different dest_paths but the same basename
506/// (e.g. `hooks/pre-commit/hook.sh` and `hooks/pre-push/hook.sh` both name "hook")
507/// would map to the same key and silently drop one. When a collision is detected,
508/// we warn and fall back to the raw dest_path string as a disambiguated key.
509fn promote_v1_items(
510    v1_items: IndexMap<DestPath, LockedItem>,
511) -> (IndexMap<String, LockedItemV2>, Vec<Diagnostic>) {
512    let mut result: IndexMap<String, LockedItemV2> = IndexMap::new();
513    let mut diagnostics = Vec::new();
514
515    for (dest_path, item) in v1_items {
516        let key = format!("{}/{}", item.kind, dest_path.item_name(item.kind));
517        let item_v2 = LockedItemV2 {
518            source: item.source,
519            kind: item.kind,
520            version: item.version,
521            source_checksum: item.source_checksum,
522            outputs: vec![OutputRecord {
523                target_root: ".mars".to_string(),
524                dest_path: item.dest_path,
525                installed_checksum: item.installed_checksum,
526            }],
527        };
528
529        if result.contains_key(&key) {
530            // Two v1 entries share the same basename — use the full dest_path as a
531            // disambiguated key so neither entry is silently dropped.
532            let fallback_key = format!("{}/{}", item_v2.kind, dest_path.as_str());
533            diagnostics.push(Diagnostic {
534                level: crate::diagnostic::DiagnosticLevel::Warning,
535                code: "lock-promotion-collision",
536                message: format!(
537                    "v1→v2 promotion: key collision on `{key}`; using dest_path key `{fallback_key}`"
538                ),
539                context: None,
540                category: None,
541            });
542            result.insert(fallback_key, item_v2);
543        } else {
544            result.insert(key, item_v2);
545        }
546    }
547
548    (result, diagnostics)
549}
550
551// ---------------------------------------------------------------------------
552// Build
553// ---------------------------------------------------------------------------
554
555/// Build a new lock file from resolved graph + apply results.
556///
557/// Constructs the lock file from the graph (source provenance) and
558/// the apply outcomes (checksums). Items that were skipped, kept, or
559/// merged retain their provenance from the graph. Removed items are excluded.
560pub fn build(
561    graph: &crate::resolve::ResolvedGraph,
562    applied: &crate::sync::apply::ApplyResult,
563    old_lock: &LockFile,
564    config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
565) -> Result<LockFile, MarsError> {
566    use crate::sync::apply::ActionTaken;
567
568    let mut dependencies = IndexMap::new();
569    let mut items: IndexMap<String, LockedItemV2> = IndexMap::new();
570    let old_lock_index = LockIndex::new(old_lock);
571
572    for outcome in &applied.outcomes {
573        match outcome.action {
574            ActionTaken::Installed
575            | ActionTaken::Updated
576            | ActionTaken::Merged
577            | ActionTaken::Conflicted => {
578                let installed =
579                    outcome
580                        .installed_checksum
581                        .as_ref()
582                        .ok_or_else(|| LockError::Corrupt {
583                            message: format!(
584                                "missing checksum for write-producing action on {}",
585                                outcome.dest_path
586                            ),
587                        })?;
588                if checksum_is_empty(installed) {
589                    return Err(LockError::Corrupt {
590                        message: format!("empty installed_checksum for {}", outcome.dest_path),
591                    }
592                    .into());
593                }
594
595                let source =
596                    outcome
597                        .source_checksum
598                        .as_ref()
599                        .ok_or_else(|| LockError::Corrupt {
600                            message: format!(
601                                "missing source checksum for write-producing action on {}",
602                                outcome.dest_path
603                            ),
604                        })?;
605                if checksum_is_empty(source) {
606                    return Err(LockError::Corrupt {
607                        message: format!("empty source_checksum for {}", outcome.dest_path),
608                    }
609                    .into());
610                }
611            }
612            ActionTaken::Removed | ActionTaken::Skipped | ActionTaken::Kept => {}
613        }
614    }
615
616    // Build dependency entries directly from resolved graph provenance.
617    for (name, node) in &graph.nodes {
618        dependencies.insert(name.clone(), to_locked_source(node));
619    }
620
621    // Build item entries from apply outcomes.
622    for outcome in &applied.outcomes {
623        match &outcome.action {
624            ActionTaken::Removed | ActionTaken::Skipped => {
625                // For skipped items, carry forward from old lock
626                if matches!(outcome.action, ActionTaken::Skipped) {
627                    let item_key = item_key(&outcome.item_id);
628                    if let Some(old_item) = old_lock.items.get(&item_key) {
629                        items.insert(item_key, old_item.clone());
630                    } else {
631                        // Fall back: search old lock by dest_path (handles v1→v2 migrations
632                        // where item_key may not match yet)
633                        if let Some(flat) =
634                            old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
635                        {
636                            let key =
637                                format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
638                            items.entry(key).or_insert_with(|| LockedItemV2 {
639                                source: flat.source,
640                                kind: flat.kind,
641                                version: flat.version,
642                                source_checksum: flat.source_checksum,
643                                outputs: vec![OutputRecord {
644                                    target_root: ".mars".to_string(),
645                                    dest_path: flat.dest_path,
646                                    installed_checksum: flat.installed_checksum,
647                                }],
648                            });
649                        }
650                    }
651                }
652                // Removed items are excluded from the new lock.
653            }
654            ActionTaken::Kept => {
655                // Keep local: carry forward old lock entry.
656                let item_key = item_key(&outcome.item_id);
657                if let Some(old_item) = old_lock.items.get(&item_key) {
658                    items.insert(item_key, old_item.clone());
659                } else if let Some(flat) =
660                    old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
661                {
662                    let key = format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
663                    items.entry(key).or_insert_with(|| LockedItemV2 {
664                        source: flat.source,
665                        kind: flat.kind,
666                        version: flat.version,
667                        source_checksum: flat.source_checksum,
668                        outputs: vec![OutputRecord {
669                            target_root: ".mars".to_string(),
670                            dest_path: flat.dest_path,
671                            installed_checksum: flat.installed_checksum,
672                        }],
673                    });
674                }
675            }
676            ActionTaken::Installed
677            | ActionTaken::Updated
678            | ActionTaken::Merged
679            | ActionTaken::Conflicted => {
680                let dest_path = outcome.dest_path.clone();
681                if dest_path.as_str().is_empty() {
682                    continue;
683                }
684
685                // Use source_name from outcome (propagated from TargetItem)
686                let source_name = if outcome.source_name.as_ref().is_empty() {
687                    None
688                } else {
689                    Some(outcome.source_name.clone())
690                };
691
692                // Determine version from graph
693                let version = source_name.as_ref().and_then(|sn| {
694                    graph
695                        .nodes
696                        .get(sn)
697                        .and_then(|n| n.resolved_ref.version_tag.clone())
698                });
699
700                let source_checksum = outcome
701                    .source_checksum
702                    .clone()
703                    .expect("validated above: source_checksum exists for write actions");
704                let installed_checksum = outcome
705                    .installed_checksum
706                    .clone()
707                    .expect("validated above: installed_checksum exists for write actions");
708
709                let key = item_key(&outcome.item_id);
710                items.insert(
711                    key,
712                    LockedItemV2 {
713                        source: source_name.unwrap_or_else(|| SourceName::from("")),
714                        kind: outcome.item_id.kind,
715                        version,
716                        source_checksum,
717                        outputs: vec![OutputRecord {
718                            target_root: ".mars".to_string(),
719                            dest_path,
720                            installed_checksum,
721                        }],
722                    },
723                );
724            }
725        }
726    }
727
728    // Add synthetic _self source if any local package items exist.
729    let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
730    let has_self_items = items.values().any(|item| item.source == local_source_name);
731    if has_self_items {
732        dependencies.insert(
733            local_source_name,
734            LockedSource {
735                url: None,
736                path: Some(".".into()),
737                subpath: None,
738                version: None,
739                commit: None,
740                tree_hash: None,
741            },
742        );
743    }
744
745    // Validate checksums.
746    for item in items.values() {
747        if checksum_is_empty(&item.source_checksum) {
748            let dest = item
749                .outputs
750                .first()
751                .map(|o| o.dest_path.to_string())
752                .unwrap_or_default();
753            return Err(LockError::Corrupt {
754                message: format!("empty source_checksum for {dest}"),
755            }
756            .into());
757        }
758        for output in &item.outputs {
759            if checksum_is_empty(&output.installed_checksum) {
760                return Err(LockError::Corrupt {
761                    message: format!("empty installed_checksum for {}", output.dest_path),
762                }
763                .into());
764            }
765        }
766    }
767
768    // Sort keys for deterministic output.
769    dependencies.sort_keys();
770    items.sort_keys();
771
772    Ok(LockFile {
773        version: LOCK_VERSION,
774        dependencies,
775        items,
776        config_entries,
777        dependency_model_aliases: IndexMap::new(),
778    })
779}
780
781/// Merge per-target sync results into a built lock file.
782pub fn apply_target_sync_outputs(
783    lock: &mut LockFile,
784    target_outcomes: &[crate::target_sync::TargetSyncOutcome],
785) {
786    for outcome in target_outcomes {
787        for dest_path in &outcome.removed_dest_paths {
788            remove_target_output(lock, &outcome.target, dest_path);
789        }
790        for synced in &outcome.synced_outputs {
791            upsert_target_output(
792                lock,
793                &outcome.target,
794                &synced.dest_path,
795                &synced.installed_checksum,
796            );
797        }
798    }
799}
800
801/// Record native harness outputs produced by dual-surface compile.
802pub fn apply_compiled_native_outputs(
803    lock: &mut LockFile,
804    records: &[(String, String, ContentHash)],
805) {
806    for (target_root, dest_path, installed_checksum) in records {
807        upsert_target_output(lock, target_root, dest_path, installed_checksum);
808    }
809}
810
811fn upsert_target_output(
812    lock: &mut LockFile,
813    target_root: &str,
814    dest_path: &str,
815    installed_checksum: &ContentHash,
816) {
817    let dest = DestPath::from(dest_path);
818    for item in lock.items.values_mut() {
819        if !item.outputs.iter().any(|output| {
820            crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
821        }) {
822            continue;
823        }
824
825        if let Some(output) = item.outputs.iter_mut().find(|output| {
826            output.target_root == target_root
827                && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
828        }) {
829            output.installed_checksum = installed_checksum.clone();
830            return;
831        }
832
833        item.outputs.push(OutputRecord {
834            target_root: target_root.to_string(),
835            dest_path: dest,
836            installed_checksum: installed_checksum.clone(),
837        });
838        item.outputs.sort_by(|a, b| {
839            a.target_root
840                .cmp(&b.target_root)
841                .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
842        });
843        return;
844    }
845}
846
847fn remove_target_output(lock: &mut LockFile, target_root: &str, dest_path: &str) {
848    for item in lock.items.values_mut() {
849        item.outputs.retain(|output| {
850            !(output.target_root == target_root
851                && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path))
852        });
853    }
854    lock.items.retain(|_, item| !item.outputs.is_empty());
855}
856
857// ---------------------------------------------------------------------------
858// Helpers
859// ---------------------------------------------------------------------------
860
861fn checksum_is_empty(checksum: &ContentHash) -> bool {
862    checksum.as_ref().trim().is_empty()
863}
864
865fn to_locked_source(node: &crate::resolve::ResolvedNode) -> LockedSource {
866    let (url, path, subpath) = match &node.source_id {
867        SourceId::Git { url, subpath } => (Some(url.clone()), None, subpath.clone()),
868        SourceId::Path { canonical, subpath } => (
869            None,
870            Some(canonical.to_string_lossy().to_string()),
871            subpath.clone(),
872        ),
873    };
874
875    LockedSource {
876        url,
877        path,
878        subpath,
879        version: node.resolved_ref.version_tag.clone(),
880        commit: node.resolved_ref.commit.clone(),
881        tree_hash: None,
882    }
883}
884
885/// Canonical item key for v2 lock: `"kind/name"`.
886pub fn item_key(id: &ItemId) -> String {
887    format!("{}/{}", id.kind, id.name)
888}
889
890// ---------------------------------------------------------------------------
891// Tests
892// ---------------------------------------------------------------------------
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897    use std::collections::HashMap;
898    use std::path::PathBuf;
899
900    use crate::resolve::{ResolvedGraph, ResolvedNode};
901    use crate::source::ResolvedRef;
902    use crate::sync::apply::{ActionOutcome, ActionTaken, ApplyResult};
903    use crate::types::{SourceId, SourceUrl};
904    use tempfile::TempDir;
905
906    fn sample_lock() -> LockFile {
907        let mut dependencies = IndexMap::new();
908        dependencies.insert(
909            "base".into(),
910            LockedSource {
911                url: Some("https://github.com/org/base.git".into()),
912                path: None,
913                subpath: None,
914                version: Some("v1.0.0".into()),
915                commit: Some("abc123".into()),
916                tree_hash: Some("def456".into()),
917            },
918        );
919
920        let mut items = IndexMap::new();
921        items.insert(
922            "agent/coder".to_string(),
923            LockedItemV2 {
924                source: "base".into(),
925                kind: ItemKind::Agent,
926                version: Some("v1.0.0".into()),
927                source_checksum: "sha256:aaa".into(),
928                outputs: vec![OutputRecord {
929                    target_root: ".mars".to_string(),
930                    dest_path: "agents/coder.md".into(),
931                    installed_checksum: "sha256:bbb".into(),
932                }],
933            },
934        );
935        items.insert(
936            "skill/review".to_string(),
937            LockedItemV2 {
938                source: "base".into(),
939                kind: ItemKind::Skill,
940                version: Some("v1.0.0".into()),
941                source_checksum: "sha256:ccc".into(),
942                outputs: vec![OutputRecord {
943                    target_root: ".mars".to_string(),
944                    dest_path: "skills/review".into(),
945                    installed_checksum: "sha256:ddd".into(),
946                }],
947            },
948        );
949
950        LockFile {
951            version: LOCK_VERSION,
952            dependencies,
953            items,
954            config_entries: BTreeMap::new(),
955            dependency_model_aliases: IndexMap::new(),
956        }
957    }
958
959    #[test]
960    fn parse_v1_lock_file_promoted_to_v2() {
961        let toml_str = r#"
962version = 1
963
964[dependencies.base]
965url = "https://github.com/org/base.git"
966version = "v1.0.0"
967commit = "abc123"
968tree_hash = "def456"
969
970[items."agents/coder.md"]
971source = "base"
972kind = "agent"
973version = "v1.0.0"
974source_checksum = "sha256:aaa"
975installed_checksum = "sha256:bbb"
976dest_path = "agents/coder.md"
977"#;
978        // Load via the full load() path (promotion happens there).
979        let dir = TempDir::new().unwrap();
980        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
981        let lock = load(dir.path()).unwrap();
982
983        // Promoted to v2 in memory.
984        assert_eq!(lock.version, LOCK_VERSION);
985        assert_eq!(lock.dependencies.len(), 1);
986        assert_eq!(lock.items.len(), 1);
987
988        // V2 key is "kind/name".
989        let item = &lock.items["agent/coder"];
990        assert_eq!(item.source, "base");
991        assert_eq!(item.kind, ItemKind::Agent);
992        assert_eq!(item.source_checksum, "sha256:aaa");
993        assert_eq!(item.outputs.len(), 1);
994        assert_eq!(item.outputs[0].installed_checksum, "sha256:bbb");
995        assert_eq!(item.outputs[0].dest_path.as_str(), "agents/coder.md");
996        assert_eq!(item.outputs[0].target_root, ".mars");
997    }
998
999    #[test]
1000    fn parse_v2_lock_file() {
1001        let toml_str = r#"
1002version = 2
1003
1004[dependencies.base]
1005url = "https://github.com/org/base.git"
1006version = "v1.0.0"
1007commit = "abc123"
1008
1009[items."agent/coder"]
1010source = "base"
1011kind = "agent"
1012version = "v1.0.0"
1013source_checksum = "sha256:aaa"
1014
1015[[items."agent/coder".outputs]]
1016target_root = ".mars"
1017dest_path = "agents/coder.md"
1018installed_checksum = "sha256:bbb"
1019"#;
1020        let dir = TempDir::new().unwrap();
1021        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1022        let lock = load(dir.path()).unwrap();
1023
1024        assert_eq!(lock.version, 2);
1025        assert_eq!(lock.items.len(), 1);
1026
1027        let item = &lock.items["agent/coder"];
1028        assert_eq!(item.source_checksum, "sha256:aaa");
1029        assert_eq!(item.outputs[0].installed_checksum, "sha256:bbb");
1030    }
1031
1032    #[test]
1033    fn load_for_runtime_aliases_rejects_legacy_v2_without_dependency_alias_authority() {
1034        let toml_str = r#"
1035version = 2
1036
1037[dependencies.base]
1038url = "https://github.com/org/base.git"
1039version = "v1.0.0"
1040commit = "abc123"
1041
1042[items."agent/coder"]
1043source = "base"
1044kind = "agent"
1045source_checksum = "sha256:aaa"
1046
1047[[items."agent/coder".outputs]]
1048target_root = ".mars"
1049dest_path = "agents/coder.md"
1050installed_checksum = "sha256:bbb"
1051"#;
1052        let dir = TempDir::new().unwrap();
1053        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1054
1055        let err = load_for_runtime_aliases(dir.path()).unwrap_err();
1056        let message = err.to_string();
1057        assert!(message.contains("missing `dependency_model_aliases`"));
1058        assert!(message.contains("run `mars sync`"));
1059    }
1060
1061    #[test]
1062    fn load_for_runtime_aliases_allows_missing_dependency_aliases_when_no_dependencies() {
1063        let toml_str = r#"
1064version = 2
1065
1066[items."agent/coder"]
1067source = "_self"
1068kind = "agent"
1069source_checksum = "sha256:aaa"
1070
1071[[items."agent/coder".outputs]]
1072target_root = ".mars"
1073dest_path = "agents/coder.md"
1074installed_checksum = "sha256:bbb"
1075"#;
1076        let dir = TempDir::new().unwrap();
1077        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1078
1079        let lock = load_for_runtime_aliases(dir.path()).unwrap();
1080        assert!(lock.dependencies.is_empty());
1081        assert!(lock.dependency_model_aliases.is_empty());
1082    }
1083
1084    #[test]
1085    fn roundtrip_lock_file() {
1086        let lock = sample_lock();
1087        let dir = TempDir::new().unwrap();
1088        write(dir.path(), &lock).unwrap();
1089        let reloaded = load(dir.path()).unwrap();
1090        assert_eq!(lock, reloaded);
1091    }
1092
1093    #[test]
1094    fn roundtrip_lock_file_with_config_entries() {
1095        let mut lock = sample_lock();
1096        lock.config_entries.insert(
1097            ".claude".to_string(),
1098            BTreeMap::from([(
1099                "mcp:context7".to_string(),
1100                ConfigEntryRecord {
1101                    source: "base".to_string(),
1102                },
1103            )]),
1104        );
1105
1106        let dir = TempDir::new().unwrap();
1107        write(dir.path(), &lock).unwrap();
1108        let reloaded = load(dir.path()).unwrap();
1109
1110        assert_eq!(lock, reloaded);
1111        assert_eq!(
1112            reloaded.config_entries[".claude"]["mcp:context7"].source,
1113            "base"
1114        );
1115    }
1116
1117    #[test]
1118    fn write_emits_dependency_model_aliases_table_even_when_empty() {
1119        let lock = sample_lock();
1120        let dir = TempDir::new().unwrap();
1121        write(dir.path(), &lock).unwrap();
1122
1123        let content = std::fs::read_to_string(dir.path().join("mars.lock")).unwrap();
1124        assert!(
1125            content.contains("dependency_model_aliases"),
1126            "serialized lock should include dependency_model_aliases authority table"
1127        );
1128    }
1129
1130    #[test]
1131    fn deterministic_serialization() {
1132        let lock = sample_lock();
1133        let s1 = toml::to_string_pretty(&lock).unwrap();
1134        let s2 = toml::to_string_pretty(&lock).unwrap();
1135        assert_eq!(s1, s2);
1136
1137        // V2: keys are "agent/coder" and "skill/review" — agent comes before skill alphabetically.
1138        let coder_pos = s1.find("agent/coder").unwrap();
1139        let review_pos = s1.find("skill/review").unwrap();
1140        assert!(
1141            coder_pos < review_pos,
1142            "agent/coder should appear before skill/review"
1143        );
1144    }
1145
1146    #[test]
1147    fn write_sorts_dependency_model_aliases_keys() {
1148        let toml_str = r#"
1149version = 2
1150
1151[dependency_model_aliases.zeta]
1152model = "openai/gpt-z"
1153
1154[dependency_model_aliases.alpha]
1155model = "openai/gpt-a"
1156"#;
1157        let dir = TempDir::new().unwrap();
1158        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1159
1160        let lock = load(dir.path()).unwrap();
1161        write(dir.path(), &lock).unwrap();
1162
1163        let written = std::fs::read_to_string(dir.path().join("mars.lock")).unwrap();
1164        let alpha = written
1165            .find("[dependency_model_aliases.alpha]")
1166            .expect("alpha alias should be serialized");
1167        let zeta = written
1168            .find("[dependency_model_aliases.zeta]")
1169            .expect("zeta alias should be serialized");
1170        assert!(alpha < zeta, "aliases should serialize in sorted key order");
1171    }
1172
1173    #[test]
1174    fn empty_lock_file() {
1175        let lock = LockFile::empty();
1176        assert_eq!(lock.version, LOCK_VERSION);
1177        assert!(lock.dependencies.is_empty());
1178        assert!(lock.items.is_empty());
1179    }
1180
1181    #[test]
1182    fn load_absent_returns_empty() {
1183        let dir = TempDir::new().unwrap();
1184        let lock = load(dir.path()).unwrap();
1185        assert_eq!(lock.version, LOCK_VERSION);
1186        assert!(lock.dependencies.is_empty());
1187        assert!(lock.items.is_empty());
1188    }
1189
1190    #[test]
1191    fn write_and_reload() {
1192        let dir = TempDir::new().unwrap();
1193        let lock = sample_lock();
1194        write(dir.path(), &lock).unwrap();
1195        let reloaded = load(dir.path()).unwrap();
1196        assert_eq!(lock, reloaded);
1197    }
1198
1199    #[test]
1200    fn dual_checksums_present() {
1201        let lock = sample_lock();
1202        let item = &lock.items["agent/coder"];
1203        assert_ne!(item.source_checksum, item.outputs[0].installed_checksum);
1204        assert!(item.source_checksum.starts_with("sha256:"));
1205        assert!(item.outputs[0].installed_checksum.starts_with("sha256:"));
1206    }
1207
1208    #[test]
1209    fn path_source_in_lock() {
1210        let toml_str = r#"
1211version = 2
1212
1213[dependencies.local]
1214path = "/home/dev/agents"
1215
1216[items."agent/helper"]
1217source = "local"
1218kind = "agent"
1219source_checksum = "sha256:111"
1220
1221[[items."agent/helper".outputs]]
1222target_root = ".mars"
1223dest_path = "agents/helper.md"
1224installed_checksum = "sha256:222"
1225"#;
1226        let dir = TempDir::new().unwrap();
1227        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1228        let lock = load(dir.path()).unwrap();
1229        let source = &lock.dependencies["local"];
1230        assert!(source.url.is_none());
1231        assert_eq!(source.path.as_deref(), Some("/home/dev/agents"));
1232        assert!(source.commit.is_none());
1233    }
1234
1235    #[test]
1236    fn item_kind_serializes_lowercase() {
1237        let item = LockedItemV2 {
1238            source: "base".into(),
1239            kind: ItemKind::Skill,
1240            version: None,
1241            source_checksum: "sha256:aaa".into(),
1242            outputs: vec![OutputRecord {
1243                target_root: ".mars".to_string(),
1244                dest_path: "skills/review".into(),
1245                installed_checksum: "sha256:bbb".into(),
1246            }],
1247        };
1248        let serialized = toml::to_string(&item).unwrap();
1249        assert!(serialized.contains("kind = \"skill\""));
1250    }
1251
1252    #[test]
1253    fn item_id_display() {
1254        let id = ItemId {
1255            kind: ItemKind::Agent,
1256            name: "coder".into(),
1257        };
1258        assert_eq!(id.to_string(), "agent/coder");
1259    }
1260
1261    #[test]
1262    fn item_kind_display() {
1263        assert_eq!(ItemKind::Agent.to_string(), "agent");
1264        assert_eq!(ItemKind::Skill.to_string(), "skill");
1265    }
1266
1267    #[test]
1268    fn find_by_dest_path_returns_flat_view() {
1269        let lock = sample_lock();
1270        let found = lock
1271            .find_by_dest_path(&DestPath::from("agents/coder.md"))
1272            .unwrap();
1273        assert_eq!(found.source, "base");
1274        assert_eq!(found.kind, ItemKind::Agent);
1275        assert_eq!(found.source_checksum, "sha256:aaa");
1276        assert_eq!(found.installed_checksum, "sha256:bbb");
1277        assert_eq!(found.dest_path.as_str(), "agents/coder.md");
1278    }
1279
1280    #[test]
1281    fn find_by_dest_path_missing_returns_none() {
1282        let lock = sample_lock();
1283        assert!(
1284            lock.find_by_dest_path(&DestPath::from("agents/missing.md"))
1285                .is_none()
1286        );
1287    }
1288
1289    #[test]
1290    fn contains_dest_path_hit_and_miss() {
1291        let lock = sample_lock();
1292        assert!(lock.contains_dest_path(&DestPath::from("agents/coder.md")));
1293        assert!(!lock.contains_dest_path(&DestPath::from("agents/nobody.md")));
1294    }
1295
1296    #[test]
1297    fn lock_index_find_by_dest_path_hit_and_miss() {
1298        let lock = sample_lock();
1299        let index = LockIndex::new(&lock);
1300
1301        let found = index
1302            .find_by_dest_path(&DestPath::from("agents/coder.md"))
1303            .unwrap();
1304        assert_eq!(found.source, "base");
1305        assert_eq!(found.kind, ItemKind::Agent);
1306        assert_eq!(found.source_checksum, "sha256:aaa");
1307        assert_eq!(found.installed_checksum, "sha256:bbb");
1308        assert_eq!(found.dest_path.as_str(), "agents/coder.md");
1309
1310        assert!(
1311            index
1312                .find_by_dest_path(&DestPath::from("agents/missing.md"))
1313                .is_none()
1314        );
1315    }
1316
1317    #[test]
1318    fn lock_index_contains_dest_path_hit_and_miss() {
1319        let lock = sample_lock();
1320        let index = LockIndex::new(&lock);
1321
1322        assert!(index.contains_dest_path(&DestPath::from("agents/coder.md")));
1323        assert!(!index.contains_dest_path(&DestPath::from("agents/nobody.md")));
1324    }
1325
1326    #[test]
1327    fn lock_index_target_scoped_lookup_distinguishes_same_dest_path() {
1328        let mut lock = sample_lock();
1329        lock.items
1330            .get_mut("agent/coder")
1331            .unwrap()
1332            .outputs
1333            .push(OutputRecord {
1334                target_root: ".pi".to_string(),
1335                dest_path: "agents/coder.md".into(),
1336                installed_checksum: "sha256:pi".into(),
1337            });
1338
1339        let index = LockIndex::new(&lock);
1340        let dest = DestPath::from("agents/coder.md");
1341
1342        let mars = index
1343            .find_output(".mars", &dest)
1344            .expect("expected canonical .mars output");
1345        let pi = index
1346            .find_output(".pi", &dest)
1347            .expect("expected .pi output");
1348
1349        assert_eq!(mars.installed_checksum, "sha256:bbb");
1350        assert_eq!(pi.installed_checksum, "sha256:pi");
1351        assert!(index.contains_output(".mars", &dest));
1352        assert!(index.contains_output(".pi", &dest));
1353        assert!(!index.contains_output(".cursor", &dest));
1354    }
1355
1356    #[test]
1357    fn output_dest_paths_for_target_filters_by_target_root() {
1358        let mut lock = sample_lock();
1359        lock.items
1360            .get_mut("agent/coder")
1361            .unwrap()
1362            .outputs
1363            .push(OutputRecord {
1364                target_root: ".cursor".to_string(),
1365                dest_path: "agents/coder.md".into(),
1366                installed_checksum: "sha256:cursor".into(),
1367            });
1368
1369        let mars_paths = lock.output_dest_paths_for_target(".mars");
1370        assert!(mars_paths.contains("agents/coder.md"));
1371        assert!(mars_paths.contains("skills/review"));
1372
1373        let cursor_paths = lock.output_dest_paths_for_target(".cursor");
1374        assert_eq!(cursor_paths.len(), 1);
1375        assert!(cursor_paths.contains("agents/coder.md"));
1376        assert!(lock.output_dest_paths_for_target(".claude").is_empty());
1377    }
1378
1379    #[test]
1380    fn contains_output_matches_target_root_and_dest_path() {
1381        let mut lock = sample_lock();
1382        assert!(lock.contains_output(".mars", "agents/coder.md"));
1383        assert!(!lock.contains_output(".cursor", "agents/coder.md"));
1384
1385        lock.items
1386            .get_mut("agent/coder")
1387            .unwrap()
1388            .outputs
1389            .push(OutputRecord {
1390                target_root: ".cursor".to_string(),
1391                dest_path: "agents/coder.md".into(),
1392                installed_checksum: "sha256:cursor".into(),
1393            });
1394        assert!(lock.contains_output(".cursor", "agents/coder.md"));
1395        assert!(!lock.contains_output(".cursor", "agents/missing.md"));
1396    }
1397
1398    #[test]
1399    fn apply_target_sync_outputs_upserts_and_removes_target_records() {
1400        let mut lock = sample_lock();
1401        apply_target_sync_outputs(
1402            &mut lock,
1403            &[crate::target_sync::TargetSyncOutcome {
1404                target: ".cursor".to_string(),
1405                items_synced: 1,
1406                items_removed: 0,
1407                errors: Vec::new(),
1408                synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
1409                    dest_path: "agents/coder.md".to_string(),
1410                    installed_checksum: "sha256:cursor".into(),
1411                }],
1412                removed_dest_paths: Vec::new(),
1413            }],
1414        );
1415        assert!(lock.contains_output(".cursor", "agents/coder.md"));
1416
1417        apply_target_sync_outputs(
1418            &mut lock,
1419            &[crate::target_sync::TargetSyncOutcome {
1420                target: ".cursor".to_string(),
1421                items_synced: 0,
1422                items_removed: 1,
1423                errors: Vec::new(),
1424                synced_outputs: Vec::new(),
1425                removed_dest_paths: vec!["agents/coder.md".to_string()],
1426            }],
1427        );
1428        assert!(!lock.contains_output(".cursor", "agents/coder.md"));
1429        assert!(lock.contains_output(".mars", "agents/coder.md"));
1430    }
1431
1432    #[test]
1433    fn canonical_flat_items_excludes_linked_target_outputs() {
1434        let mut lock = sample_lock();
1435        lock.items
1436            .get_mut("agent/coder")
1437            .unwrap()
1438            .outputs
1439            .push(OutputRecord {
1440                target_root: ".cursor".to_string(),
1441                dest_path: "agents/coder.md".into(),
1442                installed_checksum: "sha256:cursor".into(),
1443            });
1444
1445        let canonical = lock.canonical_flat_items();
1446        assert_eq!(canonical.len(), 2);
1447        assert!(
1448            canonical
1449                .iter()
1450                .any(|(dp, _)| dp.as_str() == "agents/coder.md")
1451        );
1452        assert!(
1453            canonical
1454                .iter()
1455                .all(|(_, item)| { lock.contains_output(".mars", item.dest_path.as_str()) })
1456        );
1457
1458        let cursor = lock.flat_items_for_target(".cursor");
1459        assert_eq!(cursor.len(), 1);
1460        assert_eq!(cursor[0].0.as_str(), "agents/coder.md");
1461    }
1462
1463    #[test]
1464    fn flat_items_yields_all_outputs() {
1465        let lock = sample_lock();
1466        let flat = lock.flat_items();
1467        assert_eq!(flat.len(), 2);
1468        let paths: Vec<&str> = flat.iter().map(|(dp, _)| dp.as_str()).collect();
1469        assert!(paths.contains(&"agents/coder.md"));
1470        assert!(paths.contains(&"skills/review"));
1471    }
1472
1473    #[test]
1474    fn v1_lock_no_spurious_reinstall() {
1475        // V1 lock loaded → promoted to v2 → find_by_dest_path works for diff.
1476        let v1_toml = r#"
1477version = 1
1478
1479[dependencies.base]
1480url = "https://github.com/org/base.git"
1481
1482[items."agents/coder.md"]
1483source = "base"
1484kind = "agent"
1485source_checksum = "sha256:src"
1486installed_checksum = "sha256:inst"
1487dest_path = "agents/coder.md"
1488"#;
1489        let dir = TempDir::new().unwrap();
1490        std::fs::write(dir.path().join("mars.lock"), v1_toml).unwrap();
1491        let lock = load(dir.path()).unwrap();
1492
1493        // Promoted items should still be findable by dest_path.
1494        let found = lock.find_by_dest_path(&DestPath::from("agents/coder.md"));
1495        assert!(found.is_some());
1496        let item = found.unwrap();
1497        assert_eq!(item.source_checksum, "sha256:src");
1498        assert_eq!(item.installed_checksum, "sha256:inst");
1499    }
1500
1501    #[test]
1502    fn build_uses_graph_provenance_for_sources() {
1503        let git_name: SourceName = "base".into();
1504        let path_name: SourceName = "local".into();
1505        let git_url: SourceUrl = "https://example.com/new.git".into();
1506        let path_canonical = PathBuf::from("/tmp/mars-agents-local-source");
1507
1508        let mut nodes = IndexMap::new();
1509        nodes.insert(
1510            git_name.clone(),
1511            ResolvedNode {
1512                source_name: git_name.clone(),
1513                source_id: SourceId::git_with_subpath(
1514                    git_url.clone(),
1515                    Some(crate::types::SourceSubpath::new("plugins/base").unwrap()),
1516                ),
1517                rooted_ref: crate::resolve::RootedSourceRef {
1518                    checkout_root: PathBuf::from("/tmp/cache/base"),
1519                    package_root: PathBuf::from("/tmp/cache/base/plugins/base"),
1520                },
1521                resolved_ref: ResolvedRef {
1522                    source_name: git_name.clone(),
1523                    version: Some(semver::Version::new(1, 2, 3)),
1524                    version_tag: Some("v1.2.3".into()),
1525                    commit: Some("abc123".into()),
1526                    tree_path: PathBuf::from("/tmp/cache/base"),
1527                },
1528                latest_version: None,
1529                manifest: None,
1530                deps: vec![],
1531            },
1532        );
1533        nodes.insert(
1534            path_name.clone(),
1535            ResolvedNode {
1536                source_name: path_name.clone(),
1537                source_id: SourceId::Path {
1538                    canonical: path_canonical.clone(),
1539                    subpath: Some(crate::types::SourceSubpath::new("plugins/local").unwrap()),
1540                },
1541                rooted_ref: crate::resolve::RootedSourceRef {
1542                    checkout_root: PathBuf::from("/tmp/cache/local"),
1543                    package_root: PathBuf::from("/tmp/cache/local/plugins/local"),
1544                },
1545                resolved_ref: ResolvedRef {
1546                    source_name: path_name.clone(),
1547                    version: None,
1548                    version_tag: None,
1549                    commit: None,
1550                    tree_path: PathBuf::from("/tmp/cache/local"),
1551                },
1552                latest_version: None,
1553                manifest: None,
1554                deps: vec![],
1555            },
1556        );
1557
1558        let graph = ResolvedGraph {
1559            nodes,
1560            order: vec![git_name.clone(), path_name.clone()],
1561            filters: HashMap::new(),
1562        };
1563        let applied = ApplyResult { outcomes: vec![] };
1564
1565        let mut old_sources = IndexMap::new();
1566        old_sources.insert(
1567            git_name.clone(),
1568            LockedSource {
1569                url: Some("https://example.com/old.git".into()),
1570                path: None,
1571                subpath: None,
1572                version: Some("v0.0.1".into()),
1573                commit: Some("deadbeef".into()),
1574                tree_hash: None,
1575            },
1576        );
1577        let old_lock = LockFile {
1578            version: LOCK_VERSION,
1579            dependencies: old_sources,
1580            items: IndexMap::new(),
1581            config_entries: std::collections::BTreeMap::new(),
1582            dependency_model_aliases: IndexMap::new(),
1583        };
1584
1585        let new_lock = build(
1586            &graph,
1587            &applied,
1588            &old_lock,
1589            std::collections::BTreeMap::new(),
1590        )
1591        .unwrap();
1592
1593        let base = &new_lock.dependencies["base"];
1594        assert_eq!(base.url.as_ref(), Some(&git_url));
1595        assert_eq!(
1596            base.subpath
1597                .as_ref()
1598                .map(crate::types::SourceSubpath::as_str),
1599            Some("plugins/base")
1600        );
1601        assert_eq!(base.version.as_deref(), Some("v1.2.3"));
1602        assert_eq!(base.commit.as_deref(), Some("abc123"));
1603
1604        let local = &new_lock.dependencies["local"];
1605        assert!(local.url.is_none());
1606        assert_eq!(
1607            local
1608                .subpath
1609                .as_ref()
1610                .map(crate::types::SourceSubpath::as_str),
1611            Some("plugins/local")
1612        );
1613        assert_eq!(
1614            local.path.as_deref(),
1615            Some(path_canonical.to_string_lossy().as_ref())
1616        );
1617    }
1618
1619    #[test]
1620    fn build_persists_ref_selector_in_locked_source_version() {
1621        let source_name: SourceName = "base".into();
1622        let mut nodes = IndexMap::new();
1623        nodes.insert(
1624            source_name.clone(),
1625            ResolvedNode {
1626                source_name: source_name.clone(),
1627                source_id: SourceId::git("https://example.com/base.git".into()),
1628                rooted_ref: crate::resolve::RootedSourceRef {
1629                    checkout_root: PathBuf::from("/tmp/cache/base"),
1630                    package_root: PathBuf::from("/tmp/cache/base"),
1631                },
1632                resolved_ref: ResolvedRef {
1633                    source_name: source_name.clone(),
1634                    version: None,
1635                    version_tag: Some("main".into()),
1636                    commit: Some("abc123".into()),
1637                    tree_path: PathBuf::from("/tmp/cache/base"),
1638                },
1639                latest_version: None,
1640                manifest: None,
1641                deps: vec![],
1642            },
1643        );
1644
1645        let graph = ResolvedGraph {
1646            nodes,
1647            order: vec![source_name.clone()],
1648            filters: HashMap::new(),
1649        };
1650        let applied = ApplyResult { outcomes: vec![] };
1651        let new_lock = build(
1652            &graph,
1653            &applied,
1654            &LockFile::empty(),
1655            std::collections::BTreeMap::new(),
1656        )
1657        .unwrap();
1658
1659        let source = &new_lock.dependencies["base"];
1660        assert_eq!(source.version.as_deref(), Some("main"));
1661        assert_eq!(source.commit.as_deref(), Some("abc123"));
1662    }
1663
1664    #[test]
1665    fn build_keeps_self_items_from_old_lock_on_skipped_action() {
1666        let graph = ResolvedGraph {
1667            nodes: IndexMap::new(),
1668            order: Vec::new(),
1669            filters: HashMap::new(),
1670        };
1671        let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
1672        let old_lock = LockFile {
1673            version: LOCK_VERSION,
1674            dependencies: IndexMap::from([(
1675                local_source_name.clone(),
1676                LockedSource {
1677                    url: None,
1678                    path: Some(".".into()),
1679                    subpath: None,
1680                    version: None,
1681                    commit: None,
1682                    tree_hash: None,
1683                },
1684            )]),
1685            items: IndexMap::from([(
1686                "skill/local-skill".to_string(),
1687                LockedItemV2 {
1688                    source: local_source_name.clone(),
1689                    kind: ItemKind::Skill,
1690                    version: None,
1691                    source_checksum: "sha256:self".into(),
1692                    outputs: vec![OutputRecord {
1693                        target_root: ".mars".to_string(),
1694                        dest_path: DestPath::from("skills/local-skill"),
1695                        installed_checksum: "sha256:self".into(),
1696                    }],
1697                },
1698            )]),
1699            config_entries: std::collections::BTreeMap::new(),
1700            dependency_model_aliases: IndexMap::new(),
1701        };
1702        let applied = ApplyResult {
1703            outcomes: vec![ActionOutcome {
1704                item_id: ItemId {
1705                    kind: ItemKind::Skill,
1706                    name: "local-skill".into(),
1707                },
1708                action: ActionTaken::Skipped,
1709                dest_path: "skills/local-skill".into(),
1710                source_name: local_source_name.clone(),
1711                source_checksum: None,
1712                installed_checksum: None,
1713            }],
1714        };
1715
1716        let new_lock = build(
1717            &graph,
1718            &applied,
1719            &old_lock,
1720            std::collections::BTreeMap::new(),
1721        )
1722        .unwrap();
1723
1724        assert!(
1725            new_lock
1726                .dependencies
1727                .contains_key(local_source_name.as_str())
1728        );
1729        let item = &new_lock.items["skill/local-skill"];
1730        assert_eq!(item.source, local_source_name);
1731        assert_eq!(item.kind, ItemKind::Skill);
1732        assert_eq!(item.source_checksum, "sha256:self");
1733        assert_eq!(item.outputs[0].installed_checksum, "sha256:self");
1734    }
1735
1736    #[test]
1737    fn build_rejects_missing_installed_checksum_for_write_actions() {
1738        let graph = ResolvedGraph {
1739            nodes: IndexMap::new(),
1740            order: Vec::new(),
1741            filters: HashMap::new(),
1742        };
1743        let old_lock = LockFile::empty();
1744        let applied = ApplyResult {
1745            outcomes: vec![ActionOutcome {
1746                item_id: ItemId {
1747                    kind: ItemKind::Agent,
1748                    name: "coder".into(),
1749                },
1750                action: ActionTaken::Installed,
1751                dest_path: "agents/coder.md".into(),
1752                source_name: "base".into(),
1753                source_checksum: Some("sha256:source".into()),
1754                installed_checksum: None,
1755            }],
1756        };
1757
1758        let err = build(
1759            &graph,
1760            &applied,
1761            &old_lock,
1762            std::collections::BTreeMap::new(),
1763        )
1764        .unwrap_err();
1765        let msg = err.to_string();
1766        assert!(msg.contains("missing checksum for write-producing action"));
1767        assert!(msg.contains("agents/coder.md"));
1768    }
1769
1770    #[test]
1771    fn promote_v1_collision_both_survive() {
1772        // Two v1 items with different full dest_paths but the same basename
1773        // (e.g. "hook" from two different subdirectories) must both survive promotion.
1774        // Without collision handling the second would silently overwrite the first.
1775        let mut v1_items: IndexMap<DestPath, LockedItem> = IndexMap::new();
1776
1777        v1_items.insert(
1778            DestPath::from("hooks/pre-commit/hook.sh"),
1779            LockedItem {
1780                source: "base".into(),
1781                kind: ItemKind::Hook,
1782                version: None,
1783                source_checksum: "sha256:aaa".into(),
1784                installed_checksum: "sha256:bbb".into(),
1785                dest_path: DestPath::from("hooks/pre-commit/hook.sh"),
1786            },
1787        );
1788        v1_items.insert(
1789            DestPath::from("hooks/pre-push/hook.sh"),
1790            LockedItem {
1791                source: "base".into(),
1792                kind: ItemKind::Hook,
1793                version: None,
1794                source_checksum: "sha256:ccc".into(),
1795                installed_checksum: "sha256:ddd".into(),
1796                dest_path: DestPath::from("hooks/pre-push/hook.sh"),
1797            },
1798        );
1799
1800        let (promoted, diagnostics) = promote_v1_items(v1_items);
1801
1802        // Both entries must be present — neither was silently dropped.
1803        assert_eq!(promoted.len(), 2, "both items should survive promotion");
1804        assert_eq!(diagnostics.len(), 1);
1805
1806        // The first item gets the canonical key; the second gets the fallback dest_path key.
1807        let checksums: std::collections::HashSet<String> = promoted
1808            .values()
1809            .map(|v| v.source_checksum.as_ref().to_string())
1810            .collect();
1811        assert!(
1812            checksums.contains("sha256:aaa"),
1813            "pre-commit hook must be present"
1814        );
1815        assert!(
1816            checksums.contains("sha256:ccc"),
1817            "pre-push hook must be present"
1818        );
1819    }
1820
1821    #[test]
1822    fn load_with_diagnostics_reports_v1_promotion_collision() {
1823        let v1_toml = r#"
1824version = 1
1825
1826[dependencies.base]
1827url = "https://github.com/org/base.git"
1828
1829[items."hooks/pre-commit/hook.sh"]
1830source = "base"
1831kind = "hook"
1832source_checksum = "sha256:aaa"
1833installed_checksum = "sha256:bbb"
1834dest_path = "hooks/pre-commit/hook.sh"
1835
1836[items."hooks/pre-push/hook.sh"]
1837source = "base"
1838kind = "hook"
1839source_checksum = "sha256:ccc"
1840installed_checksum = "sha256:ddd"
1841dest_path = "hooks/pre-push/hook.sh"
1842"#;
1843        let dir = TempDir::new().unwrap();
1844        std::fs::write(dir.path().join("mars.lock"), v1_toml).unwrap();
1845
1846        let (lock, diagnostics) = load_with_diagnostics(dir.path()).unwrap();
1847
1848        assert_eq!(lock.version, LOCK_VERSION);
1849        assert_eq!(lock.items.len(), 2);
1850        assert_eq!(diagnostics.len(), 1);
1851        let diagnostic = &diagnostics[0];
1852        assert_eq!(
1853            diagnostic.level,
1854            crate::diagnostic::DiagnosticLevel::Warning
1855        );
1856        assert_eq!(diagnostic.code, "lock-promotion-collision");
1857        assert!(diagnostic.message.contains("key collision"));
1858        assert!(diagnostic.message.contains("hook/hooks/pre-push/hook.sh"));
1859    }
1860
1861    #[test]
1862    fn build_rejects_empty_checksums_from_carried_items() {
1863        let graph = ResolvedGraph {
1864            nodes: IndexMap::new(),
1865            order: Vec::new(),
1866            filters: HashMap::new(),
1867        };
1868        let old_lock = LockFile {
1869            version: LOCK_VERSION,
1870            dependencies: IndexMap::new(),
1871            items: IndexMap::from([(
1872                "agent/coder".to_string(),
1873                LockedItemV2 {
1874                    source: "base".into(),
1875                    kind: ItemKind::Agent,
1876                    version: None,
1877                    source_checksum: "".into(),
1878                    outputs: vec![OutputRecord {
1879                        target_root: ".mars".to_string(),
1880                        dest_path: DestPath::from("agents/coder.md"),
1881                        installed_checksum: "sha256:installed".into(),
1882                    }],
1883                },
1884            )]),
1885            config_entries: std::collections::BTreeMap::new(),
1886            dependency_model_aliases: IndexMap::new(),
1887        };
1888        let applied = ApplyResult {
1889            outcomes: vec![ActionOutcome {
1890                item_id: ItemId {
1891                    kind: ItemKind::Agent,
1892                    name: "coder".into(),
1893                },
1894                action: ActionTaken::Skipped,
1895                dest_path: "agents/coder.md".into(),
1896                source_name: "base".into(),
1897                source_checksum: None,
1898                installed_checksum: None,
1899            }],
1900        };
1901
1902        let err = build(
1903            &graph,
1904            &applied,
1905            &old_lock,
1906            std::collections::BTreeMap::new(),
1907        )
1908        .unwrap_err();
1909        let msg = err.to_string();
1910        assert!(msg.contains("empty source_checksum"));
1911    }
1912}