Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .rustfmt.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
use_field_init_shorthand = true
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"cSpell.words": ["alx", "clippy"]
}
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "alx"
description = "Setup simple alias manager"
authors = ["hiro08gh <hiro08gh@gmail.com>"]
version = "0.2.0"
version = "0.3.0"
license = "MIT"
categories = ["command-line-utilities"]
repository = "https://github.com/hiro08gh/alx"
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},
}
68 changes: 68 additions & 0 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,71 @@ pub fn info() -> Result<()> {

Ok(())
}

pub fn migrate(from: Option<String>) -> 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<dyn ShellHandler> = 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<dyn ShellHandler> = 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(())
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
117 changes: 117 additions & 0 deletions src/shell/bash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,76 @@ impl ShellHandler for BashHandler {
})?;
Ok(home.join(".bashrc"))
}

fn parse_aliases_from_file(&self, path: &std::path::Path) -> Result<Vec<(String, String)>> {
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(&current_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 {
Expand All @@ -77,6 +147,7 @@ impl Default for BashHandler {
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;

#[test]
fn test_generate_alias_line() {
Expand Down Expand Up @@ -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);
}
}
29 changes: 29 additions & 0 deletions src/shell/detector.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::error::{AlxError, Result};
use crate::shell::ShellType;
use std::env;
use std::path::Path;

pub struct ShellDetector;

Expand All @@ -24,6 +25,34 @@ impl ShellDetector {
Err(AlxError::ShellDetectionFailed)
}

pub fn detect_from_path<P: AsRef<Path>>(path: P) -> Result<ShellType> {
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<ShellType> {
if !Self::is_supported(name) {
return Err(AlxError::UnsupportedShell(name.to_string()));
Expand Down
70 changes: 70 additions & 0 deletions src/shell/fish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<(String, String)>> {
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(&current_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 {
Expand Down
Loading