lios 0.1.88

Lios Linux terminal with native Aen and GTK4/VTE frontends.
Documentation
use std::ffi::OsString;
use std::path::PathBuf;

#[derive(Debug, PartialEq)]
pub enum Action {
    Help,
    Version,
    Check,
    Run(Options),
}

#[derive(Debug, PartialEq)]
pub struct Options {
    pub socket: Option<PathBuf>,
    pub directory: Option<PathBuf>,
    pub font: Option<PathBuf>,
    pub font_size: f32,
    pub width: u32,
    pub height: u32,
    pub scrollback: usize,
    pub hold: bool,
    pub command: Command,
}

#[derive(Debug, PartialEq)]
pub enum Command {
    Shell,
    Script(OsString),
    Argv(Vec<OsString>),
}

pub fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Action, String> {
    let mut args = args.into_iter();
    let mut options = Options {
        socket: None,
        directory: None,
        font: None,
        font_size: 16.0,
        width: 960,
        height: 640,
        scrollback: 1000,
        hold: false,
        command: Command::Shell,
    };
    while let Some(arg) = args.next() {
        let name = arg.to_str().ok_or("option name must be UTF-8")?;
        match name {
            "-h" | "--help" => return Ok(Action::Help),
            "-V" | "--version" => return Ok(Action::Version),
            "--check" => return Ok(Action::Check),
            "--hold" => options.hold = true,
            "--socket" => options.socket = Some(value(&mut args, name)?.into()),
            "--working-directory" => options.directory = Some(value(&mut args, name)?.into()),
            "--font-file" => options.font = Some(value(&mut args, name)?.into()),
            "--font-size" => {
                options.font_size = number(&mut args, name)?;
                if !options.font_size.is_finite() || !(8.0..=48.0).contains(&options.font_size) {
                    return Err("--font-size must be between 8 and 48".into());
                }
            }
            "--width" => options.width = number(&mut args, name)?,
            "--height" => options.height = number(&mut args, name)?,
            "--scrollback" => {
                options.scrollback = number(&mut args, name)?;
                if options.scrollback > 5000 {
                    return Err("--scrollback must be at most 5000 lines".into());
                }
            }
            "-e" | "--command" => {
                options.command = Command::Script(value(&mut args, name)?);
                if args.next().is_some() {
                    return Err(
                        "-e accepts one shell command string; use -- for an argument vector".into(),
                    );
                }
                break;
            }
            "--" => {
                let command: Vec<_> = args.collect();
                if command.first().is_none_or(|s| s.is_empty()) {
                    return Err("-- requires a command".into());
                }
                options.command = Command::Argv(command);
                break;
            }
            _ => return Err(format!("unknown Aen option: {name}; see lios --help")),
        }
    }
    aen_window::frame_bytes(options.width, options.height).map_err(|e| e.to_string())?;
    Ok(Action::Run(options))
}

fn value(args: &mut impl Iterator<Item = OsString>, name: &str) -> Result<OsString, String> {
    args.next()
        .filter(|v| !v.is_empty())
        .ok_or_else(|| format!("{name} requires a value"))
}
fn number<T: std::str::FromStr>(
    args: &mut impl Iterator<Item = OsString>,
    name: &str,
) -> Result<T, String> {
    value(args, name)?
        .to_str()
        .and_then(|s| s.parse().ok())
        .ok_or_else(|| format!("invalid {name} value"))
}

pub const HELP: &str = "Lios Terminal — native Aen frontend\n\nUsage: lios [options] [-- command arguments...]\n  -e, --command STRING       Run a shell command string\n  --working-directory PATH  Set the child working directory\n  --socket PATH             Select the native application endpoint\n  --width N --height N      Preferred window size (compositor decides placement)\n  --font-file PATH          Monospace TTF/OTF font\n  --font-size N             Font height, 8–48 pixels (default 16)\n  --scrollback N            History lines, 0–5000 (default 1000)\n  --hold                    Keep the window after the child exits\n  --check                   Inspect native build/font/endpoint without connecting\n  -h, --help                Show help\n  -V, --version             Show version and frontend\n\nShift+PageUp/PageDown or the wheel scroll history. Drag selects text.\nCtrl+Shift+C/V uses the compositor-owned native text clipboard (64 KiB maximum).\n";

#[cfg(test)]
mod tests {
    use super::*;
    fn args(values: &[&str]) -> Vec<OsString> {
        values.iter().map(OsString::from).collect()
    }
    #[test]
    fn argv_is_preserved_without_shell_interpretation() {
        let Action::Run(options) = parse(args(&["--", "printf", "%s", "a b;$HOME"])).unwrap()
        else {
            panic!()
        };
        assert_eq!(
            options.command,
            Command::Argv(args(&["printf", "%s", "a b;$HOME"]))
        );
    }
    #[test]
    fn rejects_invalid_and_unbounded_options() {
        for values in [
            &["--width", "0"][..],
            &["--font-size", "NaN"],
            &["--scrollback", "5001"],
            &["--"],
            &["-e", "echo", "extra"],
        ] {
            assert!(parse(args(values)).is_err(), "{values:?}");
        }
    }
}