Skip to main content

kimun_notes/cli/commands/
workspace.rs

1// tui/src/cli/commands/workspace.rs
2//
3// Workspace management CLI commands: init, list, use, rename, remove, reindex.
4
5use 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    /// Initialize a new workspace
19    Init {
20        /// Name for the workspace (defaults to "default" for first workspace)
21        #[arg(long)]
22        name: Option<String>,
23        /// Path to the workspace directory
24        path: PathBuf,
25    },
26    /// List all configured workspaces
27    List,
28    /// Switch to a different workspace
29    Use {
30        /// Name of the workspace to switch to
31        name: String,
32    },
33    /// Rename a workspace
34    Rename {
35        /// Current workspace name
36        old_name: String,
37        /// New workspace name
38        new_name: String,
39    },
40    /// Remove a workspace from the configuration
41    Remove {
42        /// Name of the workspace to remove
43        name: String,
44    },
45    /// Reindex a workspace
46    Reindex {
47        /// Workspace name (defaults to current workspace)
48        #[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    // Ensure workspace_config exists
68    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    // Workspace name is lowercased here because it backs case-insensitive
78    // cache and history filenames; same lowering must apply to the DB path
79    // computed below and the eventual add_workspace key.
80    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    // Validate/create the target path
105    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    // Validate workspace path still exists
210    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    // Move cache and history files BEFORE mutating config so a failed
252    // file move doesn't leave the config pointing at a workspace whose
253    // cache is in the wrong place.
254    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; // IndexReport only contains timing info
426    println!("Reindex complete for workspace '{}'.", workspace_name);
427
428    Ok(())
429}