add:edit functionality

This commit is contained in:
kokopi-dev
2026-04-05 00:15:48 +09:00
parent 2e00f08557
commit 9974b8eccd
4 changed files with 262 additions and 25 deletions

View File

@@ -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)?,
}

222
src/cli/edit.rs Normal file
View File

@@ -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 &registry.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(&current_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(&current_tags)
.required(false)
.interact()?;
let tags: Vec<String> = 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)
}

View File

@@ -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
}

View File

@@ -48,6 +48,17 @@ impl RegistryService {
self.save(&registry)
}
/// 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(&registry)?;
}
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
}