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())?);
}
"--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("--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 {
"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 --opacity N Set full-window opacity, 0.0 to 1.0\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 shade 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, opacity, 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))
}
#[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 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))
}
}