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