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