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, 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    /// Initialize a new workspace
21    Init {
22        /// Name for the workspace (defaults to "default" for first workspace)
23        #[arg(long)]
24        name: Option<String>,
25        /// Path to the workspace directory
26        path: PathBuf,
27    },
28    /// List all configured workspaces
29    List,
30    /// Switch to a different workspace
31    Use {
32        /// Name of the workspace to switch to
33        name: String,
34    },
35    /// Rename a workspace
36    Rename {
37        /// Current workspace name
38        old_name: String,
39        /// New workspace name
40        new_name: String,
41    },
42    /// Remove a workspace from the configuration
43    Remove {
44        /// Name of the workspace to remove
45        name: String,
46    },
47    /// Reindex a workspace
48    Reindex {
49        /// Workspace name (defaults to current workspace)
50        #[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    // Ensure workspace_config exists
70    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    // Workspace name is lowercased here because it backs case-insensitive
80    // cache and history filenames; same lowering must apply to the DB path
81    // computed below and the eventual add_workspace key.
82    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    // Validate before anything derived from the name touches the filesystem.
97    // `add_workspace` validates too, but only after the cache file below has
98    // already been created at `<cache_dir>/<name>.kimuncache` — a name with
99    // `..` or a separator in it puts that file (plus its -wal/-shm sidecars
100    // and any parent directories) outside the cache directory entirely, and
101    // the command then aborts having already written them.
102    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    // Validate/create the target path
115    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    // The entry goes in BEFORE the index is named. `add_workspace` is what
128    // mints the key the files are called after, so asking for the index first
129    // would create it under the workspace's *name* and then look for it under
130    // the key — a database written once and never found again. Nothing is
131    // persisted until `save_to_disk` below, so bailing out after this point
132    // leaves the config on disk untouched.
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(
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    // This vault existed only to create the database; release its handle on the
151    // cache file rather than leaving that to pool drop, which merely schedules
152    // the close. A later `workspace remove` in the same process (the TUI, or a
153    // test driving several commands) has to delete that file, and Windows will
154    // not delete a file that is still open. Closed before the `?` too: a failed
155    // init leaves the cache file on disk, so bailing out with it still open is
156    // the same locked file with nobody left holding a handle to close it.
157    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    // Validate workspace path still exists
228    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    // Nothing on disk moves. The index and history keep the name they were
270    // created under, which `rename_workspace` pins into the entry — see
271    // `WorkspaceEntry::file_key`. Renaming used to move an open SQLite
272    // database, and Windows will not move a file any handle still holds, so
273    // this is the one rename that cannot fail halfway.
274    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    // Read before the entry goes: the file names come from its `file_key`.
306    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        // stderr, not stdout: the removal did happen, and a script reading
321        // stdout should not have to parse this out of the success line.
322        eprintln!("{report}");
323    }
324    Ok(())
325}
326
327/// What to tell the user about files that would not delete, or `None` when
328/// everything went.
329fn 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    // Reindexing is done with the vault; close it (on the error paths too)
377    // instead of letting the process hold the cache file open, which blocks a
378    // later rename or remove of that workspace on Windows.
379    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; // IndexReport only contains timing info
410    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    /// The whole point of returning the leftovers: a delete that fails has to
427    /// reach the user. It only ever went to `tracing::warn!` before, which in a
428    /// CLI with no subscriber attached is the same as silence — and the command
429    /// printed success over an index it had not deleted.
430    #[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        // A non-empty directory where the index file should be: `remove_file`
436        // cannot delete it. A stand-in for the Windows lock, which cannot be
437        // provoked on demand — the reporting is what is under test.
438        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    /// The ordinary case stays quiet: nothing left behind, nothing printed.
451    #[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}