lios 0.1.10

A GTK4/VTE Linux terminal emulator with configurable themes, backgrounds, and desktop launcher install.
use std::env;
use std::path::PathBuf;

use crate::config::{ConfigCommand, ConfigOverrides};
use crate::terminal::{LaunchCommand, LaunchConfig};

#[derive(Debug, Clone)]
pub struct Cli {
    pub action: CliAction,
}

#[derive(Debug, Clone)]
pub enum CliAction {
    Run {
        launch: LaunchConfig,
        config_path: Option<PathBuf>,
        overrides: Box<ConfigOverrides>,
    },
    Config(ConfigCommand),
    InstallDesktop,
    UninstallDesktop,
    ShowHelp,
    ShowVersion,
}

impl Cli {
    pub fn parse() -> Result<Self, String> {
        let args: Vec<String> = env::args().skip(1).collect();
        if args.first().is_some_and(|arg| arg == "config") {
            return Ok(Self {
                action: CliAction::Config(parse_config_command(&args[1..])?),
            });
        }

        let mut args = args.into_iter().peekable();
        let mut working_directory = None;
        let mut config_path = None;
        let mut overrides = ConfigOverrides::default();

        while let Some(arg) = args.next() {
            match arg.as_str() {
                "-h" | "--help" => {
                    return Ok(Self {
                        action: CliAction::ShowHelp,
                    });
                }
                "-V" | "--version" => {
                    return Ok(Self {
                        action: CliAction::ShowVersion,
                    });
                }
                "--install" | "---install" | "install" => {
                    return Ok(Self {
                        action: CliAction::InstallDesktop,
                    });
                }
                "--uninstall" | "---uninstall" | "uninstall" => {
                    return Ok(Self {
                        action: CliAction::UninstallDesktop,
                    });
                }
                "--working-directory" => {
                    let Some(value) = args.next() else {
                        return Err("--working-directory requires a path".to_string());
                    };
                    working_directory = Some(PathBuf::from(value));
                }
                "--config" => {
                    let Some(value) = args.next() else {
                        return Err("--config requires a path".to_string());
                    };
                    config_path = Some(PathBuf::from(value));
                }
                "--theme" => {
                    overrides.theme_name = Some(required_value(&arg, args.next())?);
                }
                "--renderer" => {
                    overrides.renderer = Some(required_value(&arg, args.next())?);
                }
                "--font" => {
                    overrides.font = Some(required_value(&arg, args.next())?);
                }
                "--background-image" => {
                    overrides.background_image =
                        Some(PathBuf::from(required_value(&arg, args.next())?));
                }
                "--background-image-opacity" => {
                    overrides.background_image_opacity = Some(parse_float(&arg, args.next())?);
                }
                "--background-opacity" => {
                    overrides.terminal_opacity = Some(parse_float(&arg, args.next())?);
                }
                "--overlay-color" => {
                    overrides.overlay_color = Some(required_value(&arg, args.next())?);
                }
                "--overlay-opacity" => {
                    overrides.overlay_opacity = Some(parse_float(&arg, args.next())?);
                }
                "--random-overlay" => {
                    overrides.random_overlay = Some(true);
                }
                "--no-random-overlay" => {
                    overrides.random_overlay = Some(false);
                }
                "--hide-titlebar" | "--hide-topbar" | "--no-titlebar" | "--no-topbar" => {
                    overrides.decorated = Some(false);
                }
                "--show-titlebar" | "--show-topbar" => {
                    overrides.decorated = Some(true);
                }
                "-e" | "--command" => {
                    let Some(command) = args.next() else {
                        return Err(format!("{arg} requires a command string"));
                    };
                    return Ok(Self::run(
                        working_directory,
                        LaunchCommand::Shell(command),
                        config_path,
                        overrides,
                    ));
                }
                "--" => {
                    let command: Vec<String> = args.collect();
                    if command.is_empty() {
                        return Err("-- requires a command".to_string());
                    }
                    return Ok(Self::run(
                        working_directory,
                        LaunchCommand::Argv(command),
                        config_path,
                        overrides,
                    ));
                }
                _ if arg.starts_with("--working-directory=") => {
                    let (_, value) = arg.split_once('=').expect("prefix checked above");
                    if value.is_empty() {
                        return Err("--working-directory requires a path".to_string());
                    }
                    working_directory = Some(PathBuf::from(value));
                }
                _ if arg.starts_with("--config=") => {
                    config_path = Some(PathBuf::from(split_value(&arg)?));
                }
                _ if arg.starts_with("--theme=") => {
                    overrides.theme_name = Some(split_value(&arg)?);
                }
                _ if arg.starts_with("--renderer=") => {
                    overrides.renderer = Some(split_value(&arg)?);
                }
                _ if arg.starts_with("--font=") => {
                    overrides.font = Some(split_value(&arg)?);
                }
                _ if arg.starts_with("--background-image=") => {
                    overrides.background_image = Some(PathBuf::from(split_value(&arg)?));
                }
                _ if arg.starts_with("--background-image-opacity=") => {
                    overrides.background_image_opacity = Some(parse_split_float(&arg)?);
                }
                _ if arg.starts_with("--background-opacity=") => {
                    overrides.terminal_opacity = Some(parse_split_float(&arg)?);
                }
                _ if arg.starts_with("--overlay-color=") => {
                    overrides.overlay_color = Some(split_value(&arg)?);
                }
                _ if arg.starts_with("--overlay-opacity=") => {
                    overrides.overlay_opacity = Some(parse_split_float(&arg)?);
                }
                _ => {
                    return Err(format!(
                        "unknown option '{arg}'\n\n{}",
                        Self::short_help_text()
                    ));
                }
            }
        }

        Ok(Self::run(
            working_directory,
            LaunchCommand::DefaultShell,
            config_path,
            overrides,
        ))
    }

