cue-cli 0.2.0

Installed Cue command aggregator and extension dispatcher
Documentation
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde::Deserialize;

#[derive(Debug, Clone, Default, Deserialize)]
pub struct Config {
    #[serde(default)]
    pub extensions: ExtensionsConfig,
}

impl Config {
    pub(crate) fn load_for_extension_dispatch() -> Result<Self> {
        let Some(path) = extension_config_path() else {
            return Ok(Self::default());
        };
        let text = match std::fs::read_to_string(&path) {
            Ok(text) => text,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                return Ok(Self::default());
            }
            Err(error) => return Err(error).with_context(|| format!("read {}", path.display())),
        };
        Self::load_for_extension_dispatch_from_sources(Some((&path, &text)))
    }

    fn load_for_extension_dispatch_from_sources(source: Option<(&Path, &str)>) -> Result<Self> {
        let Some((path, text)) = source else {
            return Ok(Self::default());
        };
        validate_root_sections(text, path)?;
        let extension_config: ExtensionDispatchConfig =
            toml::from_str(text).with_context(|| format!("parse config {}", path.display()))?;
        extension_config.extensions.validate()?;
        Ok(Self {
            extensions: extension_config.extensions,
        })
    }
}

fn extension_config_path() -> Option<PathBuf> {
    if let Some(root) = std::env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
        return Some(PathBuf::from(root).join("cue/client.toml"));
    }
    std::env::var_os("HOME")
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
        .map(|home| home.join(".config/cue/client.toml"))
}

fn validate_root_sections(text: &str, path: &Path) -> Result<()> {
    let root = toml::from_str::<toml::Table>(text)
        .with_context(|| format!("parse config {}", path.display()))?;
    for key in root.keys() {
        if key != "extensions" {
            bail!("unknown top-level client config section `{key}`")
        }
    }
    Ok(())
}

#[derive(Debug, Clone, Default, Deserialize)]
struct ExtensionDispatchConfig {
    #[serde(default)]
    extensions: ExtensionsConfig,
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExtensionsConfig {
    #[serde(default)]
    pub path_lookup: bool,
    #[serde(default)]
    pub commands: BTreeMap<String, ExtensionCommand>,
}

impl ExtensionsConfig {
    fn validate(&self) -> Result<()> {
        for (name, command) in &self.commands {
            if is_reserved_extension_name(name) {
                bail!(
                    "extension name `{name}` is reserved for a built-in or first-party cue subcommand"
                );
            }
            validate_extension_name(name, "extension name")?;
            if command.program.trim().is_empty() {
                bail!("extension `{name}` program must not be empty");
            }
            if command.program.trim() != command.program {
                bail!("extension `{name}` program must not have leading or trailing whitespace");
            }
        }
        Ok(())
    }
}

pub(crate) fn validate_extension_name(name: &str, label: &str) -> Result<()> {
    if !is_valid_extension_name(name) {
        bail!("{label} `{name}` must be kebab-case ASCII, for example `foo` or `foo-bar`");
    }
    Ok(())
}

fn is_reserved_extension_name(name: &str) -> bool {
    matches!(
        name,
        "client" | "daemon" | "help" | "run" | "target" | "tui" | "version"
    )
}

fn is_valid_extension_name(name: &str) -> bool {
    let mut chars = name.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
        return false;
    }
    let mut previous_was_dash = false;

    for ch in chars {
        match ch {
            'a'..='z' | '0'..='9' => previous_was_dash = false,
            '-' if !previous_was_dash => previous_was_dash = true,
            _ => return false,
        }
    }

