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