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, VaultConfig};
11
12use crate::settings::{
13 AppSettings, config_migration::CURRENT_CONFIG_VERSION, workspace_config::WorkspaceConfig,
14};
15
16#[derive(Subcommand, Debug)]
17pub enum WorkspaceSubcommand {
18 Init {
20 #[arg(long)]
22 name: Option<String>,
23 path: PathBuf,
25 },
26 List,
28 Use {
30 name: String,
32 },
33 Rename {
35 old_name: String,
37 new_name: String,
39 },
40 Remove {
42 name: String,
44 },
45 Reindex {
47 #[arg(long)]
49 name: Option<String>,
50 },
51}
52
53pub async fn run(subcommand: WorkspaceSubcommand, settings: &mut AppSettings) -> Result<()> {
54 match subcommand {
55 WorkspaceSubcommand::Init { name, path } => run_init(settings, name, path).await,
56 WorkspaceSubcommand::List => run_list(settings),
57 WorkspaceSubcommand::Use { name } => run_use(settings, name),
58 WorkspaceSubcommand::Rename { old_name, new_name } => {
59 run_rename(settings, old_name, new_name)
60 }
61 WorkspaceSubcommand::Remove { name } => run_remove(settings, name),
62 WorkspaceSubcommand::Reindex { name } => run_reindex(settings, name).await,
63 }
64}
65
66async fn run_init(settings: &mut AppSettings, name: Option<String>, path: PathBuf) -> Result<()> {
67 if settings.workspace_config.is_none() {
69 settings.workspace_config = Some(WorkspaceConfig::new_empty());
70 }
71
72 let ws_config = settings
73 .workspace_config
74 .as_ref()
75 .expect("workspace_config must exist after init");
76
77 let workspace_name = match name {
81 Some(n) => n.to_lowercase(),
82 None => {
83 if ws_config.workspaces.is_empty() {
84 "default".to_string()
85 } else {
86 return Err(eyre!(
87 "A workspace name is required when other workspaces already exist. \
88 Use: kimun workspace init --name <name> <path>"
89 ));
90 }
91 }
92 };
93
94 if ws_config.workspaces.contains_key(&workspace_name) {
95 let existing_path = &ws_config.workspaces[&workspace_name].path;
96 return Err(eyre!(
97 "Workspace '{}' already exists at {}. \
98 Use a different name or remove the existing workspace first.",
99 workspace_name,
100 existing_path.display()
101 ));
102 }
103
104 let created = !path.exists();
106 let canonical_path = kimun_core::ensure_dir_exists(&path).map_err(|e| {
107 eyre!(
108 "Failed to create workspace directory {}: {}",
109 path.display(),
110 e
111 )
112 })?;
113 if created {
114 println!("Created directory: {}", path.display());
115 }
116
117 println!("Initializing workspace database...");
118 let cache_path = settings.cache_path_for(&workspace_name);
119 let vault = NoteVault::new(VaultConfig::new(&canonical_path).with_db_path(cache_path))
120 .await
121 .map_err(|e| {
122 eyre!(
123 "Failed to create vault at {}: {}",
124 canonical_path.display(),
125 e
126 )
127 })?;
128 vault
129 .validate_and_init()
130 .await
131 .map_err(|e| eyre!("Failed to initialize vault database: {}", e))?;
132
133 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(workspace_name.clone(), canonical_path.clone())
139 .map_err(|e| eyre!("{}", e))?;
140
141 settings.config_version = CURRENT_CONFIG_VERSION;
142 settings.save_to_disk()?;
143
144 println!(
145 "Workspace '{}' initialized at {}",
146 workspace_name,
147 canonical_path.display()
148 );
149
150 let ws_config = settings
151 .workspace_config
152 .as_ref()
153 .expect("workspace_config must exist after init");
154 if ws_config.global.current_workspace == workspace_name {
155 println!("Set as current workspace.");
156 }
157
158 Ok(())
159}
160
161fn run_list(settings: &AppSettings) -> Result<()> {
162 match &settings.workspace_config {
163 None => {
164 println!("No workspaces configured. Run 'kimun workspace init <path>' to create one.");
165 }
166 Some(ws_config) => {
167 if ws_config.workspaces.is_empty() {
168 println!(
169 "No workspaces configured. Run 'kimun workspace init <path>' to create one."
170 );
171 } else {
172 println!("Configured workspaces:");
173 let mut names: Vec<&String> = ws_config.workspaces.keys().collect();
174 names.sort();
175 for name in names {
176 let entry = &ws_config.workspaces[name];
177 let marker = if name == &ws_config.global.current_workspace {
178 "* "
179 } else {
180 " "
181 };
182 println!("{}{} ({})", marker, name, entry.path.display());
183 }
184 }
185 }
186 }
187 Ok(())
188}
189
190fn run_use(settings: &mut AppSettings, name: String) -> Result<()> {
191 let ws_config = settings
192 .workspace_config
193 .as_ref()
194 .ok_or_else(|| eyre!("No workspaces configured."))?;
195
196 let entry = ws_config.get_workspace(&name).ok_or_else(|| {
197 let available: Vec<&String> = ws_config.workspaces.keys().collect();
198 eyre!(
199 "Workspace '{}' not found. Available workspaces: {}",
200 name,
201 available
202 .iter()
203 .map(|s| s.as_str())
204 .collect::<Vec<_>>()
205 .join(", ")
206 )
207 })?;
208
209 if !entry.effective_path().exists() {
211 return Err(eyre!(
212 "Workspace '{}' path no longer exists: {}. \
213 Update the path or remove this workspace.",
214 name,
215 entry.effective_path().display()
216 ));
217 }
218
219 settings
220 .workspace_config
221 .as_mut()
222 .expect("workspace_config must exist")
223 .global
224 .current_workspace = name.clone();
225 settings.save_to_disk()?;
226
227 println!("Switched to workspace '{}'.", name);
228 Ok(())
229}
230
231fn run_rename(settings: &mut AppSettings, old_name: String, new_name: String) -> Result<()> {
232 let new_name = new_name.to_lowercase();
233 kimun_core::nfs::filename::validate_filename(&new_name).map_err(|e| eyre!("{}", e))?;
234
235 let ws_config = settings
236 .workspace_config
237 .as_ref()
238 .ok_or_else(|| eyre!("No workspaces configured."))?;
239
240 if !ws_config.workspaces.contains_key(&old_name) {
241 return Err(eyre!("Workspace '{}' not found.", old_name));
242 }
243
244 if ws_config.workspaces.contains_key(&new_name) {
245 return Err(eyre!(
246 "Workspace '{}' already exists. Choose a different name.",
247 new_name
248 ));
249 }
250
251 let old_cache = settings.cache_path_for(&old_name);
255 let new_cache = settings.cache_path_for(&new_name);
256 let old_history = settings.history_path_for(&old_name);
257 let new_history = settings.history_path_for(&new_name);
258
259 if new_cache.exists() {
260 return Err(eyre!(
261 "Destination cache already exists at {}. Refusing to overwrite.",
262 new_cache.display()
263 ));
264 }
265 if new_history.exists() {
266 return Err(eyre!(
267 "Destination history already exists at {}. Refusing to overwrite.",
268 new_history.display()
269 ));
270 }
271 if old_cache.exists() {
272 std::fs::rename(&old_cache, &new_cache).map_err(|e| {
273 eyre!(
274 "failed to move cache {} -> {}: {}",
275 old_cache.display(),
276 new_cache.display(),
277 e
278 )
279 })?;
280 }
281 if old_history.exists() {
282 std::fs::rename(&old_history, &new_history).map_err(|e| {
283 eyre!(
284 "failed to move history {} -> {}: {}",
285 old_history.display(),
286 new_history.display(),
287 e
288 )
289 })?;
290 }
291
292 let ws_config_mut = settings
293 .workspace_config
294 .as_mut()
295 .expect("workspace_config must exist after init");
296
297 let entry = ws_config_mut
298 .workspaces
299 .remove(&old_name)
300 .expect("entry must exist (checked above)");
301 ws_config_mut.workspaces.insert(new_name.clone(), entry);
302
303 if ws_config_mut.global.current_workspace == old_name {
304 ws_config_mut.global.current_workspace = new_name.clone();
305 }
306
307 settings.save_to_disk()?;
308
309 println!("Workspace '{}' renamed to '{}'.", old_name, new_name);
310 Ok(())
311}
312
313fn run_remove(settings: &mut AppSettings, name: String) -> Result<()> {
314 let ws_config = settings
315 .workspace_config
316 .as_ref()
317 .ok_or_else(|| eyre!("No workspaces configured."))?;
318
319 if !ws_config.workspaces.contains_key(&name) {
320 return Err(eyre!("Workspace '{}' not found.", name));
321 }
322
323 if ws_config.global.current_workspace == name {
324 return Err(eyre!(
325 "Cannot remove the current workspace '{}'. \
326 Switch to a different workspace first with: kimun workspace use <name>",
327 name
328 ));
329 }
330
331 let cache_path = settings.cache_path_for(&name);
332 let history_path = settings.history_path_for(&name);
333
334 settings
335 .workspace_config
336 .as_mut()
337 .expect("workspace_config must exist")
338 .workspaces
339 .remove(&name);
340
341 settings.save_to_disk()?;
342
343 for path in [&cache_path, &history_path] {
344 if path.exists() {
345 match std::fs::remove_file(path) {
346 Ok(()) => tracing::info!("removed {}", path.display()),
347 Err(e) => tracing::warn!("failed to remove {}: {}", path.display(), e),
348 }
349 }
350 }
351
352 println!("Workspace '{}' removed.", name);
353 Ok(())
354}
355
356async fn run_reindex(settings: &AppSettings, name: Option<String>) -> Result<()> {
357 let ws_config = settings
358 .workspace_config
359 .as_ref()
360 .ok_or_else(|| eyre!("No workspaces configured."))?;
361
362 let workspace_name = match name {
363 Some(n) => n,
364 None => ws_config.global.current_workspace.clone(),
365 };
366
367 if workspace_name.is_empty() {
368 return Err(eyre!("No current workspace set. Specify a workspace name."));
369 }
370
371 let entry = ws_config
372 .get_workspace(&workspace_name)
373 .ok_or_else(|| eyre!("Workspace '{}' not found.", workspace_name))?;
374
375 if !entry.effective_path().exists() {
376 return Err(eyre!(
377 "Workspace '{}' path no longer exists: {}",
378 workspace_name,
379 entry.effective_path().display()
380 ));
381 }
382
383 println!("Reindexing workspace '{}'...", workspace_name);
384
385 let cache_path = settings.cache_path_for(&workspace_name);
386 let workspace_path = entry.effective_path().clone();
387 let vault = NoteVault::new(VaultConfig::new(&workspace_path).with_db_path(cache_path))
388 .await
389 .map_err(|e| {
390 eyre!(
391 "Failed to open vault at {}: {}",
392 workspace_path.display(),
393 e
394 )
395 })?;
396
397 let report = match vault.recreate_index().await {
398 Ok(r) => r,
399 Err(VaultError::CaseConflict { conflicts }) => {
400 eprintln!(
401 "Error: vault '{}' has case-sensitivity conflicts:",
402 workspace_name
403 );
404 for c in &conflicts {
405 eprintln!(" {}", c);
406 }
407 eprintln!(
408 "\nResolve the conflicts on disk, then run `kimun workspace use {}` to re-select the vault.",
409 workspace_name
410 );
411 return Err(eyre!(
412 "Vault '{}' has case-sensitivity conflicts",
413 workspace_name
414 ));
415 }
416 Err(e) => {
417 return Err(eyre!(
418 "Failed to reindex workspace '{}': {}",
419 workspace_name,
420 e
421 ));
422 }
423 };
424
425 let _ = report; println!("Reindex complete for workspace '{}'.", workspace_name);
427
428 Ok(())
429}