Skip to main content

mars_agents/lock/
mod.rs

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/// The complete lock file — ownership registry for all managed items.
12///
13/// Tracks every managed file with provenance and integrity data.
14/// TOML format, deterministically ordered (sorted keys) for clean git diffs.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct LockFile {
17    /// Schema version, currently 1.
18    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    /// Create a new empty lock file with the current schema version.
27    pub fn empty() -> Self {
28        LockFile {
29            version: 1,
30            dependencies: IndexMap::new(),
31            items: IndexMap::new(),
32        }
33    }
34}
35
36/// One resolved source in the lock.
37#[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    /// Reserved for future content verification of fetched source trees.
48    /// TODO: populate during fetch/build once deterministic tree hashing is implemented.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub tree_hash: Option<String>,
51}
52
53/// One installed item tracked by the lock.
54#[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
65// Re-export ItemKind and ItemId from types — they're shared vocabulary,
66// not lock-specific. This preserves `use crate::lock::ItemKind` compatibility.
67pub use crate::types::{ItemId, ItemKind};
68
69const LOCK_FILE: &str = "mars.lock";
70
71/// Load the lock file from the given root directory.
72///
73/// Returns an empty LockFile if the file is absent.
74pub 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
88/// Write the lock file atomically to the given root directory.
89///
90/// Keys are sorted deterministically for clean git diffs (IndexMap preserves
91/// insertion order, so callers should ensure sorted order when building).
92pub 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
100/// Build a new lock file from resolved graph + apply results.
101///
102/// Constructs the lock file from the graph (source provenance) and
103/// the apply outcomes (checksums). Items that were skipped, kept, or
104/// merged retain their provenance from the graph. Removed items are excluded.
105pub 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    // Build dependency entries directly from resolved graph provenance.
116    for (name, node) in &graph.nodes {
117        dependencies.insert(name.clone(), to_locked_source(node));
118    }
119
120    // Build item entries from apply outcomes
121    for outcome in &applied.outcomes {
122        match &outcome.action {
123            ActionTaken::Removed | ActionTaken::Skipped => {
124                // For skipped items, carry forward from old lock
125                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                // Removed items are excluded from the new lock
132            }
133            ActionTaken::Kept => {
134                // Keep local: carry forward old lock entry (source unchanged)
135                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                // Track _self items in the lock file
142                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                // Use source_name from outcome (propagated from TargetItem)
169                let source_name = if outcome.source_name.as_ref().is_empty() {
170                    None
171                } else {
172                    Some(outcome.source_name.clone())
173                };
174
175                // Determine version from graph
176                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    // Add synthetic _self source if any symlinked items exist
208    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    // Sort keys for deterministic output.
224    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        // Verify key ordering is preserved (agents/coder.md before skills/review)
352        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        // Roundtrip empty
368        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}