Skip to content
//! Construction of `once` argument vectors.
//!
//! This module is the entire write surface of `twice`. Every command the
//! server can ever run is produced by [`argv`], and [`argv`] is a total match
//! over [`JobKind`] — which has no destructive variant. There is no code path
//! that reaches `once remove`, `once keys`, `once exec`, `once restore`, or
//! `once teardown`, and no string from a client is ever concatenated into a
//! shell.

use std::ffi::OsString;

use twice_core::JobKind;

/// Subcommands this binary is permitted to invoke. Kept next to [`argv`] so a
/// reviewer can check the two against each other at a glance.
#[allow(
    dead_code,
    reason = "reviewer-facing allowlist, asserted by the tests below"
)]
pub(crate) const ALLOWED_SUBCOMMANDS: [&str; 4] = ["start", "stop", "update", "deploy"];

/// Subcommands that must never be reachable. Asserted by tests against the
/// output of [`argv`] for every job kind.
#[allow(
    dead_code,
    reason = "reviewer-facing denylist, asserted by the tests below"
)]
pub(crate) const FORBIDDEN_SUBCOMMANDS: [&str; 6] =
    ["remove", "rm", "keys", "exec", "restore", "teardown"];

/// Builds the argument vector for a job, excluding the program name itself.
pub(crate) fn argv(kind: &JobKind) -> Vec<OsString> {
    match kind {
        JobKind::Start { host } => vec!["start".into(), host.as_str().into()],
        JobKind::Stop { host } => vec!["stop".into(), host.as_str().into()],
        JobKind::Deploy { host, image } => vec![
            "deploy".into(),
            image.as_str().into(),
            "--host".into(),
            host.as_str().into(),
        ],
        JobKind::UpdateImage {
            host,
            image,
            auto_update,
        } => {
            let mut args: Vec<OsString> = vec![
                "update".into(),
                host.as_str().into(),
                "--image".into(),
                image.as_str().into(),
            ];
            if let Some(enabled) = *auto_update {
                args.push(format!("--auto-update={enabled}").into());
            }
            args
        }
    }
}

#[cfg(test)]
mod tests {
    use twice_core::{Host, ImageRef};

    use super::*;

    fn host() -> Host {
        Host::parse("writebook.example.com").unwrap()
    }

    fn image() -> ImageRef {
        ImageRef::parse("ghcr.io/basecamp/writebook:1.4.0").unwrap()
    }

    fn every_kind() -> Vec<JobKind> {
        vec![
            JobKind::Start { host: host() },
            JobKind::Stop { host: host() },
            JobKind::Deploy {
                host: host(),
                image: image(),
            },
            JobKind::UpdateImage {
                host: host(),
                image: image(),
                auto_update: None,
            },
            JobKind::UpdateImage {
                host: host(),
                image: image(),
                auto_update: Some(true),
            },
            JobKind::UpdateImage {
                host: host(),
                image: image(),
                auto_update: Some(false),
            },
        ]
    }

    #[test]
    fn every_job_starts_with_an_allowlisted_subcommand() {
        for kind in every_kind() {
            let args = argv(&kind);
            let first = args.first().and_then(|a| a.to_str()).map(str::to_owned);
            assert!(
                first
                    .as_deref()
                    .is_some_and(|s| ALLOWED_SUBCOMMANDS.contains(&s)),
                "job {kind:?} produced non-allowlisted subcommand {first:?}"
            );
        }
    }

    #[test]
    fn no_job_can_reach_a_destructive_subcommand() {
        for kind in every_kind() {
            for arg in argv(&kind) {
                let arg = arg.to_str().unwrap().to_owned();
                assert!(
                    !FORBIDDEN_SUBCOMMANDS.contains(&arg.as_str()),
                    "job {kind:?} produced forbidden argument {arg:?}"
                );
                assert!(
                    !arg.contains("--remove-data"),
                    "job {kind:?} produced a data-destroying flag"
                );
            }
        }
    }

    #[test]
    fn update_passes_image_as_a_separate_argv_entry() {
        let args = argv(&JobKind::UpdateImage {
            host: host(),
            image: image(),
            auto_update: None,
        });
        assert_eq!(
            args,
            vec![
                OsString::from("update"),
                OsString::from("writebook.example.com"),
                OsString::from("--image"),
                OsString::from("ghcr.io/basecamp/writebook:1.4.0"),
            ]
        );
    }

    #[test]
    fn deploy_passes_the_pinned_image_and_host_as_separate_arguments() {
        let args = argv(&JobKind::Deploy {
            host: host(),
            image: image(),
        });
        assert_eq!(
            args,
            vec![
                OsString::from("deploy"),
                OsString::from("ghcr.io/basecamp/writebook:1.4.0"),
                OsString::from("--host"),
                OsString::from("writebook.example.com"),
            ]
        );
    }

    #[test]
    fn auto_update_is_omitted_unless_the_client_asked() {
        let without = argv(&JobKind::UpdateImage {
            host: host(),
            image: image(),
            auto_update: None,
        });
        assert!(
            !without
                .iter()
                .any(|a| a.to_string_lossy().starts_with("--auto-update"))
        );

        let with = argv(&JobKind::UpdateImage {
            host: host(),
            image: image(),
            auto_update: Some(false),
        });
        assert!(with.contains(&OsString::from("--auto-update=false")));
    }
}