Something went wrong. Try again.
npx ccusage
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379use 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")); }}