189 lines
6.4 KiB
Rust
189 lines
6.4 KiB
Rust
use std::os::unix::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use cliclack::{confirm, input, log, note};
|
|
|
|
use crate::{
|
|
config::types::ScriptEntry,
|
|
error::AppError,
|
|
services::{path_service::CollisionResult, ServiceStore},
|
|
shim::template::write_shim,
|
|
};
|
|
|
|
pub fn run(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)
|
|
}
|