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