mod cli;
mod config;
mod detector;
mod error;
mod executor;
mod resolver;
use crate::error::SwitchdevError;
use clap::Parser;
use cli::{Cli, Commands};
use config::{load_config, save_config, Project};
use std::path::{Path, PathBuf};
fn main() {
if let Err(error) = run() {
println!("[✗] {}", error);
std::process::exit(1);
}
}
fn run() -> Result<(), SwitchdevError> {
let cli = Cli::parse();
let dry_run = cli.dry_run;
let verbose = cli.verbose;
if let Some(command) = cli.command {
match command {
Commands::Add(args) => handle_add(args.name, args.path, args.commands)?,
Commands::Edit(args) => handle_edit(&args.name, args.commands)?,
Commands::Remove(args) => handle_remove(&args.name)?,
Commands::Rename(args) => handle_rename(&args.old_name, &args.new_name)?,
Commands::List => handle_list(),
}
} else if let Some(project) = cli.project {
handle_switch(&project, dry_run, verbose)?;
} else {
println!("No command provided.");
}
Ok(())
}
fn handle_add(name: String, path: PathBuf, commands: Vec<String>) -> Result<(), SwitchdevError> {
if let Err(error) = validate_project_name(&name) {
print_error(&error.to_string());
return Ok(());
}
let mut config = load_config()?;
if config.projects.iter().any(|project| project.name == name) {
print_error(&SwitchdevError::DuplicateProjectName.to_string());
return Ok(());
}
let normalized_path = normalize_path(&path.to_string_lossy())?;
if config
.projects
.iter()
.any(|project| project.path == normalized_path)
{
print_error(&SwitchdevError::DuplicateProjectPath.to_string());
return Ok(());
}
config.projects.push(Project {
name: name.clone(),
path: normalized_path,
commands: normalize_commands(commands),
});
save_config(&config)?;
print_success(&format!("Added project: {name}"));
Ok(())
}
fn handle_list() {
let config = match load_config() {
Ok(config) => config,
Err(_) => {
print_error("Failed to load config");
return;
}
};
if config.projects.is_empty() {
println!("No projects found.");
println!("Run: switchdev add <name> <path>");
return;
}
print_projects(&config.projects);
}
fn handle_remove(name: &str) -> Result<(), SwitchdevError> {
let mut config = load_config()?;
if !config.projects.iter().any(|project| project.name == name) {
print_error(&SwitchdevError::ProjectNotFound(String::from(name)).to_string());
return Ok(());
}
config.projects.retain(|project| project.name != name);
save_config(&config)?;
print_success(&format!("Removed project: {name}"));
Ok(())
}
fn handle_edit(name: &str, commands: Vec<String>) -> Result<(), SwitchdevError> {
let mut config = load_config()?;
let Some(project) = config
.projects
.iter_mut()
.find(|project| project.name == name)
else {
print_error(&SwitchdevError::ProjectNotFound(String::from(name)).to_string());
return Ok(());
};
let normalized_commands = normalize_commands(commands);
let reset_to_auto_detection = normalized_commands.is_none();
project.commands = normalized_commands;
save_config(&config)?;
if reset_to_auto_detection {
print_success(&format!("Reset to auto-detection for project: {name}"));
} else {
print_success(&format!("Updated commands for project: {name}"));
}
Ok(())
}
fn handle_rename(old_name: &str, new_name: &str) -> Result<(), SwitchdevError> {
if let Err(error) = validate_project_name(new_name) {
print_error(&error.to_string());
return Ok(());
}
let mut config = load_config()?;
if !config
.projects
.iter()
.any(|project| project.name == old_name)
{
print_error(&SwitchdevError::ProjectNotFound(String::from(old_name)).to_string());
return Ok(());
}
if config
.projects
.iter()
.any(|project| project.name == new_name)
{
print_error(&SwitchdevError::DuplicateProjectName.to_string());
return Ok(());
}
if let Some(project) = config
.projects
.iter_mut()
.find(|project| project.name == old_name)
{
project.name = String::from(new_name);
}
save_config(&config)?;
print_success(&format!("Renamed project: {old_name} → {new_name}"));
Ok(())
}
fn handle_switch(name: &str, dry_run: bool, verbose: bool) -> Result<(), SwitchdevError> {
let config = match load_config() {
Ok(config) => config,
Err(_) => {
print_error(&SwitchdevError::ConfigLoadFailed.to_string());
return Ok(());
}
};
let Some(project) = config.projects.iter().find(|project| project.name == name) else {
print_error(&SwitchdevError::ProjectNotFound(String::from(name)).to_string());
return Ok(());
};
let normalized_path = match normalize_path(&project.path) {
Ok(path) => path,
Err(_) => {
print_error(&SwitchdevError::InvalidPath.to_string());
return Ok(());
}
};
if !Path::new(&normalized_path).exists() {
print_error(&format!("Project path does not exist: {normalized_path}"));
return Ok(());
}
let resolved_project = Project {
name: project.name.clone(),
path: normalized_path.clone(),
commands: project.commands.clone(),
};
let Some(command) = resolver::resolve_command(&resolved_project) else {
print_error("Failed to execute command");
return Ok(());
};
let uses_custom_commands = matches!(&project.commands, Some(commands) if !commands.is_empty());
print_success(&format!("Switching to: {name}"));
if uses_custom_commands {
print_info("Using custom command(s)");
} else {
print_info(&format!("Using detected command: {command}"));
}
executor::run_project(&normalized_path, &command, dry_run, verbose)
}
fn print_projects(projects: &[Project]) {
let max_name_width: usize = projects
.iter()
.map(|project| project.name.len())
.max()
.unwrap_or_default();
println!("Projects:\n");
for project in projects {
println!(
" {:width$} \u{2192} {}",
project.name,
project.path,
width = max_name_width
);
}
}
fn normalize_commands(commands: Vec<String>) -> Option<Vec<String>> {
if commands.is_empty() {
None
} else {
Some(commands)
}
}
fn validate_project_name(name: &str) -> Result<(), SwitchdevError> {
if name.is_empty() {
return Err(SwitchdevError::InvalidProjectName(String::from(
"Project name cannot be empty",
)));
}
if name.contains(' ') {
return Err(SwitchdevError::InvalidProjectName(String::from(
"Project name cannot contain spaces",
)));
}
if name
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-' || character == '_')
{
Ok(())
} else {
Err(SwitchdevError::InvalidProjectName(String::from(
"Project name contains invalid characters",
)))
}
}
fn print_success(message: &str) {
println!("[✓] {message}");
}
fn print_error(message: &str) {
println!("[✗] {message}");
}
fn print_info(message: &str) {
println!("[i] {message}");
}
fn normalize_path(path: &str) -> Result<String, SwitchdevError> {
let expanded_path = expand_home(path)?;
let path_buf = expanded_path;
let absolute_path = if path_buf.is_relative() {
std::env::current_dir()
.map_err(|_| SwitchdevError::InvalidPath)?
.join(path_buf)
} else {
path_buf
};
let normalized_path = match std::fs::canonicalize(&absolute_path) {
Ok(canonical_path) => canonical_path,
Err(_) => absolute_path,
};
Ok(normalized_path.to_string_lossy().into_owned())
}
fn expand_home(path: &str) -> Result<PathBuf, SwitchdevError> {
let path_text = Path::new(path).to_string_lossy();
if path_text == "~" {
let home_dir = dirs::home_dir().ok_or(SwitchdevError::InvalidPath)?;
return Ok(home_dir);
}
if let Some(stripped) = path_text.strip_prefix("~/") {
let home_dir = dirs::home_dir().ok_or(SwitchdevError::InvalidPath)?;
return Ok(home_dir.join(stripped));
}
Ok(PathBuf::from(path))
}