kimun_notes/settings/
workspace_config.rs1use 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}
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 #[serde(skip)]
68 pub resolved_path: Option<PathBuf>,
69}
70
71impl WorkspaceEntry {
72 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 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 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 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}