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_path().as_os_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            id_index: HashMap::new(),
565            filters: HashMap::new(),
566        };
567        let applied = ApplyResult { outcomes: vec![] };
568
569        let mut old_sources = IndexMap::new();
570        old_sources.insert(
571            git_name.clone(),
572            LockedSource {
573                url: Some("https://example.com/old.git".into()),
574                path: None,
575                subpath: None,
576                version: Some("v0.0.1".into()),
577                commit: Some("deadbeef".into()),
578                tree_hash: None,
579            },
580        );
581        let old_lock = LockFile {
582            version: 1,
583            dependencies: old_sources,
584            items: IndexMap::new(),
585        };
586
587        let new_lock = build(&graph, &applied, &old_lock).unwrap();
588
589        let base = &new_lock.dependencies["base"];
590        assert_eq!(base.url.as_ref(), Some(&git_url));
591        assert_eq!(
592            base.subpath
593                .as_ref()
594                .map(crate::types::SourceSubpath::as_str),
595            Some("plugins/base")
596        );
597        assert_eq!(base.version.as_deref(), Some("v1.2.3"));
598        assert_eq!(base.commit.as_deref(), Some("abc123"));
599
600        let local = &new_lock.dependencies["local"];
601        assert!(local.url.is_none());
602        assert_eq!(
603            local
604                .subpath
605                .as_ref()
606                .map(crate::types::SourceSubpath::as_str),
607            Some("plugins/local")
608        );
609        assert_eq!(
610            local.path.as_deref(),
611            Some(path_canonical.to_string_lossy().as_ref())
612        );
613    }
614
615    #[test]
616    fn build_keeps_self_items_from_old_lock_on_skipped_action() {
617        let graph = ResolvedGraph {
618            nodes: IndexMap::new(),
619            order: Vec::new(),
620            id_index: HashMap::new(),
621            filters: HashMap::new(),
622        };
623        let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
624        let old_lock = LockFile {
625            version: 1,
626            dependencies: IndexMap::from([(
627                local_source_name.clone(),
628                LockedSource {
629                    url: None,
630                    path: Some(".".into()),
631                    subpath: None,
632                    version: None,
633                    commit: None,
634                    tree_hash: None,
635                },
636            )]),
637            items: IndexMap::from([(
638                DestPath::from("skills/local-skill"),
639                LockedItem {
640                    source: local_source_name.clone(),
641                    kind: ItemKind::Skill,
642                    version: None,
643                    source_checksum: "sha256:self".into(),
644                    installed_checksum: "sha256:self".into(),
645                    dest_path: DestPath::from("skills/local-skill"),
646                },
647            )]),
648        };
649        let applied = ApplyResult {
650            outcomes: vec![ActionOutcome {
651                item_id: ItemId {
652                    kind: ItemKind::Skill,
653                    name: "local-skill".into(),
654                },
655                action: ActionTaken::Skipped,
656                dest_path: "skills/local-skill".into(),
657                source_name: local_source_name.clone(),
658                source_checksum: None,
659                installed_checksum: None,
660            }],
661        };
662
663        let new_lock = build(&graph, &applied, &old_lock).unwrap();
664
665        assert!(
666            new_lock
667                .dependencies
668                .contains_key(local_source_name.as_str())
669        );
670        let item = &new_lock.items["skills/local-skill"];
671        assert_eq!(item.source, local_source_name);
672        assert_eq!(item.kind, ItemKind::Skill);
673        assert_eq!(item.source_checksum, "sha256:self");
674        assert_eq!(item.installed_checksum, "sha256:self");
675    }
676
677    #[test]
678    fn build_rejects_missing_installed_checksum_for_write_actions() {
679        let graph = ResolvedGraph {
680            nodes: IndexMap::new(),
681            order: Vec::new(),
682            id_index: HashMap::new(),
683            filters: HashMap::new(),
684        };
685        let old_lock = LockFile::empty();
686        let applied = ApplyResult {
687            outcomes: vec![ActionOutcome {
688                item_id: ItemId {
689                    kind: ItemKind::Agent,
690                    name: "coder".into(),
691                },
692                action: ActionTaken::Installed,
693                dest_path: "agents/coder.md".into(),
694                source_name: "base".into(),
695                source_checksum: Some("sha256:source".into()),
696                installed_checksum: None,
697            }],
698        };
699
700        let err = build(&graph, &applied, &old_lock).unwrap_err();
701        let msg = err.to_string();
702        assert!(msg.contains("missing checksum for write-producing action"));
703        assert!(msg.contains("agents/coder.md"));
704    }
705
706    #[test]
707    fn build_rejects_empty_checksums_from_carried_items() {
708        let graph = ResolvedGraph {
709            nodes: IndexMap::new(),
710            order: Vec::new(),
711            id_index: HashMap::new(),
712            filters: HashMap::new(),
713        };
714        let old_lock = LockFile {
715            version: 1,
716            dependencies: IndexMap::new(),
717            items: IndexMap::from([(
718                DestPath::from("agents/coder.md"),
719                LockedItem {
720                    source: "base".into(),
721                    kind: ItemKind::Agent,
722                    version: None,
723                    source_checksum: "".into(),
724                    installed_checksum: "sha256:installed".into(),
725                    dest_path: DestPath::from("agents/coder.md"),
726                },
727            )]),
728        };
729        let applied = ApplyResult {
730            outcomes: vec![ActionOutcome {
731                item_id: ItemId {
732                    kind: ItemKind::Agent,
733                    name: "coder".into(),
734                },
735                action: ActionTaken::Skipped,
736                dest_path: "agents/coder.md".into(),
737                source_name: "base".into(),
738                source_checksum: None,
739                installed_checksum: None,
740            }],
741        };
742
743        let err = build(&graph, &applied, &old_lock).unwrap_err();
744        let msg = err.to_string();
745        assert!(msg.contains("empty source_checksum"));
746        assert!(msg.contains("agents/coder.md"));
747    }
748}