Skip to main content

mars_agents/lock/
mod.rs

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