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 fn item_for_output(
236 &self,
237 target_root: &str,
238 dest_path: &DestPath,
239 ) -> Option<(&'a str, &'a LockedItemV2, &'a OutputRecord)> {
240 let (item_key, output_idx) = *self.by_output.get(&(
241 target_root.to_string(),
242 normalize_dest_path(dest_path.as_str()),
243 ))?;
244 let item = self.lock.items.get(item_key)?;
245 let output = item.outputs.get(output_idx)?;
246 Some((item_key, item, output))
247 }
248
249 pub fn contains_output(&self, target_root: &str, dest_path: &DestPath) -> bool {
251 self.by_output.contains_key(&(
252 target_root.to_string(),
253 normalize_dest_path(dest_path.as_str()),
254 ))
255 }
256
257 fn locked_item_for(&self, item_key: &str, output_idx: usize) -> Option<LockedItem> {
258 let item_v2 = self.lock.items.get(item_key)?;
259 let output = item_v2.outputs.get(output_idx)?;
260 Some(LockedItem {
261 source: item_v2.source.clone(),
262 kind: item_v2.kind,
263 version: item_v2.version.clone(),
264 source_checksum: item_v2.source_checksum.clone(),
265 installed_checksum: output.installed_checksum.clone(),
266 dest_path: output.dest_path.clone(),
267 })
268 }
269
270 pub fn contains_dest_path(&self, dest_path: &DestPath) -> bool {
272 self.by_dest_path
273 .contains_key(&normalize_dest_path(dest_path.as_str()))
274 }
275}
276
277fn normalize_dest_path(s: &str) -> String {
278 if cfg!(windows) {
279 s.replace('\\', "/")
280 } else {
281 s.to_string()
282 }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
287pub struct LockedSource {
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub url: Option<SourceUrl>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub path: Option<String>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub subpath: Option<SourceSubpath>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub version: Option<String>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub commit: Option<CommitHash>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub tree_hash: Option<String>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309pub struct LockedItemV2 {
310 pub source: SourceName,
311 pub kind: ItemKind,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub version: Option<String>,
314 pub source_checksum: ContentHash,
315 pub outputs: Vec<OutputRecord>,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
321pub struct OutputRecord {
322 pub target_root: String,
324 pub dest_path: DestPath,
326 pub installed_checksum: ContentHash,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
332pub struct ConfigEntryRecord {
333 pub source: String,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
341pub struct LockedItem {
342 pub source: SourceName,
343 pub kind: ItemKind,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub version: Option<String>,
346 pub source_checksum: ContentHash,
347 pub installed_checksum: ContentHash,
348 pub dest_path: DestPath,
349}
350
351pub use crate::types::{ItemId, ItemKind};
354
355const LOCK_FILE: &str = "mars.lock";
356const LOCK_VERSION: u32 = 2;
358pub const CANONICAL_TARGET_ROOT: &str = ".mars";
360
361#[derive(Deserialize)]
367struct LockFileV1 {
368 #[allow(dead_code)]
369 version: u32,
370 #[serde(default)]
371 dependencies: IndexMap<SourceName, LockedSource>,
372 #[serde(default)]
373 items: IndexMap<DestPath, LockedItem>,
374}
375
376#[derive(Deserialize)]
378struct LockFileV2Wire {
379 version: u32,
380 #[serde(default)]
381 dependencies: IndexMap<SourceName, LockedSource>,
382 #[serde(default)]
383 items: IndexMap<String, LockedItemV2>,
384 #[serde(default)]
385 config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
386 #[serde(default)]
387 dependency_model_aliases: IndexMap<String, ModelAlias>,
388}
389
390pub fn load(root: &Path) -> Result<LockFile, MarsError> {
400 let (lock, _) = load_with_diagnostics(root)?;
401 Ok(lock)
402}
403
404pub fn load_for_runtime_aliases(root: &Path) -> Result<LockFile, MarsError> {
411 let path = root.join(LOCK_FILE);
412 let content = match std::fs::read_to_string(&path) {
413 Ok(c) => c,
414 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(LockFile::empty()),
415 Err(e) => return Err(LockError::Io(e).into()),
416 };
417
418 let value: toml::Value = toml::from_str(&content).map_err(|e| LockError::Corrupt {
419 message: format!("failed to parse {}: {e}", path.display()),
420 })?;
421
422 let has_dependency_alias_field = value
423 .as_table()
424 .map(|table| table.contains_key("dependency_model_aliases"))
425 .unwrap_or(false);
426
427 let (lock, _) = load_with_diagnostics(root)?;
428
429 if !has_dependency_alias_field && !lock.dependencies.is_empty() {
430 return Err(LockError::Corrupt {
431 message: format!(
432 "legacy {} is missing `dependency_model_aliases` for dependency alias authority; run `{}` to update it",
433 LOCK_FILE,
434 crate::types::managed_cmd("mars sync")
435 ),
436 }
437 .into());
438 }
439
440 Ok(lock)
441}
442
443pub fn load_with_diagnostics(root: &Path) -> Result<(LockFile, Vec<Diagnostic>), MarsError> {
448 let path = root.join(LOCK_FILE);
449 let content = match std::fs::read_to_string(&path) {
450 Ok(c) => c,
451 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
452 return Ok((LockFile::empty(), Vec::new()));
453 }
454 Err(e) => return Err(LockError::Io(e).into()),
455 };
456
457 let value: toml::Value = toml::from_str(&content).map_err(|e| LockError::Corrupt {
458 message: format!("failed to parse {}: {e}", path.display()),
459 })?;
460
461 match value.clone().try_into::<LockFileV2Wire>() {
462 Ok(wire) if wire.version >= 2 => Ok((
463 LockFile {
464 version: wire.version,
465 dependencies: wire.dependencies,
466 items: wire.items,
467 config_entries: wire.config_entries,
468 dependency_model_aliases: wire.dependency_model_aliases,
469 },
470 Vec::new(),
471 )),
472 v2_result => {
473 let wire: LockFileV1 = value.clone().try_into().map_err(|v1_error| {
475 let parse_error = match v2_result {
476 Ok(wire) => format!("unsupported lock version {}", wire.version),
477 Err(v2_error) => {
478 format!("v2 parse failed: {v2_error}; v1 parse failed: {v1_error}")
479 }
480 };
481 LockError::Corrupt {
482 message: format!("failed to parse {}: {parse_error}", path.display()),
483 }
484 })?;
485 let (items, diagnostics) = promote_v1_items(wire.items);
486 Ok((
487 LockFile {
488 version: LOCK_VERSION,
489 dependencies: wire.dependencies,
490 items,
491 config_entries: BTreeMap::new(),
492 dependency_model_aliases: IndexMap::new(),
493 },
494 diagnostics,
495 ))
496 }
497 }
498}
499
500pub fn write(root: &Path, lock: &LockFile) -> Result<(), MarsError> {
502 let path = root.join(LOCK_FILE);
503 let mut normalized = lock.clone();
504 normalized.dependencies.sort_keys();
505 normalized.items.sort_keys();
506 normalized.dependency_model_aliases.sort_keys();
507
508 let content = toml::to_string_pretty(&normalized).map_err(|e| LockError::Corrupt {
509 message: format!("failed to serialize lock file: {e}"),
510 })?;
511 crate::fs::atomic_write(&path, content.as_bytes())
512}
513
514fn promote_v1_items(
524 v1_items: IndexMap<DestPath, LockedItem>,
525) -> (IndexMap<String, LockedItemV2>, Vec<Diagnostic>) {
526 let mut result: IndexMap<String, LockedItemV2> = IndexMap::new();
527 let mut diagnostics = Vec::new();
528
529 for (dest_path, item) in v1_items {
530 let key = format!("{}/{}", item.kind, dest_path.item_name(item.kind));
531 let item_v2 = LockedItemV2 {
532 source: item.source,
533 kind: item.kind,
534 version: item.version,
535 source_checksum: item.source_checksum,
536 outputs: vec![OutputRecord {
537 target_root: ".mars".to_string(),
538 dest_path: item.dest_path,
539 installed_checksum: item.installed_checksum,
540 }],
541 };
542
543 if result.contains_key(&key) {
544 let fallback_key = format!("{}/{}", item_v2.kind, dest_path.as_str());
547 diagnostics.push(Diagnostic {
548 level: crate::diagnostic::DiagnosticLevel::Warning,
549 code: "lock-promotion-collision",
550 message: format!(
551 "v1→v2 promotion: key collision on `{key}`; using dest_path key `{fallback_key}`"
552 ),
553 context: None,
554 category: None,
555 });
556 result.insert(fallback_key, item_v2);
557 } else {
558 result.insert(key, item_v2);
559 }
560 }
561
562 (result, diagnostics)
563}
564
565pub fn build(
575 graph: &crate::resolve::ResolvedGraph,
576 applied: &crate::sync::apply::ApplyResult,
577 old_lock: &LockFile,
578 config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
579) -> Result<LockFile, MarsError> {
580 use crate::sync::apply::ActionTaken;
581
582 let mut dependencies = IndexMap::new();
583 let mut items: IndexMap<String, LockedItemV2> = IndexMap::new();
584 let old_lock_index = LockIndex::new(old_lock);
585
586 for outcome in &applied.outcomes {
587 match outcome.action {
588 ActionTaken::Installed
589 | ActionTaken::Updated
590 | ActionTaken::Merged
591 | ActionTaken::Conflicted => {
592 let installed =
593 outcome
594 .installed_checksum
595 .as_ref()
596 .ok_or_else(|| LockError::Corrupt {
597 message: format!(
598 "missing checksum for write-producing action on {}",
599 outcome.dest_path
600 ),
601 })?;
602 if checksum_is_empty(installed) {
603 return Err(LockError::Corrupt {
604 message: format!("empty installed_checksum for {}", outcome.dest_path),
605 }
606 .into());
607 }
608
609 let source =
610 outcome
611 .source_checksum
612 .as_ref()
613 .ok_or_else(|| LockError::Corrupt {
614 message: format!(
615 "missing source checksum for write-producing action on {}",
616 outcome.dest_path
617 ),
618 })?;
619 if checksum_is_empty(source) {
620 return Err(LockError::Corrupt {
621 message: format!("empty source_checksum for {}", outcome.dest_path),
622 }
623 .into());
624 }
625 }
626 ActionTaken::Removed | ActionTaken::Skipped | ActionTaken::Kept => {}
627 }
628 }
629
630 for (name, node) in &graph.nodes {
632 dependencies.insert(name.clone(), to_locked_source(node));
633 }
634
635 for outcome in &applied.outcomes {
637 match &outcome.action {
638 ActionTaken::Removed | ActionTaken::Skipped => {
639 if matches!(outcome.action, ActionTaken::Skipped) {
641 let item_key = item_key(&outcome.item_id);
642 if let Some(old_item) = old_lock.items.get(&item_key) {
643 items.insert(item_key, old_item.clone());
644 } else {
645 if let Some((_, old_item, old_output)) = old_lock_index
648 .item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
649 {
650 let key = format!(
651 "{}/{}",
652 old_item.kind,
653 outcome.dest_path.item_name(old_item.kind)
654 );
655 items.entry(key).or_insert_with(|| LockedItemV2 {
656 source: old_item.source.clone(),
657 kind: old_item.kind,
658 version: old_item.version.clone(),
659 source_checksum: old_item.source_checksum.clone(),
660 outputs: outputs_with_carried_non_canonical(
661 Some(old_item),
662 OutputRecord {
663 target_root: CANONICAL_TARGET_ROOT.to_string(),
664 dest_path: old_output.dest_path.clone(),
665 installed_checksum: old_output.installed_checksum.clone(),
666 },
667 ),
668 });
669 }
670 }
671 }
672 }
674 ActionTaken::Kept => {
675 let item_key = item_key(&outcome.item_id);
677 if let Some(old_item) = old_lock.items.get(&item_key) {
678 items.insert(item_key, old_item.clone());
679 } else if let Some((_, old_item, old_output)) =
680 old_lock_index.item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
681 {
682 let key = format!(
683 "{}/{}",
684 old_item.kind,
685 outcome.dest_path.item_name(old_item.kind)
686 );
687 items.entry(key).or_insert_with(|| LockedItemV2 {
688 source: old_item.source.clone(),
689 kind: old_item.kind,
690 version: old_item.version.clone(),
691 source_checksum: old_item.source_checksum.clone(),
692 outputs: outputs_with_carried_non_canonical(
693 Some(old_item),
694 OutputRecord {
695 target_root: CANONICAL_TARGET_ROOT.to_string(),
696 dest_path: old_output.dest_path.clone(),
697 installed_checksum: old_output.installed_checksum.clone(),
698 },
699 ),
700 });
701 }
702 }
703 ActionTaken::Installed
704 | ActionTaken::Updated
705 | ActionTaken::Merged
706 | ActionTaken::Conflicted => {
707 let dest_path = outcome.dest_path.clone();
708 if dest_path.as_str().is_empty() {
709 continue;
710 }
711
712 let source_name = if outcome.source_name.as_ref().is_empty() {
714 None
715 } else {
716 Some(outcome.source_name.clone())
717 };
718
719 let version = source_name.as_ref().and_then(|sn| {
721 graph
722 .nodes
723 .get(sn)
724 .and_then(|n| n.resolved_ref.version_tag.clone())
725 });
726
727 let source_checksum = outcome
728 .source_checksum
729 .clone()
730 .expect("validated above: source_checksum exists for write actions");
731 let installed_checksum = outcome
732 .installed_checksum
733 .clone()
734 .expect("validated above: installed_checksum exists for write actions");
735
736 let key = item_key(&outcome.item_id);
737 let old_item = old_lock.items.get(&key).or_else(|| {
738 old_lock_index
739 .item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
740 .map(|(_, old_item, _)| old_item)
741 });
742 let outputs = outputs_with_carried_non_canonical(
743 old_item,
744 OutputRecord {
745 target_root: CANONICAL_TARGET_ROOT.to_string(),
746 dest_path,
747 installed_checksum,
748 },
749 );
750 items.insert(
751 key,
752 LockedItemV2 {
753 source: source_name.unwrap_or_else(|| SourceName::from("")),
754 kind: outcome.item_id.kind,
755 version,
756 source_checksum,
757 outputs,
758 },
759 );
760 }
761 }
762 }
763
764 let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
766 let has_self_items = items.values().any(|item| item.source == local_source_name);
767 if has_self_items {
768 dependencies.insert(
769 local_source_name,
770 LockedSource {
771 url: None,
772 path: Some(".".into()),
773 subpath: None,
774 version: None,
775 commit: None,
776 tree_hash: None,
777 },
778 );
779 }
780
781 for item in items.values() {
783 if checksum_is_empty(&item.source_checksum) {
784 let dest = item
785 .outputs
786 .first()
787 .map(|o| o.dest_path.to_string())
788 .unwrap_or_default();
789 return Err(LockError::Corrupt {
790 message: format!("empty source_checksum for {dest}"),
791 }
792 .into());
793 }
794 for output in &item.outputs {
795 if checksum_is_empty(&output.installed_checksum) {
796 return Err(LockError::Corrupt {
797 message: format!("empty installed_checksum for {}", output.dest_path),
798 }
799 .into());
800 }
801 }
802 }
803
804 dependencies.sort_keys();
806 items.sort_keys();
807
808 Ok(LockFile {
809 version: LOCK_VERSION,
810 dependencies,
811 items,
812 config_entries,
813 dependency_model_aliases: IndexMap::new(),
814 })
815}
816
817fn outputs_with_carried_non_canonical(
818 old_item: Option<&LockedItemV2>,
819 canonical_output: OutputRecord,
820) -> Vec<OutputRecord> {
821 let mut outputs = vec![canonical_output];
822 if let Some(old_item) = old_item {
823 for old_output in &old_item.outputs {
824 if old_output.target_root != CANONICAL_TARGET_ROOT {
825 outputs.push(old_output.clone());
826 }
827 }
828 }
829 outputs
830}
831
832pub fn ownership_lock_for_native_emission(
839 old_lock: &LockFile,
840 apply_outcomes: &[crate::sync::apply::ActionOutcome],
841 target_outcomes: &[crate::target_sync::TargetSyncOutcome],
842) -> LockFile {
843 let mut lock = old_lock.clone();
844 apply_apply_outcomes_to_lock(&mut lock, old_lock, apply_outcomes);
845 apply_target_sync_outputs(&mut lock, target_outcomes);
846 lock
847}
848
849pub fn ownership_lock_after_target_sync(
854 old_lock: &LockFile,
855 target_outcomes: &[crate::target_sync::TargetSyncOutcome],
856) -> LockFile {
857 let mut lock = old_lock.clone();
858 apply_target_sync_outputs(&mut lock, target_outcomes);
859 lock
860}
861
862pub fn apply_apply_outcomes_to_lock(
867 lock: &mut LockFile,
868 old_lock: &LockFile,
869 outcomes: &[crate::sync::apply::ActionOutcome],
870) {
871 use crate::sync::apply::ActionTaken;
872
873 let old_lock_index = LockIndex::new(old_lock);
874 for outcome in outcomes {
875 match outcome.action {
876 ActionTaken::Removed => {
877 lock.items.shift_remove(&item_key(&outcome.item_id));
878 }
879 ActionTaken::Skipped => {
880 let key = item_key(&outcome.item_id);
881 if lock.items.contains_key(&key) {
882 continue;
883 }
884 if let Some(old_item) = old_lock.items.get(&key) {
885 lock.items.insert(key, old_item.clone());
886 } else if let Some(flat) =
887 old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
888 {
889 let key = format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
890 lock.items.entry(key).or_insert_with(|| LockedItemV2 {
891 source: flat.source,
892 kind: flat.kind,
893 version: flat.version,
894 source_checksum: flat.source_checksum,
895 outputs: vec![OutputRecord {
896 target_root: CANONICAL_TARGET_ROOT.to_string(),
897 dest_path: flat.dest_path,
898 installed_checksum: flat.installed_checksum,
899 }],
900 });
901 }
902 }
903 ActionTaken::Kept => {
904 let key = item_key(&outcome.item_id);
905 if let Some(old_item) = old_lock.items.get(&key) {
906 lock.items.insert(key, old_item.clone());
907 } else if let Some(flat) =
908 old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
909 {
910 let key = format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
911 lock.items.entry(key).or_insert_with(|| LockedItemV2 {
912 source: flat.source,
913 kind: flat.kind,
914 version: flat.version,
915 source_checksum: flat.source_checksum,
916 outputs: vec![OutputRecord {
917 target_root: CANONICAL_TARGET_ROOT.to_string(),
918 dest_path: flat.dest_path,
919 installed_checksum: flat.installed_checksum,
920 }],
921 });
922 }
923 }
924 ActionTaken::Installed
925 | ActionTaken::Updated
926 | ActionTaken::Merged
927 | ActionTaken::Conflicted => {
928 if outcome.dest_path.as_str().is_empty() {
929 continue;
930 }
931 let Some(source_checksum) = outcome
932 .source_checksum
933 .as_ref()
934 .filter(|checksum| !checksum_is_empty(checksum))
935 else {
936 continue;
937 };
938 let Some(installed_checksum) = outcome
939 .installed_checksum
940 .as_ref()
941 .filter(|checksum| !checksum_is_empty(checksum))
942 else {
943 continue;
944 };
945
946 let source_name = if outcome.source_name.as_ref().is_empty() {
947 SourceName::from("")
948 } else {
949 outcome.source_name.clone()
950 };
951
952 let key = item_key(&outcome.item_id);
953 let old_entry = old_lock
954 .items
955 .get(&key)
956 .map(|old_item| (key.as_str(), old_item))
957 .or_else(|| {
958 old_lock_index
959 .item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
960 .map(|(old_key, old_item, _)| (old_key, old_item))
961 });
962 let old_key = old_entry.map(|(old_key, _)| old_key.to_string());
963 let outputs = outputs_with_carried_non_canonical(
964 old_entry.map(|(_, old_item)| old_item),
965 OutputRecord {
966 target_root: CANONICAL_TARGET_ROOT.to_string(),
967 dest_path: outcome.dest_path.clone(),
968 installed_checksum: installed_checksum.clone(),
969 },
970 );
971 if let Some(old_key) = old_key
972 && old_key != key
973 {
974 lock.items.shift_remove(&old_key);
975 }
976 lock.items.insert(
977 key,
978 LockedItemV2 {
979 source: source_name,
980 kind: outcome.item_id.kind,
981 version: None,
982 source_checksum: source_checksum.clone(),
983 outputs,
984 },
985 );
986 }
987 }
988 }
989}
990
991pub fn apply_target_sync_outputs(
993 lock: &mut LockFile,
994 target_outcomes: &[crate::target_sync::TargetSyncOutcome],
995) {
996 for outcome in target_outcomes {
997 for dest_path in &outcome.removed_dest_paths {
998 remove_target_output(lock, &outcome.target, dest_path);
999 }
1000 for synced in &outcome.synced_outputs {
1001 upsert_target_output(
1002 lock,
1003 &outcome.target,
1004 &synced.dest_path,
1005 &synced.installed_checksum,
1006 );
1007 }
1008 }
1009}
1010
1011#[derive(Debug, Clone, PartialEq, Eq)]
1013pub struct CompiledNativeOutput {
1014 pub owner_canonical_dest_path: String,
1016 pub target_root: String,
1017 pub dest_path: String,
1018 pub installed_checksum: ContentHash,
1019}
1020
1021pub fn native_output_is_new_or_changed(old: &LockFile, out: &CompiledNativeOutput) -> bool {
1025 for item in old.items.values() {
1026 for output in &item.outputs {
1027 if output.target_root == out.target_root
1028 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), &out.dest_path)
1029 {
1030 return output.installed_checksum != out.installed_checksum;
1031 }
1032 }
1033 }
1034 true
1035}
1036
1037pub fn apply_removed_native_outputs(lock: &mut LockFile, records: &[(String, String)]) {
1039 for (target_root, dest_path) in records {
1040 remove_target_output(lock, target_root, dest_path);
1041 }
1042}
1043
1044pub fn apply_compiled_native_outputs(lock: &mut LockFile, records: &[CompiledNativeOutput]) {
1046 for record in records {
1047 upsert_native_output_on_owner(
1048 lock,
1049 &record.owner_canonical_dest_path,
1050 &record.target_root,
1051 &record.dest_path,
1052 &record.installed_checksum,
1053 );
1054 }
1055}
1056
1057fn upsert_target_output(
1058 lock: &mut LockFile,
1059 target_root: &str,
1060 dest_path: &str,
1061 installed_checksum: &ContentHash,
1062) {
1063 let dest = DestPath::from(dest_path);
1064 for item in lock.items.values_mut() {
1065 if !item.outputs.iter().any(|output| {
1066 crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
1067 }) {
1068 continue;
1069 }
1070
1071 if let Some(output) = item.outputs.iter_mut().find(|output| {
1072 output.target_root == target_root
1073 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
1074 }) {
1075 output.installed_checksum = installed_checksum.clone();
1076 return;
1077 }
1078
1079 item.outputs.push(OutputRecord {
1080 target_root: target_root.to_string(),
1081 dest_path: dest,
1082 installed_checksum: installed_checksum.clone(),
1083 });
1084 item.outputs.sort_by(|a, b| {
1085 a.target_root
1086 .cmp(&b.target_root)
1087 .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
1088 });
1089 return;
1090 }
1091}
1092
1093fn upsert_native_output_on_owner(
1094 lock: &mut LockFile,
1095 owner_canonical_dest_path: &str,
1096 target_root: &str,
1097 native_dest_path: &str,
1098 installed_checksum: &ContentHash,
1099) {
1100 let native_dest = DestPath::from(native_dest_path);
1101 for item in lock.items.values_mut() {
1102 let owns_canonical = item.outputs.iter().any(|output| {
1103 output.target_root == CANONICAL_TARGET_ROOT
1104 && crate::target::dest_paths_equivalent(
1105 output.dest_path.as_str(),
1106 owner_canonical_dest_path,
1107 )
1108 });
1109 if !owns_canonical {
1110 continue;
1111 }
1112
1113 if let Some(output) = item.outputs.iter_mut().find(|output| {
1114 output.target_root == target_root
1115 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), native_dest_path)
1116 }) {
1117 output.installed_checksum = installed_checksum.clone();
1118 return;
1119 }
1120
1121 item.outputs.push(OutputRecord {
1122 target_root: target_root.to_string(),
1123 dest_path: native_dest,
1124 installed_checksum: installed_checksum.clone(),
1125 });
1126 item.outputs.sort_by(|a, b| {
1127 a.target_root
1128 .cmp(&b.target_root)
1129 .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
1130 });
1131 return;
1132 }
1133}
1134
1135fn remove_target_output(lock: &mut LockFile, target_root: &str, dest_path: &str) {
1136 for item in lock.items.values_mut() {
1137 item.outputs.retain(|output| {
1138 !(output.target_root == target_root
1139 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path))
1140 });
1141 }
1142 lock.items.retain(|_, item| !item.outputs.is_empty());
1143}
1144
1145fn checksum_is_empty(checksum: &ContentHash) -> bool {
1150 checksum.as_ref().trim().is_empty()
1151}
1152
1153fn to_locked_source(node: &crate::resolve::ResolvedNode) -> LockedSource {
1154 let (url, path, subpath) = match &node.source_id {
1155 SourceId::Git { url, subpath } => (Some(url.clone()), None, subpath.clone()),
1156 SourceId::Path { canonical, subpath } => (
1157 None,
1158 Some(canonical.to_string_lossy().to_string()),
1159 subpath.clone(),
1160 ),
1161 };
1162
1163 LockedSource {
1164 url,
1165 path,
1166 subpath,
1167 version: node.resolved_ref.version_tag.clone(),
1168 commit: node.resolved_ref.commit.clone(),
1169 tree_hash: None,
1170 }
1171}
1172
1173pub fn item_key(id: &ItemId) -> String {
1175 format!("{}/{}", id.kind, id.name)
1176}
1177
1178#[cfg(test)]
1183mod tests {
1184 use super::*;
1185 use std::collections::HashMap;
1186 use std::path::PathBuf;
1187
1188 use crate::resolve::{ResolvedGraph, ResolvedNode};
1189 use crate::source::ResolvedRef;
1190 use crate::sync::apply::{ActionOutcome, ActionTaken, ApplyResult};
1191 use crate::types::{ItemName, SourceId, SourceUrl};
1192 use tempfile::TempDir;
1193
1194 fn sample_lock() -> LockFile {
1195 let mut dependencies = IndexMap::new();
1196 dependencies.insert(
1197 "base".into(),
1198 LockedSource {
1199 url: Some("https://github.com/org/base.git".into()),
1200 path: None,
1201 subpath: None,
1202 version: Some("v1.0.0".into()),
1203 commit: Some("abc123".into()),
1204 tree_hash: Some("def456".into()),
1205 },
1206 );
1207
1208 let mut items = IndexMap::new();
1209 items.insert(
1210 "agent/coder".to_string(),
1211 LockedItemV2 {
1212 source: "base".into(),
1213 kind: ItemKind::Agent,
1214 version: Some("v1.0.0".into()),
1215 source_checksum: "sha256:aaa".into(),
1216 outputs: vec![OutputRecord {
1217 target_root: ".mars".to_string(),
1218 dest_path: "agents/coder.md".into(),
1219 installed_checksum: "sha256:bbb".into(),
1220 }],
1221 },
1222 );
1223 items.insert(
1224 "skill/review".to_string(),
1225 LockedItemV2 {
1226 source: "base".into(),
1227 kind: ItemKind::Skill,
1228 version: Some("v1.0.0".into()),
1229 source_checksum: "sha256:ccc".into(),
1230 outputs: vec![OutputRecord {
1231 target_root: ".mars".to_string(),
1232 dest_path: "skills/review".into(),
1233 installed_checksum: "sha256:ddd".into(),
1234 }],
1235 },
1236 );
1237
1238 LockFile {
1239 version: LOCK_VERSION,
1240 dependencies,
1241 items,
1242 config_entries: BTreeMap::new(),
1243 dependency_model_aliases: IndexMap::new(),
1244 }
1245 }
1246
1247 #[test]
1248 fn parse_v1_lock_file_promoted_to_v2() {
1249 let toml_str = r#"
1250version = 1
1251
1252[dependencies.base]
1253url = "https://github.com/org/base.git"
1254version = "v1.0.0"
1255commit = "abc123"
1256tree_hash = "def456"
1257
1258[items."agents/coder.md"]
1259source = "base"
1260kind = "agent"
1261version = "v1.0.0"
1262source_checksum = "sha256:aaa"
1263installed_checksum = "sha256:bbb"
1264dest_path = "agents/coder.md"
1265"#;
1266 let dir = TempDir::new().unwrap();
1268 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1269 let lock = load(dir.path()).unwrap();
1270
1271 assert_eq!(lock.version, LOCK_VERSION);
1273 assert_eq!(lock.dependencies.len(), 1);
1274 assert_eq!(lock.items.len(), 1);
1275
1276 let item = &lock.items["agent/coder"];
1278 assert_eq!(item.source, "base");
1279 assert_eq!(item.kind, ItemKind::Agent);
1280 assert_eq!(item.source_checksum, "sha256:aaa");
1281 assert_eq!(item.outputs.len(), 1);
1282 assert_eq!(item.outputs[0].installed_checksum, "sha256:bbb");
1283 assert_eq!(item.outputs[0].dest_path.as_str(), "agents/coder.md");
1284 assert_eq!(item.outputs[0].target_root, ".mars");
1285 }
1286
1287 #[test]
1288 fn parse_v2_lock_file() {
1289 let toml_str = r#"
1290version = 2
1291
1292[dependencies.base]
1293url = "https://github.com/org/base.git"
1294version = "v1.0.0"
1295commit = "abc123"
1296
1297[items."agent/coder"]
1298source = "base"
1299kind = "agent"
1300version = "v1.0.0"
1301source_checksum = "sha256:aaa"
1302
1303[[items."agent/coder".outputs]]
1304target_root = ".mars"
1305dest_path = "agents/coder.md"
1306installed_checksum = "sha256:bbb"
1307"#;
1308 let dir = TempDir::new().unwrap();
1309 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1310 let lock = load(dir.path()).unwrap();
1311
1312 assert_eq!(lock.version, 2);
1313 assert_eq!(lock.items.len(), 1);
1314
1315 let item = &lock.items["agent/coder"];
1316 assert_eq!(item.source_checksum, "sha256:aaa");
1317 assert_eq!(item.outputs[0].installed_checksum, "sha256:bbb");
1318 }
1319
1320 #[test]
1321 fn load_for_runtime_aliases_rejects_legacy_v2_without_dependency_alias_authority() {
1322 let toml_str = r#"
1323version = 2
1324
1325[dependencies.base]
1326url = "https://github.com/org/base.git"
1327version = "v1.0.0"
1328commit = "abc123"
1329
1330[items."agent/coder"]
1331source = "base"
1332kind = "agent"
1333source_checksum = "sha256:aaa"
1334
1335[[items."agent/coder".outputs]]
1336target_root = ".mars"
1337dest_path = "agents/coder.md"
1338installed_checksum = "sha256:bbb"
1339"#;
1340 let dir = TempDir::new().unwrap();
1341 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1342
1343 let err = load_for_runtime_aliases(dir.path()).unwrap_err();
1344 let message = err.to_string();
1345 assert!(message.contains("missing `dependency_model_aliases`"));
1346 assert!(message.contains("run `mars sync`"));
1347 }
1348
1349 #[test]
1350 fn load_for_runtime_aliases_allows_missing_dependency_aliases_when_no_dependencies() {
1351 let toml_str = r#"
1352version = 2
1353
1354[items."agent/coder"]
1355source = "_self"
1356kind = "agent"
1357source_checksum = "sha256:aaa"
1358
1359[[items."agent/coder".outputs]]
1360target_root = ".mars"
1361dest_path = "agents/coder.md"
1362installed_checksum = "sha256:bbb"
1363"#;
1364 let dir = TempDir::new().unwrap();
1365 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1366
1367 let lock = load_for_runtime_aliases(dir.path()).unwrap();
1368 assert!(lock.dependencies.is_empty());
1369 assert!(lock.dependency_model_aliases.is_empty());
1370 }
1371
1372 #[test]
1373 fn roundtrip_lock_file() {
1374 let lock = sample_lock();
1375 let dir = TempDir::new().unwrap();
1376 write(dir.path(), &lock).unwrap();
1377 let reloaded = load(dir.path()).unwrap();
1378 assert_eq!(lock, reloaded);
1379 }
1380
1381 #[test]
1382 fn roundtrip_lock_file_with_config_entries() {
1383 let mut lock = sample_lock();
1384 lock.config_entries.insert(
1385 ".claude".to_string(),
1386 BTreeMap::from([(
1387 "mcp:context7".to_string(),
1388 ConfigEntryRecord {
1389 source: "base".to_string(),
1390 },
1391 )]),
1392 );
1393
1394 let dir = TempDir::new().unwrap();
1395 write(dir.path(), &lock).unwrap();
1396 let reloaded = load(dir.path()).unwrap();
1397
1398 assert_eq!(lock, reloaded);
1399 assert_eq!(
1400 reloaded.config_entries[".claude"]["mcp:context7"].source,
1401 "base"
1402 );
1403 }
1404
1405 #[test]
1406 fn write_emits_dependency_model_aliases_table_even_when_empty() {
1407 let lock = sample_lock();
1408 let dir = TempDir::new().unwrap();
1409 write(dir.path(), &lock).unwrap();
1410
1411 let content = std::fs::read_to_string(dir.path().join("mars.lock")).unwrap();
1412 assert!(
1413 content.contains("dependency_model_aliases"),
1414 "serialized lock should include dependency_model_aliases authority table"
1415 );
1416 }
1417
1418 #[test]
1419 fn deterministic_serialization() {
1420 let lock = sample_lock();
1421 let s1 = toml::to_string_pretty(&lock).unwrap();
1422 let s2 = toml::to_string_pretty(&lock).unwrap();
1423 assert_eq!(s1, s2);
1424
1425 let coder_pos = s1.find("agent/coder").unwrap();
1427 let review_pos = s1.find("skill/review").unwrap();
1428 assert!(
1429 coder_pos < review_pos,
1430 "agent/coder should appear before skill/review"
1431 );
1432 }
1433
1434 #[test]
1435 fn write_sorts_dependency_model_aliases_keys() {
1436 let toml_str = r#"
1437version = 2
1438
1439[dependency_model_aliases.zeta]
1440model = "openai/gpt-z"
1441
1442[dependency_model_aliases.alpha]
1443model = "openai/gpt-a"
1444"#;
1445 let dir = TempDir::new().unwrap();
1446 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1447
1448 let lock = load(dir.path()).unwrap();
1449 write(dir.path(), &lock).unwrap();
1450
1451 let written = std::fs::read_to_string(dir.path().join("mars.lock")).unwrap();
1452 let alpha = written
1453 .find("[dependency_model_aliases.alpha]")
1454 .expect("alpha alias should be serialized");
1455 let zeta = written
1456 .find("[dependency_model_aliases.zeta]")
1457 .expect("zeta alias should be serialized");
1458 assert!(alpha < zeta, "aliases should serialize in sorted key order");
1459 }
1460
1461 #[test]
1462 fn empty_lock_file() {
1463 let lock = LockFile::empty();
1464 assert_eq!(lock.version, LOCK_VERSION);
1465 assert!(lock.dependencies.is_empty());
1466 assert!(lock.items.is_empty());
1467 }
1468
1469 #[test]
1470 fn load_absent_returns_empty() {
1471 let dir = TempDir::new().unwrap();
1472 let lock = load(dir.path()).unwrap();
1473 assert_eq!(lock.version, LOCK_VERSION);
1474 assert!(lock.dependencies.is_empty());
1475 assert!(lock.items.is_empty());
1476 }
1477
1478 #[test]
1479 fn write_and_reload() {
1480 let dir = TempDir::new().unwrap();
1481 let lock = sample_lock();
1482 write(dir.path(), &lock).unwrap();
1483 let reloaded = load(dir.path()).unwrap();
1484 assert_eq!(lock, reloaded);
1485 }
1486
1487 #[test]
1488 fn dual_checksums_present() {
1489 let lock = sample_lock();
1490 let item = &lock.items["agent/coder"];
1491 assert_ne!(item.source_checksum, item.outputs[0].installed_checksum);
1492 assert!(item.source_checksum.starts_with("sha256:"));
1493 assert!(item.outputs[0].installed_checksum.starts_with("sha256:"));
1494 }
1495
1496 #[test]
1497 fn path_source_in_lock() {
1498 let toml_str = r#"
1499version = 2
1500
1501[dependencies.local]
1502path = "/home/dev/agents"
1503
1504[items."agent/helper"]
1505source = "local"
1506kind = "agent"
1507source_checksum = "sha256:111"
1508
1509[[items."agent/helper".outputs]]
1510target_root = ".mars"
1511dest_path = "agents/helper.md"
1512installed_checksum = "sha256:222"
1513"#;
1514 let dir = TempDir::new().unwrap();
1515 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1516 let lock = load(dir.path()).unwrap();
1517 let source = &lock.dependencies["local"];
1518 assert!(source.url.is_none());
1519 assert_eq!(source.path.as_deref(), Some("/home/dev/agents"));
1520 assert!(source.commit.is_none());
1521 }
1522
1523 #[test]
1524 fn item_kind_serializes_lowercase() {
1525 let item = LockedItemV2 {
1526 source: "base".into(),
1527 kind: ItemKind::Skill,
1528 version: None,
1529 source_checksum: "sha256:aaa".into(),
1530 outputs: vec![OutputRecord {
1531 target_root: ".mars".to_string(),
1532 dest_path: "skills/review".into(),
1533 installed_checksum: "sha256:bbb".into(),
1534 }],
1535 };
1536 let serialized = toml::to_string(&item).unwrap();
1537 assert!(serialized.contains("kind = \"skill\""));
1538 }
1539
1540 #[test]
1541 fn item_id_display() {
1542 let id = ItemId {
1543 kind: ItemKind::Agent,
1544 name: "coder".into(),
1545 };
1546 assert_eq!(id.to_string(), "agent/coder");
1547 }
1548
1549 #[test]
1550 fn item_kind_display() {
1551 assert_eq!(ItemKind::Agent.to_string(), "agent");
1552 assert_eq!(ItemKind::Skill.to_string(), "skill");
1553 }
1554
1555 #[test]
1556 fn find_by_dest_path_returns_flat_view() {
1557 let lock = sample_lock();
1558 let found = lock
1559 .find_by_dest_path(&DestPath::from("agents/coder.md"))
1560 .unwrap();
1561 assert_eq!(found.source, "base");
1562 assert_eq!(found.kind, ItemKind::Agent);
1563 assert_eq!(found.source_checksum, "sha256:aaa");
1564 assert_eq!(found.installed_checksum, "sha256:bbb");
1565 assert_eq!(found.dest_path.as_str(), "agents/coder.md");
1566 }
1567
1568 #[test]
1569 fn find_by_dest_path_missing_returns_none() {
1570 let lock = sample_lock();
1571 assert!(
1572 lock.find_by_dest_path(&DestPath::from("agents/missing.md"))
1573 .is_none()
1574 );
1575 }
1576
1577 #[test]
1578 fn contains_dest_path_hit_and_miss() {
1579 let lock = sample_lock();
1580 assert!(lock.contains_dest_path(&DestPath::from("agents/coder.md")));
1581 assert!(!lock.contains_dest_path(&DestPath::from("agents/nobody.md")));
1582 }
1583
1584 #[test]
1585 fn lock_index_find_by_dest_path_hit_and_miss() {
1586 let lock = sample_lock();
1587 let index = LockIndex::new(&lock);
1588
1589 let found = index
1590 .find_by_dest_path(&DestPath::from("agents/coder.md"))
1591 .unwrap();
1592 assert_eq!(found.source, "base");
1593 assert_eq!(found.kind, ItemKind::Agent);
1594 assert_eq!(found.source_checksum, "sha256:aaa");
1595 assert_eq!(found.installed_checksum, "sha256:bbb");
1596 assert_eq!(found.dest_path.as_str(), "agents/coder.md");
1597
1598 assert!(
1599 index
1600 .find_by_dest_path(&DestPath::from("agents/missing.md"))
1601 .is_none()
1602 );
1603 }
1604
1605 #[test]
1606 fn lock_index_contains_dest_path_hit_and_miss() {
1607 let lock = sample_lock();
1608 let index = LockIndex::new(&lock);
1609
1610 assert!(index.contains_dest_path(&DestPath::from("agents/coder.md")));
1611 assert!(!index.contains_dest_path(&DestPath::from("agents/nobody.md")));
1612 }
1613
1614 #[test]
1615 fn lock_index_target_scoped_lookup_distinguishes_same_dest_path() {
1616 let mut lock = sample_lock();
1617 lock.items
1618 .get_mut("agent/coder")
1619 .unwrap()
1620 .outputs
1621 .push(OutputRecord {
1622 target_root: ".pi".to_string(),
1623 dest_path: "agents/coder.md".into(),
1624 installed_checksum: "sha256:pi".into(),
1625 });
1626
1627 let index = LockIndex::new(&lock);
1628 let dest = DestPath::from("agents/coder.md");
1629
1630 let mars = index
1631 .find_output(".mars", &dest)
1632 .expect("expected canonical .mars output");
1633 let pi = index
1634 .find_output(".pi", &dest)
1635 .expect("expected .pi output");
1636
1637 assert_eq!(mars.installed_checksum, "sha256:bbb");
1638 assert_eq!(pi.installed_checksum, "sha256:pi");
1639 assert!(index.contains_output(".mars", &dest));
1640 assert!(index.contains_output(".pi", &dest));
1641 assert!(!index.contains_output(".cursor", &dest));
1642 }
1643
1644 #[test]
1645 fn output_dest_paths_for_target_filters_by_target_root() {
1646 let mut lock = sample_lock();
1647 lock.items
1648 .get_mut("agent/coder")
1649 .unwrap()
1650 .outputs
1651 .push(OutputRecord {
1652 target_root: ".cursor".to_string(),
1653 dest_path: "agents/coder.md".into(),
1654 installed_checksum: "sha256:cursor".into(),
1655 });
1656
1657 let mars_paths = lock.output_dest_paths_for_target(".mars");
1658 assert!(mars_paths.contains("agents/coder.md"));
1659 assert!(mars_paths.contains("skills/review"));
1660
1661 let cursor_paths = lock.output_dest_paths_for_target(".cursor");
1662 assert_eq!(cursor_paths.len(), 1);
1663 assert!(cursor_paths.contains("agents/coder.md"));
1664 assert!(lock.output_dest_paths_for_target(".claude").is_empty());
1665 }
1666
1667 #[test]
1668 fn contains_output_matches_target_root_and_dest_path() {
1669 let mut lock = sample_lock();
1670 assert!(lock.contains_output(".mars", "agents/coder.md"));
1671 assert!(!lock.contains_output(".cursor", "agents/coder.md"));
1672
1673 lock.items
1674 .get_mut("agent/coder")
1675 .unwrap()
1676 .outputs
1677 .push(OutputRecord {
1678 target_root: ".cursor".to_string(),
1679 dest_path: "agents/coder.md".into(),
1680 installed_checksum: "sha256:cursor".into(),
1681 });
1682 assert!(lock.contains_output(".cursor", "agents/coder.md"));
1683 assert!(!lock.contains_output(".cursor", "agents/missing.md"));
1684 }
1685
1686 #[test]
1687 fn apply_compiled_native_outputs_upserts_codex_native_by_canonical_owner() {
1688 let mut lock = sample_lock();
1689 apply_compiled_native_outputs(
1690 &mut lock,
1691 &[CompiledNativeOutput {
1692 owner_canonical_dest_path: "agents/coder.md".to_string(),
1693 target_root: ".codex".to_string(),
1694 dest_path: "agents/coder.toml".to_string(),
1695 installed_checksum: "sha256:codex".into(),
1696 }],
1697 );
1698 assert!(lock.contains_output(".codex", "agents/coder.toml"));
1699 assert!(lock.contains_output(".mars", "agents/coder.md"));
1700 }
1701
1702 #[test]
1703 fn apply_compiled_native_outputs_upserts_when_frontmatter_name_differs_from_filename() {
1704 let mut lock = sample_lock();
1705 lock.items.insert(
1706 "agent/alias-name".to_string(),
1707 LockedItemV2 {
1708 source: "base".into(),
1709 kind: ItemKind::Agent,
1710 version: Some("v1.0.0".into()),
1711 source_checksum: "sha256:alias-src".into(),
1712 outputs: vec![OutputRecord {
1713 target_root: ".mars".to_string(),
1714 dest_path: "agents/on-disk-stem.md".into(),
1715 installed_checksum: "sha256:alias-mars".into(),
1716 }],
1717 },
1718 );
1719 apply_compiled_native_outputs(
1720 &mut lock,
1721 &[CompiledNativeOutput {
1722 owner_canonical_dest_path: "agents/on-disk-stem.md".to_string(),
1723 target_root: ".claude".to_string(),
1724 dest_path: "agents/alias-name.md".to_string(),
1725 installed_checksum: "sha256:claude-native".into(),
1726 }],
1727 );
1728 assert!(lock.contains_output(".claude", "agents/alias-name.md"));
1729 }
1730
1731 #[test]
1732 fn build_updated_carries_non_canonical_outputs() {
1733 let mut old_lock = sample_lock();
1734 old_lock
1735 .items
1736 .get_mut("agent/coder")
1737 .unwrap()
1738 .outputs
1739 .push(OutputRecord {
1740 target_root: ".claude".to_string(),
1741 dest_path: "agents/coder.md".into(),
1742 installed_checksum: "sha256:claude-old".into(),
1743 });
1744
1745 let graph = ResolvedGraph {
1746 nodes: IndexMap::new(),
1747 order: Vec::new(),
1748 filters: HashMap::new(),
1749 version_constraints: std::collections::HashMap::new(),
1750 };
1751 let applied = ApplyResult {
1752 outcomes: vec![ActionOutcome {
1753 item_id: ItemId {
1754 kind: ItemKind::Agent,
1755 name: "coder".into(),
1756 },
1757 action: ActionTaken::Updated,
1758 dest_path: "agents/coder.md".into(),
1759 source_name: "base".into(),
1760 source_checksum: Some("sha256:new-src".into()),
1761 installed_checksum: Some("sha256:new-mars".into()),
1762 }],
1763 };
1764
1765 let new_lock = build(
1766 &graph,
1767 &applied,
1768 &old_lock,
1769 std::collections::BTreeMap::new(),
1770 )
1771 .unwrap();
1772
1773 assert!(new_lock.contains_output(".mars", "agents/coder.md"));
1774 assert!(
1775 new_lock.contains_output(".claude", "agents/coder.md"),
1776 ".claude record should survive compile failure"
1777 );
1778 let item = &new_lock.items["agent/coder"];
1779 assert_eq!(item.outputs.len(), 2);
1780 assert_eq!(item.source_checksum, "sha256:new-src");
1781 let mars = item
1782 .outputs
1783 .iter()
1784 .find(|o| o.target_root == ".mars")
1785 .unwrap();
1786 assert_eq!(mars.installed_checksum, "sha256:new-mars");
1787 let claude = item
1788 .outputs
1789 .iter()
1790 .find(|o| o.target_root == ".claude")
1791 .unwrap();
1792 assert_eq!(claude.installed_checksum, "sha256:claude-old");
1793 }
1794
1795 #[test]
1796 fn build_fallback_carries_non_canonical_outputs_for_skipped_and_kept() {
1797 let old_lock = LockFile {
1798 version: LOCK_VERSION,
1799 dependencies: IndexMap::new(),
1800 items: IndexMap::from([
1801 (
1802 "agent/agents/coder.md".to_string(),
1803 LockedItemV2 {
1804 source: "base".into(),
1805 kind: ItemKind::Agent,
1806 version: None,
1807 source_checksum: "sha256:coder-src".into(),
1808 outputs: vec![
1809 OutputRecord {
1810 target_root: ".mars".to_string(),
1811 dest_path: "agents/coder.md".into(),
1812 installed_checksum: "sha256:coder-mars".into(),
1813 },
1814 OutputRecord {
1815 target_root: ".claude".to_string(),
1816 dest_path: "agents/coder.md".into(),
1817 installed_checksum: "sha256:coder-claude".into(),
1818 },
1819 ],
1820 },
1821 ),
1822 (
1823 "skill/skills/review".to_string(),
1824 LockedItemV2 {
1825 source: "base".into(),
1826 kind: ItemKind::Skill,
1827 version: None,
1828 source_checksum: "sha256:review-src".into(),
1829 outputs: vec![
1830 OutputRecord {
1831 target_root: ".mars".to_string(),
1832 dest_path: "skills/review".into(),
1833 installed_checksum: "sha256:review-mars".into(),
1834 },
1835 OutputRecord {
1836 target_root: ".codex".to_string(),
1837 dest_path: "skills/review/SKILL.md".into(),
1838 installed_checksum: "sha256:review-codex".into(),
1839 },
1840 ],
1841 },
1842 ),
1843 ]),
1844 config_entries: BTreeMap::new(),
1845 dependency_model_aliases: IndexMap::new(),
1846 };
1847 let graph = ResolvedGraph {
1848 nodes: IndexMap::new(),
1849 order: Vec::new(),
1850 filters: HashMap::new(),
1851 version_constraints: std::collections::HashMap::new(),
1852 };
1853 let applied = ApplyResult {
1854 outcomes: vec![
1855 ActionOutcome {
1856 item_id: ItemId {
1857 kind: ItemKind::Agent,
1858 name: "coder".into(),
1859 },
1860 action: ActionTaken::Skipped,
1861 dest_path: "agents/coder.md".into(),
1862 source_name: "base".into(),
1863 source_checksum: None,
1864 installed_checksum: None,
1865 },
1866 ActionOutcome {
1867 item_id: ItemId {
1868 kind: ItemKind::Skill,
1869 name: "review".into(),
1870 },
1871 action: ActionTaken::Kept,
1872 dest_path: "skills/review".into(),
1873 source_name: "base".into(),
1874 source_checksum: None,
1875 installed_checksum: None,
1876 },
1877 ],
1878 };
1879
1880 let new_lock = build(
1881 &graph,
1882 &applied,
1883 &old_lock,
1884 std::collections::BTreeMap::new(),
1885 )
1886 .unwrap();
1887
1888 assert!(!new_lock.items.contains_key("agent/agents/coder.md"));
1889 assert!(new_lock.contains_output(".mars", "agents/coder.md"));
1890 assert!(new_lock.contains_output(".claude", "agents/coder.md"));
1891
1892 assert!(!new_lock.items.contains_key("skill/skills/review"));
1893 assert!(new_lock.contains_output(".mars", "skills/review"));
1894 assert!(new_lock.contains_output(".codex", "skills/review/SKILL.md"));
1895 }
1896
1897 #[test]
1898 fn build_write_fallback_carries_non_canonical_outputs() {
1899 let old_lock = LockFile {
1900 version: LOCK_VERSION,
1901 dependencies: IndexMap::new(),
1902 items: IndexMap::from([(
1903 "agent/agents/coder.md".to_string(),
1904 LockedItemV2 {
1905 source: "base".into(),
1906 kind: ItemKind::Agent,
1907 version: None,
1908 source_checksum: "sha256:old-src".into(),
1909 outputs: vec![
1910 OutputRecord {
1911 target_root: ".mars".to_string(),
1912 dest_path: "agents/coder.md".into(),
1913 installed_checksum: "sha256:old-mars".into(),
1914 },
1915 OutputRecord {
1916 target_root: ".claude".to_string(),
1917 dest_path: "agents/coder.md".into(),
1918 installed_checksum: "sha256:old-claude".into(),
1919 },
1920 ],
1921 },
1922 )]),
1923 config_entries: BTreeMap::new(),
1924 dependency_model_aliases: IndexMap::new(),
1925 };
1926 let graph = ResolvedGraph {
1927 nodes: IndexMap::new(),
1928 order: Vec::new(),
1929 filters: HashMap::new(),
1930 version_constraints: std::collections::HashMap::new(),
1931 };
1932 let applied = ApplyResult {
1933 outcomes: vec![ActionOutcome {
1934 item_id: ItemId {
1935 kind: ItemKind::Agent,
1936 name: "coder".into(),
1937 },
1938 action: ActionTaken::Updated,
1939 dest_path: "agents/coder.md".into(),
1940 source_name: "base".into(),
1941 source_checksum: Some("sha256:new-src".into()),
1942 installed_checksum: Some("sha256:new-mars".into()),
1943 }],
1944 };
1945
1946 let new_lock = build(
1947 &graph,
1948 &applied,
1949 &old_lock,
1950 std::collections::BTreeMap::new(),
1951 )
1952 .unwrap();
1953
1954 assert!(!new_lock.items.contains_key("agent/agents/coder.md"));
1955 assert!(new_lock.contains_output(".mars", "agents/coder.md"));
1956 assert!(new_lock.contains_output(".claude", "agents/coder.md"));
1957 let item = &new_lock.items["agent/coder"];
1958 assert_eq!(item.source_checksum, "sha256:new-src");
1959 let claude = item
1960 .outputs
1961 .iter()
1962 .find(|o| o.target_root == ".claude")
1963 .unwrap();
1964 assert_eq!(claude.installed_checksum, "sha256:old-claude");
1965 }
1966
1967 #[test]
1968 fn apply_apply_outcomes_write_fallback_carries_non_canonical_outputs() {
1969 let old_lock = LockFile {
1970 version: LOCK_VERSION,
1971 dependencies: IndexMap::new(),
1972 items: IndexMap::from([(
1973 "agent/agents/coder.md".to_string(),
1974 LockedItemV2 {
1975 source: "base".into(),
1976 kind: ItemKind::Agent,
1977 version: None,
1978 source_checksum: "sha256:old-src".into(),
1979 outputs: vec![
1980 OutputRecord {
1981 target_root: ".mars".to_string(),
1982 dest_path: "agents/coder.md".into(),
1983 installed_checksum: "sha256:old-mars".into(),
1984 },
1985 OutputRecord {
1986 target_root: ".claude".to_string(),
1987 dest_path: "agents/coder.md".into(),
1988 installed_checksum: "sha256:old-claude".into(),
1989 },
1990 ],
1991 },
1992 )]),
1993 config_entries: BTreeMap::new(),
1994 dependency_model_aliases: IndexMap::new(),
1995 };
1996 let mut lock = old_lock.clone();
1997
1998 apply_apply_outcomes_to_lock(
1999 &mut lock,
2000 &old_lock,
2001 &[ActionOutcome {
2002 item_id: ItemId {
2003 kind: ItemKind::Agent,
2004 name: "coder".into(),
2005 },
2006 action: ActionTaken::Updated,
2007 dest_path: "agents/coder.md".into(),
2008 source_name: "base".into(),
2009 source_checksum: Some("sha256:new-src".into()),
2010 installed_checksum: Some("sha256:new-mars".into()),
2011 }],
2012 );
2013
2014 assert!(!lock.items.contains_key("agent/agents/coder.md"));
2015 assert!(lock.contains_output(".mars", "agents/coder.md"));
2016 assert!(lock.contains_output(".claude", "agents/coder.md"));
2017 let item = &lock.items["agent/coder"];
2018 assert_eq!(item.source_checksum, "sha256:new-src");
2019 }
2020
2021 #[test]
2022 fn apply_apply_outcomes_to_lock_updated_preserves_non_canonical_outputs() {
2023 let mut old_lock = sample_lock();
2024 old_lock
2025 .items
2026 .get_mut("agent/coder")
2027 .unwrap()
2028 .outputs
2029 .push(OutputRecord {
2030 target_root: ".claude".to_string(),
2031 dest_path: "agents/coder.md".into(),
2032 installed_checksum: "sha256:claude".into(),
2033 });
2034
2035 let mut lock = old_lock.clone();
2036 apply_apply_outcomes_to_lock(
2037 &mut lock,
2038 &old_lock,
2039 &[ActionOutcome {
2040 item_id: ItemId {
2041 kind: ItemKind::Agent,
2042 name: ItemName::from("coder"),
2043 },
2044 action: ActionTaken::Updated,
2045 dest_path: "agents/coder.md".into(),
2046 source_name: "base".into(),
2047 source_checksum: Some("sha256:new-src".into()),
2048 installed_checksum: Some("sha256:new-mars".into()),
2049 }],
2050 );
2051
2052 assert!(lock.contains_output(".mars", "agents/coder.md"));
2053 assert!(lock.contains_output(".claude", "agents/coder.md"));
2054 let item = &lock.items["agent/coder"];
2055 assert_eq!(item.source_checksum, "sha256:new-src");
2056 let mars = item
2057 .outputs
2058 .iter()
2059 .find(|o| o.target_root == ".mars")
2060 .unwrap();
2061 assert_eq!(mars.installed_checksum, "sha256:new-mars");
2062 let claude = item
2063 .outputs
2064 .iter()
2065 .find(|o| o.target_root == ".claude")
2066 .unwrap();
2067 assert_eq!(claude.installed_checksum, "sha256:claude");
2068 }
2069
2070 #[test]
2071 fn ownership_lock_for_native_emission_seeds_new_apply_outcomes() {
2072 let old_lock = LockFile::empty();
2073 let apply_outcomes = vec![ActionOutcome {
2074 item_id: ItemId {
2075 kind: ItemKind::Agent,
2076 name: ItemName::from("coder"),
2077 },
2078 action: ActionTaken::Installed,
2079 dest_path: "agents/coder.md".into(),
2080 source_name: "base".into(),
2081 source_checksum: Some("sha256:src".into()),
2082 installed_checksum: Some("sha256:mars".into()),
2083 }];
2084 let view = ownership_lock_for_native_emission(
2085 &old_lock,
2086 &apply_outcomes,
2087 &[crate::target_sync::TargetSyncOutcome {
2088 target: ".cursor".to_string(),
2089 items_synced: 1,
2090 items_removed: 0,
2091 errors: Vec::new(),
2092 synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
2093 dest_path: "agents/coder.md".to_string(),
2094 installed_checksum: "sha256:cursor".into(),
2095 }],
2096 removed_dest_paths: Vec::new(),
2097 }],
2098 );
2099 assert!(view.contains_output(".mars", "agents/coder.md"));
2100 assert!(view.contains_output(".cursor", "agents/coder.md"));
2101 assert!(!old_lock.contains_output(".mars", "agents/coder.md"));
2102 }
2103
2104 #[test]
2105 fn ownership_lock_after_target_sync_layers_synced_outputs() {
2106 let lock = sample_lock();
2107 let view = ownership_lock_after_target_sync(
2108 &lock,
2109 &[crate::target_sync::TargetSyncOutcome {
2110 target: ".cursor".to_string(),
2111 items_synced: 1,
2112 items_removed: 0,
2113 errors: Vec::new(),
2114 synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
2115 dest_path: "agents/coder.md".to_string(),
2116 installed_checksum: "sha256:cursor".into(),
2117 }],
2118 removed_dest_paths: Vec::new(),
2119 }],
2120 );
2121 assert!(view.contains_output(".cursor", "agents/coder.md"));
2122 assert!(!lock.contains_output(".cursor", "agents/coder.md"));
2123 }
2124
2125 #[test]
2126 fn apply_target_sync_outputs_upserts_and_removes_target_records() {
2127 let mut lock = sample_lock();
2128 apply_target_sync_outputs(
2129 &mut lock,
2130 &[crate::target_sync::TargetSyncOutcome {
2131 target: ".cursor".to_string(),
2132 items_synced: 1,
2133 items_removed: 0,
2134 errors: Vec::new(),
2135 synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
2136 dest_path: "agents/coder.md".to_string(),
2137 installed_checksum: "sha256:cursor".into(),
2138 }],
2139 removed_dest_paths: Vec::new(),
2140 }],
2141 );
2142 assert!(lock.contains_output(".cursor", "agents/coder.md"));
2143
2144 apply_target_sync_outputs(
2145 &mut lock,
2146 &[crate::target_sync::TargetSyncOutcome {
2147 target: ".cursor".to_string(),
2148 items_synced: 0,
2149 items_removed: 1,
2150 errors: Vec::new(),
2151 synced_outputs: Vec::new(),
2152 removed_dest_paths: vec!["agents/coder.md".to_string()],
2153 }],
2154 );
2155 assert!(!lock.contains_output(".cursor", "agents/coder.md"));
2156 assert!(lock.contains_output(".mars", "agents/coder.md"));
2157 }
2158
2159 #[test]
2160 fn canonical_flat_items_excludes_linked_target_outputs() {
2161 let mut lock = sample_lock();
2162 lock.items
2163 .get_mut("agent/coder")
2164 .unwrap()
2165 .outputs
2166 .push(OutputRecord {
2167 target_root: ".cursor".to_string(),
2168 dest_path: "agents/coder.md".into(),
2169 installed_checksum: "sha256:cursor".into(),
2170 });
2171
2172 let canonical = lock.canonical_flat_items();
2173 assert_eq!(canonical.len(), 2);
2174 assert!(
2175 canonical
2176 .iter()
2177 .any(|(dp, _)| dp.as_str() == "agents/coder.md")
2178 );
2179 assert!(
2180 canonical
2181 .iter()
2182 .all(|(_, item)| { lock.contains_output(".mars", item.dest_path.as_str()) })
2183 );
2184
2185 let cursor = lock.flat_items_for_target(".cursor");
2186 assert_eq!(cursor.len(), 1);
2187 assert_eq!(cursor[0].0.as_str(), "agents/coder.md");
2188 }
2189
2190 #[test]
2191 fn flat_items_yields_all_outputs() {
2192 let lock = sample_lock();
2193 let flat = lock.flat_items();
2194 assert_eq!(flat.len(), 2);
2195 let paths: Vec<&str> = flat.iter().map(|(dp, _)| dp.as_str()).collect();
2196 assert!(paths.contains(&"agents/coder.md"));
2197 assert!(paths.contains(&"skills/review"));
2198 }
2199
2200 #[test]
2201 fn v1_lock_no_spurious_reinstall() {
2202 let v1_toml = r#"
2204version = 1
2205
2206[dependencies.base]
2207url = "https://github.com/org/base.git"
2208
2209[items."agents/coder.md"]
2210source = "base"
2211kind = "agent"
2212source_checksum = "sha256:src"
2213installed_checksum = "sha256:inst"
2214dest_path = "agents/coder.md"
2215"#;
2216 let dir = TempDir::new().unwrap();
2217 std::fs::write(dir.path().join("mars.lock"), v1_toml).unwrap();
2218 let lock = load(dir.path()).unwrap();
2219
2220 let found = lock.find_by_dest_path(&DestPath::from("agents/coder.md"));
2222 assert!(found.is_some());
2223 let item = found.unwrap();
2224 assert_eq!(item.source_checksum, "sha256:src");
2225 assert_eq!(item.installed_checksum, "sha256:inst");
2226 }
2227
2228 #[test]
2229 fn build_uses_graph_provenance_for_sources() {
2230 let git_name: SourceName = "base".into();
2231 let path_name: SourceName = "local".into();
2232 let git_url: SourceUrl = "https://example.com/new.git".into();
2233 let path_canonical = PathBuf::from("/tmp/mars-agents-local-source");
2234
2235 let mut nodes = IndexMap::new();
2236 nodes.insert(
2237 git_name.clone(),
2238 ResolvedNode {
2239 source_name: git_name.clone(),
2240 source_id: SourceId::git_with_subpath(
2241 git_url.clone(),
2242 Some(crate::types::SourceSubpath::new("plugins/base").unwrap()),
2243 ),
2244 rooted_ref: crate::resolve::RootedSourceRef {
2245 checkout_root: PathBuf::from("/tmp/cache/base"),
2246 package_root: PathBuf::from("/tmp/cache/base/plugins/base"),
2247 },
2248 resolved_ref: ResolvedRef {
2249 source_name: git_name.clone(),
2250 version: Some(semver::Version::new(1, 2, 3)),
2251 version_tag: Some("v1.2.3".into()),
2252 commit: Some("abc123".into()),
2253 tree_path: PathBuf::from("/tmp/cache/base"),
2254 },
2255 latest_version: None,
2256 manifest: None,
2257 deps: vec![],
2258 },
2259 );
2260 nodes.insert(
2261 path_name.clone(),
2262 ResolvedNode {
2263 source_name: path_name.clone(),
2264 source_id: SourceId::Path {
2265 canonical: path_canonical.clone(),
2266 subpath: Some(crate::types::SourceSubpath::new("plugins/local").unwrap()),
2267 },
2268 rooted_ref: crate::resolve::RootedSourceRef {
2269 checkout_root: PathBuf::from("/tmp/cache/local"),
2270 package_root: PathBuf::from("/tmp/cache/local/plugins/local"),
2271 },
2272 resolved_ref: ResolvedRef {
2273 source_name: path_name.clone(),
2274 version: None,
2275 version_tag: None,
2276 commit: None,
2277 tree_path: PathBuf::from("/tmp/cache/local"),
2278 },
2279 latest_version: None,
2280 manifest: None,
2281 deps: vec![],
2282 },
2283 );
2284
2285 let graph = ResolvedGraph {
2286 nodes,
2287 order: vec![git_name.clone(), path_name.clone()],
2288 filters: HashMap::new(),
2289 version_constraints: std::collections::HashMap::new(),
2290 };
2291 let applied = ApplyResult { outcomes: vec![] };
2292
2293 let mut old_sources = IndexMap::new();
2294 old_sources.insert(
2295 git_name.clone(),
2296 LockedSource {
2297 url: Some("https://example.com/old.git".into()),
2298 path: None,
2299 subpath: None,
2300 version: Some("v0.0.1".into()),
2301 commit: Some("deadbeef".into()),
2302 tree_hash: None,
2303 },
2304 );
2305 let old_lock = LockFile {
2306 version: LOCK_VERSION,
2307 dependencies: old_sources,
2308 items: IndexMap::new(),
2309 config_entries: std::collections::BTreeMap::new(),
2310 dependency_model_aliases: IndexMap::new(),
2311 };
2312
2313 let new_lock = build(
2314 &graph,
2315 &applied,
2316 &old_lock,
2317 std::collections::BTreeMap::new(),
2318 )
2319 .unwrap();
2320
2321 let base = &new_lock.dependencies["base"];
2322 assert_eq!(base.url.as_ref(), Some(&git_url));
2323 assert_eq!(
2324 base.subpath
2325 .as_ref()
2326 .map(crate::types::SourceSubpath::as_str),
2327 Some("plugins/base")
2328 );
2329 assert_eq!(base.version.as_deref(), Some("v1.2.3"));
2330 assert_eq!(base.commit.as_deref(), Some("abc123"));
2331
2332 let local = &new_lock.dependencies["local"];
2333 assert!(local.url.is_none());
2334 assert_eq!(
2335 local
2336 .subpath
2337 .as_ref()
2338 .map(crate::types::SourceSubpath::as_str),
2339 Some("plugins/local")
2340 );
2341 assert_eq!(
2342 local.path.as_deref(),
2343 Some(path_canonical.to_string_lossy().as_ref())
2344 );
2345 }
2346
2347 #[test]
2348 fn build_persists_ref_selector_in_locked_source_version() {
2349 let source_name: SourceName = "base".into();
2350 let mut nodes = IndexMap::new();
2351 nodes.insert(
2352 source_name.clone(),
2353 ResolvedNode {
2354 source_name: source_name.clone(),
2355 source_id: SourceId::git("https://example.com/base.git".into()),
2356 rooted_ref: crate::resolve::RootedSourceRef {
2357 checkout_root: PathBuf::from("/tmp/cache/base"),
2358 package_root: PathBuf::from("/tmp/cache/base"),
2359 },
2360 resolved_ref: ResolvedRef {
2361 source_name: source_name.clone(),
2362 version: None,
2363 version_tag: Some("main".into()),
2364 commit: Some("abc123".into()),
2365 tree_path: PathBuf::from("/tmp/cache/base"),
2366 },
2367 latest_version: None,
2368 manifest: None,
2369 deps: vec![],
2370 },
2371 );
2372
2373 let graph = ResolvedGraph {
2374 nodes,
2375 order: vec![source_name.clone()],
2376 filters: HashMap::new(),
2377 version_constraints: std::collections::HashMap::new(),
2378 };
2379 let applied = ApplyResult { outcomes: vec![] };
2380 let new_lock = build(
2381 &graph,
2382 &applied,
2383 &LockFile::empty(),
2384 std::collections::BTreeMap::new(),
2385 )
2386 .unwrap();
2387
2388 let source = &new_lock.dependencies["base"];
2389 assert_eq!(source.version.as_deref(), Some("main"));
2390 assert_eq!(source.commit.as_deref(), Some("abc123"));
2391 }
2392
2393 #[test]
2394 fn build_keeps_self_items_from_old_lock_on_skipped_action() {
2395 let graph = ResolvedGraph {
2396 nodes: IndexMap::new(),
2397 order: Vec::new(),
2398 filters: HashMap::new(),
2399 version_constraints: std::collections::HashMap::new(),
2400 };
2401 let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
2402 let old_lock = LockFile {
2403 version: LOCK_VERSION,
2404 dependencies: IndexMap::from([(
2405 local_source_name.clone(),
2406 LockedSource {
2407 url: None,
2408 path: Some(".".into()),
2409 subpath: None,
2410 version: None,
2411 commit: None,
2412 tree_hash: None,
2413 },
2414 )]),
2415 items: IndexMap::from([(
2416 "skill/local-skill".to_string(),
2417 LockedItemV2 {
2418 source: local_source_name.clone(),
2419 kind: ItemKind::Skill,
2420 version: None,
2421 source_checksum: "sha256:self".into(),
2422 outputs: vec![OutputRecord {
2423 target_root: ".mars".to_string(),
2424 dest_path: DestPath::from("skills/local-skill"),
2425 installed_checksum: "sha256:self".into(),
2426 }],
2427 },
2428 )]),
2429 config_entries: std::collections::BTreeMap::new(),
2430 dependency_model_aliases: IndexMap::new(),
2431 };
2432 let applied = ApplyResult {
2433 outcomes: vec![ActionOutcome {
2434 item_id: ItemId {
2435 kind: ItemKind::Skill,
2436 name: "local-skill".into(),
2437 },
2438 action: ActionTaken::Skipped,
2439 dest_path: "skills/local-skill".into(),
2440 source_name: local_source_name.clone(),
2441 source_checksum: None,
2442 installed_checksum: None,
2443 }],
2444 };
2445
2446 let new_lock = build(
2447 &graph,
2448 &applied,
2449 &old_lock,
2450 std::collections::BTreeMap::new(),
2451 )
2452 .unwrap();
2453
2454 assert!(
2455 new_lock
2456 .dependencies
2457 .contains_key(local_source_name.as_str())
2458 );
2459 let item = &new_lock.items["skill/local-skill"];
2460 assert_eq!(item.source, local_source_name);
2461 assert_eq!(item.kind, ItemKind::Skill);
2462 assert_eq!(item.source_checksum, "sha256:self");
2463 assert_eq!(item.outputs[0].installed_checksum, "sha256:self");
2464 }
2465
2466 #[test]
2467 fn build_rejects_missing_installed_checksum_for_write_actions() {
2468 let graph = ResolvedGraph {
2469 nodes: IndexMap::new(),
2470 order: Vec::new(),
2471 filters: HashMap::new(),
2472 version_constraints: std::collections::HashMap::new(),
2473 };
2474 let old_lock = LockFile::empty();
2475 let applied = ApplyResult {
2476 outcomes: vec![ActionOutcome {
2477 item_id: ItemId {
2478 kind: ItemKind::Agent,
2479 name: "coder".into(),
2480 },
2481 action: ActionTaken::Installed,
2482 dest_path: "agents/coder.md".into(),
2483 source_name: "base".into(),
2484 source_checksum: Some("sha256:source".into()),
2485 installed_checksum: None,
2486 }],
2487 };
2488
2489 let err = build(
2490 &graph,
2491 &applied,
2492 &old_lock,
2493 std::collections::BTreeMap::new(),
2494 )
2495 .unwrap_err();
2496 let msg = err.to_string();
2497 assert!(msg.contains("missing checksum for write-producing action"));
2498 assert!(msg.contains("agents/coder.md"));
2499 }
2500
2501 #[test]
2502 fn promote_v1_collision_both_survive() {
2503 let mut v1_items: IndexMap<DestPath, LockedItem> = IndexMap::new();
2507
2508 v1_items.insert(
2509 DestPath::from("hooks/pre-commit/hook.sh"),
2510 LockedItem {
2511 source: "base".into(),
2512 kind: ItemKind::Hook,
2513 version: None,
2514 source_checksum: "sha256:aaa".into(),
2515 installed_checksum: "sha256:bbb".into(),
2516 dest_path: DestPath::from("hooks/pre-commit/hook.sh"),
2517 },
2518 );
2519 v1_items.insert(
2520 DestPath::from("hooks/pre-push/hook.sh"),
2521 LockedItem {
2522 source: "base".into(),
2523 kind: ItemKind::Hook,
2524 version: None,
2525 source_checksum: "sha256:ccc".into(),
2526 installed_checksum: "sha256:ddd".into(),
2527 dest_path: DestPath::from("hooks/pre-push/hook.sh"),
2528 },
2529 );
2530
2531 let (promoted, diagnostics) = promote_v1_items(v1_items);
2532
2533 assert_eq!(promoted.len(), 2, "both items should survive promotion");
2535 assert_eq!(diagnostics.len(), 1);
2536
2537 let checksums: std::collections::HashSet<String> = promoted
2539 .values()
2540 .map(|v| v.source_checksum.as_ref().to_string())
2541 .collect();
2542 assert!(
2543 checksums.contains("sha256:aaa"),
2544 "pre-commit hook must be present"
2545 );
2546 assert!(
2547 checksums.contains("sha256:ccc"),
2548 "pre-push hook must be present"
2549 );
2550 }
2551
2552 #[test]
2553 fn load_with_diagnostics_reports_v1_promotion_collision() {
2554 let v1_toml = r#"
2555version = 1
2556
2557[dependencies.base]
2558url = "https://github.com/org/base.git"
2559
2560[items."hooks/pre-commit/hook.sh"]
2561source = "base"
2562kind = "hook"
2563source_checksum = "sha256:aaa"
2564installed_checksum = "sha256:bbb"
2565dest_path = "hooks/pre-commit/hook.sh"
2566
2567[items."hooks/pre-push/hook.sh"]
2568source = "base"
2569kind = "hook"
2570source_checksum = "sha256:ccc"
2571installed_checksum = "sha256:ddd"
2572dest_path = "hooks/pre-push/hook.sh"
2573"#;
2574 let dir = TempDir::new().unwrap();
2575 std::fs::write(dir.path().join("mars.lock"), v1_toml).unwrap();
2576
2577 let (lock, diagnostics) = load_with_diagnostics(dir.path()).unwrap();
2578
2579 assert_eq!(lock.version, LOCK_VERSION);
2580 assert_eq!(lock.items.len(), 2);
2581 assert_eq!(diagnostics.len(), 1);
2582 let diagnostic = &diagnostics[0];
2583 assert_eq!(
2584 diagnostic.level,
2585 crate::diagnostic::DiagnosticLevel::Warning
2586 );
2587 assert_eq!(diagnostic.code, "lock-promotion-collision");
2588 assert!(diagnostic.message.contains("key collision"));
2589 assert!(diagnostic.message.contains("hook/hooks/pre-push/hook.sh"));
2590 }
2591
2592 #[test]
2593 fn build_rejects_empty_checksums_from_carried_items() {
2594 let graph = ResolvedGraph {
2595 nodes: IndexMap::new(),
2596 order: Vec::new(),
2597 filters: HashMap::new(),
2598 version_constraints: std::collections::HashMap::new(),
2599 };
2600 let old_lock = LockFile {
2601 version: LOCK_VERSION,
2602 dependencies: IndexMap::new(),
2603 items: IndexMap::from([(
2604 "agent/coder".to_string(),
2605 LockedItemV2 {
2606 source: "base".into(),
2607 kind: ItemKind::Agent,
2608 version: None,
2609 source_checksum: "".into(),
2610 outputs: vec![OutputRecord {
2611 target_root: ".mars".to_string(),
2612 dest_path: DestPath::from("agents/coder.md"),
2613 installed_checksum: "sha256:installed".into(),
2614 }],
2615 },
2616 )]),
2617 config_entries: std::collections::BTreeMap::new(),
2618 dependency_model_aliases: IndexMap::new(),
2619 };
2620 let applied = ApplyResult {
2621 outcomes: vec![ActionOutcome {
2622 item_id: ItemId {
2623 kind: ItemKind::Agent,
2624 name: "coder".into(),
2625 },
2626 action: ActionTaken::Skipped,
2627 dest_path: "agents/coder.md".into(),
2628 source_name: "base".into(),
2629 source_checksum: None,
2630 installed_checksum: None,
2631 }],
2632 };
2633
2634 let err = build(
2635 &graph,
2636 &applied,
2637 &old_lock,
2638 std::collections::BTreeMap::new(),
2639 )
2640 .unwrap_err();
2641 let msg = err.to_string();
2642 assert!(msg.contains("empty source_checksum"));
2643 }
2644}