tovuk 0.1.52

Deploy Rust backends, static frontends, and fullstack apps to Tovuk.
use super::{
    constants::{DEFAULT_API_URL, DEFAULT_DEPLOY_WAIT_TIMEOUT_SECONDS},
    errors::{Result, agent_error, internal_error},
};
use std::{env, path::PathBuf};

#[derive(Clone, Debug)]
pub(crate) struct CliOptions {
    pub(crate) command: String,
    pub(crate) args: Vec<String>,
    pub(crate) api_url: String,
    pub(crate) app: String,
    pub(crate) build: String,
    pub(crate) deploy: String,
    pub(crate) limit: String,
    pub(crate) cursor: String,
    pub(crate) failing_command: String,
    pub(crate) first_log_line: String,
    pub(crate) token: String,
    pub(crate) template: String,
    pub(crate) severity: String,
    pub(crate) port: u16,
    pub(crate) deployment: DeploymentOptions,
    pub(crate) output: OutputOptions,
}

#[derive(Clone, Debug)]
pub(crate) struct DeploymentOptions {
    pub(crate) database: bool,
    pub(crate) wait: bool,
    pub(crate) wait_timeout_seconds: u64,
}

#[derive(Clone, Debug)]
pub(crate) struct OutputOptions {
    pub(crate) json: bool,
    pub(crate) help: bool,
    pub(crate) version: bool,
}

impl Default for CliOptions {
    fn default() -> Self {
        Self {
            command: "help".to_owned(),
            args: Vec::new(),
            api_url: DEFAULT_API_URL.to_owned(),
            app: String::new(),
            build: String::new(),
            deploy: String::new(),
            limit: String::new(),
            cursor: String::new(),
            failing_command: String::new(),
            first_log_line: String::new(),
            token: String::new(),
            template: String::new(),
            severity: String::new(),
            port: 0,
            deployment: DeploymentOptions {
                database: false,
                wait: false,
                wait_timeout_seconds: DEFAULT_DEPLOY_WAIT_TIMEOUT_SECONDS,
            },
            output: OutputOptions {
                json: false,
                help: false,
                version: false,
            },
        }
    }
}

pub(crate) fn parse_args(argv: &[String]) -> Result<CliOptions> {
    let mut cli = CliOptions::default();
    let mut positional = Vec::new();
    let mut index = 0usize;

    while index < argv.len() {
        let arg = argv[index].clone();
        if arg == "--" {
            positional.extend(argv.iter().skip(index + 1).cloned());
            break;
        }
        if let Some((name, inline)) = parse_flag(&arg) {
            let consumed = apply_flag(&mut cli, name, inline, argv, index)?;
            index += consumed;
        } else if arg.starts_with('-') {
            return Err(agent_error(
                "unknown_argument",
                format!("Unknown Tovuk option: {arg}."),
                "Run `tovuk --help`, remove or correct the unsupported option, then retry.",
                cli.output.json,
            ));
        } else {
            positional.push(arg);
            index += 1;
        }
    }

    if let Some(command) = positional.first() {
        cli.command.clone_from(command);
        cli.args = positional.into_iter().skip(1).collect();
    }
    cli.api_url
        .truncate(cli.api_url.trim_end_matches('/').len());
    Ok(cli)
}

pub(crate) fn parse_flag(arg: &str) -> Option<(&str, Option<String>)> {
    if !arg.starts_with('-') {
        return None;
    }
    if arg.starts_with("--") {
        if let Some(index) = arg.find('=') {
            if index > 2 {
                return Some((&arg[..index], Some(arg[index + 1..].to_owned())));
            }
        }
    }
    Some((arg, None))
}

pub(crate) fn apply_flag(
    cli: &mut CliOptions,
    name: &str,
    inline: Option<String>,
    argv: &[String],
    index: usize,
) -> Result<usize> {
    let json_output = cli.output.json;
    match name {
        "--help" | "-h" => set_boolean_flag(
            inline.as_ref(),
            || cli.output.help = true,
            name,
            json_output,
        ),
        "--version" | "-v" | "-V" => set_boolean_flag(
            inline.as_ref(),
            || cli.output.version = true,
            name,
            json_output,
        ),
        "--json" => set_boolean_flag(
            inline.as_ref(),
            || cli.output.json = true,
            name,
            json_output,
        ),
        "--database" => set_boolean_flag(
            inline.as_ref(),
            || cli.deployment.database = true,
            name,
            json_output,
        ),
        "--no-database" => set_boolean_flag(
            inline.as_ref(),
            || cli.deployment.database = false,
            name,
            json_output,
        ),
        "--wait" => set_boolean_flag(
            inline.as_ref(),
            || cli.deployment.wait = true,
            name,
            json_output,
        ),
        "--api" => set_string_flag(&mut cli.api_url, name, inline, argv, index, cli.output.json),
        "--app" => set_string_flag(&mut cli.app, name, inline, argv, index, cli.output.json),
        "--build" => set_string_flag(&mut cli.build, name, inline, argv, index, cli.output.json),
        "--deploy" => set_string_flag(&mut cli.deploy, name, inline, argv, index, cli.output.json),
        "--failing-command" => set_string_flag(
            &mut cli.failing_command,
            name,
            inline,
            argv,
            index,
            cli.output.json,
        ),
        "--first-log-line" => set_string_flag(
            &mut cli.first_log_line,
            name,
            inline,
            argv,
            index,
            cli.output.json,
        ),
        "--limit" => set_string_flag(&mut cli.limit, name, inline, argv, index, cli.output.json),
        "--cursor" => set_string_flag(&mut cli.cursor, name, inline, argv, index, cli.output.json),
        "--severity" => set_string_flag(
            &mut cli.severity,
            name,
            inline,
            argv,
            index,
            cli.output.json,
        ),
        "--token" => set_string_flag(&mut cli.token, name, inline, argv, index, cli.output.json),
        "--template" => set_string_flag(
            &mut cli.template,
            name,
            inline,
            argv,
            index,
            cli.output.json,
        ),
        "--port" => set_u16_flag(&mut cli.port, name, inline, argv, index, cli.output.json),
        "--wait-timeout" => set_u64_flag(
            &mut cli.deployment.wait_timeout_seconds,
            name,
            inline,
            argv,
            index,
            cli.output.json,
        ),
        _ => Err(agent_error(
            "unknown_argument",
            format!("Unknown Tovuk option: {name}."),
            "Run `tovuk --help`, remove or correct the unsupported option, then retry.",
            cli.output.json,
        )),
    }
}

