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