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