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::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}
90
91impl WorkspaceEntry {
92    /// Returns the resolved absolute path if available, otherwise the original path.
93    pub fn effective_path(&self) -> &PathBuf {
94        self.resolved_path.as_ref().unwrap_or(&self.path)
95    }
96
97    pub fn effective_quick_note_path(&self) -> String {
98        self.quick_note_path
99            .clone()
100            .unwrap_or_else(|| kimun_core::nfs::VaultPath::root().to_string())
101    }
102
103    pub fn effective_inbox_path(&self) -> String {
104        self.inbox_path
105            .clone()
106            .unwrap_or_else(|| kimun_core::DEFAULT_INBOX_PATH.to_string())
107    }
108}
109
110#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
111pub struct WorkspaceConfig {
112    pub global: GlobalConfig,
113    /// Keyed by workspace name. `BTreeMap` (not `HashMap`) so serialization
114    /// order is deterministic — otherwise every config save reshuffles the
115    /// `[workspaces.*]` sections in the TOML file.
116    pub workspaces: BTreeMap<String, WorkspaceEntry>,
117}
118
119impl WorkspaceConfig {
120    pub fn new_empty() -> Self {
121        Self {
122            global: GlobalConfig {
123                current_workspace: String::new(),
124                update_check: true,
125                mouse: true,
126                kimun_server_url: None,
127                kimun_server_token: None,
128            },
129            workspaces: BTreeMap::new(),
130        }
131    }
132
133    pub fn add_workspace(
134        &mut self,
135        name: String,
136        path: PathBuf,
137    ) -> Result<(), WorkspaceConfigError> {
138        if let Err(error) = validate_filename(&name) {
139            return Err(WorkspaceConfigError::InvalidName {
140                name: name.clone(),
141                error,
142            });
143        }
144        if self.workspaces.contains_key(&name) {
145            return Err(WorkspaceConfigError::DuplicateWorkspace {
146                name: name.clone(),
147                existing_path: self.workspaces[&name].path.clone(),
148            });
149        }
150
151        let entry = WorkspaceEntry {
152            path,
153            last_paths: Vec::new(),
154            created: Utc::now(),
155            quick_note_path: None,
156            inbox_path: None,
157            resolved_path: None,
158        };
159
160        self.workspaces.insert(name.clone(), entry);
161
162        // Set as current if there is no valid current workspace (first
163        // workspace, or the previous current was removed/cleared)
164        if !self.workspaces.contains_key(&self.global.current_workspace) {
165            self.global.current_workspace = name.clone();
166        }
167
168        Ok(())
169    }
170
171    pub fn get_current_workspace(&self) -> Option<&WorkspaceEntry> {
172        self.workspaces.get(&self.global.current_workspace)
173    }
174
175    pub fn get_workspace(&self, name: &str) -> Option<&WorkspaceEntry> {
176        self.workspaces.get(name)
177    }
178
179    pub fn from_phase1_migration(workspace_dir: PathBuf, last_paths: Vec<String>) -> Self {
180        let mut config = Self::new_empty();
181
182        let entry = WorkspaceEntry {
183            path: workspace_dir,
184            last_paths,
185            created: Utc::now(),
186            quick_note_path: None,
187            inbox_path: None,
188            resolved_path: None,
189        };
190
191        config.workspaces.insert("default".to_string(), entry);
192        config.global.current_workspace = "default".to_string();
193
194        config
195    }
196}
197
198#[cfg(test)]
199mod validate_tests {
200    use super::*;
201
202    #[test]
203    fn add_workspace_rejects_disallowed_chars() {
204        let mut wc = WorkspaceConfig::new_empty();
205        let err = wc
206            .add_workspace("bad/name".to_string(), PathBuf::from("/tmp/x"))
207            .unwrap_err();
208        match err {
209            WorkspaceConfigError::InvalidName { name, .. } => assert_eq!(name, "bad/name"),
210            _ => panic!("expected InvalidName"),
211        }
212    }
213
214    #[test]
215    fn add_workspace_rejects_windows_reserved() {
216        let mut wc = WorkspaceConfig::new_empty();
217        assert!(
218            wc.add_workspace("con".to_string(), PathBuf::from("/tmp/x"))
219                .is_err()
220        );
221    }
222
223    #[test]
224    fn add_workspace_accepts_simple_names() {
225        let mut wc = WorkspaceConfig::new_empty();
226        assert!(
227            wc.add_workspace("notes".to_string(), PathBuf::from("/tmp/x"))
228                .is_ok()
229        );
230    }
231
232    #[test]
233    fn add_workspace_sets_current_when_first() {
234        let mut wc = WorkspaceConfig::new_empty();
235        wc.add_workspace("notes".to_string(), PathBuf::from("/tmp/x"))
236            .unwrap();
237        assert_eq!(wc.global.current_workspace, "notes");
238    }
239
240    #[test]
241    fn add_workspace_keeps_valid_current() {
242        let mut wc = WorkspaceConfig::new_empty();
243        wc.add_workspace("first".to_string(), PathBuf::from("/tmp/a"))
244            .unwrap();
245        wc.add_workspace("second".to_string(), PathBuf::from("/tmp/b"))
246            .unwrap();
247        assert_eq!(wc.global.current_workspace, "first");
248    }
249
250    #[test]
251    fn add_workspace_repairs_dangling_current() {
252        // After clear_workspace the current entry is removed but other
253        // workspaces remain; the next add must become current or the
254        // app can never activate a workspace again.
255        let mut wc = WorkspaceConfig::new_empty();
256        wc.add_workspace("other".to_string(), PathBuf::from("/tmp/a"))
257            .unwrap();
258        wc.global.current_workspace = String::new();
259        wc.add_workspace("fresh".to_string(), PathBuf::from("/tmp/b"))
260            .unwrap();
261        assert_eq!(wc.global.current_workspace, "fresh");
262    }
263}