From 29e23b90f7010a32487498d7a855c6930247cdcd Mon Sep 17 00:00:00 2001 From: hiro08gh Date: Wed, 12 Nov 2025 09:00:49 +0900 Subject: [PATCH 1/7] add: migrate commands --- src/cli.rs | 11 +++++ src/command.rs | 65 ++++++++++++++++++++++++++ src/main.rs | 1 + src/shell/bash.rs | 117 ++++++++++++++++++++++++++++++++++++++++++++++ src/shell/fish.rs | 70 +++++++++++++++++++++++++++ src/shell/mod.rs | 1 + src/shell/zsh.rs | 70 +++++++++++++++++++++++++++ 7 files changed, 335 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index c64c92d..f669a48 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -107,4 +107,15 @@ 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, + + // Group to assign to migrated aliases (optional) + #[arg(short, long)] + group: Option, + }, } diff --git a/src/command.rs b/src/command.rs index 3614535..1d97854 100644 --- a/src/command.rs +++ b/src/command.rs @@ -416,3 +416,68 @@ pub fn info() -> Result<()> { Ok(()) } + +pub fn migrate(from: Option, group: Option) -> Result<()> { + let config_manager = ConfigManager::new()?; + let mut store = AliasStore::load(config_manager.aliases_file())?; + + 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()), + }; + + // Determine the config file path + let config_path = if let Some(path) = from { + std::path::PathBuf::from(path) + } else { + handler.config_file_path()? + }; + + if !config_path.exists() { + return Err(error::AlxError::ConfigError(format!( + "Configuration file not found: {:?}", + config_path + ))); + } + + 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 mut alias = Alias::new(name.clone(), command); + if let Some(grp) = &group { + alias = alias.with_group(grp.clone()); + } + 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 5a645fc..5874ad7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,5 +45,6 @@ fn run() -> Result<()> { Commands::Import { file } => command::import(file), Commands::Groups => command::groups(), Commands::Info => command::info(), + Commands::Migrate { from, group } => command::migrate(from, group), } } 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/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 { From cfa290b0befe13623c4de82598e90ab522a7c791 Mon Sep 17 00:00:00 2001 From: hiro08gh Date: Wed, 12 Nov 2025 16:44:16 +0900 Subject: [PATCH 2/7] chore: replace --from option --- src/command.rs | 30 ++++++++++++++++++------------ src/shell/detector.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/command.rs b/src/command.rs index 18d4096..ce4dc48 100644 --- a/src/command.rs +++ b/src/command.rs @@ -383,19 +383,19 @@ pub fn migrate(from: Option, group: Option) -> Result<()> { let config_manager = ConfigManager::new()?; let mut store = AliasStore::load(config_manager.aliases_file())?; - 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()), - }; - - // Determine the config file path - let config_path = if let Some(path) = from { - std::path::PathBuf::from(path) + // 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 { - handler.config_file_path()? + 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() { @@ -405,6 +405,12 @@ pub fn migrate(from: Option, group: Option) -> Result<()> { ))); } + 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 diff --git a/src/shell/detector.rs b/src/shell/detector.rs index 07e46de..183b08b 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,36 @@ 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())); From 473f234c2eaeffc967bb754813dcb824c2aa3ebf Mon Sep 17 00:00:00 2001 From: hiro08gh Date: Wed, 12 Nov 2025 16:50:35 +0900 Subject: [PATCH 3/7] fix: format --- src/shell/detector.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/shell/detector.rs b/src/shell/detector.rs index 183b08b..875a4b8 100644 --- a/src/shell/detector.rs +++ b/src/shell/detector.rs @@ -30,9 +30,7 @@ impl ShellDetector { let file_name = path .file_name() .and_then(|n| n.to_str()) - .ok_or_else(|| { - AlxError::ConfigError(format!("Invalid file path: {:?}", path)) - })?; + .ok_or_else(|| AlxError::ConfigError(format!("Invalid file path: {:?}", path)))?; // Check for bash config files if file_name.contains("bash") { From 97e50c63dfaa99dd4364c18582a9437a96b745d9 Mon Sep 17 00:00:00 2001 From: hiro08gh Date: Wed, 12 Nov 2025 16:58:42 +0900 Subject: [PATCH 4/7] chore: remove group flag --- src/cli.rs | 4 ---- src/command.rs | 7 ++----- src/main.rs | 2 +- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 594e224..be8791a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -97,9 +97,5 @@ pub enum Commands { // Shell configuration file to migrate from (optional) #[arg(short, long)] from: Option, - - // Group to assign to migrated aliases (optional) - #[arg(short, long)] - group: Option, }, } diff --git a/src/command.rs b/src/command.rs index ce4dc48..4aaec0e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -379,7 +379,7 @@ pub fn info() -> Result<()> { Ok(()) } -pub fn migrate(from: Option, group: Option) -> Result<()> { +pub fn migrate(from: Option) -> Result<()> { let config_manager = ConfigManager::new()?; let mut store = AliasStore::load(config_manager.aliases_file())?; @@ -429,10 +429,7 @@ pub fn migrate(from: Option, group: Option) -> Result<()> { skipped_count += 1; eprintln!(" Skipped existing alias: {}", name); } else { - let mut alias = Alias::new(name.clone(), command); - if let Some(grp) = &group { - alias = alias.with_group(grp.clone()); - } + let alias = Alias::new(name.clone(), command); store.add(alias)?; imported_count += 1; } diff --git a/src/main.rs b/src/main.rs index 660bd9e..7caabfa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,6 @@ fn run() -> Result<()> { Commands::Import { file } => command::import(file), Commands::Groups => command::groups(), Commands::Info => command::info(), - Commands::Migrate { from, group } => command::migrate(from, group), + Commands::Migrate { from } => command::migrate(from), } } From 726da84d7d76e12d7ef7c85a4e815484f2c9808e Mon Sep 17 00:00:00 2001 From: hiro08gh Date: Wed, 12 Nov 2025 17:05:16 +0900 Subject: [PATCH 5/7] update: version 0.3.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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" From e564ffdbe40945dbfa8b33c4655fded2c33cafc7 Mon Sep 17 00:00:00 2001 From: hiro08gh Date: Wed, 12 Nov 2025 17:33:02 +0900 Subject: [PATCH 6/7] add: migration guide to README.md --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 From 6547edd317aadaf47bf457a923517af00352d5a3 Mon Sep 17 00:00:00 2001 From: hiro08gh Date: Wed, 12 Nov 2025 17:45:12 +0900 Subject: [PATCH 7/7] add: .rustfmt.toml and settings.json --- .rustfmt.toml | 1 + .vscode/settings.json | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 .rustfmt.toml create mode 100644 .vscode/settings.json 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"] +}