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