1use std::collections::BTreeMap;
2use std::collections::{HashMap, HashSet};
3use std::path::Path;
4
5use indexmap::IndexMap;
6use serde::{Deserialize, Serialize};
7
8use crate::diagnostic::Diagnostic;
9use crate::error::{LockError, MarsError};
10use crate::types::{
11 CommitHash, ContentHash, DestPath, SourceId, SourceName, SourceOrigin, SourceSubpath, SourceUrl,
12};
13
14#[derive(Debug, Clone, Serialize, PartialEq)]
21pub struct LockFile {
22 pub version: u32,
24 #[serde(default)]
25 pub dependencies: IndexMap<SourceName, LockedSource>,
26 #[serde(default)]
28 pub items: IndexMap<String, LockedItemV2>,
29 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31 pub config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
32}
33
34impl<'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 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 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 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 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 pub fn output_dest_paths_for_target(&self, target_root: &str) -> HashSet<String> {
104 self.items
105 .values()
106 .flat_map(|item| item.outputs.iter())
107 .filter(|output| output.target_root == target_root)
108 .map(|output| output.dest_path.to_string())
109 .collect()
110 }
111
112 pub fn contains_output(&self, target_root: &str, dest_path: &str) -> bool {
114 self.items.values().any(|item| {
115 item.outputs.iter().any(|output| {
116 output.target_root == target_root
117 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
118 })
119 })
120 }
121
122 pub fn canonical_flat_items(&self) -> Vec<(DestPath, LockedItem)> {
124 self.flat_items_for_target(CANONICAL_TARGET_ROOT)
125 }
126
127 pub fn flat_items_for_target(&self, target_root: &str) -> Vec<(DestPath, LockedItem)> {
129 self.items
130 .values()
131 .flat_map(|item_v2| {
132 item_v2.outputs.iter().filter_map(|output| {
133 if output.target_root != target_root {
134 return None;
135 }
136 Some((
137 output.dest_path.clone(),
138 LockedItem {
139 source: item_v2.source.clone(),
140 kind: item_v2.kind,
141 version: item_v2.version.clone(),
142 source_checksum: item_v2.source_checksum.clone(),
143 installed_checksum: output.installed_checksum.clone(),
144 dest_path: output.dest_path.clone(),
145 },
146 ))
147 })
148 })
149 .collect()
150 }
151
152 pub fn flat_items(&self) -> Vec<(DestPath, LockedItem)> {
156 self.items
157 .values()
158 .flat_map(|item_v2| {
159 item_v2.outputs.iter().map(|output| {
160 (
161 output.dest_path.clone(),
162 LockedItem {
163 source: item_v2.source.clone(),
164 kind: item_v2.kind,
165 version: item_v2.version.clone(),
166 source_checksum: item_v2.source_checksum.clone(),
167 installed_checksum: output.installed_checksum.clone(),
168 dest_path: output.dest_path.clone(),
169 },
170 )
171 })
172 })
173 .collect()
174 }
175}
176
177pub struct LockIndex<'a> {
182 lock: &'a LockFile,
183 by_output: HashMap<(String, String), (&'a str, usize)>,
184 by_dest_path: HashMap<String, (&'a str, usize)>,
185}
186
187impl<'a> LockIndex<'a> {
188 pub fn new(lock: &'a LockFile) -> Self {
189 let mut by_output = HashMap::new();
190 let mut by_dest_path = HashMap::new();
191
192 for (key, item) in &lock.items {
193 for (idx, output) in item.outputs.iter().enumerate() {
194 let normalized_dest = normalize_dest_path(output.dest_path.as_str());
195 by_dest_path
196 .entry(normalized_dest.clone())
197 .or_insert((key.as_str(), idx));
198 by_output.insert(
199 (output.target_root.clone(), normalized_dest),
200 (key.as_str(), idx),
201 );
202 }
203 }
204
205 Self {
206 lock,
207 by_output,
208 by_dest_path,
209 }
210 }
211
212 pub fn find_by_dest_path(&self, dest_path: &DestPath) -> Option<LockedItem> {
214 let (item_key, output_idx) = *self
215 .by_dest_path
216 .get(&normalize_dest_path(dest_path.as_str()))?;
217 self.locked_item_for(item_key, output_idx)
218 }
219
220 pub fn find_output(&self, target_root: &str, dest_path: &DestPath) -> Option<LockedItem> {
222 let (item_key, output_idx) = *self.by_output.get(&(
223 target_root.to_string(),
224 normalize_dest_path(dest_path.as_str()),
225 ))?;
226 self.locked_item_for(item_key, output_idx)
227 }
228
229 pub fn contains_output(&self, target_root: &str, dest_path: &DestPath) -> bool {
231 self.by_output.contains_key(&(
232 target_root.to_string(),
233 normalize_dest_path(dest_path.as_str()),
234 ))
235 }
236
237 fn locked_item_for(&self, item_key: &str, output_idx: usize) -> Option<LockedItem> {
238 let item_v2 = self.lock.items.get(item_key)?;
239 let output = item_v2.outputs.get(output_idx)?;
240 Some(LockedItem {
241 source: item_v2.source.clone(),
242 kind: item_v2.kind,
243 version: item_v2.version.clone(),
244 source_checksum: item_v2.source_checksum.clone(),
245 installed_checksum: output.installed_checksum.clone(),
246 dest_path: output.dest_path.clone(),
247 })
248 }
249
250 pub fn contains_dest_path(&self, dest_path: &DestPath) -> bool {
252 self.by_dest_path
253 .contains_key(&normalize_dest_path(dest_path.as_str()))
254 }
255}
256
257fn normalize_dest_path(s: &str) -> String {
258 if cfg!(windows) {
259 s.replace('\\', "/")
260 } else {
261 s.to_string()
262 }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267pub struct LockedSource {
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub url: Option<SourceUrl>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub path: Option<String>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub subpath: Option<SourceSubpath>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub version: Option<String>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub commit: Option<CommitHash>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub tree_hash: Option<String>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
289pub struct LockedItemV2 {
290 pub source: SourceName,
291 pub kind: ItemKind,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub version: Option<String>,
294 pub source_checksum: ContentHash,
295 pub outputs: Vec<OutputRecord>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
301pub struct OutputRecord {
302 pub target_root: String,
304 pub dest_path: DestPath,
306 pub installed_checksum: ContentHash,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
312pub struct ConfigEntryRecord {
313 pub source: String,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
321pub struct LockedItem {
322 pub source: SourceName,
323 pub kind: ItemKind,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub version: Option<String>,
326 pub source_checksum: ContentHash,
327 pub installed_checksum: ContentHash,
328 pub dest_path: DestPath,
329}
330
331pub use crate::types::{ItemId, ItemKind};
334
335const LOCK_FILE: &str = "mars.lock";
336const LOCK_VERSION: u32 = 2;
338pub const CANONICAL_TARGET_ROOT: &str = ".mars";
340
341#[derive(Deserialize)]
347struct LockFileV1 {
348 #[allow(dead_code)]
349 version: u32,
350 #[serde(default)]
351 dependencies: IndexMap<SourceName, LockedSource>,
352 #[serde(default)]
353 items: IndexMap<DestPath, LockedItem>,
354}
355
356#[derive(Deserialize)]
358struct LockFileV2Wire {
359 version: u32,
360 #[serde(default)]
361 dependencies: IndexMap<SourceName, LockedSource>,
362 #[serde(default)]
363 items: IndexMap<String, LockedItemV2>,
364 #[serde(default)]
365 config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
366}
367
368pub fn load(root: &Path) -> Result<LockFile, MarsError> {
378 let (lock, _) = load_with_diagnostics(root)?;
379 Ok(lock)
380}
381
382pub fn load_with_diagnostics(root: &Path) -> Result<(LockFile, Vec<Diagnostic>), MarsError> {
387 let path = root.join(LOCK_FILE);
388 let content = match std::fs::read_to_string(&path) {
389 Ok(c) => c,
390 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
391 return Ok((LockFile::empty(), Vec::new()));
392 }
393 Err(e) => return Err(LockError::Io(e).into()),
394 };
395
396 let value: toml::Value = toml::from_str(&content).map_err(|e| LockError::Corrupt {
397 message: format!("failed to parse {}: {e}", path.display()),
398 })?;
399
400 match value.clone().try_into::<LockFileV2Wire>() {
401 Ok(wire) if wire.version >= 2 => Ok((
402 LockFile {
403 version: wire.version,
404 dependencies: wire.dependencies,
405 items: wire.items,
406 config_entries: wire.config_entries,
407 },
408 Vec::new(),
409 )),
410 v2_result => {
411 let wire: LockFileV1 = value.clone().try_into().map_err(|v1_error| {
413 let parse_error = match v2_result {
414 Ok(wire) => format!("unsupported lock version {}", wire.version),
415 Err(v2_error) => {
416 format!("v2 parse failed: {v2_error}; v1 parse failed: {v1_error}")
417 }
418 };
419 LockError::Corrupt {
420 message: format!("failed to parse {}: {parse_error}", path.display()),
421 }
422 })?;
423 let (items, diagnostics) = promote_v1_items(wire.items);
424 Ok((
425 LockFile {
426 version: LOCK_VERSION,
427 dependencies: wire.dependencies,
428 items,
429 config_entries: BTreeMap::new(),
430 },
431 diagnostics,
432 ))
433 }
434 }
435}
436
437pub fn write(root: &Path, lock: &LockFile) -> Result<(), MarsError> {
439 let path = root.join(LOCK_FILE);
440 let content = toml::to_string_pretty(lock).map_err(|e| LockError::Corrupt {
441 message: format!("failed to serialize lock file: {e}"),
442 })?;
443 crate::fs::atomic_write(&path, content.as_bytes())
444}
445
446fn promote_v1_items(
456 v1_items: IndexMap<DestPath, LockedItem>,
457) -> (IndexMap<String, LockedItemV2>, Vec<Diagnostic>) {
458 let mut result: IndexMap<String, LockedItemV2> = IndexMap::new();
459 let mut diagnostics = Vec::new();
460
461 for (dest_path, item) in v1_items {
462 let key = format!("{}/{}", item.kind, dest_path.item_name(item.kind));
463 let item_v2 = LockedItemV2 {
464 source: item.source,
465 kind: item.kind,
466 version: item.version,
467 source_checksum: item.source_checksum,
468 outputs: vec![OutputRecord {
469 target_root: ".mars".to_string(),
470 dest_path: item.dest_path,
471 installed_checksum: item.installed_checksum,
472 }],
473 };
474
475 if result.contains_key(&key) {
476 let fallback_key = format!("{}/{}", item_v2.kind, dest_path.as_str());
479 diagnostics.push(Diagnostic {
480 level: crate::diagnostic::DiagnosticLevel::Warning,
481 code: "lock-promotion-collision",
482 message: format!(
483 "v1→v2 promotion: key collision on `{key}`; using dest_path key `{fallback_key}`"
484 ),
485 context: None,
486 category: None,
487 });
488 result.insert(fallback_key, item_v2);
489 } else {
490 result.insert(key, item_v2);
491 }
492 }
493
494 (result, diagnostics)
495}
496
497pub fn build(
507 graph: &crate::resolve::ResolvedGraph,
508 applied: &crate::sync::apply::ApplyResult,
509 old_lock: &LockFile,
510 config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
511) -> Result<LockFile, MarsError> {
512 use crate::sync::apply::ActionTaken;
513
514 let mut dependencies = IndexMap::new();
515 let mut items: IndexMap<String, LockedItemV2> = IndexMap::new();
516 let old_lock_index = LockIndex::new(old_lock);
517
518 for outcome in &applied.outcomes {
519 match outcome.action {
520 ActionTaken::Installed
521 | ActionTaken::Updated
522 | ActionTaken::Merged
523 | ActionTaken::Conflicted => {
524 let installed =
525 outcome
526 .installed_checksum
527 .as_ref()
528 .ok_or_else(|| LockError::Corrupt {
529 message: format!(
530 "missing checksum for write-producing action on {}",
531 outcome.dest_path
532 ),
533 })?;
534 if checksum_is_empty(installed) {
535 return Err(LockError::Corrupt {
536 message: format!("empty installed_checksum for {}", outcome.dest_path),
537 }
538 .into());
539 }
540
541 let source =
542 outcome
543 .source_checksum
544 .as_ref()
545 .ok_or_else(|| LockError::Corrupt {
546 message: format!(
547 "missing source checksum for write-producing action on {}",
548 outcome.dest_path
549 ),
550 })?;
551 if checksum_is_empty(source) {
552 return Err(LockError::Corrupt {
553 message: format!("empty source_checksum for {}", outcome.dest_path),
554 }
555 .into());
556 }
557 }
558 ActionTaken::Removed | ActionTaken::Skipped | ActionTaken::Kept => {}
559 }
560 }
561
562 for (name, node) in &graph.nodes {
564 dependencies.insert(name.clone(), to_locked_source(node));
565 }
566
567 for outcome in &applied.outcomes {
569 match &outcome.action {
570 ActionTaken::Removed | ActionTaken::Skipped => {
571 if matches!(outcome.action, ActionTaken::Skipped) {
573 let item_key = item_key(&outcome.item_id);
574 if let Some(old_item) = old_lock.items.get(&item_key) {
575 items.insert(item_key, old_item.clone());
576 } else {
577 if let Some(flat) =
580 old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
581 {
582 let key =
583 format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
584 items.entry(key).or_insert_with(|| LockedItemV2 {
585 source: flat.source,
586 kind: flat.kind,
587 version: flat.version,
588 source_checksum: flat.source_checksum,
589 outputs: vec![OutputRecord {
590 target_root: ".mars".to_string(),
591 dest_path: flat.dest_path,
592 installed_checksum: flat.installed_checksum,
593 }],
594 });
595 }
596 }
597 }
598 }
600 ActionTaken::Kept => {
601 let item_key = item_key(&outcome.item_id);
603 if let Some(old_item) = old_lock.items.get(&item_key) {
604 items.insert(item_key, old_item.clone());
605 } else if let Some(flat) =
606 old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
607 {
608 let key = format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
609 items.entry(key).or_insert_with(|| LockedItemV2 {
610 source: flat.source,
611 kind: flat.kind,
612 version: flat.version,
613 source_checksum: flat.source_checksum,
614 outputs: vec![OutputRecord {
615 target_root: ".mars".to_string(),
616 dest_path: flat.dest_path,
617 installed_checksum: flat.installed_checksum,
618 }],
619 });
620 }
621 }
622 ActionTaken::Installed
623 | ActionTaken::Updated
624 | ActionTaken::Merged
625 | ActionTaken::Conflicted => {
626 let dest_path = outcome.dest_path.clone();
627 if dest_path.as_str().is_empty() {
628 continue;
629 }
630
631 let source_name = if outcome.source_name.as_ref().is_empty() {
633 None
634 } else {
635 Some(outcome.source_name.clone())
636 };
637
638 let version = source_name.as_ref().and_then(|sn| {
640 graph
641 .nodes
642 .get(sn)
643 .and_then(|n| n.resolved_ref.version_tag.clone())
644 });
645
646 let source_checksum = outcome
647 .source_checksum
648 .clone()
649 .expect("validated above: source_checksum exists for write actions");
650 let installed_checksum = outcome
651 .installed_checksum
652 .clone()
653 .expect("validated above: installed_checksum exists for write actions");
654
655 let key = item_key(&outcome.item_id);
656 items.insert(
657 key,
658 LockedItemV2 {
659 source: source_name.unwrap_or_else(|| SourceName::from("")),
660 kind: outcome.item_id.kind,
661 version,
662 source_checksum,
663 outputs: vec![OutputRecord {
664 target_root: ".mars".to_string(),
665 dest_path,
666 installed_checksum,
667 }],
668 },
669 );
670 }
671 }
672 }
673
674 let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
676 let has_self_items = items.values().any(|item| item.source == local_source_name);
677 if has_self_items {
678 dependencies.insert(
679 local_source_name,
680 LockedSource {
681 url: None,
682 path: Some(".".into()),
683 subpath: None,
684 version: None,
685 commit: None,
686 tree_hash: None,
687 },
688 );
689 }
690
691 for item in items.values() {
693 if checksum_is_empty(&item.source_checksum) {
694 let dest = item
695 .outputs
696 .first()
697 .map(|o| o.dest_path.to_string())
698 .unwrap_or_default();
699 return Err(LockError::Corrupt {
700 message: format!("empty source_checksum for {dest}"),
701 }
702 .into());
703 }
704 for output in &item.outputs {
705 if checksum_is_empty(&output.installed_checksum) {
706 return Err(LockError::Corrupt {
707 message: format!("empty installed_checksum for {}", output.dest_path),
708 }
709 .into());
710 }
711 }
712 }
713
714 dependencies.sort_keys();
716 items.sort_keys();
717
718 Ok(LockFile {
719 version: LOCK_VERSION,
720 dependencies,
721 items,
722 config_entries,
723 })
724}
725
726pub fn apply_target_sync_outputs(
728 lock: &mut LockFile,
729 target_outcomes: &[crate::target_sync::TargetSyncOutcome],
730) {
731 for outcome in target_outcomes {
732 for dest_path in &outcome.removed_dest_paths {
733 remove_target_output(lock, &outcome.target, dest_path);
734 }
735 for synced in &outcome.synced_outputs {
736 upsert_target_output(
737 lock,
738 &outcome.target,
739 &synced.dest_path,
740 &synced.installed_checksum,
741 );
742 }
743 }
744}
745
746pub fn apply_compiled_native_outputs(
748 lock: &mut LockFile,
749 records: &[(String, String, ContentHash)],
750) {
751 for (target_root, dest_path, installed_checksum) in records {
752 upsert_target_output(lock, target_root, dest_path, installed_checksum);
753 }
754}
755
756fn upsert_target_output(
757 lock: &mut LockFile,
758 target_root: &str,
759 dest_path: &str,
760 installed_checksum: &ContentHash,
761) {
762 let dest = DestPath::from(dest_path);
763 for item in lock.items.values_mut() {
764 if !item.outputs.iter().any(|output| {
765 crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
766 }) {
767 continue;
768 }
769
770 if let Some(output) = item.outputs.iter_mut().find(|output| {
771 output.target_root == target_root
772 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
773 }) {
774 output.installed_checksum = installed_checksum.clone();
775 return;
776 }
777
778 item.outputs.push(OutputRecord {
779 target_root: target_root.to_string(),
780 dest_path: dest,
781 installed_checksum: installed_checksum.clone(),
782 });
783 item.outputs.sort_by(|a, b| {
784 a.target_root
785 .cmp(&b.target_root)
786 .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
787 });
788 return;
789 }
790}
791
792fn remove_target_output(lock: &mut LockFile, target_root: &str, dest_path: &str) {
793 for item in lock.items.values_mut() {
794 item.outputs.retain(|output| {
795 !(output.target_root == target_root
796 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path))
797 });
798 }
799 lock.items.retain(|_, item| !item.outputs.is_empty());
800}
801
802fn checksum_is_empty(checksum: &ContentHash) -> bool {
807 checksum.as_ref().trim().is_empty()
808}
809
810fn to_locked_source(node: &crate::resolve::ResolvedNode) -> LockedSource {
811 let (url, path, subpath) = match &node.source_id {
812 SourceId::Git { url, subpath } => (Some(url.clone()), None, subpath.clone()),
813 SourceId::Path { canonical, subpath } => (
814 None,
815 Some(canonical.to_string_lossy().to_string()),
816 subpath.clone(),
817 ),
818 };
819
820 LockedSource {
821 url,
822 path,
823 subpath,
824 version: node.resolved_ref.version_tag.clone(),
825 commit: node.resolved_ref.commit.clone(),
826 tree_hash: None,
827 }
828}
829
830pub fn item_key(id: &ItemId) -> String {
832 format!("{}/{}", id.kind, id.name)
833}
834
835#[cfg(test)]
840mod tests {
841 use super::*;
842 use std::collections::HashMap;
843 use std::path::PathBuf;
844
845 use crate::resolve::{ResolvedGraph, ResolvedNode};
846 use crate::source::ResolvedRef;
847 use crate::sync::apply::{ActionOutcome, ActionTaken, ApplyResult};
848 use crate::types::{SourceId, SourceUrl};
849 use tempfile::TempDir;
850
851 fn sample_lock() -> LockFile {
852 let mut dependencies = IndexMap::new();
853 dependencies.insert(
854 "base".into(),
855 LockedSource {
856 url: Some("https://github.com/org/base.git".into()),
857 path: None,
858 subpath: None,
859 version: Some("v1.0.0".into()),
860 commit: Some("abc123".into()),
861 tree_hash: Some("def456".into()),
862 },
863 );
864
865 let mut items = IndexMap::new();
866 items.insert(
867 "agent/coder".to_string(),
868 LockedItemV2 {
869 source: "base".into(),
870 kind: ItemKind::Agent,
871 version: Some("v1.0.0".into()),
872 source_checksum: "sha256:aaa".into(),
873 outputs: vec![OutputRecord {
874 target_root: ".mars".to_string(),
875 dest_path: "agents/coder.md".into(),
876 installed_checksum: "sha256:bbb".into(),
877 }],
878 },
879 );
880 items.insert(
881 "skill/review".to_string(),
882 LockedItemV2 {
883 source: "base".into(),
884 kind: ItemKind::Skill,
885 version: Some("v1.0.0".into()),
886 source_checksum: "sha256:ccc".into(),
887 outputs: vec![OutputRecord {
888 target_root: ".mars".to_string(),
889 dest_path: "skills/review".into(),
890 installed_checksum: "sha256:ddd".into(),
891 }],
892 },
893 );
894
895 LockFile {
896 version: LOCK_VERSION,
897 dependencies,
898 items,
899 config_entries: BTreeMap::new(),
900 }
901 }
902
903 #[test]
904 fn parse_v1_lock_file_promoted_to_v2() {
905 let toml_str = r#"
906version = 1
907
908[dependencies.base]
909url = "https://github.com/org/base.git"
910version = "v1.0.0"
911commit = "abc123"
912tree_hash = "def456"
913
914[items."agents/coder.md"]
915source = "base"
916kind = "agent"
917version = "v1.0.0"
918source_checksum = "sha256:aaa"
919installed_checksum = "sha256:bbb"
920dest_path = "agents/coder.md"
921"#;
922 let dir = TempDir::new().unwrap();
924 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
925 let lock = load(dir.path()).unwrap();
926
927 assert_eq!(lock.version, LOCK_VERSION);
929 assert_eq!(lock.dependencies.len(), 1);
930 assert_eq!(lock.items.len(), 1);
931
932 let item = &lock.items["agent/coder"];
934 assert_eq!(item.source, "base");
935 assert_eq!(item.kind, ItemKind::Agent);
936 assert_eq!(item.source_checksum, "sha256:aaa");
937 assert_eq!(item.outputs.len(), 1);
938 assert_eq!(item.outputs[0].installed_checksum, "sha256:bbb");
939 assert_eq!(item.outputs[0].dest_path.as_str(), "agents/coder.md");
940 assert_eq!(item.outputs[0].target_root, ".mars");
941 }
942
943 #[test]
944 fn parse_v2_lock_file() {
945 let toml_str = r#"
946version = 2
947
948[dependencies.base]
949url = "https://github.com/org/base.git"
950version = "v1.0.0"
951commit = "abc123"
952
953[items."agent/coder"]
954source = "base"
955kind = "agent"
956version = "v1.0.0"
957source_checksum = "sha256:aaa"
958
959[[items."agent/coder".outputs]]
960target_root = ".mars"
961dest_path = "agents/coder.md"
962installed_checksum = "sha256:bbb"
963"#;
964 let dir = TempDir::new().unwrap();
965 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
966 let lock = load(dir.path()).unwrap();
967
968 assert_eq!(lock.version, 2);
969 assert_eq!(lock.items.len(), 1);
970
971 let item = &lock.items["agent/coder"];
972 assert_eq!(item.source_checksum, "sha256:aaa");
973 assert_eq!(item.outputs[0].installed_checksum, "sha256:bbb");
974 }
975
976 #[test]
977 fn roundtrip_lock_file() {
978 let lock = sample_lock();
979 let dir = TempDir::new().unwrap();
980 write(dir.path(), &lock).unwrap();
981 let reloaded = load(dir.path()).unwrap();
982 assert_eq!(lock, reloaded);
983 }
984
985 #[test]
986 fn roundtrip_lock_file_with_config_entries() {
987 let mut lock = sample_lock();
988 lock.config_entries.insert(
989 ".claude".to_string(),
990 BTreeMap::from([(
991 "mcp:context7".to_string(),
992 ConfigEntryRecord {
993 source: "base".to_string(),
994 },
995 )]),
996 );
997
998 let dir = TempDir::new().unwrap();
999 write(dir.path(), &lock).unwrap();
1000 let reloaded = load(dir.path()).unwrap();
1001
1002 assert_eq!(lock, reloaded);
1003 assert_eq!(
1004 reloaded.config_entries[".claude"]["mcp:context7"].source,
1005 "base"
1006 );
1007 }
1008
1009 #[test]
1010 fn deterministic_serialization() {
1011 let lock = sample_lock();
1012 let s1 = toml::to_string_pretty(&lock).unwrap();
1013 let s2 = toml::to_string_pretty(&lock).unwrap();
1014 assert_eq!(s1, s2);
1015
1016 let coder_pos = s1.find("agent/coder").unwrap();
1018 let review_pos = s1.find("skill/review").unwrap();
1019 assert!(
1020 coder_pos < review_pos,
1021 "agent/coder should appear before skill/review"
1022 );
1023 }
1024
1025 #[test]
1026 fn empty_lock_file() {
1027 let lock = LockFile::empty();
1028 assert_eq!(lock.version, LOCK_VERSION);
1029 assert!(lock.dependencies.is_empty());
1030 assert!(lock.items.is_empty());
1031 }
1032
1033 #[test]
1034 fn load_absent_returns_empty() {
1035 let dir = TempDir::new().unwrap();
1036 let lock = load(dir.path()).unwrap();
1037 assert_eq!(lock.version, LOCK_VERSION);
1038 assert!(lock.dependencies.is_empty());
1039 assert!(lock.items.is_empty());
1040 }
1041
1042 #[test]
1043 fn write_and_reload() {
1044 let dir = TempDir::new().unwrap();
1045 let lock = sample_lock();
1046 write(dir.path(), &lock).unwrap();
1047 let reloaded = load(dir.path()).unwrap();
1048 assert_eq!(lock, reloaded);
1049 }
1050
1051 #[test]
1052 fn dual_checksums_present() {
1053 let lock = sample_lock();
1054 let item = &lock.items["agent/coder"];
1055 assert_ne!(item.source_checksum, item.outputs[0].installed_checksum);
1056 assert!(item.source_checksum.starts_with("sha256:"));
1057 assert!(item.outputs[0].installed_checksum.starts_with("sha256:"));
1058 }
1059
1060 #[test]
1061 fn path_source_in_lock() {
1062 let toml_str = r#"
1063version = 2
1064
1065[dependencies.local]
1066path = "/home/dev/agents"
1067
1068[items."agent/helper"]
1069source = "local"
1070kind = "agent"
1071source_checksum = "sha256:111"
1072
1073[[items."agent/helper".outputs]]
1074target_root = ".mars"
1075dest_path = "agents/helper.md"
1076installed_checksum = "sha256:222"
1077"#;
1078 let dir = TempDir::new().unwrap();
1079 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1080 let lock = load(dir.path()).unwrap();
1081 let source = &lock.dependencies["local"];
1082 assert!(source.url.is_none());
1083 assert_eq!(source.path.as_deref(), Some("/home/dev/agents"));
1084 assert!(source.commit.is_none());
1085 }
1086
1087 #[test]
1088 fn item_kind_serializes_lowercase() {
1089 let item = LockedItemV2 {
1090 source: "base".into(),
1091 kind: ItemKind::Skill,
1092 version: None,
1093 source_checksum: "sha256:aaa".into(),
1094 outputs: vec![OutputRecord {
1095 target_root: ".mars".to_string(),
1096 dest_path: "skills/review".into(),
1097 installed_checksum: "sha256:bbb".into(),
1098 }],
1099 };
1100 let serialized = toml::to_string(&item).unwrap();
1101 assert!(serialized.contains("kind = \"skill\""));
1102 }
1103
1104 #[test]
1105 fn item_id_display() {
1106 let id = ItemId {
1107 kind: ItemKind::Agent,
1108 name: "coder".into(),
1109 };
1110 assert_eq!(id.to_string(), "agent/coder");
1111 }
1112
1113 #[test]
1114 fn item_kind_display() {
1115 assert_eq!(ItemKind::Agent.to_string(), "agent");
1116 assert_eq!(ItemKind::Skill.to_string(), "skill");
1117 }
1118
1119 #[test]
1120 fn find_by_dest_path_returns_flat_view() {
1121 let lock = sample_lock();
1122 let found = lock
1123 .find_by_dest_path(&DestPath::from("agents/coder.md"))
1124 .unwrap();
1125 assert_eq!(found.source, "base");
1126 assert_eq!(found.kind, ItemKind::Agent);
1127 assert_eq!(found.source_checksum, "sha256:aaa");
1128 assert_eq!(found.installed_checksum, "sha256:bbb");
1129 assert_eq!(found.dest_path.as_str(), "agents/coder.md");
1130 }
1131
1132 #[test]
1133 fn find_by_dest_path_missing_returns_none() {
1134 let lock = sample_lock();
1135 assert!(
1136 lock.find_by_dest_path(&DestPath::from("agents/missing.md"))
1137 .is_none()
1138 );
1139 }
1140
1141 #[test]
1142 fn contains_dest_path_hit_and_miss() {
1143 let lock = sample_lock();
1144 assert!(lock.contains_dest_path(&DestPath::from("agents/coder.md")));
1145 assert!(!lock.contains_dest_path(&DestPath::from("agents/nobody.md")));
1146 }
1147
1148 #[test]
1149 fn lock_index_find_by_dest_path_hit_and_miss() {
1150 let lock = sample_lock();
1151 let index = LockIndex::new(&lock);
1152
1153 let found = index
1154 .find_by_dest_path(&DestPath::from("agents/coder.md"))
1155 .unwrap();
1156 assert_eq!(found.source, "base");
1157 assert_eq!(found.kind, ItemKind::Agent);
1158 assert_eq!(found.source_checksum, "sha256:aaa");
1159 assert_eq!(found.installed_checksum, "sha256:bbb");
1160 assert_eq!(found.dest_path.as_str(), "agents/coder.md");
1161
1162 assert!(
1163 index
1164 .find_by_dest_path(&DestPath::from("agents/missing.md"))
1165 .is_none()
1166 );
1167 }
1168
1169 #[test]
1170 fn lock_index_contains_dest_path_hit_and_miss() {
1171 let lock = sample_lock();
1172 let index = LockIndex::new(&lock);
1173
1174 assert!(index.contains_dest_path(&DestPath::from("agents/coder.md")));
1175 assert!(!index.contains_dest_path(&DestPath::from("agents/nobody.md")));
1176 }
1177
1178 #[test]
1179 fn lock_index_target_scoped_lookup_distinguishes_same_dest_path() {
1180 let mut lock = sample_lock();
1181 lock.items
1182 .get_mut("agent/coder")
1183 .unwrap()
1184 .outputs
1185 .push(OutputRecord {
1186 target_root: ".pi".to_string(),
1187 dest_path: "agents/coder.md".into(),
1188 installed_checksum: "sha256:pi".into(),
1189 });
1190
1191 let index = LockIndex::new(&lock);
1192 let dest = DestPath::from("agents/coder.md");
1193
1194 let mars = index
1195 .find_output(".mars", &dest)
1196 .expect("expected canonical .mars output");
1197 let pi = index
1198 .find_output(".pi", &dest)
1199 .expect("expected .pi output");
1200
1201 assert_eq!(mars.installed_checksum, "sha256:bbb");
1202 assert_eq!(pi.installed_checksum, "sha256:pi");
1203 assert!(index.contains_output(".mars", &dest));
1204 assert!(index.contains_output(".pi", &dest));
1205 assert!(!index.contains_output(".cursor", &dest));
1206 }
1207
1208 #[test]
1209 fn output_dest_paths_for_target_filters_by_target_root() {
1210 let mut lock = sample_lock();
1211 lock.items
1212 .get_mut("agent/coder")
1213 .unwrap()
1214 .outputs
1215 .push(OutputRecord {
1216 target_root: ".cursor".to_string(),
1217 dest_path: "agents/coder.md".into(),
1218 installed_checksum: "sha256:cursor".into(),
1219 });
1220
1221 let mars_paths = lock.output_dest_paths_for_target(".mars");
1222 assert!(mars_paths.contains("agents/coder.md"));
1223 assert!(mars_paths.contains("skills/review"));
1224
1225 let cursor_paths = lock.output_dest_paths_for_target(".cursor");
1226 assert_eq!(cursor_paths.len(), 1);
1227 assert!(cursor_paths.contains("agents/coder.md"));
1228 assert!(lock.output_dest_paths_for_target(".claude").is_empty());
1229 }
1230
1231 #[test]
1232 fn contains_output_matches_target_root_and_dest_path() {
1233 let mut lock = sample_lock();
1234 assert!(lock.contains_output(".mars", "agents/coder.md"));
1235 assert!(!lock.contains_output(".cursor", "agents/coder.md"));
1236
1237 lock.items
1238 .get_mut("agent/coder")
1239 .unwrap()
1240 .outputs
1241 .push(OutputRecord {
1242 target_root: ".cursor".to_string(),
1243 dest_path: "agents/coder.md".into(),
1244 installed_checksum: "sha256:cursor".into(),
1245 });
1246 assert!(lock.contains_output(".cursor", "agents/coder.md"));
1247 assert!(!lock.contains_output(".cursor", "agents/missing.md"));
1248 }
1249
1250 #[test]
1251 fn apply_target_sync_outputs_upserts_and_removes_target_records() {
1252 let mut lock = sample_lock();
1253 apply_target_sync_outputs(
1254 &mut lock,
1255 &[crate::target_sync::TargetSyncOutcome {
1256 target: ".cursor".to_string(),
1257 items_synced: 1,
1258 items_removed: 0,
1259 errors: Vec::new(),
1260 synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
1261 dest_path: "agents/coder.md".to_string(),
1262 installed_checksum: "sha256:cursor".into(),
1263 }],
1264 removed_dest_paths: Vec::new(),
1265 }],
1266 );
1267 assert!(lock.contains_output(".cursor", "agents/coder.md"));
1268
1269 apply_target_sync_outputs(
1270 &mut lock,
1271 &[crate::target_sync::TargetSyncOutcome {
1272 target: ".cursor".to_string(),
1273 items_synced: 0,
1274 items_removed: 1,
1275 errors: Vec::new(),
1276 synced_outputs: Vec::new(),
1277 removed_dest_paths: vec!["agents/coder.md".to_string()],
1278 }],
1279 );
1280 assert!(!lock.contains_output(".cursor", "agents/coder.md"));
1281 assert!(lock.contains_output(".mars", "agents/coder.md"));
1282 }
1283
1284 #[test]
1285 fn canonical_flat_items_excludes_linked_target_outputs() {
1286 let mut lock = sample_lock();
1287 lock.items
1288 .get_mut("agent/coder")
1289 .unwrap()
1290 .outputs
1291 .push(OutputRecord {
1292 target_root: ".cursor".to_string(),
1293 dest_path: "agents/coder.md".into(),
1294 installed_checksum: "sha256:cursor".into(),
1295 });
1296
1297 let canonical = lock.canonical_flat_items();
1298 assert_eq!(canonical.len(), 2);
1299 assert!(
1300 canonical
1301 .iter()
1302 .any(|(dp, _)| dp.as_str() == "agents/coder.md")
1303 );
1304 assert!(
1305 canonical
1306 .iter()
1307 .all(|(_, item)| { lock.contains_output(".mars", item.dest_path.as_str()) })
1308 );
1309
1310 let cursor = lock.flat_items_for_target(".cursor");
1311 assert_eq!(cursor.len(), 1);
1312 assert_eq!(cursor[0].0.as_str(), "agents/coder.md");
1313 }
1314
1315 #[test]
1316 fn flat_items_yields_all_outputs() {
1317 let lock = sample_lock();
1318 let flat = lock.flat_items();
1319 assert_eq!(flat.len(), 2);
1320 let paths: Vec<&str> = flat.iter().map(|(dp, _)| dp.as_str()).collect();
1321 assert!(paths.contains(&"agents/coder.md"));
1322 assert!(paths.contains(&"skills/review"));
1323 }
1324
1325 #[test]
1326 fn v1_lock_no_spurious_reinstall() {
1327 let v1_toml = r#"
1329version = 1
1330
1331[dependencies.base]
1332url = "https://github.com/org/base.git"
1333
1334[items."agents/coder.md"]
1335source = "base"
1336kind = "agent"
1337source_checksum = "sha256:src"
1338installed_checksum = "sha256:inst"
1339dest_path = "agents/coder.md"
1340"#;
1341 let dir = TempDir::new().unwrap();
1342 std::fs::write(dir.path().join("mars.lock"), v1_toml).unwrap();
1343 let lock = load(dir.path()).unwrap();
1344
1345 let found = lock.find_by_dest_path(&DestPath::from("agents/coder.md"));
1347 assert!(found.is_some());
1348 let item = found.unwrap();
1349 assert_eq!(item.source_checksum, "sha256:src");
1350 assert_eq!(item.installed_checksum, "sha256:inst");
1351 }
1352
1353 #[test]
1354 fn build_uses_graph_provenance_for_sources() {
1355 let git_name: SourceName = "base".into();
1356 let path_name: SourceName = "local".into();
1357 let git_url: SourceUrl = "https://example.com/new.git".into();
1358 let path_canonical = PathBuf::from("/tmp/mars-agents-local-source");
1359
1360 let mut nodes = IndexMap::new();
1361 nodes.insert(
1362 git_name.clone(),
1363 ResolvedNode {
1364 source_name: git_name.clone(),
1365 source_id: SourceId::git_with_subpath(
1366 git_url.clone(),
1367 Some(crate::types::SourceSubpath::new("plugins/base").unwrap()),
1368 ),
1369 rooted_ref: crate::resolve::RootedSourceRef {
1370 checkout_root: PathBuf::from("/tmp/cache/base"),
1371 package_root: PathBuf::from("/tmp/cache/base/plugins/base"),
1372 },
1373 resolved_ref: ResolvedRef {
1374 source_name: git_name.clone(),
1375 version: Some(semver::Version::new(1, 2, 3)),
1376 version_tag: Some("v1.2.3".into()),
1377 commit: Some("abc123".into()),
1378 tree_path: PathBuf::from("/tmp/cache/base"),
1379 },
1380 latest_version: None,
1381 manifest: None,
1382 deps: vec![],
1383 },
1384 );
1385 nodes.insert(
1386 path_name.clone(),
1387 ResolvedNode {
1388 source_name: path_name.clone(),
1389 source_id: SourceId::Path {
1390 canonical: path_canonical.clone(),
1391 subpath: Some(crate::types::SourceSubpath::new("plugins/local").unwrap()),
1392 },
1393 rooted_ref: crate::resolve::RootedSourceRef {
1394 checkout_root: PathBuf::from("/tmp/cache/local"),
1395 package_root: PathBuf::from("/tmp/cache/local/plugins/local"),
1396 },
1397 resolved_ref: ResolvedRef {
1398 source_name: path_name.clone(),
1399 version: None,
1400 version_tag: None,
1401 commit: None,
1402 tree_path: PathBuf::from("/tmp/cache/local"),
1403 },
1404 latest_version: None,
1405 manifest: None,
1406 deps: vec![],
1407 },
1408 );
1409
1410 let graph = ResolvedGraph {
1411 nodes,
1412 order: vec![git_name.clone(), path_name.clone()],
1413 filters: HashMap::new(),
1414 };
1415 let applied = ApplyResult { outcomes: vec![] };
1416
1417 let mut old_sources = IndexMap::new();
1418 old_sources.insert(
1419 git_name.clone(),
1420 LockedSource {
1421 url: Some("https://example.com/old.git".into()),
1422 path: None,
1423 subpath: None,
1424 version: Some("v0.0.1".into()),
1425 commit: Some("deadbeef".into()),
1426 tree_hash: None,
1427 },
1428 );
1429 let old_lock = LockFile {
1430 version: LOCK_VERSION,
1431 dependencies: old_sources,
1432 items: IndexMap::new(),
1433 config_entries: std::collections::BTreeMap::new(),
1434 };
1435
1436 let new_lock = build(
1437 &graph,
1438 &applied,
1439 &old_lock,
1440 std::collections::BTreeMap::new(),
1441 )
1442 .unwrap();
1443
1444 let base = &new_lock.dependencies["base"];
1445 assert_eq!(base.url.as_ref(), Some(&git_url));
1446 assert_eq!(
1447 base.subpath
1448 .as_ref()
1449 .map(crate::types::SourceSubpath::as_str),
1450 Some("plugins/base")
1451 );
1452 assert_eq!(base.version.as_deref(), Some("v1.2.3"));
1453 assert_eq!(base.commit.as_deref(), Some("abc123"));
1454
1455 let local = &new_lock.dependencies["local"];
1456 assert!(local.url.is_none());
1457 assert_eq!(
1458 local
1459 .subpath
1460 .as_ref()
1461 .map(crate::types::SourceSubpath::as_str),
1462 Some("plugins/local")
1463 );
1464 assert_eq!(
1465 local.path.as_deref(),
1466 Some(path_canonical.to_string_lossy().as_ref())
1467 );
1468 }
1469
1470 #[test]
1471 fn build_persists_ref_selector_in_locked_source_version() {
1472 let source_name: SourceName = "base".into();
1473 let mut nodes = IndexMap::new();
1474 nodes.insert(
1475 source_name.clone(),
1476 ResolvedNode {
1477 source_name: source_name.clone(),
1478 source_id: SourceId::git("https://example.com/base.git".into()),
1479 rooted_ref: crate::resolve::RootedSourceRef {
1480 checkout_root: PathBuf::from("/tmp/cache/base"),
1481 package_root: PathBuf::from("/tmp/cache/base"),
1482 },
1483 resolved_ref: ResolvedRef {
1484 source_name: source_name.clone(),
1485 version: None,
1486 version_tag: Some("main".into()),
1487 commit: Some("abc123".into()),
1488 tree_path: PathBuf::from("/tmp/cache/base"),
1489 },
1490 latest_version: None,
1491 manifest: None,
1492 deps: vec![],
1493 },
1494 );
1495
1496 let graph = ResolvedGraph {
1497 nodes,
1498 order: vec![source_name.clone()],
1499 filters: HashMap::new(),
1500 };
1501 let applied = ApplyResult { outcomes: vec![] };
1502 let new_lock = build(
1503 &graph,
1504 &applied,
1505 &LockFile::empty(),
1506 std::collections::BTreeMap::new(),
1507 )
1508 .unwrap();
1509
1510 let source = &new_lock.dependencies["base"];
1511 assert_eq!(source.version.as_deref(), Some("main"));
1512 assert_eq!(source.commit.as_deref(), Some("abc123"));
1513 }
1514
1515 #[test]
1516 fn build_keeps_self_items_from_old_lock_on_skipped_action() {
1517 let graph = ResolvedGraph {
1518 nodes: IndexMap::new(),
1519 order: Vec::new(),
1520 filters: HashMap::new(),
1521 };
1522 let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
1523 let old_lock = LockFile {
1524 version: LOCK_VERSION,
1525 dependencies: IndexMap::from([(
1526 local_source_name.clone(),
1527 LockedSource {
1528 url: None,
1529 path: Some(".".into()),
1530 subpath: None,
1531 version: None,
1532 commit: None,
1533 tree_hash: None,
1534 },
1535 )]),
1536 items: IndexMap::from([(
1537 "skill/local-skill".to_string(),
1538 LockedItemV2 {
1539 source: local_source_name.clone(),
1540 kind: ItemKind::Skill,
1541 version: None,
1542 source_checksum: "sha256:self".into(),
1543 outputs: vec![OutputRecord {
1544 target_root: ".mars".to_string(),
1545 dest_path: DestPath::from("skills/local-skill"),
1546 installed_checksum: "sha256:self".into(),
1547 }],
1548 },
1549 )]),
1550 config_entries: std::collections::BTreeMap::new(),
1551 };
1552 let applied = ApplyResult {
1553 outcomes: vec![ActionOutcome {
1554 item_id: ItemId {
1555 kind: ItemKind::Skill,
1556 name: "local-skill".into(),
1557 },
1558 action: ActionTaken::Skipped,
1559 dest_path: "skills/local-skill".into(),
1560 source_name: local_source_name.clone(),
1561 source_checksum: None,
1562 installed_checksum: None,
1563 }],
1564 };
1565
1566 let new_lock = build(
1567 &graph,
1568 &applied,
1569 &old_lock,
1570 std::collections::BTreeMap::new(),
1571 )
1572 .unwrap();
1573
1574 assert!(
1575 new_lock
1576 .dependencies
1577 .contains_key(local_source_name.as_str())
1578 );
1579 let item = &new_lock.items["skill/local-skill"];
1580 assert_eq!(item.source, local_source_name);
1581 assert_eq!(item.kind, ItemKind::Skill);
1582 assert_eq!(item.source_checksum, "sha256:self");
1583 assert_eq!(item.outputs[0].installed_checksum, "sha256:self");
1584 }
1585
1586 #[test]
1587 fn build_rejects_missing_installed_checksum_for_write_actions() {
1588 let graph = ResolvedGraph {
1589 nodes: IndexMap::new(),
1590 order: Vec::new(),
1591 filters: HashMap::new(),
1592 };
1593 let old_lock = LockFile::empty();
1594 let applied = ApplyResult {
1595 outcomes: vec![ActionOutcome {
1596 item_id: ItemId {
1597 kind: ItemKind::Agent,
1598 name: "coder".into(),
1599 },
1600 action: ActionTaken::Installed,
1601 dest_path: "agents/coder.md".into(),
1602 source_name: "base".into(),
1603 source_checksum: Some("sha256:source".into()),
1604 installed_checksum: None,
1605 }],
1606 };
1607
1608 let err = build(
1609 &graph,
1610 &applied,
1611 &old_lock,
1612 std::collections::BTreeMap::new(),
1613 )
1614 .unwrap_err();
1615 let msg = err.to_string();
1616 assert!(msg.contains("missing checksum for write-producing action"));
1617 assert!(msg.contains("agents/coder.md"));
1618 }
1619
1620 #[test]
1621 fn promote_v1_collision_both_survive() {
1622 let mut v1_items: IndexMap<DestPath, LockedItem> = IndexMap::new();
1626
1627 v1_items.insert(
1628 DestPath::from("hooks/pre-commit/hook.sh"),
1629 LockedItem {
1630 source: "base".into(),
1631 kind: ItemKind::Hook,
1632 version: None,
1633 source_checksum: "sha256:aaa".into(),
1634 installed_checksum: "sha256:bbb".into(),
1635 dest_path: DestPath::from("hooks/pre-commit/hook.sh"),
1636 },
1637 );
1638 v1_items.insert(
1639 DestPath::from("hooks/pre-push/hook.sh"),
1640 LockedItem {
1641 source: "base".into(),
1642 kind: ItemKind::Hook,
1643 version: None,
1644 source_checksum: "sha256:ccc".into(),
1645 installed_checksum: "sha256:ddd".into(),
1646 dest_path: DestPath::from("hooks/pre-push/hook.sh"),
1647 },
1648 );
1649
1650 let (promoted, diagnostics) = promote_v1_items(v1_items);
1651
1652 assert_eq!(promoted.len(), 2, "both items should survive promotion");
1654 assert_eq!(diagnostics.len(), 1);
1655
1656 let checksums: std::collections::HashSet<String> = promoted
1658 .values()
1659 .map(|v| v.source_checksum.as_ref().to_string())
1660 .collect();
1661 assert!(
1662 checksums.contains("sha256:aaa"),
1663 "pre-commit hook must be present"
1664 );
1665 assert!(
1666 checksums.contains("sha256:ccc"),
1667 "pre-push hook must be present"
1668 );
1669 }
1670
1671 #[test]
1672 fn load_with_diagnostics_reports_v1_promotion_collision() {
1673 let v1_toml = r#"
1674version = 1
1675
1676[dependencies.base]
1677url = "https://github.com/org/base.git"
1678
1679[items."hooks/pre-commit/hook.sh"]
1680source = "base"
1681kind = "hook"
1682source_checksum = "sha256:aaa"
1683installed_checksum = "sha256:bbb"
1684dest_path = "hooks/pre-commit/hook.sh"
1685
1686[items."hooks/pre-push/hook.sh"]
1687source = "base"
1688kind = "hook"
1689source_checksum = "sha256:ccc"
1690installed_checksum = "sha256:ddd"
1691dest_path = "hooks/pre-push/hook.sh"
1692"#;
1693 let dir = TempDir::new().unwrap();
1694 std::fs::write(dir.path().join("mars.lock"), v1_toml).unwrap();
1695
1696 let (lock, diagnostics) = load_with_diagnostics(dir.path()).unwrap();
1697
1698 assert_eq!(lock.version, LOCK_VERSION);
1699 assert_eq!(lock.items.len(), 2);
1700 assert_eq!(diagnostics.len(), 1);
1701 let diagnostic = &diagnostics[0];
1702 assert_eq!(
1703 diagnostic.level,
1704 crate::diagnostic::DiagnosticLevel::Warning
1705 );
1706 assert_eq!(diagnostic.code, "lock-promotion-collision");
1707 assert!(diagnostic.message.contains("key collision"));
1708 assert!(diagnostic.message.contains("hook/hooks/pre-push/hook.sh"));
1709 }
1710
1711 #[test]
1712 fn build_rejects_empty_checksums_from_carried_items() {
1713 let graph = ResolvedGraph {
1714 nodes: IndexMap::new(),
1715 order: Vec::new(),
1716 filters: HashMap::new(),
1717 };
1718 let old_lock = LockFile {
1719 version: LOCK_VERSION,
1720 dependencies: IndexMap::new(),
1721 items: IndexMap::from([(
1722 "agent/coder".to_string(),
1723 LockedItemV2 {
1724 source: "base".into(),
1725 kind: ItemKind::Agent,
1726 version: None,
1727 source_checksum: "".into(),
1728 outputs: vec![OutputRecord {
1729 target_root: ".mars".to_string(),
1730 dest_path: DestPath::from("agents/coder.md"),
1731 installed_checksum: "sha256:installed".into(),
1732 }],
1733 },
1734 )]),
1735 config_entries: std::collections::BTreeMap::new(),
1736 };
1737 let applied = ApplyResult {
1738 outcomes: vec![ActionOutcome {
1739 item_id: ItemId {
1740 kind: ItemKind::Agent,
1741 name: "coder".into(),
1742 },
1743 action: ActionTaken::Skipped,
1744 dest_path: "agents/coder.md".into(),
1745 source_name: "base".into(),
1746 source_checksum: None,
1747 installed_checksum: None,
1748 }],
1749 };
1750
1751 let err = build(
1752 &graph,
1753 &applied,
1754 &old_lock,
1755 std::collections::BTreeMap::new(),
1756 )
1757 .unwrap_err();
1758 let msg = err.to_string();
1759 assert!(msg.contains("empty source_checksum"));
1760 }
1761}