csusage-cli-parser 0.0.1

Coding agent CLI usage reports (ccusage fork with Claude Science support)
Documentation
use std::collections::BTreeMap;

use serde_json::{Map, Value};

pub(crate) fn generate_cli_help_source(option_sets: &Value, command_spec: &Value) -> String {
    let Value::Object(option_sets) = option_sets else {
        panic!("CLI help spec must be a JSON object");
    };
    let Value::Object(command_spec) = command_spec else {
        panic!("CLI command spec must be a JSON object");
    };

    let mut output = String::from("// @generated by build.rs\n\n");
    let rendered_options = generate_option_functions(&mut output, option_sets, command_spec);
    generate_command_spec(&mut output, command_spec, &rendered_options);
    output
}

fn generate_option_functions(
    output: &mut String,
    option_sets: &Map<String, Value>,
    command_spec: &Map<String, Value>,
) -> BTreeMap<String, String> {
    let mut rendered_options = BTreeMap::new();
    let mut option_sets = option_sets.iter().collect::<Vec<_>>();
    option_sets.sort_by_key(|(left, _)| *left);
    for (function_name, options) in option_sets {
        let rendered = render_options(options);
        rendered_options.insert(function_name.to_string(), rendered.clone());
        write_static_str_fn(output, function_name, &rendered);
    }

    let combined_options = object_field(command_spec, "combinedOptions");
    let mut combined_options = combined_options.iter().collect::<Vec<_>>();
    combined_options.sort_by_key(|(left, _)| *left);
    for (function_name, parts) in combined_options {
        let Value::Array(parts) = parts else {
            panic!("combined option parts must be an array");
        };
        let parts = parts
            .iter()
            .map(|part| {
                part.as_str()
                    .expect("combined option part must be a string")
            })
            .collect::<Vec<_>>();
        let rendered = render_combined_options(&rendered_options, &parts);
        rendered_options.insert(function_name.to_string(), rendered.clone());
        write_static_str_fn(output, function_name, &rendered);
    }
    rendered_options
}

pub(crate) fn render_options(options: &Value) -> String {
    let Value::Array(options) = options else {
        panic!("CLI help option set must be an array");
    };
    let width = options
        .iter()
        .map(|option| {
            let Value::Object(option) = option else {
                panic!("CLI help option must be an object");
            };
            string_field(option, "flags").len()
        })
        .max()
        .unwrap_or(36)
        .max(36);
    let mut lines = vec!["OPTIONS:".to_string()];
    for option in options {
        let Value::Object(option) = option else {
            panic!("CLI help option must be an object");
        };
        let flags = string_field(option, "flags");
        let description = string_field(option, "description");
        let mut line = format!("  {flags:<width$} {description}");
        let mut details = Vec::new();
        if let Some(default) = optional_string_field(option, "default") {
            details.push(format!("default: {default}"));
        }
        if let Some(choices) = option.get("choices") {
            let Value::Array(choices) = choices else {
                panic!("CLI help choices must be an array");
            };
            if !choices.is_empty() {
                details.push(format!(
                    "choices: {}",
                    choices
                        .iter()
                        .map(|choice| {
                            choice
                                .as_str()
                                .expect("CLI help choice must be a string")
                                .to_string()
                        })
                        .collect::<Vec<_>>()
                        .join(" | ")
                ));
            }
        }
        if !details.is_empty() {
            line.push_str(&format!(" ({})", details.join(", ")));
        }
        lines.push(line);
    }
    lines.join("\n")
}

fn generate_command_spec(
    output: &mut String,
    command_spec: &Map<String, Value>,
    rendered_options: &BTreeMap<String, String>,
) {
    let root = object_field(command_spec, "root");
    output.push_str("const ROOT_USAGE: &[&str] = &");
    output.push_str(&rust_string_slice(array_field(root, "usage")));
    output.push_str(";\n\n");
    output.push_str("const ROOT_COMMANDS: &[(&str, &str)] = &");
    output.push_str(&rust_command_slice(array_field(root, "commands")));
    output.push_str(";\n\n");

    let pages = array_field(command_spec, "pages");
    output.push_str("const HELP_PAGES: &[HelpPage] = &[\n");
    for page in pages {
        let Value::Object(page) = page else {
            panic!("help page must be an object");
        };
        let path = array_field(page, "path");
        let description = string_field(page, "description");
        let usage = string_field(page, "usage");
        let commands = optional_array_field(page, "commands");
        let option_set = optional_string_field(page, "options");
        if let Some(option_set) = option_set.as_deref()
            && !rendered_options.contains_key(option_set)
        {
            panic!("missing option set {option_set}");
        }
        output.push_str("    HelpPage {\n");
        output.push_str("        path: &");
        output.push_str(&rust_string_slice(path));
        output.push_str(",\n");
        output.push_str("        description: ");
        output.push_str(&rust_string_literal(&description));
        output.push_str(",\n");
        output.push_str("        usage: ");
        output.push_str(&rust_string_literal(&usage));
        output.push_str(",\n");
        output.push_str("        options: ");
        match option_set {
            Some(option_set) => {
                output.push_str("Some(");
                output.push_str(&option_set);
                output.push_str("()),\n");
            }
            None => output.push_str("None,\n"),
        }
        output.push_str("        commands: &");
        output.push_str(&rust_command_slice(commands.unwrap_or(&[])));
        output.push_str(",\n");
        output.push_str("    },\n");
    }
    output.push_str("];\n");
}