pub(crate) fn set_string_flag(
    target: &mut String,
    name: &str,
    inline: Option<String>,
    argv: &[String],
    index: usize,
    json_output: bool,
) -> Result<usize> {
    *target = flag_value(name, inline, argv, index, json_output)?;
    Ok(flag_consumed(argv, index))
}

pub(crate) fn set_u16_flag(
    target: &mut u16,
    name: &str,
    inline: Option<String>,
    argv: &[String],
    index: usize,
    json_output: bool,
) -> Result<usize> {
    *target = parse_u16(
        &flag_value(name, inline, argv, index, json_output)?,
        name,
        json_output,
    )?;
    Ok(flag_consumed(argv, index))
}

pub(crate) fn set_u64_flag(
    target: &mut u64,
    name: &str,
    inline: Option<String>,
    argv: &[String],
    index: usize,
    json_output: bool,
) -> Result<usize> {
    *target = parse_u64(
        &flag_value(name, inline, argv, index, json_output)?,
        name,
        json_output,
    )?;
    Ok(flag_consumed(argv, index))
}

pub(crate) fn set_boolean_flag(
    inline: Option<&String>,
    mut set: impl FnMut(),
    name: &str,
    json_output: bool,
) -> Result<usize> {
    if inline.is_some() {
        return Err(agent_error(
            "invalid_argument",
            format!("{name} does not accept a value."),
            format!("Use {name} without =value."),
            json_output,
        ));
    }
    set();
    Ok(1)
}

pub(crate) fn flag_value(
    name: &str,
    inline: Option<String>,
    argv: &[String],
    index: usize,
    json_output: bool,
) -> Result<String> {
    let value = if let Some(value) = inline {
        value
    } else {
        argv.get(index + 1).cloned().ok_or_else(|| {
            agent_error(
                "missing_argument",
                format!("{name} requires a value."),
                format!("Pass a value after {name}."),
                json_output,
            )
        })?
    };
    if value.is_empty() || (!arg_has_inline_value(argv, index) && value.starts_with("--")) {
        return Err(agent_error(
            "missing_argument",
            format!("{name} requires a value."),
            format!("Pass a value after {name}."),
            json_output,
        ));
    }
    Ok(value)
}

pub(crate) fn flag_consumed(argv: &[String], index: usize) -> usize {
    if arg_has_inline_value(argv, index) {
        1
    } else {
        2
    }
}

pub(crate) fn arg_has_inline_value(argv: &[String], index: usize) -> bool {
    argv.get(index)
        .is_some_and(|arg| arg.starts_with("--") && arg.contains('='))
}

pub(crate) fn parse_u16(value: &str, name: &str, json_output: bool) -> Result<u16> {
    let parsed = value.parse::<u16>().map_err(|_error| {
        agent_error(
            "invalid_argument",
            format!("{name} must be a positive integer."),
            format!("Pass {name} as a positive integer."),
            json_output,
        )
    })?;
    if parsed == 0 {
        return Err(agent_error(
            "invalid_argument",
            format!("{name} must be a positive integer."),
            format!("Pass {name} as a positive integer."),
            json_output,
        ));
    }
    Ok(parsed)
}

pub(crate) fn parse_u64(value: &str, name: &str, json_output: bool) -> Result<u64> {
    let parsed = value.parse::<u64>().map_err(|_error| {
        agent_error(
            "invalid_argument",
            format!("{name} must be a positive integer."),
            format!("Pass {name} as seconds, for example {name} 900."),
            json_output,
        )
    })?;
    if parsed == 0 {
        return Err(agent_error(
            "invalid_argument",
            format!("{name} must be a positive integer."),
            format!("Pass {name} as seconds, for example {name} 900."),
            json_output,
        ));
    }
    Ok(parsed)
}

pub(crate) fn project_path(value: Option<&String>) -> Result<PathBuf> {
    let path = value.map_or_else(PathBuf::new, PathBuf::from);
    let path = if path.as_os_str().is_empty() {
        env::current_dir().map_err(|error| internal_error(error.to_string()))?
    } else if path.is_absolute() {
        path
    } else {
        env::current_dir()
            .map_err(|error| internal_error(error.to_string()))?
            .join(path)
    };
    Ok(path)
}