add:edit functionality
This commit is contained in:
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 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user