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))
}