add:add command + add existing

This commit is contained in:
kokopi-dev
2026-04-04 22:45:23 +09:00
parent 973356cda5
commit 69fa44b36a
19 changed files with 1088 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

403
Cargo.lock generated Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
pub mod defaults;
pub mod types;
pub use types::{Config, Registry, ScriptEntry};

38
src/config/defaults.rs Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
use anyhow::Result;
fn main() -> Result<()> {
scripts_organizer::run()
}

35
src/services.rs Normal file
View 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 })
}
}

View 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(())
}
}

View 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)
}

View 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(&registry)
}
/// 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
View File

@@ -0,0 +1 @@
pub mod template;

82
src/shim/template.rs Normal file
View 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"));
}
}