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}
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 #[serde(skip)]
59 pub resolved_path: Option<PathBuf>,
60}
61
62impl WorkspaceEntry {
63 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 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 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 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}