From 69fa44b36abbca0ee91d4bf3a740219e8843d36c Mon Sep 17 00:00:00 2001 From: kokopi-dev Date: Sat, 4 Apr 2026 22:45:23 +0900 Subject: [PATCH] add:add command + add existing --- .gitignore | 1 + Cargo.lock | 403 +++++++++++++++++++++++++++++++ Cargo.toml | 15 ++ src/cli.rs | 33 +++ src/cli/add.rs | 211 ++++++++++++++++ src/cli/list.rs | 6 + src/cli/remove.rs | 6 + src/config.rs | 4 + src/config/defaults.rs | 38 +++ src/config/types.rs | 51 ++++ src/error.rs | 19 ++ src/lib.rs | 15 ++ src/main.rs | 5 + src/services.rs | 35 +++ src/services/config_service.rs | 55 +++++ src/services/path_service.rs | 55 +++++ src/services/registry_service.rs | 53 ++++ src/shim.rs | 1 + src/shim/template.rs | 82 +++++++ 19 files changed, 1088 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/cli.rs create mode 100644 src/cli/add.rs create mode 100644 src/cli/list.rs create mode 100644 src/cli/remove.rs create mode 100644 src/config.rs create mode 100644 src/config/defaults.rs create mode 100644 src/config/types.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/services.rs create mode 100644 src/services/config_service.rs create mode 100644 src/services/path_service.rs create mode 100644 src/services/registry_service.rs create mode 100644 src/shim.rs create mode 100644 src/shim/template.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f3eaa56 --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8009b91 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..526af5b --- /dev/null +++ b/src/cli.rs @@ -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(()) +} diff --git a/src/cli/add.rs b/src/cli/add.rs new file mode 100644 index 0000000..72bd3fe --- /dev/null +++ b/src/cli/add.rs @@ -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 = 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) +} diff --git a/src/cli/list.rs b/src/cli/list.rs new file mode 100644 index 0000000..08e36d7 --- /dev/null +++ b/src/cli/list.rs @@ -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(()) +} diff --git a/src/cli/remove.rs b/src/cli/remove.rs new file mode 100644 index 0000000..244e34d --- /dev/null +++ b/src/cli/remove.rs @@ -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(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..f3c743b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,4 @@ +pub mod defaults; +pub mod types; + +pub use types::{Config, Registry, ScriptEntry}; diff --git a/src/config/defaults.rs b/src/config/defaults.rs new file mode 100644 index 0000000..88b9311 --- /dev/null +++ b/src/config/defaults.rs @@ -0,0 +1,38 @@ +use std::path::PathBuf; + +use crate::error::AppError; + +/// Returns `~/.config/scripts-organizer/` +pub fn config_dir() -> Result { + 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 { + Ok(config_dir()?.join("scripts")) +} + +/// Returns `~/.config/scripts-organizer/config.toml` +pub fn config_file() -> Result { + Ok(config_dir()?.join("config.toml")) +} + +/// Returns `~/.config/scripts-organizer/registry.toml` +pub fn registry_file() -> Result { + Ok(config_dir()?.join("registry.toml")) +} + +/// Returns the user's home directory. +pub fn home_dir() -> Result { + // `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 { + Ok(home_dir()?.join(".local").join("bin")) +} diff --git a/src/config/types.rs b/src/config/types.rs new file mode 100644 index 0000000..aa2f631 --- /dev/null +++ b/src/config/types.rs @@ -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, +} + +/// 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, +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..45abf81 --- /dev/null +++ b/src/error.rs @@ -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, +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..20f0ea8 --- /dev/null +++ b/src/lib.rs @@ -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(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..305b7da --- /dev/null +++ b/src/main.rs @@ -0,0 +1,5 @@ +use anyhow::Result; + +fn main() -> Result<()> { + scripts_organizer::run() +} diff --git a/src/services.rs b/src/services.rs new file mode 100644 index 0000000..0005629 --- /dev/null +++ b/src/services.rs @@ -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 { + 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 }) + } +} diff --git a/src/services/config_service.rs b/src/services/config_service.rs new file mode 100644 index 0000000..c879a14 --- /dev/null +++ b/src/services/config_service.rs @@ -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 { + 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 { + 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(()) + } +} diff --git a/src/services/path_service.rs b/src/services/path_service.rs new file mode 100644 index 0000000..e206351 --- /dev/null +++ b/src/services/path_service.rs @@ -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) +} diff --git a/src/services/registry_service.rs b/src/services/registry_service.rs new file mode 100644 index 0000000..02a4a14 --- /dev/null +++ b/src/services/registry_service.rs @@ -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 { + 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 { + 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 { + let registry = self.load()?; + Ok(registry.scripts.iter().any(|s| s.name == name)) + } +} diff --git a/src/shim.rs b/src/shim.rs new file mode 100644 index 0000000..612b5b9 --- /dev/null +++ b/src/shim.rs @@ -0,0 +1 @@ +pub mod template; diff --git a/src/shim/template.rs b/src/shim/template.rs new file mode 100644 index 0000000..fe2aa71 --- /dev/null +++ b/src/shim/template.rs @@ -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")); + } +}