Skip to main content

loom_core/manifest/
sync.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4/// Sync status for a workspace.
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "lowercase")]
7pub enum SyncStatus {
8    Active,
9    Partial,
10    Closed,
11}
12
13/// Per-workspace sync manifest, stored in the sync repo at `loom/{name}.json`.
14///
15/// Contains enough information to reconstruct the workspace on another machine.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct SyncManifest {
19    pub name: String,
20    pub created: DateTime<Utc>,
21    #[serde(default = "default_sync_status")]
22    pub status: SyncStatus,
23    /// The workspace branch name (e.g., `loom/bold-cedar-hawk`).
24    /// Added after random branch naming was introduced; older manifests
25    /// won't have this field, so it defaults to `None` for backward compatibility.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub branch: Option<String>,
28    #[serde(default)]
29    pub repos: Vec<SyncRepoEntry>,
30}
31
32/// Minimal repo info needed to reconstruct a worktree on another machine.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct SyncRepoEntry {
36    pub name: String,
37    pub remote_url: String,
38    pub branch: String,
39}
40
41fn default_sync_status() -> SyncStatus {
42    SyncStatus::Active
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48
49    #[test]
50    fn test_sync_manifest_round_trip() {
51        let manifest = SyncManifest {
52            name: "my-feature".to_string(),
53            created: Utc::now(),
54            status: SyncStatus::Active,
55            branch: Some("loom/bold-cedar-hawk".to_string()),
56            repos: vec![SyncRepoEntry {
57                name: "dsp-api".to_string(),
58                remote_url: "git@github.com:dasch-swiss/dsp-api.git".to_string(),
59                branch: "loom/my-feature".to_string(),
60            }],
61        };
62
63        let json = serde_json::to_string_pretty(&manifest).unwrap();
64        let parsed: SyncManifest = serde_json::from_str(&json).unwrap();
65
66        assert_eq!(parsed.name, "my-feature");
67        assert_eq!(parsed.status, SyncStatus::Active);
68        assert_eq!(parsed.branch, Some("loom/bold-cedar-hawk".to_string()));
69        assert_eq!(parsed.repos.len(), 1);
70    }
71
72    #[test]
73    fn test_sync_status_serialization() {
74        assert_eq!(
75            serde_json::to_string(&SyncStatus::Active).unwrap(),
76            "\"active\""
77        );
78        assert_eq!(
79            serde_json::to_string(&SyncStatus::Partial).unwrap(),
80            "\"partial\""
81        );
82        assert_eq!(
83            serde_json::to_string(&SyncStatus::Closed).unwrap(),
84            "\"closed\""
85        );
86    }
87
88    #[test]
89    fn test_sync_manifest_defaults() {
90        let json = r#"{
91            "name": "minimal",
92            "created": "2026-01-15T10:00:00Z"
93        }"#;
94
95        let manifest: SyncManifest = serde_json::from_str(json).unwrap();
96        assert_eq!(manifest.status, SyncStatus::Active);
97        assert!(manifest.branch.is_none());
98        assert!(manifest.repos.is_empty());
99    }
100}