use std::path::PathBuf;
use clap::Subcommand;
use color_eyre::eyre::{Result, eyre};
use kimun_core::error::VaultError;
use kimun_core::{NoteVault, SystemPath, VaultConfig};
use kimun_core::system;
use crate::settings::{
AppSettings, config_migration::CURRENT_CONFIG_VERSION, workspace_config::WorkspaceConfig,
};
#[derive(Subcommand, Debug)]
pub enum WorkspaceSubcommand {
Init {
#[arg(long)]
name: Option<String>,
path: PathBuf,
},
List,
Use {
name: String,
},
Rename {
old_name: String,
new_name: String,
},
Remove {
name: String,
},
Reindex {
#[arg(long)]
name: Option<String>,
},
}
pub async fn run(subcommand: WorkspaceSubcommand, settings: &mut AppSettings) -> Result<()> {
match subcommand {
WorkspaceSubcommand::Init { name, path } => run_init(settings, name, path).await,
WorkspaceSubcommand::List => run_list(settings),
WorkspaceSubcommand::Use { name } => run_use(settings, name),
WorkspaceSubcommand::Rename { old_name, new_name } => {
run_rename(settings, old_name, new_name)
}
WorkspaceSubcommand::Remove { name } => run_remove(settings, name),
WorkspaceSubcommand::Reindex { name } => run_reindex(settings, name).await,
}
}
async fn run_init(settings: &mut AppSettings, name: Option<String>, path: PathBuf) -> Result<()> {
if settings.workspace_config.is_none() {
settings.workspace_config = Some(WorkspaceConfig::new_empty());
}
let ws_config = settings
.workspace_config
.as_ref()
.expect("workspace_config must exist after init");
let workspace_name = match name {
Some(n) => n.to_lowercase(),
None => {
if ws_config.workspaces.is_empty() {
"default".to_string()
} else {
return Err(eyre!(
"A workspace name is required when other workspaces already exist. \
Use: kimun workspace init --name <name> <path>"
));
}
}
};
kimun_core::nfs::filename::validate_filename(&workspace_name).map_err(|e| eyre!("{}", e))?;
if ws_config.workspaces.contains_key(&workspace_name) {
let existing_path = &ws_config.workspaces[&workspace_name].path;
return Err(eyre!(
"Workspace '{}' already exists at {}. \
Use a different name or remove the existing workspace first.",
workspace_name,
existing_path.display()
));
}
let created = !path.exists();
let canonical_path = system::create_dir(&path).map_err(|e| {
eyre!(
"Failed to create workspace directory {}: {}",
path.display(),
e
)
})?;
if created {
println!("Created directory: {}", path.display());
}
let ws_config_mut = settings
.workspace_config
.as_mut()
.expect("workspace_config must exist after init");
ws_config_mut
.add_workspace(
workspace_name.clone(),
canonical_path.clone().into_path_buf(),
)
.map_err(|e| eyre!("{}", e))?;
println!("Initializing workspace database...");
let cache_path = settings.index_for(&workspace_name);
let vault = NoteVault::new(VaultConfig::new(canonical_path.clone()).with_index(cache_path))
.await
.map_err(|e| eyre!("Failed to create vault at {}: {}", canonical_path, e))?;
let init_result = vault.validate_and_init().await;
vault.close().await;
init_result.map_err(|e| eyre!("Failed to initialize vault database: {}", e))?;
settings.config_version = CURRENT_CONFIG_VERSION;
settings.save_to_disk()?;
println!(
"Workspace '{}' initialized at {}",
workspace_name, canonical_path
);
let ws_config = settings
.workspace_config
.as_ref()
.expect("workspace_config must exist after init");
if ws_config.global.current_workspace == workspace_name {
println!("Set as current workspace.");
}
Ok(())
}
fn run_list(settings: &AppSettings) -> Result<()> {
match &settings.workspace_config {
None => {
println!("No workspaces configured. Run 'kimun workspace init <path>' to create one.");
}
Some(ws_config) => {
if ws_config.workspaces.is_empty() {
println!(
"No workspaces configured. Run 'kimun workspace init <path>' to create one."
);
} else {
println!("Configured workspaces:");
let mut names: Vec<&String> = ws_config.workspaces.keys().collect();
names.sort();
for name in names {
let entry = &ws_config.workspaces[name];
let marker = if name == &ws_config.global.current_workspace {
"* "
} else {
" "
};
println!("{}{} ({})", marker, name, entry.path.display());
}
}
}
}
Ok(())
}
fn run_use(settings: &mut AppSettings, name: String) -> Result<()> {
let ws_config = settings
.workspace_config
.as_ref()
.ok_or_else(|| eyre!("No workspaces configured."))?;
let entry = ws_config.get_workspace(&name).ok_or_else(|| {
let available: Vec<&String> = ws_config.workspaces.keys().collect();
eyre!(
"Workspace '{}' not found. Available workspaces: {}",
name,
available
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
if !entry.effective_path().exists() {
return Err(eyre!(
"Workspace '{}' path no longer exists: {}. \
Update the path or remove this workspace.",
name,
entry.effective_path().display()
));
}
settings
.workspace_config
.as_mut()
.expect("workspace_config must exist")
.global
.current_workspace = name.clone();
settings.save_to_disk()?;
println!("Switched to workspace '{}'.", name);
Ok(())
}
fn run_rename(settings: &mut AppSettings, old_name: String, new_name: String) -> Result<()> {
let new_name = new_name.to_lowercase();
kimun_core::nfs::filename::validate_filename(&new_name).map_err(|e| eyre!("{}", e))?;
let ws_config = settings
.workspace_config
.as_ref()
.ok_or_else(|| eyre!("No workspaces configured."))?;
if !ws_config.workspaces.contains_key(&old_name) {
return Err(eyre!("Workspace '{}' not found.", old_name));
}
if ws_config.workspaces.contains_key(&new_name) {
return Err(eyre!(
"Workspace '{}' already exists. Choose a different name.",
new_name
));
}
let ws_config_mut = settings
.workspace_config
.as_mut()
.expect("workspace_config must exist after init");
let renamed = ws_config_mut.rename_workspace(&old_name, new_name.clone());
debug_assert!(renamed, "entry existence was checked above");
settings.save_to_disk()?;
println!("Workspace '{}' renamed to '{}'.", old_name, new_name);
Ok(())
}
fn run_remove(settings: &mut AppSettings, name: String) -> Result<()> {
let ws_config = settings
.workspace_config
.as_ref()
.ok_or_else(|| eyre!("No workspaces configured."))?;
if !ws_config.workspaces.contains_key(&name) {
return Err(eyre!("Workspace '{}' not found.", name));
}
if ws_config.global.current_workspace == name {
return Err(eyre!(
"Cannot remove the current workspace '{}'. \
Switch to a different workspace first with: kimun workspace use <name>",
name
));
}
let (index, history) = settings.workspace_artifacts(&name);
let leftovers = crate::settings::delete_artifacts(&index, &history);
settings
.workspace_config
.as_mut()
.expect("workspace_config must exist")
.workspaces
.remove(&name);
settings.save_to_disk()?;
println!("Workspace '{}' removed.", name);
if let Some(report) = leftover_report(&leftovers) {
eprintln!("{report}");
}
Ok(())
}
fn leftover_report(leftovers: &[String]) -> Option<String> {
(!leftovers.is_empty()).then(|| {
format!(
"\nWarning: these files could not be deleted and are safe to \
delete by hand:\n{}",
leftovers.join("\n")
)
})
}
async fn run_reindex(settings: &AppSettings, name: Option<String>) -> Result<()> {
let ws_config = settings
.workspace_config
.as_ref()
.ok_or_else(|| eyre!("No workspaces configured."))?;
let workspace_name = match name {
Some(n) => n,
None => ws_config.global.current_workspace.clone(),
};
if workspace_name.is_empty() {
return Err(eyre!("No current workspace set. Specify a workspace name."));
}
let entry = ws_config
.get_workspace(&workspace_name)
.ok_or_else(|| eyre!("Workspace '{}' not found.", workspace_name))?;
if !entry.effective_path().exists() {
return Err(eyre!(
"Workspace '{}' path no longer exists: {}",
workspace_name,
entry.effective_path().display()
));
}
println!("Reindexing workspace '{}'...", workspace_name);
let cache_path = settings.index_for(&workspace_name);
let workspace_path = SystemPath::try_absolute(entry.effective_path())
.map_err(|e| eyre!("Workspace '{}' has an unusable path: {}", workspace_name, e))?;
let vault = NoteVault::new(VaultConfig::new(workspace_path.clone()).with_index(cache_path))
.await
.map_err(|e| eyre!("Failed to open vault at {}: {}", workspace_path, e))?;
let index_result = vault.recreate_index().await;
vault.close().await;
let report = match index_result {
Ok(r) => r,
Err(VaultError::CaseConflict { conflicts }) => {
eprintln!(
"Error: vault '{}' has case-sensitivity conflicts:",
workspace_name
);
for c in &conflicts {
eprintln!(" {}", c);
}
eprintln!(
"\nResolve the conflicts on disk, then run `kimun workspace use {}` to re-select the vault.",
workspace_name
);
return Err(eyre!(
"Vault '{}' has case-sensitivity conflicts",
workspace_name
));
}
Err(e) => {
return Err(eyre!(
"Failed to reindex workspace '{}': {}",
workspace_name,
e
));
}
};
let _ = report; println!("Reindex complete for workspace '{}'.", workspace_name);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::history::HistoryFile;
use kimun_core::IndexFile;
use kimun_core::system::SystemPath;
fn sys(path: &std::path::Path) -> SystemPath {
SystemPath::try_absolute(path).unwrap()
}
#[test]
fn a_file_that_will_not_delete_is_reported() {
let dir = tempfile::TempDir::new().unwrap();
let index = IndexFile::in_dir(&sys(dir.path()), "stuck");
let history = HistoryFile::in_dir(&sys(dir.path()), "stuck");
std::fs::create_dir(index.path().as_path()).unwrap();
std::fs::write(index.path().as_path().join("occupied"), b"x").unwrap();
let leftovers = crate::settings::delete_artifacts(&index, &history);
assert_eq!(leftovers.len(), 1, "got {leftovers:?}");
assert!(leftovers[0].contains("stuck.kimuncache"), "{leftovers:?}");
let report = leftover_report(&leftovers).expect("a leftover must be reported");
assert!(report.contains("could not be deleted"), "{report}");
assert!(report.contains("stuck.kimuncache"), "{report}");
}
#[test]
fn a_clean_delete_reports_nothing() {
let dir = tempfile::TempDir::new().unwrap();
let index = IndexFile::in_dir(&sys(dir.path()), "gone");
let history = HistoryFile::in_dir(&sys(dir.path()), "gone");
std::fs::write(index.path().as_path(), b"index").unwrap();
std::fs::write(history.path().as_path(), b"a.md\n").unwrap();
let leftovers = crate::settings::delete_artifacts(&index, &history);
assert!(leftovers.is_empty(), "got {leftovers:?}");
assert!(leftover_report(&leftovers).is_none());
assert!(!index.exists());
}
}