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 {
42 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
43 let wire = LockFileWire::deserialize(deserializer)?;
44 Ok(LockFile {
45 version: wire.version,
46 dependencies: wire.dependencies,
47 items: wire.items,
48 config_entries: wire.config_entries,
49 dependency_model_aliases: wire.dependency_model_aliases,
50 })
51 }
52}
53
54impl LockFile {
55 pub fn empty() -> Self {
57 LockFile {
58 version: LOCK_VERSION,
59 dependencies: IndexMap::new(),
60 items: IndexMap::new(),
61 config_entries: BTreeMap::new(),
62 dependency_model_aliases: IndexMap::new(),
63 }
64 }
65
66 pub fn find_by_dest_path(&self, dest_path: &DestPath) -> Option<LockedItem> {
70 for item_v2 in self.items.values() {
71 for output in &item_v2.outputs {
72 if crate::target::dest_paths_equivalent(
73 output.dest_path.as_str(),
74 dest_path.as_str(),
75 ) && let Some(installed_checksum) = output.installed_checksum()
76 {
77 return Some(LockedItem {
78 source: item_v2.source.clone(),
79 kind: item_v2.kind,
80 version: item_v2.version.clone(),
81 source_checksum: item_v2.source_checksum.clone(),
82 installed_checksum: installed_checksum.clone(),
83 dest_path: output.dest_path.clone(),
84 });
85 }
86 }
87 }
88 None
89 }
90
91 pub fn contains_dest_path(&self, dest_path: &DestPath) -> bool {
93 self.items.values().any(|item| {
94 item.outputs.iter().any(|o| {
95 crate::target::dest_paths_equivalent(o.dest_path.as_str(), dest_path.as_str())
96 })
97 })
98 }
99
100 pub fn all_output_dest_paths(&self) -> impl Iterator<Item = &DestPath> {
102 self.items
103 .values()
104 .flat_map(|item| item.outputs.iter().map(|o| &o.dest_path))
105 }
106
107 pub fn output_dest_paths_for_target(&self, target_root: &str) -> HashSet<String> {
109 self.items
110 .values()
111 .flat_map(|item| item.outputs.iter())
112 .filter(|output| output.target_root == target_root)
113 .map(|output| output.dest_path.to_string())
114 .collect()
115 }
116
117 pub fn contains_output(&self, target_root: &str, dest_path: &str) -> bool {
119 self.items.values().any(|item| {
120 item.outputs.iter().any(|output| {
121 output.target_root == target_root
122 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
123 })
124 })
125 }
126
127 pub(crate) fn installed_checksum_for_output(
133 &self,
134 target_root: &str,
135 dest_path: &str,
136 ) -> Option<&ContentHash> {
137 self.items.values().find_map(|item| {
138 item.outputs.iter().find_map(|output| {
139 (output.target_root == target_root
140 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path))
141 .then(|| output.installed_checksum())
142 .flatten()
143 })
144 })
145 }
146
147 pub fn canonical_flat_items(&self) -> Vec<(DestPath, LockedItem)> {
149 self.flat_items_for_target(CANONICAL_TARGET_ROOT)
150 }
151
152 pub fn flat_items_for_target(&self, target_root: &str) -> Vec<(DestPath, LockedItem)> {
154 self.items
155 .values()
156 .flat_map(|item_v2| {
157 item_v2.outputs.iter().filter_map(|output| {
158 if output.target_root != target_root {
159 return None;
160 }
161 let installed_checksum = output.installed_checksum()?;
162 Some((
163 output.dest_path.clone(),
164 LockedItem {
165 source: item_v2.source.clone(),
166 kind: item_v2.kind,
167 version: item_v2.version.clone(),
168 source_checksum: item_v2.source_checksum.clone(),
169 installed_checksum: installed_checksum.clone(),
170 dest_path: output.dest_path.clone(),
171 },
172 ))
173 })
174 })
175 .collect()
176 }
177}
178
179pub struct LockIndex<'a> {
184 lock: &'a LockFile,
185 by_output: HashMap<(String, String), (&'a str, usize)>,
186}
187
188impl<'a> LockIndex<'a> {
189 pub fn new(lock: &'a LockFile) -> Self {
190 let mut by_output = HashMap::new();
191 for (key, item) in &lock.items {
192 for (idx, output) in item.outputs.iter().enumerate() {
193 let normalized_dest = normalize_dest_path(output.dest_path.as_str());
194 by_output.insert(
195 (output.target_root.clone(), normalized_dest),
196 (key.as_str(), idx),
197 );
198 }
199 }
200
201 Self { lock, by_output }
202 }
203
204 pub fn find_output(&self, target_root: &str, dest_path: &DestPath) -> Option<LockedItem> {
206 let (item_key, output_idx) = *self.by_output.get(&(
207 target_root.to_string(),
208 normalize_dest_path(dest_path.as_str()),
209 ))?;
210 self.locked_item_for(item_key, output_idx)
211 }
212
213 fn item_for_output(
214 &self,
215 target_root: &str,
216 dest_path: &DestPath,
217 ) -> Option<(&'a str, &'a LockedItemV2, &'a OutputRecord)> {
218 let (item_key, output_idx) = *self.by_output.get(&(
219 target_root.to_string(),
220 normalize_dest_path(dest_path.as_str()),
221 ))?;
222 let item = self.lock.items.get(item_key)?;
223 let output = item.outputs.get(output_idx)?;
224 Some((item_key, item, output))
225 }
226
227 pub fn contains_output(&self, target_root: &str, dest_path: &DestPath) -> bool {
229 self.by_output.contains_key(&(
230 target_root.to_string(),
231 normalize_dest_path(dest_path.as_str()),
232 ))
233 }
234
235 pub(crate) fn contains_installed_output(
237 &self,
238 target_root: &str,
239 dest_path: &DestPath,
240 ) -> bool {
241 self.item_for_output(target_root, dest_path)
242 .is_some_and(|(_, _, output)| output.installed_checksum().is_some())
243 }
244
245 fn locked_item_for(&self, item_key: &str, output_idx: usize) -> Option<LockedItem> {
246 let item_v2 = self.lock.items.get(item_key)?;
247 let output = item_v2.outputs.get(output_idx)?;
248 let installed_checksum = output.installed_checksum()?;
249 Some(LockedItem {
250 source: item_v2.source.clone(),
251 kind: item_v2.kind,
252 version: item_v2.version.clone(),
253 source_checksum: item_v2.source_checksum.clone(),
254 installed_checksum: installed_checksum.clone(),
255 dest_path: output.dest_path.clone(),
256 })
257 }
258}
259
260fn normalize_dest_path(s: &str) -> String {
261 if cfg!(windows) {
262 s.replace('\\', "/")
263 } else {
264 s.to_string()
265 }
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
270pub struct LockedSource {
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub url: Option<SourceUrl>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub path: Option<String>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub subpath: Option<SourceSubpath>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub version: Option<String>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub commit: Option<CommitHash>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
288pub struct LockedItemV2 {
289 pub source: SourceName,
290 pub kind: ItemKind,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub version: Option<String>,
293 pub source_checksum: ContentHash,
294 pub outputs: Vec<OutputRecord>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
300pub struct OutputRecord {
301 pub target_root: String,
303 pub dest_path: DestPath,
305 #[serde(flatten)]
307 pub state: OutputState,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
312#[serde(tag = "state", rename_all = "kebab-case")]
313pub enum OutputState {
314 Installed { installed_checksum: ContentHash },
316 PendingDeletion,
318}
319
320impl OutputRecord {
321 pub fn installed(
322 target_root: String,
323 dest_path: DestPath,
324 installed_checksum: ContentHash,
325 ) -> Self {
326 Self {
327 target_root,
328 dest_path,
329 state: OutputState::Installed { installed_checksum },
330 }
331 }
332
333 pub fn pending_deletion(
334 target_root: impl Into<String>,
335 dest_path: impl Into<DestPath>,
336 ) -> Self {
337 Self {
338 target_root: target_root.into(),
339 dest_path: dest_path.into(),
340 state: OutputState::PendingDeletion,
341 }
342 }
343
344 pub fn installed_checksum(&self) -> Option<&ContentHash> {
345 match &self.state {
346 OutputState::Installed { installed_checksum } => Some(installed_checksum),
347 OutputState::PendingDeletion => None,
348 }
349 }
350
351 pub fn mark_installed(&mut self, installed_checksum: ContentHash) {
352 self.state = OutputState::Installed { installed_checksum };
353 }
354
355 pub fn mark_pending_deletion(&mut self) {
356 self.state = OutputState::PendingDeletion;
357 }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
362pub struct ConfigEntryRecord {
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub emitted_json: Option<String>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
373pub struct LockedItem {
374 pub source: SourceName,
375 pub kind: ItemKind,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub version: Option<String>,
378 pub source_checksum: ContentHash,
379 pub installed_checksum: ContentHash,
380 pub dest_path: DestPath,
381}
382
383pub use crate::types::{ItemId, ItemKind};
386
387const LOCK_FILE: &str = "mars.lock";
388const LOCK_VERSION: u32 = 3;
390pub const CANONICAL_TARGET_ROOT: &str = ".mars";
392
393#[derive(Deserialize)]
399struct LockFileWire {
400 version: u32,
401 #[serde(default)]
402 dependencies: IndexMap<SourceName, LockedSource>,
403 #[serde(default)]
404 items: IndexMap<String, LockedItemV2>,
405 #[serde(default)]
406 config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
407 #[serde(default)]
408 dependency_model_aliases: IndexMap<String, ModelAlias>,
409}
410
411#[derive(Deserialize)]
413struct OutputRecordV2 {
414 target_root: String,
415 dest_path: DestPath,
416 installed_checksum: ContentHash,
417}
418
419#[derive(Deserialize)]
420struct LockedItemV2Wire {
421 source: SourceName,
422 kind: ItemKind,
423 #[serde(default)]
424 version: Option<String>,
425 source_checksum: ContentHash,
426 outputs: Vec<OutputRecordV2>,
427}
428
429#[derive(Deserialize)]
432struct LockFileV2Wire {
433 #[allow(dead_code)]
434 version: u32,
435 #[serde(default)]
436 dependencies: IndexMap<SourceName, LockedSource>,
437 #[serde(default)]
438 items: IndexMap<String, LockedItemV2Wire>,
439 #[serde(default)]
440 config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
441 #[serde(default)]
442 dependency_model_aliases: IndexMap<String, ModelAlias>,
443}
444
445pub fn load(root: &Path) -> Result<LockFile, MarsError> {
455 let (lock, _) = load_with_diagnostics(root)?;
456 Ok(lock)
457}
458
459pub fn load_for_runtime_aliases(root: &Path) -> Result<LockFile, MarsError> {
466 let path = root.join(LOCK_FILE);
467 let content = match std::fs::read_to_string(&path) {
468 Ok(c) => c,
469 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(LockFile::empty()),
470 Err(e) => return Err(LockError::Io(e).into()),
471 };
472
473 let value: toml::Value = toml::from_str(&content).map_err(|e| LockError::Corrupt {
474 message: format!("failed to parse {}: {e}", path.display()),
475 })?;
476
477 let has_dependency_alias_field = value
478 .as_table()
479 .map(|table| table.contains_key("dependency_model_aliases"))
480 .unwrap_or(false);
481
482 let (lock, _) = load_with_diagnostics(root)?;
483
484 if !has_dependency_alias_field && !lock.dependencies.is_empty() {
485 return Err(LockError::Corrupt {
486 message: format!(
487 "legacy {} is missing `dependency_model_aliases` for dependency alias authority; run `{}` to update it",
488 LOCK_FILE,
489 crate::types::managed_cmd("mars sync")
490 ),
491 }
492 .into());
493 }
494
495 Ok(lock)
496}
497
498pub fn load_with_diagnostics(root: &Path) -> Result<(LockFile, Vec<Diagnostic>), MarsError> {
504 let path = root.join(LOCK_FILE);
505 let content = match std::fs::read_to_string(&path) {
506 Ok(c) => c,
507 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
508 return Ok((LockFile::empty(), Vec::new()));
509 }
510 Err(e) => return Err(LockError::Io(e).into()),
511 };
512
513 let value: toml::Value = toml::from_str(&content).map_err(|e| LockError::Corrupt {
514 message: format!("failed to parse {}: {e}", path.display()),
515 })?;
516 let version = value
517 .get("version")
518 .and_then(toml::Value::as_integer)
519 .ok_or_else(|| LockError::Corrupt {
520 message: format!("{} has no integer lock version", path.display()),
521 })?;
522 match version {
523 3 => {
524 let wire: LockFileWire = value.try_into().map_err(|error| LockError::Corrupt {
525 message: format!(
526 "failed to parse {} lock version {LOCK_VERSION}: {error}",
527 path.display()
528 ),
529 })?;
530 Ok((
531 LockFile {
532 version: wire.version,
533 dependencies: wire.dependencies,
534 items: wire.items,
535 config_entries: wire.config_entries,
536 dependency_model_aliases: wire.dependency_model_aliases,
537 },
538 Vec::new(),
539 ))
540 }
541 2 => {
542 let wire: LockFileV2Wire =
543 value.try_into().map_err(|error| LockError::Corrupt {
544 message: format!("failed to parse {} lock version 2: {error}", path.display()),
545 })?;
546 Ok((promote_v2_lock(root, wire), Vec::new()))
547 }
548 older if older < i64::from(LOCK_VERSION) => Err(LockError::Corrupt {
549 message: format!(
550 "{} uses unsupported lock version {older}; remove it and run `{}` (only version 2 can be promoted to version {LOCK_VERSION})",
551 path.display(),
552 crate::types::managed_cmd("mars sync")
553 ),
554 }
555 .into()),
556 newer => Err(LockError::Corrupt {
557 message: format!(
558 "{} uses unsupported lock version {newer}; this Mars supports version {LOCK_VERSION}",
559 path.display()
560 ),
561 }
562 .into()),
563 }
564}
565
566fn promote_v2_lock(root: &Path, wire: LockFileV2Wire) -> LockFile {
573 let items = wire
574 .items
575 .into_iter()
576 .map(|(key, item)| {
577 let outputs = item
578 .outputs
579 .into_iter()
580 .map(|output| {
581 let path = root
582 .join(&output.target_root)
583 .join(output.dest_path.as_str());
584 let matches_disk = v2_output_checksum(&path)
585 .is_some_and(|checksum| checksum == output.installed_checksum.as_ref());
586 if matches_disk {
587 OutputRecord::installed(
588 output.target_root,
589 output.dest_path,
590 output.installed_checksum,
591 )
592 } else {
593 OutputRecord::pending_deletion(output.target_root, output.dest_path)
594 }
595 })
596 .collect();
597 (
598 key,
599 LockedItemV2 {
600 source: item.source,
601 kind: item.kind,
602 version: item.version,
603 source_checksum: item.source_checksum,
604 outputs,
605 },
606 )
607 })
608 .collect();
609
610 LockFile {
611 version: LOCK_VERSION,
612 dependencies: wire.dependencies,
613 items,
614 config_entries: wire.config_entries,
615 dependency_model_aliases: wire.dependency_model_aliases,
616 }
617}
618
619fn v2_output_checksum(path: &Path) -> Option<String> {
620 let metadata = std::fs::symlink_metadata(path).ok()?;
621 let file_type = metadata.file_type();
622 if file_type.is_symlink() {
623 return None;
624 }
625 if file_type.is_file() {
626 return std::fs::read(path)
627 .ok()
628 .map(|bytes| crate::hash::hash_bytes(&bytes));
629 }
630 if file_type.is_dir() {
631 if !has_only_regular_file_entries(path) {
632 return None;
633 }
634 return crate::hash::compute_dir_hash(path).ok();
635 }
636 None
637}
638
639fn has_only_regular_file_entries(dir: &Path) -> bool {
641 let Ok(entries) = std::fs::read_dir(dir) else {
642 return false;
643 };
644 for entry in entries {
645 let Ok(entry) = entry else {
646 return false;
647 };
648 let path = entry.path();
649 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
650 return false;
651 };
652 let file_type = metadata.file_type();
653 if file_type.is_dir() {
654 if !has_only_regular_file_entries(&path) {
655 return false;
656 }
657 } else if !file_type.is_file() {
658 return false;
659 }
660 }
661 true
662}
663
664pub fn write(root: &Path, lock: &LockFile) -> Result<(), MarsError> {
666 let path = root.join(LOCK_FILE);
667 let mut normalized = lock.clone();
668 normalized.version = LOCK_VERSION;
669 normalized.dependencies.sort_keys();
670 normalized.items.sort_keys();
671 normalized.dependency_model_aliases.sort_keys();
672
673 let content = toml::to_string_pretty(&normalized).map_err(|e| LockError::Corrupt {
674 message: format!("failed to serialize lock file: {e}"),
675 })?;
676 crate::fs::atomic_write_if_changed(&path, content.as_bytes()).map(|_| ())
677}
678
679pub fn build(
689 graph: &crate::resolve::ResolvedGraph,
690 applied: &crate::sync::apply::ApplyResult,
691 old_lock: &LockFile,
692 config_entries: BTreeMap<String, BTreeMap<String, ConfigEntryRecord>>,
693) -> Result<LockFile, MarsError> {
694 use crate::sync::apply::ActionTaken;
695
696 let mut dependencies = IndexMap::new();
697 let mut items: IndexMap<String, LockedItemV2> = IndexMap::new();
698 let old_lock_index = LockIndex::new(old_lock);
699
700 for outcome in &applied.outcomes {
701 match outcome.action {
702 ActionTaken::Installed | ActionTaken::Updated => {
703 let installed =
704 outcome
705 .installed_checksum
706 .as_ref()
707 .ok_or_else(|| LockError::Corrupt {
708 message: format!(
709 "missing checksum for write-producing action on {}",
710 outcome.dest_path
711 ),
712 })?;
713 if checksum_is_empty(installed) {
714 return Err(LockError::Corrupt {
715 message: format!("empty installed_checksum for {}", outcome.dest_path),
716 }
717 .into());
718 }
719
720 let source =
721 outcome
722 .source_checksum
723 .as_ref()
724 .ok_or_else(|| LockError::Corrupt {
725 message: format!(
726 "missing source checksum for write-producing action on {}",
727 outcome.dest_path
728 ),
729 })?;
730 if checksum_is_empty(source) {
731 return Err(LockError::Corrupt {
732 message: format!("empty source_checksum for {}", outcome.dest_path),
733 }
734 .into());
735 }
736 }
737 ActionTaken::Removed | ActionTaken::Skipped | ActionTaken::Kept => {}
738 }
739 }
740
741 for (name, node) in &graph.nodes {
743 dependencies.insert(name.clone(), to_locked_source(node));
744 }
745
746 for outcome in &applied.outcomes {
748 match &outcome.action {
749 ActionTaken::Removed | ActionTaken::Skipped => {
750 if matches!(outcome.action, ActionTaken::Skipped) {
752 let item_key = item_key(&outcome.item_id);
753 if let Some(old_item) = old_lock.items.get(&item_key) {
754 items.insert(item_key, old_item.clone());
755 } else {
756 if let Some((_, old_item, old_output)) = old_lock_index
758 .item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
759 {
760 let key = format!(
761 "{}/{}",
762 old_item.kind,
763 outcome.dest_path.item_name(old_item.kind)
764 );
765 items.entry(key).or_insert_with(|| LockedItemV2 {
766 source: old_item.source.clone(),
767 kind: old_item.kind,
768 version: old_item.version.clone(),
769 source_checksum: old_item.source_checksum.clone(),
770 outputs: outputs_with_carried_non_canonical(
771 Some(old_item),
772 OutputRecord::installed(
773 CANONICAL_TARGET_ROOT.to_string(),
774 old_output.dest_path.clone(),
775 old_output
776 .installed_checksum()
777 .expect("canonical output is installed")
778 .clone(),
779 ),
780 ),
781 });
782 }
783 }
784 }
785 }
787 ActionTaken::Kept => {
788 let item_key = item_key(&outcome.item_id);
790 if let Some(old_item) = old_lock.items.get(&item_key) {
791 items.insert(item_key, old_item.clone());
792 } else if let Some((_, old_item, old_output)) =
793 old_lock_index.item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
794 {
795 let key = format!(
796 "{}/{}",
797 old_item.kind,
798 outcome.dest_path.item_name(old_item.kind)
799 );
800 items.entry(key).or_insert_with(|| LockedItemV2 {
801 source: old_item.source.clone(),
802 kind: old_item.kind,
803 version: old_item.version.clone(),
804 source_checksum: old_item.source_checksum.clone(),
805 outputs: outputs_with_carried_non_canonical(
806 Some(old_item),
807 OutputRecord::installed(
808 CANONICAL_TARGET_ROOT.to_string(),
809 old_output.dest_path.clone(),
810 old_output
811 .installed_checksum()
812 .expect("canonical output is installed")
813 .clone(),
814 ),
815 ),
816 });
817 }
818 }
819 ActionTaken::Installed | ActionTaken::Updated => {
820 let dest_path = outcome.dest_path.clone();
821 if dest_path.as_str().is_empty() {
822 continue;
823 }
824
825 let source_name = if outcome.source_name.as_ref().is_empty() {
827 None
828 } else {
829 Some(outcome.source_name.clone())
830 };
831
832 let version = source_name.as_ref().and_then(|sn| {
834 graph
835 .nodes
836 .get(sn)
837 .and_then(|n| n.resolved_ref.version_tag.clone())
838 });
839
840 let source_checksum = outcome
841 .source_checksum
842 .clone()
843 .expect("validated above: source_checksum exists for write actions");
844 let installed_checksum = outcome
845 .installed_checksum
846 .clone()
847 .expect("validated above: installed_checksum exists for write actions");
848
849 let key = item_key(&outcome.item_id);
850 let old_item = old_lock.items.get(&key).or_else(|| {
851 old_lock_index
852 .item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
853 .map(|(_, old_item, _)| old_item)
854 });
855 let outputs = outputs_with_carried_non_canonical(
856 old_item,
857 OutputRecord::installed(
858 CANONICAL_TARGET_ROOT.to_string(),
859 dest_path,
860 installed_checksum,
861 ),
862 );
863 items.insert(
864 key,
865 LockedItemV2 {
866 source: source_name.unwrap_or_else(|| SourceName::from("")),
867 kind: outcome.item_id.kind,
868 version,
869 source_checksum,
870 outputs,
871 },
872 );
873 }
874 }
875 }
876
877 let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
879 let has_self_items = items.values().any(|item| item.source == local_source_name);
880 if has_self_items {
881 dependencies.insert(
882 local_source_name,
883 LockedSource {
884 url: None,
885 path: Some(".".into()),
886 subpath: None,
887 version: None,
888 commit: None,
889 },
890 );
891 }
892
893 for item in items.values() {
895 if checksum_is_empty(&item.source_checksum) {
896 let dest = item
897 .outputs
898 .first()
899 .map(|o| o.dest_path.to_string())
900 .unwrap_or_default();
901 return Err(LockError::Corrupt {
902 message: format!("empty source_checksum for {dest}"),
903 }
904 .into());
905 }
906 for output in &item.outputs {
907 if output.installed_checksum().is_some_and(checksum_is_empty) {
908 return Err(LockError::Corrupt {
909 message: format!("empty installed_checksum for {}", output.dest_path),
910 }
911 .into());
912 }
913 }
914 }
915
916 dependencies.sort_keys();
918 items.sort_keys();
919
920 Ok(LockFile {
921 version: LOCK_VERSION,
922 dependencies,
923 items,
924 config_entries,
925 dependency_model_aliases: IndexMap::new(),
926 })
927}
928
929fn outputs_with_carried_non_canonical(
930 old_item: Option<&LockedItemV2>,
931 canonical_output: OutputRecord,
932) -> Vec<OutputRecord> {
933 let mut outputs = vec![canonical_output];
934 if let Some(old_item) = old_item {
935 for old_output in &old_item.outputs {
936 if old_output.target_root != CANONICAL_TARGET_ROOT {
937 outputs.push(old_output.clone());
938 }
939 }
940 }
941 outputs
942}
943
944pub fn ownership_lock_for_native_emission(
951 old_lock: &LockFile,
952 apply_outcomes: &[crate::sync::apply::ActionOutcome],
953 target_outcomes: &[crate::target_sync::TargetSyncOutcome],
954) -> LockFile {
955 let mut lock = old_lock.clone();
956 apply_apply_outcomes_to_lock(&mut lock, old_lock, apply_outcomes);
957 apply_target_sync_outputs(&mut lock, target_outcomes);
958 lock
959}
960
961pub fn ownership_lock_after_target_sync(
966 old_lock: &LockFile,
967 target_outcomes: &[crate::target_sync::TargetSyncOutcome],
968) -> LockFile {
969 let mut lock = old_lock.clone();
970 apply_target_sync_outputs(&mut lock, target_outcomes);
971 lock
972}
973
974pub fn apply_apply_outcomes_to_lock(
979 lock: &mut LockFile,
980 old_lock: &LockFile,
981 outcomes: &[crate::sync::apply::ActionOutcome],
982) {
983 use crate::sync::apply::ActionTaken;
984
985 let old_lock_index = LockIndex::new(old_lock);
986 for outcome in outcomes {
987 match outcome.action {
988 ActionTaken::Removed => {
989 lock.items.shift_remove(&item_key(&outcome.item_id));
990 }
991 ActionTaken::Skipped => {
992 let key = item_key(&outcome.item_id);
993 if lock.items.contains_key(&key) {
994 continue;
995 }
996 if let Some(old_item) = old_lock.items.get(&key) {
997 lock.items.insert(key, old_item.clone());
998 } else if let Some(flat) =
999 old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
1000 {
1001 let key = format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
1002 lock.items.entry(key).or_insert_with(|| LockedItemV2 {
1003 source: flat.source,
1004 kind: flat.kind,
1005 version: flat.version,
1006 source_checksum: flat.source_checksum,
1007 outputs: vec![OutputRecord::installed(
1008 CANONICAL_TARGET_ROOT.to_string(),
1009 flat.dest_path,
1010 flat.installed_checksum,
1011 )],
1012 });
1013 }
1014 }
1015 ActionTaken::Kept => {
1016 let key = item_key(&outcome.item_id);
1017 if let Some(old_item) = old_lock.items.get(&key) {
1018 lock.items.insert(key, old_item.clone());
1019 } else if let Some(flat) =
1020 old_lock_index.find_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
1021 {
1022 let key = format!("{}/{}", flat.kind, outcome.dest_path.item_name(flat.kind));
1023 lock.items.entry(key).or_insert_with(|| LockedItemV2 {
1024 source: flat.source,
1025 kind: flat.kind,
1026 version: flat.version,
1027 source_checksum: flat.source_checksum,
1028 outputs: vec![OutputRecord::installed(
1029 CANONICAL_TARGET_ROOT.to_string(),
1030 flat.dest_path,
1031 flat.installed_checksum,
1032 )],
1033 });
1034 }
1035 }
1036 ActionTaken::Installed | ActionTaken::Updated => {
1037 if outcome.dest_path.as_str().is_empty() {
1038 continue;
1039 }
1040 let Some(source_checksum) = outcome
1041 .source_checksum
1042 .as_ref()
1043 .filter(|checksum| !checksum_is_empty(checksum))
1044 else {
1045 continue;
1046 };
1047 let Some(installed_checksum) = outcome
1048 .installed_checksum
1049 .as_ref()
1050 .filter(|checksum| !checksum_is_empty(checksum))
1051 else {
1052 continue;
1053 };
1054
1055 let source_name = if outcome.source_name.as_ref().is_empty() {
1056 SourceName::from("")
1057 } else {
1058 outcome.source_name.clone()
1059 };
1060
1061 let key = item_key(&outcome.item_id);
1062 let old_entry = old_lock
1063 .items
1064 .get(&key)
1065 .map(|old_item| (key.as_str(), old_item))
1066 .or_else(|| {
1067 old_lock_index
1068 .item_for_output(CANONICAL_TARGET_ROOT, &outcome.dest_path)
1069 .map(|(old_key, old_item, _)| (old_key, old_item))
1070 });
1071 let old_key = old_entry.map(|(old_key, _)| old_key.to_string());
1072 let outputs = outputs_with_carried_non_canonical(
1073 old_entry.map(|(_, old_item)| old_item),
1074 OutputRecord::installed(
1075 CANONICAL_TARGET_ROOT.to_string(),
1076 outcome.dest_path.clone(),
1077 installed_checksum.clone(),
1078 ),
1079 );
1080 if let Some(old_key) = old_key
1081 && old_key != key
1082 {
1083 lock.items.shift_remove(&old_key);
1084 }
1085 lock.items.insert(
1086 key,
1087 LockedItemV2 {
1088 source: source_name,
1089 kind: outcome.item_id.kind,
1090 version: None,
1091 source_checksum: source_checksum.clone(),
1092 outputs,
1093 },
1094 );
1095 }
1096 }
1097 }
1098}
1099
1100pub fn apply_target_sync_outputs(
1102 lock: &mut LockFile,
1103 target_outcomes: &[crate::target_sync::TargetSyncOutcome],
1104) {
1105 for outcome in target_outcomes {
1106 for dest_path in &outcome.removed_dest_paths {
1107 remove_target_output(lock, &outcome.target, dest_path);
1108 }
1109 for synced in &outcome.synced_outputs {
1110 upsert_target_output(
1111 lock,
1112 &outcome.target,
1113 &synced.dest_path,
1114 &synced.installed_checksum,
1115 );
1116 }
1117 }
1118}
1119
1120#[derive(Debug, Clone, PartialEq, Eq)]
1122pub struct CompiledNativeOutput {
1123 pub owner_canonical_dest_path: String,
1125 pub target_root: String,
1126 pub dest_path: String,
1127 pub installed_checksum: ContentHash,
1128}
1129
1130pub fn native_output_is_new_or_changed(old: &LockFile, out: &CompiledNativeOutput) -> bool {
1134 for item in old.items.values() {
1135 for output in &item.outputs {
1136 if output.target_root == out.target_root
1137 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), &out.dest_path)
1138 {
1139 return output.installed_checksum() != Some(&out.installed_checksum);
1140 }
1141 }
1142 }
1143 true
1144}
1145
1146pub fn apply_removed_native_outputs(lock: &mut LockFile, records: &[(String, String)]) {
1148 for (target_root, dest_path) in records {
1149 remove_target_output(lock, target_root, dest_path);
1150 }
1151}
1152
1153pub fn apply_compiled_native_outputs(
1155 lock: &mut LockFile,
1156 records: &[CompiledNativeOutput],
1157) -> Result<(), LockError> {
1158 for record in records {
1159 if !upsert_native_output_on_owner(
1160 lock,
1161 &record.owner_canonical_dest_path,
1162 &record.target_root,
1163 &record.dest_path,
1164 &record.installed_checksum,
1165 ) {
1166 return Err(LockError::Corrupt {
1167 message: format!(
1168 "native output `{}/{}` has no canonical owner `{}`",
1169 record.target_root, record.dest_path, record.owner_canonical_dest_path
1170 ),
1171 });
1172 }
1173 }
1174 Ok(())
1175}
1176
1177pub fn retain_unremoved_noncanonical_outputs(
1184 lock: &mut LockFile,
1185 old_lock: &LockFile,
1186 removed: &[(String, String)],
1187) {
1188 for (old_key, old_item) in &old_lock.items {
1189 let unresolved: Vec<_> = old_item
1190 .outputs
1191 .iter()
1192 .filter(|output| output.target_root != CANONICAL_TARGET_ROOT)
1193 .filter(|output| {
1194 !removed.iter().any(|(target_root, dest_path)| {
1195 output.target_root == *target_root
1196 && crate::target::dest_paths_equivalent(
1197 output.dest_path.as_str(),
1198 dest_path,
1199 )
1200 })
1201 })
1202 .filter(|output| !lock.contains_output(&output.target_root, output.dest_path.as_str()))
1203 .map(|output| {
1204 OutputRecord::pending_deletion(output.target_root.clone(), output.dest_path.clone())
1205 })
1206 .collect();
1207 if unresolved.is_empty() {
1208 continue;
1209 }
1210
1211 let item = lock
1212 .items
1213 .entry(old_key.clone())
1214 .or_insert_with(|| LockedItemV2 {
1215 source: old_item.source.clone(),
1216 kind: old_item.kind,
1217 version: old_item.version.clone(),
1218 source_checksum: old_item.source_checksum.clone(),
1219 outputs: Vec::new(),
1223 });
1224 item.outputs.extend(unresolved);
1225 item.outputs.sort_by(|a, b| {
1226 a.target_root
1227 .cmp(&b.target_root)
1228 .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
1229 });
1230 item.outputs.dedup_by(|a, b| {
1231 a.target_root == b.target_root
1232 && crate::target::dest_paths_equivalent(a.dest_path.as_str(), b.dest_path.as_str())
1233 });
1234 }
1235}
1236
1237fn upsert_target_output(
1238 lock: &mut LockFile,
1239 target_root: &str,
1240 dest_path: &str,
1241 installed_checksum: &ContentHash,
1242) {
1243 let dest = DestPath::from(dest_path);
1244 let scoped_hook_owner = dest_path.strip_prefix("hooks/").map(|hook_name| {
1245 format!(
1246 "hooks/{}/{}",
1247 target_root.trim_start_matches('.'),
1248 hook_name
1249 )
1250 });
1251 for item in lock.items.values_mut() {
1252 let owns_output = if item.kind == ItemKind::Hook {
1253 item.outputs.iter().any(|output| {
1254 scoped_hook_owner.as_deref().is_some_and(|owner| {
1255 output.target_root == CANONICAL_TARGET_ROOT
1256 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), owner)
1257 })
1258 })
1259 } else {
1260 item.outputs.iter().any(|output| {
1261 (output.target_root == target_root || output.target_root == CANONICAL_TARGET_ROOT)
1262 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
1263 })
1264 };
1265 if !owns_output {
1266 continue;
1267 }
1268
1269 if let Some(output) = item.outputs.iter_mut().find(|output| {
1270 output.target_root == target_root
1271 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path)
1272 }) {
1273 output.mark_installed(installed_checksum.clone());
1274 return;
1275 }
1276
1277 item.outputs.push(OutputRecord::installed(
1278 target_root.to_string(),
1279 dest,
1280 installed_checksum.clone(),
1281 ));
1282 item.outputs.sort_by(|a, b| {
1283 a.target_root
1284 .cmp(&b.target_root)
1285 .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
1286 });
1287 return;
1288 }
1289}
1290
1291fn upsert_native_output_on_owner(
1292 lock: &mut LockFile,
1293 owner_canonical_dest_path: &str,
1294 target_root: &str,
1295 native_dest_path: &str,
1296 installed_checksum: &ContentHash,
1297) -> bool {
1298 let native_dest = DestPath::from(native_dest_path);
1299 for item in lock.items.values_mut() {
1300 let owns_canonical = item.outputs.iter().any(|output| {
1301 output.target_root == CANONICAL_TARGET_ROOT
1302 && crate::target::dest_paths_equivalent(
1303 output.dest_path.as_str(),
1304 owner_canonical_dest_path,
1305 )
1306 });
1307 if !owns_canonical {
1308 continue;
1309 }
1310
1311 if let Some(output) = item.outputs.iter_mut().find(|output| {
1312 output.target_root == target_root
1313 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), native_dest_path)
1314 }) {
1315 output.mark_installed(installed_checksum.clone());
1316 return true;
1317 }
1318
1319 item.outputs.push(OutputRecord::installed(
1320 target_root.to_string(),
1321 native_dest,
1322 installed_checksum.clone(),
1323 ));
1324 item.outputs.sort_by(|a, b| {
1325 a.target_root
1326 .cmp(&b.target_root)
1327 .then_with(|| a.dest_path.as_str().cmp(b.dest_path.as_str()))
1328 });
1329 return true;
1330 }
1331 false
1332}
1333
1334fn remove_target_output(lock: &mut LockFile, target_root: &str, dest_path: &str) {
1335 for item in lock.items.values_mut() {
1336 item.outputs.retain(|output| {
1337 !(output.target_root == target_root
1338 && crate::target::dest_paths_equivalent(output.dest_path.as_str(), dest_path))
1339 });
1340 }
1341 lock.items.retain(|_, item| !item.outputs.is_empty());
1342}
1343
1344fn checksum_is_empty(checksum: &ContentHash) -> bool {
1349 checksum.as_ref().trim().is_empty()
1350}
1351
1352fn to_locked_source(node: &crate::resolve::ResolvedNode) -> LockedSource {
1353 let (url, path, subpath) = match &node.source_id {
1354 SourceId::Git { url, subpath } => (Some(url.clone()), None, subpath.clone()),
1355 SourceId::Path { canonical, subpath } => (
1356 None,
1357 Some(canonical.to_string_lossy().to_string()),
1358 subpath.clone(),
1359 ),
1360 };
1361
1362 LockedSource {
1363 url,
1364 path,
1365 subpath,
1366 version: node.resolved_ref.version_tag.clone(),
1367 commit: node.resolved_ref.commit.clone(),
1368 }
1369}
1370
1371pub fn item_key(id: &ItemId) -> String {
1373 format!("{}/{}", id.kind, id.name)
1374}
1375
1376#[cfg(test)]
1381mod tests {
1382 use super::*;
1383 use std::collections::HashMap;
1384 use std::path::PathBuf;
1385
1386 use crate::resolve::{ResolvedGraph, ResolvedNode};
1387 use crate::source::ResolvedRef;
1388 use crate::sync::apply::{ActionOutcome, ActionTaken, ApplyResult};
1389 use crate::types::{ItemName, SourceId, SourceUrl};
1390 use tempfile::TempDir;
1391
1392 fn sample_lock() -> LockFile {
1393 let mut dependencies = IndexMap::new();
1394 dependencies.insert(
1395 "base".into(),
1396 LockedSource {
1397 url: Some("https://github.com/org/base.git".into()),
1398 path: None,
1399 subpath: None,
1400 version: Some("v1.0.0".into()),
1401 commit: Some("abc123".into()),
1402 },
1403 );
1404
1405 let mut items = IndexMap::new();
1406 items.insert(
1407 "agent/coder".to_string(),
1408 LockedItemV2 {
1409 source: "base".into(),
1410 kind: ItemKind::Agent,
1411 version: Some("v1.0.0".into()),
1412 source_checksum: "sha256:aaa".into(),
1413 outputs: vec![OutputRecord::installed(
1414 ".mars".to_string(),
1415 "agents/coder.md".into(),
1416 "sha256:bbb".into(),
1417 )],
1418 },
1419 );
1420 items.insert(
1421 "skill/review".to_string(),
1422 LockedItemV2 {
1423 source: "base".into(),
1424 kind: ItemKind::Skill,
1425 version: Some("v1.0.0".into()),
1426 source_checksum: "sha256:ccc".into(),
1427 outputs: vec![OutputRecord::installed(
1428 ".mars".to_string(),
1429 "skills/review".into(),
1430 "sha256:ddd".into(),
1431 )],
1432 },
1433 );
1434
1435 LockFile {
1436 version: LOCK_VERSION,
1437 dependencies,
1438 items,
1439 config_entries: BTreeMap::new(),
1440 dependency_model_aliases: IndexMap::new(),
1441 }
1442 }
1443
1444 #[test]
1445 fn v1_lock_version_has_actionable_error() {
1446 let dir = TempDir::new().unwrap();
1447 std::fs::write(dir.path().join("mars.lock"), "version = 1\n").unwrap();
1448
1449 let error = load(dir.path()).unwrap_err().to_string();
1450
1451 assert!(error.contains("unsupported lock version 1"));
1452 assert!(error.contains("only version 2 can be promoted"));
1453 assert!(error.contains(&format!(
1454 "remove it and run `{}`",
1455 crate::types::managed_cmd("mars sync")
1456 )));
1457 }
1458
1459 #[test]
1460 fn v2_output_matching_regular_file_promotes_to_installed() {
1461 let dir = TempDir::new().unwrap();
1462 let output = dir.path().join(".mars/agents/coder.md");
1463 std::fs::create_dir_all(output.parent().unwrap()).unwrap();
1464 std::fs::write(&output, "managed").unwrap();
1465 let checksum = crate::hash::hash_bytes(b"managed");
1466 std::fs::write(
1467 dir.path().join("mars.lock"),
1468 format!(
1469 r#"
1470version = 2
1471
1472[items."agent/coder"]
1473source = "_self"
1474kind = "agent"
1475source_checksum = "{checksum}"
1476
1477[[items."agent/coder".outputs]]
1478target_root = ".mars"
1479dest_path = "agents/coder.md"
1480installed_checksum = "{checksum}"
1481"#
1482 ),
1483 )
1484 .unwrap();
1485
1486 let lock = load(dir.path()).unwrap();
1487 let output = &lock.items["agent/coder"].outputs[0];
1488
1489 assert_eq!(lock.version, LOCK_VERSION);
1490 assert!(matches!(output.state, OutputState::Installed { .. }));
1491 }
1492
1493 #[test]
1494 fn v2_output_absent_on_disk_promotes_to_pending_deletion() {
1495 let dir = TempDir::new().unwrap();
1496 std::fs::write(
1497 dir.path().join("mars.lock"),
1498 r#"
1499version = 2
1500
1501[items."hook/audit"]
1502source = "_self"
1503kind = "hook"
1504source_checksum = "sha256:source"
1505
1506[[items."hook/audit".outputs]]
1507target_root = ".opencode"
1508dest_path = "plugins/mars-audit.ts"
1509installed_checksum = "sha256:old"
1510"#,
1511 )
1512 .unwrap();
1513
1514 let lock = load(dir.path()).unwrap();
1515
1516 assert!(matches!(
1517 lock.items["hook/audit"].outputs[0].state,
1518 OutputState::PendingDeletion
1519 ));
1520 }
1521
1522 #[cfg(unix)]
1523 #[test]
1524 fn v2_directory_output_with_nested_dangling_symlink_has_no_checksum() {
1525 use std::os::unix::fs::symlink;
1526
1527 let dir = TempDir::new().unwrap();
1528 let output = dir.path().join("skill");
1529 std::fs::create_dir(&output).unwrap();
1530 std::fs::write(output.join("SKILL.md"), "# Skill").unwrap();
1531 symlink("missing.md", output.join("reference.md")).unwrap();
1532
1533 assert_eq!(v2_output_checksum(&output), None);
1534 }
1535
1536 #[cfg(unix)]
1537 #[test]
1538 fn v2_directory_output_with_nested_directory_symlink_has_no_checksum() {
1539 use std::os::unix::fs::symlink;
1540
1541 let dir = TempDir::new().unwrap();
1542 let output = dir.path().join("skill");
1543 let external = dir.path().join("external");
1544 std::fs::create_dir(&output).unwrap();
1545 std::fs::create_dir(&external).unwrap();
1546 std::fs::write(output.join("SKILL.md"), "# Skill").unwrap();
1547 std::fs::write(external.join("reference.md"), "# Reference").unwrap();
1548 symlink(&external, output.join("references")).unwrap();
1549
1550 assert_eq!(v2_output_checksum(&output), None);
1551 }
1552
1553 #[cfg(unix)]
1554 #[test]
1555 fn v2_directory_output_with_nested_fifo_returns_without_opening_it() {
1556 let dir = TempDir::new().unwrap();
1557 let output = dir.path().join("skill");
1558 std::fs::create_dir(&output).unwrap();
1559 std::fs::write(output.join("SKILL.md"), "# Skill").unwrap();
1560 let status = std::process::Command::new("mkfifo")
1561 .arg(output.join("events"))
1562 .status()
1563 .unwrap();
1564 assert!(status.success());
1565
1566 let started = std::time::Instant::now();
1567 assert_eq!(v2_output_checksum(&output), None);
1568 assert!(
1569 started.elapsed() < std::time::Duration::from_secs(1),
1570 "shape validation must not open and block on the FIFO"
1571 );
1572 }
1573
1574 #[cfg(unix)]
1575 #[test]
1576 fn v2_promotion_classifies_outputs_by_disk_shape_and_checksum() {
1577 use std::os::unix::fs::{PermissionsExt, symlink};
1578
1579 let dir = TempDir::new().unwrap();
1580 let root = dir.path();
1581 let outputs = root.join(".mars/outputs");
1582 std::fs::create_dir_all(&outputs).unwrap();
1583
1584 std::fs::write(outputs.join("file-match"), "managed").unwrap();
1585 std::fs::write(outputs.join("file-mismatch"), "changed").unwrap();
1586 std::fs::create_dir(outputs.join("dir-match")).unwrap();
1587 std::fs::write(outputs.join("dir-match/SKILL.md"), "# Managed").unwrap();
1588 std::fs::create_dir(outputs.join("dir-mismatch")).unwrap();
1589 std::fs::write(outputs.join("dir-mismatch/SKILL.md"), "# Changed").unwrap();
1590 std::fs::write(outputs.join("symlink-target"), "managed").unwrap();
1591 symlink("symlink-target", outputs.join("symlink")).unwrap();
1592 std::fs::write(outputs.join("unreadable"), "managed").unwrap();
1593 std::fs::set_permissions(
1594 outputs.join("unreadable"),
1595 std::fs::Permissions::from_mode(0o000),
1596 )
1597 .unwrap();
1598
1599 let file_checksum = crate::hash::hash_bytes(b"managed");
1600 let directory_checksum =
1601 crate::hash::compute_hash(&outputs.join("dir-match"), ItemKind::Skill).unwrap();
1602 let cases = [
1603 ("file-match", &file_checksum),
1604 ("file-mismatch", &file_checksum),
1605 ("dir-match", &directory_checksum),
1606 ("dir-mismatch", &directory_checksum),
1607 ("absent", &file_checksum),
1608 ("symlink", &file_checksum),
1609 ("unreadable", &file_checksum),
1610 ];
1611 let mut lock = String::from("version = 2\n");
1612 for (name, checksum) in cases {
1613 lock.push_str(&format!(
1614 r#"
1615[items."agent/{name}"]
1616source = "_self"
1617kind = "agent"
1618source_checksum = "{checksum}"
1619
1620[[items."agent/{name}".outputs]]
1621target_root = ".mars"
1622dest_path = "outputs/{name}"
1623installed_checksum = "{checksum}"
1624"#
1625 ));
1626 }
1627 std::fs::write(root.join("mars.lock"), lock).unwrap();
1628
1629 let promoted = load(root).unwrap();
1630 std::fs::set_permissions(
1631 outputs.join("unreadable"),
1632 std::fs::Permissions::from_mode(0o600),
1633 )
1634 .unwrap();
1635
1636 for name in ["file-match", "dir-match"] {
1637 assert!(
1638 matches!(
1639 promoted.items[&format!("agent/{name}")].outputs[0].state,
1640 OutputState::Installed { .. }
1641 ),
1642 "{name} should retain installed-content authority"
1643 );
1644 }
1645 for name in [
1646 "file-mismatch",
1647 "dir-mismatch",
1648 "absent",
1649 "symlink",
1650 "unreadable",
1651 ] {
1652 assert!(
1653 matches!(
1654 promoted.items[&format!("agent/{name}")].outputs[0].state,
1655 OutputState::PendingDeletion
1656 ),
1657 "{name} should retain deletion authority only"
1658 );
1659 }
1660 }
1661
1662 #[test]
1663 fn load_for_runtime_aliases_rejects_legacy_v2_without_dependency_alias_authority() {
1664 let toml_str = r#"
1665version = 3
1666
1667[dependencies.base]
1668url = "https://github.com/org/base.git"
1669version = "v1.0.0"
1670commit = "abc123"
1671
1672[items."agent/coder"]
1673source = "base"
1674kind = "agent"
1675source_checksum = "sha256:aaa"
1676
1677[[items."agent/coder".outputs]]
1678target_root = ".mars"
1679dest_path = "agents/coder.md"
1680state = "installed"
1681installed_checksum = "sha256:bbb"
1682"#;
1683 let dir = TempDir::new().unwrap();
1684 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1685
1686 let err = load_for_runtime_aliases(dir.path()).unwrap_err();
1687 let message = err.to_string();
1688 assert!(message.contains("missing `dependency_model_aliases`"));
1689 assert!(message.contains(&format!("run `{}`", crate::types::managed_cmd("mars sync"))));
1690 }
1691
1692 #[test]
1693 fn load_for_runtime_aliases_allows_missing_dependency_aliases_when_no_dependencies() {
1694 let toml_str = r#"
1695version = 3
1696
1697[items."agent/coder"]
1698source = "_self"
1699kind = "agent"
1700source_checksum = "sha256:aaa"
1701
1702[[items."agent/coder".outputs]]
1703target_root = ".mars"
1704dest_path = "agents/coder.md"
1705state = "installed"
1706installed_checksum = "sha256:bbb"
1707"#;
1708 let dir = TempDir::new().unwrap();
1709 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1710
1711 let lock = load_for_runtime_aliases(dir.path()).unwrap();
1712 assert!(lock.dependencies.is_empty());
1713 assert!(lock.dependency_model_aliases.is_empty());
1714 }
1715
1716 #[test]
1717 fn roundtrip_lock_file() {
1718 let lock = sample_lock();
1719 let dir = TempDir::new().unwrap();
1720 write(dir.path(), &lock).unwrap();
1721 let reloaded = load(dir.path()).unwrap();
1722 assert_eq!(lock, reloaded);
1723 }
1724
1725 #[test]
1726 fn roundtrip_lock_file_with_config_entries() {
1727 let mut lock = sample_lock();
1728 lock.config_entries.insert(
1729 ".claude".to_string(),
1730 BTreeMap::from([(
1731 "mcp:context7".to_string(),
1732 ConfigEntryRecord { emitted_json: None },
1733 )]),
1734 );
1735
1736 let dir = TempDir::new().unwrap();
1737 write(dir.path(), &lock).unwrap();
1738 let reloaded = load(dir.path()).unwrap();
1739
1740 assert_eq!(lock, reloaded);
1741 assert_eq!(
1742 reloaded.config_entries[".claude"]["mcp:context7"].emitted_json,
1743 None
1744 );
1745 }
1746
1747 #[test]
1748 fn write_emits_dependency_model_aliases_table_even_when_empty() {
1749 let lock = sample_lock();
1750 let dir = TempDir::new().unwrap();
1751 write(dir.path(), &lock).unwrap();
1752
1753 let content = std::fs::read_to_string(dir.path().join("mars.lock")).unwrap();
1754 assert!(
1755 content.contains("dependency_model_aliases"),
1756 "serialized lock should include dependency_model_aliases authority table"
1757 );
1758 }
1759
1760 #[test]
1761 fn deterministic_serialization() {
1762 let lock = sample_lock();
1763 let s1 = toml::to_string_pretty(&lock).unwrap();
1764 let s2 = toml::to_string_pretty(&lock).unwrap();
1765 assert_eq!(s1, s2);
1766
1767 let coder_pos = s1.find("agent/coder").unwrap();
1769 let review_pos = s1.find("skill/review").unwrap();
1770 assert!(
1771 coder_pos < review_pos,
1772 "agent/coder should appear before skill/review"
1773 );
1774 }
1775
1776 #[test]
1777 fn write_sorts_dependency_model_aliases_keys() {
1778 let toml_str = r#"
1779version = 3
1780
1781[dependency_model_aliases.zeta]
1782model = "openai/gpt-z"
1783
1784[dependency_model_aliases.alpha]
1785model = "openai/gpt-a"
1786"#;
1787 let dir = TempDir::new().unwrap();
1788 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1789
1790 let lock = load(dir.path()).unwrap();
1791 write(dir.path(), &lock).unwrap();
1792
1793 let written = std::fs::read_to_string(dir.path().join("mars.lock")).unwrap();
1794 let alpha = written
1795 .find("[dependency_model_aliases.alpha]")
1796 .expect("alpha alias should be serialized");
1797 let zeta = written
1798 .find("[dependency_model_aliases.zeta]")
1799 .expect("zeta alias should be serialized");
1800 assert!(alpha < zeta, "aliases should serialize in sorted key order");
1801 }
1802
1803 #[test]
1804 fn empty_lock_file() {
1805 let lock = LockFile::empty();
1806 assert_eq!(lock.version, LOCK_VERSION);
1807 assert!(lock.dependencies.is_empty());
1808 assert!(lock.items.is_empty());
1809 }
1810
1811 #[test]
1812 fn load_absent_returns_empty() {
1813 let dir = TempDir::new().unwrap();
1814 let lock = load(dir.path()).unwrap();
1815 assert_eq!(lock.version, LOCK_VERSION);
1816 assert!(lock.dependencies.is_empty());
1817 assert!(lock.items.is_empty());
1818 }
1819
1820 #[test]
1821 fn write_and_reload() {
1822 let dir = TempDir::new().unwrap();
1823 let lock = sample_lock();
1824 write(dir.path(), &lock).unwrap();
1825 let reloaded = load(dir.path()).unwrap();
1826 assert_eq!(lock, reloaded);
1827 }
1828
1829 #[test]
1830 fn dual_checksums_present() {
1831 let lock = sample_lock();
1832 let item = &lock.items["agent/coder"];
1833 assert_ne!(
1834 &item.source_checksum,
1835 item.outputs[0]
1836 .installed_checksum()
1837 .expect("installed output")
1838 );
1839 assert!(item.source_checksum.starts_with("sha256:"));
1840 assert!(
1841 item.outputs[0]
1842 .installed_checksum()
1843 .expect("installed output")
1844 .starts_with("sha256:")
1845 );
1846 }
1847
1848 #[test]
1849 fn path_source_in_lock() {
1850 let toml_str = r#"
1851version = 3
1852
1853[dependencies.local]
1854path = "/home/dev/agents"
1855
1856[items."agent/helper"]
1857source = "local"
1858kind = "agent"
1859source_checksum = "sha256:111"
1860
1861[[items."agent/helper".outputs]]
1862target_root = ".mars"
1863dest_path = "agents/helper.md"
1864state = "installed"
1865installed_checksum = "sha256:222"
1866"#;
1867 let dir = TempDir::new().unwrap();
1868 std::fs::write(dir.path().join("mars.lock"), toml_str).unwrap();
1869 let lock = load(dir.path()).unwrap();
1870 let source = &lock.dependencies["local"];
1871 assert!(source.url.is_none());
1872 assert_eq!(source.path.as_deref(), Some("/home/dev/agents"));
1873 assert!(source.commit.is_none());
1874 }
1875
1876 #[test]
1877 fn item_kind_serializes_lowercase() {
1878 let item = LockedItemV2 {
1879 source: "base".into(),
1880 kind: ItemKind::Skill,
1881 version: None,
1882 source_checksum: "sha256:aaa".into(),
1883 outputs: vec![OutputRecord::installed(
1884 ".mars".to_string(),
1885 "skills/review".into(),
1886 "sha256:bbb".into(),
1887 )],
1888 };
1889 let serialized = toml::to_string(&item).unwrap();
1890 assert!(serialized.contains("kind = \"skill\""));
1891 }
1892
1893 #[test]
1894 fn item_id_display() {
1895 let id = ItemId {
1896 kind: ItemKind::Agent,
1897 name: "coder".into(),
1898 };
1899 assert_eq!(id.to_string(), "agent/coder");
1900 }
1901
1902 #[test]
1903 fn item_kind_display() {
1904 assert_eq!(ItemKind::Agent.to_string(), "agent");
1905 assert_eq!(ItemKind::Skill.to_string(), "skill");
1906 }
1907
1908 #[test]
1909 fn find_by_dest_path_returns_flat_view() {
1910 let lock = sample_lock();
1911 let found = lock
1912 .find_by_dest_path(&DestPath::from("agents/coder.md"))
1913 .unwrap();
1914 assert_eq!(found.source, "base");
1915 assert_eq!(found.kind, ItemKind::Agent);
1916 assert_eq!(found.source_checksum, "sha256:aaa");
1917 assert_eq!(found.installed_checksum, "sha256:bbb");
1918 assert_eq!(found.dest_path.as_str(), "agents/coder.md");
1919 }
1920
1921 #[test]
1922 fn find_by_dest_path_missing_returns_none() {
1923 let lock = sample_lock();
1924 assert!(
1925 lock.find_by_dest_path(&DestPath::from("agents/missing.md"))
1926 .is_none()
1927 );
1928 }
1929
1930 #[test]
1931 fn contains_dest_path_hit_and_miss() {
1932 let lock = sample_lock();
1933 assert!(lock.contains_dest_path(&DestPath::from("agents/coder.md")));
1934 assert!(!lock.contains_dest_path(&DestPath::from("agents/nobody.md")));
1935 }
1936
1937 #[test]
1938 fn lock_index_target_scoped_lookup_distinguishes_same_dest_path() {
1939 let mut lock = sample_lock();
1940 lock.items
1941 .get_mut("agent/coder")
1942 .unwrap()
1943 .outputs
1944 .push(OutputRecord::installed(
1945 ".pi".to_string(),
1946 "agents/coder.md".into(),
1947 "sha256:pi".into(),
1948 ));
1949
1950 let index = LockIndex::new(&lock);
1951 let dest = DestPath::from("agents/coder.md");
1952
1953 let mars = index
1954 .find_output(".mars", &dest)
1955 .expect("expected canonical .mars output");
1956 let pi = index
1957 .find_output(".pi", &dest)
1958 .expect("expected .pi output");
1959
1960 assert_eq!(mars.installed_checksum, "sha256:bbb");
1961 assert_eq!(pi.installed_checksum, "sha256:pi");
1962 assert!(index.contains_output(".mars", &dest));
1963 assert!(index.contains_output(".pi", &dest));
1964 assert!(!index.contains_output(".cursor", &dest));
1965 }
1966
1967 #[test]
1968 fn output_dest_paths_for_target_filters_by_target_root() {
1969 let mut lock = sample_lock();
1970 lock.items
1971 .get_mut("agent/coder")
1972 .unwrap()
1973 .outputs
1974 .push(OutputRecord::installed(
1975 ".cursor".to_string(),
1976 "agents/coder.md".into(),
1977 "sha256:cursor".into(),
1978 ));
1979
1980 let mars_paths = lock.output_dest_paths_for_target(".mars");
1981 assert!(mars_paths.contains("agents/coder.md"));
1982 assert!(mars_paths.contains("skills/review"));
1983
1984 let cursor_paths = lock.output_dest_paths_for_target(".cursor");
1985 assert_eq!(cursor_paths.len(), 1);
1986 assert!(cursor_paths.contains("agents/coder.md"));
1987 assert!(lock.output_dest_paths_for_target(".claude").is_empty());
1988 }
1989
1990 #[test]
1991 fn contains_output_matches_target_root_and_dest_path() {
1992 let mut lock = sample_lock();
1993 assert!(lock.contains_output(".mars", "agents/coder.md"));
1994 assert!(!lock.contains_output(".cursor", "agents/coder.md"));
1995
1996 lock.items
1997 .get_mut("agent/coder")
1998 .unwrap()
1999 .outputs
2000 .push(OutputRecord::installed(
2001 ".cursor".to_string(),
2002 "agents/coder.md".into(),
2003 "sha256:cursor".into(),
2004 ));
2005 assert!(lock.contains_output(".cursor", "agents/coder.md"));
2006 assert!(!lock.contains_output(".cursor", "agents/missing.md"));
2007 }
2008
2009 #[test]
2010 fn apply_compiled_native_outputs_upserts_codex_native_by_canonical_owner() {
2011 let mut lock = sample_lock();
2012 apply_compiled_native_outputs(
2013 &mut lock,
2014 &[CompiledNativeOutput {
2015 owner_canonical_dest_path: "agents/coder.md".to_string(),
2016 target_root: ".codex".to_string(),
2017 dest_path: "agents/coder.toml".to_string(),
2018 installed_checksum: "sha256:codex".into(),
2019 }],
2020 )
2021 .unwrap();
2022 assert!(lock.contains_output(".codex", "agents/coder.toml"));
2023 assert!(lock.contains_output(".mars", "agents/coder.md"));
2024 }
2025
2026 #[test]
2027 fn apply_compiled_native_outputs_upserts_when_frontmatter_name_differs_from_filename() {
2028 let mut lock = sample_lock();
2029 lock.items.insert(
2030 "agent/alias-name".to_string(),
2031 LockedItemV2 {
2032 source: "base".into(),
2033 kind: ItemKind::Agent,
2034 version: Some("v1.0.0".into()),
2035 source_checksum: "sha256:alias-src".into(),
2036 outputs: vec![OutputRecord::installed(
2037 ".mars".to_string(),
2038 "agents/on-disk-stem.md".into(),
2039 "sha256:alias-mars".into(),
2040 )],
2041 },
2042 );
2043 apply_compiled_native_outputs(
2044 &mut lock,
2045 &[CompiledNativeOutput {
2046 owner_canonical_dest_path: "agents/on-disk-stem.md".to_string(),
2047 target_root: ".claude".to_string(),
2048 dest_path: "agents/alias-name.md".to_string(),
2049 installed_checksum: "sha256:claude-native".into(),
2050 }],
2051 )
2052 .unwrap();
2053 assert!(lock.contains_output(".claude", "agents/alias-name.md"));
2054 }
2055
2056 #[test]
2057 fn build_updated_carries_non_canonical_outputs() {
2058 let mut old_lock = sample_lock();
2059 old_lock
2060 .items
2061 .get_mut("agent/coder")
2062 .unwrap()
2063 .outputs
2064 .push(OutputRecord::installed(
2065 ".claude".to_string(),
2066 "agents/coder.md".into(),
2067 "sha256:claude-old".into(),
2068 ));
2069
2070 let graph = ResolvedGraph {
2071 nodes: IndexMap::new(),
2072 order: Vec::new(),
2073 filters: HashMap::new(),
2074 version_constraints: std::collections::HashMap::new(),
2075 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2076 };
2077 let applied = ApplyResult {
2078 outcomes: vec![ActionOutcome {
2079 item_id: ItemId {
2080 kind: ItemKind::Agent,
2081 name: "coder".into(),
2082 },
2083 action: ActionTaken::Updated,
2084 dest_path: "agents/coder.md".into(),
2085 source_name: "base".into(),
2086 source_checksum: Some("sha256:new-src".into()),
2087 installed_checksum: Some("sha256:new-mars".into()),
2088 }],
2089 };
2090
2091 let new_lock = build(
2092 &graph,
2093 &applied,
2094 &old_lock,
2095 std::collections::BTreeMap::new(),
2096 )
2097 .unwrap();
2098
2099 assert!(new_lock.contains_output(".mars", "agents/coder.md"));
2100 assert!(
2101 new_lock.contains_output(".claude", "agents/coder.md"),
2102 ".claude record should survive compile failure"
2103 );
2104 let item = &new_lock.items["agent/coder"];
2105 assert_eq!(item.outputs.len(), 2);
2106 assert_eq!(item.source_checksum, "sha256:new-src");
2107 let mars = item
2108 .outputs
2109 .iter()
2110 .find(|o| o.target_root == ".mars")
2111 .unwrap();
2112 assert_eq!(
2113 mars.installed_checksum().expect("installed output"),
2114 "sha256:new-mars"
2115 );
2116 let claude = item
2117 .outputs
2118 .iter()
2119 .find(|o| o.target_root == ".claude")
2120 .unwrap();
2121 assert_eq!(
2122 claude.installed_checksum().expect("installed output"),
2123 "sha256:claude-old"
2124 );
2125 }
2126
2127 #[test]
2128 fn build_fallback_carries_non_canonical_outputs_for_skipped_and_kept() {
2129 let old_lock = LockFile {
2130 version: LOCK_VERSION,
2131 dependencies: IndexMap::new(),
2132 items: IndexMap::from([
2133 (
2134 "agent/agents/coder.md".to_string(),
2135 LockedItemV2 {
2136 source: "base".into(),
2137 kind: ItemKind::Agent,
2138 version: None,
2139 source_checksum: "sha256:coder-src".into(),
2140 outputs: vec![
2141 OutputRecord::installed(
2142 ".mars".to_string(),
2143 "agents/coder.md".into(),
2144 "sha256:coder-mars".into(),
2145 ),
2146 OutputRecord::installed(
2147 ".claude".to_string(),
2148 "agents/coder.md".into(),
2149 "sha256:coder-claude".into(),
2150 ),
2151 ],
2152 },
2153 ),
2154 (
2155 "skill/skills/review".to_string(),
2156 LockedItemV2 {
2157 source: "base".into(),
2158 kind: ItemKind::Skill,
2159 version: None,
2160 source_checksum: "sha256:review-src".into(),
2161 outputs: vec![
2162 OutputRecord::installed(
2163 ".mars".to_string(),
2164 "skills/review".into(),
2165 "sha256:review-mars".into(),
2166 ),
2167 OutputRecord::installed(
2168 ".codex".to_string(),
2169 "skills/review/SKILL.md".into(),
2170 "sha256:review-codex".into(),
2171 ),
2172 ],
2173 },
2174 ),
2175 ]),
2176 config_entries: BTreeMap::new(),
2177 dependency_model_aliases: IndexMap::new(),
2178 };
2179 let graph = ResolvedGraph {
2180 nodes: IndexMap::new(),
2181 order: Vec::new(),
2182 filters: HashMap::new(),
2183 version_constraints: std::collections::HashMap::new(),
2184 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2185 };
2186 let applied = ApplyResult {
2187 outcomes: vec![
2188 ActionOutcome {
2189 item_id: ItemId {
2190 kind: ItemKind::Agent,
2191 name: "coder".into(),
2192 },
2193 action: ActionTaken::Skipped,
2194 dest_path: "agents/coder.md".into(),
2195 source_name: "base".into(),
2196 source_checksum: None,
2197 installed_checksum: None,
2198 },
2199 ActionOutcome {
2200 item_id: ItemId {
2201 kind: ItemKind::Skill,
2202 name: "review".into(),
2203 },
2204 action: ActionTaken::Kept,
2205 dest_path: "skills/review".into(),
2206 source_name: "base".into(),
2207 source_checksum: None,
2208 installed_checksum: None,
2209 },
2210 ],
2211 };
2212
2213 let new_lock = build(
2214 &graph,
2215 &applied,
2216 &old_lock,
2217 std::collections::BTreeMap::new(),
2218 )
2219 .unwrap();
2220
2221 assert!(!new_lock.items.contains_key("agent/agents/coder.md"));
2222 assert!(new_lock.contains_output(".mars", "agents/coder.md"));
2223 assert!(new_lock.contains_output(".claude", "agents/coder.md"));
2224
2225 assert!(!new_lock.items.contains_key("skill/skills/review"));
2226 assert!(new_lock.contains_output(".mars", "skills/review"));
2227 assert!(new_lock.contains_output(".codex", "skills/review/SKILL.md"));
2228 }
2229
2230 #[test]
2231 fn build_write_fallback_carries_non_canonical_outputs() {
2232 let old_lock = LockFile {
2233 version: LOCK_VERSION,
2234 dependencies: IndexMap::new(),
2235 items: IndexMap::from([(
2236 "agent/agents/coder.md".to_string(),
2237 LockedItemV2 {
2238 source: "base".into(),
2239 kind: ItemKind::Agent,
2240 version: None,
2241 source_checksum: "sha256:old-src".into(),
2242 outputs: vec![
2243 OutputRecord::installed(
2244 ".mars".to_string(),
2245 "agents/coder.md".into(),
2246 "sha256:old-mars".into(),
2247 ),
2248 OutputRecord::installed(
2249 ".claude".to_string(),
2250 "agents/coder.md".into(),
2251 "sha256:old-claude".into(),
2252 ),
2253 ],
2254 },
2255 )]),
2256 config_entries: BTreeMap::new(),
2257 dependency_model_aliases: IndexMap::new(),
2258 };
2259 let graph = ResolvedGraph {
2260 nodes: IndexMap::new(),
2261 order: Vec::new(),
2262 filters: HashMap::new(),
2263 version_constraints: std::collections::HashMap::new(),
2264 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2265 };
2266 let applied = ApplyResult {
2267 outcomes: vec![ActionOutcome {
2268 item_id: ItemId {
2269 kind: ItemKind::Agent,
2270 name: "coder".into(),
2271 },
2272 action: ActionTaken::Updated,
2273 dest_path: "agents/coder.md".into(),
2274 source_name: "base".into(),
2275 source_checksum: Some("sha256:new-src".into()),
2276 installed_checksum: Some("sha256:new-mars".into()),
2277 }],
2278 };
2279
2280 let new_lock = build(
2281 &graph,
2282 &applied,
2283 &old_lock,
2284 std::collections::BTreeMap::new(),
2285 )
2286 .unwrap();
2287
2288 assert!(!new_lock.items.contains_key("agent/agents/coder.md"));
2289 assert!(new_lock.contains_output(".mars", "agents/coder.md"));
2290 assert!(new_lock.contains_output(".claude", "agents/coder.md"));
2291 let item = &new_lock.items["agent/coder"];
2292 assert_eq!(item.source_checksum, "sha256:new-src");
2293 let claude = item
2294 .outputs
2295 .iter()
2296 .find(|o| o.target_root == ".claude")
2297 .unwrap();
2298 assert_eq!(
2299 claude.installed_checksum().expect("installed output"),
2300 "sha256:old-claude"
2301 );
2302 }
2303
2304 #[test]
2305 fn apply_apply_outcomes_write_fallback_carries_non_canonical_outputs() {
2306 let old_lock = LockFile {
2307 version: LOCK_VERSION,
2308 dependencies: IndexMap::new(),
2309 items: IndexMap::from([(
2310 "agent/agents/coder.md".to_string(),
2311 LockedItemV2 {
2312 source: "base".into(),
2313 kind: ItemKind::Agent,
2314 version: None,
2315 source_checksum: "sha256:old-src".into(),
2316 outputs: vec![
2317 OutputRecord::installed(
2318 ".mars".to_string(),
2319 "agents/coder.md".into(),
2320 "sha256:old-mars".into(),
2321 ),
2322 OutputRecord::installed(
2323 ".claude".to_string(),
2324 "agents/coder.md".into(),
2325 "sha256:old-claude".into(),
2326 ),
2327 ],
2328 },
2329 )]),
2330 config_entries: BTreeMap::new(),
2331 dependency_model_aliases: IndexMap::new(),
2332 };
2333 let mut lock = old_lock.clone();
2334
2335 apply_apply_outcomes_to_lock(
2336 &mut lock,
2337 &old_lock,
2338 &[ActionOutcome {
2339 item_id: ItemId {
2340 kind: ItemKind::Agent,
2341 name: "coder".into(),
2342 },
2343 action: ActionTaken::Updated,
2344 dest_path: "agents/coder.md".into(),
2345 source_name: "base".into(),
2346 source_checksum: Some("sha256:new-src".into()),
2347 installed_checksum: Some("sha256:new-mars".into()),
2348 }],
2349 );
2350
2351 assert!(!lock.items.contains_key("agent/agents/coder.md"));
2352 assert!(lock.contains_output(".mars", "agents/coder.md"));
2353 assert!(lock.contains_output(".claude", "agents/coder.md"));
2354 let item = &lock.items["agent/coder"];
2355 assert_eq!(item.source_checksum, "sha256:new-src");
2356 }
2357
2358 #[test]
2359 fn apply_apply_outcomes_to_lock_updated_preserves_non_canonical_outputs() {
2360 let mut old_lock = sample_lock();
2361 old_lock
2362 .items
2363 .get_mut("agent/coder")
2364 .unwrap()
2365 .outputs
2366 .push(OutputRecord::installed(
2367 ".claude".to_string(),
2368 "agents/coder.md".into(),
2369 "sha256:claude".into(),
2370 ));
2371
2372 let mut lock = old_lock.clone();
2373 apply_apply_outcomes_to_lock(
2374 &mut lock,
2375 &old_lock,
2376 &[ActionOutcome {
2377 item_id: ItemId {
2378 kind: ItemKind::Agent,
2379 name: ItemName::from("coder"),
2380 },
2381 action: ActionTaken::Updated,
2382 dest_path: "agents/coder.md".into(),
2383 source_name: "base".into(),
2384 source_checksum: Some("sha256:new-src".into()),
2385 installed_checksum: Some("sha256:new-mars".into()),
2386 }],
2387 );
2388
2389 assert!(lock.contains_output(".mars", "agents/coder.md"));
2390 assert!(lock.contains_output(".claude", "agents/coder.md"));
2391 let item = &lock.items["agent/coder"];
2392 assert_eq!(item.source_checksum, "sha256:new-src");
2393 let mars = item
2394 .outputs
2395 .iter()
2396 .find(|o| o.target_root == ".mars")
2397 .unwrap();
2398 assert_eq!(
2399 mars.installed_checksum().expect("installed output"),
2400 "sha256:new-mars"
2401 );
2402 let claude = item
2403 .outputs
2404 .iter()
2405 .find(|o| o.target_root == ".claude")
2406 .unwrap();
2407 assert_eq!(
2408 claude.installed_checksum().expect("installed output"),
2409 "sha256:claude"
2410 );
2411 }
2412
2413 #[test]
2414 fn ownership_lock_for_native_emission_seeds_new_apply_outcomes() {
2415 let old_lock = LockFile::empty();
2416 let apply_outcomes = vec![ActionOutcome {
2417 item_id: ItemId {
2418 kind: ItemKind::Agent,
2419 name: ItemName::from("coder"),
2420 },
2421 action: ActionTaken::Installed,
2422 dest_path: "agents/coder.md".into(),
2423 source_name: "base".into(),
2424 source_checksum: Some("sha256:src".into()),
2425 installed_checksum: Some("sha256:mars".into()),
2426 }];
2427 let view = ownership_lock_for_native_emission(
2428 &old_lock,
2429 &apply_outcomes,
2430 &[crate::target_sync::TargetSyncOutcome {
2431 target: ".cursor".to_string(),
2432 items_synced: 1,
2433 items_removed: 0,
2434 errors: Vec::new(),
2435 synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
2436 dest_path: "agents/coder.md".to_string(),
2437 installed_checksum: "sha256:cursor".into(),
2438 }],
2439 removed_dest_paths: Vec::new(),
2440 }],
2441 );
2442 assert!(view.contains_output(".mars", "agents/coder.md"));
2443 assert!(view.contains_output(".cursor", "agents/coder.md"));
2444 assert!(!old_lock.contains_output(".mars", "agents/coder.md"));
2445 }
2446
2447 #[test]
2448 fn ownership_lock_after_target_sync_layers_synced_outputs() {
2449 let lock = sample_lock();
2450 let view = ownership_lock_after_target_sync(
2451 &lock,
2452 &[crate::target_sync::TargetSyncOutcome {
2453 target: ".cursor".to_string(),
2454 items_synced: 1,
2455 items_removed: 0,
2456 errors: Vec::new(),
2457 synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
2458 dest_path: "agents/coder.md".to_string(),
2459 installed_checksum: "sha256:cursor".into(),
2460 }],
2461 removed_dest_paths: Vec::new(),
2462 }],
2463 );
2464 assert!(view.contains_output(".cursor", "agents/coder.md"));
2465 assert!(!lock.contains_output(".cursor", "agents/coder.md"));
2466 }
2467
2468 #[test]
2469 fn apply_target_sync_outputs_upserts_and_removes_target_records() {
2470 let mut lock = sample_lock();
2471 apply_target_sync_outputs(
2472 &mut lock,
2473 &[crate::target_sync::TargetSyncOutcome {
2474 target: ".cursor".to_string(),
2475 items_synced: 1,
2476 items_removed: 0,
2477 errors: Vec::new(),
2478 synced_outputs: vec![crate::target_sync::TargetSyncedOutput {
2479 dest_path: "agents/coder.md".to_string(),
2480 installed_checksum: "sha256:cursor".into(),
2481 }],
2482 removed_dest_paths: Vec::new(),
2483 }],
2484 );
2485 assert!(lock.contains_output(".cursor", "agents/coder.md"));
2486
2487 apply_target_sync_outputs(
2488 &mut lock,
2489 &[crate::target_sync::TargetSyncOutcome {
2490 target: ".cursor".to_string(),
2491 items_synced: 0,
2492 items_removed: 1,
2493 errors: Vec::new(),
2494 synced_outputs: Vec::new(),
2495 removed_dest_paths: vec!["agents/coder.md".to_string()],
2496 }],
2497 );
2498 assert!(!lock.contains_output(".cursor", "agents/coder.md"));
2499 assert!(lock.contains_output(".mars", "agents/coder.md"));
2500 }
2501
2502 #[test]
2503 fn canonical_flat_items_excludes_linked_target_outputs() {
2504 let mut lock = sample_lock();
2505 lock.items
2506 .get_mut("agent/coder")
2507 .unwrap()
2508 .outputs
2509 .push(OutputRecord::installed(
2510 ".cursor".to_string(),
2511 "agents/coder.md".into(),
2512 "sha256:cursor".into(),
2513 ));
2514
2515 let canonical = lock.canonical_flat_items();
2516 assert_eq!(canonical.len(), 2);
2517 assert!(
2518 canonical
2519 .iter()
2520 .any(|(dp, _)| dp.as_str() == "agents/coder.md")
2521 );
2522 assert!(
2523 canonical
2524 .iter()
2525 .all(|(_, item)| { lock.contains_output(".mars", item.dest_path.as_str()) })
2526 );
2527
2528 let cursor = lock.flat_items_for_target(".cursor");
2529 assert_eq!(cursor.len(), 1);
2530 assert_eq!(cursor[0].0.as_str(), "agents/coder.md");
2531 }
2532
2533 #[test]
2534 fn build_uses_graph_provenance_for_sources() {
2535 let git_name: SourceName = "base".into();
2536 let path_name: SourceName = "local".into();
2537 let git_url: SourceUrl = "https://example.com/new.git".into();
2538 let path_canonical = PathBuf::from("/tmp/mars-agents-local-source");
2539
2540 let mut nodes = IndexMap::new();
2541 nodes.insert(
2542 git_name.clone(),
2543 ResolvedNode {
2544 source_name: git_name.clone(),
2545 source_id: SourceId::git_with_subpath(
2546 git_url.clone(),
2547 Some(crate::types::SourceSubpath::new("plugins/base").unwrap()),
2548 ),
2549 rooted_ref: crate::resolve::RootedSourceRef {
2550 checkout_root: PathBuf::from("/tmp/cache/base"),
2551 package_root: PathBuf::from("/tmp/cache/base/plugins/base"),
2552 },
2553 resolved_ref: ResolvedRef {
2554 source_name: git_name.clone(),
2555 version: Some(semver::Version::new(1, 2, 3)),
2556 version_tag: Some("v1.2.3".into()),
2557 commit: Some("abc123".into()),
2558 tree_path: PathBuf::from("/tmp/cache/base"),
2559 },
2560 manifest: None,
2561 deps: vec![],
2562 },
2563 );
2564 nodes.insert(
2565 path_name.clone(),
2566 ResolvedNode {
2567 source_name: path_name.clone(),
2568 source_id: SourceId::Path {
2569 canonical: path_canonical.clone(),
2570 subpath: Some(crate::types::SourceSubpath::new("plugins/local").unwrap()),
2571 },
2572 rooted_ref: crate::resolve::RootedSourceRef {
2573 checkout_root: PathBuf::from("/tmp/cache/local"),
2574 package_root: PathBuf::from("/tmp/cache/local/plugins/local"),
2575 },
2576 resolved_ref: ResolvedRef {
2577 source_name: path_name.clone(),
2578 version: None,
2579 version_tag: None,
2580 commit: None,
2581 tree_path: PathBuf::from("/tmp/cache/local"),
2582 },
2583 manifest: None,
2584 deps: vec![],
2585 },
2586 );
2587
2588 let graph = ResolvedGraph {
2589 nodes,
2590 order: vec![git_name.clone(), path_name.clone()],
2591 filters: HashMap::new(),
2592 version_constraints: std::collections::HashMap::new(),
2593 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2594 };
2595 let applied = ApplyResult { outcomes: vec![] };
2596
2597 let mut old_sources = IndexMap::new();
2598 old_sources.insert(
2599 git_name.clone(),
2600 LockedSource {
2601 url: Some("https://example.com/old.git".into()),
2602 path: None,
2603 subpath: None,
2604 version: Some("v0.0.1".into()),
2605 commit: Some("deadbeef".into()),
2606 },
2607 );
2608 let old_lock = LockFile {
2609 version: LOCK_VERSION,
2610 dependencies: old_sources,
2611 items: IndexMap::new(),
2612 config_entries: std::collections::BTreeMap::new(),
2613 dependency_model_aliases: IndexMap::new(),
2614 };
2615
2616 let new_lock = build(
2617 &graph,
2618 &applied,
2619 &old_lock,
2620 std::collections::BTreeMap::new(),
2621 )
2622 .unwrap();
2623
2624 let base = &new_lock.dependencies["base"];
2625 assert_eq!(base.url.as_ref(), Some(&git_url));
2626 assert_eq!(
2627 base.subpath
2628 .as_ref()
2629 .map(crate::types::SourceSubpath::as_str),
2630 Some("plugins/base")
2631 );
2632 assert_eq!(base.version.as_deref(), Some("v1.2.3"));
2633 assert_eq!(base.commit.as_deref(), Some("abc123"));
2634
2635 let local = &new_lock.dependencies["local"];
2636 assert!(local.url.is_none());
2637 assert_eq!(
2638 local
2639 .subpath
2640 .as_ref()
2641 .map(crate::types::SourceSubpath::as_str),
2642 Some("plugins/local")
2643 );
2644 assert_eq!(
2645 local.path.as_deref(),
2646 Some(path_canonical.to_string_lossy().as_ref())
2647 );
2648 }
2649
2650 #[test]
2651 fn build_persists_ref_selector_in_locked_source_version() {
2652 let source_name: SourceName = "base".into();
2653 let mut nodes = IndexMap::new();
2654 nodes.insert(
2655 source_name.clone(),
2656 ResolvedNode {
2657 source_name: source_name.clone(),
2658 source_id: SourceId::git_with_subpath("https://example.com/base.git".into(), None),
2659 rooted_ref: crate::resolve::RootedSourceRef {
2660 checkout_root: PathBuf::from("/tmp/cache/base"),
2661 package_root: PathBuf::from("/tmp/cache/base"),
2662 },
2663 resolved_ref: ResolvedRef {
2664 source_name: source_name.clone(),
2665 version: None,
2666 version_tag: Some("main".into()),
2667 commit: Some("abc123".into()),
2668 tree_path: PathBuf::from("/tmp/cache/base"),
2669 },
2670 manifest: None,
2671 deps: vec![],
2672 },
2673 );
2674
2675 let graph = ResolvedGraph {
2676 nodes,
2677 order: vec![source_name.clone()],
2678 filters: HashMap::new(),
2679 version_constraints: std::collections::HashMap::new(),
2680 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2681 };
2682 let applied = ApplyResult { outcomes: vec![] };
2683 let new_lock = build(
2684 &graph,
2685 &applied,
2686 &LockFile::empty(),
2687 std::collections::BTreeMap::new(),
2688 )
2689 .unwrap();
2690
2691 let source = &new_lock.dependencies["base"];
2692 assert_eq!(source.version.as_deref(), Some("main"));
2693 assert_eq!(source.commit.as_deref(), Some("abc123"));
2694 }
2695
2696 #[test]
2697 fn build_keeps_self_items_from_old_lock_on_skipped_action() {
2698 let graph = ResolvedGraph {
2699 nodes: IndexMap::new(),
2700 order: Vec::new(),
2701 filters: HashMap::new(),
2702 version_constraints: std::collections::HashMap::new(),
2703 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2704 };
2705 let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
2706 let old_lock = LockFile {
2707 version: LOCK_VERSION,
2708 dependencies: IndexMap::from([(
2709 local_source_name.clone(),
2710 LockedSource {
2711 url: None,
2712 path: Some(".".into()),
2713 subpath: None,
2714 version: None,
2715 commit: None,
2716 },
2717 )]),
2718 items: IndexMap::from([(
2719 "skill/local-skill".to_string(),
2720 LockedItemV2 {
2721 source: local_source_name.clone(),
2722 kind: ItemKind::Skill,
2723 version: None,
2724 source_checksum: "sha256:self".into(),
2725 outputs: vec![OutputRecord::installed(
2726 ".mars".to_string(),
2727 DestPath::from("skills/local-skill"),
2728 "sha256:self".into(),
2729 )],
2730 },
2731 )]),
2732 config_entries: std::collections::BTreeMap::new(),
2733 dependency_model_aliases: IndexMap::new(),
2734 };
2735 let applied = ApplyResult {
2736 outcomes: vec![ActionOutcome {
2737 item_id: ItemId {
2738 kind: ItemKind::Skill,
2739 name: "local-skill".into(),
2740 },
2741 action: ActionTaken::Skipped,
2742 dest_path: "skills/local-skill".into(),
2743 source_name: local_source_name.clone(),
2744 source_checksum: None,
2745 installed_checksum: None,
2746 }],
2747 };
2748
2749 let new_lock = build(
2750 &graph,
2751 &applied,
2752 &old_lock,
2753 std::collections::BTreeMap::new(),
2754 )
2755 .unwrap();
2756
2757 assert!(
2758 new_lock
2759 .dependencies
2760 .contains_key(local_source_name.as_str())
2761 );
2762 let item = &new_lock.items["skill/local-skill"];
2763 assert_eq!(item.source, local_source_name);
2764 assert_eq!(item.kind, ItemKind::Skill);
2765 assert_eq!(item.source_checksum, "sha256:self");
2766 assert_eq!(
2767 item.outputs[0]
2768 .installed_checksum()
2769 .expect("installed output"),
2770 "sha256:self"
2771 );
2772 }
2773
2774 #[test]
2775 fn build_rejects_missing_installed_checksum_for_write_actions() {
2776 let graph = ResolvedGraph {
2777 nodes: IndexMap::new(),
2778 order: Vec::new(),
2779 filters: HashMap::new(),
2780 version_constraints: std::collections::HashMap::new(),
2781 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2782 };
2783 let old_lock = LockFile::empty();
2784 let applied = ApplyResult {
2785 outcomes: vec![ActionOutcome {
2786 item_id: ItemId {
2787 kind: ItemKind::Agent,
2788 name: "coder".into(),
2789 },
2790 action: ActionTaken::Installed,
2791 dest_path: "agents/coder.md".into(),
2792 source_name: "base".into(),
2793 source_checksum: Some("sha256:source".into()),
2794 installed_checksum: None,
2795 }],
2796 };
2797
2798 let err = build(
2799 &graph,
2800 &applied,
2801 &old_lock,
2802 std::collections::BTreeMap::new(),
2803 )
2804 .unwrap_err();
2805 let msg = err.to_string();
2806 assert!(msg.contains("missing checksum for write-producing action"));
2807 assert!(msg.contains("agents/coder.md"));
2808 }
2809
2810 #[test]
2811 fn build_rejects_empty_checksums_from_carried_items() {
2812 let graph = ResolvedGraph {
2813 nodes: IndexMap::new(),
2814 order: Vec::new(),
2815 filters: HashMap::new(),
2816 version_constraints: std::collections::HashMap::new(),
2817 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
2818 };
2819 let old_lock = LockFile {
2820 version: LOCK_VERSION,
2821 dependencies: IndexMap::new(),
2822 items: IndexMap::from([(
2823 "agent/coder".to_string(),
2824 LockedItemV2 {
2825 source: "base".into(),
2826 kind: ItemKind::Agent,
2827 version: None,
2828 source_checksum: "".into(),
2829 outputs: vec![OutputRecord::installed(
2830 ".mars".to_string(),
2831 DestPath::from("agents/coder.md"),
2832 "sha256:installed".into(),
2833 )],
2834 },
2835 )]),
2836 config_entries: std::collections::BTreeMap::new(),
2837 dependency_model_aliases: IndexMap::new(),
2838 };
2839 let applied = ApplyResult {
2840 outcomes: vec![ActionOutcome {
2841 item_id: ItemId {
2842 kind: ItemKind::Agent,
2843 name: "coder".into(),
2844 },
2845 action: ActionTaken::Skipped,
2846 dest_path: "agents/coder.md".into(),
2847 source_name: "base".into(),
2848 source_checksum: None,
2849 installed_checksum: None,
2850 }],
2851 };
2852
2853 let err = build(
2854 &graph,
2855 &applied,
2856 &old_lock,
2857 std::collections::BTreeMap::new(),
2858 )
2859 .unwrap_err();
2860 let msg = err.to_string();
2861 assert!(msg.contains("empty source_checksum"));
2862 }
2863}
2864
2865#[cfg(test)]
2866mod output_lifecycle_contract_tests {
2867 use super::{OutputRecord, OutputState};
2868
2869 #[test]
2870 fn pending_deletion_record_carries_no_checksum() {
2871 let record = OutputRecord::pending_deletion(".opencode", "plugins/mars-audit.ts");
2872
2873 assert!(matches!(record.state, OutputState::PendingDeletion));
2874 let encoded = toml::to_string(&record).expect("pending record serializes");
2875 assert!(!encoded.contains("installed_checksum"));
2876 }
2877}