switchdev 0.1.0

A fast CLI to instantly switch between development projects and run their startup commands
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))
}