add:add command + add existing
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
403
Cargo.lock
generated
Normal file
403
Cargo.lock
generated
Normal file
@@ -0,0 +1,403 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cliclack"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fd870c423d925f0257dda3ce62067e2e3b64b0e1ad29bac8affdd4b6ce938bd"
|
||||
dependencies = [
|
||||
"console",
|
||||
"indicatif",
|
||||
"once_cell",
|
||||
"strsim",
|
||||
"textwrap",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"unicode-width",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indicatif"
|
||||
version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
|
||||
dependencies = [
|
||||
"console",
|
||||
"portable-atomic",
|
||||
"unicode-width",
|
||||
"unit-prefix",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.94"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.184"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "scripts-organizer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cliclack",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smawk"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||
dependencies = [
|
||||
"smawk",
|
||||
"unicode-linebreak",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-linebreak"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unit-prefix"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "scripts-organizer"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "scripts-organizer"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
cliclack = "0.5.2"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
thiserror = "2.0.18"
|
||||
toml = "1.1.2"
|
||||
33
src/cli.rs
Normal file
33
src/cli.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
pub mod add;
|
||||
pub mod list;
|
||||
pub mod remove;
|
||||
|
||||
use cliclack::{intro, outro, select};
|
||||
|
||||
use crate::{error::AppError, services::ServiceStore};
|
||||
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
enum MenuAction {
|
||||
Add,
|
||||
List,
|
||||
Remove,
|
||||
}
|
||||
|
||||
pub fn show_main_menu(store: &ServiceStore) -> Result<(), AppError> {
|
||||
intro("scripts-organizer")?;
|
||||
|
||||
let action = select("What would you like to do?")
|
||||
.item(MenuAction::Add, "Add", "register a new script")
|
||||
.item(MenuAction::List, "List", "view and run registered scripts")
|
||||
.item(MenuAction::Remove, "Remove", "unregister a script")
|
||||
.interact()?;
|
||||
|
||||
match action {
|
||||
MenuAction::Add => add::run(store)?,
|
||||
MenuAction::List => list::run(store)?,
|
||||
MenuAction::Remove => remove::run(store)?,
|
||||
}
|
||||
|
||||
outro("Done.")?;
|
||||
Ok(())
|
||||
}
|
||||
211
src/cli/add.rs
Normal file
211
src/cli/add.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
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)
|
||||
}
|
||||
6
src/cli/list.rs
Normal file
6
src/cli/list.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
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(())
|
||||
}
|
||||
6
src/cli/remove.rs
Normal file
6
src/cli/remove.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use crate::{error::AppError, services::ServiceStore};
|
||||
|
||||
pub fn run(_store: &ServiceStore) -> Result<(), AppError> {
|
||||
// TODO: load registry, prompt for selection, delete shim, update registry
|
||||
Ok(())
|
||||
}
|
||||
4
src/config.rs
Normal file
4
src/config.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod defaults;
|
||||
pub mod types;
|
||||
|
||||
pub use types::{Config, Registry, ScriptEntry};
|
||||
38
src/config/defaults.rs
Normal file
38
src/config/defaults.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Returns `~/.config/scripts-organizer/`
|
||||
pub fn config_dir() -> Result<PathBuf, AppError> {
|
||||
Ok(home_dir()?.join(".config").join("scripts-organizer"))
|
||||
}
|
||||
|
||||
/// Returns `~/.config/scripts-organizer/scripts/`
|
||||
/// This directory holds symlinks to the original script files.
|
||||
/// It is always derived — never stored in config.toml.
|
||||
pub fn scripts_dir() -> Result<PathBuf, AppError> {
|
||||
Ok(config_dir()?.join("scripts"))
|
||||
}
|
||||
|
||||
/// Returns `~/.config/scripts-organizer/config.toml`
|
||||
pub fn config_file() -> Result<PathBuf, AppError> {
|
||||
Ok(config_dir()?.join("config.toml"))
|
||||
}
|
||||
|
||||
/// Returns `~/.config/scripts-organizer/registry.toml`
|
||||
pub fn registry_file() -> Result<PathBuf, AppError> {
|
||||
Ok(config_dir()?.join("registry.toml"))
|
||||
}
|
||||
|
||||
/// Returns the user's home directory.
|
||||
pub fn home_dir() -> Result<PathBuf, AppError> {
|
||||
// `HOME` is the most portable approach on Unix; std removed home_dir in 1.29
|
||||
std::env::var("HOME")
|
||||
.map(PathBuf::from)
|
||||
.map_err(|_| AppError::NoHomeDir)
|
||||
}
|
||||
|
||||
/// Default `bin_dir`: `~/.local/bin/`
|
||||
pub fn default_bin_dir() -> Result<PathBuf, AppError> {
|
||||
Ok(home_dir()?.join(".local").join("bin"))
|
||||
}
|
||||
51
src/config/types.rs
Normal file
51
src/config/types.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Global user preferences, stored in `config.toml`.
|
||||
/// This file is user-editable; the program never clobbers an existing one.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Directory where shim files are placed so they are on `$PATH`.
|
||||
/// Default: `~/.local/bin/`
|
||||
pub bin_dir: PathBuf,
|
||||
|
||||
/// Shell used in generated shims.
|
||||
/// Default: `bash`
|
||||
pub default_shell: String,
|
||||
}
|
||||
|
||||
/// A single registered script entry, stored inside `registry.toml`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScriptEntry {
|
||||
/// Friendly name — becomes the shim filename and the callable command (e.g. `git-config`).
|
||||
pub name: String,
|
||||
|
||||
/// Absolute path to the original script file, wherever it lives on the system.
|
||||
pub source_path: PathBuf,
|
||||
|
||||
/// Absolute path to the symlink inside `~/.config/scripts-organizer/scripts/`.
|
||||
/// No file extension — the shim calls this path directly.
|
||||
pub symlink_path: PathBuf,
|
||||
|
||||
/// Absolute path to the generated bash shim in `bin_dir`.
|
||||
pub shim_path: PathBuf,
|
||||
|
||||
/// One-line description shown in the list view and in `--help` output.
|
||||
pub description: String,
|
||||
|
||||
/// Usage line shown in `--help` output (e.g. `git-config [profile] [--global]`).
|
||||
pub usage: String,
|
||||
|
||||
/// Optional tags for filtering in the list view.
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Top-level wrapper for `registry.toml`.
|
||||
/// Kept separate from `Config` so the program can rewrite it freely
|
||||
/// without touching user-edited settings.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Registry {
|
||||
#[serde(default)]
|
||||
pub scripts: Vec<ScriptEntry>,
|
||||
}
|
||||
19
src/error.rs
Normal file
19
src/error.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("config parse error: {0}")]
|
||||
ConfigParse(#[from] toml::de::Error),
|
||||
|
||||
#[error("config serialise error: {0}")]
|
||||
ConfigSerialise(#[from] toml::ser::Error),
|
||||
|
||||
#[error("home directory could not be determined")]
|
||||
NoHomeDir,
|
||||
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
15
src/lib.rs
Normal file
15
src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod services;
|
||||
pub mod shim;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::services::ServiceStore;
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let store = ServiceStore::init()?;
|
||||
cli::show_main_menu(&store)?;
|
||||
Ok(())
|
||||
}
|
||||
5
src/main.rs
Normal file
5
src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use anyhow::Result;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
scripts_organizer::run()
|
||||
}
|
||||
35
src/services.rs
Normal file
35
src/services.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
pub mod config_service;
|
||||
pub mod path_service;
|
||||
pub mod registry_service;
|
||||
|
||||
use crate::error::AppError;
|
||||
use config_service::ConfigService;
|
||||
use path_service::PathService;
|
||||
use registry_service::RegistryService;
|
||||
|
||||
/// Owns all application services and is constructed once at startup.
|
||||
/// Pass as `&ServiceStore` into every CLI handler.
|
||||
pub struct ServiceStore {
|
||||
pub config: ConfigService,
|
||||
pub registry: RegistryService,
|
||||
pub path: PathService,
|
||||
}
|
||||
|
||||
impl ServiceStore {
|
||||
/// Boots all services: resolves paths, creates missing config files/dirs.
|
||||
/// `PathService` is constructed after config is loaded so it can exclude
|
||||
/// our own bin_dir from collision results.
|
||||
pub fn init() -> Result<Self, AppError> {
|
||||
let config = ConfigService::new()?;
|
||||
let registry = RegistryService::new()?;
|
||||
|
||||
config.ensure_initialized()?;
|
||||
registry.ensure_initialized()?;
|
||||
|
||||
// Load config to get bin_dir for PathService
|
||||
let loaded_config = config.load()?;
|
||||
let path = PathService::new(loaded_config.bin_dir);
|
||||
|
||||
Ok(Self { config, registry, path })
|
||||
}
|
||||
}
|
||||
55
src/services/config_service.rs
Normal file
55
src/services/config_service.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use crate::{
|
||||
config::{
|
||||
defaults::{config_dir, config_file, default_bin_dir, scripts_dir},
|
||||
types::Config,
|
||||
},
|
||||
error::AppError,
|
||||
};
|
||||
|
||||
pub struct ConfigService {
|
||||
pub config_dir: PathBuf,
|
||||
pub scripts_dir: PathBuf,
|
||||
pub config_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ConfigService {
|
||||
pub fn new() -> Result<Self, AppError> {
|
||||
Ok(Self {
|
||||
config_dir: config_dir()?,
|
||||
scripts_dir: scripts_dir()?,
|
||||
config_path: config_file()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates all required directories and a default `config.toml` if they do
|
||||
/// not already exist. Safe to call on every startup.
|
||||
pub fn ensure_initialized(&self) -> Result<(), AppError> {
|
||||
fs::create_dir_all(&self.config_dir)?;
|
||||
fs::create_dir_all(&self.scripts_dir)?;
|
||||
|
||||
if !self.config_path.exists() {
|
||||
let default_config = Config {
|
||||
bin_dir: default_bin_dir()?,
|
||||
default_shell: "bash".to_string(),
|
||||
};
|
||||
let contents = toml::to_string_pretty(&default_config)?;
|
||||
fs::write(&self.config_path, contents)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<Config, AppError> {
|
||||
let contents = fs::read_to_string(&self.config_path)?;
|
||||
let config = toml::from_str(&contents)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save(&self, config: &Config) -> Result<(), AppError> {
|
||||
let contents = toml::to_string_pretty(config)?;
|
||||
fs::write(&self.config_path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
55
src/services/path_service.rs
Normal file
55
src/services/path_service.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::{
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// Result of checking whether a name collides with an existing executable on `$PATH`.
|
||||
#[derive(Debug)]
|
||||
pub enum CollisionResult {
|
||||
/// No conflict found — safe to use this name.
|
||||
Clear,
|
||||
/// A file with this name already exists at the given path.
|
||||
/// The caller decides whether to warn and proceed or re-prompt.
|
||||
Collision { path: PathBuf },
|
||||
}
|
||||
|
||||
pub struct PathService {
|
||||
/// Our own bin_dir — shims we've written here are excluded from collision results
|
||||
/// so we don't report our own scripts as conflicts.
|
||||
bin_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl PathService {
|
||||
pub fn new(bin_dir: PathBuf) -> Self {
|
||||
Self { bin_dir }
|
||||
}
|
||||
|
||||
/// Walks every directory in `$PATH` looking for an executable named `name`.
|
||||
/// Skips our own `bin_dir` so previously registered shims don't self-collide.
|
||||
pub fn check_collision(&self, name: &str) -> CollisionResult {
|
||||
let path_var = env::var("PATH").unwrap_or_default();
|
||||
|
||||
for dir in env::split_paths(&path_var) {
|
||||
// Skip our own bin_dir — our shims live here
|
||||
if dir == self.bin_dir {
|
||||
continue;
|
||||
}
|
||||
|
||||
let candidate = dir.join(name);
|
||||
if is_executable(&candidate) {
|
||||
return CollisionResult::Collision { path: candidate };
|
||||
}
|
||||
}
|
||||
|
||||
CollisionResult::Clear
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the path exists, is a file, and has at least one executable bit set.
|
||||
fn is_executable(path: &Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
path.metadata()
|
||||
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
53
src/services/registry_service.rs
Normal file
53
src/services/registry_service.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use crate::{
|
||||
config::{defaults::registry_file, types::{Registry, ScriptEntry}},
|
||||
error::AppError,
|
||||
};
|
||||
|
||||
pub struct RegistryService {
|
||||
pub registry_path: PathBuf,
|
||||
}
|
||||
|
||||
impl RegistryService {
|
||||
pub fn new() -> Result<Self, AppError> {
|
||||
Ok(Self {
|
||||
registry_path: registry_file()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates an empty `registry.toml` if it does not already exist.
|
||||
pub fn ensure_initialized(&self) -> Result<(), AppError> {
|
||||
if !self.registry_path.exists() {
|
||||
let empty = Registry::default();
|
||||
let contents = toml::to_string_pretty(&empty)?;
|
||||
fs::write(&self.registry_path, contents)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<Registry, AppError> {
|
||||
let contents = fs::read_to_string(&self.registry_path)?;
|
||||
let registry = toml::from_str(&contents)?;
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
pub fn save(&self, registry: &Registry) -> Result<(), AppError> {
|
||||
let contents = toml::to_string_pretty(registry)?;
|
||||
fs::write(&self.registry_path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Appends a new entry and persists the registry.
|
||||
pub fn add(&self, entry: ScriptEntry) -> Result<(), AppError> {
|
||||
let mut registry = self.load()?;
|
||||
registry.scripts.push(entry);
|
||||
self.save(®istry)
|
||||
}
|
||||
|
||||
/// Returns true if a script with the given name is already registered.
|
||||
pub fn name_exists(&self, name: &str) -> Result<bool, AppError> {
|
||||
let registry = self.load()?;
|
||||
Ok(registry.scripts.iter().any(|s| s.name == name))
|
||||
}
|
||||
}
|
||||
1
src/shim.rs
Normal file
1
src/shim.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod template;
|
||||
82
src/shim/template.rs
Normal file
82
src/shim/template.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use std::{
|
||||
fs,
|
||||
os::unix::fs::PermissionsExt,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Renders and writes a bash shim to `shim_path`, then makes it executable.
|
||||
///
|
||||
/// The shim intercepts `--help` / `-h` and prints uniform metadata.
|
||||
/// All other arguments are forwarded to the symlink target via `exec`.
|
||||
pub fn write_shim(
|
||||
shim_path: &Path,
|
||||
symlink_path: &Path,
|
||||
name: &str,
|
||||
description: &str,
|
||||
usage: &str,
|
||||
shell: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let contents = render(symlink_path, name, description, usage, shell);
|
||||
fs::write(shim_path, contents)?;
|
||||
fs::set_permissions(shim_path, fs::Permissions::from_mode(0o755))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renders the shim script as a String.
|
||||
fn render(
|
||||
symlink_path: &Path,
|
||||
name: &str,
|
||||
description: &str,
|
||||
usage: &str,
|
||||
shell: &str,
|
||||
) -> String {
|
||||
// Use display() for the path — symlinks inside our config dir are always valid UTF-8
|
||||
let target = symlink_path.display();
|
||||
|
||||
format!(
|
||||
r#"#!/usr/bin/env {shell}
|
||||
# Generated by scripts-organizer — do not edit manually.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_TARGET="{target}"
|
||||
SCRIPT_NAME="{name}"
|
||||
SCRIPT_DESCRIPTION="{description}"
|
||||
SCRIPT_USAGE="{usage}"
|
||||
|
||||
if [[ "${{1:-}}" == "--help" || "${{1:-}}" == "-h" ]]; then
|
||||
echo ""
|
||||
echo " $SCRIPT_NAME"
|
||||
echo " $SCRIPT_DESCRIPTION"
|
||||
echo ""
|
||||
echo " Usage: $SCRIPT_USAGE"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec "$SCRIPT_TARGET" "$@"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn renders_help_block() {
|
||||
let rendered = render(
|
||||
&PathBuf::from("/home/user/.config/scripts-organizer/scripts/git-config"),
|
||||
"git-config",
|
||||
"Manages git user config across profiles",
|
||||
"git-config [profile] [--global]",
|
||||
"bash",
|
||||
);
|
||||
assert!(rendered.contains("#!/usr/bin/env bash"));
|
||||
assert!(rendered.contains("exec \"$SCRIPT_TARGET\" \"$@\""));
|
||||
assert!(rendered.contains("--help"));
|
||||
assert!(rendered.contains("git-config"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user