clawft_types/
workspace.rs1use std::path::{Path, PathBuf};
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct WorkspaceEntry {
18 pub name: String,
20
21 pub path: PathBuf,
23
24 #[serde(default, deserialize_with = "deserialize_optional_datetime")]
26 pub last_accessed: Option<DateTime<Utc>>,
27
28 #[serde(default, deserialize_with = "deserialize_optional_datetime")]
30 pub created_at: Option<DateTime<Utc>>,
31}
32
33fn 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 match DateTime::parse_from_rfc3339(&s) {
49 Ok(dt) => Ok(Some(dt.with_timezone(&Utc))),
50 Err(_) => {
51 match s.parse::<DateTime<Utc>>() {
53 Ok(dt) => Ok(Some(dt)),
54 Err(_) => Ok(None), }
56 }
57 }
58 }
59 }
60}
61
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct WorkspaceRegistry {
67 #[serde(default)]
69 pub workspaces: Vec<WorkspaceEntry>,
70}
71
72impl WorkspaceRegistry {
73 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 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 pub fn find_by_name(&self, name: &str) -> Option<&WorkspaceEntry> {
95 self.workspaces.iter().find(|e| e.name == name)
96 }
97
98 pub fn find_by_path(&self, path: &Path) -> Option<&WorkspaceEntry> {
100 self.workspaces.iter().find(|e| e.path == path)
101 }
102
103 pub fn register(&mut self, entry: WorkspaceEntry) {
107 self.remove_by_name(&entry.name);
108 self.workspaces.push(entry);
109 }
110
111 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
121impl 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(®).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 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 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}