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#[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 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
127pub 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 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 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub tree_hash: Option<String>,
203}
204
205#[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 pub outputs: Vec<OutputRecord>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
222pub struct OutputRecord {
223 pub target_root: String,
225 pub dest_path: DestPath,
227 pub installed_checksum: ContentHash,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
233pub struct ConfigEntryRecord {
234 pub source: String,
235}
236
237#[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
252pub use crate::types::{ItemId, ItemKind};
255
256const LOCK_FILE: &str = "mars.lock";
257const LOCK_VERSION: u32 = 2;
259
260#[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#[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
287pub fn load(root: &Path) -> Result<LockFile, MarsError> {
297 let (lock, _) = load_with_diagnostics(root)?;
298 Ok(lock)
299}
300
301pub 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 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
356pub 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
365fn 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 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
416pub 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 for (name, node) in &graph.nodes {
483 dependencies.insert(name.clone(), to_locked_source(node));
484 }
485
486 for outcome in &applied.outcomes {
488 match &outcome.action {
489 ActionTaken::Removed | ActionTaken::Skipped => {
490 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 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 }
517 ActionTaken::Kept => {
518 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 let source_name = if outcome.source_name.as_ref().is_empty() {
548 None
549 } else {
550 Some(outcome.source_name.clone())
551 };
552
553 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 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 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 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
641fn 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
669pub fn item_key(id: &ItemId) -> String {
671 format!("{}/{}", id.kind, id.name)
672}
673
674#[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 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 assert_eq!(lock.version, LOCK_VERSION);
768 assert_eq!(lock.dependencies.len(), 1);
769 assert_eq!(lock.items.len(), 1);
770
771 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 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 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 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 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 assert_eq!(promoted.len(), 2, "both items should survive promotion");
1311 assert_eq!(diagnostics.len(), 1);
1312
1313 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}