kimun_notes/cli/commands/
workspace.rs1use std::path::PathBuf;
6
7use clap::Subcommand;
8use color_eyre::eyre::{Result, eyre};
9use kimun_core::error::VaultError;
10use kimun_core::{NoteVault, SystemPath, VaultConfig};
11
12use kimun_core::system;
13
14use crate::settings::{
15 AppSettings, config_migration::CURRENT_CONFIG_VERSION, workspace_config::WorkspaceConfig,
16};
17
18#[derive(Subcommand, Debug)]
19pub enum WorkspaceSubcommand {
20 Init {
22 #[arg(long)]
24 name: Option<String>,
25 path: PathBuf,
27 },
28 List,
30 Use {
32 name: String,
34 },
35 Rename {
37 old_name: String,
39 new_name: String,
41 },
42 Remove {
44 name: String,
46 },
47 Reindex {
49 #[arg(long)]
51 name: Option<String>,
52 },
53}
54
55pub async fn run(subcommand: WorkspaceSubcommand, settings: &mut AppSettings) -> Result<()> {
56 match subcommand {
57 WorkspaceSubcommand::Init { name, path } => run_init(settings, name, path).await,
58 WorkspaceSubcommand::List => run_list(settings),
59 WorkspaceSubcommand::Use { name } => run_use(settings, name),
60 WorkspaceSubcommand::Rename { old_name, new_name } => {
61 run_rename(settings, old_name, new_name)
62 }
63 WorkspaceSubcommand::Remove { name } => run_remove(settings, name),
64 WorkspaceSubcommand::Reindex { name } => run_reindex(settings, name).await,
65 }
66}
67
68async fn run_init(settings: &mut AppSettings, name: Option<String>, path: PathBuf) -> Result<()> {
69 if settings.workspace_config.is_none() {
71 settings.workspace_config = Some(WorkspaceConfig::new_empty());
72 }
73
74 let ws_config = settings
75 .workspace_config
76 .as_ref()
77 .expect("workspace_config must exist after init");
78
79 let workspace_name = match name {
83 Some(n) => n.to_lowercase(),
84 None => {
85 if ws_config.workspaces.is_empty() {
86 "default".to_string()
87 } else {
88 return Err(eyre!(
89 "A workspace name is required when other workspaces already exist. \
90 Use: kimun workspace init --name <name> <path>"
91 ));
92 }
93 }
94 };
95
96 kimun_core::nfs::filename::validate_filename(&workspace_name).map_err(|e| eyre!("{}", e))?;
103
104 if ws_config.workspaces.contains_key(&workspace_name) {
105 let existing_path = &ws_config.workspaces[&workspace_name].path;
106 return Err(eyre!(
107 "Workspace '{}' already exists at {}. \
108 Use a different name or remove the existing workspace first.",
109 workspace_name,
110 existing_path.display()
111 ));
112 }
113
114 let created = !path.exists();
116 let canonical_path = system::create_dir(&path).map_err(|e| {
117 eyre!(
118 "Failed to create workspace directory {}: {}",
119 path.display(),
120 e
121 )
122 })?;
123 if created {
124 println!("Created directory: {}", path.display());
125 }
126
127 let ws_config_mut = settings
134 .workspace_config
135 .as_mut()
136 .expect("workspace_config must exist after init");
137 ws_config_mut
138 .add_workspace(
139 workspace_name.clone(),
140 canonical_path.clone().into_path_buf(),
141 )
142 .map_err(|e| eyre!("{}", e))?;
143
144 println!("Initializing workspace database...");
145 let cache_path = settings.index_for(&workspace_name);
146 let vault = NoteVault::new(VaultConfig::new(canonical_path.clone()).with_index(cache_path))
147 .await
148 .map_err(|e| eyre!("Failed to create vault at {}: {}", canonical_path, e))?;
149 let init_result = vault.validate_and_init().await;
150 vault.close().await;
158 init_result.map_err(|e| eyre!("Failed to initialize vault database: {}", e))?;
159
160 settings.config_version = CURRENT_CONFIG_VERSION;
161 settings.save_to_disk()?;
162
163 println!(
164 "Workspace '{}' initialized at {}",
165 workspace_name, canonical_path
166 );
167
168 let ws_config = settings
169 .workspace_config
170 .as_ref()
171 .expect("workspace_config must exist after init");
172 if ws_config.global.current_workspace == workspace_name {
173 println!("Set as current workspace.");
174 }
175
176 Ok(())
177}
178
179fn run_list(settings: &AppSettings) -> Result<()> {
180 match &settings.workspace_config {
181 None => {
182 println!("No workspaces configured. Run 'kimun workspace init <path>' to create one.");
183 }
184 Some(ws_config) => {
185 if ws_config.workspaces.is_empty() {
186 println!(
187 "No workspaces configured. Run 'kimun workspace init <path>' to create one."
188 );
189 } else {
190 println!("Configured workspaces:");
191 let mut names: Vec<&String> = ws_config.workspaces.keys().collect();
192 names.sort();
193 for name in names {
194 let entry = &ws_config.workspaces[name];
195 let marker = if name == &ws_config.global.current_workspace {
196 "* "
197 } else {
198 " "
199 };
200 println!("{}{} ({})", marker, name, entry.path.display());
201 }
202 }
203 }
204 }
205 Ok(())
206}
207
208fn run_use(settings: &mut AppSettings, name: String) -> Result<()> {
209 let ws_config = settings
210 .workspace_config
211 .as_ref()
212 .ok_or_else(|| eyre!("No workspaces configured."))?;
213
214 let entry = ws_config.get_workspace(&name).ok_or_else(|| {
215 let available: Vec<&String> = ws_config.workspaces.keys().collect();
216 eyre!(
217 "Workspace '{}' not found. Available workspaces: {}",
218 name,
219 available
220 .iter()
221 .map(|s| s.as_str())
222 .collect::<Vec<_>>()
223 .join(", ")
224 )
225 })?;
226
227 if !entry.effective_path().exists() {
229 return Err(eyre!(
230 "Workspace '{}' path no longer exists: {}. \
231 Update the path or remove this workspace.",
232 name,
233 entry.effective_path().display()
234 ));
235 }
236
237 settings
238 .workspace_config
239 .as_mut()
240 .expect("workspace_config must exist")
241 .global
242 .current_workspace = name.clone();
243 settings.save_to_disk()?;
244
245 println!("Switched to workspace '{}'.", name);
246 Ok(())
247}
248
249fn run_rename(settings: &mut AppSettings, old_name: String, new_name: String) -> Result<()> {
250 let new_name = new_name.to_lowercase();
251 kimun_core::nfs::filename::validate_filename(&new_name).map_err(|e| eyre!("{}", e))?;
252
253 let ws_config = settings
254 .workspace_config
255 .as_ref()
256 .ok_or_else(|| eyre!("No workspaces configured."))?;
257
258 if !ws_config.workspaces.contains_key(&old_name) {
259 return Err(eyre!("Workspace '{}' not found.", old_name));
260 }
261
262 if ws_config.workspaces.contains_key(&new_name) {
263 return Err(eyre!(
264 "Workspace '{}' already exists. Choose a different name.",
265 new_name
266 ));
267 }
268
269 let ws_config_mut = settings
275 .workspace_config
276 .as_mut()
277 .expect("workspace_config must exist after init");
278 let renamed = ws_config_mut.rename_workspace(&old_name, new_name.clone());
279 debug_assert!(renamed, "entry existence was checked above");
280
281 settings.save_to_disk()?;
282
283 println!("Workspace '{}' renamed to '{}'.", old_name, new_name);
284 Ok(())
285}
286
287fn run_remove(settings: &mut AppSettings, name: String) -> Result<()> {
288 let ws_config = settings
289 .workspace_config
290 .as_ref()
291 .ok_or_else(|| eyre!("No workspaces configured."))?;
292
293 if !ws_config.workspaces.contains_key(&name) {
294 return Err(eyre!("Workspace '{}' not found.", name));
295 }
296
297 if ws_config.global.current_workspace == name {
298 return Err(eyre!(
299 "Cannot remove the current workspace '{}'. \
300 Switch to a different workspace first with: kimun workspace use <name>",
301 name
302 ));
303 }
304
305 let (index, history) = settings.workspace_artifacts(&name);
307 let leftovers = crate::settings::delete_artifacts(&index, &history);
308
309 settings
310 .workspace_config
311 .as_mut()
312 .expect("workspace_config must exist")
313 .workspaces
314 .remove(&name);
315
316 settings.save_to_disk()?;
317
318 println!("Workspace '{}' removed.", name);
319 if let Some(report) = leftover_report(&leftovers) {
320 eprintln!("{report}");
323 }
324 Ok(())
325}
326
327fn leftover_report(leftovers: &[String]) -> Option<String> {
330 (!leftovers.is_empty()).then(|| {
331 format!(
332 "\nWarning: these files could not be deleted and are safe to \
333 delete by hand:\n{}",
334 leftovers.join("\n")
335 )
336 })
337}
338
339async fn run_reindex(settings: &AppSettings, name: Option<String>) -> Result<()> {
340 let ws_config = settings
341 .workspace_config
342 .as_ref()
343 .ok_or_else(|| eyre!("No workspaces configured."))?;
344
345 let workspace_name = match name {
346 Some(n) => n,
347 None => ws_config.global.current_workspace.clone(),
348 };
349
350 if workspace_name.is_empty() {
351 return Err(eyre!("No current workspace set. Specify a workspace name."));
352 }
353
354 let entry = ws_config
355 .get_workspace(&workspace_name)
356 .ok_or_else(|| eyre!("Workspace '{}' not found.", workspace_name))?;
357
358 if !entry.effective_path().exists() {
359 return Err(eyre!(
360 "Workspace '{}' path no longer exists: {}",
361 workspace_name,
362 entry.effective_path().display()
363 ));
364 }
365
366 println!("Reindexing workspace '{}'...", workspace_name);
367
368 let cache_path = settings.index_for(&workspace_name);
369 let workspace_path = SystemPath::try_absolute(entry.effective_path())
370 .map_err(|e| eyre!("Workspace '{}' has an unusable path: {}", workspace_name, e))?;
371 let vault = NoteVault::new(VaultConfig::new(workspace_path.clone()).with_index(cache_path))
372 .await
373 .map_err(|e| eyre!("Failed to open vault at {}: {}", workspace_path, e))?;
374
375 let index_result = vault.recreate_index().await;
376 vault.close().await;
380
381 let report = match index_result {
382 Ok(r) => r,
383 Err(VaultError::CaseConflict { conflicts }) => {
384 eprintln!(
385 "Error: vault '{}' has case-sensitivity conflicts:",
386 workspace_name
387 );
388 for c in &conflicts {
389 eprintln!(" {}", c);
390 }
391 eprintln!(
392 "\nResolve the conflicts on disk, then run `kimun workspace use {}` to re-select the vault.",
393 workspace_name
394 );
395 return Err(eyre!(
396 "Vault '{}' has case-sensitivity conflicts",
397 workspace_name
398 ));
399 }
400 Err(e) => {
401 return Err(eyre!(
402 "Failed to reindex workspace '{}': {}",
403 workspace_name,
404 e
405 ));
406 }
407 };
408
409 let _ = report; println!("Reindex complete for workspace '{}'.", workspace_name);
411
412 Ok(())
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::settings::history::HistoryFile;
419 use kimun_core::IndexFile;
420 use kimun_core::system::SystemPath;
421
422 fn sys(path: &std::path::Path) -> SystemPath {
423 SystemPath::try_absolute(path).unwrap()
424 }
425
426 #[test]
431 fn a_file_that_will_not_delete_is_reported() {
432 let dir = tempfile::TempDir::new().unwrap();
433 let index = IndexFile::in_dir(&sys(dir.path()), "stuck");
434 let history = HistoryFile::in_dir(&sys(dir.path()), "stuck");
435 std::fs::create_dir(index.path().as_path()).unwrap();
439 std::fs::write(index.path().as_path().join("occupied"), b"x").unwrap();
440
441 let leftovers = crate::settings::delete_artifacts(&index, &history);
442
443 assert_eq!(leftovers.len(), 1, "got {leftovers:?}");
444 assert!(leftovers[0].contains("stuck.kimuncache"), "{leftovers:?}");
445 let report = leftover_report(&leftovers).expect("a leftover must be reported");
446 assert!(report.contains("could not be deleted"), "{report}");
447 assert!(report.contains("stuck.kimuncache"), "{report}");
448 }
449
450 #[test]
452 fn a_clean_delete_reports_nothing() {
453 let dir = tempfile::TempDir::new().unwrap();
454 let index = IndexFile::in_dir(&sys(dir.path()), "gone");
455 let history = HistoryFile::in_dir(&sys(dir.path()), "gone");
456 std::fs::write(index.path().as_path(), b"index").unwrap();
457 std::fs::write(history.path().as_path(), b"a.md\n").unwrap();
458
459 let leftovers = crate::settings::delete_artifacts(&index, &history);
460
461 assert!(leftovers.is_empty(), "got {leftovers:?}");
462 assert!(leftover_report(&leftovers).is_none());
463 assert!(!index.exists());
464 }
465}