sylphx-cli 0.29.11

The Sylphx CLI: every Sylphx API method as a command, generated from the Sylphx schema registry.
//! The hand-written commands, as data: `sylphx porcelain-manifest` prints
//! them from the same clap tree the binary parses, and `generated/porcelain.json`
//! is that output, regenerated by `scripts/ci/regenerate.sh`. The docs guard
//! (`scripts/check-doc-cli.ts`) reads the file, so a new porcelain command is
//! documentable the moment it exists and nothing keeps a second list by hand.

use std::collections::BTreeMap;

use clap::Command;
use serde_json::{json, Value};

use crate::tree::Tree;

/// The subcommand that prints the manifest. Hidden: it is a build tool, not an
/// operator command, so it is not part of the manifest it prints.
pub const COMMAND: &str = "porcelain-manifest";

pub fn command() -> Command {
    Command::new(COMMAND)
        .about("Print the hand-written commands as JSON (regenerates generated/porcelain.json)")
        .hide(true)
}

/// Every command that is not generated: top-level commands that are not a
/// generated service (with their subcommands), and the subcommands a generated
/// service carries that are neither a collection nor a service-level method
/// (`auth enable`, `ai top`). Hidden commands are left out; `help` is
/// implicit in clap and always present.
pub fn manifest(cmd: &Command, tree: &Tree) -> Value {
    let visible = |c: &Command| !c.is_hide_set();
    let names = |c: &Command| -> Vec<String> {
        let mut v: Vec<String> = c
            .get_subcommands()
            .filter(|s| visible(s))
            .map(|s| s.get_name().to_string())
            .collect();
        v.sort();
        v
    };
    fn nested(cmd: &Command, path: &str, out: &mut BTreeMap<String, Vec<String>>) {
        let mut children: Vec<_> = cmd.get_subcommands().filter(|s| !s.is_hide_set()).collect();
        children.sort_by_key(|s| s.get_name());
        if !children.is_empty() {
            out.insert(
                path.to_string(),
                children.iter().map(|s| s.get_name().to_string()).collect(),
            );
            for child in children {
                nested(child, &format!("{path} {}", child.get_name()), out);
            }
        }
    }
    let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for sub in cmd.get_subcommands().filter(|s| visible(s)) {
        let name = sub.get_name();
        match tree.services.iter().find(|s| s.noun == name) {
            None => {
                out.insert(name.to_string(), names(sub));
                nested(sub, name, &mut out);
            }
            Some(svc) => {
                let extra: Vec<String> = names(sub)
                    .into_iter()
                    .filter(|n| {
                        !svc.collections.iter().any(|c| &c.noun == n)
                            && !svc.commands.iter().any(|m| &m.verb == n)
                            && (crate::HAND_WRITTEN.contains(&(name, n.as_str()))
                                || !svc
                                    .porcelain
                                    .iter()
                                    .any(|p| p.words().first() == Some(&n.as_str())))
                    })
                    .collect();
                if !extra.is_empty() {
                    for child in sub
                        .get_subcommands()
                        .filter(|c| extra.iter().any(|n| n == c.get_name()))
                    {
                        nested(child, &format!("{name} {}", child.get_name()), &mut out);
                    }
                    out.insert(name.to_string(), extra);
                }
            }
        }
    }
    out.entry("help".to_string()).or_default();
    json!({
        "generated": "@generated by `sylphx porcelain-manifest` (scripts/ci/regenerate.sh). Do not edit.",
        "commands": out,
    })
}

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

    fn tree() -> Tree {
        crate::tree::load()
    }

    #[test]
    fn lists_porcelain_and_service_porcelain_only() {
        let t = tree();
        let m = manifest(&crate::cli(&t), &t);
        let c = &m["commands"];
        // Top-level porcelain, with its subcommands.
        assert!(c["login"].is_array());
        assert_eq!(c["org"], json!(["use"]));
        assert!(c["help"].is_array());
        assert!(c["devices"].as_array().is_some_and(|d| !d.is_empty()));
        // Hand-written commands mounted on generated services.
        assert_eq!(c["ai"], json!(["top"]));
        assert_eq!(c["auth"], json!(["enable", "status"]));
        // Generated services and the hidden build command are not porcelain.
        assert!(c["access"].is_null());
        assert!(c[COMMAND].is_null());
    }

    #[test]
    fn a_new_hand_written_command_shows_up_without_any_list() {
        let t = tree();
        let svc = t
            .services
            .first()
            .expect("a generated service")
            .noun
            .clone();
        let cmd = crate::cli(&t)
            .subcommand(Command::new("frobnicate"))
            .mut_subcommand(svc.clone(), |s| {
                s.subcommand(
                    Command::new("zap")
                        .subcommand(Command::new("nested").subcommand(Command::new("run"))),
                )
            });
        let m = manifest(&cmd, &t);
        assert_eq!(m["commands"]["frobnicate"], json!([]));
        assert_eq!(m["commands"][format!("{svc} zap")], json!(["nested"]));
        assert_eq!(m["commands"][format!("{svc} zap nested")], json!(["run"]));
        assert!(m["commands"][&svc]
            .as_array()
            .is_some_and(|v| v.contains(&json!("zap"))));
    }

    /// `generated/porcelain.json` is the manifest of this binary. When a
    /// command changed: `cargo run -p sylphx-cli -- porcelain-manifest >
    /// clients/cli/generated/porcelain.json` (CI's regenerate step does it).
    #[test]
    fn the_checked_in_manifest_is_fresh() {
        let t = tree();
        let want = serde_json::to_string_pretty(&manifest(&crate::cli(&t), &t)).unwrap() + "\n";
        let have = include_str!("../generated/porcelain.json");
        assert_eq!(
            have, want,
            "generated/porcelain.json is stale: regenerate it"
        );
    }
}