diff --git a/src/services.rs b/src/services.rs index 0005629..a0ad500 100644 --- a/src/services.rs +++ b/src/services.rs @@ -1,11 +1,13 @@ pub mod config_service; pub mod path_service; pub mod registry_service; +pub mod shell_service; use crate::error::AppError; use config_service::ConfigService; use path_service::PathService; use registry_service::RegistryService; +use shell_service::ShellService; /// Owns all application services and is constructed once at startup. /// Pass as `&ServiceStore` into every CLI handler. @@ -13,12 +15,12 @@ pub struct ServiceStore { pub config: ConfigService, pub registry: RegistryService, pub path: PathService, + pub shell: ShellService, } 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. + /// Boots all services: resolves paths, creates missing config files/dirs, + /// and ensures bin_dir is on $PATH. pub fn init() -> Result { let config = ConfigService::new()?; let registry = RegistryService::new()?; @@ -26,10 +28,13 @@ impl ServiceStore { 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); + let path = PathService::new(loaded_config.bin_dir.clone()); + let shell = ShellService::new(loaded_config.bin_dir.clone()); - Ok(Self { config, registry, path }) + // Check and patch $PATH on every startup — idempotent, safe to repeat + shell.ensure_bin_dir_on_path()?; + + Ok(Self { config, registry, path, shell }) } } diff --git a/src/services/shell_service.rs b/src/services/shell_service.rs new file mode 100644 index 0000000..576091b --- /dev/null +++ b/src/services/shell_service.rs @@ -0,0 +1,121 @@ +use std::io::Write; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use cliclack::log; + +use crate::error::AppError; + +/// The line we append to the shell rc file, guarded by a marker comment so we +/// never append it twice and can reliably detect our own prior writes. +const PATH_MARKER: &str = "# scripts-organizer: bin_dir"; +const PATH_EXPORT: &str = "export PATH=\"$HOME/.local/bin:$PATH\""; + +/// Result of checking whether bin_dir is already on $PATH. +#[derive(Debug)] +pub enum PathStatus { + /// bin_dir is already present in the current $PATH — nothing to do. + AlreadySet, + /// bin_dir was not found; we appended the export line to the rc file. + Added { rc_path: PathBuf }, + /// bin_dir was not found and we could not determine a supported rc file. + NoRcFound, +} + +pub struct ShellService { + bin_dir: PathBuf, +} + +impl ShellService { + pub fn new(bin_dir: PathBuf) -> Self { + Self { bin_dir } + } + + /// Checks whether `bin_dir` is on the current `$PATH`. + /// If it is not, attempts to append an export line to the user's shell rc. + /// Prints an informational message via cliclack for either outcome. + pub fn ensure_bin_dir_on_path(&self) -> Result { + if self.is_on_path() { + return Ok(PathStatus::AlreadySet); + } + + match detect_rc_file() { + Some(rc_path) => { + self.append_to_rc(&rc_path)?; + log::info(format!( + "Added {} to PATH in {}\n Restart your shell or run: source {}", + self.bin_dir.display(), + rc_path.display(), + rc_path.display(), + ))?; + Ok(PathStatus::Added { rc_path }) + } + None => { + log::warning(format!( + "{} is not on your PATH and no supported rc file was found.\n Add this line to your shell config manually:\n\n {}", + self.bin_dir.display(), + PATH_EXPORT, + ))?; + Ok(PathStatus::NoRcFound) + } + } + } + + /// Returns true if `bin_dir` appears in any entry of the current `$PATH`. + fn is_on_path(&self) -> bool { + let path_var = env::var("PATH").unwrap_or_default(); + env::split_paths(&path_var).any(|p| p == self.bin_dir) + } + + /// Appends the PATH export to `rc_path`, but only if our marker isn't already + /// present — making this safe to call repeatedly. + fn append_to_rc(&self, rc_path: &Path) -> Result<(), AppError> { + let existing = if rc_path.exists() { + fs::read_to_string(rc_path)? + } else { + String::new() + }; + + // Idempotency guard — never write twice + if existing.contains(PATH_MARKER) { + return Ok(()); + } + + let addition = format!( + "\n{PATH_MARKER}\n{PATH_EXPORT}\n", + ); + + // Append rather than overwrite — never touch anything already in the file + fs::OpenOptions::new() + .create(true) + .append(true) + .open(rc_path)? + .write_all(addition.as_bytes())?; + + Ok(()) + } +} + +/// Detects which rc file to write to based on what exists in $HOME. +/// Preference order: ~/.bashrc → ~/.bash_profile → ~/.profile +/// Returns `None` if none of these are found and none can be created. +fn detect_rc_file() -> Option { + let home = env::var("HOME").ok()?; + let home = PathBuf::from(home); + + // Candidates in preference order for bash/Linux + let candidates = [".bashrc", ".bash_profile", ".profile"]; + + // Return the first one that already exists + for name in &candidates { + let path = home.join(name); + if path.exists() { + return Some(path); + } + } + + // Nothing exists yet — fall back to creating ~/.bashrc + Some(home.join(".bashrc")) +}