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 #[serde(default = "default_update_check")]
48 pub update_check: bool,
49 #[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 #[serde(skip)]
79 pub resolved_path: Option<PathBuf>,
80}
81
82impl WorkspaceEntry {
83 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 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 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 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}