1use std::path::Path;
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::error::{LockError, MarsError};
7use crate::types::{CommitHash, ContentHash, DestPath, ItemName, SourceId, SourceName, SourceUrl};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct LockFile {
15 pub version: u32,
17 #[serde(default)]
18 pub dependencies: IndexMap<SourceName, LockedSource>,
19 #[serde(default)]
20 pub items: IndexMap<DestPath, LockedItem>,
21}
22
23impl LockFile {
24 pub fn empty() -> Self {
26 LockFile {
27 version: 1,
28 dependencies: IndexMap::new(),
29 items: IndexMap::new(),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct LockedSource {
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub url: Option<SourceUrl>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub path: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub version: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub commit: Option<CommitHash>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub tree_hash: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53pub struct LockedItem {
54 pub source: SourceName,
55 pub kind: ItemKind,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub version: Option<String>,
58 pub source_checksum: ContentHash,
59 pub installed_checksum: ContentHash,
60 pub dest_path: DestPath,
61}
62
63#[derive(Debug, Clone)]
65pub struct SelfLockItem {
66 pub dest_path: DestPath,
67 pub kind: ItemKind,
68 pub source_checksum: ContentHash,
69}
70
71#[derive(Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
76pub struct ItemId {
77 pub kind: ItemKind,
78 pub name: ItemName,
79}
80
81impl std::fmt::Display for ItemId {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 write!(f, "{}/{}", self.kind, self.name)
84 }
85}
86
87#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum ItemKind {
91 Agent,
92 Skill,
93}
94
95impl std::fmt::Display for ItemKind {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 match self {
98 ItemKind::Agent => write!(f, "agent"),
99 ItemKind::Skill => write!(f, "skill"),
100 }
101 }
102}
103
104const LOCK_FILE: &str = "mars.lock";
105
106pub fn load(root: &Path) -> Result<LockFile, MarsError> {
110 let path = root.join(LOCK_FILE);
111 match std::fs::read_to_string(&path) {
112 Ok(content) => {
113 let lock: LockFile = toml::from_str(&content).map_err(|e| LockError::Corrupt {
114 message: format!("failed to parse {}: {e}", path.display()),
115 })?;
116 Ok(lock)
117 }
118 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LockFile::empty()),
119 Err(e) => Err(LockError::Io(e).into()),
120 }
121}
122
123pub fn write(root: &Path, lock: &LockFile) -> Result<(), MarsError> {
128 let path = root.join(LOCK_FILE);
129 let content = toml::to_string_pretty(lock).map_err(|e| LockError::Corrupt {
130 message: format!("failed to serialize lock file: {e}"),
131 })?;
132 crate::fs::atomic_write(&path, content.as_bytes())
133}
134
135pub fn build(
141 graph: &crate::resolve::ResolvedGraph,
142 applied: &crate::sync::apply::ApplyResult,
143 old_lock: &LockFile,
144 self_items: Option<&[SelfLockItem]>,
145) -> Result<LockFile, MarsError> {
146 use crate::sync::apply::ActionTaken;
147
148 let mut dependencies = IndexMap::new();
149 let mut items = IndexMap::new();
150
151 for (name, node) in &graph.nodes {
153 dependencies.insert(name.clone(), to_locked_source(node));
154 }
155
156 for outcome in &applied.outcomes {
158 match &outcome.action {
159 ActionTaken::Removed | ActionTaken::Skipped => {
160 if matches!(outcome.action, ActionTaken::Skipped) {
162 let dest_path = outcome.dest_path.clone();
163 if let Some(old_item) = old_lock.items.get(&dest_path) {
164 items.insert(dest_path, old_item.clone());
165 }
166 }
167 }
169 ActionTaken::Kept => {
170 let dest_path = outcome.dest_path.clone();
172 if let Some(old_item) = old_lock.items.get(&dest_path) {
173 items.insert(dest_path, old_item.clone());
174 }
175 }
176 ActionTaken::Symlinked => {
177 let dest_path = outcome.dest_path.clone();
179 let source_checksum = outcome
180 .source_checksum
181 .clone()
182 .unwrap_or_else(|| ContentHash::from(""));
183 items.insert(
184 dest_path.clone(),
185 LockedItem {
186 source: SourceName::from("_self"),
187 kind: outcome.item_id.kind,
188 version: None,
189 source_checksum: source_checksum.clone(),
190 installed_checksum: source_checksum,
191 dest_path,
192 },
193 );
194 }
195 ActionTaken::Installed
196 | ActionTaken::Updated
197 | ActionTaken::Merged
198 | ActionTaken::Conflicted => {
199 let dest_path = outcome.dest_path.clone();
200 if dest_path.as_path().as_os_str().is_empty() {
201 continue;
202 }
203
204 let source_name = if outcome.source_name.as_ref().is_empty() {
206 None
207 } else {
208 Some(outcome.source_name.clone())
209 };
210
211 let version = source_name.as_ref().and_then(|sn| {
213 graph
214 .nodes
215 .get(sn)
216 .and_then(|n| n.resolved_ref.version_tag.clone())
217 });
218
219 let source_checksum = outcome
220 .source_checksum
221 .clone()
222 .unwrap_or_else(|| ContentHash::from(""));
223 let installed_checksum = outcome
224 .installed_checksum
225 .clone()
226 .unwrap_or_else(|| source_checksum.clone());
227
228 items.insert(
229 dest_path.clone(),
230 LockedItem {
231 source: source_name.unwrap_or_else(|| SourceName::from("")),
232 kind: outcome.item_id.kind,
233 version,
234 source_checksum,
235 installed_checksum,
236 dest_path,
237 },
238 );
239 }
240 }
241 }
242
243 if let Some(self_items) = self_items {
244 for item in self_items {
245 items.insert(
246 item.dest_path.clone(),
247 LockedItem {
248 source: SourceName::from("_self"),
249 kind: item.kind,
250 version: None,
251 source_checksum: item.source_checksum.clone(),
252 installed_checksum: item.source_checksum.clone(),
253 dest_path: item.dest_path.clone(),
254 },
255 );
256 }
257 }
258
259 let has_self_items = items.values().any(|item| item.source.as_ref() == "_self");
261 if has_self_items {
262 dependencies.insert(
263 SourceName::from("_self"),
264 LockedSource {
265 url: None,
266 path: Some(".".into()),
267 version: None,
268 commit: None,
269 tree_hash: None,
270 },
271 );
272 }
273
274 dependencies.sort_keys();
276 items.sort_keys();
277
278 Ok(LockFile {
279 version: 1,
280 dependencies,
281 items,
282 })
283}
284
285fn to_locked_source(node: &crate::resolve::ResolvedNode) -> LockedSource {
286 let (url, path) = match &node.source_id {
287 SourceId::Git { url } => (Some(url.clone()), None),
288 SourceId::Path { canonical } => (None, Some(canonical.to_string_lossy().to_string())),
289 };
290
291 LockedSource {
292 url,
293 path,
294 version: node.resolved_ref.version_tag.clone(),
295 commit: node.resolved_ref.commit.clone(),
296 tree_hash: None,
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use std::collections::HashMap;
304 use std::path::PathBuf;
305
306 use crate::resolve::{ResolvedGraph, ResolvedNode};
307 use crate::source::ResolvedRef;
308 use crate::sync::apply::ApplyResult;
309 use crate::types::{SourceId, SourceUrl};
310 use tempfile::TempDir;
311
312 fn sample_lock() -> LockFile {
313 let mut dependencies = IndexMap::new();
314 dependencies.insert(
315 "base".into(),
316 LockedSource {
317 url: Some("https://github.com/org/base.git".into()),
318 path: None,
319 version: Some("v1.0.0".into()),
320 commit: Some("abc123".into()),
321 tree_hash: Some("def456".into()),
322 },
323 );
324
325 let mut items = IndexMap::new();
326 items.insert(
327 "agents/coder.md".into(),
328 LockedItem {
329 source: "base".into(),
330 kind: ItemKind::Agent,
331 version: Some("v1.0.0".into()),
332 source_checksum: "sha256:aaa".into(),
333 installed_checksum: "sha256:bbb".into(),
334 dest_path: "agents/coder.md".into(),
335 },
336 );
337 items.insert(
338 "skills/review".into(),
339 LockedItem {
340 source: "base".into(),
341 kind: ItemKind::Skill,
342 version: Some("v1.0.0".into()),
343 source_checksum: "sha256:ccc".into(),
344 installed_checksum: "sha256:ddd".into(),
345 dest_path: "skills/review".into(),
346 },
347 );
348
349 LockFile {
350 version: 1,
351 dependencies,
352 items,
353 }
354 }
355
356 #[test]
357 fn parse_valid_lock_file() {
358 let toml_str = r#"
359version = 1
360
361[dependencies.base]
362url = "https://github.com/org/base.git"
363version = "v1.0.0"
364commit = "abc123"
365tree_hash = "def456"
366
367[items."agents/coder.md"]
368source = "base"
369kind = "agent"
370version = "v1.0.0"
371source_checksum = "sha256:aaa"
372installed_checksum = "sha256:bbb"
373dest_path = "agents/coder.md"
374"#;
375 let lock: LockFile = toml::from_str(toml_str).unwrap();
376 assert_eq!(lock.version, 1);
377 assert_eq!(lock.dependencies.len(), 1);
378 assert_eq!(lock.items.len(), 1);
379
380 let item = &lock.items["agents/coder.md"];
381 assert_eq!(item.source, "base");
382 assert_eq!(item.kind, ItemKind::Agent);
383 assert_eq!(item.source_checksum, "sha256:aaa");
384 assert_eq!(item.installed_checksum, "sha256:bbb");
385 }
386
387 #[test]
388 fn roundtrip_lock_file() {
389 let lock = sample_lock();
390 let serialized = toml::to_string_pretty(&lock).unwrap();
391 let deserialized: LockFile = toml::from_str(&serialized).unwrap();
392 assert_eq!(lock, deserialized);
393 }
394
395 #[test]
396 fn deterministic_serialization() {
397 let lock = sample_lock();
398 let s1 = toml::to_string_pretty(&lock).unwrap();
399 let s2 = toml::to_string_pretty(&lock).unwrap();
400 assert_eq!(s1, s2);
401
402 let coder_pos = s1.find("agents/coder.md").unwrap();
404 let review_pos = s1.find("skills/review").unwrap();
405 assert!(
406 coder_pos < review_pos,
407 "keys should preserve insertion order"
408 );
409 }
410
411 #[test]
412 fn empty_lock_file() {
413 let lock = LockFile::empty();
414 assert_eq!(lock.version, 1);
415 assert!(lock.dependencies.is_empty());
416 assert!(lock.items.is_empty());
417
418 let serialized = toml::to_string_pretty(&lock).unwrap();
420 let deserialized: LockFile = toml::from_str(&serialized).unwrap();
421 assert_eq!(lock, deserialized);
422 }
423
424 #[test]
425 fn load_absent_returns_empty() {
426 let dir = TempDir::new().unwrap();
427 let lock = load(dir.path()).unwrap();
428 assert_eq!(lock.version, 1);
429 assert!(lock.dependencies.is_empty());
430 assert!(lock.items.is_empty());
431 }
432
433 #[test]
434 fn write_and_reload() {
435 let dir = TempDir::new().unwrap();
436 let lock = sample_lock();
437 write(dir.path(), &lock).unwrap();
438 let reloaded = load(dir.path()).unwrap();
439 assert_eq!(lock, reloaded);
440 }
441
442 #[test]
443 fn dual_checksums_present() {
444 let lock = sample_lock();
445 let item = &lock.items["agents/coder.md"];
446 assert_ne!(item.source_checksum, item.installed_checksum);
447 assert!(item.source_checksum.starts_with("sha256:"));
448 assert!(item.installed_checksum.starts_with("sha256:"));
449 }
450
451 #[test]
452 fn path_source_in_lock() {
453 let toml_str = r#"
454version = 1
455
456[dependencies.local]
457path = "/home/dev/agents"
458
459[items."agents/helper.md"]
460source = "local"
461kind = "agent"
462source_checksum = "sha256:111"
463installed_checksum = "sha256:222"
464dest_path = "agents/helper.md"
465"#;
466 let lock: LockFile = toml::from_str(toml_str).unwrap();
467 let source = &lock.dependencies["local"];
468 assert!(source.url.is_none());
469 assert_eq!(source.path.as_deref(), Some("/home/dev/agents"));
470 assert!(source.commit.is_none());
471 }
472
473 #[test]
474 fn item_kind_serializes_lowercase() {
475 let item = LockedItem {
476 source: "base".into(),
477 kind: ItemKind::Skill,
478 version: None,
479 source_checksum: "sha256:aaa".into(),
480 installed_checksum: "sha256:bbb".into(),
481 dest_path: "skills/review".into(),
482 };
483 let serialized = toml::to_string(&item).unwrap();
484 assert!(serialized.contains("kind = \"skill\""));
485 }
486
487 #[test]
488 fn item_id_display() {
489 let id = ItemId {
490 kind: ItemKind::Agent,
491 name: "coder".into(),
492 };
493 assert_eq!(id.to_string(), "agent/coder");
494 }
495
496 #[test]
497 fn item_kind_display() {
498 assert_eq!(ItemKind::Agent.to_string(), "agent");
499 assert_eq!(ItemKind::Skill.to_string(), "skill");
500 }
501
502 #[test]
503 fn build_uses_graph_provenance_for_sources() {
504 let git_name: SourceName = "base".into();
505 let path_name: SourceName = "local".into();
506 let git_url: SourceUrl = "https://example.com/new.git".into();
507 let path_canonical = PathBuf::from("/tmp/mars-agents-local-source");
508
509 let mut nodes = IndexMap::new();
510 nodes.insert(
511 git_name.clone(),
512 ResolvedNode {
513 source_name: git_name.clone(),
514 source_id: SourceId::git(git_url.clone()),
515 resolved_ref: ResolvedRef {
516 source_name: git_name.clone(),
517 version: Some(semver::Version::new(1, 2, 3)),
518 version_tag: Some("v1.2.3".into()),
519 commit: Some("abc123".into()),
520 tree_path: PathBuf::from("/tmp/cache/base"),
521 },
522 manifest: None,
523 deps: vec![],
524 },
525 );
526 nodes.insert(
527 path_name.clone(),
528 ResolvedNode {
529 source_name: path_name.clone(),
530 source_id: SourceId::Path {
531 canonical: path_canonical.clone(),
532 },
533 resolved_ref: ResolvedRef {
534 source_name: path_name.clone(),
535 version: None,
536 version_tag: None,
537 commit: None,
538 tree_path: PathBuf::from("/tmp/cache/local"),
539 },
540 manifest: None,
541 deps: vec![],
542 },
543 );
544
545 let graph = ResolvedGraph {
546 nodes,
547 order: vec![git_name.clone(), path_name.clone()],
548 id_index: HashMap::new(),
549 };
550 let applied = ApplyResult { outcomes: vec![] };
551
552 let mut old_sources = IndexMap::new();
553 old_sources.insert(
554 git_name.clone(),
555 LockedSource {
556 url: Some("https://example.com/old.git".into()),
557 path: None,
558 version: Some("v0.0.1".into()),
559 commit: Some("deadbeef".into()),
560 tree_hash: None,
561 },
562 );
563 let old_lock = LockFile {
564 version: 1,
565 dependencies: old_sources,
566 items: IndexMap::new(),
567 };
568
569 let new_lock = build(&graph, &applied, &old_lock, None).unwrap();
570
571 let base = &new_lock.dependencies["base"];
572 assert_eq!(base.url.as_ref(), Some(&git_url));
573 assert_eq!(base.version.as_deref(), Some("v1.2.3"));
574 assert_eq!(base.commit.as_deref(), Some("abc123"));
575
576 let local = &new_lock.dependencies["local"];
577 assert!(local.url.is_none());
578 assert_eq!(
579 local.path.as_deref(),
580 Some(path_canonical.to_string_lossy().as_ref())
581 );
582 }
583
584 #[test]
585 fn build_keeps_self_items_even_without_symlink_actions() {
586 let graph = ResolvedGraph {
587 nodes: IndexMap::new(),
588 order: Vec::new(),
589 id_index: HashMap::new(),
590 };
591 let applied = ApplyResult { outcomes: vec![] };
592 let old_lock = LockFile::empty();
593 let self_items = vec![SelfLockItem {
594 dest_path: "skills/local-skill".into(),
595 kind: ItemKind::Skill,
596 source_checksum: "sha256:self".into(),
597 }];
598
599 let new_lock = build(&graph, &applied, &old_lock, Some(&self_items)).unwrap();
600
601 assert!(new_lock.dependencies.contains_key("_self"));
602 let item = &new_lock.items["skills/local-skill"];
603 assert_eq!(item.source, "_self");
604 assert_eq!(item.kind, ItemKind::Skill);
605 assert_eq!(item.source_checksum, "sha256:self");
606 assert_eq!(item.installed_checksum, "sha256:self");
607 }
608}