    fn run(
        working_directory: Option<PathBuf>,
        command: LaunchCommand,
        config_path: Option<PathBuf>,
        overrides: ConfigOverrides,
    ) -> Self {
        Self {
            action: CliAction::Run {
                launch: LaunchConfig {
                    command,
                    working_directory,
                },
                config_path,
                overrides: Box::new(overrides),
            },
        }
    }

    pub fn help_text() -> &'static str {
        "Lios Terminal\n\nUsage:\n  lios [OPTIONS]\n  lios [OPTIONS] -- COMMAND [ARGUMENTS...]\n  lios config COMMAND [ARGS...]\n\nOptions:\n  -h, --help                         Show this help text\n  -V, --version                      Show the application version\n      --install, ---install          Install a desktop launcher for this binary\n      --uninstall, ---uninstall      Remove the desktop launcher\n      --config PATH                  Load a TOML config file\n      --working-directory PATH       Start the child process in PATH\n  -e, --command COMMAND              Run COMMAND through /bin/sh -lc\n      --renderer NAME                GTK renderer: auto, gl, vulkan, cairo\n      --theme NAME                   Use a built-in theme\n      --font FONT                    Set the terminal font\n      --background-image PATH        Draw an image behind the terminal\n      --background-image-opacity N   Set image opacity, 0.0 to 1.0\n      --background-opacity N         Set terminal background opacity, 0.0 to 1.0\n      --overlay-color COLOR          Tint the background, for example #7c3aed\n      --overlay-opacity N            Set tint opacity, 0.0 to 1.0\n      --random-overlay               Pick a new accent tint each launch\n      --no-random-overlay            Disable random accent tint\n      --hide-titlebar, --hide-topbar  Remove window decorations/topbar\n      --show-titlebar, --show-topbar  Keep window decorations/topbar\n\nConfig Commands:\n  lios config path\n  lios config sample\n  lios config init [--force] [--path PATH]\n  lios config show [--path PATH]\n  lios config set [--path PATH] KEY VALUE\n\nShortcuts: Ctrl+Shift+N opens another terminal window. Ctrl+Shift+Q closes the current window. Ctrl+Shift+, toggles preferences.\nCommon config keys: renderer, theme, font, background.image, background_opacity, background_image_opacity, overlay_color, overlay_opacity, random_overlay, topbar.\nBuilt-in themes: xfce, xterm, green-on-black, white-on-black, dark-pastels, solarized-dark, solarized-light, black-on-white.\nWithout a command, the terminal starts your login shell from $SHELL or a safe fallback.\n"
    }

    fn short_help_text() -> &'static str {
        "Run 'lios --help' for usage."
    }
}

fn required_value(option: &str, value: Option<String>) -> Result<String, String> {
    value.ok_or_else(|| format!("{option} requires a value"))
}

fn split_value(option: &str) -> Result<String, String> {
    let (_, value) = option.split_once('=').expect("caller checked for '='");
    if value.is_empty() {
        Err(format!("{option} requires a value"))
    } else {
        Ok(value.to_string())
    }
}

fn parse_float(option: &str, value: Option<String>) -> Result<f64, String> {
    let value = required_value(option, value)?;
    value
        .parse::<f64>()
        .map_err(|_| format!("{option} requires a number"))
}

fn parse_split_float(option: &str) -> Result<f64, String> {
    let value = split_value(option)?;
    value
        .parse::<f64>()
        .map_err(|_| format!("{option} requires a number"))
}

fn parse_config_command(args: &[String]) -> Result<ConfigCommand, String> {
    let Some(command) = args.first().map(String::as_str) else {
        return Err("missing config command\n\nRun 'lios --help' for usage.".to_string());
    };

    match command {
        "path" => Ok(ConfigCommand::Path),
        "sample" => Ok(ConfigCommand::Sample),
        "init" => {
            let (path, force, rest) = parse_config_options(&args[1..])?;
            if !rest.is_empty() {
                return Err(format!(
                    "unexpected argument '{}'
",
                    rest[0]
                ));
            }
            Ok(ConfigCommand::Init { path, force })
        }
        "show" => {
            let (path, _, rest) = parse_config_options(&args[1..])?;
            if !rest.is_empty() {
                return Err(format!(
                    "unexpected argument '{}'
",
                    rest[0]
                ));
            }
            Ok(ConfigCommand::Show { path })
        }
        "set" => {
            let (path, _, rest) = parse_config_options(&args[1..])?;
            if rest.len() != 2 {
                return Err("usage: lios config set [--path PATH] KEY VALUE".to_string());
            }
            Ok(ConfigCommand::Set {
                path,
                key: rest[0].clone(),
                value: rest[1].clone(),
            })
        }
        _ => Err(format!("unknown config command '{command}'")),
    }
}

fn parse_config_options(args: &[String]) -> Result<(Option<PathBuf>, bool, Vec<String>), String> {
    let mut path = None;
    let mut force = false;
    let mut rest = Vec::new();
    let mut index = 0;

    while index < args.len() {
        match args[index].as_str() {
            "--path" | "--config" => {
                index += 1;
                let Some(value) = args.get(index) else {
                    return Err("--path requires a value".to_string());
                };
                path = Some(PathBuf::from(value));
            }
            "--force" => force = true,
            _ if args[index].starts_with("--path=") || args[index].starts_with("--config=") => {
                path = Some(PathBuf::from(split_value(&args[index])?));
            }
            _ => rest.push(args[index].clone()),
        }
        index += 1;
    }

    Ok((path, force, rest))
}