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    fn item_for_output(
236        &self,
237        target_root: &str,
238        dest_path: &DestPath,
239    ) -> Option<(&'a str, &'a LockedItemV2, &'a OutputRecord)> {
240        let (item_key, output_idx) = *self.by_output.get(&(
241            target_root.to_string(),
242            normalize_dest_path(dest_path.as_str()),
243        ))?;
244        let item = self.lock.items.get(item_key)?;
245        let output = item.outputs.get(output_idx)?;
246        Some((item_key, item, output))
247    }
248
249    /// Whether any output is recorded for `target_root + dest_path`.
250    pub fn contains_output(&self, target_root: &str, dest_path: &DestPath) -> bool {
251        self.by_output.contains_key(&(
252            target_root.to_string(),
253            normalize_dest_path(dest_path.as_str()),
254        ))
255    }
256
257    fn locked_item_for(&self, item_key: &str, output_idx: usize) -> Option<LockedItem> {
258        let item_v2 = self.lock.items.get(item_key)?;
259        let output = item_v2.outputs.get(output_idx)?;
260        Some(LockedItem {
261            source: item_v2.source.clone(),
262            kind: item_v2.kind,
263            version: item_v2.version.clone(),
264            source_checksum: item_v2.source_checksum.clone(),
265            installed_checksum: output.installed_checksum.clone(),
266            dest_path: output.dest_path.clone(),
267        })
268    }
269
270    /// Check if any output record has the given dest_path.
271    pub fn contains_dest_path(&self, dest_path: &DestPath) -> bool {
272        self.by_dest_path
273            .contains_key(&normalize_dest_path(dest_path.as_str()))
274    }
275}
276
277fn normalize_dest_path(s: &str) -> String {
278    if cfg!(windows) {
279        s.replace('\\', "/")
280    } else {
281        s.to_string()
282    }
283}
284
285/// One resolved source in the lock.
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
287pub struct LockedSource {
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub url: Option<SourceUrl>,
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub path: Option<String>,
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub subpath: Option<SourceSubpath>,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub version: Option<String>,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub commit: Option<CommitHash>,
298    /// Reserved for future content verification of fetched source trees.
299    /// TODO: populate during fetch/build once deterministic tree hashing is implemented.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub tree_hash: Option<String>,
302}
303
304/// V2 locked item: one logical item with per-output records.
305///
306/// `source_checksum` is shared across all outputs (same source content).
307/// Each `OutputRecord` has its own `installed_checksum` for divergence detection.
308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309pub struct LockedItemV2 {
310    pub source: SourceName,
311    pub kind: ItemKind,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub version: Option<String>,
314    pub source_checksum: ContentHash,
315    /// Per-output records: one per target root this item was materialized to.
316    pub outputs: Vec<OutputRecord>,
317}
318
319/// A single materialized output of a logical item.
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
321pub struct OutputRecord {
322    /// Target root this output belongs to (e.g., ".mars", ".claude").
323    pub target_root: String,
324    /// Relative path under the target root (e.g., "agents/coder.md").
325    pub dest_path: DestPath,
326    /// Checksum of the installed content at this output location.
327    pub installed_checksum: ContentHash,
328}
329
330/// Ownership record for one target-native config entry.
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
332pub struct ConfigEntryRecord {
333    pub source: String,
334}
335
336/// Flat view of a single installed item — used by diff, plan, and apply stages.
337///
338/// Constructed from [`LockedItemV2`] + one [`OutputRecord`]; preserves backward
339/// compat with code that operates on per-dest-path records.
340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
341pub struct LockedItem {
342    pub source: SourceName,
343    pub kind: ItemKind,
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub version: Option<String>,
346    pub source_checksum: ContentHash,
347    pub installed_checksum: ContentHash,
348    pub dest_path: DestPath,
349}
350
351// Re-export ItemKind and ItemId from types — they're shared vocabulary,
352// not lock-specific. This preserves `use crate::lock::ItemKind` compatibility.
353pub use crate::types::{ItemId, ItemKind};
354
355const LOCK_FILE: &str = "mars.lock";
356/// Current lock file schema version.
357const LOCK_VERSION: u32 = 2;
358/// Canonical materialization root for `.mars/` apply outcomes.
359pub const CANONICAL_TARGET_ROOT: &str = ".mars";
360
361// ---------------------------------------------------------------------------
362// V1 wire type — used only for reading legacy lock files.
363// ---------------------------------------------------------------------------
364
365/// V1 wire format for reading legacy lock files.
366#[derive(Deserialize)]
367struct LockFileV1 {
368    #[allow(dead_code)]
369    version: u32,
370    #[serde(default)]
371    dependencies: IndexMap<SourceName, LockedSource>,
372    #[serde(default)]
373    items: IndexMap<DestPath, LockedItem>,
374}
375
376/// V2 wire format for Deserialize (mirrors `LockFile` but derives `Deserialize`).
377#[derive(Deserialize)]
378struct LockFileV2Wire {
379    version: u32,
380    #[serde(default)]
381    dependencies: IndexMap<SourceName, LockedSource>,
382    #[serde(default)]
383    items: IndexMap<String, LockedItemV2>,
384    #[serde(default)]
385    config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
386    #[serde(default)]
387    dependency_model_aliases: IndexMap<String, ModelAlias>,
388}
389
390// ---------------------------------------------------------------------------
391// Load / write
392// ---------------------------------------------------------------------------
393
394/// Load the lock file from the given root directory.
395///
396/// Returns an empty LockFile (v2) if the file is absent.
397/// V1 lock files are transparently promoted to the v2 in-memory shape (D19):
398/// the lock is only written as v2 after a successful sync.
399pub fn load(root: &Path) -> Result<LockFile, MarsError> {
400    let (lock, _) = load_with_diagnostics(root)?;
401    Ok(lock)
402}
403
404/// Load lock for runtime alias commands (`models list/resolve`, launch bundle routing).
405///
406/// Legacy v2 lock files created before dependency aliases were moved into `mars.lock`
407/// may omit `dependency_model_aliases` entirely. When dependency entries exist, runtime
408/// alias consumers must fail closed so dependency alias authority is not silently treated
409/// as empty.
410pub fn load_for_runtime_aliases(root: &Path) -> Result<LockFile, MarsError> {
411    let path = root.join(LOCK_FILE);
412    let content = match std::fs::read_to_string(&path) {
413        Ok(c) => c,
414        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(LockFile::empty()),
415        Err(e) => return Err(LockError::Io(e).into()),
416    };
417
418    let value: toml::Value = toml::from_str(&content).map_err(|e| LockError::Corrupt {
419        message: format!("failed to parse {}: {e}", path.display()),
420    })?;
421
422    let has_dependency_alias_field = value
423        .as_table()
424        .map(|table| table.contains_key("dependency_model_aliases"))
425        .unwrap_or(false);
426
427    let (lock, _) = load_with_diagnostics(root)?;
428
429    if !has_dependency_alias_field && !lock.dependencies.is_empty() {
430        return Err(LockError::Corrupt {
431            message: format!(
432                "legacy {} is missing `dependency_model_aliases` for dependency alias authority; run `{}` to update it",
433                LOCK_FILE,
434                crate::types::managed_cmd("mars sync")
435            ),
436        }
437        .into());
438    }
439
440    Ok(lock)
441}
442
443/// Load the lock file and return any diagnostics produced while reading it.
444///
445/// This preserves legacy v1→v2 in-memory promotion while routing promotion
446/// warnings through the normal diagnostic flow for sync callers.
447pub fn load_with_diagnostics(root: &Path) -> Result<(LockFile, Vec<Diagnostic>), MarsError> {
448    let path = root.join(LOCK_FILE);
449    let content = match std::fs::read_to_string(&path) {
450        Ok(c) => c,
451        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
452            return Ok((LockFile::empty(), Vec::new()));
453        }
454        Err(e) => return Err(LockError::Io(e).into()),
455    };
456
457    let value: toml::Value = toml::from_str(&content).map_err(|e| LockError::Corrupt {
458        message: format!("failed to parse {}: {e}", path.display()),
459    })?;
460
461    match value.clone().try_into::<LockFileV2Wire>() {
462        Ok(wire) if wire.version >= 2 => Ok((
463            LockFile {
464                version: wire.version,
465                dependencies: wire.dependencies,
466                items: wire.items,
467                config_entries: wire.config_entries,
468                dependency_model_aliases: wire.dependency_model_aliases,
469            },
470            Vec::new(),
471        )),
472        v2_result => {
473            // V1 → V2 promotion (D19): map each DestPath key to a logical identity.
474            let wire: LockFileV1 = value.clone().try_into().map_err(|v1_error| {
475                let parse_error = match v2_result {
476                    Ok(wire) => format!("unsupported lock version {}", wire.version),
477                    Err(v2_error) => {
478                        format!("v2 parse failed: {v2_error}; v1 parse failed: {v1_error}")
479                    }
480                };
481                LockError::Corrupt {
482                    message: format!("failed to parse {}: {parse_error}", path.display()),
483                }
484            })?;
485            let (items, diagnostics) = promote_v1_items(wire.items);
486            Ok((
487                LockFile {
488                    version: LOCK_VERSION,
489                    dependencies: wire.dependencies,
490                    items,
491                    config_entries: BTreeMap::new(),
492                    dependency_model_aliases: IndexMap::new(),
493                },
494                diagnostics,
495            ))
496        }
497    }
498}
499
500/// Write the lock file atomically to the given root directory (always v2 format).
501pub fn write(root: &Path, lock: &LockFile) -> Result<(), MarsError> {
502    let path = root.join(LOCK_FILE);
503    let mut normalized = lock.clone();
504    normalized.dependencies.sort_keys();
505    normalized.items.sort_keys();
506    normalized.dependency_model_aliases.sort_keys();
507
508    let content = toml::to_string_pretty(&normalized).map_err(|e| LockError::Corrupt {
509        message: format!("failed to serialize lock file: {e}"),
510    })?;
511    crate::fs::atomic_write(&path, content.as_bytes())
512}
513
514/// Convert v1 `IndexMap<DestPath, LockedItem>` to v2 `IndexMap<String, LockedItemV2>`.
515///
516/// Each v1 entry becomes one `LockedItemV2` with exactly one `OutputRecord`
517/// using `target_root = ".mars"` (the only output root in v1).
518///
519/// Key collision: two v1 entries with different dest_paths but the same basename
520/// (e.g. `hooks/pre-commit/hook.sh` and `hooks/pre-push/hook.sh` both name "hook")
521/// would map to the same key and silently drop one. When a collision is detected,
522/// we warn and fall back to the raw dest_path string as a disambiguated key.
523fn promote_v1_items(
524    v1_items: IndexMap<DestPath, LockedItem>,
525) -> (IndexMap<String, LockedItemV2>, Vec<Diagnostic>) {
526    let mut result: IndexMap<String, LockedItemV2> = IndexMap::new();
527    let mut diagnostics = Vec::new();
528
529    for (dest_path, item) in v1_items {
530        let key = format!("{}/{}", item.kind, dest_path.item_name(item.kind));
531        let item_v2 = LockedItemV2 {
532            source: item.source,
533            kind: item.kind,
534            version: item.version,
535            source_checksum: item.source_checksum,
536            outputs: vec![OutputRecord {
537                target_root: ".mars".to_string(),
538                dest_path: item.dest_path,
539                installed_checksum: item.installed_checksum,
540            }],
541        };
542
543        if result.contains_key(&key) {
544            // Two v1 entries share the same basename — use the full dest_path as a
545            // disambiguated key so neither entry is silently dropped.
546            let fallback_key = format!("{}/{}", item_v2.kind, dest_path.as_str());
547            diagnostics.push(Diagnostic {
548                level: crate::diagnostic::DiagnosticLevel::Warning,
549                code: "lock-promotion-collision",
550                message: format!(
551                    "v1→v2 promotion: key collision on `{key}`; using dest_path key `{fallback_key}`"
552                ),
553                context: None,
554                category: None,
555            });
556            result.insert(fallback_key, item_v2);
557        } else {
558            result.insert(key, item_v2);
559        }
560    }
561
562    (result, diagnostics)
563}
564
565// ---------------------------------------------------------------------------
566// Build
567// ---------------------------------------------------------------------------
568
569/// Build a new lock file from resolved graph + apply results.
570///
571/// Constructs the lock file from the graph (source provenance) and
572/// the apply outcomes (checksums). Items that were skipped, kept, or
573/// merged retain their provenance from the graph. Removed items are excluded.
574pub fn build(
575    graph: &crate::resolve::ResolvedGraph,
576    applied: &crate::sync::apply::ApplyResult,
577    old_lock: &LockFile,
578    config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
579) -> Result<LockFile, MarsError> {
580    use crate::sync::apply::ActionTaken;
581
582    let mut dependencies = IndexMap::new();
583    let mut items: IndexMap<String, LockedItemV2> = IndexMap::new();
584    let old_lock_index = LockIndex::new(old_lock);
585
586    for outcome in &applied.outcomes {
587        match outcome.action {
588            ActionTaken::Installed
589            | ActionTaken::Updated
590            | ActionTaken::Merged
591            | ActionTaken::Conflicted => {
592                let installed =
593                    outcome
594                        .installed_checksum
595                        .as_ref()
596                        .ok_or_else(|| LockError::Corrupt {
597                            message: format!(
598                                "missing checksum for write-producing action on {}",
599                                outcome.dest_path
600                            ),
601                        })?;
602                if checksum_is_empty(installed) {
603                    return Err(LockError::Corrupt {
604                        message: format!("empty installed_checksum for {}", outcome.dest_path),
605                    }
606                    .into());
607                }
608
609                let source =
610                    outcome
611                        .source_checksum
612                        .as_ref()
613                        .ok_or_else(|| LockError::Corrupt {
614                            message: format!(
615                                "missing source checksum for write-producing action on {}",
616                                outcome.dest_path
617                            ),
618                        })?;
619                if checksum_is_empty(source) {
620                    return Err(LockError::Corrupt {
621                        message: format!("empty source_checksum for {}", outcome.dest_path),
622                    }
623                    .into());
624                }
625            }
626            ActionTaken::Removed | ActionTaken::Skipped | ActionTaken::Kept => {}
627        }
628    }
629
630    // Build dependency entries directly from resolved graph provenance.
631    for (name, node) in &graph.nodes {
632        dependencies.insert(name.clone(), to_locked_source(node));
633    }
634
635    // Build item entries from apply outcomes.
636    for outcome in &applied.outcomes {
637        match &outcome.action {
638            ActionTaken::Removed | ActionTaken::Skipped => {
639                // For skipped items, carry forward from old lock
640                if matches!(outcome.action, ActionTaken::Skipped) {
641                    let item_key = item_key(&outcome.item_id);
642                    if let Some(old_item) = old_lock.items.get(&item_key) {
643                        items.insert(item_key, old_item.clone());
644                    } else {
645                        // Fall back: search old lock by dest_path (handles v1→v2 migrations
646                        // where item_key may not match yet)
647                        if let Some((_, old_item, old_output)) = old_lock_index
648                            .item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
649                        {
650                            let key = format!(
651                                "{}/{}",
652                                old_item.kind,
653                                outcome.dest_path.item_name(old_item.kind)
654                            );
655                            items.entry(key).or_insert_with(|| LockedItemV2 {
656                                source: old_item.source.clone(),
657                                kind: old_item.kind,
658                                version: old_item.version.clone(),
659                                source_checksum: old_item.source_checksum.clone(),
660                                outputs: outputs_with_carried_non_canonical(
661                                    Some(old_item),
662                                    OutputRecord {
663                                        target_root: CANONICAL_TARGET_ROOT.to_string(),
664                                        dest_path: old_output.dest_path.clone(),
665                                        installed_checksum: old_output.installed_checksum.clone(),
666                                    },
667                                ),
668                            });
669                        }
670                    }
671                }
672                // Removed items are excluded from the new lock.
673            }
674            ActionTaken::Kept => {
675                // Keep local: carry forward old lock entry.
676                let item_key = item_key(&outcome.item_id);
677                if let Some(old_item) = old_lock.items.get(&item_key) {
678                    items.insert(item_key, old_item.clone());
679                } else if let Some((_, old_item, old_output)) =
680                    old_lock_index.item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
681                {
682                    let key = format!(
683                        "{}/{}",
684                        old_item.kind,
685                        outcome.dest_path.item_name(old_item.kind)
686                    );
687                    items.entry(key).or_insert_with(|| LockedItemV2 {
688                        source: old_item.source.clone(),
689                        kind: old_item.kind,
690                        version: old_item.version.clone(),
691                        source_checksum: old_item.source_checksum.clone(),
692                        outputs: outputs_with_carried_non_canonical(
693                            Some(old_item),
694                            OutputRecord {
695                                target_root: CANONICAL_TARGET_ROOT.to_string(),
696                                dest_path: old_output.dest_path.clone(),
697                                installed_checksum: old_output.installed_checksum.clone(),
698                            },
699                        ),
700                    });
701                }
702            }
703            ActionTaken::Installed
704            | ActionTaken::Updated
705            | ActionTaken::Merged
706            | ActionTaken::Conflicted => {
707                let dest_path = outcome.dest_path.clone();
708                if dest_path.as_str().is_empty() {
709                    continue;
710                }
711
712                // Use source_name from outcome (propagated from TargetItem)
713                let source_name = if outcome.source_name.as_ref().is_empty() {
714                    None
715                } else {
716                    Some(outcome.source_name.clone())
717                };
718
719                // Determine version from graph
720                let version = source_name.as_ref().and_then(|sn| {
721                    graph
722                        .nodes
723                        .get(sn)
724                        .and_then(|n| n.resolved_ref.version_tag.clone())
725                });
726
727                let source_checksum = outcome
728                    .source_checksum
729                    .clone()
730                    .expect("validated above: source_checksum exists for write actions");
731                let installed_checksum = outcome
732                    .installed_checksum
733                    .clone()
734                    .expect("validated above: installed_checksum exists for write actions");
735
736                let key = item_key(&outcome.item_id);
737                let old_item = old_lock.items.get(&key).or_else(|| {
738                    old_lock_index
739                        .item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
740                        .map(|(_, old_item, _)| old_item)
741                });
742                let outputs = outputs_with_carried_non_canonical(
743                    old_item,
744                    OutputRecord {
745                        target_root: CANONICAL_TARGET_ROOT.to_string(),
746                        dest_path,
747                        installed_checksum,
748                    },
749                );
750                items.insert(
751                    key,
752                    LockedItemV2 {
753                        source: source_name.unwrap_or_else(|| SourceName::from("")),
754                        kind: outcome.item_id.kind,
755                        version,
756                        source_checksum,
757                        outputs,
758                    },
759                );
760            }
761        }
762    }
763
764    // Add synthetic _self source if any local package items exist.
765    let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
766    let has_self_items = items.values().any(|item| item.source == local_source_name);
767    if has_self_items {
768        dependencies.insert(
769            local_source_name,
770            LockedSource {
771                url: None,
772                path: Some(".".into()),
773                subpath: None,
774                version: None,
775                commit: None,
776                tree_hash: None,
777            },
778        );
779    }
780
781    // Validate checksums.
782    for item in items.values() {
783        if checksum_is_empty(&item.source_checksum) {
784            let dest = item
785                .outputs
786                .first()
787                .map(|o| o.dest_path.to_string())
788                .unwrap_or_default();
789            return Err(LockError::Corrupt {
790                message: format!("empty source_checksum for {dest}"),
791            }
792            .into());
793        }
794        for output in &item.outputs {
795            if checksum_is_empty(&output.installed_checksum) {
796                return Err(LockError::Corrupt {
797                    message: format!("empty installed_checksum for {}", output.dest_path),
798                }
799                .into());
800            }
801        }
802    }
803
804    // Sort keys for deterministic output.
805    dependencies.sort_keys();
806    items.sort_keys();
807
808    Ok(LockFile {
809        version: LOCK_VERSION,
810        dependencies,
811        items,
812        config_entries,
813        dependency_model_aliases: IndexMap::new(),
814    })
815}
816
817fn outputs_with_carried_non_canonical(
818    old_item: Option<&LockedItemV2>,
819    canonical_output: OutputRecord,
820) -> Vec<OutputRecord> {
821    let mut outputs = vec![canonical_output];
822    if let Some(old_item) = old_item {
823        for old_output in &old_item.outputs {
824            if old_output.target_root != CANONICAL_TARGET_ROOT {
825                outputs.push(old_output.clone());
826            }
827        }
828    }
829    outputs
830}
831
832/// Lock view for native emission immediately after apply + target sync.
833///
834/// Seeds canonical `.mars` items from the current apply pass, then layers
835/// per-target sync outputs so `copy_decision` treats freshly synced paths as
836/// managed. Full lock rebuild happens in `finalize()`; this path avoids a
837/// graph walk while still covering first-sync agents absent from `old_lock`.
838pub fn ownership_lock_for_native_emission(
839    old_lock: &LockFile,
840    apply_outcomes: &[crate::sync::apply::ActionOutcome],
841    target_outcomes: &[crate::target_sync::TargetSyncOutcome],
842) -> LockFile {
843    let mut lock = old_lock.clone();
844    apply_apply_outcomes_to_lock(&mut lock, old_lock, apply_outcomes);
845    apply_target_sync_outputs(&mut lock, target_outcomes);
846    lock
847}
848
849/// Lock view for native emission after `mars link` target sync.
850///
851/// The persisted lock already reflects canonical items; only target-sync outputs
852/// from the link pass need to be layered on for ownership checks.
853pub fn ownership_lock_after_target_sync(
854    old_lock: &LockFile,
855    target_outcomes: &[crate::target_sync::TargetSyncOutcome],
856) -> LockFile {
857    let mut lock = old_lock.clone();
858    apply_target_sync_outputs(&mut lock, target_outcomes);
859    lock
860}
861
862/// Merge current apply outcomes into a lock view for ownership checks.
863///
864/// Write actions upsert canonical `.mars` outputs; removals drop the item;
865/// skipped/kept entries carry forward from `old_lock` when the clone lacks them.
866pub fn apply_apply_outcomes_to_lock(
867    lock: &mut LockFile,
868    old_lock: &LockFile,
869    outcomes: &[crate::sync::apply::ActionOutcome],
870) {
871    use crate::sync::apply::ActionTaken;
872
873    let old_lock_index = LockIndex::new(old_lock);
874    for outcome in outcomes {
875        match outcome.action {
876            ActionTaken::Removed => {
877                lock.items.shift_remove(&item_key(&outcome.item_id));
878            }
879            ActionTaken::Skipped => {
880                let key = item_key(&outcome.item_id);
881                if lock.items.contains_key(&key) {
882                    continue;
883                }
884                if let Some(old_item) = old_lock.items.get(&key) {
885                    lock.items.insert(key, old_item.clone());
886                } else if let Some(flat) =
887                    old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
888                {
889                    let key = format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
890                    lock.items.entry(key).or_insert_with(|| LockedItemV2 {
891                        source: flat.source,
892                        kind: flat.kind,
893                        version: flat.version,
894                        source_checksum: flat.source_checksum,
895                        outputs: vec![OutputRecord {
896                            target_root: CANONICAL_TARGET_ROOT.to_string(),
897                            dest_path: flat.dest_path,
898                            installed_checksum: flat.installed_checksum,
899                        }],
900                    });
901                }
902            }
903            ActionTaken::Kept => {
904                let key = item_key(&outcome.item_id);
905                if let Some(old_item) = old_lock.items.get(&key) {
906                    lock.items.insert(key, old_item.clone());
907                } else if let Some(flat) =
908                    old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
909                {
910                    let key = format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
911                    lock.items.entry(key).or_insert_with(|| LockedItemV2 {
912                        source: flat.source,
913                        kind: flat.kind,
914                        version: flat.version,
915                        source_checksum: flat.source_checksum,
916                        outputs: vec![OutputRecord {
917                            target_root: CANONICAL_TARGET_ROOT.to_string(),
918                            dest_path: flat.dest_path,
919                            installed_checksum: flat.installed_checksum,
920                        }],
921                    });
922                }
923            }
924            ActionTaken::Installed
925            | ActionTaken::Updated
926            | ActionTaken::Merged
927            | ActionTaken::Conflicted => {
928                if outcome.dest_path.as_str().is_empty() {
929                    continue;
930                }
931                let Some(source_checksum) = outcome
932                    .source_checksum
933                    .as_ref()
934                    .filter(|checksum| !checksum_is_empty(checksum))
935                else {
936                    continue;
937                };
938                let Some(installed_checksum) = outcome
939                    .installed_checksum
940                    .as_ref()
941                    .filter(|checksum| !checksum_is_empty(checksum))
942                else {
943                    continue;
944                };
945
946                let source_name = if outcome.source_name.as_ref().is_empty() {
947                    SourceName::from("")
948                } else {
949                    outcome.source_name.clone()
950                };
951
952                let key = item_key(&outcome.item_id);
953                let old_entry = old_lock
954                    .items
955                    .get(&key)
956                    .map(|old_item| (key.as_str(), old_item))
957                    .or_else(|| {
958                        old_lock_index
959                            .item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
960                            .map(|(old_key, old_item, _)| (old_key, old_item))
961                    });
962                let old_key = old_entry.map(|(old_key, _)| old_key.to_string());
963                let outputs = outputs_with_carried_non_canonical(
964                    old_entry.map(|(_, old_item)| old_item),
965                    OutputRecord {
966                        target_root: CANONICAL_TARGET_ROOT.to_string(),
967                        dest_path: outcome.dest_path.clone(),
968                        installed_checksum: installed_checksum.clone(),
969                    },
970                );
971                if let Some(old_key) = old_key
972                    && old_key != key
973                {
974                    lock.items.shift_remove(&old_key);
975                }
976                lock.items.insert(
977                    key,
978                    LockedItemV2 {
979                        source: source_name,
980                        kind: outcome.item_id.kind,
981                        version: None,
982                        source_checksum: source_checksum.clone(),
983                        outputs,
984                    },
985                );
986            }
987        }
988    }
989}
990
991/// Merge per-target sync results into a built lock file.
992pub fn apply_target_sync_outputs(
993    lock: &mut LockFile,
994    target_outcomes: &[crate::target_sync::TargetSyncOutcome],
995) {
996    for outcome in target_outcomes {
997        for dest_path in &outcome.removed_dest_paths {
998            remove_target_output(lock, &outcome.target, dest_path);
999        }
1000        for synced in &outcome.synced_outputs {
1001            upsert_target_output(
1002                lock,
1003                &outcome.target,
1004                &synced.dest_path,
1005                &synced.installed_checksum,
1006            );
1007        }
1008    }
1009}
1010
1011/// Native harness output recorded in the lock for a canonical `.mars` agent item.
1012#[derive(Debug, Clone, PartialEq, Eq)]
1013pub struct CompiledNativeOutput {
1014    /// Canonical `.mars` dest path for the owning agent (e.g. `agents/coder.md`).
1015    pub owner_canonical_dest_path: String,
1016    pub target_root: String,
1017    pub dest_path: String,
1018    pub installed_checksum: ContentHash,
1019}
1020
1021/// Whether a freshly compiled native output is new or content-changed vs the
1022/// previous lock at the same `(target_root, dest_path)`. Lets the sync summary
1023/// count only real emissions — steady-state re-emits don't inflate the count.
1024pub fn native_output_is_new_or_changed(old: &LockFile, out: &CompiledNativeOutput) -> bool {
1025    for item in old.items.values() {
1026        for output in &item.outputs {
1027            if output.target_root == out.target_root
1028                && crate::target::dest_paths_equivalent(output.dest_path.as_str(), &out.dest_path)
1029            {
1030                return output.installed_checksum != out.installed_checksum;
1031            }
1032        }
1033    }
1034    true
1035}
1036
1037/// Drop native harness output records removed by native agent reconcile.
1038pub fn apply_removed_native_outputs(lock: &mut LockFile, records: &[(String, String)]) {
1039    for (target_root, dest_path) in records {
1040        remove_target_output(lock, target_root, dest_path);
1041    }
1042}
1043
1044/// Record native harness outputs produced by dual-surface compile.
1045pub fn apply_compiled_native_outputs(lock: &mut LockFile, records: &[CompiledNativeOutput]) {
1046    for record in records {
1047        upsert_native_output_on_owner(
1048            lock,
1049            &record.owner_canonical_dest_path,
1050            &record.target_root,
1051            &record.dest_path,
1052            &record.installed_checksum,
1053        );
1054    }
1055}
1056
1057fn upsert_target_output(
1058    lock: &mut LockFile,
1059    target_root: &str,
1060    dest_path: &str,
1061    installed_checksum: &ContentHash,
1062) {
1063    let dest = DestPath::from(dest_path);
1064    for item in lock.items.values_mut() {
1065        if !item.outputs.iter().any(|output| {
1066            crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
1067        }) {
1068            continue;
1069        }
1070
1071        if let Some(output) = item.outputs.iter_mut().find(|output| {
1072            output.target_root == target_root
1073                && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
1074        }) {
1075            output.installed_checksum = installed_checksum.clone();
1076            return;
1077        }
1078
1079        item.outputs.push(OutputRecord {
1080            target_root: target_root.to_string(),
1081            dest_path: dest,
1082            installed_checksum: installed_checksum.clone(),
1083        });
1084        item.outputs.sort_by(|a, b| {
1085            a.target_root
1086                .cmp(&b.target_root)
1087                .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
1088        });
1089        return;
1090    }
1091}
1092
1093fn upsert_native_output_on_owner(
1094    lock: &mut LockFile,
1095    owner_canonical_dest_path: &str,
1096    target_root: &str,
1097    native_dest_path: &str,
1098    installed_checksum: &ContentHash,
1099) {
1100    let native_dest = DestPath::from(native_dest_path);
1101    for item in lock.items.values_mut() {
1102        let owns_canonical = item.outputs.iter().any(|output| {
1103            output.target_root == CANONICAL_TARGET_ROOT
1104                && crate::target::dest_paths_equivalent(
1105                    output.dest_path.as_str(),
1106                    owner_canonical_dest_path,
1107                )
1108        });
1109        if !owns_canonical {
1110            continue;
1111        }
1112
1113        if let Some(output) = item.outputs.iter_mut().find(|output| {
1114            output.target_root == target_root
1115                && crate::target::dest_paths_equivalent(output.dest_path.as_str(), native_dest_path)
1116        }) {
1117            output.installed_checksum = installed_checksum.clone();
1118            return;
1119        }
1120
1121        item.outputs.push(OutputRecord {
1122            target_root: target_root.to_string(),
1123            dest_path: native_dest,
1124            installed_checksum: installed_checksum.clone(),
1125        });
1126        item.outputs.sort_by(|a, b| {
1127            a.target_root
1128                .cmp(&b.target_root)
1129                .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
1130        });
1131        return;
1132    }
1133}
1134
1135fn remove_target_output(lock: &mut LockFile, target_root: &str, dest_path: &str) {
1136    for item in lock.items.values_mut() {
1137        item.outputs.retain(|output| {
1138            !(output.target_root == target_root
1139                && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path))
1140        });
1141    }
1142    lock.items.retain(|_, item| !item.outputs.is_empty());
1143}
1144
1145// ---------------------------------------------------------------------------
1146// Helpers
1147// ---------------------------------------------------------------------------
1148
1149fn checksum_is_empty(checksum: &ContentHash) -> bool {
1150    checksum.as_ref().trim().is_empty()
1151}
1152
1153fn to_locked_source(node: &crate::resolve::ResolvedNode) -> LockedSource {
1154    let (url, path, subpath) = match &node.source_id {
1155        SourceId::Git { url, subpath } => (Some(url.clone()), None, subpath.clone()),
1156        SourceId::Path { canonical, subpath } => (
1157            None,
1158            Some(canonical.to_string_lossy().to_string()),
1159            subpath.clone(),
1160        ),
1161    };
1162
1163    LockedSource {
1164        url,
1165        path,
1166        subpath,
1167        version: node.resolved_ref.version_tag.clone(),
1168        commit: node.resolved_ref.commit.clone(),
1169        tree_hash: None,
1170    }
1171}
1172
1173/// Canonical item key for v2 lock: `"kind/name"`.
1174pub fn item_key(id: &ItemId) -> String {
1175    format!("{}/{}", id.kind, id.name)
1176}
1177
1178// ---------------------------------------------------------------------------
1179// Tests
1180// ---------------------------------------------------------------------------
1181
1182#[cfg(test)]
1183mod tests {
1184    use super::*;
1185    use std::collections::HashMap;
1186    use std::path::PathBuf;
1187
1188    use crate::resolve::{ResolvedGraph, ResolvedNode};
1189    use crate::source::ResolvedRef;
1190    use crate::sync::apply::{ActionOutcome, ActionTaken, ApplyResult};
1191    use crate::types::{ItemName, SourceId, SourceUrl};
1192    use tempfile::TempDir;
1193
1194    fn sample_lock() -> LockFile {
1195        let mut dependencies = IndexMap::new();
1196        dependencies.insert(
1197            "base".into(),
1198            LockedSource {
1199                url: Some("https://github.com/org/base.git".into()),
1200                path: None,
1201                subpath: None,
1202                version: Some("v1.0.0".into()),
1203                commit: Some("abc123".into()),
1204                tree_hash: Some("def456".into()),
1205            },
1206        );
1207
1208        let mut items = IndexMap::new();
1209        items.insert(
1210            "agent/coder".to_string(),
1211            LockedItemV2 {
1212                source: "base".into(),
1213                kind: ItemKind::Agent,
1214                version: Some("v1.0.0".into()),
1215                source_checksum: "sha256:aaa".into(),
1216                outputs: vec![OutputRecord {
1217                    target_root: ".mars".to_string(),
1218                    dest_path: "agents/coder.md".into(),
1219                    installed_checksum: "sha256:bbb".into(),
1220                }],
1221            },
1222        );
1223        items.insert(
1224            "skill/review".to_string(),
1225            LockedItemV2 {
1226                source: "base".into(),
1227                kind: ItemKind::Skill,
1228                version: Some("v1.0.0".into()),
1229                source_checksum: "sha256:ccc".into(),
1230                outputs: vec![OutputRecord {
1231                    target_root: ".mars".to_string(),
1232                    dest_path: "skills/review".into(),
1233                    installed_checksum: "sha256:ddd".into(),
1234                }],
1235            },
1236        );
1237
1238        LockFile {
1239            version: LOCK_VERSION,
1240            dependencies,
1241            items,
1242            config_entries: BTreeMap::new(),
1243            dependency_model_aliases: IndexMap::new(),
1244        }
1245    }
1246
1247    #[test]
1248    fn parse_v1_lock_file_promoted_to_v2() {
1249        let toml_str = r#"
1250version = 1
1251
1252[dependencies.base]
1253url = "https://github.com/org/base.git"
1254version = "v1.0.0"
1255commit = "abc123"
1256tree_hash = "def456"
1257
1258[items."agents/coder.md"]
1259source = "base"
1260kind = "agent"
1261version = "v1.0.0"
1262source_checksum = "sha256:aaa"
1263installed_checksum = "sha256:bbb"
1264dest_path = "agents/coder.md"
1265"#;
1266        // Load via the full load() path (promotion happens there).
1267        let dir = TempDir::new().unwrap();
1268        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1269        let lock = load(dir.path()).unwrap();
1270
1271        // Promoted to v2 in memory.
1272        assert_eq!(lock.version, LOCK_VERSION);
1273        assert_eq!(lock.dependencies.len(), 1);
1274        assert_eq!(lock.items.len(), 1);
1275
1276        // V2 key is "kind/name".
1277        let item = &lock.items["agent/coder"];
1278        assert_eq!(item.source, "base");
1279        assert_eq!(item.kind, ItemKind::Agent);
1280        assert_eq!(item.source_checksum, "sha256:aaa");
1281        assert_eq!(item.outputs.len(), 1);
1282        assert_eq!(item.outputs[0].installed_checksum, "sha256:bbb");
1283        assert_eq!(item.outputs[0].dest_path.as_str(), "agents/coder.md");
1284        assert_eq!(item.outputs[0].target_root, ".mars");
1285    }
1286
1287    #[test]
1288    fn parse_v2_lock_file() {
1289        let toml_str = r#"
1290version = 2
1291
1292[dependencies.base]
1293url = "https://github.com/org/base.git"
1294version = "v1.0.0"
1295commit = "abc123"
1296
1297[items."agent/coder"]
1298source = "base"
1299kind = "agent"
1300version = "v1.0.0"
1301source_checksum = "sha256:aaa"
1302
1303[[items."agent/coder".outputs]]
1304target_root = ".mars"
1305dest_path = "agents/coder.md"
1306installed_checksum = "sha256:bbb"
1307"#;
1308        let dir = TempDir::new().unwrap();
1309        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1310        let lock = load(dir.path()).unwrap();
1311
1312        assert_eq!(lock.version, 2);
1313        assert_eq!(lock.items.len(), 1);
1314
1315        let item = &lock.items["agent/coder"];
1316        assert_eq!(item.source_checksum, "sha256:aaa");
1317        assert_eq!(item.outputs[0].installed_checksum, "sha256:bbb");
1318    }
1319
1320    #[test]
1321    fn load_for_runtime_aliases_rejects_legacy_v2_without_dependency_alias_authority() {
1322        let toml_str = r#"
1323version = 2
1324
1325[dependencies.base]
1326url = "https://github.com/org/base.git"
1327version = "v1.0.0"
1328commit = "abc123"
1329
1330[items."agent/coder"]
1331source = "base"
1332kind = "agent"
1333source_checksum = "sha256:aaa"
1334
1335[[items."agent/coder".outputs]]
1336target_root = ".mars"
1337dest_path = "agents/coder.md"
1338installed_checksum = "sha256:bbb"
1339"#;
1340        let dir = TempDir::new().unwrap();
1341        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1342
1343        let err = load_for_runtime_aliases(dir.path()).unwrap_err();
1344        let message = err.to_string();
1345        assert!(message.contains("missing `dependency_model_aliases`"));
1346        assert!(message.contains("run `mars sync`"));
1347    }
1348
1349    #[test]
1350    fn load_for_runtime_aliases_allows_missing_dependency_aliases_when_no_dependencies() {
1351        let toml_str = r#"
1352version = 2
1353
1354[items."agent/coder"]
1355source = "_self"
1356kind = "agent"
1357source_checksum = "sha256:aaa"
1358
1359[[items."agent/coder".outputs]]
1360target_root = ".mars"
1361dest_path = "agents/coder.md"
1362installed_checksum = "sha256:bbb"
1363"#;
1364        let dir = TempDir::new().unwrap();
1365        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1366
1367        let lock = load_for_runtime_aliases(dir.path()).unwrap();
1368        assert!(lock.dependencies.is_empty());
1369        assert!(lock.dependency_model_aliases.is_empty());
1370    }
1371
1372    #[test]
1373    fn roundtrip_lock_file() {
1374        let lock = sample_lock();
1375        let dir = TempDir::new().unwrap();
1376        write(dir.path(), &lock).unwrap();
1377        let reloaded = load(dir.path()).unwrap();
1378        assert_eq!(lock, reloaded);
1379    }
1380
1381    #[test]
1382    fn roundtrip_lock_file_with_config_entries() {
1383        let mut lock = sample_lock();
1384        lock.config_entries.insert(
1385            ".claude".to_string(),
1386            BTreeMap::from([(
1387                "mcp:context7".to_string(),
1388                ConfigEntryRecord {
1389                    source: "base".to_string(),
1390                },
1391            )]),
1392        );
1393
1394        let dir = TempDir::new().unwrap();
1395        write(dir.path(), &lock).unwrap();
1396        let reloaded = load(dir.path()).unwrap();
1397
1398        assert_eq!(lock, reloaded);
1399        assert_eq!(
1400            reloaded.config_entries[".claude"]["mcp:context7"].source,
1401            "base"
1402        );
1403    }
1404
1405    #[test]
1406    fn write_emits_dependency_model_aliases_table_even_when_empty() {
1407        let lock = sample_lock();
1408        let dir = TempDir::new().unwrap();
1409        write(dir.path(), &lock).unwrap();
1410
1411        let content = std::fs::read_to_string(dir.path().join("mars.lock")).unwrap();
1412        assert!(
1413            content.contains("dependency_model_aliases"),
1414            "serialized lock should include dependency_model_aliases authority table"
1415        );
1416    }
1417
1418    #[test]
1419    fn deterministic_serialization() {
1420        let lock = sample_lock();
1421        let s1 = toml::to_string_pretty(&lock).unwrap();
1422        let s2 = toml::to_string_pretty(&lock).unwrap();
1423        assert_eq!(s1, s2);
1424
1425        // V2: keys are "agent/coder" and "skill/review" — agent comes before skill alphabetically.
1426        let coder_pos = s1.find("agent/coder").unwrap();
1427        let review_pos = s1.find("skill/review").unwrap();
1428        assert!(
1429            coder_pos < review_pos,
1430            "agent/coder should appear before skill/review"
1431        );
1432    }
1433
1434    #[test]
1435    fn write_sorts_dependency_model_aliases_keys() {
1436        let toml_str = r#"
1437version = 2
1438
1439[dependency_model_aliases.zeta]
1440model = "openai/gpt-z"
1441
1442[dependency_model_aliases.alpha]
1443model = "openai/gpt-a"
1444"#;
1445        let dir = TempDir::new().unwrap();
1446        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1447
1448        let lock = load(dir.path()).unwrap();
1449        write(dir.path(), &lock).unwrap();
1450
1451        let written = std::fs::read_to_string(dir.path().join("mars.lock")).unwrap();
1452        let alpha = written
1453            .find("[dependency_model_aliases.alpha]")
1454            .expect("alpha alias should be serialized");
1455        let zeta = written
1456            .find("[dependency_model_aliases.zeta]")
1457            .expect("zeta alias should be serialized");
1458        assert!(alpha < zeta, "aliases should serialize in sorted key order");
1459    }
1460
1461    #[test]
1462    fn empty_lock_file() {
1463        let lock = LockFile::empty();
1464        assert_eq!(lock.version, LOCK_VERSION);
1465        assert!(lock.dependencies.is_empty());
1466        assert!(lock.items.is_empty());
1467    }
1468
1469    #[test]
1470    fn load_absent_returns_empty() {
1471        let dir = TempDir::new().unwrap();
1472        let lock = load(dir.path()).unwrap();
1473        assert_eq!(lock.version, LOCK_VERSION);
1474        assert!(lock.dependencies.is_empty());
1475        assert!(lock.items.is_empty());
1476    }
1477
1478    #[test]
1479    fn write_and_reload() {
1480        let dir = TempDir::new().unwrap();
1481        let lock = sample_lock();
1482        write(dir.path(), &lock).unwrap();
1483        let reloaded = load(dir.path()).unwrap();
1484        assert_eq!(lock, reloaded);
1485    }
1486
1487    #[test]
1488    fn dual_checksums_present() {
1489        let lock = sample_lock();
1490        let item = &lock.items["agent/coder"];
1491        assert_ne!(item.source_checksum, item.outputs[0].installed_checksum);
1492        assert!(item.source_checksum.starts_with("sha256:"));
1493        assert!(item.outputs[0].installed_checksum.starts_with("sha256:"));
1494    }
1495
1496    #[test]
1497    fn path_source_in_lock() {
1498        let toml_str = r#"
1499version = 2
1500
1501[dependencies.local]
1502path = "/home/dev/agents"
1503
1504[items."agent/helper"]
1505source = "local"
1506kind = "agent"
1507source_checksum = "sha256:111"
1508
1509[[items."agent/helper".outputs]]
1510target_root = ".mars"
1511dest_path = "agents/helper.md"
1512installed_checksum = "sha256:222"
1513"#;
1514        let dir = TempDir::new().unwrap();
1515        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1516        let lock = load(dir.path()).unwrap();
1517        let source = &lock.dependencies["local"];
1518        assert!(source.url.is_none());
1519        assert_eq!(source.path.as_deref(), Some("/home/dev/agents"));
1520        assert!(source.commit.is_none());
1521    }
1522
1523    #[test]
1524    fn item_kind_serializes_lowercase() {
1525        let item = LockedItemV2 {
1526            source: "base".into(),
1527            kind: ItemKind::Skill,
1528            version: None,
1529            source_checksum: "sha256:aaa".into(),
1530            outputs: vec![OutputRecord {
1531                target_root: ".mars".to_string(),
1532                dest_path: "skills/review".into(),
1533                installed_checksum: "sha256:bbb".into(),
1534            }],
1535        };
1536        let serialized = toml::to_string(&item).unwrap();
1537        assert!(serialized.contains("kind = \"skill\""));
1538    }
1539
1540    #[test]
1541    fn item_id_display() {
1542        let id = ItemId {
1543            kind: ItemKind::Agent,
1544            name: "coder".into(),
1545        };
1546        assert_eq!(id.to_string(), "agent/coder");
1547    }
1548
1549    #[test]
1550    fn item_kind_display() {
1551        assert_eq!(ItemKind::Agent.to_string(), "agent");
1552        assert_eq!(ItemKind::Skill.to_string(), "skill");
1553    }
1554
1555    #[test]
1556    fn find_by_dest_path_returns_flat_view() {
1557        let lock = sample_lock();
1558        let found = lock
1559            .find_by_dest_path(&DestPath::from("agents/coder.md"))
1560            .unwrap();
1561        assert_eq!(found.source, "base");
1562        assert_eq!(found.kind, ItemKind::Agent);
1563        assert_eq!(found.source_checksum, "sha256:aaa");
1564        assert_eq!(found.installed_checksum, "sha256:bbb");
1565        assert_eq!(found.dest_path.as_str(), "agents/coder.md");
1566    }
1567
1568    #[test]
1569    fn find_by_dest_path_missing_returns_none() {
1570        let lock = sample_lock();
1571        assert!(
1572            lock.find_by_dest_path(&DestPath::from("agents/missing.md"))
1573                .is_none()
1574        );
1575    }
1576
1577    #[test]
1578    fn contains_dest_path_hit_and_miss() {
1579        let lock = sample_lock();
1580        assert!(lock.contains_dest_path(&DestPath::from("agents/coder.md")));
1581        assert!(!lock.contains_dest_path(&DestPath::from("agents/nobody.md")));
1582    }
1583
1584    #[test]
1585    fn lock_index_find_by_dest_path_hit_and_miss() {
1586        let lock = sample_lock();
1587        let index = LockIndex::new(&lock);
1588
1589        let found = index
1590            .find_by_dest_path(&DestPath::from("agents/coder.md"))
1591            .unwrap();
1592        assert_eq!(found.source, "base");
1593        assert_eq!(found.kind, ItemKind::Agent);
1594        assert_eq!(found.source_checksum, "sha256:aaa");
1595        assert_eq!(found.installed_checksum, "sha256:bbb");
1596        assert_eq!(found.dest_path.as_str(), "agents/coder.md");
1597
1598        assert!(
1599            index
1600                .find_by_dest_path(&DestPath::from("agents/missing.md"))
1601                .is_none()
1602        );
1603    }
1604
1605    #[test]
1606    fn lock_index_contains_dest_path_hit_and_miss() {
1607        let lock = sample_lock();
1608        let index = LockIndex::new(&lock);
1609
1610        assert!(index.contains_dest_path(&DestPath::from("agents/coder.md")));
1611        assert!(!index.contains_dest_path(&DestPath::from("agents/nobody.md")));
1612    }
1613
1614    #[test]
1615    fn lock_index_target_scoped_lookup_distinguishes_same_dest_path() {
1616        let mut lock = sample_lock();
1617        lock.items
1618            .get_mut("agent/coder")
1619            .unwrap()
1620            .outputs
1621            .push(OutputRecord {
1622                target_root: ".pi".to_string(),
1623                dest_path: "agents/coder.md".into(),
1624                installed_checksum: "sha256:pi".into(),
1625            });
1626
1627        let index = LockIndex::new(&lock);
1628        let dest = DestPath::from("agents/coder.md");
1629
1630        let mars = index
1631            .find_output(".mars", &dest)
1632            .expect("expected canonical .mars output");
1633        let pi = index
1634            .find_output(".pi", &dest)
1635            .expect("expected .pi output");
1636
1637        assert_eq!(mars.installed_checksum, "sha256:bbb");
1638        assert_eq!(pi.installed_checksum, "sha256:pi");
1639        assert!(index.contains_output(".mars", &dest));
1640        assert!(index.contains_output(".pi", &dest));
1641        assert!(!index.contains_output(".cursor", &dest));
1642    }
1643
1644    #[test]
1645    fn output_dest_paths_for_target_filters_by_target_root() {
1646        let mut lock = sample_lock();
1647        lock.items
1648            .get_mut("agent/coder")
1649            .unwrap()
1650            .outputs
1651            .push(OutputRecord {
1652                target_root: ".cursor".to_string(),
1653                dest_path: "agents/coder.md".into(),
1654                installed_checksum: "sha256:cursor".into(),
1655            });
1656
1657        let mars_paths = lock.output_dest_paths_for_target(".mars");
1658        assert!(mars_paths.contains("agents/coder.md"));
1659        assert!(mars_paths.contains("skills/review"));
1660
1661        let cursor_paths = lock.output_dest_paths_for_target(".cursor");
1662        assert_eq!(cursor_paths.len(), 1);
1663        assert!(cursor_paths.contains("agents/coder.md"));
1664        assert!(lock.output_dest_paths_for_target(".claude").is_empty());
1665    }
1666
1667    #[test]
1668    fn contains_output_matches_target_root_and_dest_path() {
1669        let mut lock = sample_lock();
1670        assert!(lock.contains_output(".mars", "agents/coder.md"));
1671        assert!(!lock.contains_output(".cursor", "agents/coder.md"));
1672
1673        lock.items
1674            .get_mut("agent/coder")
1675            .unwrap()
1676            .outputs
1677            .push(OutputRecord {
1678                target_root: ".cursor".to_string(),
1679                dest_path: "agents/coder.md".into(),
1680                installed_checksum: "sha256:cursor".into(),
1681            });
1682        assert!(lock.contains_output(".cursor", "agents/coder.md"));
1683        assert!(!lock.contains_output(".cursor", "agents/missing.md"));
1684    }
1685
1686    #[test]
1687    fn apply_compiled_native_outputs_upserts_codex_native_by_canonical_owner() {
1688        let mut lock = sample_lock();
1689        apply_compiled_native_outputs(
1690            &mut lock,
1691            &[CompiledNativeOutput {
1692                owner_canonical_dest_path: "agents/coder.md".to_string(),
1693                target_root: ".codex".to_string(),
1694                dest_path: "agents/coder.toml".to_string(),
1695                installed_checksum: "sha256:codex".into(),
1696            }],
1697        );
1698        assert!(lock.contains_output(".codex", "agents/coder.toml"));
1699        assert!(lock.contains_output(".mars", "agents/coder.md"));
1700    }
1701
1702    #[test]
1703    fn apply_compiled_native_outputs_upserts_when_frontmatter_name_differs_from_filename() {
1704        let mut lock = sample_lock();
1705        lock.items.insert(
1706            "agent/alias-name".to_string(),
1707            LockedItemV2 {
1708                source: "base".into(),
1709                kind: ItemKind::Agent,
1710                version: Some("v1.0.0".into()),
1711                source_checksum: "sha256:alias-src".into(),
1712                outputs: vec![OutputRecord {
1713                    target_root: ".mars".to_string(),
1714                    dest_path: "agents/on-disk-stem.md".into(),
1715                    installed_checksum: "sha256:alias-mars".into(),
1716                }],
1717            },
1718        );
1719        apply_compiled_native_outputs(
1720            &mut lock,
1721            &[CompiledNativeOutput {
1722                owner_canonical_dest_path: "agents/on-disk-stem.md".to_string(),
1723                target_root: ".claude".to_string(),
1724                dest_path: "agents/alias-name.md".to_string(),
1725                installed_checksum: "sha256:claude-native".into(),
1726            }],
1727        );
1728        assert!(lock.contains_output(".claude", "agents/alias-name.md"));
1729    }
1730
1731    #[test]
1732    fn build_updated_carries_non_canonical_outputs() {
1733        let mut old_lock = sample_lock();
1734        old_lock
1735            .items
1736            .get_mut("agent/coder")
1737            .unwrap()
1738            .outputs
1739            .push(OutputRecord {
1740                target_root: ".claude".to_string(),
1741                dest_path: "agents/coder.md".into(),
1742                installed_checksum: "sha256:claude-old".into(),
1743            });
1744
1745        let graph = ResolvedGraph {
1746            nodes: IndexMap::new(),
1747            order: Vec::new(),
1748            filters: HashMap::new(),
1749            version_constraints: std::collections::HashMap::new(),
1750        };
1751        let applied = ApplyResult {
1752            outcomes: vec![ActionOutcome {
1753                item_id: ItemId {
1754                    kind: ItemKind::Agent,
1755                    name: "coder".into(),
1756                },
1757                action: ActionTaken::Updated,
1758                dest_path: "agents/coder.md".into(),
1759                source_name: "base".into(),
1760                source_checksum: Some("sha256:new-src".into()),
1761                installed_checksum: Some("sha256:new-mars".into()),
1762            }],
1763        };
1764
1765        let new_lock = build(
1766            &graph,
1767            &applied,
1768            &old_lock,
1769            std::collections::BTreeMap::new(),
1770        )
1771        .unwrap();
1772
1773        assert!(new_lock.contains_output(".mars", "agents/coder.md"));
1774        assert!(
1775            new_lock.contains_output(".claude", "agents/coder.md"),
1776            ".claude record should survive compile failure"
1777        );
1778        let item = &new_lock.items["agent/coder"];
1779        assert_eq!(item.outputs.len(), 2);
1780        assert_eq!(item.source_checksum, "sha256:new-src");
1781        let mars = item
1782            .outputs
1783            .iter()
1784            .find(|o| o.target_root == ".mars")
1785            .unwrap();
1786        assert_eq!(mars.installed_checksum, "sha256:new-mars");
1787        let claude = item
1788            .outputs
1789            .iter()
1790            .find(|o| o.target_root == ".claude")
1791            .unwrap();
1792        assert_eq!(claude.installed_checksum, "sha256:claude-old");
1793    }
1794
1795    #[test]
1796    fn build_fallback_carries_non_canonical_outputs_for_skipped_and_kept() {
1797        let old_lock = LockFile {
1798            version: LOCK_VERSION,
1799            dependencies: IndexMap::new(),
1800            items: IndexMap::from([
1801                (
1802                    "agent/agents/coder.md".to_string(),
1803                    LockedItemV2 {
1804                        source: "base".into(),
1805                        kind: ItemKind::Agent,
1806                        version: None,
1807                        source_checksum: "sha256:coder-src".into(),
1808                        outputs: vec![
1809                            OutputRecord {
1810                                target_root: ".mars".to_string(),
1811                                dest_path: "agents/coder.md".into(),
1812                                installed_checksum: "sha256:coder-mars".into(),
1813                            },
1814                            OutputRecord {
1815                                target_root: ".claude".to_string(),
1816                                dest_path: "agents/coder.md".into(),
1817                                installed_checksum: "sha256:coder-claude".into(),
1818                            },
1819                        ],
1820                    },
1821                ),
1822                (
1823                    "skill/skills/review".to_string(),
1824                    LockedItemV2 {
1825                        source: "base".into(),
1826                        kind: ItemKind::Skill,
1827                        version: None,
1828                        source_checksum: "sha256:review-src".into(),
1829                        outputs: vec![
1830                            OutputRecord {
1831                                target_root: ".mars".to_string(),
1832                                dest_path: "skills/review".into(),
1833                                installed_checksum: "sha256:review-mars".into(),
1834                            },
1835                            OutputRecord {
1836                                target_root: ".codex".to_string(),
1837                                dest_path: "skills/review/SKILL.md".into(),
1838                                installed_checksum: "sha256:review-codex".into(),
1839                            },
1840                        ],
1841                    },
1842                ),
1843            ]),
1844            config_entries: BTreeMap::new(),
1845            dependency_model_aliases: IndexMap::new(),
1846        };
1847        let graph = ResolvedGraph {
1848            nodes: IndexMap::new(),
1849            order: Vec::new(),
1850            filters: HashMap::new(),
1851            version_constraints: std::collections::HashMap::new(),
1852        };
1853        let applied = ApplyResult {
1854            outcomes: vec![
1855                ActionOutcome {
1856                    item_id: ItemId {
1857                        kind: ItemKind::Agent,
1858                        name: "coder".into(),
1859                    },
1860                    action: ActionTaken::Skipped,
1861                    dest_path: "agents/coder.md".into(),
1862                    source_name: "base".into(),
1863                    source_checksum: None,
1864                    installed_checksum: None,
1865                },
1866                ActionOutcome {
1867                    item_id: ItemId {
1868                        kind: ItemKind::Skill,
1869                        name: "review".into(),
1870                    },
1871                    action: ActionTaken::Kept,
1872                    dest_path: "skills/review".into(),
1873                    source_name: "base".into(),
1874                    source_checksum: None,
1875                    installed_checksum: None,
1876                },
1877            ],
1878        };
1879
1880        let new_lock = build(
1881            &graph,
1882            &applied,
1883            &old_lock,
1884            std::collections::BTreeMap::new(),
1885        )
1886        .unwrap();
1887
1888        assert!(!new_lock.items.contains_key("agent/agents/coder.md"));
1889        assert!(new_lock.contains_output(".mars", "agents/coder.md"));
1890        assert!(new_lock.contains_output(".claude", "agents/coder.md"));
1891
1892        assert!(!new_lock.items.contains_key("skill/skills/review"));
1893        assert!(new_lock.contains_output(".mars", "skills/review"));
1894        assert!(new_lock.contains_output(".codex", "skills/review/SKILL.md"));
1895    }
1896
1897    #[test]
1898    fn build_write_fallback_carries_non_canonical_outputs() {
1899        let old_lock = LockFile {
1900            version: LOCK_VERSION,
1901            dependencies: IndexMap::new(),
1902            items: IndexMap::from([(
1903                "agent/agents/coder.md".to_string(),
1904                LockedItemV2 {
1905                    source: "base".into(),
1906                    kind: ItemKind::Agent,
1907                    version: None,
1908                    source_checksum: "sha256:old-src".into(),
1909                    outputs: vec![
1910                        OutputRecord {
1911                            target_root: ".mars".to_string(),
1912                            dest_path: "agents/coder.md".into(),
1913                            installed_checksum: "sha256:old-mars".into(),
1914                        },
1915                        OutputRecord {
1916                            target_root: ".claude".to_string(),
1917                            dest_path: "agents/coder.md".into(),
1918                            installed_checksum: "sha256:old-claude".into(),
1919                        },
1920                    ],
1921                },
1922            )]),
1923            config_entries: BTreeMap::new(),
1924            dependency_model_aliases: IndexMap::new(),
1925        };
1926        let graph = ResolvedGraph {
1927            nodes: IndexMap::new(),
1928            order: Vec::new(),
1929            filters: HashMap::new(),
1930            version_constraints: std::collections::HashMap::new(),
1931        };
1932        let applied = ApplyResult {
1933            outcomes: vec![ActionOutcome {
1934                item_id: ItemId {
1935                    kind: ItemKind::Agent,
1936                    name: "coder".into(),
1937                },
1938                action: ActionTaken::Updated,
1939                dest_path: "agents/coder.md".into(),
1940                source_name: "base".into(),
1941                source_checksum: Some("sha256:new-src".into()),
1942                installed_checksum: Some("sha256:new-mars".into()),
1943            }],
1944        };
1945
1946        let new_lock = build(
1947            &graph,
1948            &applied,
1949            &old_lock,
1950            std::collections::BTreeMap::new(),
1951        )
1952        .unwrap();
1953
1954        assert!(!new_lock.items.contains_key("agent/agents/coder.md"));
1955        assert!(new_lock.contains_output(".mars", "agents/coder.md"));
1956        assert!(new_lock.contains_output(".claude", "agents/coder.md"));
1957        let item = &new_lock.items["agent/coder"];
1958        assert_eq!(item.source_checksum, "sha256:new-src");
1959        let claude = item
1960            .outputs
1961            .iter()
1962            .find(|o| o.target_root == ".claude")
1963            .unwrap();
1964        assert_eq!(claude.installed_checksum, "sha256:old-claude");
1965    }
1966
1967    #[test]
1968    fn apply_apply_outcomes_write_fallback_carries_non_canonical_outputs() {
1969        let old_lock = LockFile {
1970            version: LOCK_VERSION,
1971            dependencies: IndexMap::new(),
1972            items: IndexMap::from([(
1973                "agent/agents/coder.md".to_string(),
1974                LockedItemV2 {
1975                    source: "base".into(),
1976                    kind: ItemKind::Agent,
1977                    version: None,
1978                    source_checksum: "sha256:old-src".into(),
1979                    outputs: vec![
1980                        OutputRecord {
1981                            target_root: ".mars".to_string(),
1982                            dest_path: "agents/coder.md".into(),
1983                            installed_checksum: "sha256:old-mars".into(),
1984                        },
1985                        OutputRecord {
1986                            target_root: ".claude".to_string(),
1987                            dest_path: "agents/coder.md".into(),
1988                            installed_checksum: "sha256:old-claude".into(),
1989                        },
1990                    ],
1991                },
1992            )]),
1993            config_entries: BTreeMap::new(),
1994            dependency_model_aliases: IndexMap::new(),
1995        };
1996        let mut lock = old_lock.clone();
1997
1998        apply_apply_outcomes_to_lock(
1999            &mut lock,
2000            &old_lock,
2001            &[ActionOutcome {
2002                item_id: ItemId {
2003                    kind: ItemKind::Agent,
2004                    name: "coder".into(),
2005                },
2006                action: ActionTaken::Updated,
2007                dest_path: "agents/coder.md".into(),
2008                source_name: "base".into(),
2009                source_checksum: Some("sha256:new-src".into()),
2010                installed_checksum: Some("sha256:new-mars".into()),
2011            }],
2012        );
2013
2014        assert!(!lock.items.contains_key("agent/agents/coder.md"));
2015        assert!(lock.contains_output(".mars", "agents/coder.md"));
2016        assert!(lock.contains_output(".claude", "agents/coder.md"));
2017        let item = &lock.items["agent/coder"];
2018        assert_eq!(item.source_checksum, "sha256:new-src");
2019    }
2020
2021    #[test]
2022    fn apply_apply_outcomes_to_lock_updated_preserves_non_canonical_outputs() {
2023        let mut old_lock = sample_lock();
2024        old_lock
2025            .items
2026            .get_mut("agent/coder")
2027            .unwrap()
2028            .outputs
2029            .push(OutputRecord {
2030                target_root: ".claude".to_string(),
2031                dest_path: "agents/coder.md".into(),
2032                installed_checksum: "sha256:claude".into(),
2033            });
2034
2035        let mut lock = old_lock.clone();
2036        apply_apply_outcomes_to_lock(
2037            &mut lock,
2038            &old_lock,
2039            &[ActionOutcome {
2040                item_id: ItemId {
2041                    kind: ItemKind::Agent,
2042                    name: ItemName::from("coder"),
2043                },
2044                action: ActionTaken::Updated,
2045                dest_path: "agents/coder.md".into(),
2046                source_name: "base".into(),
2047                source_checksum: Some("sha256:new-src".into()),
2048                installed_checksum: Some("sha256:new-mars".into()),
2049            }],
2050        );
2051
2052        assert!(lock.contains_output(".mars", "agents/coder.md"));
2053        assert!(lock.contains_output(".claude", "agents/coder.md"));
2054        let item = &lock.items["agent/coder"];
2055        assert_eq!(item.source_checksum, "sha256:new-src");
2056        let mars = item
2057            .outputs
2058            .iter()
2059            .find(|o| o.target_root == ".mars")
2060            .unwrap();
2061        assert_eq!(mars.installed_checksum, "sha256:new-mars");
2062        let claude = item
2063            .outputs
2064            .iter()
2065            .find(|o| o.target_root == ".claude")
2066            .unwrap();
2067        assert_eq!(claude.installed_checksum, "sha256:claude");
2068    }
2069
2070    #[test]
2071    fn ownership_lock_for_native_emission_seeds_new_apply_outcomes() {
2072        let old_lock = LockFile::empty();
2073        let apply_outcomes = vec![ActionOutcome {
2074            item_id: ItemId {
2075                kind: ItemKind::Agent,
2076                name: ItemName::from("coder"),
2077            },
2078            action: ActionTaken::Installed,
2079            dest_path: "agents/coder.md".into(),
2080            source_name: "base".into(),
2081            source_checksum: Some("sha256:src".into()),
2082            installed_checksum: Some("sha256:mars".into()),
2083        }];
2084        let view = ownership_lock_for_native_emission(
2085            &old_lock,
2086            &apply_outcomes,
2087            &[crate::target_sync::TargetSyncOutcome {
2088                target: ".cursor".to_string(),
2089                items_synced: 1,
2090                items_removed: 0,
2091                errors: Vec::new(),
2092                synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
2093                    dest_path: "agents/coder.md".to_string(),
2094                    installed_checksum: "sha256:cursor".into(),
2095                }],
2096                removed_dest_paths: Vec::new(),
2097            }],
2098        );
2099        assert!(view.contains_output(".mars", "agents/coder.md"));
2100        assert!(view.contains_output(".cursor", "agents/coder.md"));
2101        assert!(!old_lock.contains_output(".mars", "agents/coder.md"));
2102    }
2103
2104    #[test]
2105    fn ownership_lock_after_target_sync_layers_synced_outputs() {
2106        let lock = sample_lock();
2107        let view = ownership_lock_after_target_sync(
2108            &lock,
2109            &[crate::target_sync::TargetSyncOutcome {
2110                target: ".cursor".to_string(),
2111                items_synced: 1,
2112                items_removed: 0,
2113                errors: Vec::new(),
2114                synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
2115                    dest_path: "agents/coder.md".to_string(),
2116                    installed_checksum: "sha256:cursor".into(),
2117                }],
2118                removed_dest_paths: Vec::new(),
2119            }],
2120        );
2121        assert!(view.contains_output(".cursor", "agents/coder.md"));
2122        assert!(!lock.contains_output(".cursor", "agents/coder.md"));
2123    }
2124
2125    #[test]
2126    fn apply_target_sync_outputs_upserts_and_removes_target_records() {
2127        let mut lock = sample_lock();
2128        apply_target_sync_outputs(
2129            &mut lock,
2130            &[crate::target_sync::TargetSyncOutcome {
2131                target: ".cursor".to_string(),
2132                items_synced: 1,
2133                items_removed: 0,
2134                errors: Vec::new(),
2135                synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
2136                    dest_path: "agents/coder.md".to_string(),
2137                    installed_checksum: "sha256:cursor".into(),
2138                }],
2139                removed_dest_paths: Vec::new(),
2140            }],
2141        );
2142        assert!(lock.contains_output(".cursor", "agents/coder.md"));
2143
2144        apply_target_sync_outputs(
2145            &mut lock,
2146            &[crate::target_sync::TargetSyncOutcome {
2147                target: ".cursor".to_string(),
2148                items_synced: 0,
2149                items_removed: 1,
2150                errors: Vec::new(),
2151                synced_outputs: Vec::new(),
2152                removed_dest_paths: vec!["agents/coder.md".to_string()],
2153            }],
2154        );
2155        assert!(!lock.contains_output(".cursor", "agents/coder.md"));
2156        assert!(lock.contains_output(".mars", "agents/coder.md"));
2157    }
2158
2159    #[test]
2160    fn canonical_flat_items_excludes_linked_target_outputs() {
2161        let mut lock = sample_lock();
2162        lock.items
2163            .get_mut("agent/coder")
2164            .unwrap()
2165            .outputs
2166            .push(OutputRecord {
2167                target_root: ".cursor".to_string(),
2168                dest_path: "agents/coder.md".into(),
2169                installed_checksum: "sha256:cursor".into(),
2170            });
2171
2172        let canonical = lock.canonical_flat_items();
2173        assert_eq!(canonical.len(), 2);
2174        assert!(
2175            canonical
2176                .iter()
2177                .any(|(dp, _)| dp.as_str() == "agents/coder.md")
2178        );
2179        assert!(
2180            canonical
2181                .iter()
2182                .all(|(_, item)| { lock.contains_output(".mars", item.dest_path.as_str()) })
2183        );
2184
2185        let cursor = lock.flat_items_for_target(".cursor");
2186        assert_eq!(cursor.len(), 1);
2187        assert_eq!(cursor[0].0.as_str(), "agents/coder.md");
2188    }
2189
2190    #[test]
2191    fn flat_items_yields_all_outputs() {
2192        let lock = sample_lock();
2193        let flat = lock.flat_items();
2194        assert_eq!(flat.len(), 2);
2195        let paths: Vec<&str> = flat.iter().map(|(dp, _)| dp.as_str()).collect();
2196        assert!(paths.contains(&"agents/coder.md"));
2197        assert!(paths.contains(&"skills/review"));
2198    }
2199
2200    #[test]
2201    fn v1_lock_no_spurious_reinstall() {
2202        // V1 lock loaded → promoted to v2 → find_by_dest_path works for diff.
2203        let v1_toml = r#"
2204version = 1
2205
2206[dependencies.base]
2207url = "https://github.com/org/base.git"
2208
2209[items."agents/coder.md"]
2210source = "base"
2211kind = "agent"
2212source_checksum = "sha256:src"
2213installed_checksum = "sha256:inst"
2214dest_path = "agents/coder.md"
2215"#;
2216        let dir = TempDir::new().unwrap();
2217        std::fs::write(dir.path().join("mars.lock"), v1_toml).unwrap();
2218        let lock = load(dir.path()).unwrap();
2219
2220        // Promoted items should still be findable by dest_path.
2221        let found = lock.find_by_dest_path(&DestPath::from("agents/coder.md"));
2222        assert!(found.is_some());
2223        let item = found.unwrap();
2224        assert_eq!(item.source_checksum, "sha256:src");
2225        assert_eq!(item.installed_checksum, "sha256:inst");
2226    }
2227
2228    #[test]
2229    fn build_uses_graph_provenance_for_sources() {
2230        let git_name: SourceName = "base".into();
2231        let path_name: SourceName = "local".into();
2232        let git_url: SourceUrl = "https://example.com/new.git".into();
2233        let path_canonical = PathBuf::from("/tmp/mars-agents-local-source");
2234
2235        let mut nodes = IndexMap::new();
2236        nodes.insert(
2237            git_name.clone(),
2238            ResolvedNode {
2239                source_name: git_name.clone(),
2240                source_id: SourceId::git_with_subpath(
2241                    git_url.clone(),
2242                    Some(crate::types::SourceSubpath::new("plugins/base").unwrap()),
2243                ),
2244                rooted_ref: crate::resolve::RootedSourceRef {
2245                    checkout_root: PathBuf::from("/tmp/cache/base"),
2246                    package_root: PathBuf::from("/tmp/cache/base/plugins/base"),
2247                },
2248                resolved_ref: ResolvedRef {
2249                    source_name: git_name.clone(),
2250                    version: Some(semver::Version::new(1, 2, 3)),
2251                    version_tag: Some("v1.2.3".into()),
2252                    commit: Some("abc123".into()),
2253                    tree_path: PathBuf::from("/tmp/cache/base"),
2254                },
2255                latest_version: None,
2256                manifest: None,
2257                deps: vec![],
2258            },
2259        );
2260        nodes.insert(
2261            path_name.clone(),
2262            ResolvedNode {
2263                source_name: path_name.clone(),
2264                source_id: SourceId::Path {
2265                    canonical: path_canonical.clone(),
2266                    subpath: Some(crate::types::SourceSubpath::new("plugins/local").unwrap()),
2267                },
2268                rooted_ref: crate::resolve::RootedSourceRef {
2269                    checkout_root: PathBuf::from("/tmp/cache/local"),
2270                    package_root: PathBuf::from("/tmp/cache/local/plugins/local"),
2271                },
2272                resolved_ref: ResolvedRef {
2273                    source_name: path_name.clone(),
2274                    version: None,
2275                    version_tag: None,
2276                    commit: None,
2277                    tree_path: PathBuf::from("/tmp/cache/local"),
2278                },
2279                latest_version: None,
2280                manifest: None,
2281                deps: vec![],
2282            },
2283        );
2284
2285        let graph = ResolvedGraph {
2286            nodes,
2287            order: vec![git_name.clone(), path_name.clone()],
2288            filters: HashMap::new(),
2289            version_constraints: std::collections::HashMap::new(),
2290        };
2291        let applied = ApplyResult { outcomes: vec![] };
2292
2293        let mut old_sources = IndexMap::new();
2294        old_sources.insert(
2295            git_name.clone(),
2296            LockedSource {
2297                url: Some("https://example.com/old.git".into()),
2298                path: None,
2299                subpath: None,
2300                version: Some("v0.0.1".into()),
2301                commit: Some("deadbeef".into()),
2302                tree_hash: None,
2303            },
2304        );
2305        let old_lock = LockFile {
2306            version: LOCK_VERSION,
2307            dependencies: old_sources,
2308            items: IndexMap::new(),
2309            config_entries: std::collections::BTreeMap::new(),
2310            dependency_model_aliases: IndexMap::new(),
2311        };
2312
2313        let new_lock = build(
2314            &graph,
2315            &applied,
2316            &old_lock,
2317            std::collections::BTreeMap::new(),
2318        )
2319        .unwrap();
2320
2321        let base = &new_lock.dependencies["base"];
2322        assert_eq!(base.url.as_ref(), Some(&git_url));
2323        assert_eq!(
2324            base.subpath
2325                .as_ref()
2326                .map(crate::types::SourceSubpath::as_str),
2327            Some("plugins/base")
2328        );
2329        assert_eq!(base.version.as_deref(), Some("v1.2.3"));
2330        assert_eq!(base.commit.as_deref(), Some("abc123"));
2331
2332        let local = &new_lock.dependencies["local"];
2333        assert!(local.url.is_none());
2334        assert_eq!(
2335            local
2336                .subpath
2337                .as_ref()
2338                .map(crate::types::SourceSubpath::as_str),
2339            Some("plugins/local")
2340        );
2341        assert_eq!(
2342            local.path.as_deref(),
2343            Some(path_canonical.to_string_lossy().as_ref())
2344        );
2345    }
2346
2347    #[test]
2348    fn build_persists_ref_selector_in_locked_source_version() {
2349        let source_name: SourceName = "base".into();
2350        let mut nodes = IndexMap::new();
2351        nodes.insert(
2352            source_name.clone(),
2353            ResolvedNode {
2354                source_name: source_name.clone(),
2355                source_id: SourceId::git("https://example.com/base.git".into()),
2356                rooted_ref: crate::resolve::RootedSourceRef {
2357                    checkout_root: PathBuf::from("/tmp/cache/base"),
2358                    package_root: PathBuf::from("/tmp/cache/base"),
2359                },
2360                resolved_ref: ResolvedRef {
2361                    source_name: source_name.clone(),
2362                    version: None,
2363                    version_tag: Some("main".into()),
2364                    commit: Some("abc123".into()),
2365                    tree_path: PathBuf::from("/tmp/cache/base"),
2366                },
2367                latest_version: None,
2368                manifest: None,
2369                deps: vec![],
2370            },
2371        );
2372
2373        let graph = ResolvedGraph {
2374            nodes,
2375            order: vec![source_name.clone()],
2376            filters: HashMap::new(),
2377            version_constraints: std::collections::HashMap::new(),
2378        };
2379        let applied = ApplyResult { outcomes: vec![] };
2380        let new_lock = build(
2381            &graph,
2382            &applied,
2383            &LockFile::empty(),
2384            std::collections::BTreeMap::new(),
2385        )
2386        .unwrap();
2387
2388        let source = &new_lock.dependencies["base"];
2389        assert_eq!(source.version.as_deref(), Some("main"));
2390        assert_eq!(source.commit.as_deref(), Some("abc123"));
2391    }
2392
2393    #[test]
2394    fn build_keeps_self_items_from_old_lock_on_skipped_action() {
2395        let graph = ResolvedGraph {
2396            nodes: IndexMap::new(),
2397            order: Vec::new(),
2398            filters: HashMap::new(),
2399            version_constraints: std::collections::HashMap::new(),
2400        };
2401        let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
2402        let old_lock = LockFile {
2403            version: LOCK_VERSION,
2404            dependencies: IndexMap::from([(
2405                local_source_name.clone(),
2406                LockedSource {
2407                    url: None,
2408                    path: Some(".".into()),
2409                    subpath: None,
2410                    version: None,
2411                    commit: None,
2412                    tree_hash: None,
2413                },
2414            )]),
2415            items: IndexMap::from([(
2416                "skill/local-skill".to_string(),
2417                LockedItemV2 {
2418                    source: local_source_name.clone(),
2419                    kind: ItemKind::Skill,
2420                    version: None,
2421                    source_checksum: "sha256:self".into(),
2422                    outputs: vec![OutputRecord {
2423                        target_root: ".mars".to_string(),
2424                        dest_path: DestPath::from("skills/local-skill"),
2425                        installed_checksum: "sha256:self".into(),
2426                    }],
2427                },
2428            )]),
2429            config_entries: std::collections::BTreeMap::new(),
2430            dependency_model_aliases: IndexMap::new(),
2431        };
2432        let applied = ApplyResult {
2433            outcomes: vec![ActionOutcome {
2434                item_id: ItemId {
2435                    kind: ItemKind::Skill,
2436                    name: "local-skill".into(),
2437                },
2438                action: ActionTaken::Skipped,
2439                dest_path: "skills/local-skill".into(),
2440                source_name: local_source_name.clone(),
2441                source_checksum: None,
2442                installed_checksum: None,
2443            }],
2444        };
2445
2446        let new_lock = build(
2447            &graph,
2448            &applied,
2449            &old_lock,
2450            std::collections::BTreeMap::new(),
2451        )
2452        .unwrap();
2453
2454        assert!(
2455            new_lock
2456                .dependencies
2457                .contains_key(local_source_name.as_str())
2458        );
2459        let item = &new_lock.items["skill/local-skill"];
2460        assert_eq!(item.source, local_source_name);
2461        assert_eq!(item.kind, ItemKind::Skill);
2462        assert_eq!(item.source_checksum, "sha256:self");
2463        assert_eq!(item.outputs[0].installed_checksum, "sha256:self");
2464    }
2465
2466    #[test]
2467    fn build_rejects_missing_installed_checksum_for_write_actions() {
2468        let graph = ResolvedGraph {
2469            nodes: IndexMap::new(),
2470            order: Vec::new(),
2471            filters: HashMap::new(),
2472            version_constraints: std::collections::HashMap::new(),
2473        };
2474        let old_lock = LockFile::empty();
2475        let applied = ApplyResult {
2476            outcomes: vec![ActionOutcome {
2477                item_id: ItemId {
2478                    kind: ItemKind::Agent,
2479                    name: "coder".into(),
2480                },
2481                action: ActionTaken::Installed,
2482                dest_path: "agents/coder.md".into(),
2483                source_name: "base".into(),
2484                source_checksum: Some("sha256:source".into()),
2485                installed_checksum: None,
2486            }],
2487        };
2488
2489        let err = build(
2490            &graph,
2491            &applied,
2492            &old_lock,
2493            std::collections::BTreeMap::new(),
2494        )
2495        .unwrap_err();
2496        let msg = err.to_string();
2497        assert!(msg.contains("missing checksum for write-producing action"));
2498        assert!(msg.contains("agents/coder.md"));
2499    }
2500
2501    #[test]
2502    fn promote_v1_collision_both_survive() {
2503        // Two v1 items with different full dest_paths but the same basename
2504        // (e.g. "hook" from two different subdirectories) must both survive promotion.
2505        // Without collision handling the second would silently overwrite the first.
2506        let mut v1_items: IndexMap<DestPath, LockedItem> = IndexMap::new();
2507
2508        v1_items.insert(
2509            DestPath::from("hooks/pre-commit/hook.sh"),
2510            LockedItem {
2511                source: "base".into(),
2512                kind: ItemKind::Hook,
2513                version: None,
2514                source_checksum: "sha256:aaa".into(),
2515                installed_checksum: "sha256:bbb".into(),
2516                dest_path: DestPath::from("hooks/pre-commit/hook.sh"),
2517            },
2518        );
2519        v1_items.insert(
2520            DestPath::from("hooks/pre-push/hook.sh"),
2521            LockedItem {
2522                source: "base".into(),
2523                kind: ItemKind::Hook,
2524                version: None,
2525                source_checksum: "sha256:ccc".into(),
2526                installed_checksum: "sha256:ddd".into(),
2527                dest_path: DestPath::from("hooks/pre-push/hook.sh"),
2528            },
2529        );
2530
2531        let (promoted, diagnostics) = promote_v1_items(v1_items);
2532
2533        // Both entries must be present — neither was silently dropped.
2534        assert_eq!(promoted.len(), 2, "both items should survive promotion");
2535        assert_eq!(diagnostics.len(), 1);
2536
2537        // The first item gets the canonical key; the second gets the fallback dest_path key.
2538        let checksums: std::collections::HashSet<String> = promoted
2539            .values()
2540            .map(|v| v.source_checksum.as_ref().to_string())
2541            .collect();
2542        assert!(
2543            checksums.contains("sha256:aaa"),
2544            "pre-commit hook must be present"
2545        );
2546        assert!(
2547            checksums.contains("sha256:ccc"),
2548            "pre-push hook must be present"
2549        );
2550    }
2551
2552    #[test]
2553    fn load_with_diagnostics_reports_v1_promotion_collision() {
2554        let v1_toml = r#"
2555version = 1
2556
2557[dependencies.base]
2558url = "https://github.com/org/base.git"
2559
2560[items."hooks/pre-commit/hook.sh"]
2561source = "base"
2562kind = "hook"
2563source_checksum = "sha256:aaa"
2564installed_checksum = "sha256:bbb"
2565dest_path = "hooks/pre-commit/hook.sh"
2566
2567[items."hooks/pre-push/hook.sh"]
2568source = "base"
2569kind = "hook"
2570source_checksum = "sha256:ccc"
2571installed_checksum = "sha256:ddd"
2572dest_path = "hooks/pre-push/hook.sh"
2573"#;
2574        let dir = TempDir::new().unwrap();
2575        std::fs::write(dir.path().join("mars.lock"), v1_toml).unwrap();
2576
2577        let (lock, diagnostics) = load_with_diagnostics(dir.path()).unwrap();
2578
2579        assert_eq!(lock.version, LOCK_VERSION);
2580        assert_eq!(lock.items.len(), 2);
2581        assert_eq!(diagnostics.len(), 1);
2582        let diagnostic = &diagnostics[0];
2583        assert_eq!(
2584            diagnostic.level,
2585            crate::diagnostic::DiagnosticLevel::Warning
2586        );
2587        assert_eq!(diagnostic.code, "lock-promotion-collision");
2588        assert!(diagnostic.message.contains("key collision"));
2589        assert!(diagnostic.message.contains("hook/hooks/pre-push/hook.sh"));
2590    }
2591
2592    #[test]
2593    fn build_rejects_empty_checksums_from_carried_items() {
2594        let graph = ResolvedGraph {
2595            nodes: IndexMap::new(),
2596            order: Vec::new(),
2597            filters: HashMap::new(),
2598            version_constraints: std::collections::HashMap::new(),
2599        };
2600        let old_lock = LockFile {
2601            version: LOCK_VERSION,
2602            dependencies: IndexMap::new(),
2603            items: IndexMap::from([(
2604                "agent/coder".to_string(),
2605                LockedItemV2 {
2606                    source: "base".into(),
2607                    kind: ItemKind::Agent,
2608                    version: None,
2609                    source_checksum: "".into(),
2610                    outputs: vec![OutputRecord {
2611                        target_root: ".mars".to_string(),
2612                        dest_path: DestPath::from("agents/coder.md"),
2613                        installed_checksum: "sha256:installed".into(),
2614                    }],
2615                },
2616            )]),
2617            config_entries: std::collections::BTreeMap::new(),
2618            dependency_model_aliases: IndexMap::new(),
2619        };
2620        let applied = ApplyResult {
2621            outcomes: vec![ActionOutcome {
2622                item_id: ItemId {
2623                    kind: ItemKind::Agent,
2624                    name: "coder".into(),
2625                },
2626                action: ActionTaken::Skipped,
2627                dest_path: "agents/coder.md".into(),
2628                source_name: "base".into(),
2629                source_checksum: None,
2630                installed_checksum: None,
2631            }],
2632        };
2633
2634        let err = build(
2635            &graph,
2636            &applied,
2637            &old_lock,
2638            std::collections::BTreeMap::new(),
2639        )
2640        .unwrap_err();
2641        let msg = err.to_string();
2642        assert!(msg.contains("empty source_checksum"));
2643    }
2644}