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::{CommitHash, ContentHash, DestPath, ItemName, SourceId, SourceName, SourceUrl};
8
9/// The complete lock file — ownership registry for all managed items.
10///
11/// Tracks every managed file with provenance and integrity data.
12/// TOML format, deterministically ordered (sorted keys) for clean git diffs.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct LockFile {
15    /// Schema version, currently 1.
16    pub version: u32,
17    #[serde(default)]
18    pub dependencies: IndexMap<SourceName, LockedSource>,
19    #[serde(default)]
20    pub items: IndexMap<DestPath, LockedItem>,
21}
22
23impl LockFile {
24    /// Create a new empty lock file with the current schema version.
25    pub fn empty() -> Self {
26        LockFile {
27            version: 1,
28            dependencies: IndexMap::new(),
29            items: IndexMap::new(),
30        }
31    }
32}
33
34/// One resolved source in the lock.
35#[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    /// Reserved for future content verification of fetched source trees.
46    /// TODO: populate during fetch/build once deterministic tree hashing is implemented.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub tree_hash: Option<String>,
49}
50
51/// One installed item tracked by the lock.
52#[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/// `_self` item data provided by sync to force lock retention.
64#[derive(Debug, Clone)]
65pub struct SelfLockItem {
66    pub dest_path: DestPath,
67    pub kind: ItemKind,
68    pub source_checksum: ContentHash,
69}
70
71/// Stable identity for an installed item — decoupled from source URL.
72///
73/// Items are identified by `(kind, name)`, not by source URL.
74/// If a package moves to a different git host, the item identity is preserved.
75#[derive(Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
76pub struct ItemId {
77    pub kind: ItemKind,
78    pub name: ItemName,
79}
80
81impl std::fmt::Display for ItemId {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "{}/{}", self.kind, self.name)
84    }
85}
86
87/// Kind of installable item.
88#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum ItemKind {
91    Agent,
92    Skill,
93}
94
95impl std::fmt::Display for ItemKind {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            ItemKind::Agent => write!(f, "agent"),
99            ItemKind::Skill => write!(f, "skill"),
100        }
101    }
102}
103
104const LOCK_FILE: &str = "mars.lock";
105
106/// Load the lock file from the given root directory.
107///
108/// Returns an empty LockFile if the file is absent.
109pub fn load(root: &Path) -> Result<LockFile, MarsError> {
110    let path = root.join(LOCK_FILE);
111    match std::fs::read_to_string(&path) {
112        Ok(content) => {
113            let lock: LockFile = toml::from_str(&content).map_err(|e| LockError::Corrupt {
114                message: format!("failed to parse {}: {e}", path.display()),
115            })?;
116            Ok(lock)
117        }
118        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LockFile::empty()),
119        Err(e) => Err(LockError::Io(e).into()),
120    }
121}
122
123/// Write the lock file atomically to the given root directory.
124///
125/// Keys are sorted deterministically for clean git diffs (IndexMap preserves
126/// insertion order, so callers should ensure sorted order when building).
127pub fn write(root: &Path, lock: &LockFile) -> Result<(), MarsError> {
128    let path = root.join(LOCK_FILE);
129    let content = toml::to_string_pretty(lock).map_err(|e| LockError::Corrupt {
130        message: format!("failed to serialize lock file: {e}"),
131    })?;
132    crate::fs::atomic_write(&path, content.as_bytes())
133}
134
135/// Build a new lock file from resolved graph + apply results.
136///
137/// Constructs the lock file from the graph (source provenance) and
138/// the apply outcomes (checksums). Items that were skipped, kept, or
139/// merged retain their provenance from the graph. Removed items are excluded.
140pub fn build(
141    graph: &crate::resolve::ResolvedGraph,
142    applied: &crate::sync::apply::ApplyResult,
143    old_lock: &LockFile,
144    self_items: Option<&[SelfLockItem]>,
145) -> Result<LockFile, MarsError> {
146    use crate::sync::apply::ActionTaken;
147
148    let mut dependencies = IndexMap::new();
149    let mut items = IndexMap::new();
150
151    // Build dependency entries directly from resolved graph provenance.
152    for (name, node) in &graph.nodes {
153        dependencies.insert(name.clone(), to_locked_source(node));
154    }
155
156    // Build item entries from apply outcomes
157    for outcome in &applied.outcomes {
158        match &outcome.action {
159            ActionTaken::Removed | ActionTaken::Skipped => {
160                // For skipped items, carry forward from old lock
161                if matches!(outcome.action, ActionTaken::Skipped) {
162                    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                // Removed items are excluded from the new lock
168            }
169            ActionTaken::Kept => {
170                // Keep local: carry forward old lock entry (source unchanged)
171                let dest_path = outcome.dest_path.clone();
172                if let Some(old_item) = old_lock.items.get(&dest_path) {
173                    items.insert(dest_path, old_item.clone());
174                }
175            }
176            ActionTaken::Symlinked => {
177                // Track _self items in the lock file
178                let dest_path = outcome.dest_path.clone();
179                let source_checksum = outcome
180                    .source_checksum
181                    .clone()
182                    .unwrap_or_else(|| ContentHash::from(""));
183                items.insert(
184                    dest_path.clone(),
185                    LockedItem {
186                        source: SourceName::from("_self"),
187                        kind: outcome.item_id.kind,
188                        version: None,
189                        source_checksum: source_checksum.clone(),
190                        installed_checksum: source_checksum,
191                        dest_path,
192                    },
193                );
194            }
195            ActionTaken::Installed
196            | ActionTaken::Updated
197            | ActionTaken::Merged
198            | ActionTaken::Conflicted => {
199                let dest_path = outcome.dest_path.clone();
200                if dest_path.as_path().as_os_str().is_empty() {
201                    continue;
202                }
203
204                // Use source_name from outcome (propagated from TargetItem)
205                let source_name = if outcome.source_name.as_ref().is_empty() {
206                    None
207                } else {
208                    Some(outcome.source_name.clone())
209                };
210
211                // Determine version from graph
212                let version = source_name.as_ref().and_then(|sn| {
213                    graph
214                        .nodes
215                        .get(sn)
216                        .and_then(|n| n.resolved_ref.version_tag.clone())
217                });
218
219                let source_checksum = outcome
220                    .source_checksum
221                    .clone()
222                    .unwrap_or_else(|| ContentHash::from(""));
223                let installed_checksum = outcome
224                    .installed_checksum
225                    .clone()
226                    .unwrap_or_else(|| source_checksum.clone());
227
228                items.insert(
229                    dest_path.clone(),
230                    LockedItem {
231                        source: source_name.unwrap_or_else(|| SourceName::from("")),
232                        kind: outcome.item_id.kind,
233                        version,
234                        source_checksum,
235                        installed_checksum,
236                        dest_path,
237                    },
238                );
239            }
240        }
241    }
242
243    if let Some(self_items) = self_items {
244        for item in self_items {
245            items.insert(
246                item.dest_path.clone(),
247                LockedItem {
248                    source: SourceName::from("_self"),
249                    kind: item.kind,
250                    version: None,
251                    source_checksum: item.source_checksum.clone(),
252                    installed_checksum: item.source_checksum.clone(),
253                    dest_path: item.dest_path.clone(),
254                },
255            );
256        }
257    }
258
259    // Add synthetic _self source if any symlinked items exist
260    let has_self_items = items.values().any(|item| item.source.as_ref() == "_self");
261    if has_self_items {
262        dependencies.insert(
263            SourceName::from("_self"),
264            LockedSource {
265                url: None,
266                path: Some(".".into()),
267                version: None,
268                commit: None,
269                tree_hash: None,
270            },
271        );
272    }
273
274    // Sort keys for deterministic output.
275    dependencies.sort_keys();
276    items.sort_keys();
277
278    Ok(LockFile {
279        version: 1,
280        dependencies,
281        items,
282    })
283}
284
285fn to_locked_source(node: &crate::resolve::ResolvedNode) -> LockedSource {
286    let (url, path) = match &node.source_id {
287        SourceId::Git { url } => (Some(url.clone()), None),
288        SourceId::Path { canonical } => (None, Some(canonical.to_string_lossy().to_string())),
289    };
290
291    LockedSource {
292        url,
293        path,
294        version: node.resolved_ref.version_tag.clone(),
295        commit: node.resolved_ref.commit.clone(),
296        tree_hash: None,
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use std::collections::HashMap;
304    use std::path::PathBuf;
305
306    use crate::resolve::{ResolvedGraph, ResolvedNode};
307    use crate::source::ResolvedRef;
308    use crate::sync::apply::ApplyResult;
309    use crate::types::{SourceId, SourceUrl};
310    use tempfile::TempDir;
311
312    fn sample_lock() -> LockFile {
313        let mut dependencies = IndexMap::new();
314        dependencies.insert(
315            "base".into(),
316            LockedSource {
317                url: Some("https://github.com/org/base.git".into()),
318                path: None,
319                version: Some("v1.0.0".into()),
320                commit: Some("abc123".into()),
321                tree_hash: Some("def456".into()),
322            },
323        );
324
325        let mut items = IndexMap::new();
326        items.insert(
327            "agents/coder.md".into(),
328            LockedItem {
329                source: "base".into(),
330                kind: ItemKind::Agent,
331                version: Some("v1.0.0".into()),
332                source_checksum: "sha256:aaa".into(),
333                installed_checksum: "sha256:bbb".into(),
334                dest_path: "agents/coder.md".into(),
335            },
336        );
337        items.insert(
338            "skills/review".into(),
339            LockedItem {
340                source: "base".into(),
341                kind: ItemKind::Skill,
342                version: Some("v1.0.0".into()),
343                source_checksum: "sha256:ccc".into(),
344                installed_checksum: "sha256:ddd".into(),
345                dest_path: "skills/review".into(),
346            },
347        );
348
349        LockFile {
350            version: 1,
351            dependencies,
352            items,
353        }
354    }
355
356    #[test]
357    fn parse_valid_lock_file() {
358        let toml_str = r#"
359version = 1
360
361[dependencies.base]
362url = "https://github.com/org/base.git"
363version = "v1.0.0"
364commit = "abc123"
365tree_hash = "def456"
366
367[items."agents/coder.md"]
368source = "base"
369kind = "agent"
370version = "v1.0.0"
371source_checksum = "sha256:aaa"
372installed_checksum = "sha256:bbb"
373dest_path = "agents/coder.md"
374"#;
375        let lock: LockFile = toml::from_str(toml_str).unwrap();
376        assert_eq!(lock.version, 1);
377        assert_eq!(lock.dependencies.len(), 1);
378        assert_eq!(lock.items.len(), 1);
379
380        let item = &lock.items["agents/coder.md"];
381        assert_eq!(item.source, "base");
382        assert_eq!(item.kind, ItemKind::Agent);
383        assert_eq!(item.source_checksum, "sha256:aaa");
384        assert_eq!(item.installed_checksum, "sha256:bbb");
385    }
386
387    #[test]
388    fn roundtrip_lock_file() {
389        let lock = sample_lock();
390        let serialized = toml::to_string_pretty(&lock).unwrap();
391        let deserialized: LockFile = toml::from_str(&serialized).unwrap();
392        assert_eq!(lock, deserialized);
393    }
394
395    #[test]
396    fn deterministic_serialization() {
397        let lock = sample_lock();
398        let s1 = toml::to_string_pretty(&lock).unwrap();
399        let s2 = toml::to_string_pretty(&lock).unwrap();
400        assert_eq!(s1, s2);
401
402        // Verify key ordering is preserved (agents/coder.md before skills/review)
403        let coder_pos = s1.find("agents/coder.md").unwrap();
404        let review_pos = s1.find("skills/review").unwrap();
405        assert!(
406            coder_pos < review_pos,
407            "keys should preserve insertion order"
408        );
409    }
410
411    #[test]
412    fn empty_lock_file() {
413        let lock = LockFile::empty();
414        assert_eq!(lock.version, 1);
415        assert!(lock.dependencies.is_empty());
416        assert!(lock.items.is_empty());
417
418        // Roundtrip empty
419        let serialized = toml::to_string_pretty(&lock).unwrap();
420        let deserialized: LockFile = toml::from_str(&serialized).unwrap();
421        assert_eq!(lock, deserialized);
422    }
423
424    #[test]
425    fn load_absent_returns_empty() {
426        let dir = TempDir::new().unwrap();
427        let lock = load(dir.path()).unwrap();
428        assert_eq!(lock.version, 1);
429        assert!(lock.dependencies.is_empty());
430        assert!(lock.items.is_empty());
431    }
432
433    #[test]
434    fn write_and_reload() {
435        let dir = TempDir::new().unwrap();
436        let lock = sample_lock();
437        write(dir.path(), &lock).unwrap();
438        let reloaded = load(dir.path()).unwrap();
439        assert_eq!(lock, reloaded);
440    }
441
442    #[test]
443    fn dual_checksums_present() {
444        let lock = sample_lock();
445        let item = &lock.items["agents/coder.md"];
446        assert_ne!(item.source_checksum, item.installed_checksum);
447        assert!(item.source_checksum.starts_with("sha256:"));
448        assert!(item.installed_checksum.starts_with("sha256:"));
449    }
450
451    #[test]
452    fn path_source_in_lock() {
453        let toml_str = r#"
454version = 1
455
456[dependencies.local]
457path = "/home/dev/agents"
458
459[items."agents/helper.md"]
460source = "local"
461kind = "agent"
462source_checksum = "sha256:111"
463installed_checksum = "sha256:222"
464dest_path = "agents/helper.md"
465"#;
466        let lock: LockFile = toml::from_str(toml_str).unwrap();
467        let source = &lock.dependencies["local"];
468        assert!(source.url.is_none());
469        assert_eq!(source.path.as_deref(), Some("/home/dev/agents"));
470        assert!(source.commit.is_none());
471    }
472
473    #[test]
474    fn item_kind_serializes_lowercase() {
475        let item = LockedItem {
476            source: "base".into(),
477            kind: ItemKind::Skill,
478            version: None,
479            source_checksum: "sha256:aaa".into(),
480            installed_checksum: "sha256:bbb".into(),
481            dest_path: "skills/review".into(),
482        };
483        let serialized = toml::to_string(&item).unwrap();
484        assert!(serialized.contains("kind = \"skill\""));
485    }
486
487    #[test]
488    fn item_id_display() {
489        let id = ItemId {
490            kind: ItemKind::Agent,
491            name: "coder".into(),
492        };
493        assert_eq!(id.to_string(), "agent/coder");
494    }
495
496    #[test]
497    fn item_kind_display() {
498        assert_eq!(ItemKind::Agent.to_string(), "agent");
499        assert_eq!(ItemKind::Skill.to_string(), "skill");
500    }
501
502    #[test]
503    fn build_uses_graph_provenance_for_sources() {
504        let git_name: SourceName = "base".into();
505        let path_name: SourceName = "local".into();
506        let git_url: SourceUrl = "https://example.com/new.git".into();
507        let path_canonical = PathBuf::from("/tmp/mars-agents-local-source");
508
509        let mut nodes = IndexMap::new();
510        nodes.insert(
511            git_name.clone(),
512            ResolvedNode {
513                source_name: git_name.clone(),
514                source_id: SourceId::git(git_url.clone()),
515                resolved_ref: ResolvedRef {
516                    source_name: git_name.clone(),
517                    version: Some(semver::Version::new(1, 2, 3)),
518                    version_tag: Some("v1.2.3".into()),
519                    commit: Some("abc123".into()),
520                    tree_path: PathBuf::from("/tmp/cache/base"),
521                },
522                manifest: None,
523                deps: vec![],
524            },
525        );
526        nodes.insert(
527            path_name.clone(),
528            ResolvedNode {
529                source_name: path_name.clone(),
530                source_id: SourceId::Path {
531                    canonical: path_canonical.clone(),
532                },
533                resolved_ref: ResolvedRef {
534                    source_name: path_name.clone(),
535                    version: None,
536                    version_tag: None,
537                    commit: None,
538                    tree_path: PathBuf::from("/tmp/cache/local"),
539                },
540                manifest: None,
541                deps: vec![],
542            },
543        );
544
545        let graph = ResolvedGraph {
546            nodes,
547            order: vec![git_name.clone(), path_name.clone()],
548            id_index: HashMap::new(),
549        };
550        let applied = ApplyResult { outcomes: vec![] };
551
552        let mut old_sources = IndexMap::new();
553        old_sources.insert(
554            git_name.clone(),
555            LockedSource {
556                url: Some("https://example.com/old.git".into()),
557                path: None,
558                version: Some("v0.0.1".into()),
559                commit: Some("deadbeef".into()),
560                tree_hash: None,
561            },
562        );
563        let old_lock = LockFile {
564            version: 1,
565            dependencies: old_sources,
566            items: IndexMap::new(),
567        };
568
569        let new_lock = build(&graph, &applied, &old_lock, None).unwrap();
570
571        let base = &new_lock.dependencies["base"];
572        assert_eq!(base.url.as_ref(), Some(&git_url));
573        assert_eq!(base.version.as_deref(), Some("v1.2.3"));
574        assert_eq!(base.commit.as_deref(), Some("abc123"));
575
576        let local = &new_lock.dependencies["local"];
577        assert!(local.url.is_none());
578        assert_eq!(
579            local.path.as_deref(),
580            Some(path_canonical.to_string_lossy().as_ref())
581        );
582    }
583
584    #[test]
585    fn build_keeps_self_items_even_without_symlink_actions() {
586        let graph = ResolvedGraph {
587            nodes: IndexMap::new(),
588            order: Vec::new(),
589            id_index: HashMap::new(),
590        };
591        let applied = ApplyResult { outcomes: vec![] };
592        let old_lock = LockFile::empty();
593        let self_items = vec![SelfLockItem {
594            dest_path: "skills/local-skill".into(),
595            kind: ItemKind::Skill,
596            source_checksum: "sha256:self".into(),
597        }];
598
599        let new_lock = build(&graph, &applied, &old_lock, Some(&self_items)).unwrap();
600
601        assert!(new_lock.dependencies.contains_key("_self"));
602        let item = &new_lock.items["skills/local-skill"];
603        assert_eq!(item.source, "_self");
604        assert_eq!(item.kind, ItemKind::Skill);
605        assert_eq!(item.source_checksum, "sha256:self");
606        assert_eq!(item.installed_checksum, "sha256:self");
607    }
608}