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 ownership_lock_for_native_emission(
788 old_lock: &LockFile,
789 apply_outcomes: &[crate::sync::apply::ActionOutcome],
790 target_outcomes: &[crate::target_sync::TargetSyncOutcome],
791) -> LockFile {
792 let mut lock = old_lock.clone();
793 apply_apply_outcomes_to_lock(&mut lock, old_lock, apply_outcomes);
794 apply_target_sync_outputs(&mut lock, target_outcomes);
795 lock
796}
797
798pub fn ownership_lock_after_target_sync(
803 old_lock: &LockFile,
804 target_outcomes: &[crate::target_sync::TargetSyncOutcome],
805) -> LockFile {
806 let mut lock = old_lock.clone();
807 apply_target_sync_outputs(&mut lock, target_outcomes);
808 lock
809}
810
811pub fn apply_apply_outcomes_to_lock(
816 lock: &mut LockFile,
817 old_lock: &LockFile,
818 outcomes: &[crate::sync::apply::ActionOutcome],
819) {
820 use crate::sync::apply::ActionTaken;
821
822 let old_lock_index = LockIndex::new(old_lock);
823 for outcome in outcomes {
824 match outcome.action {
825 ActionTaken::Removed => {
826 lock.items.shift_remove(&item_key(&outcome.item_id));
827 }
828 ActionTaken::Skipped => {
829 let key = item_key(&outcome.item_id);
830 if lock.items.contains_key(&key) {
831 continue;
832 }
833 if let Some(old_item) = old_lock.items.get(&key) {
834 lock.items.insert(key, old_item.clone());
835 } else if let Some(flat) =
836 old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
837 {
838 let key = format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
839 lock.items.entry(key).or_insert_with(|| LockedItemV2 {
840 source: flat.source,
841 kind: flat.kind,
842 version: flat.version,
843 source_checksum: flat.source_checksum,
844 outputs: vec![OutputRecord {
845 target_root: CANONICAL_TARGET_ROOT.to_string(),
846 dest_path: flat.dest_path,
847 installed_checksum: flat.installed_checksum,
848 }],
849 });
850 }
851 }
852 ActionTaken::Kept => {
853 let key = item_key(&outcome.item_id);
854 if let Some(old_item) = old_lock.items.get(&key) {
855 lock.items.insert(key, old_item.clone());
856 } else if let Some(flat) =
857 old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
858 {
859 let key = format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
860 lock.items.entry(key).or_insert_with(|| LockedItemV2 {
861 source: flat.source,
862 kind: flat.kind,
863 version: flat.version,
864 source_checksum: flat.source_checksum,
865 outputs: vec![OutputRecord {
866 target_root: CANONICAL_TARGET_ROOT.to_string(),
867 dest_path: flat.dest_path,
868 installed_checksum: flat.installed_checksum,
869 }],
870 });
871 }
872 }
873 ActionTaken::Installed
874 | ActionTaken::Updated
875 | ActionTaken::Merged
876 | ActionTaken::Conflicted => {
877 if outcome.dest_path.as_str().is_empty() {
878 continue;
879 }
880 let Some(source_checksum) = outcome
881 .source_checksum
882 .as_ref()
883 .filter(|checksum| !checksum_is_empty(checksum))
884 else {
885 continue;
886 };
887 let Some(installed_checksum) = outcome
888 .installed_checksum
889 .as_ref()
890 .filter(|checksum| !checksum_is_empty(checksum))
891 else {
892 continue;
893 };
894
895 let source_name = if outcome.source_name.as_ref().is_empty() {
896 SourceName::from("")
897 } else {
898 outcome.source_name.clone()
899 };
900
901 let key = item_key(&outcome.item_id);
902 lock.items.insert(
903 key,
904 LockedItemV2 {
905 source: source_name,
906 kind: outcome.item_id.kind,
907 version: None,
908 source_checksum: source_checksum.clone(),
909 outputs: vec![OutputRecord {
910 target_root: CANONICAL_TARGET_ROOT.to_string(),
911 dest_path: outcome.dest_path.clone(),
912 installed_checksum: installed_checksum.clone(),
913 }],
914 },
915 );
916 }
917 }
918 }
919}
920
921pub fn apply_target_sync_outputs(
923 lock: &mut LockFile,
924 target_outcomes: &[crate::target_sync::TargetSyncOutcome],
925) {
926 for outcome in target_outcomes {
927 for dest_path in &outcome.removed_dest_paths {
928 remove_target_output(lock, &outcome.target, dest_path);
929 }
930 for synced in &outcome.synced_outputs {
931 upsert_target_output(
932 lock,
933 &outcome.target,
934 &synced.dest_path,
935 &synced.installed_checksum,
936 );
937 }
938 }
939}
940
941#[derive(Debug, Clone, PartialEq, Eq)]
943pub struct CompiledNativeOutput {
944 pub owner_canonical_dest_path: String,
946 pub target_root: String,
947 pub dest_path: String,
948 pub installed_checksum: ContentHash,
949}
950
951pub fn apply_removed_native_outputs(lock: &mut LockFile, records: &[(String, String)]) {
953 for (target_root, dest_path) in records {
954 remove_target_output(lock, target_root, dest_path);
955 }
956}
957
958pub fn apply_compiled_native_outputs(lock: &mut LockFile, records: &[CompiledNativeOutput]) {
960 for record in records {
961 upsert_native_output_on_owner(
962 lock,
963 &record.owner_canonical_dest_path,
964 &record.target_root,
965 &record.dest_path,
966 &record.installed_checksum,
967 );
968 }
969}
970
971fn upsert_target_output(
972 lock: &mut LockFile,
973 target_root: &str,
974 dest_path: &str,
975 installed_checksum: &ContentHash,
976) {
977 let dest = DestPath::from(dest_path);
978 for item in lock.items.values_mut() {
979 if !item.outputs.iter().any(|output| {
980 crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
981 }) {
982 continue;
983 }
984
985 if let Some(output) = item.outputs.iter_mut().find(|output| {
986 output.target_root == target_root
987 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
988 }) {
989 output.installed_checksum = installed_checksum.clone();
990 return;
991 }
992
993 item.outputs.push(OutputRecord {
994 target_root: target_root.to_string(),
995 dest_path: dest,
996 installed_checksum: installed_checksum.clone(),
997 });
998 item.outputs.sort_by(|a, b| {
999 a.target_root
1000 .cmp(&b.target_root)
1001 .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
1002 });
1003 return;
1004 }
1005}
1006
1007fn upsert_native_output_on_owner(
1008 lock: &mut LockFile,
1009 owner_canonical_dest_path: &str,
1010 target_root: &str,
1011 native_dest_path: &str,
1012 installed_checksum: &ContentHash,
1013) {
1014 let native_dest = DestPath::from(native_dest_path);
1015 for item in lock.items.values_mut() {
1016 let owns_canonical = item.outputs.iter().any(|output| {
1017 output.target_root == CANONICAL_TARGET_ROOT
1018 && crate::target::dest_paths_equivalent(
1019 output.dest_path.as_str(),
1020 owner_canonical_dest_path,
1021 )
1022 });
1023 if !owns_canonical {
1024 continue;
1025 }
1026
1027 if let Some(output) = item.outputs.iter_mut().find(|output| {
1028 output.target_root == target_root
1029 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), native_dest_path)
1030 }) {
1031 output.installed_checksum = installed_checksum.clone();
1032 return;
1033 }
1034
1035 item.outputs.push(OutputRecord {
1036 target_root: target_root.to_string(),
1037 dest_path: native_dest,
1038 installed_checksum: installed_checksum.clone(),
1039 });
1040 item.outputs.sort_by(|a, b| {
1041 a.target_root
1042 .cmp(&b.target_root)
1043 .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
1044 });
1045 return;
1046 }
1047}
1048
1049fn remove_target_output(lock: &mut LockFile, target_root: &str, dest_path: &str) {
1050 for item in lock.items.values_mut() {
1051 item.outputs.retain(|output| {
1052 !(output.target_root == target_root
1053 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path))
1054 });
1055 }
1056 lock.items.retain(|_, item| !item.outputs.is_empty());
1057}
1058
1059fn checksum_is_empty(checksum: &ContentHash) -> bool {
1064 checksum.as_ref().trim().is_empty()
1065}
1066
1067fn to_locked_source(node: &crate::resolve::ResolvedNode) -> LockedSource {
1068 let (url, path, subpath) = match &node.source_id {
1069 SourceId::Git { url, subpath } => (Some(url.clone()), None, subpath.clone()),
1070 SourceId::Path { canonical, subpath } => (
1071 None,
1072 Some(canonical.to_string_lossy().to_string()),
1073 subpath.clone(),
1074 ),
1075 };
1076
1077 LockedSource {
1078 url,
1079 path,
1080 subpath,
1081 version: node.resolved_ref.version_tag.clone(),
1082 commit: node.resolved_ref.commit.clone(),
1083 tree_hash: None,
1084 }
1085}
1086
1087pub fn item_key(id: &ItemId) -> String {
1089 format!("{}/{}", id.kind, id.name)
1090}
1091
1092#[cfg(test)]
1097mod tests {
1098 use super::*;
1099 use std::collections::HashMap;
1100 use std::path::PathBuf;
1101
1102 use crate::resolve::{ResolvedGraph, ResolvedNode};
1103 use crate::source::ResolvedRef;
1104 use crate::sync::apply::{ActionOutcome, ActionTaken, ApplyResult};
1105 use crate::types::{ItemName, SourceId, SourceUrl};
1106 use tempfile::TempDir;
1107
1108 fn sample_lock() -> LockFile {
1109 let mut dependencies = IndexMap::new();
1110 dependencies.insert(
1111 "base".into(),
1112 LockedSource {
1113 url: Some("https://github.com/org/base.git".into()),
1114 path: None,
1115 subpath: None,
1116 version: Some("v1.0.0".into()),
1117 commit: Some("abc123".into()),
1118 tree_hash: Some("def456".into()),
1119 },
1120 );
1121
1122 let mut items = IndexMap::new();
1123 items.insert(
1124 "agent/coder".to_string(),
1125 LockedItemV2 {
1126 source: "base".into(),
1127 kind: ItemKind::Agent,
1128 version: Some("v1.0.0".into()),
1129 source_checksum: "sha256:aaa".into(),
1130 outputs: vec![OutputRecord {
1131 target_root: ".mars".to_string(),
1132 dest_path: "agents/coder.md".into(),
1133 installed_checksum: "sha256:bbb".into(),
1134 }],
1135 },
1136 );
1137 items.insert(
1138 "skill/review".to_string(),
1139 LockedItemV2 {
1140 source: "base".into(),
1141 kind: ItemKind::Skill,
1142 version: Some("v1.0.0".into()),
1143 source_checksum: "sha256:ccc".into(),
1144 outputs: vec![OutputRecord {
1145 target_root: ".mars".to_string(),
1146 dest_path: "skills/review".into(),
1147 installed_checksum: "sha256:ddd".into(),
1148 }],
1149 },
1150 );
1151
1152 LockFile {
1153 version: LOCK_VERSION,
1154 dependencies,
1155 items,
1156 config_entries: BTreeMap::new(),
1157 dependency_model_aliases: IndexMap::new(),
1158 }
1159 }
1160
1161 #[test]
1162 fn parse_v1_lock_file_promoted_to_v2() {
1163 let toml_str = r#"
1164version = 1
1165
1166[dependencies.base]
1167url = "https://github.com/org/base.git"
1168version = "v1.0.0"
1169commit = "abc123"
1170tree_hash = "def456"
1171
1172[items."agents/coder.md"]
1173source = "base"
1174kind = "agent"
1175version = "v1.0.0"
1176source_checksum = "sha256:aaa"
1177installed_checksum = "sha256:bbb"
1178dest_path = "agents/coder.md"
1179"#;
1180 let dir = TempDir::new().unwrap();
1182 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1183 let lock = load(dir.path()).unwrap();
1184
1185 assert_eq!(lock.version, LOCK_VERSION);
1187 assert_eq!(lock.dependencies.len(), 1);
1188 assert_eq!(lock.items.len(), 1);
1189
1190 let item = &lock.items["agent/coder"];
1192 assert_eq!(item.source, "base");
1193 assert_eq!(item.kind, ItemKind::Agent);
1194 assert_eq!(item.source_checksum, "sha256:aaa");
1195 assert_eq!(item.outputs.len(), 1);
1196 assert_eq!(item.outputs[0].installed_checksum, "sha256:bbb");
1197 assert_eq!(item.outputs[0].dest_path.as_str(), "agents/coder.md");
1198 assert_eq!(item.outputs[0].target_root, ".mars");
1199 }
1200
1201 #[test]
1202 fn parse_v2_lock_file() {
1203 let toml_str = r#"
1204version = 2
1205
1206[dependencies.base]
1207url = "https://github.com/org/base.git"
1208version = "v1.0.0"
1209commit = "abc123"
1210
1211[items."agent/coder"]
1212source = "base"
1213kind = "agent"
1214version = "v1.0.0"
1215source_checksum = "sha256:aaa"
1216
1217[[items."agent/coder".outputs]]
1218target_root = ".mars"
1219dest_path = "agents/coder.md"
1220installed_checksum = "sha256:bbb"
1221"#;
1222 let dir = TempDir::new().unwrap();
1223 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1224 let lock = load(dir.path()).unwrap();
1225
1226 assert_eq!(lock.version, 2);
1227 assert_eq!(lock.items.len(), 1);
1228
1229 let item = &lock.items["agent/coder"];
1230 assert_eq!(item.source_checksum, "sha256:aaa");
1231 assert_eq!(item.outputs[0].installed_checksum, "sha256:bbb");
1232 }
1233
1234 #[test]
1235 fn load_for_runtime_aliases_rejects_legacy_v2_without_dependency_alias_authority() {
1236 let toml_str = r#"
1237version = 2
1238
1239[dependencies.base]
1240url = "https://github.com/org/base.git"
1241version = "v1.0.0"
1242commit = "abc123"
1243
1244[items."agent/coder"]
1245source = "base"
1246kind = "agent"
1247source_checksum = "sha256:aaa"
1248
1249[[items."agent/coder".outputs]]
1250target_root = ".mars"
1251dest_path = "agents/coder.md"
1252installed_checksum = "sha256:bbb"
1253"#;
1254 let dir = TempDir::new().unwrap();
1255 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1256
1257 let err = load_for_runtime_aliases(dir.path()).unwrap_err();
1258 let message = err.to_string();
1259 assert!(message.contains("missing `dependency_model_aliases`"));
1260 assert!(message.contains("run `mars sync`"));
1261 }
1262
1263 #[test]
1264 fn load_for_runtime_aliases_allows_missing_dependency_aliases_when_no_dependencies() {
1265 let toml_str = r#"
1266version = 2
1267
1268[items."agent/coder"]
1269source = "_self"
1270kind = "agent"
1271source_checksum = "sha256:aaa"
1272
1273[[items."agent/coder".outputs]]
1274target_root = ".mars"
1275dest_path = "agents/coder.md"
1276installed_checksum = "sha256:bbb"
1277"#;
1278 let dir = TempDir::new().unwrap();
1279 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1280
1281 let lock = load_for_runtime_aliases(dir.path()).unwrap();
1282 assert!(lock.dependencies.is_empty());
1283 assert!(lock.dependency_model_aliases.is_empty());
1284 }
1285
1286 #[test]
1287 fn roundtrip_lock_file() {
1288 let lock = sample_lock();
1289 let dir = TempDir::new().unwrap();
1290 write(dir.path(), &lock).unwrap();
1291 let reloaded = load(dir.path()).unwrap();
1292 assert_eq!(lock, reloaded);
1293 }
1294
1295 #[test]
1296 fn roundtrip_lock_file_with_config_entries() {
1297 let mut lock = sample_lock();
1298 lock.config_entries.insert(
1299 ".claude".to_string(),
1300 BTreeMap::from([(
1301 "mcp:context7".to_string(),
1302 ConfigEntryRecord {
1303 source: "base".to_string(),
1304 },
1305 )]),
1306 );
1307
1308 let dir = TempDir::new().unwrap();
1309 write(dir.path(), &lock).unwrap();
1310 let reloaded = load(dir.path()).unwrap();
1311
1312 assert_eq!(lock, reloaded);
1313 assert_eq!(
1314 reloaded.config_entries[".claude"]["mcp:context7"].source,
1315 "base"
1316 );
1317 }
1318
1319 #[test]
1320 fn write_emits_dependency_model_aliases_table_even_when_empty() {
1321 let lock = sample_lock();
1322 let dir = TempDir::new().unwrap();
1323 write(dir.path(), &lock).unwrap();
1324
1325 let content = std::fs::read_to_string(dir.path().join("mars.lock")).unwrap();
1326 assert!(
1327 content.contains("dependency_model_aliases"),
1328 "serialized lock should include dependency_model_aliases authority table"
1329 );
1330 }
1331
1332 #[test]
1333 fn deterministic_serialization() {
1334 let lock = sample_lock();
1335 let s1 = toml::to_string_pretty(&lock).unwrap();
1336 let s2 = toml::to_string_pretty(&lock).unwrap();
1337 assert_eq!(s1, s2);
1338
1339 let coder_pos = s1.find("agent/coder").unwrap();
1341 let review_pos = s1.find("skill/review").unwrap();
1342 assert!(
1343 coder_pos < review_pos,
1344 "agent/coder should appear before skill/review"
1345 );
1346 }
1347
1348 #[test]
1349 fn write_sorts_dependency_model_aliases_keys() {
1350 let toml_str = r#"
1351version = 2
1352
1353[dependency_model_aliases.zeta]
1354model = "openai/gpt-z"
1355
1356[dependency_model_aliases.alpha]
1357model = "openai/gpt-a"
1358"#;
1359 let dir = TempDir::new().unwrap();
1360 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1361
1362 let lock = load(dir.path()).unwrap();
1363 write(dir.path(), &lock).unwrap();
1364
1365 let written = std::fs::read_to_string(dir.path().join("mars.lock")).unwrap();
1366 let alpha = written
1367 .find("[dependency_model_aliases.alpha]")
1368 .expect("alpha alias should be serialized");
1369 let zeta = written
1370 .find("[dependency_model_aliases.zeta]")
1371 .expect("zeta alias should be serialized");
1372 assert!(alpha < zeta, "aliases should serialize in sorted key order");
1373 }
1374
1375 #[test]
1376 fn empty_lock_file() {
1377 let lock = LockFile::empty();
1378 assert_eq!(lock.version, LOCK_VERSION);
1379 assert!(lock.dependencies.is_empty());
1380 assert!(lock.items.is_empty());
1381 }
1382
1383 #[test]
1384 fn load_absent_returns_empty() {
1385 let dir = TempDir::new().unwrap();
1386 let lock = load(dir.path()).unwrap();
1387 assert_eq!(lock.version, LOCK_VERSION);
1388 assert!(lock.dependencies.is_empty());
1389 assert!(lock.items.is_empty());
1390 }
1391
1392 #[test]
1393 fn write_and_reload() {
1394 let dir = TempDir::new().unwrap();
1395 let lock = sample_lock();
1396 write(dir.path(), &lock).unwrap();
1397 let reloaded = load(dir.path()).unwrap();
1398 assert_eq!(lock, reloaded);
1399 }
1400
1401 #[test]
1402 fn dual_checksums_present() {
1403 let lock = sample_lock();
1404 let item = &lock.items["agent/coder"];
1405 assert_ne!(item.source_checksum, item.outputs[0].installed_checksum);
1406 assert!(item.source_checksum.starts_with("sha256:"));
1407 assert!(item.outputs[0].installed_checksum.starts_with("sha256:"));
1408 }
1409
1410 #[test]
1411 fn path_source_in_lock() {
1412 let toml_str = r#"
1413version = 2
1414
1415[dependencies.local]
1416path = "/home/dev/agents"
1417
1418[items."agent/helper"]
1419source = "local"
1420kind = "agent"
1421source_checksum = "sha256:111"
1422
1423[[items."agent/helper".outputs]]
1424target_root = ".mars"
1425dest_path = "agents/helper.md"
1426installed_checksum = "sha256:222"
1427"#;
1428 let dir = TempDir::new().unwrap();
1429 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1430 let lock = load(dir.path()).unwrap();
1431 let source = &lock.dependencies["local"];
1432 assert!(source.url.is_none());
1433 assert_eq!(source.path.as_deref(), Some("/home/dev/agents"));
1434 assert!(source.commit.is_none());
1435 }
1436
1437 #[test]
1438 fn item_kind_serializes_lowercase() {
1439 let item = LockedItemV2 {
1440 source: "base".into(),
1441 kind: ItemKind::Skill,
1442 version: None,
1443 source_checksum: "sha256:aaa".into(),
1444 outputs: vec![OutputRecord {
1445 target_root: ".mars".to_string(),
1446 dest_path: "skills/review".into(),
1447 installed_checksum: "sha256:bbb".into(),
1448 }],
1449 };
1450 let serialized = toml::to_string(&item).unwrap();
1451 assert!(serialized.contains("kind = \"skill\""));
1452 }
1453
1454 #[test]
1455 fn item_id_display() {
1456 let id = ItemId {
1457 kind: ItemKind::Agent,
1458 name: "coder".into(),
1459 };
1460 assert_eq!(id.to_string(), "agent/coder");
1461 }
1462
1463 #[test]
1464 fn item_kind_display() {
1465 assert_eq!(ItemKind::Agent.to_string(), "agent");
1466 assert_eq!(ItemKind::Skill.to_string(), "skill");
1467 }
1468
1469 #[test]
1470 fn find_by_dest_path_returns_flat_view() {
1471 let lock = sample_lock();
1472 let found = lock
1473 .find_by_dest_path(&DestPath::from("agents/coder.md"))
1474 .unwrap();
1475 assert_eq!(found.source, "base");
1476 assert_eq!(found.kind, ItemKind::Agent);
1477 assert_eq!(found.source_checksum, "sha256:aaa");
1478 assert_eq!(found.installed_checksum, "sha256:bbb");
1479 assert_eq!(found.dest_path.as_str(), "agents/coder.md");
1480 }
1481
1482 #[test]
1483 fn find_by_dest_path_missing_returns_none() {
1484 let lock = sample_lock();
1485 assert!(
1486 lock.find_by_dest_path(&DestPath::from("agents/missing.md"))
1487 .is_none()
1488 );
1489 }
1490
1491 #[test]
1492 fn contains_dest_path_hit_and_miss() {
1493 let lock = sample_lock();
1494 assert!(lock.contains_dest_path(&DestPath::from("agents/coder.md")));
1495 assert!(!lock.contains_dest_path(&DestPath::from("agents/nobody.md")));
1496 }
1497
1498 #[test]
1499 fn lock_index_find_by_dest_path_hit_and_miss() {
1500 let lock = sample_lock();
1501 let index = LockIndex::new(&lock);
1502
1503 let found = index
1504 .find_by_dest_path(&DestPath::from("agents/coder.md"))
1505 .unwrap();
1506 assert_eq!(found.source, "base");
1507 assert_eq!(found.kind, ItemKind::Agent);
1508 assert_eq!(found.source_checksum, "sha256:aaa");
1509 assert_eq!(found.installed_checksum, "sha256:bbb");
1510 assert_eq!(found.dest_path.as_str(), "agents/coder.md");
1511
1512 assert!(
1513 index
1514 .find_by_dest_path(&DestPath::from("agents/missing.md"))
1515 .is_none()
1516 );
1517 }
1518
1519 #[test]
1520 fn lock_index_contains_dest_path_hit_and_miss() {
1521 let lock = sample_lock();
1522 let index = LockIndex::new(&lock);
1523
1524 assert!(index.contains_dest_path(&DestPath::from("agents/coder.md")));
1525 assert!(!index.contains_dest_path(&DestPath::from("agents/nobody.md")));
1526 }
1527
1528 #[test]
1529 fn lock_index_target_scoped_lookup_distinguishes_same_dest_path() {
1530 let mut lock = sample_lock();
1531 lock.items
1532 .get_mut("agent/coder")
1533 .unwrap()
1534 .outputs
1535 .push(OutputRecord {
1536 target_root: ".pi".to_string(),
1537 dest_path: "agents/coder.md".into(),
1538 installed_checksum: "sha256:pi".into(),
1539 });
1540
1541 let index = LockIndex::new(&lock);
1542 let dest = DestPath::from("agents/coder.md");
1543
1544 let mars = index
1545 .find_output(".mars", &dest)
1546 .expect("expected canonical .mars output");
1547 let pi = index
1548 .find_output(".pi", &dest)
1549 .expect("expected .pi output");
1550
1551 assert_eq!(mars.installed_checksum, "sha256:bbb");
1552 assert_eq!(pi.installed_checksum, "sha256:pi");
1553 assert!(index.contains_output(".mars", &dest));
1554 assert!(index.contains_output(".pi", &dest));
1555 assert!(!index.contains_output(".cursor", &dest));
1556 }
1557
1558 #[test]
1559 fn output_dest_paths_for_target_filters_by_target_root() {
1560 let mut lock = sample_lock();
1561 lock.items
1562 .get_mut("agent/coder")
1563 .unwrap()
1564 .outputs
1565 .push(OutputRecord {
1566 target_root: ".cursor".to_string(),
1567 dest_path: "agents/coder.md".into(),
1568 installed_checksum: "sha256:cursor".into(),
1569 });
1570
1571 let mars_paths = lock.output_dest_paths_for_target(".mars");
1572 assert!(mars_paths.contains("agents/coder.md"));
1573 assert!(mars_paths.contains("skills/review"));
1574
1575 let cursor_paths = lock.output_dest_paths_for_target(".cursor");
1576 assert_eq!(cursor_paths.len(), 1);
1577 assert!(cursor_paths.contains("agents/coder.md"));
1578 assert!(lock.output_dest_paths_for_target(".claude").is_empty());
1579 }
1580
1581 #[test]
1582 fn contains_output_matches_target_root_and_dest_path() {
1583 let mut lock = sample_lock();
1584 assert!(lock.contains_output(".mars", "agents/coder.md"));
1585 assert!(!lock.contains_output(".cursor", "agents/coder.md"));
1586
1587 lock.items
1588 .get_mut("agent/coder")
1589 .unwrap()
1590 .outputs
1591 .push(OutputRecord {
1592 target_root: ".cursor".to_string(),
1593 dest_path: "agents/coder.md".into(),
1594 installed_checksum: "sha256:cursor".into(),
1595 });
1596 assert!(lock.contains_output(".cursor", "agents/coder.md"));
1597 assert!(!lock.contains_output(".cursor", "agents/missing.md"));
1598 }
1599
1600 #[test]
1601 fn apply_compiled_native_outputs_upserts_codex_native_by_canonical_owner() {
1602 let mut lock = sample_lock();
1603 apply_compiled_native_outputs(
1604 &mut lock,
1605 &[CompiledNativeOutput {
1606 owner_canonical_dest_path: "agents/coder.md".to_string(),
1607 target_root: ".codex".to_string(),
1608 dest_path: "agents/coder.toml".to_string(),
1609 installed_checksum: "sha256:codex".into(),
1610 }],
1611 );
1612 assert!(lock.contains_output(".codex", "agents/coder.toml"));
1613 assert!(lock.contains_output(".mars", "agents/coder.md"));
1614 }
1615
1616 #[test]
1617 fn apply_compiled_native_outputs_upserts_when_frontmatter_name_differs_from_filename() {
1618 let mut lock = sample_lock();
1619 lock.items.insert(
1620 "agent/alias-name".to_string(),
1621 LockedItemV2 {
1622 source: "base".into(),
1623 kind: ItemKind::Agent,
1624 version: Some("v1.0.0".into()),
1625 source_checksum: "sha256:alias-src".into(),
1626 outputs: vec![OutputRecord {
1627 target_root: ".mars".to_string(),
1628 dest_path: "agents/on-disk-stem.md".into(),
1629 installed_checksum: "sha256:alias-mars".into(),
1630 }],
1631 },
1632 );
1633 apply_compiled_native_outputs(
1634 &mut lock,
1635 &[CompiledNativeOutput {
1636 owner_canonical_dest_path: "agents/on-disk-stem.md".to_string(),
1637 target_root: ".claude".to_string(),
1638 dest_path: "agents/alias-name.md".to_string(),
1639 installed_checksum: "sha256:claude-native".into(),
1640 }],
1641 );
1642 assert!(lock.contains_output(".claude", "agents/alias-name.md"));
1643 }
1644
1645 #[test]
1646 fn ownership_lock_for_native_emission_seeds_new_apply_outcomes() {
1647 let old_lock = LockFile::empty();
1648 let apply_outcomes = vec![ActionOutcome {
1649 item_id: ItemId {
1650 kind: ItemKind::Agent,
1651 name: ItemName::from("coder"),
1652 },
1653 action: ActionTaken::Installed,
1654 dest_path: "agents/coder.md".into(),
1655 source_name: "base".into(),
1656 source_checksum: Some("sha256:src".into()),
1657 installed_checksum: Some("sha256:mars".into()),
1658 }];
1659 let view = ownership_lock_for_native_emission(
1660 &old_lock,
1661 &apply_outcomes,
1662 &[crate::target_sync::TargetSyncOutcome {
1663 target: ".cursor".to_string(),
1664 items_synced: 1,
1665 items_removed: 0,
1666 errors: Vec::new(),
1667 synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
1668 dest_path: "agents/coder.md".to_string(),
1669 installed_checksum: "sha256:cursor".into(),
1670 }],
1671 removed_dest_paths: Vec::new(),
1672 }],
1673 );
1674 assert!(view.contains_output(".mars", "agents/coder.md"));
1675 assert!(view.contains_output(".cursor", "agents/coder.md"));
1676 assert!(!old_lock.contains_output(".mars", "agents/coder.md"));
1677 }
1678
1679 #[test]
1680 fn ownership_lock_after_target_sync_layers_synced_outputs() {
1681 let lock = sample_lock();
1682 let view = ownership_lock_after_target_sync(
1683 &lock,
1684 &[crate::target_sync::TargetSyncOutcome {
1685 target: ".cursor".to_string(),
1686 items_synced: 1,
1687 items_removed: 0,
1688 errors: Vec::new(),
1689 synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
1690 dest_path: "agents/coder.md".to_string(),
1691 installed_checksum: "sha256:cursor".into(),
1692 }],
1693 removed_dest_paths: Vec::new(),
1694 }],
1695 );
1696 assert!(view.contains_output(".cursor", "agents/coder.md"));
1697 assert!(!lock.contains_output(".cursor", "agents/coder.md"));
1698 }
1699
1700 #[test]
1701 fn apply_target_sync_outputs_upserts_and_removes_target_records() {
1702 let mut lock = sample_lock();
1703 apply_target_sync_outputs(
1704 &mut lock,
1705 &[crate::target_sync::TargetSyncOutcome {
1706 target: ".cursor".to_string(),
1707 items_synced: 1,
1708 items_removed: 0,
1709 errors: Vec::new(),
1710 synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
1711 dest_path: "agents/coder.md".to_string(),
1712 installed_checksum: "sha256:cursor".into(),
1713 }],
1714 removed_dest_paths: Vec::new(),
1715 }],
1716 );
1717 assert!(lock.contains_output(".cursor", "agents/coder.md"));
1718
1719 apply_target_sync_outputs(
1720 &mut lock,
1721 &[crate::target_sync::TargetSyncOutcome {
1722 target: ".cursor".to_string(),
1723 items_synced: 0,
1724 items_removed: 1,
1725 errors: Vec::new(),
1726 synced_outputs: Vec::new(),
1727 removed_dest_paths: vec!["agents/coder.md".to_string()],
1728 }],
1729 );
1730 assert!(!lock.contains_output(".cursor", "agents/coder.md"));
1731 assert!(lock.contains_output(".mars", "agents/coder.md"));
1732 }
1733
1734 #[test]
1735 fn canonical_flat_items_excludes_linked_target_outputs() {
1736 let mut lock = sample_lock();
1737 lock.items
1738 .get_mut("agent/coder")
1739 .unwrap()
1740 .outputs
1741 .push(OutputRecord {
1742 target_root: ".cursor".to_string(),
1743 dest_path: "agents/coder.md".into(),
1744 installed_checksum: "sha256:cursor".into(),
1745 });
1746
1747 let canonical = lock.canonical_flat_items();
1748 assert_eq!(canonical.len(), 2);
1749 assert!(
1750 canonical
1751 .iter()
1752 .any(|(dp, _)| dp.as_str() == "agents/coder.md")
1753 );
1754 assert!(
1755 canonical
1756 .iter()
1757 .all(|(_, item)| { lock.contains_output(".mars", item.dest_path.as_str()) })
1758 );
1759
1760 let cursor = lock.flat_items_for_target(".cursor");
1761 assert_eq!(cursor.len(), 1);
1762 assert_eq!(cursor[0].0.as_str(), "agents/coder.md");
1763 }
1764
1765 #[test]
1766 fn flat_items_yields_all_outputs() {
1767 let lock = sample_lock();
1768 let flat = lock.flat_items();
1769 assert_eq!(flat.len(), 2);
1770 let paths: Vec<&str> = flat.iter().map(|(dp, _)| dp.as_str()).collect();
1771 assert!(paths.contains(&"agents/coder.md"));
1772 assert!(paths.contains(&"skills/review"));
1773 }
1774
1775 #[test]
1776 fn v1_lock_no_spurious_reinstall() {
1777 let v1_toml = r#"
1779version = 1
1780
1781[dependencies.base]
1782url = "https://github.com/org/base.git"
1783
1784[items."agents/coder.md"]
1785source = "base"
1786kind = "agent"
1787source_checksum = "sha256:src"
1788installed_checksum = "sha256:inst"
1789dest_path = "agents/coder.md"
1790"#;
1791 let dir = TempDir::new().unwrap();
1792 std::fs::write(dir.path().join("mars.lock"), v1_toml).unwrap();
1793 let lock = load(dir.path()).unwrap();
1794
1795 let found = lock.find_by_dest_path(&DestPath::from("agents/coder.md"));
1797 assert!(found.is_some());
1798 let item = found.unwrap();
1799 assert_eq!(item.source_checksum, "sha256:src");
1800 assert_eq!(item.installed_checksum, "sha256:inst");
1801 }
1802
1803 #[test]
1804 fn build_uses_graph_provenance_for_sources() {
1805 let git_name: SourceName = "base".into();
1806 let path_name: SourceName = "local".into();
1807 let git_url: SourceUrl = "https://example.com/new.git".into();
1808 let path_canonical = PathBuf::from("/tmp/mars-agents-local-source");
1809
1810 let mut nodes = IndexMap::new();
1811 nodes.insert(
1812 git_name.clone(),
1813 ResolvedNode {
1814 source_name: git_name.clone(),
1815 source_id: SourceId::git_with_subpath(
1816 git_url.clone(),
1817 Some(crate::types::SourceSubpath::new("plugins/base").unwrap()),
1818 ),
1819 rooted_ref: crate::resolve::RootedSourceRef {
1820 checkout_root: PathBuf::from("/tmp/cache/base"),
1821 package_root: PathBuf::from("/tmp/cache/base/plugins/base"),
1822 },
1823 resolved_ref: ResolvedRef {
1824 source_name: git_name.clone(),
1825 version: Some(semver::Version::new(1, 2, 3)),
1826 version_tag: Some("v1.2.3".into()),
1827 commit: Some("abc123".into()),
1828 tree_path: PathBuf::from("/tmp/cache/base"),
1829 },
1830 latest_version: None,
1831 manifest: None,
1832 deps: vec![],
1833 },
1834 );
1835 nodes.insert(
1836 path_name.clone(),
1837 ResolvedNode {
1838 source_name: path_name.clone(),
1839 source_id: SourceId::Path {
1840 canonical: path_canonical.clone(),
1841 subpath: Some(crate::types::SourceSubpath::new("plugins/local").unwrap()),
1842 },
1843 rooted_ref: crate::resolve::RootedSourceRef {
1844 checkout_root: PathBuf::from("/tmp/cache/local"),
1845 package_root: PathBuf::from("/tmp/cache/local/plugins/local"),
1846 },
1847 resolved_ref: ResolvedRef {
1848 source_name: path_name.clone(),
1849 version: None,
1850 version_tag: None,
1851 commit: None,
1852 tree_path: PathBuf::from("/tmp/cache/local"),
1853 },
1854 latest_version: None,
1855 manifest: None,
1856 deps: vec![],
1857 },
1858 );
1859
1860 let graph = ResolvedGraph {
1861 nodes,
1862 order: vec![git_name.clone(), path_name.clone()],
1863 filters: HashMap::new(),
1864 version_constraints: std::collections::HashMap::new(),
1865 };
1866 let applied = ApplyResult { outcomes: vec![] };
1867
1868 let mut old_sources = IndexMap::new();
1869 old_sources.insert(
1870 git_name.clone(),
1871 LockedSource {
1872 url: Some("https://example.com/old.git".into()),
1873 path: None,
1874 subpath: None,
1875 version: Some("v0.0.1".into()),
1876 commit: Some("deadbeef".into()),
1877 tree_hash: None,
1878 },
1879 );
1880 let old_lock = LockFile {
1881 version: LOCK_VERSION,
1882 dependencies: old_sources,
1883 items: IndexMap::new(),
1884 config_entries: std::collections::BTreeMap::new(),
1885 dependency_model_aliases: IndexMap::new(),
1886 };
1887
1888 let new_lock = build(
1889 &graph,
1890 &applied,
1891 &old_lock,
1892 std::collections::BTreeMap::new(),
1893 )
1894 .unwrap();
1895
1896 let base = &new_lock.dependencies["base"];
1897 assert_eq!(base.url.as_ref(), Some(&git_url));
1898 assert_eq!(
1899 base.subpath
1900 .as_ref()
1901 .map(crate::types::SourceSubpath::as_str),
1902 Some("plugins/base")
1903 );
1904 assert_eq!(base.version.as_deref(), Some("v1.2.3"));
1905 assert_eq!(base.commit.as_deref(), Some("abc123"));
1906
1907 let local = &new_lock.dependencies["local"];
1908 assert!(local.url.is_none());
1909 assert_eq!(
1910 local
1911 .subpath
1912 .as_ref()
1913 .map(crate::types::SourceSubpath::as_str),
1914 Some("plugins/local")
1915 );
1916 assert_eq!(
1917 local.path.as_deref(),
1918 Some(path_canonical.to_string_lossy().as_ref())
1919 );
1920 }
1921
1922 #[test]
1923 fn build_persists_ref_selector_in_locked_source_version() {
1924 let source_name: SourceName = "base".into();
1925 let mut nodes = IndexMap::new();
1926 nodes.insert(
1927 source_name.clone(),
1928 ResolvedNode {
1929 source_name: source_name.clone(),
1930 source_id: SourceId::git("https://example.com/base.git".into()),
1931 rooted_ref: crate::resolve::RootedSourceRef {
1932 checkout_root: PathBuf::from("/tmp/cache/base"),
1933 package_root: PathBuf::from("/tmp/cache/base"),
1934 },
1935 resolved_ref: ResolvedRef {
1936 source_name: source_name.clone(),
1937 version: None,
1938 version_tag: Some("main".into()),
1939 commit: Some("abc123".into()),
1940 tree_path: PathBuf::from("/tmp/cache/base"),
1941 },
1942 latest_version: None,
1943 manifest: None,
1944 deps: vec![],
1945 },
1946 );
1947
1948 let graph = ResolvedGraph {
1949 nodes,
1950 order: vec![source_name.clone()],
1951 filters: HashMap::new(),
1952 version_constraints: std::collections::HashMap::new(),
1953 };
1954 let applied = ApplyResult { outcomes: vec![] };
1955 let new_lock = build(
1956 &graph,
1957 &applied,
1958 &LockFile::empty(),
1959 std::collections::BTreeMap::new(),
1960 )
1961 .unwrap();
1962
1963 let source = &new_lock.dependencies["base"];
1964 assert_eq!(source.version.as_deref(), Some("main"));
1965 assert_eq!(source.commit.as_deref(), Some("abc123"));
1966 }
1967
1968 #[test]
1969 fn build_keeps_self_items_from_old_lock_on_skipped_action() {
1970 let graph = ResolvedGraph {
1971 nodes: IndexMap::new(),
1972 order: Vec::new(),
1973 filters: HashMap::new(),
1974 version_constraints: std::collections::HashMap::new(),
1975 };
1976 let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
1977 let old_lock = LockFile {
1978 version: LOCK_VERSION,
1979 dependencies: IndexMap::from([(
1980 local_source_name.clone(),
1981 LockedSource {
1982 url: None,
1983 path: Some(".".into()),
1984 subpath: None,
1985 version: None,
1986 commit: None,
1987 tree_hash: None,
1988 },
1989 )]),
1990 items: IndexMap::from([(
1991 "skill/local-skill".to_string(),
1992 LockedItemV2 {
1993 source: local_source_name.clone(),
1994 kind: ItemKind::Skill,
1995 version: None,
1996 source_checksum: "sha256:self".into(),
1997 outputs: vec![OutputRecord {
1998 target_root: ".mars".to_string(),
1999 dest_path: DestPath::from("skills/local-skill"),
2000 installed_checksum: "sha256:self".into(),
2001 }],
2002 },
2003 )]),
2004 config_entries: std::collections::BTreeMap::new(),
2005 dependency_model_aliases: IndexMap::new(),
2006 };
2007 let applied = ApplyResult {
2008 outcomes: vec![ActionOutcome {
2009 item_id: ItemId {
2010 kind: ItemKind::Skill,
2011 name: "local-skill".into(),
2012 },
2013 action: ActionTaken::Skipped,
2014 dest_path: "skills/local-skill".into(),
2015 source_name: local_source_name.clone(),
2016 source_checksum: None,
2017 installed_checksum: None,
2018 }],
2019 };
2020
2021 let new_lock = build(
2022 &graph,
2023 &applied,
2024 &old_lock,
2025 std::collections::BTreeMap::new(),
2026 )
2027 .unwrap();
2028
2029 assert!(
2030 new_lock
2031 .dependencies
2032 .contains_key(local_source_name.as_str())
2033 );
2034 let item = &new_lock.items["skill/local-skill"];
2035 assert_eq!(item.source, local_source_name);
2036 assert_eq!(item.kind, ItemKind::Skill);
2037 assert_eq!(item.source_checksum, "sha256:self");
2038 assert_eq!(item.outputs[0].installed_checksum, "sha256:self");
2039 }
2040
2041 #[test]
2042 fn build_rejects_missing_installed_checksum_for_write_actions() {
2043 let graph = ResolvedGraph {
2044 nodes: IndexMap::new(),
2045 order: Vec::new(),
2046 filters: HashMap::new(),
2047 version_constraints: std::collections::HashMap::new(),
2048 };
2049 let old_lock = LockFile::empty();
2050 let applied = ApplyResult {
2051 outcomes: vec![ActionOutcome {
2052 item_id: ItemId {
2053 kind: ItemKind::Agent,
2054 name: "coder".into(),
2055 },
2056 action: ActionTaken::Installed,
2057 dest_path: "agents/coder.md".into(),
2058 source_name: "base".into(),
2059 source_checksum: Some("sha256:source".into()),
2060 installed_checksum: None,
2061 }],
2062 };
2063
2064 let err = build(
2065 &graph,
2066 &applied,
2067 &old_lock,
2068 std::collections::BTreeMap::new(),
2069 )
2070 .unwrap_err();
2071 let msg = err.to_string();
2072 assert!(msg.contains("missing checksum for write-producing action"));
2073 assert!(msg.contains("agents/coder.md"));
2074 }
2075
2076 #[test]
2077 fn promote_v1_collision_both_survive() {
2078 let mut v1_items: IndexMap<DestPath, LockedItem> = IndexMap::new();
2082
2083 v1_items.insert(
2084 DestPath::from("hooks/pre-commit/hook.sh"),
2085 LockedItem {
2086 source: "base".into(),
2087 kind: ItemKind::Hook,
2088 version: None,
2089 source_checksum: "sha256:aaa".into(),
2090 installed_checksum: "sha256:bbb".into(),
2091 dest_path: DestPath::from("hooks/pre-commit/hook.sh"),
2092 },
2093 );
2094 v1_items.insert(
2095 DestPath::from("hooks/pre-push/hook.sh"),
2096 LockedItem {
2097 source: "base".into(),
2098 kind: ItemKind::Hook,
2099 version: None,
2100 source_checksum: "sha256:ccc".into(),
2101 installed_checksum: "sha256:ddd".into(),
2102 dest_path: DestPath::from("hooks/pre-push/hook.sh"),
2103 },
2104 );
2105
2106 let (promoted, diagnostics) = promote_v1_items(v1_items);
2107
2108 assert_eq!(promoted.len(), 2, "both items should survive promotion");
2110 assert_eq!(diagnostics.len(), 1);
2111
2112 let checksums: std::collections::HashSet<String> = promoted
2114 .values()
2115 .map(|v| v.source_checksum.as_ref().to_string())
2116 .collect();
2117 assert!(
2118 checksums.contains("sha256:aaa"),
2119 "pre-commit hook must be present"
2120 );
2121 assert!(
2122 checksums.contains("sha256:ccc"),
2123 "pre-push hook must be present"
2124 );
2125 }
2126
2127 #[test]
2128 fn load_with_diagnostics_reports_v1_promotion_collision() {
2129 let v1_toml = r#"
2130version = 1
2131
2132[dependencies.base]
2133url = "https://github.com/org/base.git"
2134
2135[items."hooks/pre-commit/hook.sh"]
2136source = "base"
2137kind = "hook"
2138source_checksum = "sha256:aaa"
2139installed_checksum = "sha256:bbb"
2140dest_path = "hooks/pre-commit/hook.sh"
2141
2142[items."hooks/pre-push/hook.sh"]
2143source = "base"
2144kind = "hook"
2145source_checksum = "sha256:ccc"
2146installed_checksum = "sha256:ddd"
2147dest_path = "hooks/pre-push/hook.sh"
2148"#;
2149 let dir = TempDir::new().unwrap();
2150 std::fs::write(dir.path().join("mars.lock"), v1_toml).unwrap();
2151
2152 let (lock, diagnostics) = load_with_diagnostics(dir.path()).unwrap();
2153
2154 assert_eq!(lock.version, LOCK_VERSION);
2155 assert_eq!(lock.items.len(), 2);
2156 assert_eq!(diagnostics.len(), 1);
2157 let diagnostic = &diagnostics[0];
2158 assert_eq!(
2159 diagnostic.level,
2160 crate::diagnostic::DiagnosticLevel::Warning
2161 );
2162 assert_eq!(diagnostic.code, "lock-promotion-collision");
2163 assert!(diagnostic.message.contains("key collision"));
2164 assert!(diagnostic.message.contains("hook/hooks/pre-push/hook.sh"));
2165 }
2166
2167 #[test]
2168 fn build_rejects_empty_checksums_from_carried_items() {
2169 let graph = ResolvedGraph {
2170 nodes: IndexMap::new(),
2171 order: Vec::new(),
2172 filters: HashMap::new(),
2173 version_constraints: std::collections::HashMap::new(),
2174 };
2175 let old_lock = LockFile {
2176 version: LOCK_VERSION,
2177 dependencies: IndexMap::new(),
2178 items: IndexMap::from([(
2179 "agent/coder".to_string(),
2180 LockedItemV2 {
2181 source: "base".into(),
2182 kind: ItemKind::Agent,
2183 version: None,
2184 source_checksum: "".into(),
2185 outputs: vec![OutputRecord {
2186 target_root: ".mars".to_string(),
2187 dest_path: DestPath::from("agents/coder.md"),
2188 installed_checksum: "sha256:installed".into(),
2189 }],
2190 },
2191 )]),
2192 config_entries: std::collections::BTreeMap::new(),
2193 dependency_model_aliases: IndexMap::new(),
2194 };
2195 let applied = ApplyResult {
2196 outcomes: vec![ActionOutcome {
2197 item_id: ItemId {
2198 kind: ItemKind::Agent,
2199 name: "coder".into(),
2200 },
2201 action: ActionTaken::Skipped,
2202 dest_path: "agents/coder.md".into(),
2203 source_name: "base".into(),
2204 source_checksum: None,
2205 installed_checksum: None,
2206 }],
2207 };
2208
2209 let err = build(
2210 &graph,
2211 &applied,
2212 &old_lock,
2213 std::collections::BTreeMap::new(),
2214 )
2215 .unwrap_err();
2216 let msg = err.to_string();
2217 assert!(msg.contains("empty source_checksum"));
2218 }
2219}