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"));
}
}