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, SourceSubpath, 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 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    /// Reserved for future content verification of fetched source trees.
50    /// TODO: populate during fetch/build once deterministic tree hashing is implemented.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub tree_hash: Option<String>,
53}
54
55/// One installed item tracked by the lock.
56#[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
67// Re-export ItemKind and ItemId from types — they're shared vocabulary,
68// not lock-specific. This preserves `use crate::lock::ItemKind` compatibility.
69pub use crate::types::{ItemId, ItemKind};
70
71const LOCK_FILE: &str = "mars.lock";
72
73/// Load the lock file from the given root directory.
74///
75/// Returns an empty LockFile if the file is absent.
76pub 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
90/// Write the lock file atomically to the given root directory.
91///
92/// Keys are sorted deterministically for clean git diffs (IndexMap preserves
93/// insertion order, so callers should ensure sorted order when building).
94pub 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
102/// Build a new lock file from resolved graph + apply results.
103///
104/// Constructs the lock file from the graph (source provenance) and
105/// the apply outcomes (checksums). Items that were skipped, kept, or
106/// merged retain their provenance from the graph. Removed items are excluded.
107pub 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    // Build dependency entries directly from resolved graph provenance.
162    for (name, node) in &graph.nodes {
163        dependencies.insert(name.clone(), to_locked_source(node));
164    }
165
166    // Build item entries from apply outcomes
167    for outcome in &applied.outcomes {
168        match &outcome.action {
169            ActionTaken::Removed | ActionTaken::Skipped => {
170                // For skipped items, carry forward from old lock
171                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                // Removed items are excluded from the new lock
178            }
179            ActionTaken::Kept => {
180                // Keep local: carry forward old lock entry (source unchanged)
181                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_str().is_empty() {
192                    continue;
193                }
194
195                // Use source_name from outcome (propagated from TargetItem)
196                let source_name = if outcome.source_name.as_ref().is_empty() {
197                    None
198                } else {
199                    Some(outcome.source_name.clone())
200                };
201
202                // Determine version from graph
203                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    // Add synthetic _self source if any local package items exist.
235    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    // Sort keys for deterministic output.
267    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        // Verify key ordering is preserved (agents/coder.md before skills/review)
405        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        // Roundtrip empty
421        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            filters: HashMap::new(),
565        };
566        let applied = ApplyResult { outcomes: vec![] };
567
568        let mut old_sources = IndexMap::new();
569        old_sources.insert(
570            git_name.clone(),
571            LockedSource {
572                url: Some("https://example.com/old.git".into()),
573                path: None,
574                subpath: None,
575                version: Some("v0.0.1".into()),
576                commit: Some("deadbeef".into()),
577                tree_hash: None,
578            },
579        );
580        let old_lock = LockFile {
581            version: 1,
582            dependencies: old_sources,
583            items: IndexMap::new(),
584        };
585
586        let new_lock = build(&graph, &applied, &old_lock).unwrap();
587
588        let base = &new_lock.dependencies["base"];
589        assert_eq!(base.url.as_ref(), Some(&git_url));
590        assert_eq!(
591            base.subpath
592                .as_ref()
593                .map(crate::types::SourceSubpath::as_str),
594            Some("plugins/base")
595        );
596        assert_eq!(base.version.as_deref(), Some("v1.2.3"));
597        assert_eq!(base.commit.as_deref(), Some("abc123"));
598
599        let local = &new_lock.dependencies["local"];
600        assert!(local.url.is_none());
601        assert_eq!(
602            local
603                .subpath
604                .as_ref()
605                .map(crate::types::SourceSubpath::as_str),
606            Some("plugins/local")
607        );
608        assert_eq!(
609            local.path.as_deref(),
610            Some(path_canonical.to_string_lossy().as_ref())
611        );
612    }
613
614    #[test]
615    fn build_keeps_self_items_from_old_lock_on_skipped_action() {
616        let graph = ResolvedGraph {
617            nodes: IndexMap::new(),
618            order: Vec::new(),
619            filters: HashMap::new(),
620        };
621        let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
622        let old_lock = LockFile {
623            version: 1,
624            dependencies: IndexMap::from([(
625                local_source_name.clone(),
626                LockedSource {
627                    url: None,
628                    path: Some(".".into()),
629                    subpath: None,
630                    version: None,
631                    commit: None,
632                    tree_hash: None,
633                },
634            )]),
635            items: IndexMap::from([(
636                DestPath::from("skills/local-skill"),
637                LockedItem {
638                    source: local_source_name.clone(),
639                    kind: ItemKind::Skill,
640                    version: None,
641                    source_checksum: "sha256:self".into(),
642                    installed_checksum: "sha256:self".into(),
643                    dest_path: DestPath::from("skills/local-skill"),
644                },
645            )]),
646        };
647        let applied = ApplyResult {
648            outcomes: vec![ActionOutcome {
649                item_id: ItemId {
650                    kind: ItemKind::Skill,
651                    name: "local-skill".into(),
652                },
653                action: ActionTaken::Skipped,
654                dest_path: "skills/local-skill".into(),
655                source_name: local_source_name.clone(),
656                source_checksum: None,
657                installed_checksum: None,
658            }],
659        };
660
661        let new_lock = build(&graph, &applied, &old_lock).unwrap();
662
663        assert!(
664            new_lock
665                .dependencies
666                .contains_key(local_source_name.as_str())
667        );
668        let item = &new_lock.items["skills/local-skill"];
669        assert_eq!(item.source, local_source_name);
670        assert_eq!(item.kind, ItemKind::Skill);
671        assert_eq!(item.source_checksum, "sha256:self");
672        assert_eq!(item.installed_checksum, "sha256:self");
673    }
674
675    #[test]
676    fn build_rejects_missing_installed_checksum_for_write_actions() {
677        let graph = ResolvedGraph {
678            nodes: IndexMap::new(),
679            order: Vec::new(),
680            filters: HashMap::new(),
681        };
682        let old_lock = LockFile::empty();
683        let applied = ApplyResult {
684            outcomes: vec![ActionOutcome {
685                item_id: ItemId {
686                    kind: ItemKind::Agent,
687                    name: "coder".into(),
688                },
689                action: ActionTaken::Installed,
690                dest_path: "agents/coder.md".into(),
691                source_name: "base".into(),
692                source_checksum: Some("sha256:source".into()),
693                installed_checksum: None,
694            }],
695        };
696
697        let err = build(&graph, &applied, &old_lock).unwrap_err();
698        let msg = err.to_string();
699        assert!(msg.contains("missing checksum for write-producing action"));
700        assert!(msg.contains("agents/coder.md"));
701    }
702
703    #[test]
704    fn build_rejects_empty_checksums_from_carried_items() {
705        let graph = ResolvedGraph {
706            nodes: IndexMap::new(),
707            order: Vec::new(),
708            filters: HashMap::new(),
709        };
710        let old_lock = LockFile {
711            version: 1,
712            dependencies: IndexMap::new(),
713            items: IndexMap::from([(
714                DestPath::from("agents/coder.md"),
715                LockedItem {
716                    source: "base".into(),
717                    kind: ItemKind::Agent,
718                    version: None,
719                    source_checksum: "".into(),
720                    installed_checksum: "sha256:installed".into(),
721                    dest_path: DestPath::from("agents/coder.md"),
722                },
723            )]),
724        };
725        let applied = ApplyResult {
726            outcomes: vec![ActionOutcome {
727                item_id: ItemId {
728                    kind: ItemKind::Agent,
729                    name: "coder".into(),
730                },
731                action: ActionTaken::Skipped,
732                dest_path: "agents/coder.md".into(),
733                source_name: "base".into(),
734                source_checksum: None,
735                installed_checksum: None,
736            }],
737        };
738
739        let err = build(&graph, &applied, &old_lock).unwrap_err();
740        let msg = err.to_string();
741        assert!(msg.contains("empty source_checksum"));
742        assert!(msg.contains("agents/coder.md"));
743    }
744}