Files
scripts-organizer/src/cli/add.rs
2026-04-04 22:45:23 +09:00

212 lines
7.0 KiB
Rust

use std::{
os::unix::fs,
path::PathBuf,
};
use cliclack::{confirm, input, log, note, select};
use crate::{
config::types::ScriptEntry,
error::AppError,
services::{path_service::CollisionResult, ServiceStore},
shim::template::write_shim,
};
#[derive(Clone, Eq, PartialEq)]
enum AddMode {
Existing,
Create,
}
pub fn run(store: &ServiceStore) -> Result<(), AppError> {
let mode = select("What would you like to do?")
.item(AddMode::Existing, "Add existing script", "register a script that already exists")
.item(AddMode::Create, "Create new script", "scaffold a new script from a template")
.interact()?;
match mode {
AddMode::Existing => add_existing(store),
AddMode::Create => {
log::info("Create new script — coming soon.")?;
Ok(())
}
}
}
fn add_existing(store: &ServiceStore) -> Result<(), AppError> {
let config = store.config.load()?;
// ── 1. Path to the original script ────────────────────────────────────────
let source_path: String = input("Path to the script")
.placeholder("~/dotfiles/git/git-config.py")
.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_path.trim());
// ── 2. Name (shim name, no extension) ─────────────────────────────────────
// Default to the file stem of the source, without extension
let default_name = source_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let name: String = input("Script name (this becomes the command you call)")
.placeholder(&default_name)
.default_input(&default_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();
// Guard: already registered under this name
if store.registry.name_exists(&name)? {
log::error(format!("A script named '{name}' is already registered."))?;
return Err(AppError::Cancelled);
}
// Guard: collision with existing $PATH executable
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 => {}
}
// ── 3. Description ─────────────────────────────────────────────────────────
let description: String = input("Description")
.placeholder("One-line summary shown in --help and the script list")
.validate(|d: &String| {
if d.trim().is_empty() {
Err("Description cannot be empty.")
} else {
Ok(())
}
})
.interact()?;
// ── 4. Usage line ──────────────────────────────────────────────────────────
let default_usage = format!("{name} [options]");
let usage: String = input("Usage line")
.placeholder(&default_usage)
.default_input(&default_usage)
.interact()?;
// ── 5. Tags (optional) ─────────────────────────────────────────────────────
let tags_raw: String = input("Tags (optional, comma-separated)")
.placeholder("git, config, tools")
.required(false)
.interact()?;
let tags: Vec<String> = tags_raw
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
// ── 6. Confirm ─────────────────────────────────────────────────────────────
let symlink_path = store.config.scripts_dir.join(&name);
let shim_path = config.bin_dir.join(&name);
note(
"Summary",
format!(
"Name: {name}\n\
Source: {source}\n\
Symlink: {symlink}\n\
Shim: {shim}\n\
Description: {description}\n\
Usage: {usage}\n\
Tags: {tags}",
source = source_path.display(),
symlink = symlink_path.display(),
shim = shim_path.display(),
tags = if tags.is_empty() { "none".to_string() } else { tags.join(", ") },
),
)?;
let confirmed = confirm("Register this script?").interact()?;
if !confirmed {
return Err(AppError::Cancelled);
}
// ── 7. Write symlink, shim, registry entry ─────────────────────────────────
// Ensure bin_dir exists
std::fs::create_dir_all(&config.bin_dir)?;
// Create symlink: scripts_dir/name → source_path
if symlink_path.exists() || symlink_path.is_symlink() {
std::fs::remove_file(&symlink_path)?;
}
fs::symlink(&source_path, &symlink_path)?;
// Write the bash shim
write_shim(
&shim_path,
&symlink_path,
&name,
&description,
&usage,
&config.default_shell,
)?;
// Persist to registry
store.registry.add(ScriptEntry {
name: name.clone(),
source_path,
symlink_path,
shim_path,
description,
usage,
tags,
})?;
log::success(format!("'{name}' registered. You can now call it directly."))?;
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)
}