1use chrono::{DateTime, Utc};
2use kimun_core::nfs::filename::{InvalidFilenameError, validate_filename};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone)]
8pub enum WorkspaceConfigError {
9 DuplicateWorkspace {
10 name: String,
11 existing_path: PathBuf,
12 },
13 InvalidName {
14 name: String,
15 error: InvalidFilenameError,
16 },
17}
18
19impl std::fmt::Display for WorkspaceConfigError {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 match self {
22 WorkspaceConfigError::DuplicateWorkspace {
23 name,
24 existing_path,
25 } => {
26 write!(
27 f,
28 "Workspace '{}' already exists at {:?}",
29 name, existing_path
30 )
31 }
32 WorkspaceConfigError::InvalidName { error, .. } => {
33 write!(f, "Workspace {error}")
34 }
35 }
36 }
37}
38
39impl std::error::Error for WorkspaceConfigError {}
40
41#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
42pub struct GlobalConfig {
43 pub current_workspace: String,
44 #[serde(default = "default_update_check")]
48 pub update_check: bool,
49 #[serde(default = "default_mouse")]
55 pub mouse: bool,
56 #[serde(default)]
61 pub kimun_server_url: Option<String>,
62 #[serde(default)]
64 pub kimun_server_token: Option<String>,
65}
66
67fn default_update_check() -> bool {
68 true
69}
70
71fn default_mouse() -> bool {
72 true
73}
74
75#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
76pub struct WorkspaceEntry {
77 pub path: PathBuf,
78 #[serde(default, skip_serializing)]
79 pub last_paths: Vec<String>,
80 pub created: DateTime<Utc>,
81 #[serde(default)]
82 pub quick_note_path: Option<String>,
83 #[serde(default)]
84 pub inbox_path: Option<String>,
85 #[serde(skip)]
88 pub resolved_path: Option<PathBuf>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub file_key: Option<String>,
105}
106
107impl WorkspaceEntry {
108 pub fn file_key_or(&self, name: &str) -> String {
111 self.file_key.clone().unwrap_or_else(|| name.to_string())
112 }
113
114 pub fn effective_path(&self) -> &PathBuf {
116 self.resolved_path.as_ref().unwrap_or(&self.path)
117 }
118
119 pub fn effective_quick_note_path(&self) -> String {
120 self.quick_note_path
121 .clone()
122 .unwrap_or_else(|| kimun_core::nfs::VaultPath::root().to_string())
123 }
124
125 pub fn effective_inbox_path(&self) -> String {
126 self.inbox_path
127 .clone()
128 .unwrap_or_else(|| kimun_core::DEFAULT_INBOX_PATH.to_string())
129 }
130}
131
132#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
133pub struct WorkspaceConfig {
134 pub global: GlobalConfig,
135 pub workspaces: BTreeMap<String, WorkspaceEntry>,
139}
140
141impl WorkspaceConfig {
142 pub fn new_empty() -> Self {
143 Self {
144 global: GlobalConfig {
145 current_workspace: String::new(),
146 update_check: true,
147 mouse: true,
148 kimun_server_url: None,
149 kimun_server_token: None,
150 },
151 workspaces: BTreeMap::new(),
152 }
153 }
154
155 pub fn add_workspace(
156 &mut self,
157 name: String,
158 path: PathBuf,
159 ) -> Result<(), WorkspaceConfigError> {
160 if let Err(error) = validate_filename(&name) {
161 return Err(WorkspaceConfigError::InvalidName {
162 name: name.clone(),
163 error,
164 });
165 }
166 if self.workspaces.contains_key(&name) {
167 return Err(WorkspaceConfigError::DuplicateWorkspace {
168 name: name.clone(),
169 existing_path: self.workspaces[&name].path.clone(),
170 });
171 }
172
173 let created = Utc::now();
174 let entry = WorkspaceEntry {
175 file_key: Some(self.fresh_file_key(&name, &path, created)),
176 path,
177 last_paths: Vec::new(),
178 created,
179 quick_note_path: None,
180 inbox_path: None,
181 resolved_path: None,
182 };
183
184 self.workspaces.insert(name.clone(), entry);
185
186 if !self.workspaces.contains_key(&self.global.current_workspace) {
189 self.global.current_workspace = name.clone();
190 }
191
192 Ok(())
193 }
194
195 fn file_keys_in_use(&self) -> std::collections::HashSet<String> {
197 self.workspaces
198 .iter()
199 .map(|(name, entry)| entry.file_key_or(name))
200 .collect()
201 }
202
203 fn fresh_file_key(&self, name: &str, path: &Path, created: DateTime<Utc>) -> String {
222 use sha2::{Digest, Sha256};
223
224 let taken = self.file_keys_in_use();
225 for salt in 0u32.. {
226 let mut hasher = Sha256::new();
227 for part in [
231 name.as_bytes(),
232 path.to_string_lossy().as_bytes(),
233 created.to_rfc3339().as_bytes(),
234 &salt.to_le_bytes(),
235 ] {
236 hasher.update((part.len() as u64).to_le_bytes());
237 hasher.update(part);
238 }
239 let key: String = hasher.finalize()[..6]
240 .iter()
241 .map(|byte| format!("{byte:02x}"))
242 .collect();
243 if !taken.contains(&key) {
244 return key;
245 }
246 }
247 unreachable!("a 48-bit key space is not exhausted by one config's workspaces")
248 }
249
250 pub fn rename_workspace(&mut self, old_name: &str, new_name: String) -> bool {
264 let Some(mut entry) = self.workspaces.remove(old_name) else {
265 return false;
266 };
267 entry.file_key = Some(entry.file_key_or(old_name));
271 self.workspaces.insert(new_name.clone(), entry);
272 if self.global.current_workspace == old_name {
273 self.global.current_workspace = new_name;
274 }
275 true
276 }
277
278 pub fn get_current_workspace(&self) -> Option<&WorkspaceEntry> {
279 self.workspaces.get(&self.global.current_workspace)
280 }
281
282 pub fn get_workspace(&self, name: &str) -> Option<&WorkspaceEntry> {
283 self.workspaces.get(name)
284 }
285}
286
287#[cfg(test)]
288mod validate_tests {
289 use super::*;
290
291 #[test]
292 fn add_workspace_rejects_disallowed_chars() {
293 let mut wc = WorkspaceConfig::new_empty();
294 let err = wc
295 .add_workspace("bad/name".to_string(), PathBuf::from("/tmp/x"))
296 .unwrap_err();
297 match err {
298 WorkspaceConfigError::InvalidName { name, .. } => assert_eq!(name, "bad/name"),
299 _ => panic!("expected InvalidName"),
300 }
301 }
302
303 #[test]
304 fn add_workspace_rejects_windows_reserved() {
305 let mut wc = WorkspaceConfig::new_empty();
306 assert!(
307 wc.add_workspace("con".to_string(), PathBuf::from("/tmp/x"))
308 .is_err()
309 );
310 }
311
312 #[test]
313 fn add_workspace_accepts_simple_names() {
314 let mut wc = WorkspaceConfig::new_empty();
315 assert!(
316 wc.add_workspace("notes".to_string(), PathBuf::from("/tmp/x"))
317 .is_ok()
318 );
319 }
320
321 #[test]
322 fn add_workspace_sets_current_when_first() {
323 let mut wc = WorkspaceConfig::new_empty();
324 wc.add_workspace("notes".to_string(), PathBuf::from("/tmp/x"))
325 .unwrap();
326 assert_eq!(wc.global.current_workspace, "notes");
327 }
328
329 #[test]
330 fn add_workspace_keeps_valid_current() {
331 let mut wc = WorkspaceConfig::new_empty();
332 wc.add_workspace("first".to_string(), PathBuf::from("/tmp/a"))
333 .unwrap();
334 wc.add_workspace("second".to_string(), PathBuf::from("/tmp/b"))
335 .unwrap();
336 assert_eq!(wc.global.current_workspace, "first");
337 }
338
339 #[test]
342 fn a_new_workspace_gets_an_opaque_file_key() {
343 let mut wc = WorkspaceConfig::new_empty();
344 wc.add_workspace("work".to_string(), PathBuf::from("/tmp/a"))
345 .unwrap();
346
347 let key = wc.workspaces["work"].file_key_or("work");
348 assert_ne!(key, "work", "the name must not reach the filename");
349 assert_eq!(key.len(), 12, "twelve hex characters: {key}");
350 assert!(
351 key.chars()
352 .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
353 "must be a plain lowercase hex key, got {key}"
354 );
355 }
356
357 #[test]
360 fn rename_keeps_the_file_key() {
361 let mut wc = WorkspaceConfig::new_empty();
362 wc.add_workspace("work".to_string(), PathBuf::from("/tmp/a"))
363 .unwrap();
364 let before = wc.workspaces["work"].file_key_or("work");
365
366 assert!(wc.rename_workspace("work", "job".to_string()));
367
368 assert_eq!(wc.workspaces["job"].file_key_or("job"), before);
369 assert_eq!(wc.global.current_workspace, "job");
370 assert!(!wc.workspaces.contains_key("work"));
371 }
372
373 #[test]
375 fn renaming_twice_keeps_the_file_key() {
376 let mut wc = WorkspaceConfig::new_empty();
377 wc.add_workspace("first".to_string(), PathBuf::from("/tmp/a"))
378 .unwrap();
379 let before = wc.workspaces["first"].file_key_or("first");
380
381 assert!(wc.rename_workspace("first", "second".to_string()));
382 assert!(wc.rename_workspace("second", "third".to_string()));
383
384 assert_eq!(wc.workspaces["third"].file_key_or("third"), before);
385 }
386
387 #[test]
390 fn a_legacy_entry_without_a_file_key_still_uses_its_name() {
391 let mut wc = WorkspaceConfig::new_empty();
392 wc.add_workspace("work".to_string(), PathBuf::from("/tmp/a"))
393 .unwrap();
394 wc.workspaces.get_mut("work").unwrap().file_key = None;
395
396 assert_eq!(wc.workspaces["work"].file_key_or("work"), "work");
397
398 assert!(wc.rename_workspace("work", "job".to_string()));
400 assert_eq!(wc.workspaces["job"].file_key.as_deref(), Some("work"));
401 }
402
403 #[test]
406 fn rename_leaves_an_unrelated_current_alone() {
407 let mut wc = WorkspaceConfig::new_empty();
408 wc.add_workspace("first".to_string(), PathBuf::from("/tmp/a"))
409 .unwrap();
410 wc.add_workspace("second".to_string(), PathBuf::from("/tmp/b"))
411 .unwrap();
412 assert_eq!(wc.global.current_workspace, "first");
413
414 assert!(wc.rename_workspace("second", "renamed".to_string()));
415
416 assert_eq!(wc.global.current_workspace, "first");
417 }
418
419 #[test]
423 fn workspaces_never_share_a_file_key() {
424 let mut wc = WorkspaceConfig::new_empty();
425 wc.add_workspace("work".to_string(), PathBuf::from("/tmp/a"))
426 .unwrap();
427 wc.rename_workspace("work", "first".to_string());
428 wc.add_workspace("work".to_string(), PathBuf::from("/tmp/b"))
429 .unwrap();
430 wc.rename_workspace("work", "second".to_string());
431 wc.add_workspace("work".to_string(), PathBuf::from("/tmp/c"))
432 .unwrap();
433
434 let keys: Vec<String> = wc
435 .workspaces
436 .iter()
437 .map(|(name, entry)| entry.file_key_or(name))
438 .collect();
439 let unique: std::collections::HashSet<_> = keys.iter().collect();
440 assert_eq!(unique.len(), keys.len(), "duplicate file keys: {keys:?}");
441 assert_eq!(keys.len(), 3);
442 }
443
444 #[test]
447 fn a_fresh_key_avoids_one_already_taken() {
448 let mut wc = WorkspaceConfig::new_empty();
449 wc.add_workspace("work".to_string(), PathBuf::from("/tmp/a"))
450 .unwrap();
451 let colliding = wc.fresh_file_key("other", Path::new("/tmp/b"), Utc::now());
453 wc.workspaces.get_mut("work").unwrap().file_key = Some(colliding.clone());
454
455 let next = wc.fresh_file_key("other", Path::new("/tmp/b"), Utc::now());
456
457 assert_ne!(next, colliding);
458 }
459
460 #[test]
461 fn renaming_a_missing_workspace_reports_it() {
462 let mut wc = WorkspaceConfig::new_empty();
463 assert!(!wc.rename_workspace("nope", "other".to_string()));
464 assert!(wc.workspaces.is_empty());
465 }
466
467 #[test]
468 fn add_workspace_repairs_dangling_current() {
469 let mut wc = WorkspaceConfig::new_empty();
473 wc.add_workspace("other".to_string(), PathBuf::from("/tmp/a"))
474 .unwrap();
475 wc.global.current_workspace = String::new();
476 wc.add_workspace("fresh".to_string(), PathBuf::from("/tmp/b"))
477 .unwrap();
478 assert_eq!(wc.global.current_workspace, "fresh");
479 }
480}