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> {
Self::parse_from(env::args().skip(1))
}
fn parse_from(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let args: Vec<String> = args.into_iter().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())?);
}
"--gpu" | "--gpu-acceleration" => {
overrides.renderer = Some("auto".to_string());
}
"--no-gpu" | "--cpu" | "--software-renderer" => {
overrides.renderer = Some("cairo".to_string());
}
"--gpu-mode" => {
overrides.renderer = Some(required_gpu_mode(&arg, args.next())?);
}
"--opacity" | "--window-opacity" | "--total-opacity" => {
overrides.window_opacity = Some(parse_float(&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("--gpu-mode=") => {
overrides.renderer = Some(split_gpu_mode(&arg)?);
}
_ if arg.starts_with("--opacity=")
|| arg.starts_with("--window-opacity=")
|| arg.starts_with("--total-opacity=") =>
{
overrides.window_opacity = Some(parse_split_float(&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 {
r#"Lios Terminal
Usage:
lios [OPTIONS]
lios [OPTIONS] -- COMMAND [ARGUMENTS...]
lios config COMMAND [ARGS...]
Options:
-h, --help Show this help text
-V, --version Show the application version
--install, ---install Install a desktop launcher for this binary
--uninstall, ---uninstall Remove the desktop launcher
--config PATH Load a TOML config file
--working-directory PATH Start the child process in PATH
-e, --command COMMAND Run COMMAND through /bin/sh -lc
--gpu Enable GTK GPU renderer auto-selection
--no-gpu Force the reliable Cairo software renderer
--gpu-mode NAME GPU mode: auto, gl, or vulkan
--renderer NAME GTK renderer: auto, gl, vulkan, cairo
--opacity N Set full-window opacity, 0.0 to 1.0
--theme NAME Use a built-in theme
--font FONT Set the terminal font
--background-image PATH Draw an image behind the terminal
--background-image-opacity N Set image opacity, 0.0 to 1.0
--background-opacity N Set terminal shade opacity, 0.0 to 1.0
--overlay-color COLOR Tint the background, for example #7c3aed
--overlay-opacity N Set tint opacity, 0.0 to 1.0
--random-overlay Pick a new accent tint each launch
--no-random-overlay Disable random accent tint
--hide-titlebar, --hide-topbar Remove window decorations/topbar
--show-titlebar, --show-topbar Keep window decorations/topbar
Config Commands:
lios config path
lios config sample
lios config init [--force] [--path PATH]
lios config show [--path PATH]
lios config set [--path PATH] KEY VALUE
Shortcuts: Ctrl+Shift+N opens another terminal window. Ctrl+Shift+Q closes the current window. Ctrl+Shift+, toggles preferences.
Common config keys: gpu, gpu_mode, renderer, theme, font, opacity, background.image, background_opacity, background_image_opacity, overlay_color, overlay_opacity, random_overlay, topbar.
Built-in themes: xfce, xterm, green-on-black, white-on-black, dark-pastels, solarized-dark, solarized-light, black-on-white.
Without a command, the terminal starts your login shell from $SHELL or a safe fallback.
"#
}
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 required_gpu_mode(option: &str, value: Option<String>) -> Result<String, String> {
let value = required_value(option, value)?;
validate_gpu_mode(option, &value)?;
Ok(value)
}
fn split_gpu_mode(option: &str) -> Result<String, String> {
let value = split_value(option)?;
validate_gpu_mode(option, &value)?;
Ok(value)
}
fn validate_gpu_mode(option: &str, value: &str) -> Result<(), String> {
match value.trim().to_ascii_lowercase().as_str() {
"auto" | "default" | "gl" | "opengl" | "vulkan" | "vk" => Ok(()),
_ => Err(format!("{option} must be auto, gl, or vulkan")),
}
}
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))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_aliases_parse() {
assert!(matches!(
parse(["--install"]).action,
CliAction::InstallDesktop
));
assert!(matches!(
parse(["---install"]).action,
CliAction::InstallDesktop
));
assert!(matches!(
parse(["install"]).action,
CliAction::InstallDesktop
));
}
#[test]
fn uninstall_aliases_parse() {
assert!(matches!(
parse(["--uninstall"]).action,
CliAction::UninstallDesktop
));
assert!(matches!(
parse(["---uninstall"]).action,
CliAction::UninstallDesktop
));
assert!(matches!(
parse(["uninstall"]).action,
CliAction::UninstallDesktop
));
}
#[test]
fn opacity_flags_parse() {
let action = parse(["--opacity", "0.82"]).action;
let CliAction::Run { overrides, .. } = action else {
panic!("expected run action");
};
assert_eq!(overrides.window_opacity, Some(0.82));
let action = parse(["--total-opacity=0.64"]).action;
let CliAction::Run { overrides, .. } = action else {
panic!("expected run action");
};
assert_eq!(overrides.window_opacity, Some(0.64));
}
#[test]
fn gpu_flags_parse_renderer_override() {
let action = parse(["--gpu"]).action;
let CliAction::Run { overrides, .. } = action else {
panic!("expected run action");
};
assert_eq!(overrides.renderer, Some("auto".to_string()));
let action = parse(["--gpu-mode=vulkan"]).action;
let CliAction::Run { overrides, .. } = action else {
panic!("expected run action");
};
assert_eq!(overrides.renderer, Some("vulkan".to_string()));
let action = parse(["--no-gpu"]).action;
let CliAction::Run { overrides, .. } = action else {
panic!("expected run action");
};
assert_eq!(overrides.renderer, Some("cairo".to_string()));
}
#[test]
fn gpu_mode_rejects_software_renderer() {
assert!(parse_result(["--gpu-mode=cairo"]).is_err());
}
#[test]
fn background_opacity_flag_parses_terminal_shade() {
let action = parse(["--background-opacity=0.42"]).action;
let CliAction::Run { overrides, .. } = action else {
panic!("expected run action");
};
assert_eq!(overrides.terminal_opacity, Some(0.42));
}
#[test]
fn shell_command_mode_parses() {
let action = parse(["--working-directory", "/tmp", "--command", "printf ok"]).action;
let CliAction::Run { launch, .. } = action else {
panic!("expected run action");
};
assert_eq!(launch.working_directory, Some(PathBuf::from("/tmp")));
match launch.command {
LaunchCommand::Shell(command) => assert_eq!(command, "printf ok"),
_ => panic!("expected shell command"),
}
}
#[test]
fn argv_command_mode_parses() {
let action = parse(["--", "printf", "ok"]).action;
let CliAction::Run { launch, .. } = action else {
panic!("expected run action");
};
match launch.command {
LaunchCommand::Argv(argv) => assert_eq!(argv, vec!["printf", "ok"]),
_ => panic!("expected argv command"),
}
}
#[test]
fn config_set_command_parses() {
let action = parse([
"config",
"set",
"--path",
"/tmp/lios.toml",
"opacity",
"0.73",
])
.action;
let CliAction::Config(ConfigCommand::Set { path, key, value }) = action else {
panic!("expected config set action");
};
assert_eq!(path, Some(PathBuf::from("/tmp/lios.toml")));
assert_eq!(key, "opacity");
assert_eq!(value, "0.73");
}
#[test]
fn empty_argv_mode_errors() {
assert!(parse_result(["--"]).is_err());
}
fn parse<const N: usize>(args: [&str; N]) -> Cli {
parse_result(args).unwrap()
}
fn parse_result<const N: usize>(args: [&str; N]) -> Result<Cli, String> {
Cli::parse_from(args.into_iter().map(str::to_string))
}
}