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 3: 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 3.
24    pub version: u32,
25    #[serde(default)]
26    pub dependencies: IndexMap<SourceName, LockedSource>,
27    /// 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`, delegated to the current wire type.
39/// Version validation is performed by [`load`]; direct deserialization expects the
40/// current schema shape.
41impl<'de> serde::Deserialize<'de> for LockFile {
42    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
43        let wire = LockFileWire::deserialize(deserializer)?;
44        Ok(LockFile {
45            version: wire.version,
46            dependencies: wire.dependencies,
47            items: wire.items,
48            config_entries: wire.config_entries,
49            dependency_model_aliases: wire.dependency_model_aliases,
50        })
51    }
52}
53
54impl LockFile {
55    /// Create a new empty lock file with the current schema version.
56    pub fn empty() -> Self {
57        LockFile {
58            version: LOCK_VERSION,
59            dependencies: IndexMap::new(),
60            items: IndexMap::new(),
61            config_entries: BTreeMap::new(),
62            dependency_model_aliases: IndexMap::new(),
63        }
64    }
65
66    /// Look up a locked item by its output dest_path, returning a flat [`LockedItem`] view.
67    ///
68    /// Searches across all items and their output records. Returns the first match.
69    pub fn find_by_dest_path(&self, dest_path: &DestPath) -> Option<LockedItem> {
70        for item_v2 in self.items.values() {
71            for output in &item_v2.outputs {
72                if crate::target::dest_paths_equivalent(
73                    output.dest_path.as_str(),
74                    dest_path.as_str(),
75                ) && let Some(installed_checksum) = output.installed_checksum()
76                {
77                    return Some(LockedItem {
78                        source: item_v2.source.clone(),
79                        kind: item_v2.kind,
80                        version: item_v2.version.clone(),
81                        source_checksum: item_v2.source_checksum.clone(),
82                        installed_checksum: installed_checksum.clone(),
83                        dest_path: output.dest_path.clone(),
84                    });
85                }
86            }
87        }
88        None
89    }
90
91    /// Check if any output record has the given dest_path.
92    pub fn contains_dest_path(&self, dest_path: &DestPath) -> bool {
93        self.items.values().any(|item| {
94            item.outputs.iter().any(|o| {
95                crate::target::dest_paths_equivalent(o.dest_path.as_str(), dest_path.as_str())
96            })
97        })
98    }
99
100    /// Iterate all output dest_paths across all items.
101    pub fn all_output_dest_paths(&self) -> impl Iterator<Item = &DestPath> {
102        self.items
103            .values()
104            .flat_map(|item| item.outputs.iter().map(|o| &o.dest_path))
105    }
106
107    /// Dest paths previously managed under a specific target root.
108    pub fn output_dest_paths_for_target(&self, target_root: &str) -> HashSet<String> {
109        self.items
110            .values()
111            .flat_map(|item| item.outputs.iter())
112            .filter(|output| output.target_root == target_root)
113            .map(|output| output.dest_path.to_string())
114            .collect()
115    }
116
117    /// Whether the lock records ownership of `dest_path` under `target_root`.
118    pub fn contains_output(&self, target_root: &str, dest_path: &str) -> bool {
119        self.items.values().any(|item| {
120            item.outputs.iter().any(|output| {
121                output.target_root == target_root
122                    && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
123            })
124        })
125    }
126
127    /// The installed checksum claimed for `dest_path` under `target_root`.
128    ///
129    /// Pending-deletion records intentionally return `None`: they authorize a
130    /// removal retry, but do not authorize treating whatever is currently at
131    /// the path as Mars-installed content.
132    pub(crate) fn installed_checksum_for_output(
133        &self,
134        target_root: &str,
135        dest_path: &str,
136    ) -> Option<&ContentHash> {
137        self.items.values().find_map(|item| {
138            item.outputs.iter().find_map(|output| {
139                (output.target_root == target_root
140                    && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path))
141                .then(|| output.installed_checksum())
142                .flatten()
143            })
144        })
145    }
146
147    /// Flat view of canonical `.mars` outputs only.
148    pub fn canonical_flat_items(&self) -> Vec<(DestPath, LockedItem)> {
149        self.flat_items_for_target(CANONICAL_TARGET_ROOT)
150    }
151
152    /// Flat view of outputs materialized under `target_root`.
153    pub fn flat_items_for_target(&self, target_root: &str) -> Vec<(DestPath, LockedItem)> {
154        self.items
155            .values()
156            .flat_map(|item_v2| {
157                item_v2.outputs.iter().filter_map(|output| {
158                    if output.target_root != target_root {
159                        return None;
160                    }
161                    let installed_checksum = output.installed_checksum()?;
162                    Some((
163                        output.dest_path.clone(),
164                        LockedItem {
165                            source: item_v2.source.clone(),
166                            kind: item_v2.kind,
167                            version: item_v2.version.clone(),
168                            source_checksum: item_v2.source_checksum.clone(),
169                            installed_checksum: installed_checksum.clone(),
170                            dest_path: output.dest_path.clone(),
171                        },
172                    ))
173                })
174            })
175            .collect()
176    }
177}
178
179/// Ephemeral lookup index for lock files.
180///
181/// `LockFile` preserves the persisted v2 shape. Build this short-lived index
182/// at hot call sites that need repeated output-path lookups.
183pub struct LockIndex<'a> {
184    lock: &'a LockFile,
185    by_output: HashMap<(String, String), (&'a str, usize)>,
186}
187
188impl<'a> LockIndex<'a> {
189    pub fn new(lock: &'a LockFile) -> Self {
190        let mut by_output = HashMap::new();
191        for (key, item) in &lock.items {
192            for (idx, output) in item.outputs.iter().enumerate() {
193                let normalized_dest = normalize_dest_path(output.dest_path.as_str());
194                by_output.insert(
195                    (output.target_root.clone(), normalized_dest),
196                    (key.as_str(), idx),
197                );
198            }
199        }
200
201        Self { lock, by_output }
202    }
203
204    /// Look up a locked output by target root + dest_path, returning a flat [`LockedItem`] view.
205    pub fn find_output(&self, target_root: &str, dest_path: &DestPath) -> Option<LockedItem> {
206        let (item_key, output_idx) = *self.by_output.get(&(
207            target_root.to_string(),
208            normalize_dest_path(dest_path.as_str()),
209        ))?;
210        self.locked_item_for(item_key, output_idx)
211    }
212
213    fn item_for_output(
214        &self,
215        target_root: &str,
216        dest_path: &DestPath,
217    ) -> Option<(&'a str, &'a LockedItemV2, &'a OutputRecord)> {
218        let (item_key, output_idx) = *self.by_output.get(&(
219            target_root.to_string(),
220            normalize_dest_path(dest_path.as_str()),
221        ))?;
222        let item = self.lock.items.get(item_key)?;
223        let output = item.outputs.get(output_idx)?;
224        Some((item_key, item, output))
225    }
226
227    /// Whether any output is recorded for `target_root + dest_path`.
228    pub fn contains_output(&self, target_root: &str, dest_path: &DestPath) -> bool {
229        self.by_output.contains_key(&(
230            target_root.to_string(),
231            normalize_dest_path(dest_path.as_str()),
232        ))
233    }
234
235    /// Whether an installed (not pending-deletion) output is recorded for this path.
236    pub(crate) fn contains_installed_output(
237        &self,
238        target_root: &str,
239        dest_path: &DestPath,
240    ) -> bool {
241        self.item_for_output(target_root, dest_path)
242            .is_some_and(|(_, _, output)| output.installed_checksum().is_some())
243    }
244
245    fn locked_item_for(&self, item_key: &str, output_idx: usize) -> Option<LockedItem> {
246        let item_v2 = self.lock.items.get(item_key)?;
247        let output = item_v2.outputs.get(output_idx)?;
248        let installed_checksum = output.installed_checksum()?;
249        Some(LockedItem {
250            source: item_v2.source.clone(),
251            kind: item_v2.kind,
252            version: item_v2.version.clone(),
253            source_checksum: item_v2.source_checksum.clone(),
254            installed_checksum: installed_checksum.clone(),
255            dest_path: output.dest_path.clone(),
256        })
257    }
258}
259
260fn normalize_dest_path(s: &str) -> String {
261    if cfg!(windows) {
262        s.replace('\\', "/")
263    } else {
264        s.to_string()
265    }
266}
267
268/// One resolved source in the lock.
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
270pub struct LockedSource {
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub url: Option<SourceUrl>,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub path: Option<String>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub subpath: Option<SourceSubpath>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub version: Option<String>,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub commit: Option<CommitHash>,
281}
282
283/// V2 locked item: one logical item with per-output records.
284///
285/// `source_checksum` is shared across all outputs (same source content).
286/// Each `OutputRecord` has its own `installed_checksum` for divergence detection.
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
288pub struct LockedItemV2 {
289    pub source: SourceName,
290    pub kind: ItemKind,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub version: Option<String>,
293    pub source_checksum: ContentHash,
294    /// Per-output records: one per target root this item was materialized to.
295    pub outputs: Vec<OutputRecord>,
296}
297
298/// A single path owned by Mars, with its lifecycle claim made explicit.
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
300pub struct OutputRecord {
301    /// Target root this output belongs to (e.g., ".mars", ".claude").
302    pub target_root: String,
303    /// Relative path under the target root (e.g., "agents/coder.md").
304    pub dest_path: DestPath,
305    /// What authority this record currently asserts for the path.
306    #[serde(flatten)]
307    pub state: OutputState,
308}
309
310/// The lifecycle claim carried by an [`OutputRecord`].
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
312#[serde(tag = "state", rename_all = "kebab-case")]
313pub enum OutputState {
314    /// Mars confirms its installed bytes are present at the path.
315    Installed { installed_checksum: ContentHash },
316    /// Removal was not confirmed; retain path ownership solely to retry deletion.
317    PendingDeletion,
318}
319
320impl OutputRecord {
321    pub fn installed(
322        target_root: String,
323        dest_path: DestPath,
324        installed_checksum: ContentHash,
325    ) -> Self {
326        Self {
327            target_root,
328            dest_path,
329            state: OutputState::Installed { installed_checksum },
330        }
331    }
332
333    pub fn pending_deletion(
334        target_root: impl Into<String>,
335        dest_path: impl Into<DestPath>,
336    ) -> Self {
337        Self {
338            target_root: target_root.into(),
339            dest_path: dest_path.into(),
340            state: OutputState::PendingDeletion,
341        }
342    }
343
344    pub fn installed_checksum(&self) -> Option<&ContentHash> {
345        match &self.state {
346            OutputState::Installed { installed_checksum } => Some(installed_checksum),
347            OutputState::PendingDeletion => None,
348        }
349    }
350
351    pub fn mark_installed(&mut self, installed_checksum: ContentHash) {
352        self.state = OutputState::Installed { installed_checksum };
353    }
354
355    pub fn mark_pending_deletion(&mut self) {
356        self.state = OutputState::PendingDeletion;
357    }
358}
359
360/// Ownership record for one target-native config entry.
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
362pub struct ConfigEntryRecord {
363    /// Canonical JSON for the exact post-substitution hook entry array.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub emitted_json: Option<String>,
366}
367
368/// Flat view of a single installed item — used by diff, plan, and apply stages.
369///
370/// Constructed from [`LockedItemV2`] + one [`OutputRecord`]; preserves backward
371/// compat with code that operates on per-dest-path records.
372#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
373pub struct LockedItem {
374    pub source: SourceName,
375    pub kind: ItemKind,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub version: Option<String>,
378    pub source_checksum: ContentHash,
379    pub installed_checksum: ContentHash,
380    pub dest_path: DestPath,
381}
382
383// Re-export ItemKind and ItemId from types — they're shared vocabulary,
384// not lock-specific. This preserves `use crate::lock::ItemKind` compatibility.
385pub use crate::types::{ItemId, ItemKind};
386
387const LOCK_FILE: &str = "mars.lock";
388/// Current lock file schema version.
389const LOCK_VERSION: u32 = 3;
390/// Canonical materialization root for `.mars/` apply outcomes.
391pub const CANONICAL_TARGET_ROOT: &str = ".mars";
392
393// ---------------------------------------------------------------------------
394// Persisted wire formats.
395// ---------------------------------------------------------------------------
396
397/// Current wire format for Deserialize (mirrors `LockFile` but derives `Deserialize`).
398#[derive(Deserialize)]
399struct LockFileWire {
400    version: u32,
401    #[serde(default)]
402    dependencies: IndexMap<SourceName, LockedSource>,
403    #[serde(default)]
404    items: IndexMap<String, LockedItemV2>,
405    #[serde(default)]
406    config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
407    #[serde(default)]
408    dependency_model_aliases: IndexMap<String, ModelAlias>,
409}
410
411/// Version 2 output records did not distinguish installed content from retry tombstones.
412#[derive(Deserialize)]
413struct OutputRecordV2 {
414    target_root: String,
415    dest_path: DestPath,
416    installed_checksum: ContentHash,
417}
418
419#[derive(Deserialize)]
420struct LockedItemV2Wire {
421    source: SourceName,
422    kind: ItemKind,
423    #[serde(default)]
424    version: Option<String>,
425    source_checksum: ContentHash,
426    outputs: Vec<OutputRecordV2>,
427}
428
429/// One-release v2 wire format. Delete this promotion after the release following
430/// lock v3, alongside the #130 legacy-hook sweeps that depend on these records.
431#[derive(Deserialize)]
432struct LockFileV2Wire {
433    #[allow(dead_code)]
434    version: u32,
435    #[serde(default)]
436    dependencies: IndexMap<SourceName, LockedSource>,
437    #[serde(default)]
438    items: IndexMap<String, LockedItemV2Wire>,
439    #[serde(default)]
440    config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
441    #[serde(default)]
442    dependency_model_aliases: IndexMap<String, ModelAlias>,
443}
444
445// ---------------------------------------------------------------------------
446// Load / write
447// ---------------------------------------------------------------------------
448
449/// Load the lock file from the given root directory.
450///
451/// Returns an empty current-version lock if the file is absent.
452/// Version 2 is promoted in memory for one release; other older schemas fail
453/// with actionable re-sync guidance.
454pub fn load(root: &Path) -> Result<LockFile, MarsError> {
455    let (lock, _) = load_with_diagnostics(root)?;
456    Ok(lock)
457}
458
459/// Load lock for runtime alias commands (`models list/resolve`, launch bundle routing).
460///
461/// Legacy v2 lock files created before dependency aliases were moved into `mars.lock`
462/// may omit `dependency_model_aliases` entirely. When dependency entries exist, runtime
463/// alias consumers must fail closed so dependency alias authority is not silently treated
464/// as empty.
465pub fn load_for_runtime_aliases(root: &Path) -> Result<LockFile, MarsError> {
466    let path = root.join(LOCK_FILE);
467    let content = match std::fs::read_to_string(&path) {
468        Ok(c) => c,
469        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(LockFile::empty()),
470        Err(e) => return Err(LockError::Io(e).into()),
471    };
472
473    let value: toml::Value = toml::from_str(&content).map_err(|e| LockError::Corrupt {
474        message: format!("failed to parse {}: {e}", path.display()),
475    })?;
476
477    let has_dependency_alias_field = value
478        .as_table()
479        .map(|table| table.contains_key("dependency_model_aliases"))
480        .unwrap_or(false);
481
482    let (lock, _) = load_with_diagnostics(root)?;
483
484    if !has_dependency_alias_field && !lock.dependencies.is_empty() {
485        return Err(LockError::Corrupt {
486            message: format!(
487                "legacy {} is missing `dependency_model_aliases` for dependency alias authority; run `{}` to update it",
488                LOCK_FILE,
489                crate::types::managed_cmd("mars sync")
490            ),
491        }
492        .into());
493    }
494
495    Ok(lock)
496}
497
498/// Load the lock file and return any diagnostics produced while reading it.
499///
500/// Version 2 locks are promoted in memory so one-release cleanup bridges can
501/// inspect their ownership records. Other version and schema failures are
502/// returned as actionable lock errors.
503pub fn load_with_diagnostics(root: &Path) -> Result<(LockFile, Vec<Diagnostic>), MarsError> {
504    let path = root.join(LOCK_FILE);
505    let content = match std::fs::read_to_string(&path) {
506        Ok(c) => c,
507        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
508            return Ok((LockFile::empty(), Vec::new()));
509        }
510        Err(e) => return Err(LockError::Io(e).into()),
511    };
512
513    let value: toml::Value = toml::from_str(&content).map_err(|e| LockError::Corrupt {
514        message: format!("failed to parse {}: {e}", path.display()),
515    })?;
516    let version = value
517        .get("version")
518        .and_then(toml::Value::as_integer)
519        .ok_or_else(|| LockError::Corrupt {
520            message: format!("{} has no integer lock version", path.display()),
521        })?;
522    match version {
523        3 => {
524            let wire: LockFileWire = value.try_into().map_err(|error| LockError::Corrupt {
525                message: format!(
526                    "failed to parse {} lock version {LOCK_VERSION}: {error}",
527                    path.display()
528                ),
529            })?;
530            Ok((
531                LockFile {
532                    version: wire.version,
533                    dependencies: wire.dependencies,
534                    items: wire.items,
535                    config_entries: wire.config_entries,
536                    dependency_model_aliases: wire.dependency_model_aliases,
537                },
538                Vec::new(),
539            ))
540        }
541        2 => {
542            let wire: LockFileV2Wire =
543                value.try_into().map_err(|error| LockError::Corrupt {
544                    message: format!("failed to parse {} lock version 2: {error}", path.display()),
545                })?;
546            Ok((promote_v2_lock(root, wire), Vec::new()))
547        }
548        older if older < i64::from(LOCK_VERSION) => Err(LockError::Corrupt {
549            message: format!(
550                "{} uses unsupported lock version {older}; remove it and run `{}` (only version 2 can be promoted to version {LOCK_VERSION})",
551                path.display(),
552                crate::types::managed_cmd("mars sync")
553            ),
554        }
555        .into()),
556        newer => Err(LockError::Corrupt {
557            message: format!(
558                "{} uses unsupported lock version {newer}; this Mars supports version {LOCK_VERSION}",
559                path.display()
560            ),
561        }
562        .into()),
563    }
564}
565
566/// Cross the untyped v2 output boundary exactly once.
567///
568/// A v2 checksum could describe either installed content or a retry tombstone left
569/// after failed removal. The output's actual disk shape selects the canonical file
570/// or directory hash. Only matching regular content is promoted as installed;
571/// every other path retains deletion authority without asserting ghost content.
572fn promote_v2_lock(root: &Path, wire: LockFileV2Wire) -> LockFile {
573    let items = wire
574        .items
575        .into_iter()
576        .map(|(key, item)| {
577            let outputs = item
578                .outputs
579                .into_iter()
580                .map(|output| {
581                    let path = root
582                        .join(&output.target_root)
583                        .join(output.dest_path.as_str());
584                    let matches_disk = v2_output_checksum(&path)
585                        .is_some_and(|checksum| checksum == output.installed_checksum.as_ref());
586                    if matches_disk {
587                        OutputRecord::installed(
588                            output.target_root,
589                            output.dest_path,
590                            output.installed_checksum,
591                        )
592                    } else {
593                        OutputRecord::pending_deletion(output.target_root, output.dest_path)
594                    }
595                })
596                .collect();
597            (
598                key,
599                LockedItemV2 {
600                    source: item.source,
601                    kind: item.kind,
602                    version: item.version,
603                    source_checksum: item.source_checksum,
604                    outputs,
605                },
606            )
607        })
608        .collect();
609
610    LockFile {
611        version: LOCK_VERSION,
612        dependencies: wire.dependencies,
613        items,
614        config_entries: wire.config_entries,
615        dependency_model_aliases: wire.dependency_model_aliases,
616    }
617}
618
619fn v2_output_checksum(path: &Path) -> Option<String> {
620    let metadata = std::fs::symlink_metadata(path).ok()?;
621    let file_type = metadata.file_type();
622    if file_type.is_symlink() {
623        return None;
624    }
625    if file_type.is_file() {
626        return std::fs::read(path)
627            .ok()
628            .map(|bytes| crate::hash::hash_bytes(&bytes));
629    }
630    if file_type.is_dir() {
631        if !has_only_regular_file_entries(path) {
632            return None;
633        }
634        return crate::hash::compute_dir_hash(path).ok();
635    }
636    None
637}
638
639/// Validate a directory without following links or opening entry contents.
640fn has_only_regular_file_entries(dir: &Path) -> bool {
641    let Ok(entries) = std::fs::read_dir(dir) else {
642        return false;
643    };
644    for entry in entries {
645        let Ok(entry) = entry else {
646            return false;
647        };
648        let path = entry.path();
649        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
650            return false;
651        };
652        let file_type = metadata.file_type();
653        if file_type.is_dir() {
654            if !has_only_regular_file_entries(&path) {
655                return false;
656            }
657        } else if !file_type.is_file() {
658            return false;
659        }
660    }
661    true
662}
663
664/// Write the lock file atomically to the given root directory (always current format).
665pub fn write(root: &Path, lock: &LockFile) -> Result<(), MarsError> {
666    let path = root.join(LOCK_FILE);
667    let mut normalized = lock.clone();
668    normalized.version = LOCK_VERSION;
669    normalized.dependencies.sort_keys();
670    normalized.items.sort_keys();
671    normalized.dependency_model_aliases.sort_keys();
672
673    let content = toml::to_string_pretty(&normalized).map_err(|e| LockError::Corrupt {
674        message: format!("failed to serialize lock file: {e}"),
675    })?;
676    crate::fs::atomic_write_if_changed(&path, content.as_bytes()).map(|_| ())
677}
678
679// ---------------------------------------------------------------------------
680// Build
681// ---------------------------------------------------------------------------
682
683/// Build a new lock file from resolved graph + apply results.
684///
685/// Constructs the lock file from the graph (source provenance) and
686/// the apply outcomes (checksums). Items that were skipped, kept, or
687/// merged retain their provenance from the graph. Removed items are excluded.
688pub fn build(
689    graph: &crate::resolve::ResolvedGraph,
690    applied: &crate::sync::apply::ApplyResult,
691    old_lock: &LockFile,
692    config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
693) -> Result<LockFile, MarsError> {
694    use crate::sync::apply::ActionTaken;
695
696    let mut dependencies = IndexMap::new();
697    let mut items: IndexMap<String, LockedItemV2> = IndexMap::new();
698    let old_lock_index = LockIndex::new(old_lock);
699
700    for outcome in &applied.outcomes {
701        match outcome.action {
702            ActionTaken::Installed | ActionTaken::Updated => {
703                let installed =
704                    outcome
705                        .installed_checksum
706                        .as_ref()
707                        .ok_or_else(|| LockError::Corrupt {
708                            message: format!(
709                                "missing checksum for write-producing action on {}",
710                                outcome.dest_path
711                            ),
712                        })?;
713                if checksum_is_empty(installed) {
714                    return Err(LockError::Corrupt {
715                        message: format!("empty installed_checksum for {}", outcome.dest_path),
716                    }
717                    .into());
718                }
719
720                let source =
721                    outcome
722                        .source_checksum
723                        .as_ref()
724                        .ok_or_else(|| LockError::Corrupt {
725                            message: format!(
726                                "missing source checksum for write-producing action on {}",
727                                outcome.dest_path
728                            ),
729                        })?;
730                if checksum_is_empty(source) {
731                    return Err(LockError::Corrupt {
732                        message: format!("empty source_checksum for {}", outcome.dest_path),
733                    }
734                    .into());
735                }
736            }
737            ActionTaken::Removed | ActionTaken::Skipped | ActionTaken::Kept => {}
738        }
739    }
740
741    // Build dependency entries directly from resolved graph provenance.
742    for (name, node) in &graph.nodes {
743        dependencies.insert(name.clone(), to_locked_source(node));
744    }
745
746    // Build item entries from apply outcomes.
747    for outcome in &applied.outcomes {
748        match &outcome.action {
749            ActionTaken::Removed | ActionTaken::Skipped => {
750                // For skipped items, carry forward from old lock
751                if matches!(outcome.action, ActionTaken::Skipped) {
752                    let item_key = item_key(&outcome.item_id);
753                    if let Some(old_item) = old_lock.items.get(&item_key) {
754                        items.insert(item_key, old_item.clone());
755                    } else {
756                        // Fall back: search old lock by dest_path when the logical item key differs
757                        if let Some((_, old_item, old_output)) = old_lock_index
758                            .item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
759                        {
760                            let key = format!(
761                                "{}/{}",
762                                old_item.kind,
763                                outcome.dest_path.item_name(old_item.kind)
764                            );
765                            items.entry(key).or_insert_with(|| LockedItemV2 {
766                                source: old_item.source.clone(),
767                                kind: old_item.kind,
768                                version: old_item.version.clone(),
769                                source_checksum: old_item.source_checksum.clone(),
770                                outputs: outputs_with_carried_non_canonical(
771                                    Some(old_item),
772                                    OutputRecord::installed(
773                                        CANONICAL_TARGET_ROOT.to_string(),
774                                        old_output.dest_path.clone(),
775                                        old_output
776                                            .installed_checksum()
777                                            .expect("canonical output is installed")
778                                            .clone(),
779                                    ),
780                                ),
781                            });
782                        }
783                    }
784                }
785                // Removed items are excluded from the new lock.
786            }
787            ActionTaken::Kept => {
788                // Keep local: carry forward old lock entry.
789                let item_key = item_key(&outcome.item_id);
790                if let Some(old_item) = old_lock.items.get(&item_key) {
791                    items.insert(item_key, old_item.clone());
792                } else if let Some((_, old_item, old_output)) =
793                    old_lock_index.item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
794                {
795                    let key = format!(
796                        "{}/{}",
797                        old_item.kind,
798                        outcome.dest_path.item_name(old_item.kind)
799                    );
800                    items.entry(key).or_insert_with(|| LockedItemV2 {
801                        source: old_item.source.clone(),
802                        kind: old_item.kind,
803                        version: old_item.version.clone(),
804                        source_checksum: old_item.source_checksum.clone(),
805                        outputs: outputs_with_carried_non_canonical(
806                            Some(old_item),
807                            OutputRecord::installed(
808                                CANONICAL_TARGET_ROOT.to_string(),
809                                old_output.dest_path.clone(),
810                                old_output
811                                    .installed_checksum()
812                                    .expect("canonical output is installed")
813                                    .clone(),
814                            ),
815                        ),
816                    });
817                }
818            }
819            ActionTaken::Installed | ActionTaken::Updated => {
820                let dest_path = outcome.dest_path.clone();
821                if dest_path.as_str().is_empty() {
822                    continue;
823                }
824
825                // Use source_name from outcome (propagated from TargetItem)
826                let source_name = if outcome.source_name.as_ref().is_empty() {
827                    None
828                } else {
829                    Some(outcome.source_name.clone())
830                };
831
832                // Determine version from graph
833                let version = source_name.as_ref().and_then(|sn| {
834                    graph
835                        .nodes
836                        .get(sn)
837                        .and_then(|n| n.resolved_ref.version_tag.clone())
838                });
839
840                let source_checksum = outcome
841                    .source_checksum
842                    .clone()
843                    .expect("validated above: source_checksum exists for write actions");
844                let installed_checksum = outcome
845                    .installed_checksum
846                    .clone()
847                    .expect("validated above: installed_checksum exists for write actions");
848
849                let key = item_key(&outcome.item_id);
850                let old_item = old_lock.items.get(&key).or_else(|| {
851                    old_lock_index
852                        .item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
853                        .map(|(_, old_item, _)| old_item)
854                });
855                let outputs = outputs_with_carried_non_canonical(
856                    old_item,
857                    OutputRecord::installed(
858                        CANONICAL_TARGET_ROOT.to_string(),
859                        dest_path,
860                        installed_checksum,
861                    ),
862                );
863                items.insert(
864                    key,
865                    LockedItemV2 {
866                        source: source_name.unwrap_or_else(|| SourceName::from("")),
867                        kind: outcome.item_id.kind,
868                        version,
869                        source_checksum,
870                        outputs,
871                    },
872                );
873            }
874        }
875    }
876
877    // Add synthetic _self source if any local package items exist.
878    let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
879    let has_self_items = items.values().any(|item| item.source == local_source_name);
880    if has_self_items {
881        dependencies.insert(
882            local_source_name,
883            LockedSource {
884                url: None,
885                path: Some(".".into()),
886                subpath: None,
887                version: None,
888                commit: None,
889            },
890        );
891    }
892
893    // Validate checksums.
894    for item in items.values() {
895        if checksum_is_empty(&item.source_checksum) {
896            let dest = item
897                .outputs
898                .first()
899                .map(|o| o.dest_path.to_string())
900                .unwrap_or_default();
901            return Err(LockError::Corrupt {
902                message: format!("empty source_checksum for {dest}"),
903            }
904            .into());
905        }
906        for output in &item.outputs {
907            if output.installed_checksum().is_some_and(checksum_is_empty) {
908                return Err(LockError::Corrupt {
909                    message: format!("empty installed_checksum for {}", output.dest_path),
910                }
911                .into());
912            }
913        }
914    }
915
916    // Sort keys for deterministic output.
917    dependencies.sort_keys();
918    items.sort_keys();
919
920    Ok(LockFile {
921        version: LOCK_VERSION,
922        dependencies,
923        items,
924        config_entries,
925        dependency_model_aliases: IndexMap::new(),
926    })
927}
928
929fn outputs_with_carried_non_canonical(
930    old_item: Option<&LockedItemV2>,
931    canonical_output: OutputRecord,
932) -> Vec<OutputRecord> {
933    let mut outputs = vec![canonical_output];
934    if let Some(old_item) = old_item {
935        for old_output in &old_item.outputs {
936            if old_output.target_root != CANONICAL_TARGET_ROOT {
937                outputs.push(old_output.clone());
938            }
939        }
940    }
941    outputs
942}
943
944/// Lock view for native emission immediately after apply + target sync.
945///
946/// Seeds canonical `.mars` items from the current apply pass, then layers
947/// per-target sync outputs so `copy_decision` treats freshly synced paths as
948/// managed. Full lock rebuild happens in `finalize()`; this path avoids a
949/// graph walk while still covering first-sync agents absent from `old_lock`.
950pub fn ownership_lock_for_native_emission(
951    old_lock: &LockFile,
952    apply_outcomes: &[crate::sync::apply::ActionOutcome],
953    target_outcomes: &[crate::target_sync::TargetSyncOutcome],
954) -> LockFile {
955    let mut lock = old_lock.clone();
956    apply_apply_outcomes_to_lock(&mut lock, old_lock, apply_outcomes);
957    apply_target_sync_outputs(&mut lock, target_outcomes);
958    lock
959}
960
961/// Lock view for native emission after `mars link` target sync.
962///
963/// The persisted lock already reflects canonical items; only target-sync outputs
964/// from the link pass need to be layered on for ownership checks.
965pub fn ownership_lock_after_target_sync(
966    old_lock: &LockFile,
967    target_outcomes: &[crate::target_sync::TargetSyncOutcome],
968) -> LockFile {
969    let mut lock = old_lock.clone();
970    apply_target_sync_outputs(&mut lock, target_outcomes);
971    lock
972}
973
974/// Merge current apply outcomes into a lock view for ownership checks.
975///
976/// Write actions upsert canonical `.mars` outputs; removals drop the item;
977/// skipped/kept entries carry forward from `old_lock` when the clone lacks them.
978pub fn apply_apply_outcomes_to_lock(
979    lock: &mut LockFile,
980    old_lock: &LockFile,
981    outcomes: &[crate::sync::apply::ActionOutcome],
982) {
983    use crate::sync::apply::ActionTaken;
984
985    let old_lock_index = LockIndex::new(old_lock);
986    for outcome in outcomes {
987        match outcome.action {
988            ActionTaken::Removed => {
989                lock.items.shift_remove(&item_key(&outcome.item_id));
990            }
991            ActionTaken::Skipped => {
992                let key = item_key(&outcome.item_id);
993                if lock.items.contains_key(&key) {
994                    continue;
995                }
996                if let Some(old_item) = old_lock.items.get(&key) {
997                    lock.items.insert(key, old_item.clone());
998                } else if let Some(flat) =
999                    old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
1000                {
1001                    let key = format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
1002                    lock.items.entry(key).or_insert_with(|| LockedItemV2 {
1003                        source: flat.source,
1004                        kind: flat.kind,
1005                        version: flat.version,
1006                        source_checksum: flat.source_checksum,
1007                        outputs: vec![OutputRecord::installed(
1008                            CANONICAL_TARGET_ROOT.to_string(),
1009                            flat.dest_path,
1010                            flat.installed_checksum,
1011                        )],
1012                    });
1013                }
1014            }
1015            ActionTaken::Kept => {
1016                let key = item_key(&outcome.item_id);
1017                if let Some(old_item) = old_lock.items.get(&key) {
1018                    lock.items.insert(key, old_item.clone());
1019                } else if let Some(flat) =
1020                    old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
1021                {
1022                    let key = format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
1023                    lock.items.entry(key).or_insert_with(|| LockedItemV2 {
1024                        source: flat.source,
1025                        kind: flat.kind,
1026                        version: flat.version,
1027                        source_checksum: flat.source_checksum,
1028                        outputs: vec![OutputRecord::installed(
1029                            CANONICAL_TARGET_ROOT.to_string(),
1030                            flat.dest_path,
1031                            flat.installed_checksum,
1032                        )],
1033                    });
1034                }
1035            }
1036            ActionTaken::Installed | ActionTaken::Updated => {
1037                if outcome.dest_path.as_str().is_empty() {
1038                    continue;
1039                }
1040                let Some(source_checksum) = outcome
1041                    .source_checksum
1042                    .as_ref()
1043                    .filter(|checksum| !checksum_is_empty(checksum))
1044                else {
1045                    continue;
1046                };
1047                let Some(installed_checksum) = outcome
1048                    .installed_checksum
1049                    .as_ref()
1050                    .filter(|checksum| !checksum_is_empty(checksum))
1051                else {
1052                    continue;
1053                };
1054
1055                let source_name = if outcome.source_name.as_ref().is_empty() {
1056                    SourceName::from("")
1057                } else {
1058                    outcome.source_name.clone()
1059                };
1060
1061                let key = item_key(&outcome.item_id);
1062                let old_entry = old_lock
1063                    .items
1064                    .get(&key)
1065                    .map(|old_item| (key.as_str(), old_item))
1066                    .or_else(|| {
1067                        old_lock_index
1068                            .item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
1069                            .map(|(old_key, old_item, _)| (old_key, old_item))
1070                    });
1071                let old_key = old_entry.map(|(old_key, _)| old_key.to_string());
1072                let outputs = outputs_with_carried_non_canonical(
1073                    old_entry.map(|(_, old_item)| old_item),
1074                    OutputRecord::installed(
1075                        CANONICAL_TARGET_ROOT.to_string(),
1076                        outcome.dest_path.clone(),
1077                        installed_checksum.clone(),
1078                    ),
1079                );
1080                if let Some(old_key) = old_key
1081                    && old_key != key
1082                {
1083                    lock.items.shift_remove(&old_key);
1084                }
1085                lock.items.insert(
1086                    key,
1087                    LockedItemV2 {
1088                        source: source_name,
1089                        kind: outcome.item_id.kind,
1090                        version: None,
1091                        source_checksum: source_checksum.clone(),
1092                        outputs,
1093                    },
1094                );
1095            }
1096        }
1097    }
1098}
1099
1100/// Merge per-target sync results into a built lock file.
1101pub fn apply_target_sync_outputs(
1102    lock: &mut LockFile,
1103    target_outcomes: &[crate::target_sync::TargetSyncOutcome],
1104) {
1105    for outcome in target_outcomes {
1106        for dest_path in &outcome.removed_dest_paths {
1107            remove_target_output(lock, &outcome.target, dest_path);
1108        }
1109        for synced in &outcome.synced_outputs {
1110            upsert_target_output(
1111                lock,
1112                &outcome.target,
1113                &synced.dest_path,
1114                &synced.installed_checksum,
1115            );
1116        }
1117    }
1118}
1119
1120/// Native harness output recorded in the lock for a canonical `.mars` agent item.
1121#[derive(Debug, Clone, PartialEq, Eq)]
1122pub struct CompiledNativeOutput {
1123    /// Canonical `.mars` dest path for the owning agent (e.g. `agents/coder.md`).
1124    pub owner_canonical_dest_path: String,
1125    pub target_root: String,
1126    pub dest_path: String,
1127    pub installed_checksum: ContentHash,
1128}
1129
1130/// Whether a freshly compiled native output is new or content-changed vs the
1131/// previous lock at the same `(target_root, dest_path)`. Lets the sync summary
1132/// count only real emissions — steady-state re-emits don't inflate the count.
1133pub fn native_output_is_new_or_changed(old: &LockFile, out: &CompiledNativeOutput) -> bool {
1134    for item in old.items.values() {
1135        for output in &item.outputs {
1136            if output.target_root == out.target_root
1137                && crate::target::dest_paths_equivalent(output.dest_path.as_str(), &out.dest_path)
1138            {
1139                return output.installed_checksum() != Some(&out.installed_checksum);
1140            }
1141        }
1142    }
1143    true
1144}
1145
1146/// Drop native harness output records removed by native agent reconcile.
1147pub fn apply_removed_native_outputs(lock: &mut LockFile, records: &[(String, String)]) {
1148    for (target_root, dest_path) in records {
1149        remove_target_output(lock, target_root, dest_path);
1150    }
1151}
1152
1153/// Record native harness outputs produced by dual-surface compile.
1154pub fn apply_compiled_native_outputs(
1155    lock: &mut LockFile,
1156    records: &[CompiledNativeOutput],
1157) -> Result<(), LockError> {
1158    for record in records {
1159        if !upsert_native_output_on_owner(
1160            lock,
1161            &record.owner_canonical_dest_path,
1162            &record.target_root,
1163            &record.dest_path,
1164            &record.installed_checksum,
1165        ) {
1166            return Err(LockError::Corrupt {
1167                message: format!(
1168                    "native output `{}/{}` has no canonical owner `{}`",
1169                    record.target_root, record.dest_path, record.owner_canonical_dest_path
1170                ),
1171            });
1172        }
1173    }
1174    Ok(())
1175}
1176
1177/// Preserve unresolved noncanonical removal authority as a retry tombstone.
1178///
1179/// A rebuilt lock omits canonical items removed from the source graph. Their
1180/// linked-target artifacts can outlive that removal when filesystem deletion
1181/// fails, so the unresolved linked outputs must remain owned until removal
1182/// succeeds.
1183pub fn retain_unremoved_noncanonical_outputs(
1184    lock: &mut LockFile,
1185    old_lock: &LockFile,
1186    removed: &[(String, String)],
1187) {
1188    for (old_key, old_item) in &old_lock.items {
1189        let unresolved: Vec<_> = old_item
1190            .outputs
1191            .iter()
1192            .filter(|output| output.target_root != CANONICAL_TARGET_ROOT)
1193            .filter(|output| {
1194                !removed.iter().any(|(target_root, dest_path)| {
1195                    output.target_root == *target_root
1196                        && crate::target::dest_paths_equivalent(
1197                            output.dest_path.as_str(),
1198                            dest_path,
1199                        )
1200                })
1201            })
1202            .filter(|output| !lock.contains_output(&output.target_root, output.dest_path.as_str()))
1203            .map(|output| {
1204                OutputRecord::pending_deletion(output.target_root.clone(), output.dest_path.clone())
1205            })
1206            .collect();
1207        if unresolved.is_empty() {
1208            continue;
1209        }
1210
1211        let item = lock
1212            .items
1213            .entry(old_key.clone())
1214            .or_insert_with(|| LockedItemV2 {
1215                source: old_item.source.clone(),
1216                kind: old_item.kind,
1217                version: old_item.version.clone(),
1218                source_checksum: old_item.source_checksum.clone(),
1219                // A retry tombstone may carry only unresolved noncanonical outputs.
1220                // It must never resurrect an old canonical output: only the current
1221                // apply pass can grant canonical ownership and deletion authority.
1222                outputs: Vec::new(),
1223            });
1224        item.outputs.extend(unresolved);
1225        item.outputs.sort_by(|a, b| {
1226            a.target_root
1227                .cmp(&b.target_root)
1228                .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
1229        });
1230        item.outputs.dedup_by(|a, b| {
1231            a.target_root == b.target_root
1232                && crate::target::dest_paths_equivalent(a.dest_path.as_str(), b.dest_path.as_str())
1233        });
1234    }
1235}
1236
1237fn upsert_target_output(
1238    lock: &mut LockFile,
1239    target_root: &str,
1240    dest_path: &str,
1241    installed_checksum: &ContentHash,
1242) {
1243    let dest = DestPath::from(dest_path);
1244    let scoped_hook_owner = dest_path.strip_prefix("hooks/").map(|hook_name| {
1245        format!(
1246            "hooks/{}/{}",
1247            target_root.trim_start_matches('.'),
1248            hook_name
1249        )
1250    });
1251    for item in lock.items.values_mut() {
1252        let owns_output = if item.kind == ItemKind::Hook {
1253            item.outputs.iter().any(|output| {
1254                scoped_hook_owner.as_deref().is_some_and(|owner| {
1255                    output.target_root == CANONICAL_TARGET_ROOT
1256                        && crate::target::dest_paths_equivalent(output.dest_path.as_str(), owner)
1257                })
1258            })
1259        } else {
1260            item.outputs.iter().any(|output| {
1261                (output.target_root == target_root || output.target_root == CANONICAL_TARGET_ROOT)
1262                    && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
1263            })
1264        };
1265        if !owns_output {
1266            continue;
1267        }
1268
1269        if let Some(output) = item.outputs.iter_mut().find(|output| {
1270            output.target_root == target_root
1271                && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
1272        }) {
1273            output.mark_installed(installed_checksum.clone());
1274            return;
1275        }
1276
1277        item.outputs.push(OutputRecord::installed(
1278            target_root.to_string(),
1279            dest,
1280            installed_checksum.clone(),
1281        ));
1282        item.outputs.sort_by(|a, b| {
1283            a.target_root
1284                .cmp(&b.target_root)
1285                .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
1286        });
1287        return;
1288    }
1289}
1290
1291fn upsert_native_output_on_owner(
1292    lock: &mut LockFile,
1293    owner_canonical_dest_path: &str,
1294    target_root: &str,
1295    native_dest_path: &str,
1296    installed_checksum: &ContentHash,
1297) -> bool {
1298    let native_dest = DestPath::from(native_dest_path);
1299    for item in lock.items.values_mut() {
1300        let owns_canonical = item.outputs.iter().any(|output| {
1301            output.target_root == CANONICAL_TARGET_ROOT
1302                && crate::target::dest_paths_equivalent(
1303                    output.dest_path.as_str(),
1304                    owner_canonical_dest_path,
1305                )
1306        });
1307        if !owns_canonical {
1308            continue;
1309        }
1310
1311        if let Some(output) = item.outputs.iter_mut().find(|output| {
1312            output.target_root == target_root
1313                && crate::target::dest_paths_equivalent(output.dest_path.as_str(), native_dest_path)
1314        }) {
1315            output.mark_installed(installed_checksum.clone());
1316            return true;
1317        }
1318
1319        item.outputs.push(OutputRecord::installed(
1320            target_root.to_string(),
1321            native_dest,
1322            installed_checksum.clone(),
1323        ));
1324        item.outputs.sort_by(|a, b| {
1325            a.target_root
1326                .cmp(&b.target_root)
1327                .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
1328        });
1329        return true;
1330    }
1331    false
1332}
1333
1334fn remove_target_output(lock: &mut LockFile, target_root: &str, dest_path: &str) {
1335    for item in lock.items.values_mut() {
1336        item.outputs.retain(|output| {
1337            !(output.target_root == target_root
1338                && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path))
1339        });
1340    }
1341    lock.items.retain(|_, item| !item.outputs.is_empty());
1342}
1343
1344// ---------------------------------------------------------------------------
1345// Helpers
1346// ---------------------------------------------------------------------------
1347
1348fn checksum_is_empty(checksum: &ContentHash) -> bool {
1349    checksum.as_ref().trim().is_empty()
1350}
1351
1352fn to_locked_source(node: &crate::resolve::ResolvedNode) -> LockedSource {
1353    let (url, path, subpath) = match &node.source_id {
1354        SourceId::Git { url, subpath } => (Some(url.clone()), None, subpath.clone()),
1355        SourceId::Path { canonical, subpath } => (
1356            None,
1357            Some(canonical.to_string_lossy().to_string()),
1358            subpath.clone(),
1359        ),
1360    };
1361
1362    LockedSource {
1363        url,
1364        path,
1365        subpath,
1366        version: node.resolved_ref.version_tag.clone(),
1367        commit: node.resolved_ref.commit.clone(),
1368    }
1369}
1370
1371/// Canonical item key for v2 lock: `"kind/name"`.
1372pub fn item_key(id: &ItemId) -> String {
1373    format!("{}/{}", id.kind, id.name)
1374}
1375
1376// ---------------------------------------------------------------------------
1377// Tests
1378// ---------------------------------------------------------------------------
1379
1380#[cfg(test)]
1381mod tests {
1382    use super::*;
1383    use std::collections::HashMap;
1384    use std::path::PathBuf;
1385
1386    use crate::resolve::{ResolvedGraph, ResolvedNode};
1387    use crate::source::ResolvedRef;
1388    use crate::sync::apply::{ActionOutcome, ActionTaken, ApplyResult};
1389    use crate::types::{ItemName, SourceId, SourceUrl};
1390    use tempfile::TempDir;
1391
1392    fn sample_lock() -> LockFile {
1393        let mut dependencies = IndexMap::new();
1394        dependencies.insert(
1395            "base".into(),
1396            LockedSource {
1397                url: Some("https://github.com/org/base.git".into()),
1398                path: None,
1399                subpath: None,
1400                version: Some("v1.0.0".into()),
1401                commit: Some("abc123".into()),
1402            },
1403        );
1404
1405        let mut items = IndexMap::new();
1406        items.insert(
1407            "agent/coder".to_string(),
1408            LockedItemV2 {
1409                source: "base".into(),
1410                kind: ItemKind::Agent,
1411                version: Some("v1.0.0".into()),
1412                source_checksum: "sha256:aaa".into(),
1413                outputs: vec![OutputRecord::installed(
1414                    ".mars".to_string(),
1415                    "agents/coder.md".into(),
1416                    "sha256:bbb".into(),
1417                )],
1418            },
1419        );
1420        items.insert(
1421            "skill/review".to_string(),
1422            LockedItemV2 {
1423                source: "base".into(),
1424                kind: ItemKind::Skill,
1425                version: Some("v1.0.0".into()),
1426                source_checksum: "sha256:ccc".into(),
1427                outputs: vec![OutputRecord::installed(
1428                    ".mars".to_string(),
1429                    "skills/review".into(),
1430                    "sha256:ddd".into(),
1431                )],
1432            },
1433        );
1434
1435        LockFile {
1436            version: LOCK_VERSION,
1437            dependencies,
1438            items,
1439            config_entries: BTreeMap::new(),
1440            dependency_model_aliases: IndexMap::new(),
1441        }
1442    }
1443
1444    #[test]
1445    fn v1_lock_version_has_actionable_error() {
1446        let dir = TempDir::new().unwrap();
1447        std::fs::write(dir.path().join("mars.lock"), "version = 1\n").unwrap();
1448
1449        let error = load(dir.path()).unwrap_err().to_string();
1450
1451        assert!(error.contains("unsupported lock version 1"));
1452        assert!(error.contains("only version 2 can be promoted"));
1453        assert!(error.contains(&format!(
1454            "remove it and run `{}`",
1455            crate::types::managed_cmd("mars sync")
1456        )));
1457    }
1458
1459    #[test]
1460    fn v2_output_matching_regular_file_promotes_to_installed() {
1461        let dir = TempDir::new().unwrap();
1462        let output = dir.path().join(".mars/agents/coder.md");
1463        std::fs::create_dir_all(output.parent().unwrap()).unwrap();
1464        std::fs::write(&output, "managed").unwrap();
1465        let checksum = crate::hash::hash_bytes(b"managed");
1466        std::fs::write(
1467            dir.path().join("mars.lock"),
1468            format!(
1469                r#"
1470version = 2
1471
1472[items."agent/coder"]
1473source = "_self"
1474kind = "agent"
1475source_checksum = "{checksum}"
1476
1477[[items."agent/coder".outputs]]
1478target_root = ".mars"
1479dest_path = "agents/coder.md"
1480installed_checksum = "{checksum}"
1481"#
1482            ),
1483        )
1484        .unwrap();
1485
1486        let lock = load(dir.path()).unwrap();
1487        let output = &lock.items["agent/coder"].outputs[0];
1488
1489        assert_eq!(lock.version, LOCK_VERSION);
1490        assert!(matches!(output.state, OutputState::Installed { .. }));
1491    }
1492
1493    #[test]
1494    fn v2_output_absent_on_disk_promotes_to_pending_deletion() {
1495        let dir = TempDir::new().unwrap();
1496        std::fs::write(
1497            dir.path().join("mars.lock"),
1498            r#"
1499version = 2
1500
1501[items."hook/audit"]
1502source = "_self"
1503kind = "hook"
1504source_checksum = "sha256:source"
1505
1506[[items."hook/audit".outputs]]
1507target_root = ".opencode"
1508dest_path = "plugins/mars-audit.ts"
1509installed_checksum = "sha256:old"
1510"#,
1511        )
1512        .unwrap();
1513
1514        let lock = load(dir.path()).unwrap();
1515
1516        assert!(matches!(
1517            lock.items["hook/audit"].outputs[0].state,
1518            OutputState::PendingDeletion
1519        ));
1520    }
1521
1522    #[cfg(unix)]
1523    #[test]
1524    fn v2_directory_output_with_nested_dangling_symlink_has_no_checksum() {
1525        use std::os::unix::fs::symlink;
1526
1527        let dir = TempDir::new().unwrap();
1528        let output = dir.path().join("skill");
1529        std::fs::create_dir(&output).unwrap();
1530        std::fs::write(output.join("SKILL.md"), "# Skill").unwrap();
1531        symlink("missing.md", output.join("reference.md")).unwrap();
1532
1533        assert_eq!(v2_output_checksum(&output), None);
1534    }
1535
1536    #[cfg(unix)]
1537    #[test]
1538    fn v2_directory_output_with_nested_directory_symlink_has_no_checksum() {
1539        use std::os::unix::fs::symlink;
1540
1541        let dir = TempDir::new().unwrap();
1542        let output = dir.path().join("skill");
1543        let external = dir.path().join("external");
1544        std::fs::create_dir(&output).unwrap();
1545        std::fs::create_dir(&external).unwrap();
1546        std::fs::write(output.join("SKILL.md"), "# Skill").unwrap();
1547        std::fs::write(external.join("reference.md"), "# Reference").unwrap();
1548        symlink(&external, output.join("references")).unwrap();
1549
1550        assert_eq!(v2_output_checksum(&output), None);
1551    }
1552
1553    #[cfg(unix)]
1554    #[test]
1555    fn v2_directory_output_with_nested_fifo_returns_without_opening_it() {
1556        let dir = TempDir::new().unwrap();
1557        let output = dir.path().join("skill");
1558        std::fs::create_dir(&output).unwrap();
1559        std::fs::write(output.join("SKILL.md"), "# Skill").unwrap();
1560        let status = std::process::Command::new("mkfifo")
1561            .arg(output.join("events"))
1562            .status()
1563            .unwrap();
1564        assert!(status.success());
1565
1566        let started = std::time::Instant::now();
1567        assert_eq!(v2_output_checksum(&output), None);
1568        assert!(
1569            started.elapsed() < std::time::Duration::from_secs(1),
1570            "shape validation must not open and block on the FIFO"
1571        );
1572    }
1573
1574    #[cfg(unix)]
1575    #[test]
1576    fn v2_promotion_classifies_outputs_by_disk_shape_and_checksum() {
1577        use std::os::unix::fs::{PermissionsExt, symlink};
1578
1579        let dir = TempDir::new().unwrap();
1580        let root = dir.path();
1581        let outputs = root.join(".mars/outputs");
1582        std::fs::create_dir_all(&outputs).unwrap();
1583
1584        std::fs::write(outputs.join("file-match"), "managed").unwrap();
1585        std::fs::write(outputs.join("file-mismatch"), "changed").unwrap();
1586        std::fs::create_dir(outputs.join("dir-match")).unwrap();
1587        std::fs::write(outputs.join("dir-match/SKILL.md"), "# Managed").unwrap();
1588        std::fs::create_dir(outputs.join("dir-mismatch")).unwrap();
1589        std::fs::write(outputs.join("dir-mismatch/SKILL.md"), "# Changed").unwrap();
1590        std::fs::write(outputs.join("symlink-target"), "managed").unwrap();
1591        symlink("symlink-target", outputs.join("symlink")).unwrap();
1592        std::fs::write(outputs.join("unreadable"), "managed").unwrap();
1593        std::fs::set_permissions(
1594            outputs.join("unreadable"),
1595            std::fs::Permissions::from_mode(0o000),
1596        )
1597        .unwrap();
1598
1599        let file_checksum = crate::hash::hash_bytes(b"managed");
1600        let directory_checksum =
1601            crate::hash::compute_hash(&outputs.join("dir-match"), ItemKind::Skill).unwrap();
1602        let cases = [
1603            ("file-match", &file_checksum),
1604            ("file-mismatch", &file_checksum),
1605            ("dir-match", &directory_checksum),
1606            ("dir-mismatch", &directory_checksum),
1607            ("absent", &file_checksum),
1608            ("symlink", &file_checksum),
1609            ("unreadable", &file_checksum),
1610        ];
1611        let mut lock = String::from("version = 2\n");
1612        for (name, checksum) in cases {
1613            lock.push_str(&format!(
1614                r#"
1615[items."agent/{name}"]
1616source = "_self"
1617kind = "agent"
1618source_checksum = "{checksum}"
1619
1620[[items."agent/{name}".outputs]]
1621target_root = ".mars"
1622dest_path = "outputs/{name}"
1623installed_checksum = "{checksum}"
1624"#
1625            ));
1626        }
1627        std::fs::write(root.join("mars.lock"), lock).unwrap();
1628
1629        let promoted = load(root).unwrap();
1630        std::fs::set_permissions(
1631            outputs.join("unreadable"),
1632            std::fs::Permissions::from_mode(0o600),
1633        )
1634        .unwrap();
1635
1636        for name in ["file-match", "dir-match"] {
1637            assert!(
1638                matches!(
1639                    promoted.items[&format!("agent/{name}")].outputs[0].state,
1640                    OutputState::Installed { .. }
1641                ),
1642                "{name} should retain installed-content authority"
1643            );
1644        }
1645        for name in [
1646            "file-mismatch",
1647            "dir-mismatch",
1648            "absent",
1649            "symlink",
1650            "unreadable",
1651        ] {
1652            assert!(
1653                matches!(
1654                    promoted.items[&format!("agent/{name}")].outputs[0].state,
1655                    OutputState::PendingDeletion
1656                ),
1657                "{name} should retain deletion authority only"
1658            );
1659        }
1660    }
1661
1662    #[test]
1663    fn load_for_runtime_aliases_rejects_legacy_v2_without_dependency_alias_authority() {
1664        let toml_str = r#"
1665version = 3
1666
1667[dependencies.base]
1668url = "https://github.com/org/base.git"
1669version = "v1.0.0"
1670commit = "abc123"
1671
1672[items."agent/coder"]
1673source = "base"
1674kind = "agent"
1675source_checksum = "sha256:aaa"
1676
1677[[items."agent/coder".outputs]]
1678target_root = ".mars"
1679dest_path = "agents/coder.md"
1680state = "installed"
1681installed_checksum = "sha256:bbb"
1682"#;
1683        let dir = TempDir::new().unwrap();
1684        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1685
1686        let err = load_for_runtime_aliases(dir.path()).unwrap_err();
1687        let message = err.to_string();
1688        assert!(message.contains("missing `dependency_model_aliases`"));
1689        assert!(message.contains(&format!("run `{}`", crate::types::managed_cmd("mars sync"))));
1690    }
1691
1692    #[test]
1693    fn load_for_runtime_aliases_allows_missing_dependency_aliases_when_no_dependencies() {
1694        let toml_str = r#"
1695version = 3
1696
1697[items."agent/coder"]
1698source = "_self"
1699kind = "agent"
1700source_checksum = "sha256:aaa"
1701
1702[[items."agent/coder".outputs]]
1703target_root = ".mars"
1704dest_path = "agents/coder.md"
1705state = "installed"
1706installed_checksum = "sha256:bbb"
1707"#;
1708        let dir = TempDir::new().unwrap();
1709        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1710
1711        let lock = load_for_runtime_aliases(dir.path()).unwrap();
1712        assert!(lock.dependencies.is_empty());
1713        assert!(lock.dependency_model_aliases.is_empty());
1714    }
1715
1716    #[test]
1717    fn roundtrip_lock_file() {
1718        let lock = sample_lock();
1719        let dir = TempDir::new().unwrap();
1720        write(dir.path(), &lock).unwrap();
1721        let reloaded = load(dir.path()).unwrap();
1722        assert_eq!(lock, reloaded);
1723    }
1724
1725    #[test]
1726    fn roundtrip_lock_file_with_config_entries() {
1727        let mut lock = sample_lock();
1728        lock.config_entries.insert(
1729            ".claude".to_string(),
1730            BTreeMap::from([(
1731                "mcp:context7".to_string(),
1732                ConfigEntryRecord { emitted_json: None },
1733            )]),
1734        );
1735
1736        let dir = TempDir::new().unwrap();
1737        write(dir.path(), &lock).unwrap();
1738        let reloaded = load(dir.path()).unwrap();
1739
1740        assert_eq!(lock, reloaded);
1741        assert_eq!(
1742            reloaded.config_entries[".claude"]["mcp:context7"].emitted_json,
1743            None
1744        );
1745    }
1746
1747    #[test]
1748    fn write_emits_dependency_model_aliases_table_even_when_empty() {
1749        let lock = sample_lock();
1750        let dir = TempDir::new().unwrap();
1751        write(dir.path(), &lock).unwrap();
1752
1753        let content = std::fs::read_to_string(dir.path().join("mars.lock")).unwrap();
1754        assert!(
1755            content.contains("dependency_model_aliases"),
1756            "serialized lock should include dependency_model_aliases authority table"
1757        );
1758    }
1759
1760    #[test]
1761    fn deterministic_serialization() {
1762        let lock = sample_lock();
1763        let s1 = toml::to_string_pretty(&lock).unwrap();
1764        let s2 = toml::to_string_pretty(&lock).unwrap();
1765        assert_eq!(s1, s2);
1766
1767        // V2: keys are "agent/coder" and "skill/review" — agent comes before skill alphabetically.
1768        let coder_pos = s1.find("agent/coder").unwrap();
1769        let review_pos = s1.find("skill/review").unwrap();
1770        assert!(
1771            coder_pos < review_pos,
1772            "agent/coder should appear before skill/review"
1773        );
1774    }
1775
1776    #[test]
1777    fn write_sorts_dependency_model_aliases_keys() {
1778        let toml_str = r#"
1779version = 3
1780
1781[dependency_model_aliases.zeta]
1782model = "openai/gpt-z"
1783
1784[dependency_model_aliases.alpha]
1785model = "openai/gpt-a"
1786"#;
1787        let dir = TempDir::new().unwrap();
1788        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1789
1790        let lock = load(dir.path()).unwrap();
1791        write(dir.path(), &lock).unwrap();
1792
1793        let written = std::fs::read_to_string(dir.path().join("mars.lock")).unwrap();
1794        let alpha = written
1795            .find("[dependency_model_aliases.alpha]")
1796            .expect("alpha alias should be serialized");
1797        let zeta = written
1798            .find("[dependency_model_aliases.zeta]")
1799            .expect("zeta alias should be serialized");
1800        assert!(alpha < zeta, "aliases should serialize in sorted key order");
1801    }
1802
1803    #[test]
1804    fn empty_lock_file() {
1805        let lock = LockFile::empty();
1806        assert_eq!(lock.version, LOCK_VERSION);
1807        assert!(lock.dependencies.is_empty());
1808        assert!(lock.items.is_empty());
1809    }
1810
1811    #[test]
1812    fn load_absent_returns_empty() {
1813        let dir = TempDir::new().unwrap();
1814        let lock = load(dir.path()).unwrap();
1815        assert_eq!(lock.version, LOCK_VERSION);
1816        assert!(lock.dependencies.is_empty());
1817        assert!(lock.items.is_empty());
1818    }
1819
1820    #[test]
1821    fn write_and_reload() {
1822        let dir = TempDir::new().unwrap();
1823        let lock = sample_lock();
1824        write(dir.path(), &lock).unwrap();
1825        let reloaded = load(dir.path()).unwrap();
1826        assert_eq!(lock, reloaded);
1827    }
1828
1829    #[test]
1830    fn dual_checksums_present() {
1831        let lock = sample_lock();
1832        let item = &lock.items["agent/coder"];
1833        assert_ne!(
1834            &item.source_checksum,
1835            item.outputs[0]
1836                .installed_checksum()
1837                .expect("installed output")
1838        );
1839        assert!(item.source_checksum.starts_with("sha256:"));
1840        assert!(
1841            item.outputs[0]
1842                .installed_checksum()
1843                .expect("installed output")
1844                .starts_with("sha256:")
1845        );
1846    }
1847
1848    #[test]
1849    fn path_source_in_lock() {
1850        let toml_str = r#"
1851version = 3
1852
1853[dependencies.local]
1854path = "/home/dev/agents"
1855
1856[items."agent/helper"]
1857source = "local"
1858kind = "agent"
1859source_checksum = "sha256:111"
1860
1861[[items."agent/helper".outputs]]
1862target_root = ".mars"
1863dest_path = "agents/helper.md"
1864state = "installed"
1865installed_checksum = "sha256:222"
1866"#;
1867        let dir = TempDir::new().unwrap();
1868        std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1869        let lock = load(dir.path()).unwrap();
1870        let source = &lock.dependencies["local"];
1871        assert!(source.url.is_none());
1872        assert_eq!(source.path.as_deref(), Some("/home/dev/agents"));
1873        assert!(source.commit.is_none());
1874    }
1875
1876    #[test]
1877    fn item_kind_serializes_lowercase() {
1878        let item = LockedItemV2 {
1879            source: "base".into(),
1880            kind: ItemKind::Skill,
1881            version: None,
1882            source_checksum: "sha256:aaa".into(),
1883            outputs: vec![OutputRecord::installed(
1884                ".mars".to_string(),
1885                "skills/review".into(),
1886                "sha256:bbb".into(),
1887            )],
1888        };
1889        let serialized = toml::to_string(&item).unwrap();
1890        assert!(serialized.contains("kind = \"skill\""));
1891    }
1892
1893    #[test]
1894    fn item_id_display() {
1895        let id = ItemId {
1896            kind: ItemKind::Agent,
1897            name: "coder".into(),
1898        };
1899        assert_eq!(id.to_string(), "agent/coder");
1900    }
1901
1902    #[test]
1903    fn item_kind_display() {
1904        assert_eq!(ItemKind::Agent.to_string(), "agent");
1905        assert_eq!(ItemKind::Skill.to_string(), "skill");
1906    }
1907
1908    #[test]
1909    fn find_by_dest_path_returns_flat_view() {
1910        let lock = sample_lock();
1911        let found = lock
1912            .find_by_dest_path(&DestPath::from("agents/coder.md"))
1913            .unwrap();
1914        assert_eq!(found.source, "base");
1915        assert_eq!(found.kind, ItemKind::Agent);
1916        assert_eq!(found.source_checksum, "sha256:aaa");
1917        assert_eq!(found.installed_checksum, "sha256:bbb");
1918        assert_eq!(found.dest_path.as_str(), "agents/coder.md");
1919    }
1920
1921    #[test]
1922    fn find_by_dest_path_missing_returns_none() {
1923        let lock = sample_lock();
1924        assert!(
1925            lock.find_by_dest_path(&DestPath::from("agents/missing.md"))
1926                .is_none()
1927        );
1928    }
1929
1930    #[test]
1931    fn contains_dest_path_hit_and_miss() {
1932        let lock = sample_lock();
1933        assert!(lock.contains_dest_path(&DestPath::from("agents/coder.md")));
1934        assert!(!lock.contains_dest_path(&DestPath::from("agents/nobody.md")));
1935    }
1936
1937    #[test]
1938    fn lock_index_target_scoped_lookup_distinguishes_same_dest_path() {
1939        let mut lock = sample_lock();
1940        lock.items
1941            .get_mut("agent/coder")
1942            .unwrap()
1943            .outputs
1944            .push(OutputRecord::installed(
1945                ".pi".to_string(),
1946                "agents/coder.md".into(),
1947                "sha256:pi".into(),
1948            ));
1949
1950        let index = LockIndex::new(&lock);
1951        let dest = DestPath::from("agents/coder.md");
1952
1953        let mars = index
1954            .find_output(".mars", &dest)
1955            .expect("expected canonical .mars output");
1956        let pi = index
1957            .find_output(".pi", &dest)
1958            .expect("expected .pi output");
1959
1960        assert_eq!(mars.installed_checksum, "sha256:bbb");
1961        assert_eq!(pi.installed_checksum, "sha256:pi");
1962        assert!(index.contains_output(".mars", &dest));
1963        assert!(index.contains_output(".pi", &dest));
1964        assert!(!index.contains_output(".cursor", &dest));
1965    }
1966
1967    #[test]
1968    fn output_dest_paths_for_target_filters_by_target_root() {
1969        let mut lock = sample_lock();
1970        lock.items
1971            .get_mut("agent/coder")
1972            .unwrap()
1973            .outputs
1974            .push(OutputRecord::installed(
1975                ".cursor".to_string(),
1976                "agents/coder.md".into(),
1977                "sha256:cursor".into(),
1978            ));
1979
1980        let mars_paths = lock.output_dest_paths_for_target(".mars");
1981        assert!(mars_paths.contains("agents/coder.md"));
1982        assert!(mars_paths.contains("skills/review"));
1983
1984        let cursor_paths = lock.output_dest_paths_for_target(".cursor");
1985        assert_eq!(cursor_paths.len(), 1);
1986        assert!(cursor_paths.contains("agents/coder.md"));
1987        assert!(lock.output_dest_paths_for_target(".claude").is_empty());
1988    }
1989
1990    #[test]
1991    fn contains_output_matches_target_root_and_dest_path() {
1992        let mut lock = sample_lock();
1993        assert!(lock.contains_output(".mars", "agents/coder.md"));
1994        assert!(!lock.contains_output(".cursor", "agents/coder.md"));
1995
1996        lock.items
1997            .get_mut("agent/coder")
1998            .unwrap()
1999            .outputs
2000            .push(OutputRecord::installed(
2001                ".cursor".to_string(),
2002                "agents/coder.md".into(),
2003                "sha256:cursor".into(),
2004            ));
2005        assert!(lock.contains_output(".cursor", "agents/coder.md"));
2006        assert!(!lock.contains_output(".cursor", "agents/missing.md"));
2007    }
2008
2009    #[test]
2010    fn apply_compiled_native_outputs_upserts_codex_native_by_canonical_owner() {
2011        let mut lock = sample_lock();
2012        apply_compiled_native_outputs(
2013            &mut lock,
2014            &[CompiledNativeOutput {
2015                owner_canonical_dest_path: "agents/coder.md".to_string(),
2016                target_root: ".codex".to_string(),
2017                dest_path: "agents/coder.toml".to_string(),
2018                installed_checksum: "sha256:codex".into(),
2019            }],
2020        )
2021        .unwrap();
2022        assert!(lock.contains_output(".codex", "agents/coder.toml"));
2023        assert!(lock.contains_output(".mars", "agents/coder.md"));
2024    }
2025
2026    #[test]
2027    fn apply_compiled_native_outputs_upserts_when_frontmatter_name_differs_from_filename() {
2028        let mut lock = sample_lock();
2029        lock.items.insert(
2030            "agent/alias-name".to_string(),
2031            LockedItemV2 {
2032                source: "base".into(),
2033                kind: ItemKind::Agent,
2034                version: Some("v1.0.0".into()),
2035                source_checksum: "sha256:alias-src".into(),
2036                outputs: vec![OutputRecord::installed(
2037                    ".mars".to_string(),
2038                    "agents/on-disk-stem.md".into(),
2039                    "sha256:alias-mars".into(),
2040                )],
2041            },
2042        );
2043        apply_compiled_native_outputs(
2044            &mut lock,
2045            &[CompiledNativeOutput {
2046                owner_canonical_dest_path: "agents/on-disk-stem.md".to_string(),
2047                target_root: ".claude".to_string(),
2048                dest_path: "agents/alias-name.md".to_string(),
2049                installed_checksum: "sha256:claude-native".into(),
2050            }],
2051        )
2052        .unwrap();
2053        assert!(lock.contains_output(".claude", "agents/alias-name.md"));
2054    }
2055
2056    #[test]
2057    fn build_updated_carries_non_canonical_outputs() {
2058        let mut old_lock = sample_lock();
2059        old_lock
2060            .items
2061            .get_mut("agent/coder")
2062            .unwrap()
2063            .outputs
2064            .push(OutputRecord::installed(
2065                ".claude".to_string(),
2066                "agents/coder.md".into(),
2067                "sha256:claude-old".into(),
2068            ));
2069
2070        let graph = ResolvedGraph {
2071            nodes: IndexMap::new(),
2072            order: Vec::new(),
2073            filters: HashMap::new(),
2074            version_constraints: std::collections::HashMap::new(),
2075            unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2076        };
2077        let applied = ApplyResult {
2078            outcomes: vec![ActionOutcome {
2079                item_id: ItemId {
2080                    kind: ItemKind::Agent,
2081                    name: "coder".into(),
2082                },
2083                action: ActionTaken::Updated,
2084                dest_path: "agents/coder.md".into(),
2085                source_name: "base".into(),
2086                source_checksum: Some("sha256:new-src".into()),
2087                installed_checksum: Some("sha256:new-mars".into()),
2088            }],
2089        };
2090
2091        let new_lock = build(
2092            &graph,
2093            &applied,
2094            &old_lock,
2095            std::collections::BTreeMap::new(),
2096        )
2097        .unwrap();
2098
2099        assert!(new_lock.contains_output(".mars", "agents/coder.md"));
2100        assert!(
2101            new_lock.contains_output(".claude", "agents/coder.md"),
2102            ".claude record should survive compile failure"
2103        );
2104        let item = &new_lock.items["agent/coder"];
2105        assert_eq!(item.outputs.len(), 2);
2106        assert_eq!(item.source_checksum, "sha256:new-src");
2107        let mars = item
2108            .outputs
2109            .iter()
2110            .find(|o| o.target_root == ".mars")
2111            .unwrap();
2112        assert_eq!(
2113            mars.installed_checksum().expect("installed output"),
2114            "sha256:new-mars"
2115        );
2116        let claude = item
2117            .outputs
2118            .iter()
2119            .find(|o| o.target_root == ".claude")
2120            .unwrap();
2121        assert_eq!(
2122            claude.installed_checksum().expect("installed output"),
2123            "sha256:claude-old"
2124        );
2125    }
2126
2127    #[test]
2128    fn build_fallback_carries_non_canonical_outputs_for_skipped_and_kept() {
2129        let old_lock = LockFile {
2130            version: LOCK_VERSION,
2131            dependencies: IndexMap::new(),
2132            items: IndexMap::from([
2133                (
2134                    "agent/agents/coder.md".to_string(),
2135                    LockedItemV2 {
2136                        source: "base".into(),
2137                        kind: ItemKind::Agent,
2138                        version: None,
2139                        source_checksum: "sha256:coder-src".into(),
2140                        outputs: vec![
2141                            OutputRecord::installed(
2142                                ".mars".to_string(),
2143                                "agents/coder.md".into(),
2144                                "sha256:coder-mars".into(),
2145                            ),
2146                            OutputRecord::installed(
2147                                ".claude".to_string(),
2148                                "agents/coder.md".into(),
2149                                "sha256:coder-claude".into(),
2150                            ),
2151                        ],
2152                    },
2153                ),
2154                (
2155                    "skill/skills/review".to_string(),
2156                    LockedItemV2 {
2157                        source: "base".into(),
2158                        kind: ItemKind::Skill,
2159                        version: None,
2160                        source_checksum: "sha256:review-src".into(),
2161                        outputs: vec![
2162                            OutputRecord::installed(
2163                                ".mars".to_string(),
2164                                "skills/review".into(),
2165                                "sha256:review-mars".into(),
2166                            ),
2167                            OutputRecord::installed(
2168                                ".codex".to_string(),
2169                                "skills/review/SKILL.md".into(),
2170                                "sha256:review-codex".into(),
2171                            ),
2172                        ],
2173                    },
2174                ),
2175            ]),
2176            config_entries: BTreeMap::new(),
2177            dependency_model_aliases: IndexMap::new(),
2178        };
2179        let graph = ResolvedGraph {
2180            nodes: IndexMap::new(),
2181            order: Vec::new(),
2182            filters: HashMap::new(),
2183            version_constraints: std::collections::HashMap::new(),
2184            unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2185        };
2186        let applied = ApplyResult {
2187            outcomes: vec![
2188                ActionOutcome {
2189                    item_id: ItemId {
2190                        kind: ItemKind::Agent,
2191                        name: "coder".into(),
2192                    },
2193                    action: ActionTaken::Skipped,
2194                    dest_path: "agents/coder.md".into(),
2195                    source_name: "base".into(),
2196                    source_checksum: None,
2197                    installed_checksum: None,
2198                },
2199                ActionOutcome {
2200                    item_id: ItemId {
2201                        kind: ItemKind::Skill,
2202                        name: "review".into(),
2203                    },
2204                    action: ActionTaken::Kept,
2205                    dest_path: "skills/review".into(),
2206                    source_name: "base".into(),
2207                    source_checksum: None,
2208                    installed_checksum: None,
2209                },
2210            ],
2211        };
2212
2213        let new_lock = build(
2214            &graph,
2215            &applied,
2216            &old_lock,
2217            std::collections::BTreeMap::new(),
2218        )
2219        .unwrap();
2220
2221        assert!(!new_lock.items.contains_key("agent/agents/coder.md"));
2222        assert!(new_lock.contains_output(".mars", "agents/coder.md"));
2223        assert!(new_lock.contains_output(".claude", "agents/coder.md"));
2224
2225        assert!(!new_lock.items.contains_key("skill/skills/review"));
2226        assert!(new_lock.contains_output(".mars", "skills/review"));
2227        assert!(new_lock.contains_output(".codex", "skills/review/SKILL.md"));
2228    }
2229
2230    #[test]
2231    fn build_write_fallback_carries_non_canonical_outputs() {
2232        let old_lock = LockFile {
2233            version: LOCK_VERSION,
2234            dependencies: IndexMap::new(),
2235            items: IndexMap::from([(
2236                "agent/agents/coder.md".to_string(),
2237                LockedItemV2 {
2238                    source: "base".into(),
2239                    kind: ItemKind::Agent,
2240                    version: None,
2241                    source_checksum: "sha256:old-src".into(),
2242                    outputs: vec![
2243                        OutputRecord::installed(
2244                            ".mars".to_string(),
2245                            "agents/coder.md".into(),
2246                            "sha256:old-mars".into(),
2247                        ),
2248                        OutputRecord::installed(
2249                            ".claude".to_string(),
2250                            "agents/coder.md".into(),
2251                            "sha256:old-claude".into(),
2252                        ),
2253                    ],
2254                },
2255            )]),
2256            config_entries: BTreeMap::new(),
2257            dependency_model_aliases: IndexMap::new(),
2258        };
2259        let graph = ResolvedGraph {
2260            nodes: IndexMap::new(),
2261            order: Vec::new(),
2262            filters: HashMap::new(),
2263            version_constraints: std::collections::HashMap::new(),
2264            unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2265        };
2266        let applied = ApplyResult {
2267            outcomes: vec![ActionOutcome {
2268                item_id: ItemId {
2269                    kind: ItemKind::Agent,
2270                    name: "coder".into(),
2271                },
2272                action: ActionTaken::Updated,
2273                dest_path: "agents/coder.md".into(),
2274                source_name: "base".into(),
2275                source_checksum: Some("sha256:new-src".into()),
2276                installed_checksum: Some("sha256:new-mars".into()),
2277            }],
2278        };
2279
2280        let new_lock = build(
2281            &graph,
2282            &applied,
2283            &old_lock,
2284            std::collections::BTreeMap::new(),
2285        )
2286        .unwrap();
2287
2288        assert!(!new_lock.items.contains_key("agent/agents/coder.md"));
2289        assert!(new_lock.contains_output(".mars", "agents/coder.md"));
2290        assert!(new_lock.contains_output(".claude", "agents/coder.md"));
2291        let item = &new_lock.items["agent/coder"];
2292        assert_eq!(item.source_checksum, "sha256:new-src");
2293        let claude = item
2294            .outputs
2295            .iter()
2296            .find(|o| o.target_root == ".claude")
2297            .unwrap();
2298        assert_eq!(
2299            claude.installed_checksum().expect("installed output"),
2300            "sha256:old-claude"
2301        );
2302    }
2303
2304    #[test]
2305    fn apply_apply_outcomes_write_fallback_carries_non_canonical_outputs() {
2306        let old_lock = LockFile {
2307            version: LOCK_VERSION,
2308            dependencies: IndexMap::new(),
2309            items: IndexMap::from([(
2310                "agent/agents/coder.md".to_string(),
2311                LockedItemV2 {
2312                    source: "base".into(),
2313                    kind: ItemKind::Agent,
2314                    version: None,
2315                    source_checksum: "sha256:old-src".into(),
2316                    outputs: vec![
2317                        OutputRecord::installed(
2318                            ".mars".to_string(),
2319                            "agents/coder.md".into(),
2320                            "sha256:old-mars".into(),
2321                        ),
2322                        OutputRecord::installed(
2323                            ".claude".to_string(),
2324                            "agents/coder.md".into(),
2325                            "sha256:old-claude".into(),
2326                        ),
2327                    ],
2328                },
2329            )]),
2330            config_entries: BTreeMap::new(),
2331            dependency_model_aliases: IndexMap::new(),
2332        };
2333        let mut lock = old_lock.clone();
2334
2335        apply_apply_outcomes_to_lock(
2336            &mut lock,
2337            &old_lock,
2338            &[ActionOutcome {
2339                item_id: ItemId {
2340                    kind: ItemKind::Agent,
2341                    name: "coder".into(),
2342                },
2343                action: ActionTaken::Updated,
2344                dest_path: "agents/coder.md".into(),
2345                source_name: "base".into(),
2346                source_checksum: Some("sha256:new-src".into()),
2347                installed_checksum: Some("sha256:new-mars".into()),
2348            }],
2349        );
2350
2351        assert!(!lock.items.contains_key("agent/agents/coder.md"));
2352        assert!(lock.contains_output(".mars", "agents/coder.md"));
2353        assert!(lock.contains_output(".claude", "agents/coder.md"));
2354        let item = &lock.items["agent/coder"];
2355        assert_eq!(item.source_checksum, "sha256:new-src");
2356    }
2357
2358    #[test]
2359    fn apply_apply_outcomes_to_lock_updated_preserves_non_canonical_outputs() {
2360        let mut old_lock = sample_lock();
2361        old_lock
2362            .items
2363            .get_mut("agent/coder")
2364            .unwrap()
2365            .outputs
2366            .push(OutputRecord::installed(
2367                ".claude".to_string(),
2368                "agents/coder.md".into(),
2369                "sha256:claude".into(),
2370            ));
2371
2372        let mut lock = old_lock.clone();
2373        apply_apply_outcomes_to_lock(
2374            &mut lock,
2375            &old_lock,
2376            &[ActionOutcome {
2377                item_id: ItemId {
2378                    kind: ItemKind::Agent,
2379                    name: ItemName::from("coder"),
2380                },
2381                action: ActionTaken::Updated,
2382                dest_path: "agents/coder.md".into(),
2383                source_name: "base".into(),
2384                source_checksum: Some("sha256:new-src".into()),
2385                installed_checksum: Some("sha256:new-mars".into()),
2386            }],
2387        );
2388
2389        assert!(lock.contains_output(".mars", "agents/coder.md"));
2390        assert!(lock.contains_output(".claude", "agents/coder.md"));
2391        let item = &lock.items["agent/coder"];
2392        assert_eq!(item.source_checksum, "sha256:new-src");
2393        let mars = item
2394            .outputs
2395            .iter()
2396            .find(|o| o.target_root == ".mars")
2397            .unwrap();
2398        assert_eq!(
2399            mars.installed_checksum().expect("installed output"),
2400            "sha256:new-mars"
2401        );
2402        let claude = item
2403            .outputs
2404            .iter()
2405            .find(|o| o.target_root == ".claude")
2406            .unwrap();
2407        assert_eq!(
2408            claude.installed_checksum().expect("installed output"),
2409            "sha256:claude"
2410        );
2411    }
2412
2413    #[test]
2414    fn ownership_lock_for_native_emission_seeds_new_apply_outcomes() {
2415        let old_lock = LockFile::empty();
2416        let apply_outcomes = vec![ActionOutcome {
2417            item_id: ItemId {
2418                kind: ItemKind::Agent,
2419                name: ItemName::from("coder"),
2420            },
2421            action: ActionTaken::Installed,
2422            dest_path: "agents/coder.md".into(),
2423            source_name: "base".into(),
2424            source_checksum: Some("sha256:src".into()),
2425            installed_checksum: Some("sha256:mars".into()),
2426        }];
2427        let view = ownership_lock_for_native_emission(
2428            &old_lock,
2429            &apply_outcomes,
2430            &[crate::target_sync::TargetSyncOutcome {
2431                target: ".cursor".to_string(),
2432                items_synced: 1,
2433                items_removed: 0,
2434                errors: Vec::new(),
2435                synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
2436                    dest_path: "agents/coder.md".to_string(),
2437                    installed_checksum: "sha256:cursor".into(),
2438                }],
2439                removed_dest_paths: Vec::new(),
2440            }],
2441        );
2442        assert!(view.contains_output(".mars", "agents/coder.md"));
2443        assert!(view.contains_output(".cursor", "agents/coder.md"));
2444        assert!(!old_lock.contains_output(".mars", "agents/coder.md"));
2445    }
2446
2447    #[test]
2448    fn ownership_lock_after_target_sync_layers_synced_outputs() {
2449        let lock = sample_lock();
2450        let view = ownership_lock_after_target_sync(
2451            &lock,
2452            &[crate::target_sync::TargetSyncOutcome {
2453                target: ".cursor".to_string(),
2454                items_synced: 1,
2455                items_removed: 0,
2456                errors: Vec::new(),
2457                synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
2458                    dest_path: "agents/coder.md".to_string(),
2459                    installed_checksum: "sha256:cursor".into(),
2460                }],
2461                removed_dest_paths: Vec::new(),
2462            }],
2463        );
2464        assert!(view.contains_output(".cursor", "agents/coder.md"));
2465        assert!(!lock.contains_output(".cursor", "agents/coder.md"));
2466    }
2467
2468    #[test]
2469    fn apply_target_sync_outputs_upserts_and_removes_target_records() {
2470        let mut lock = sample_lock();
2471        apply_target_sync_outputs(
2472            &mut lock,
2473            &[crate::target_sync::TargetSyncOutcome {
2474                target: ".cursor".to_string(),
2475                items_synced: 1,
2476                items_removed: 0,
2477                errors: Vec::new(),
2478                synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
2479                    dest_path: "agents/coder.md".to_string(),
2480                    installed_checksum: "sha256:cursor".into(),
2481                }],
2482                removed_dest_paths: Vec::new(),
2483            }],
2484        );
2485        assert!(lock.contains_output(".cursor", "agents/coder.md"));
2486
2487        apply_target_sync_outputs(
2488            &mut lock,
2489            &[crate::target_sync::TargetSyncOutcome {
2490                target: ".cursor".to_string(),
2491                items_synced: 0,
2492                items_removed: 1,
2493                errors: Vec::new(),
2494                synced_outputs: Vec::new(),
2495                removed_dest_paths: vec!["agents/coder.md".to_string()],
2496            }],
2497        );
2498        assert!(!lock.contains_output(".cursor", "agents/coder.md"));
2499        assert!(lock.contains_output(".mars", "agents/coder.md"));
2500    }
2501
2502    #[test]
2503    fn canonical_flat_items_excludes_linked_target_outputs() {
2504        let mut lock = sample_lock();
2505        lock.items
2506            .get_mut("agent/coder")
2507            .unwrap()
2508            .outputs
2509            .push(OutputRecord::installed(
2510                ".cursor".to_string(),
2511                "agents/coder.md".into(),
2512                "sha256:cursor".into(),
2513            ));
2514
2515        let canonical = lock.canonical_flat_items();
2516        assert_eq!(canonical.len(), 2);
2517        assert!(
2518            canonical
2519                .iter()
2520                .any(|(dp, _)| dp.as_str() == "agents/coder.md")
2521        );
2522        assert!(
2523            canonical
2524                .iter()
2525                .all(|(_, item)| { lock.contains_output(".mars", item.dest_path.as_str()) })
2526        );
2527
2528        let cursor = lock.flat_items_for_target(".cursor");
2529        assert_eq!(cursor.len(), 1);
2530        assert_eq!(cursor[0].0.as_str(), "agents/coder.md");
2531    }
2532
2533    #[test]
2534    fn build_uses_graph_provenance_for_sources() {
2535        let git_name: SourceName = "base".into();
2536        let path_name: SourceName = "local".into();
2537        let git_url: SourceUrl = "https://example.com/new.git".into();
2538        let path_canonical = PathBuf::from("/tmp/mars-agents-local-source");
2539
2540        let mut nodes = IndexMap::new();
2541        nodes.insert(
2542            git_name.clone(),
2543            ResolvedNode {
2544                source_name: git_name.clone(),
2545                source_id: SourceId::git_with_subpath(
2546                    git_url.clone(),
2547                    Some(crate::types::SourceSubpath::new("plugins/base").unwrap()),
2548                ),
2549                rooted_ref: crate::resolve::RootedSourceRef {
2550                    checkout_root: PathBuf::from("/tmp/cache/base"),
2551                    package_root: PathBuf::from("/tmp/cache/base/plugins/base"),
2552                },
2553                resolved_ref: ResolvedRef {
2554                    source_name: git_name.clone(),
2555                    version: Some(semver::Version::new(1, 2, 3)),
2556                    version_tag: Some("v1.2.3".into()),
2557                    commit: Some("abc123".into()),
2558                    tree_path: PathBuf::from("/tmp/cache/base"),
2559                },
2560                manifest: None,
2561                deps: vec![],
2562            },
2563        );
2564        nodes.insert(
2565            path_name.clone(),
2566            ResolvedNode {
2567                source_name: path_name.clone(),
2568                source_id: SourceId::Path {
2569                    canonical: path_canonical.clone(),
2570                    subpath: Some(crate::types::SourceSubpath::new("plugins/local").unwrap()),
2571                },
2572                rooted_ref: crate::resolve::RootedSourceRef {
2573                    checkout_root: PathBuf::from("/tmp/cache/local"),
2574                    package_root: PathBuf::from("/tmp/cache/local/plugins/local"),
2575                },
2576                resolved_ref: ResolvedRef {
2577                    source_name: path_name.clone(),
2578                    version: None,
2579                    version_tag: None,
2580                    commit: None,
2581                    tree_path: PathBuf::from("/tmp/cache/local"),
2582                },
2583                manifest: None,
2584                deps: vec![],
2585            },
2586        );
2587
2588        let graph = ResolvedGraph {
2589            nodes,
2590            order: vec![git_name.clone(), path_name.clone()],
2591            filters: HashMap::new(),
2592            version_constraints: std::collections::HashMap::new(),
2593            unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2594        };
2595        let applied = ApplyResult { outcomes: vec![] };
2596
2597        let mut old_sources = IndexMap::new();
2598        old_sources.insert(
2599            git_name.clone(),
2600            LockedSource {
2601                url: Some("https://example.com/old.git".into()),
2602                path: None,
2603                subpath: None,
2604                version: Some("v0.0.1".into()),
2605                commit: Some("deadbeef".into()),
2606            },
2607        );
2608        let old_lock = LockFile {
2609            version: LOCK_VERSION,
2610            dependencies: old_sources,
2611            items: IndexMap::new(),
2612            config_entries: std::collections::BTreeMap::new(),
2613            dependency_model_aliases: IndexMap::new(),
2614        };
2615
2616        let new_lock = build(
2617            &graph,
2618            &applied,
2619            &old_lock,
2620            std::collections::BTreeMap::new(),
2621        )
2622        .unwrap();
2623
2624        let base = &new_lock.dependencies["base"];
2625        assert_eq!(base.url.as_ref(), Some(&git_url));
2626        assert_eq!(
2627            base.subpath
2628                .as_ref()
2629                .map(crate::types::SourceSubpath::as_str),
2630            Some("plugins/base")
2631        );
2632        assert_eq!(base.version.as_deref(), Some("v1.2.3"));
2633        assert_eq!(base.commit.as_deref(), Some("abc123"));
2634
2635        let local = &new_lock.dependencies["local"];
2636        assert!(local.url.is_none());
2637        assert_eq!(
2638            local
2639                .subpath
2640                .as_ref()
2641                .map(crate::types::SourceSubpath::as_str),
2642            Some("plugins/local")
2643        );
2644        assert_eq!(
2645            local.path.as_deref(),
2646            Some(path_canonical.to_string_lossy().as_ref())
2647        );
2648    }
2649
2650    #[test]
2651    fn build_persists_ref_selector_in_locked_source_version() {
2652        let source_name: SourceName = "base".into();
2653        let mut nodes = IndexMap::new();
2654        nodes.insert(
2655            source_name.clone(),
2656            ResolvedNode {
2657                source_name: source_name.clone(),
2658                source_id: SourceId::git_with_subpath("https://example.com/base.git".into(), None),
2659                rooted_ref: crate::resolve::RootedSourceRef {
2660                    checkout_root: PathBuf::from("/tmp/cache/base"),
2661                    package_root: PathBuf::from("/tmp/cache/base"),
2662                },
2663                resolved_ref: ResolvedRef {
2664                    source_name: source_name.clone(),
2665                    version: None,
2666                    version_tag: Some("main".into()),
2667                    commit: Some("abc123".into()),
2668                    tree_path: PathBuf::from("/tmp/cache/base"),
2669                },
2670                manifest: None,
2671                deps: vec![],
2672            },
2673        );
2674
2675        let graph = ResolvedGraph {
2676            nodes,
2677            order: vec![source_name.clone()],
2678            filters: HashMap::new(),
2679            version_constraints: std::collections::HashMap::new(),
2680            unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2681        };
2682        let applied = ApplyResult { outcomes: vec![] };
2683        let new_lock = build(
2684            &graph,
2685            &applied,
2686            &LockFile::empty(),
2687            std::collections::BTreeMap::new(),
2688        )
2689        .unwrap();
2690
2691        let source = &new_lock.dependencies["base"];
2692        assert_eq!(source.version.as_deref(), Some("main"));
2693        assert_eq!(source.commit.as_deref(), Some("abc123"));
2694    }
2695
2696    #[test]
2697    fn build_keeps_self_items_from_old_lock_on_skipped_action() {
2698        let graph = ResolvedGraph {
2699            nodes: IndexMap::new(),
2700            order: Vec::new(),
2701            filters: HashMap::new(),
2702            version_constraints: std::collections::HashMap::new(),
2703            unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2704        };
2705        let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
2706        let old_lock = LockFile {
2707            version: LOCK_VERSION,
2708            dependencies: IndexMap::from([(
2709                local_source_name.clone(),
2710                LockedSource {
2711                    url: None,
2712                    path: Some(".".into()),
2713                    subpath: None,
2714                    version: None,
2715                    commit: None,
2716                },
2717            )]),
2718            items: IndexMap::from([(
2719                "skill/local-skill".to_string(),
2720                LockedItemV2 {
2721                    source: local_source_name.clone(),
2722                    kind: ItemKind::Skill,
2723                    version: None,
2724                    source_checksum: "sha256:self".into(),
2725                    outputs: vec![OutputRecord::installed(
2726                        ".mars".to_string(),
2727                        DestPath::from("skills/local-skill"),
2728                        "sha256:self".into(),
2729                    )],
2730                },
2731            )]),
2732            config_entries: std::collections::BTreeMap::new(),
2733            dependency_model_aliases: IndexMap::new(),
2734        };
2735        let applied = ApplyResult {
2736            outcomes: vec![ActionOutcome {
2737                item_id: ItemId {
2738                    kind: ItemKind::Skill,
2739                    name: "local-skill".into(),
2740                },
2741                action: ActionTaken::Skipped,
2742                dest_path: "skills/local-skill".into(),
2743                source_name: local_source_name.clone(),
2744                source_checksum: None,
2745                installed_checksum: None,
2746            }],
2747        };
2748
2749        let new_lock = build(
2750            &graph,
2751            &applied,
2752            &old_lock,
2753            std::collections::BTreeMap::new(),
2754        )
2755        .unwrap();
2756
2757        assert!(
2758            new_lock
2759                .dependencies
2760                .contains_key(local_source_name.as_str())
2761        );
2762        let item = &new_lock.items["skill/local-skill"];
2763        assert_eq!(item.source, local_source_name);
2764        assert_eq!(item.kind, ItemKind::Skill);
2765        assert_eq!(item.source_checksum, "sha256:self");
2766        assert_eq!(
2767            item.outputs[0]
2768                .installed_checksum()
2769                .expect("installed output"),
2770            "sha256:self"
2771        );
2772    }
2773
2774    #[test]
2775    fn build_rejects_missing_installed_checksum_for_write_actions() {
2776        let graph = ResolvedGraph {
2777            nodes: IndexMap::new(),
2778            order: Vec::new(),
2779            filters: HashMap::new(),
2780            version_constraints: std::collections::HashMap::new(),
2781            unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2782        };
2783        let old_lock = LockFile::empty();
2784        let applied = ApplyResult {
2785            outcomes: vec![ActionOutcome {
2786                item_id: ItemId {
2787                    kind: ItemKind::Agent,
2788                    name: "coder".into(),
2789                },
2790                action: ActionTaken::Installed,
2791                dest_path: "agents/coder.md".into(),
2792                source_name: "base".into(),
2793                source_checksum: Some("sha256:source".into()),
2794                installed_checksum: None,
2795            }],
2796        };
2797
2798        let err = build(
2799            &graph,
2800            &applied,
2801            &old_lock,
2802            std::collections::BTreeMap::new(),
2803        )
2804        .unwrap_err();
2805        let msg = err.to_string();
2806        assert!(msg.contains("missing checksum for write-producing action"));
2807        assert!(msg.contains("agents/coder.md"));
2808    }
2809
2810    #[test]
2811    fn build_rejects_empty_checksums_from_carried_items() {
2812        let graph = ResolvedGraph {
2813            nodes: IndexMap::new(),
2814            order: Vec::new(),
2815            filters: HashMap::new(),
2816            version_constraints: std::collections::HashMap::new(),
2817            unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2818        };
2819        let old_lock = LockFile {
2820            version: LOCK_VERSION,
2821            dependencies: IndexMap::new(),
2822            items: IndexMap::from([(
2823                "agent/coder".to_string(),
2824                LockedItemV2 {
2825                    source: "base".into(),
2826                    kind: ItemKind::Agent,
2827                    version: None,
2828                    source_checksum: "".into(),
2829                    outputs: vec![OutputRecord::installed(
2830                        ".mars".to_string(),
2831                        DestPath::from("agents/coder.md"),
2832                        "sha256:installed".into(),
2833                    )],
2834                },
2835            )]),
2836            config_entries: std::collections::BTreeMap::new(),
2837            dependency_model_aliases: IndexMap::new(),
2838        };
2839        let applied = ApplyResult {
2840            outcomes: vec![ActionOutcome {
2841                item_id: ItemId {
2842                    kind: ItemKind::Agent,
2843                    name: "coder".into(),
2844                },
2845                action: ActionTaken::Skipped,
2846                dest_path: "agents/coder.md".into(),
2847                source_name: "base".into(),
2848                source_checksum: None,
2849                installed_checksum: None,
2850            }],
2851        };
2852
2853        let err = build(
2854            &graph,
2855            &applied,
2856            &old_lock,
2857            std::collections::BTreeMap::new(),
2858        )
2859        .unwrap_err();
2860        let msg = err.to_string();
2861        assert!(msg.contains("empty source_checksum"));
2862    }
2863}
2864
2865#[cfg(test)]
2866mod output_lifecycle_contract_tests {
2867    use super::{OutputRecord, OutputState};
2868
2869    #[test]
2870    fn pending_deletion_record_carries_no_checksum() {
2871        let record = OutputRecord::pending_deletion(".opencode", "plugins/mars-audit.ts");
2872
2873        assert!(matches!(record.state, OutputState::PendingDeletion));
2874        let encoded = toml::to_string(&record).expect("pending record serializes");
2875        assert!(!encoded.contains("installed_checksum"));
2876    }
2877}