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