diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..7f81904 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1 @@ +use_field_init_shorthand = true \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..47a7ecf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["alx", "clippy"] +} diff --git a/Cargo.lock b/Cargo.lock index d0db828..dac03ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "alx" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 78bcf26..f6daa2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "alx" description = "Setup simple alias manager" authors = ["hiro08gh "] -version = "0.2.0" +version = "0.3.0" license = "MIT" categories = ["command-line-utilities"] repository = "https://github.com/hiro08gh/alx" diff --git a/README.md b/README.md index b2e8c2e..162b064 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,25 @@ alx import aliases.json alx groups ``` +## Migration guide + +You can automatically apply settings from your current Bash shell configuration to alx. + +Run the `alx migrate` command. This command targets files such as `.bashrc`, `.zshrc`, and `config.fish`, specifically focusing on `alias (ex: alias gs="git status")` definitions within those files. + +```bash +# Initialize alx +alx init + +# Migrate your target shell +alx migrate or alx migrate --from "./bashrc" + +# Check if the aliases are applied correctly +alx list +``` + +Remove the aliases from the shell settings, if there are no issues. + ## Development ### Build diff --git a/src/cli.rs b/src/cli.rs index e5b3767..be8791a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -91,4 +91,11 @@ pub enum Commands { // Show information about alx Info, + + // Migrate aliases from shell configuration file + Migrate { + // Shell configuration file to migrate from (optional) + #[arg(short, long)] + from: Option, + }, } diff --git a/src/command.rs b/src/command.rs index 523a36e..4aaec0e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -378,3 +378,71 @@ pub fn info() -> Result<()> { Ok(()) } + +pub fn migrate(from: Option) -> Result<()> { + let config_manager = ConfigManager::new()?; + let mut store = AliasStore::load(config_manager.aliases_file())?; + + // Determine the config file path and shell type + let (config_path, shell_type) = if let Some(path) = from { + let path_buf = std::path::PathBuf::from(path); + let shell_type = ShellDetector::detect_from_path(&path_buf)?; + (path_buf, shell_type) + } else { + let shell_type = ShellDetector::detect()?; + let handler: Box = match shell_type { + ShellType::Bash => Box::new(BashHandler::new()), + ShellType::Zsh => Box::new(ZshHandler::new()), + ShellType::Fish => Box::new(FishHandler::new()), + }; + (handler.config_file_path()?, shell_type) + }; + + if !config_path.exists() { + return Err(error::AlxError::ConfigError(format!( + "Configuration file not found: {:?}", + config_path + ))); + } + + let handler: Box = match shell_type { + ShellType::Bash => Box::new(BashHandler::new()), + ShellType::Zsh => Box::new(ZshHandler::new()), + ShellType::Fish => Box::new(FishHandler::new()), + }; + + println!("Migrating aliases from: {:?}", config_path); + + // Parse aliases from the config file + let parsed_aliases = handler.parse_aliases_from_file(&config_path)?; + + if parsed_aliases.is_empty() { + println!("No aliases found in the configuration file"); + return Ok(()); + } + + let mut imported_count = 0; + let mut skipped_count = 0; + + for (name, command) in parsed_aliases { + if store.exists(&name) { + skipped_count += 1; + eprintln!(" Skipped existing alias: {}", name); + } else { + let alias = Alias::new(name.clone(), command); + store.add(alias)?; + imported_count += 1; + } + } + + store.save(config_manager.aliases_file())?; + + sync_aliases()?; + + println!("✓ Migrated {} aliases", imported_count); + if skipped_count > 0 { + println!(" Skipped {} existing aliases", skipped_count); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 25095f7..7caabfa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,5 +40,6 @@ fn run() -> Result<()> { Commands::Import { file } => command::import(file), Commands::Groups => command::groups(), Commands::Info => command::info(), + Commands::Migrate { from } => command::migrate(from), } } diff --git a/src/shell/bash.rs b/src/shell/bash.rs index a0ccaa4..87fbf18 100644 --- a/src/shell/bash.rs +++ b/src/shell/bash.rs @@ -66,6 +66,76 @@ impl ShellHandler for BashHandler { })?; Ok(home.join(".bashrc")) } + + fn parse_aliases_from_file(&self, path: &std::path::Path) -> Result> { + use std::fs; + + let content = fs::read_to_string(path)?; + let mut aliases = Vec::new(); + let mut current_line = String::new(); + + for line in content.lines() { + let trimmed = line.trim(); + + // Handle line continuation + if current_line.is_empty() { + current_line = trimmed.to_string(); + } else { + current_line.push(' '); + current_line.push_str(trimmed); + } + + // Check if line continues + if current_line.ends_with('\\') { + current_line.pop(); // Remove backslash + continue; + } + + // Parse alias from the complete line + if let Some(alias) = Self::parse_alias_line(¤t_line) { + aliases.push(alias); + } + + current_line.clear(); + } + + Ok(aliases) + } +} + +impl BashHandler { + fn parse_alias_line(line: &str) -> Option<(String, String)> { + let trimmed = line.trim(); + + // Skip comments and empty lines + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + // Check if line starts with 'alias ' + if !trimmed.starts_with("alias ") { + return None; + } + + // Remove 'alias ' prefix + let alias_def = trimmed.strip_prefix("alias ")?.trim(); + + // Find the '=' separator + let equals_pos = alias_def.find('=')?; + let name = alias_def[..equals_pos].trim().to_string(); + let value_part = alias_def[equals_pos + 1..].trim(); + + // Remove quotes (single or double) + let command = if (value_part.starts_with('\'') && value_part.ends_with('\'')) + || (value_part.starts_with('"') && value_part.ends_with('"')) + { + value_part[1..value_part.len() - 1].to_string() + } else { + value_part.to_string() + }; + + Some((name, command)) + } } impl Default for BashHandler { @@ -77,6 +147,7 @@ impl Default for BashHandler { #[cfg(test)] mod tests { use super::*; + use std::io::Write; #[test] fn test_generate_alias_line() { @@ -112,4 +183,50 @@ mod tests { assert!(content.contains("alias gs='git status'")); assert!(content.contains("# List all files")); } + + #[test] + fn test_parse_aliases_from_file() { + let handler = BashHandler::new(); + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join(".bashrc"); + + let content = r#" +# Some comment +alias ll='ls -la' +alias gs="git status" +alias gp='git push' + +# Multi-line alias +alias complex='echo "hello" && \ + echo "world"' + +# Not an alias +export PATH=$PATH:/usr/local/bin +"#; + + let mut file = std::fs::File::create(&file_path).unwrap(); + file.write_all(content.as_bytes()).unwrap(); + + let aliases = handler.parse_aliases_from_file(&file_path).unwrap(); + + assert_eq!(aliases.len(), 4); + assert!(aliases.contains(&("ll".to_string(), "ls -la".to_string()))); + assert!(aliases.contains(&("gs".to_string(), "git status".to_string()))); + assert!(aliases.contains(&("gp".to_string(), "git push".to_string()))); + assert!(aliases.iter().any(|(name, _)| name == "complex")); + } + + #[test] + fn test_parse_aliases_empty_file() { + let handler = BashHandler::new(); + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join(".bashrc"); + + let mut file = std::fs::File::create(&file_path).unwrap(); + file.write_all(b"# Just comments\n").unwrap(); + + let aliases = handler.parse_aliases_from_file(&file_path).unwrap(); + + assert_eq!(aliases.len(), 0); + } } diff --git a/src/shell/detector.rs b/src/shell/detector.rs index 07e46de..875a4b8 100644 --- a/src/shell/detector.rs +++ b/src/shell/detector.rs @@ -1,6 +1,7 @@ use crate::error::{AlxError, Result}; use crate::shell::ShellType; use std::env; +use std::path::Path; pub struct ShellDetector; @@ -24,6 +25,34 @@ impl ShellDetector { Err(AlxError::ShellDetectionFailed) } + pub fn detect_from_path>(path: P) -> Result { + let path = path.as_ref(); + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| AlxError::ConfigError(format!("Invalid file path: {:?}", path)))?; + + // Check for bash config files + if file_name.contains("bash") { + return Ok(ShellType::Bash); + } + + // Check for zsh config files + if file_name.contains("zsh") { + return Ok(ShellType::Zsh); + } + + // Check for fish config files + if file_name.contains("fish") || path.to_string_lossy().contains("fish") { + return Ok(ShellType::Fish); + } + + Err(AlxError::ConfigError(format!( + "Could not detect shell type from file path: {:?}", + path + ))) + } + fn parse_shell_name(name: &str) -> Result { if !Self::is_supported(name) { return Err(AlxError::UnsupportedShell(name.to_string())); diff --git a/src/shell/fish.rs b/src/shell/fish.rs index c00b7d7..5373351 100644 --- a/src/shell/fish.rs +++ b/src/shell/fish.rs @@ -66,6 +66,76 @@ impl ShellHandler for FishHandler { })?; Ok(home.join(".config/fish/config.fish")) } + + fn parse_aliases_from_file(&self, path: &std::path::Path) -> Result> { + use std::fs; + + let content = fs::read_to_string(path)?; + let mut aliases = Vec::new(); + let mut current_line = String::new(); + + for line in content.lines() { + let trimmed = line.trim(); + + // Handle line continuation + if current_line.is_empty() { + current_line = trimmed.to_string(); + } else { + current_line.push(' '); + current_line.push_str(trimmed); + } + + // Check if line continues + if current_line.ends_with('\\') { + current_line.pop(); // Remove backslash + continue; + } + + // Parse alias from the complete line + if let Some(alias) = Self::parse_alias_line(¤t_line) { + aliases.push(alias); + } + + current_line.clear(); + } + + Ok(aliases) + } +} + +impl FishHandler { + fn parse_alias_line(line: &str) -> Option<(String, String)> { + let trimmed = line.trim(); + + // Skip comments and empty lines + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + // Check if line starts with 'alias ' + if !trimmed.starts_with("alias ") { + return None; + } + + // Remove 'alias ' prefix + let alias_def = trimmed.strip_prefix("alias ")?.trim(); + + // Find the first space to separate name and command + let space_pos = alias_def.find(' ')?; + let name = alias_def[..space_pos].trim().to_string(); + let value_part = alias_def[space_pos + 1..].trim(); + + // Remove quotes (single or double) + let command = if (value_part.starts_with('\'') && value_part.ends_with('\'')) + || (value_part.starts_with('"') && value_part.ends_with('"')) + { + value_part[1..value_part.len() - 1].to_string() + } else { + value_part.to_string() + }; + + Some((name, command)) + } } impl Default for FishHandler { diff --git a/src/shell/mod.rs b/src/shell/mod.rs index e6b69d0..1c0ce53 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -27,4 +27,5 @@ pub trait ShellHandler { fn generate_alias_line(&self, alias: &Alias) -> String; fn generate_aliases_file(&self, aliases: &[&Alias]) -> String; fn config_file_path(&self) -> Result; + fn parse_aliases_from_file(&self, path: &std::path::Path) -> Result>; } diff --git a/src/shell/zsh.rs b/src/shell/zsh.rs index b5837d3..2057adc 100644 --- a/src/shell/zsh.rs +++ b/src/shell/zsh.rs @@ -66,6 +66,76 @@ impl ShellHandler for ZshHandler { })?; Ok(home.join(".zshrc")) } + + fn parse_aliases_from_file(&self, path: &std::path::Path) -> Result> { + use std::fs; + + let content = fs::read_to_string(path)?; + let mut aliases = Vec::new(); + let mut current_line = String::new(); + + for line in content.lines() { + let trimmed = line.trim(); + + // Handle line continuation + if current_line.is_empty() { + current_line = trimmed.to_string(); + } else { + current_line.push(' '); + current_line.push_str(trimmed); + } + + // Check if line continues + if current_line.ends_with('\\') { + current_line.pop(); // Remove backslash + continue; + } + + // Parse alias from the complete line + if let Some(alias) = Self::parse_alias_line(¤t_line) { + aliases.push(alias); + } + + current_line.clear(); + } + + Ok(aliases) + } +} + +impl ZshHandler { + fn parse_alias_line(line: &str) -> Option<(String, String)> { + let trimmed = line.trim(); + + // Skip comments and empty lines + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + // Check if line starts with 'alias ' + if !trimmed.starts_with("alias ") { + return None; + } + + // Remove 'alias ' prefix + let alias_def = trimmed.strip_prefix("alias ")?.trim(); + + // Find the '=' separator + let equals_pos = alias_def.find('=')?; + let name = alias_def[..equals_pos].trim().to_string(); + let value_part = alias_def[equals_pos + 1..].trim(); + + // Remove quotes (single or double) + let command = if (value_part.starts_with('\'') && value_part.ends_with('\'')) + || (value_part.starts_with('"') && value_part.ends_with('"')) + { + value_part[1..value_part.len() - 1].to_string() + } else { + value_part.to_string() + }; + + Some((name, command)) + } } impl Default for ZshHandler {