Skip to main content

mars_agents/lock/
mod.rs

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