add:edit functionality
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
pub mod add;
|
pub mod add;
|
||||||
|
pub mod edit;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
pub mod remove;
|
pub mod remove;
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ use crate::{error::AppError, services::ServiceStore};
|
|||||||
enum MenuAction {
|
enum MenuAction {
|
||||||
Add,
|
Add,
|
||||||
List,
|
List,
|
||||||
|
Edit,
|
||||||
Remove,
|
Remove,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,12 +21,14 @@ pub fn show_main_menu(store: &ServiceStore) -> Result<(), AppError> {
|
|||||||
let action = select("What would you like to do?")
|
let action = select("What would you like to do?")
|
||||||
.item(MenuAction::Add, "Add", "register a new script")
|
.item(MenuAction::Add, "Add", "register a new script")
|
||||||
.item(MenuAction::List, "List", "view and run registered scripts")
|
.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")
|
.item(MenuAction::Remove, "Remove", "unregister a script")
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
MenuAction::Add => add::run(store)?,
|
MenuAction::Add => add::run(store)?,
|
||||||
MenuAction::List => list::run(store)?,
|
MenuAction::List => list::run(store)?,
|
||||||
|
MenuAction::Edit => edit::run(store)?,
|
||||||
MenuAction::Remove => remove::run(store)?,
|
MenuAction::Remove => remove::run(store)?,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
222
src/cli/edit.rs
Normal file
222
src/cli/edit.rs
Normal 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 ®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<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)
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ use std::{os::unix::process::CommandExt, process::Command};
|
|||||||
|
|
||||||
use cliclack::{confirm, log, note, outro, select};
|
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.
|
/// Health of a registered script's files on disk.
|
||||||
#[derive(Clone, Eq, PartialEq)]
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
@@ -27,17 +30,12 @@ pub fn run(store: &ServiceStore) -> Result<(), AppError> {
|
|||||||
.scripts
|
.scripts
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| {
|
.map(|entry| {
|
||||||
let health = if is_healthy(entry) {
|
let health = if is_healthy(entry) { Health::Ok } else { Health::Broken };
|
||||||
Health::Ok
|
|
||||||
} else {
|
|
||||||
Health::Broken
|
|
||||||
};
|
|
||||||
(entry, health)
|
(entry, health)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// ── Script selection ───────────────────────────────────────────────────────
|
// ── Script selection ───────────────────────────────────────────────────────
|
||||||
// Use the script name as the select value — simple, Clone+Eq for free.
|
|
||||||
let selected_name: String = {
|
let selected_name: String = {
|
||||||
let mut prompt = select("Which script?");
|
let mut prompt = select("Which script?");
|
||||||
for (entry, health) in &annotated {
|
for (entry, health) in &annotated {
|
||||||
@@ -50,7 +48,6 @@ pub fn run(store: &ServiceStore) -> Result<(), AppError> {
|
|||||||
prompt.interact()?
|
prompt.interact()?
|
||||||
};
|
};
|
||||||
|
|
||||||
// Look up the full entry and its health by name
|
|
||||||
let (entry, health) = annotated
|
let (entry, health) = annotated
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|(e, _)| e.name == selected_name)
|
.find(|(e, _)| e.name == selected_name)
|
||||||
@@ -80,20 +77,3 @@ pub fn run(store: &ServiceStore) -> Result<(), AppError> {
|
|||||||
// exec() only returns on failure
|
// exec() only returns on failure
|
||||||
Err(AppError::Io(err))
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ impl RegistryService {
|
|||||||
self.save(®istry)
|
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.
|
/// Removes the entry with the given name and persists the registry.
|
||||||
/// Also removes the shim and symlink files from disk if they exist.
|
/// Also removes the shim and symlink files from disk if they exist.
|
||||||
/// Does nothing if the name is not found.
|
/// Does nothing if the name is not found.
|
||||||
@@ -74,3 +85,23 @@ impl RegistryService {
|
|||||||
Ok(registry.scripts.iter().any(|s| s.name == name))
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user