1use std::path::Path;
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::error::{LockError, MarsError};
7use crate::types::{
8 CommitHash, ContentHash, DestPath, SourceId, SourceName, SourceOrigin, SourceSubpath, SourceUrl,
9};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct LockFile {
17 pub version: u32,
19 #[serde(default)]
20 pub dependencies: IndexMap<SourceName, LockedSource>,
21 #[serde(default)]
22 pub items: IndexMap<DestPath, LockedItem>,
23}
24
25impl LockFile {
26 pub fn empty() -> Self {
28 LockFile {
29 version: 1,
30 dependencies: IndexMap::new(),
31 items: IndexMap::new(),
32 }
33 }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct LockedSource {
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub url: Option<SourceUrl>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub path: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub subpath: Option<SourceSubpath>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub version: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub commit: Option<CommitHash>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub tree_hash: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct LockedItem {
58 pub source: SourceName,
59 pub kind: ItemKind,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub version: Option<String>,
62 pub source_checksum: ContentHash,
63 pub installed_checksum: ContentHash,
64 pub dest_path: DestPath,
65}
66
67pub use crate::types::{ItemId, ItemKind};
70
71const LOCK_FILE: &str = "mars.lock";
72
73pub fn load(root: &Path) -> Result<LockFile, MarsError> {
77 let path = root.join(LOCK_FILE);
78 match std::fs::read_to_string(&path) {
79 Ok(content) => {
80 let lock: LockFile = toml::from_str(&content).map_err(|e| LockError::Corrupt {
81 message: format!("failed to parse {}: {e}", path.display()),
82 })?;
83 Ok(lock)
84 }
85 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LockFile::empty()),
86 Err(e) => Err(LockError::Io(e).into()),
87 }
88}
89
90pub fn write(root: &Path, lock: &LockFile) -> Result<(), MarsError> {
95 let path = root.join(LOCK_FILE);
96 let content = toml::to_string_pretty(lock).map_err(|e| LockError::Corrupt {
97 message: format!("failed to serialize lock file: {e}"),
98 })?;
99 crate::fs::atomic_write(&path, content.as_bytes())
100}
101
102pub fn build(
108 graph: &crate::resolve::ResolvedGraph,
109 applied: &crate::sync::apply::ApplyResult,
110 old_lock: &LockFile,
111) -> Result<LockFile, MarsError> {
112 use crate::sync::apply::ActionTaken;
113
114 let mut dependencies = IndexMap::new();
115 let mut items = IndexMap::new();
116
117 for outcome in &applied.outcomes {
118 match outcome.action {
119 ActionTaken::Installed
120 | ActionTaken::Updated
121 | ActionTaken::Merged
122 | ActionTaken::Conflicted => {
123 let installed =
124 outcome
125 .installed_checksum
126 .as_ref()
127 .ok_or_else(|| LockError::Corrupt {
128 message: format!(
129 "missing checksum for write-producing action on {}",
130 outcome.dest_path
131 ),
132 })?;
133 if checksum_is_empty(installed) {
134 return Err(LockError::Corrupt {
135 message: format!("empty installed_checksum for {}", outcome.dest_path),
136 }
137 .into());
138 }
139
140 let source =
141 outcome
142 .source_checksum
143 .as_ref()
144 .ok_or_else(|| LockError::Corrupt {
145 message: format!(
146 "missing source checksum for write-producing action on {}",
147 outcome.dest_path
148 ),
149 })?;
150 if checksum_is_empty(source) {
151 return Err(LockError::Corrupt {
152 message: format!("empty source_checksum for {}", outcome.dest_path),
153 }
154 .into());
155 }
156 }
157 ActionTaken::Removed | ActionTaken::Skipped | ActionTaken::Kept => {}
158 }
159 }
160
161 for (name, node) in &graph.nodes {
163 dependencies.insert(name.clone(), to_locked_source(node));
164 }
165
166 for outcome in &applied.outcomes {
168 match &outcome.action {
169 ActionTaken::Removed | ActionTaken::Skipped => {
170 if matches!(outcome.action, ActionTaken::Skipped) {
172 let dest_path = outcome.dest_path.clone();
173 if let Some(old_item) = old_lock.items.get(&dest_path) {
174 items.insert(dest_path, old_item.clone());
175 }
176 }
177 }
179 ActionTaken::Kept => {
180 let dest_path = outcome.dest_path.clone();
182 if let Some(old_item) = old_lock.items.get(&dest_path) {
183 items.insert(dest_path, old_item.clone());
184 }
185 }
186 ActionTaken::Installed
187 | ActionTaken::Updated
188 | ActionTaken::Merged
189 | ActionTaken::Conflicted => {
190 let dest_path = outcome.dest_path.clone();
191 if dest_path.as_path().as_os_str().is_empty() {
192 continue;
193 }
194
195 let source_name = if outcome.source_name.as_ref().is_empty() {
197 None
198 } else {
199 Some(outcome.source_name.clone())
200 };
201
202 let version = source_name.as_ref().and_then(|sn| {
204 graph
205 .nodes
206 .get(sn)
207 .and_then(|n| n.resolved_ref.version_tag.clone())
208 });
209
210 let source_checksum = outcome
211 .source_checksum
212 .clone()
213 .expect("validated above: source_checksum exists for write actions");
214 let installed_checksum = outcome
215 .installed_checksum
216 .clone()
217 .expect("validated above: installed_checksum exists for write actions");
218
219 items.insert(
220 dest_path.clone(),
221 LockedItem {
222 source: source_name.unwrap_or_else(|| SourceName::from("")),
223 kind: outcome.item_id.kind,
224 version,
225 source_checksum,
226 installed_checksum,
227 dest_path,
228 },
229 );
230 }
231 }
232 }
233
234 let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
236 let has_self_items = items.values().any(|item| item.source == local_source_name);
237 if has_self_items {
238 dependencies.insert(
239 local_source_name,
240 LockedSource {
241 url: None,
242 path: Some(".".into()),
243 subpath: None,
244 version: None,
245 commit: None,
246 tree_hash: None,
247 },
248 );
249 }
250
251 for item in items.values() {
252 if checksum_is_empty(&item.source_checksum) {
253 return Err(LockError::Corrupt {
254 message: format!("empty source_checksum for {}", item.dest_path),
255 }
256 .into());
257 }
258 if checksum_is_empty(&item.installed_checksum) {
259 return Err(LockError::Corrupt {
260 message: format!("empty installed_checksum for {}", item.dest_path),
261 }
262 .into());
263 }
264 }
265
266 dependencies.sort_keys();
268 items.sort_keys();
269
270 Ok(LockFile {
271 version: 1,
272 dependencies,
273 items,
274 })
275}
276
277fn checksum_is_empty(checksum: &ContentHash) -> bool {
278 checksum.as_ref().trim().is_empty()
279}
280
281fn to_locked_source(node: &crate::resolve::ResolvedNode) -> LockedSource {
282 let (url, path, subpath) = match &node.source_id {
283 SourceId::Git { url, subpath } => (Some(url.clone()), None, subpath.clone()),
284 SourceId::Path { canonical, subpath } => (
285 None,
286 Some(canonical.to_string_lossy().to_string()),
287 subpath.clone(),
288 ),
289 };
290
291 LockedSource {
292 url,
293 path,
294 subpath,
295 version: node.resolved_ref.version_tag.clone(),
296 commit: node.resolved_ref.commit.clone(),
297 tree_hash: None,
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use std::collections::HashMap;
305 use std::path::PathBuf;
306
307 use crate::resolve::{ResolvedGraph, ResolvedNode};
308 use crate::source::ResolvedRef;
309 use crate::sync::apply::{ActionOutcome, ActionTaken, ApplyResult};
310 use crate::types::{SourceId, SourceUrl};
311 use tempfile::TempDir;
312
313 fn sample_lock() -> LockFile {
314 let mut dependencies = IndexMap::new();
315 dependencies.insert(
316 "base".into(),
317 LockedSource {
318 url: Some("https://github.com/org/base.git".into()),
319 path: None,
320 subpath: None,
321 version: Some("v1.0.0".into()),
322 commit: Some("abc123".into()),
323 tree_hash: Some("def456".into()),
324 },
325 );
326
327 let mut items = IndexMap::new();
328 items.insert(
329 "agents/coder.md".into(),
330 LockedItem {
331 source: "base".into(),
332 kind: ItemKind::Agent,
333 version: Some("v1.0.0".into()),
334 source_checksum: "sha256:aaa".into(),
335 installed_checksum: "sha256:bbb".into(),
336 dest_path: "agents/coder.md".into(),
337 },
338 );
339 items.insert(
340 "skills/review".into(),
341 LockedItem {
342 source: "base".into(),
343 kind: ItemKind::Skill,
344 version: Some("v1.0.0".into()),
345 source_checksum: "sha256:ccc".into(),
346 installed_checksum: "sha256:ddd".into(),
347 dest_path: "skills/review".into(),
348 },
349 );
350
351 LockFile {
352 version: 1,
353 dependencies,
354 items,
355 }
356 }
357
358 #[test]
359 fn parse_valid_lock_file() {
360 let toml_str = r#"
361version = 1
362
363[dependencies.base]
364url = "https://github.com/org/base.git"
365version = "v1.0.0"
366commit = "abc123"
367tree_hash = "def456"
368
369[items."agents/coder.md"]
370source = "base"
371kind = "agent"
372version = "v1.0.0"
373source_checksum = "sha256:aaa"
374installed_checksum = "sha256:bbb"
375dest_path = "agents/coder.md"
376"#;
377 let lock: LockFile = toml::from_str(toml_str).unwrap();
378 assert_eq!(lock.version, 1);
379 assert_eq!(lock.dependencies.len(), 1);
380 assert_eq!(lock.items.len(), 1);
381
382 let item = &lock.items["agents/coder.md"];
383 assert_eq!(item.source, "base");
384 assert_eq!(item.kind, ItemKind::Agent);
385 assert_eq!(item.source_checksum, "sha256:aaa");
386 assert_eq!(item.installed_checksum, "sha256:bbb");
387 }
388
389 #[test]
390 fn roundtrip_lock_file() {
391 let lock = sample_lock();
392 let serialized = toml::to_string_pretty(&lock).unwrap();
393 let deserialized: LockFile = toml::from_str(&serialized).unwrap();
394 assert_eq!(lock, deserialized);
395 }
396
397 #[test]
398 fn deterministic_serialization() {
399 let lock = sample_lock();
400 let s1 = toml::to_string_pretty(&lock).unwrap();
401 let s2 = toml::to_string_pretty(&lock).unwrap();
402 assert_eq!(s1, s2);
403
404 let coder_pos = s1.find("agents/coder.md").unwrap();
406 let review_pos = s1.find("skills/review").unwrap();
407 assert!(
408 coder_pos < review_pos,
409 "keys should preserve insertion order"
410 );
411 }
412
413 #[test]
414 fn empty_lock_file() {
415 let lock = LockFile::empty();
416 assert_eq!(lock.version, 1);
417 assert!(lock.dependencies.is_empty());
418 assert!(lock.items.is_empty());
419
420 let serialized = toml::to_string_pretty(&lock).unwrap();
422 let deserialized: LockFile = toml::from_str(&serialized).unwrap();
423 assert_eq!(lock, deserialized);
424 }
425
426 #[test]
427 fn load_absent_returns_empty() {
428 let dir = TempDir::new().unwrap();
429 let lock = load(dir.path()).unwrap();
430 assert_eq!(lock.version, 1);
431 assert!(lock.dependencies.is_empty());
432 assert!(lock.items.is_empty());
433 }
434
435 #[test]
436 fn write_and_reload() {
437 let dir = TempDir::new().unwrap();
438 let lock = sample_lock();
439 write(dir.path(), &lock).unwrap();
440 let reloaded = load(dir.path()).unwrap();
441 assert_eq!(lock, reloaded);
442 }
443
444 #[test]
445 fn dual_checksums_present() {
446 let lock = sample_lock();
447 let item = &lock.items["agents/coder.md"];
448 assert_ne!(item.source_checksum, item.installed_checksum);
449 assert!(item.source_checksum.starts_with("sha256:"));
450 assert!(item.installed_checksum.starts_with("sha256:"));
451 }
452
453 #[test]
454 fn path_source_in_lock() {
455 let toml_str = r#"
456version = 1
457
458[dependencies.local]
459path = "/home/dev/agents"
460
461[items."agents/helper.md"]
462source = "local"
463kind = "agent"
464source_checksum = "sha256:111"
465installed_checksum = "sha256:222"
466dest_path = "agents/helper.md"
467"#;
468 let lock: LockFile = toml::from_str(toml_str).unwrap();
469 let source = &lock.dependencies["local"];
470 assert!(source.url.is_none());
471 assert_eq!(source.path.as_deref(), Some("/home/dev/agents"));
472 assert!(source.commit.is_none());
473 }
474
475 #[test]
476 fn item_kind_serializes_lowercase() {
477 let item = LockedItem {
478 source: "base".into(),
479 kind: ItemKind::Skill,
480 version: None,
481 source_checksum: "sha256:aaa".into(),
482 installed_checksum: "sha256:bbb".into(),
483 dest_path: "skills/review".into(),
484 };
485 let serialized = toml::to_string(&item).unwrap();
486 assert!(serialized.contains("kind = \"skill\""));
487 }
488
489 #[test]
490 fn item_id_display() {
491 let id = ItemId {
492 kind: ItemKind::Agent,
493 name: "coder".into(),
494 };
495 assert_eq!(id.to_string(), "agent/coder");
496 }
497
498 #[test]
499 fn item_kind_display() {
500 assert_eq!(ItemKind::Agent.to_string(), "agent");
501 assert_eq!(ItemKind::Skill.to_string(), "skill");
502 }
503
504 #[test]
505 fn build_uses_graph_provenance_for_sources() {
506 let git_name: SourceName = "base".into();
507 let path_name: SourceName = "local".into();
508 let git_url: SourceUrl = "https://example.com/new.git".into();
509 let path_canonical = PathBuf::from("/tmp/mars-agents-local-source");
510
511 let mut nodes = IndexMap::new();
512 nodes.insert(
513 git_name.clone(),
514 ResolvedNode {
515 source_name: git_name.clone(),
516 source_id: SourceId::git_with_subpath(
517 git_url.clone(),
518 Some(crate::types::SourceSubpath::new("plugins/base").unwrap()),
519 ),
520 rooted_ref: crate::resolve::RootedSourceRef {
521 checkout_root: PathBuf::from("/tmp/cache/base"),
522 package_root: PathBuf::from("/tmp/cache/base/plugins/base"),
523 },
524 resolved_ref: ResolvedRef {
525 source_name: git_name.clone(),
526 version: Some(semver::Version::new(1, 2, 3)),
527 version_tag: Some("v1.2.3".into()),
528 commit: Some("abc123".into()),
529 tree_path: PathBuf::from("/tmp/cache/base"),
530 },
531 latest_version: None,
532 manifest: None,
533 deps: vec![],
534 },
535 );
536 nodes.insert(
537 path_name.clone(),
538 ResolvedNode {
539 source_name: path_name.clone(),
540 source_id: SourceId::Path {
541 canonical: path_canonical.clone(),
542 subpath: Some(crate::types::SourceSubpath::new("plugins/local").unwrap()),
543 },
544 rooted_ref: crate::resolve::RootedSourceRef {
545 checkout_root: PathBuf::from("/tmp/cache/local"),
546 package_root: PathBuf::from("/tmp/cache/local/plugins/local"),
547 },
548 resolved_ref: ResolvedRef {
549 source_name: path_name.clone(),
550 version: None,
551 version_tag: None,
552 commit: None,
553 tree_path: PathBuf::from("/tmp/cache/local"),
554 },
555 latest_version: None,
556 manifest: None,
557 deps: vec![],
558 },
559 );
560
561 let graph = ResolvedGraph {
562 nodes,
563 order: vec![git_name.clone(), path_name.clone()],
564 id_index: HashMap::new(),
565 filters: HashMap::new(),
566 };
567 let applied = ApplyResult { outcomes: vec![] };
568
569 let mut old_sources = IndexMap::new();
570 old_sources.insert(
571 git_name.clone(),
572 LockedSource {
573 url: Some("https://example.com/old.git".into()),
574 path: None,
575 subpath: None,
576 version: Some("v0.0.1".into()),
577 commit: Some("deadbeef".into()),
578 tree_hash: None,
579 },
580 );
581 let old_lock = LockFile {
582 version: 1,
583 dependencies: old_sources,
584 items: IndexMap::new(),
585 };
586
587 let new_lock = build(&graph, &applied, &old_lock).unwrap();
588
589 let base = &new_lock.dependencies["base"];
590 assert_eq!(base.url.as_ref(), Some(&git_url));
591 assert_eq!(
592 base.subpath
593 .as_ref()
594 .map(crate::types::SourceSubpath::as_str),
595 Some("plugins/base")
596 );
597 assert_eq!(base.version.as_deref(), Some("v1.2.3"));
598 assert_eq!(base.commit.as_deref(), Some("abc123"));
599
600 let local = &new_lock.dependencies["local"];
601 assert!(local.url.is_none());
602 assert_eq!(
603 local
604 .subpath
605 .as_ref()
606 .map(crate::types::SourceSubpath::as_str),
607 Some("plugins/local")
608 );
609 assert_eq!(
610 local.path.as_deref(),
611 Some(path_canonical.to_string_lossy().as_ref())
612 );
613 }
614
615 #[test]
616 fn build_keeps_self_items_from_old_lock_on_skipped_action() {
617 let graph = ResolvedGraph {
618 nodes: IndexMap::new(),
619 order: Vec::new(),
620 id_index: HashMap::new(),
621 filters: HashMap::new(),
622 };
623 let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
624 let old_lock = LockFile {
625 version: 1,
626 dependencies: IndexMap::from([(
627 local_source_name.clone(),
628 LockedSource {
629 url: None,
630 path: Some(".".into()),
631 subpath: None,
632 version: None,
633 commit: None,
634 tree_hash: None,
635 },
636 )]),
637 items: IndexMap::from([(
638 DestPath::from("skills/local-skill"),
639 LockedItem {
640 source: local_source_name.clone(),
641 kind: ItemKind::Skill,
642 version: None,
643 source_checksum: "sha256:self".into(),
644 installed_checksum: "sha256:self".into(),
645 dest_path: DestPath::from("skills/local-skill"),
646 },
647 )]),
648 };
649 let applied = ApplyResult {
650 outcomes: vec![ActionOutcome {
651 item_id: ItemId {
652 kind: ItemKind::Skill,
653 name: "local-skill".into(),
654 },
655 action: ActionTaken::Skipped,
656 dest_path: "skills/local-skill".into(),
657 source_name: local_source_name.clone(),
658 source_checksum: None,
659 installed_checksum: None,
660 }],
661 };
662
663 let new_lock = build(&graph, &applied, &old_lock).unwrap();
664
665 assert!(
666 new_lock
667 .dependencies
668 .contains_key(local_source_name.as_str())
669 );
670 let item = &new_lock.items["skills/local-skill"];
671 assert_eq!(item.source, local_source_name);
672 assert_eq!(item.kind, ItemKind::Skill);
673 assert_eq!(item.source_checksum, "sha256:self");
674 assert_eq!(item.installed_checksum, "sha256:self");
675 }
676
677 #[test]
678 fn build_rejects_missing_installed_checksum_for_write_actions() {
679 let graph = ResolvedGraph {
680 nodes: IndexMap::new(),
681 order: Vec::new(),
682 id_index: HashMap::new(),
683 filters: HashMap::new(),
684 };
685 let old_lock = LockFile::empty();
686 let applied = ApplyResult {
687 outcomes: vec![ActionOutcome {
688 item_id: ItemId {
689 kind: ItemKind::Agent,
690 name: "coder".into(),
691 },
692 action: ActionTaken::Installed,
693 dest_path: "agents/coder.md".into(),
694 source_name: "base".into(),
695 source_checksum: Some("sha256:source".into()),
696 installed_checksum: None,
697 }],
698 };
699
700 let err = build(&graph, &applied, &old_lock).unwrap_err();
701 let msg = err.to_string();
702 assert!(msg.contains("missing checksum for write-producing action"));
703 assert!(msg.contains("agents/coder.md"));
704 }
705
706 #[test]
707 fn build_rejects_empty_checksums_from_carried_items() {
708 let graph = ResolvedGraph {
709 nodes: IndexMap::new(),
710 order: Vec::new(),
711 id_index: HashMap::new(),
712 filters: HashMap::new(),
713 };
714 let old_lock = LockFile {
715 version: 1,
716 dependencies: IndexMap::new(),
717 items: IndexMap::from([(
718 DestPath::from("agents/coder.md"),
719 LockedItem {
720 source: "base".into(),
721 kind: ItemKind::Agent,
722 version: None,
723 source_checksum: "".into(),
724 installed_checksum: "sha256:installed".into(),
725 dest_path: DestPath::from("agents/coder.md"),
726 },
727 )]),
728 };
729 let applied = ApplyResult {
730 outcomes: vec![ActionOutcome {
731 item_id: ItemId {
732 kind: ItemKind::Agent,
733 name: "coder".into(),
734 },
735 action: ActionTaken::Skipped,
736 dest_path: "agents/coder.md".into(),
737 source_name: "base".into(),
738 source_checksum: None,
739 installed_checksum: None,
740 }],
741 };
742
743 let err = build(&graph, &applied, &old_lock).unwrap_err();
744 let msg = err.to_string();
745 assert!(msg.contains("empty source_checksum"));
746 assert!(msg.contains("agents/coder.md"));
747 }
748}