Skip to main content

kimun_notes/settings/
workspace_config.rs

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    /// Whether kimün may contact GitHub to check for a newer release. User-owned
45    /// (toggled in onboarding and preferences); defaults on. All machine-managed
46    /// update state lives separately in `update_state.toml`, never here.
47    #[serde(default = "default_update_check")]
48    pub update_check: bool,
49    /// Whether kimün captures the mouse for in-app use (divider drag, list
50    /// scroll, click-to-focus). When off, the mouse is left to the terminal so
51    /// its native selection and middle-click paste work; mouse reporting is
52    /// all-or-nothing, so there is no per-button middle ground.
53    /// Read only at startup. Defaults on (today's behavior).
54    #[serde(default = "default_mouse")]
55    pub mouse: bool,
56    /// Base URL of the optional RAG server (e.g. `http://localhost:7573`). When
57    /// set and reachable, kimün enables semantic search and Q&A.
58    /// Global (one server serves many vaults, each as its own collection);
59    /// `None` means the feature is off.
60    #[serde(default)]
61    pub kimun_server_url: Option<String>,
62    /// Bearer token for the RAG server, when it requires one.
63    #[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    /// Absolute resolved path for runtime use. Not serialized — `path` is
86    /// written to disk as the user configured it (relative, ~/..., or absolute).
87    #[serde(skip)]
88    pub resolved_path: Option<PathBuf>,
89    /// What this workspace's index and history files are named after.
90    ///
91    /// A short opaque key — twelve hex characters, minted at creation — for
92    /// anything a current kimün made, deliberately *not* the workspace's name:
93    /// a name is a label the user owns and can change, and letting it reach a
94    /// filename is what forced a rename to move an open SQLite database, which
95    /// Windows refuses while any handle is on it.
96    ///
97    /// `None` means "my name", and exists only for configs written before this
98    /// field did. Those workspaces keep resolving to `work.kimuncache` exactly
99    /// as they always have, so upgrading costs no migration and no reindex;
100    /// [`rename_workspace`] pins the name in before it can change.
101    ///
102    /// [`rename_workspace`]: WorkspaceConfig::rename_workspace
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub file_key: Option<String>,
105}
106
107impl WorkspaceEntry {
108    /// What this workspace's files are named after: [`Self::file_key`] once
109    /// pinned, otherwise the name it is filed under.
110    pub fn file_key_or(&self, name: &str) -> String {
111        self.file_key.clone().unwrap_or_else(|| name.to_string())
112    }
113
114    /// Returns the resolved absolute path if available, otherwise the original path.
115    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    /// Keyed by workspace name. `BTreeMap` (not `HashMap`) so serialization
136    /// order is deterministic — otherwise every config save reshuffles the
137    /// `[workspaces.*]` sections in the TOML file.
138    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        // Set as current if there is no valid current workspace (first
187        // workspace, or the previous current was removed/cleared)
188        if !self.workspaces.contains_key(&self.global.current_workspace) {
189            self.global.current_workspace = name.clone();
190        }
191
192        Ok(())
193    }
194
195    /// Every file key currently spoken for.
196    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    /// A short, unused key for a new workspace's index and history files.
204    ///
205    /// Not the workspace's name. A name is a label the user owns — it can be
206    /// renamed, freed and handed to a different workspace — and none of that
207    /// should reach a file on disk. Naming files after it made a rename move
208    /// an open SQLite database, and then made a reused name collide with the
209    /// files of the workspace that had been renamed away from it.
210    ///
211    /// Derived rather than random, so a config entry always explains its own
212    /// filename: SHA-256 over the name, path and creation instant, truncated
213    /// to twelve hex characters. The instant is in there so that removing a
214    /// workspace and recreating it identically does not land on the previous
215    /// key — and so inherit an index whose delete quietly failed.
216    ///
217    /// Twelve hex characters is 48 bits, far past what a handful of workspaces
218    /// needs, but the collision check is here regardless: `salt` bumps until
219    /// the key is free, which also covers the case of a key that survives in
220    /// the config from an older kimün.
221    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            // Length-delimited, so ("ab", "c") and ("a", "bc") cannot hash the
228            // same — a path and a name are both attacker-free here, but the
229            // habit costs nothing.
230            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    /// Re-files the workspace at `old_name` under `new_name`, pinning what its
251    /// index and history files are called so neither has to move.
252    ///
253    /// This is the whole rename: no file on disk is touched. Renaming used to
254    /// move `<name>.kimuncache` — a SQLite database — and Windows refuses to
255    /// move a file while any handle is on it, which cost a workspace its index
256    /// on roughly one run in three. Pinning [`WorkspaceEntry::file_key`] to the
257    /// name the files were created under removes the move rather than
258    /// retrying it.
259    ///
260    /// Returns `false` if `old_name` is not there. The caller is responsible
261    /// for validating `new_name` and for rejecting a collision — this only
262    /// re-files.
263    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        // Only on the first rename: after that the key is already pinned to
268        // whatever the files are actually called, and must not drift to the
269        // intermediate name.
270        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    /// A new workspace's files are named after an opaque key, never after the
340    /// workspace — the name is the user's to change, and a filename is not.
341    #[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    /// A rename does not change what the files are called, so nothing on disk
358    /// has to move and a later lookup still finds the index that exists.
359    #[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    /// Two renames, still the same files.
374    #[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    /// A config written before `file_key` existed has none, and must keep
388    /// resolving to its name — upgrading kimün must not orphan an index.
389    #[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        // And renaming pins it before it can drift.
399        assert!(wc.rename_workspace("work", "job".to_string()));
400        assert_eq!(wc.workspaces["job"].file_key.as_deref(), Some("work"));
401    }
402
403    /// Renaming a workspace that is not the current one must not steal the
404    /// current pointer.
405    #[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    /// Reusing a name that a rename freed must not hand the newcomer the
420    /// renamed workspace's files — two workspaces on one SQLite index would
421    /// each reindex over the other's notes.
422    #[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    /// The collision check covers a legacy name-shaped key too, not just the
445    /// hashes it mints itself.
446    #[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        // Force the next mint to land on a taken key by claiming it first.
452        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        // After clear_workspace the current entry is removed but other
470        // workspaces remain; the next add must become current or the
471        // app can never activate a workspace again.
472        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}