    !previous_was_dash
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ExtensionCommand {
    pub program: String,
    #[serde(default)]
    pub description: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_external_extensions() {
        let config = Config::load_for_extension_dispatch_from_sources(Some((
            Path::new("client.toml"),
            r#"
[extensions]
path_lookup = true

[extensions.commands.foo]
program = "cue-foo"
description = "Foo extension"
"#,
        )))
        .expect("load config");

        assert!(config.extensions.path_lookup);
        assert_eq!(
            config.extensions.commands.get("foo"),
            Some(&ExtensionCommand {
                program: "cue-foo".into(),
                description: Some("Foo extension".into()),
            })
        );
    }

    #[test]
    fn extension_registry_requires_program_field() {
        let error = Config::load_for_extension_dispatch_from_sources(Some((
            Path::new("client.toml"),
            r#"
[extensions.commands.foo]
command = "cue-foo"
"#,
        )))
        .expect_err("command field should not be accepted as a program");

        let message = format!("{error:#}");
        assert!(message.contains("parse config client.toml"));
        assert!(message.contains("unknown field `command`"));
    }

    #[test]
    fn extension_registry_rejects_unknown_extension_fields() {
        let error = Config::load_for_extension_dispatch_from_sources(Some((
            Path::new("client.toml"),
            r#"
[extensions]
path_lookkup = true
"#,
        )))
        .expect_err("unknown extension config keys should fail during config loading");

        let message = format!("{error:#}");
        assert!(message.contains("parse config client.toml"));
        assert!(message.contains("unknown field `path_lookkup`"));
    }

    #[test]
    fn extension_registry_rejects_reserved_names() {
        let error = Config::load_for_extension_dispatch_from_sources(Some((
            Path::new("client.toml"),
            r#"
[extensions.commands.tui]
program = "custom-tui"
"#,
        )))
        .expect_err("first-party and built-in subcommands should not be configurable extensions");

        assert_eq!(
            format!("{error:#}"),
            "extension name `tui` is reserved for a built-in or first-party cue subcommand"
        );
    }

    #[test]
    fn extension_registry_rejects_non_kebab_case_names() {
        let error = Config::load_for_extension_dispatch_from_sources(Some((
            Path::new("client.toml"),
            r#"
[extensions.commands.foo_bar]
program = "cue-foo-bar"
"#,
        )))
        .expect_err("extension names should be stable CLI subcommand names");

        assert_eq!(
            format!("{error:#}"),
            "extension name `foo_bar` must be kebab-case ASCII, for example `foo` or `foo-bar`"
        );
    }

    #[test]
    fn extension_registry_rejects_empty_program() {
        let error = Config::load_for_extension_dispatch_from_sources(Some((
            Path::new("client.toml"),
            r#"
[extensions.commands.foo]
program = "   "
"#,
        )))
        .expect_err("extension program should be validated by config loading");

        assert_eq!(
            format!("{error:#}"),
            "extension `foo` program must not be empty"
        );
    }

    #[test]
    fn extension_registry_rejects_padded_program() {
        for program in [r#"" cue-foo""#, r#""cue-foo ""#] {
            let error = Config::load_for_extension_dispatch_from_sources(Some((
                Path::new("client.toml"),
                &format!(
                    r#"
[extensions.commands.foo]
program = {program}
"#
                ),
            )))
            .expect_err("extension program should be validated by config loading");

            assert_eq!(
                format!("{error:#}"),
                "extension `foo` program must not have leading or trailing whitespace"
            );
        }
    }

    #[test]
    fn extension_dispatch_config_rejects_removed_transport_section() {
        let error = Config::load_for_extension_dispatch_from_sources(Some((
            Path::new("client.toml"),
            r#"
[transport]
default_profile = " remote"

[extensions.commands.foo]
program = "cue-foo"
"#,
        )))
        .expect_err("removed transport config must not survive the hard cut");
        assert!(
            format!("{error:#}").contains("unknown top-level client config section `transport`")
        );
    }

    #[test]
    fn extension_dispatch_config_rejects_unknown_top_level_sections() {
        let error = Config::load_for_extension_dispatch_from_sources(Some((
            Path::new("client.toml"),
            r#"
[extensons]
path_lookup = true
"#,
        )))
        .expect_err("top-level extension config typos should not be silently defaulted");

        assert!(
            format!("{error:#}").contains("unknown top-level client config section `extensons`")
        );
    }
}