Skip to main content

clawft_types/
workspace.rs

1//! Workspace types for the global workspace registry.
2//!
3//! The registry lives at `~/.clawft/workspaces.json` and tracks
4//! all known workspaces by name and filesystem path.
5
6use std::path::{Path, PathBuf};
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11/// Entry in the global workspace registry (`~/.clawft/workspaces.json`).
12///
13/// Timestamps are stored as `DateTime<Utc>`. For backward compatibility with
14/// registries that stored ISO 8601 strings, the custom deserializer accepts
15/// both RFC 3339 strings and native `DateTime<Utc>` JSON representations.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct WorkspaceEntry {
18    /// Human-readable workspace name (unique within registry).
19    pub name: String,
20
21    /// Absolute path to the workspace root directory.
22    pub path: PathBuf,
23
24    /// UTC timestamp of the last time this workspace was accessed.
25    #[serde(default, deserialize_with = "deserialize_optional_datetime")]
26    pub last_accessed: Option<DateTime<Utc>>,
27
28    /// UTC timestamp of when the workspace was first created.
29    #[serde(default, deserialize_with = "deserialize_optional_datetime")]
30    pub created_at: Option<DateTime<Utc>>,
31}
32
33/// Deserialize an `Option<DateTime<Utc>>` that accepts both:
34/// - A native chrono `DateTime<Utc>` JSON value (RFC 3339 string from chrono's Serialize)
35/// - A plain ISO 8601 / RFC 3339 string (legacy format)
36/// - `null` / missing field -> `None`
37fn deserialize_optional_datetime<'de, D>(
38    deserializer: D,
39) -> Result<Option<DateTime<Utc>>, D::Error>
40where
41    D: serde::Deserializer<'de>,
42{
43    let opt: Option<String> = Option::deserialize(deserializer)?;
44    match opt {
45        None => Ok(None),
46        Some(s) => {
47            // Try RFC 3339 parsing (covers both chrono's output and legacy strings).
48            match DateTime::parse_from_rfc3339(&s) {
49                Ok(dt) => Ok(Some(dt.with_timezone(&Utc))),
50                Err(_) => {
51                    // Try a more relaxed ISO 8601 parse as fallback.
52                    match s.parse::<DateTime<Utc>>() {
53                        Ok(dt) => Ok(Some(dt)),
54                        Err(_) => Ok(None), // Gracefully degrade: treat unparseable as missing.
55                    }
56                }
57            }
58        }
59    }
60}
61
62/// Global workspace registry.
63///
64/// Serialized to / deserialized from `~/.clawft/workspaces.json`.
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct WorkspaceRegistry {
67    /// All known workspace entries.
68    #[serde(default)]
69    pub workspaces: Vec<WorkspaceEntry>,
70}
71
72impl WorkspaceRegistry {
73    /// Load the registry from a JSON file, returning `Default` if it does
74    /// not exist.
75    pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
76        if !path.exists() {
77            return Ok(Self::default());
78        }
79        let content = std::fs::read_to_string(path)?;
80        Ok(serde_json::from_str(&content)?)
81    }
82
83    /// Persist the registry to a JSON file, creating parent directories
84    /// as needed.
85    pub fn save(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
86        let dir = path.parent().ok_or("registry path has no parent")?;
87        std::fs::create_dir_all(dir)?;
88        let content = serde_json::to_string_pretty(self)?;
89        std::fs::write(path, content)?;
90        Ok(())
91    }
92
93    /// Find an entry by workspace name.
94    pub fn find_by_name(&self, name: &str) -> Option<&WorkspaceEntry> {
95        self.workspaces.iter().find(|e| e.name == name)
96    }
97
98    /// Find an entry by filesystem path.
99    pub fn find_by_path(&self, path: &Path) -> Option<&WorkspaceEntry> {
100        self.workspaces.iter().find(|e| e.path == path)
101    }
102
103    /// Register a workspace entry.
104    ///
105    /// If an entry with the same name already exists, it is replaced.
106    pub fn register(&mut self, entry: WorkspaceEntry) {
107        self.remove_by_name(&entry.name);
108        self.workspaces.push(entry);
109    }
110
111    /// Remove a workspace entry by name.
112    ///
113    /// Returns `true` if an entry was found and removed.
114    pub fn remove_by_name(&mut self, name: &str) -> bool {
115        let before = self.workspaces.len();
116        self.workspaces.retain(|e| e.name != name);
117        self.workspaces.len() < before
118    }
119}
120
121// ── Registry trait implementation ────────────────────────────────────
122
123impl crate::Registry for WorkspaceRegistry {
124    type Value = WorkspaceEntry;
125
126    fn get(&self, key: &str) -> Option<Self::Value> {
127        self.find_by_name(key).cloned()
128    }
129
130    fn list_keys(&self) -> Vec<String> {
131        self.workspaces.iter().map(|e| e.name.clone()).collect()
132    }
133
134    fn count(&self) -> usize {
135        self.workspaces.len()
136    }
137
138    fn is_empty(&self) -> bool {
139        self.workspaces.is_empty()
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use chrono::TimeZone;
147
148    fn sample_entry(name: &str) -> WorkspaceEntry {
149        WorkspaceEntry {
150            name: name.into(),
151            path: PathBuf::from(format!("/tmp/ws-{name}")),
152            last_accessed: None,
153            created_at: Some(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()),
154        }
155    }
156
157    #[test]
158    fn default_registry_is_empty() {
159        let reg = WorkspaceRegistry::default();
160        assert!(reg.workspaces.is_empty());
161    }
162
163    #[test]
164    fn register_and_find_by_name() {
165        let mut reg = WorkspaceRegistry::default();
166        reg.register(sample_entry("alpha"));
167        assert!(reg.find_by_name("alpha").is_some());
168        assert!(reg.find_by_name("beta").is_none());
169    }
170
171    #[test]
172    fn register_and_find_by_path() {
173        let mut reg = WorkspaceRegistry::default();
174        reg.register(sample_entry("alpha"));
175        assert!(reg.find_by_path(Path::new("/tmp/ws-alpha")).is_some());
176        assert!(reg.find_by_path(Path::new("/tmp/ws-other")).is_none());
177    }
178
179    #[test]
180    fn register_replaces_existing() {
181        let mut reg = WorkspaceRegistry::default();
182        let mut e1 = sample_entry("alpha");
183        e1.path = PathBuf::from("/old");
184        reg.register(e1);
185
186        let mut e2 = sample_entry("alpha");
187        e2.path = PathBuf::from("/new");
188        reg.register(e2);
189
190        assert_eq!(reg.workspaces.len(), 1);
191        assert_eq!(reg.find_by_name("alpha").unwrap().path, Path::new("/new"));
192    }
193
194    #[test]
195    fn remove_by_name_returns_true_when_found() {
196        let mut reg = WorkspaceRegistry::default();
197        reg.register(sample_entry("alpha"));
198        assert!(reg.remove_by_name("alpha"));
199        assert!(reg.workspaces.is_empty());
200    }
201
202    #[test]
203    fn remove_by_name_returns_false_when_not_found() {
204        let mut reg = WorkspaceRegistry::default();
205        assert!(!reg.remove_by_name("missing"));
206    }
207
208    #[test]
209    fn serde_roundtrip() {
210        let mut reg = WorkspaceRegistry::default();
211        reg.register(sample_entry("alpha"));
212        reg.register(sample_entry("beta"));
213
214        let json = serde_json::to_string(&reg).unwrap();
215        let restored: WorkspaceRegistry = serde_json::from_str(&json).unwrap();
216        assert_eq!(restored.workspaces.len(), 2);
217        assert!(restored.find_by_name("alpha").is_some());
218        assert!(restored.find_by_name("beta").is_some());
219    }
220
221    #[test]
222    fn load_returns_default_for_missing_file() {
223        let reg = WorkspaceRegistry::load(Path::new("/nonexistent/workspaces.json")).unwrap();
224        assert!(reg.workspaces.is_empty());
225    }
226
227    #[test]
228    fn load_save_roundtrip() {
229        let dir = std::env::temp_dir().join("clawft-test-ws-registry");
230        let _ = std::fs::remove_dir_all(&dir);
231        let path = dir.join("workspaces.json");
232
233        let mut reg = WorkspaceRegistry::default();
234        reg.register(sample_entry("test-ws"));
235        reg.save(&path).unwrap();
236
237        let loaded = WorkspaceRegistry::load(&path).unwrap();
238        assert_eq!(loaded.workspaces.len(), 1);
239        assert_eq!(loaded.find_by_name("test-ws").unwrap().name, "test-ws");
240
241        // Clean up
242        let _ = std::fs::remove_dir_all(&dir);
243    }
244
245    #[test]
246    fn workspace_entry_optional_fields_default() {
247        let json = r#"{"name": "ws", "path": "/tmp/ws"}"#;
248        let entry: WorkspaceEntry = serde_json::from_str(json).unwrap();
249        assert!(entry.last_accessed.is_none());
250        assert!(entry.created_at.is_none());
251    }
252
253    #[test]
254    fn backward_compat_string_timestamps() {
255        // Legacy format: plain ISO 8601 strings in JSON.
256        let json = r#"{
257            "name": "legacy",
258            "path": "/tmp/legacy",
259            "last_accessed": "2026-01-15T10:30:00Z",
260            "created_at": "2026-01-01T00:00:00Z"
261        }"#;
262        let entry: WorkspaceEntry = serde_json::from_str(json).unwrap();
263        assert!(entry.last_accessed.is_some());
264        assert!(entry.created_at.is_some());
265        let created = entry.created_at.unwrap();
266        assert_eq!(created, Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap());
267    }
268
269    #[test]
270    fn unparseable_timestamp_becomes_none() {
271        let json = r#"{
272            "name": "bad-ts",
273            "path": "/tmp/bad",
274            "created_at": "not-a-date"
275        }"#;
276        let entry: WorkspaceEntry = serde_json::from_str(json).unwrap();
277        assert!(entry.created_at.is_none());
278    }
279}