fn render_combined_options(options: &BTreeMap<String, String>, parts: &[&str]) -> String {
    let option_lines = parts
        .iter()
        .flat_map(|part| {
            options
                .get(*part)
                .unwrap_or_else(|| panic!("missing generated option set {part}"))
                .lines()
                .skip(1)
        })
        .collect::<Vec<_>>()
        .join("\n");
    format!("OPTIONS:\n{option_lines}")
}

fn write_static_str_fn(output: &mut String, function_name: &str, value: &str) {
    output.push_str(&format!(
        "#[allow(dead_code)]\nconst fn {function_name}() -> &'static str {{\n    {}\n}}\n\n",
        rust_string_literal(value)
    ));
}

fn object_field<'a>(object: &'a Map<String, Value>, field: &str) -> &'a Map<String, Value> {
    let Some(Value::Object(value)) = object.get(field) else {
        panic!("missing CLI help object field {field}");
    };
    value
}

fn array_field<'a>(object: &'a Map<String, Value>, field: &str) -> &'a [Value] {
    let Some(Value::Array(value)) = object.get(field) else {
        panic!("missing CLI help array field {field}");
    };
    value
}

fn optional_array_field<'a>(object: &'a Map<String, Value>, field: &str) -> Option<&'a [Value]> {
    object.get(field).map(|value| {
        let Value::Array(value) = value else {
            panic!("CLI help field {field} must be an array");
        };
        value.as_slice()
    })
}

fn string_field(object: &Map<String, Value>, field: &str) -> String {
    optional_string_field(object, field).unwrap_or_else(|| panic!("missing CLI help field {field}"))
}

fn optional_string_field(object: &Map<String, Value>, field: &str) -> Option<String> {
    object.get(field).map(|value| {
        value
            .as_str()
            .unwrap_or_else(|| panic!("CLI help field {field} must be a string"))
            .to_string()
    })
}

fn rust_string_slice(values: &[Value]) -> String {
    let values = values
        .iter()
        .map(|value| {
            rust_string_literal(
                value
                    .as_str()
                    .expect("CLI help string array item must be a string"),
            )
        })
        .collect::<Vec<_>>()
        .join(", ");
    format!("[{values}]")
}

fn rust_command_slice(values: &[Value]) -> String {
    let values = values
        .iter()
        .map(|value| {
            let Value::Object(value) = value else {
                panic!("CLI help command item must be an object");
            };
            format!(
                "({}, {})",
                rust_string_literal(&string_field(value, "name")),
                rust_string_literal(&string_field(value, "description"))
            )
        })
        .collect::<Vec<_>>()
        .join(", ");
    format!("[{values}]")
}

fn rust_string_literal(value: &str) -> String {
    serde_json::to_string(value).expect("encode Rust string literal")
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    #[test]
    fn generates_static_combined_option_functions_from_data() {
        let option_sets = json!({
            "agent_options": [
                {
                    "flags": "--json",
                    "description": "Output in JSON format"
                }
            ],
            "session_options": [
                {
                    "flags": "-i, --id <ID>",
                    "description": "Show a specific session"
                }
            ]
        });
        let command_spec = json!({
            "combinedOptions": {
                "agent_session_options": ["agent_options", "session_options"]
            },
            "root": {
                "usage": ["ccusage <COMMANDS>"],
                "commands": [
                    {
                        "name": "session",
                        "description": "Show usage grouped by session"
                    }
                ]
            },
            "pages": [
                {
                    "path": ["session"],
                    "description": "Show usage grouped by session",
                    "usage": "ccusage session <OPTIONS>",
                    "options": "agent_session_options"
                }
            ]
        });

        let output = generate_cli_help_source(&option_sets, &command_spec);

        assert!(output.contains("const fn agent_session_options() -> &'static str"));
        assert!(output.contains("OPTIONS:\\n  --json"));
        assert!(output.contains("-i, --id <ID>"));
    }

    #[test]
    fn generates_command_help_pages_from_data() {
        let option_sets = json!({
            "agent_options": [
                {
                    "flags": "--json",
                    "description": "Output in JSON format"
                }
            ]
        });
        let command_spec = json!({
            "combinedOptions": {},
            "root": {
                "usage": ["ccusage <COMMANDS>"],
                "commands": [
                    {
                        "name": "agent",
                        "description": "Show agent usage commands"
                    }
                ]
            },
            "pages": [
                {
                    "path": ["agent"],
                    "description": "Usage reports for agent.",
                    "usage": "ccusage agent <COMMANDS>",
                    "commands": [
                        {
                            "name": "daily",
                            "description": "Show usage by day"
                        }
                    ]
                },
                {
                    "path": ["agent", "daily"],
                    "description": "Show usage by day",
                    "usage": "ccusage agent daily <OPTIONS>",
                    "options": "agent_options"
                }
            ]
        });

        let output = generate_cli_help_source(&option_sets, &command_spec);

        assert!(output.contains("const ROOT_COMMANDS"));
        assert!(output.contains("path: &[\"agent\"]"));
        assert!(output.contains("path: &[\"agent\", \"daily\"]"));
        assert!(output.contains("commands: &[(\"daily\", \"Show usage by day\")]"));
    }

    #[test]
    fn renders_defaults_and_choices_in_option_details() {
        let options = json!([
            {
                "flags": "--mode <MODE>",
                "description": "Cost calculation mode",
                "default": "auto",
                "choices": ["auto", "calculate", "display"]
            }
        ]);

        let output = render_options(&options);

        assert!(output.contains("default: auto"));
        assert!(output.contains("choices: auto | calculate | display"));
    }
}