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