From 36d2227a864f6cdf6a9eb03edff4a194bec3d1f1 Mon Sep 17 00:00:00 2001 From: kokopi-dev Date: Sat, 4 Apr 2026 23:04:46 +0900 Subject: [PATCH] add:list functionality --- src/cli/list.rs | 99 +++++++++++++++++++++++++++++++- src/services/registry_service.rs | 25 +++++++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/src/cli/list.rs b/src/cli/list.rs index 08e36d7..e5a1b88 100644 --- a/src/cli/list.rs +++ b/src/cli/list.rs @@ -1,6 +1,99 @@ +use std::{os::unix::process::CommandExt, process::Command}; + +use cliclack::{confirm, log, note, outro, select}; + use crate::{error::AppError, services::ServiceStore}; -pub fn run(_store: &ServiceStore) -> Result<(), AppError> { - // TODO: load registry, display all scripts in a cliclack table/select - Ok(()) +/// Health of a registered script's files on disk. +#[derive(Clone, Eq, PartialEq)] +enum Health { + Ok, + Broken, +} + +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(()); + } + + // Annotate each entry with its health before building the select items + let annotated: Vec<(&crate::config::types::ScriptEntry, Health)> = registry + .scripts + .iter() + .map(|entry| { + 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 { + let label = match health { + Health::Ok => entry.name.clone(), + Health::Broken => format!("⚠ {}", entry.name), + }; + prompt = prompt.item(entry.name.clone(), label, &entry.description); + } + prompt.interact()? + }; + + // Look up the full entry and its health by name + let (entry, health) = annotated + .into_iter() + .find(|(e, _)| e.name == selected_name) + .expect("selected name must exist in annotated list"); + + // ── Broken entry handling ────────────────────────────────────────────────── + if health == Health::Broken { + log::warning(format!( + "'{}' is broken — shim or symlink is missing.\n Source: {}", + entry.name, + entry.source_path.display(), + ))?; + + let remove = confirm("Remove this broken entry from the registry?").interact()?; + if remove { + store.registry.remove(&entry.name)?; + log::success(format!("'{}' removed from registry.", entry.name))?; + } + return Ok(()); + } + + // ── Healthy entry: exec shim --help, replacing this process ─────────────── + outro(format!("Running {} --help...", entry.name))?; + + let err = Command::new(&entry.shim_path).arg("--help").exec(); + + // 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 02a4a14..a923625 100644 --- a/src/services/registry_service.rs +++ b/src/services/registry_service.rs @@ -1,7 +1,10 @@ use std::{fs, path::PathBuf}; use crate::{ - config::{defaults::registry_file, types::{Registry, ScriptEntry}}, + config::{ + defaults::registry_file, + types::{Registry, ScriptEntry}, + }, error::AppError, }; @@ -45,6 +48,26 @@ impl RegistryService { self.save(®istry) } + /// 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. + pub fn remove(&self, name: &str) -> Result<(), AppError> { + let mut registry = self.load()?; + + if let Some(pos) = registry.scripts.iter().position(|s| s.name == name) { + let entry = ®istry.scripts[pos]; + + // Best-effort removal of generated files — don't fail if already gone + let _ = fs::remove_file(&entry.shim_path); + let _ = fs::remove_file(&entry.symlink_path); + + registry.scripts.remove(pos); + self.save(®istry)?; + } + + Ok(()) + } + /// Returns true if a script with the given name is already registered. pub fn name_exists(&self, name: &str) -> Result { let registry = self.load()?;