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 #[serde(default)]
61 pub kimun_server_url: Option<String>,
62 #[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 #[serde(skip)]
88 pub resolved_path: Option<PathBuf>,
89}
90
91impl WorkspaceEntry {
92 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 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 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 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}