From 9974b8eccdf14d4ac071eb6f0bb854468c78eca8 Mon Sep 17 00:00:00 2001 From: kokopi-dev Date: Sun, 5 Apr 2026 00:15:48 +0900 Subject: [PATCH] add:edit functionality --- src/cli.rs | 4 + src/cli/edit.rs | 222 +++++++++++++++++++++++++++++++ src/cli/list.rs | 30 +---- src/services/registry_service.rs | 31 +++++ 4 files changed, 262 insertions(+), 25 deletions(-) create mode 100644 src/cli/edit.rs diff --git a/src/cli.rs b/src/cli.rs index 526af5b..7a6c425 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,5 @@ pub mod add; +pub mod edit; pub mod list; pub mod remove; @@ -10,6 +11,7 @@ use crate::{error::AppError, services::ServiceStore}; enum MenuAction { Add, List, + Edit, Remove, } @@ -19,12 +21,14 @@ pub fn show_main_menu(store: &ServiceStore) -> Result<(), AppError> { let action = select("What would you like to do?") .item(MenuAction::Add, "Add", "register a new script") .item(MenuAction::List, "List", "view and run registered scripts") + .item(MenuAction::Edit, "Edit", "update a registered script's settings") .item(MenuAction::Remove, "Remove", "unregister a script") .interact()?; match action { MenuAction::Add => add::run(store)?, MenuAction::List => list::run(store)?, + MenuAction::Edit => edit::run(store)?, MenuAction::Remove => remove::run(store)?, } diff --git a/src/cli/edit.rs b/src/cli/edit.rs new file mode 100644 index 0000000..87b2462 --- /dev/null +++ b/src/cli/edit.rs @@ -0,0 +1,222 @@ +use std::os::unix::fs as unix_fs; +use std::path::PathBuf; + +use cliclack::{confirm, input, log, note, select}; + +use crate::{ + config::types::ScriptEntry, + error::AppError, + services::{path_service::CollisionResult, registry_service::is_healthy, ServiceStore}, + shim::template::write_shim, +}; + +pub fn run(store: &ServiceStore) -> Result<(), AppError> { + let registry = store.registry.load()?; + + if registry.scripts.is_empty() { + note( + "No scripts registered", + "Use 'Add' to register your first script.", + )?; + return Ok(()); + } + + let config = store.config.load()?; + + // ── Select script to edit ────────────────────────────────────────────────── + let selected_name: String = { + let mut prompt = select("Which script would you like to edit?"); + for entry in ®istry.scripts { + let label = if is_healthy(entry) { + entry.name.clone() + } else { + format!("⚠ {}", entry.name) + }; + prompt = prompt.item(entry.name.clone(), label, &entry.description); + } + prompt.interact()? + }; + + let original = registry + .scripts + .iter() + .find(|e| e.name == selected_name) + .expect("selected name must exist in registry") + .clone(); + + // Warn if the selected entry is broken — let the user proceed anyway so + // they can fix the source path without having to remove and re-add + if !is_healthy(&original) { + log::warning(format!( + "'{}' is broken — shim or symlink is missing.\n Editing the source path will repair it.", + original.name, + ))?; + } + + // ── Edit fields — each pre-filled with the current value ────────────────── + + // Source path + let current_source = original.source_path.to_string_lossy().to_string(); + let source_raw: String = input("Path to script (Press enter to autofill)") + .default_input(¤t_source) + .validate(|raw: &String| { + let expanded = expand_tilde(raw.trim()); + if !expanded.exists() { + Err("File not found.") + } else if !expanded.is_file() { + Err("Path is a directory, not a file.") + } else { + Ok(()) + } + }) + .interact()?; + let source_path = expand_tilde(source_raw.trim()); + + // Name + let name: String = input("Name") + .default_input(&original.name) + .validate(|n: &String| { + let n = n.trim(); + if n.is_empty() { + return Err("Name cannot be empty."); + } + if n.contains(' ') { + return Err("Name cannot contain spaces."); + } + if n.contains('/') { + return Err("Name cannot contain slashes."); + } + Ok(()) + }) + .interact()?; + let name = name.trim().to_string(); + + // If the name changed, guard against collisions + if name != original.name { + if store.registry.name_exists(&name)? { + log::error(format!("A script named '{name}' is already registered."))?; + return Err(AppError::Cancelled); + } + match store.path.check_collision(&name) { + CollisionResult::Collision { path } => { + note( + "Name conflict", + format!( + "'{name}' already exists at {}\nYour shim will shadow it on $PATH.", + path.display() + ), + )?; + let proceed = confirm("Continue anyway?").interact()?; + if !proceed { + return Err(AppError::Cancelled); + } + } + CollisionResult::Clear => {} + } + } + + // Description + let description: String = input("Description") + .default_input(&original.description) + .validate(|d: &String| { + if d.trim().is_empty() { + Err("Description cannot be empty.") + } else { + Ok(()) + } + }) + .interact()?; + + // Usage + let usage: String = input("Usage") + .default_input(&original.usage) + .interact()?; + + // Tags + let current_tags = original.tags.join(", "); + let tags_raw: String = input("Tags (comma-separated)") + .default_input(¤t_tags) + .required(false) + .interact()?; + + let tags: Vec = tags_raw + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + // ── Confirm ──────────────────────────────────────────────────────────────── + let confirmed = confirm("Save changes?").interact()?; + if !confirmed { + return Err(AppError::Cancelled); + } + + // ── Apply changes to disk ────────────────────────────────────────────────── + let name_changed = name != original.name; + let source_changed = source_path != original.source_path; + + let new_symlink_path = if name_changed { + store.config.scripts_dir.join(&name) + } else { + original.symlink_path.clone() + }; + + let new_shim_path = if name_changed { + config.bin_dir.join(&name) + } else { + original.shim_path.clone() + }; + + // Recreate the symlink if the name or the source path changed + if name_changed || source_changed { + if new_symlink_path.exists() || new_symlink_path.is_symlink() { + std::fs::remove_file(&new_symlink_path)?; + } + unix_fs::symlink(&source_path, &new_symlink_path)?; + } + + // Always rewrite the shim — any field change may affect its output + write_shim( + &new_shim_path, + &new_symlink_path, + &name, + &description, + &usage, + &config.default_shell, + )?; + + // Persist updated entry — do this before removing old files + store.registry.update( + &original.name, + ScriptEntry { + name: name.clone(), + source_path, + symlink_path: new_symlink_path, + shim_path: new_shim_path, + description, + usage, + tags, + }, + )?; + + // Remove old named files only after registry is safely updated + if name_changed { + let _ = std::fs::remove_file(&original.shim_path); + let _ = std::fs::remove_file(&original.symlink_path); + } + + log::success(format!("'{name}' updated."))?; + + Ok(()) +} + +/// Expands a leading `~/` to the user's home directory. +/// Returns the path unchanged if it doesn't start with `~/`. +fn expand_tilde(raw: &str) -> PathBuf { + if let Some(rest) = raw.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(raw) +} diff --git a/src/cli/list.rs b/src/cli/list.rs index e5a1b88..d851ab0 100644 --- a/src/cli/list.rs +++ b/src/cli/list.rs @@ -2,7 +2,10 @@ use std::{os::unix::process::CommandExt, process::Command}; use cliclack::{confirm, log, note, outro, select}; -use crate::{error::AppError, services::ServiceStore}; +use crate::{ + error::AppError, + services::{registry_service::is_healthy, ServiceStore}, +}; /// Health of a registered script's files on disk. #[derive(Clone, Eq, PartialEq)] @@ -27,17 +30,12 @@ pub fn run(store: &ServiceStore) -> Result<(), AppError> { .scripts .iter() .map(|entry| { - let health = if is_healthy(entry) { - Health::Ok - } else { - Health::Broken - }; + let health = if is_healthy(entry) { Health::Ok } else { Health::Broken }; (entry, health) }) .collect(); // ── Script selection ─────────────────────────────────────────────────────── - // Use the script name as the select value — simple, Clone+Eq for free. let selected_name: String = { let mut prompt = select("Which script?"); for (entry, health) in &annotated { @@ -50,7 +48,6 @@ pub fn run(store: &ServiceStore) -> Result<(), AppError> { prompt.interact()? }; - // Look up the full entry and its health by name let (entry, health) = annotated .into_iter() .find(|(e, _)| e.name == selected_name) @@ -80,20 +77,3 @@ pub fn run(store: &ServiceStore) -> Result<(), AppError> { // exec() only returns on failure Err(AppError::Io(err)) } - -/// Returns true if both the shim and the symlink target are present on disk. -fn is_healthy(entry: &crate::config::types::ScriptEntry) -> bool { - use std::os::unix::fs::PermissionsExt; - - let shim_ok = entry - .shim_path - .metadata() - .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) - .unwrap_or(false); - - // Follow the symlink — if the target is gone, symlink_metadata succeeds but - // metadata() (which follows links) will fail - let symlink_ok = entry.symlink_path.metadata().map(|m| m.is_file()).unwrap_or(false); - - shim_ok && symlink_ok -} diff --git a/src/services/registry_service.rs b/src/services/registry_service.rs index a923625..3a121e9 100644 --- a/src/services/registry_service.rs +++ b/src/services/registry_service.rs @@ -48,6 +48,17 @@ impl RegistryService { self.save(®istry) } + /// Replaces the entry matching `original_name` with `updated` and persists. + /// Handles the case where the name itself changed. + pub fn update(&self, original_name: &str, updated: ScriptEntry) -> Result<(), AppError> { + let mut registry = self.load()?; + if let Some(pos) = registry.scripts.iter().position(|s| s.name == original_name) { + registry.scripts[pos] = updated; + self.save(®istry)?; + } + Ok(()) + } + /// Removes the entry with the given name and persists the registry. /// Also removes the shim and symlink files from disk if they exist. /// Does nothing if the name is not found. @@ -74,3 +85,23 @@ impl RegistryService { Ok(registry.scripts.iter().any(|s| s.name == name)) } } + +/// Returns true if both the shim and the symlink target are present on disk. +pub fn is_healthy(entry: &crate::config::types::ScriptEntry) -> bool { + use std::os::unix::fs::PermissionsExt; + + let shim_ok = entry + .shim_path + .metadata() + .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + .unwrap_or(false); + + // metadata() follows the symlink — if the target is gone this returns Err + let symlink_ok = entry + .symlink_path + .metadata() + .map(|m| m.is_file()) + .unwrap_or(false); + + shim_ok && symlink_ok +}