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