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, command_spec: &Map, ) -> BTreeMap { let mut rendered_options = BTreeMap::new(); let mut option_sets = option_sets.iter().collect::>(); 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::>(); 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::>(); 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:>() .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, rendered_options: &BTreeMap, ) { 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, 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::>() .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, field: &str) -> &'a Map { let Some(Value::Object(value)) = object.get(field) else { panic!("missing CLI help object field {field}"); }; value } fn array_field<'a>(object: &'a Map, 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, 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, field: &str) -> String { optional_string_field(object, field).unwrap_or_else(|| panic!("missing CLI help field {field}")) } fn optional_string_field(object: &Map, field: &str) -> Option { 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::>() .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::>() .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 ", "description": "Show a specific session" } ] }); let command_spec = json!({ "combinedOptions": { "agent_session_options": ["agent_options", "session_options"] }, "root": { "usage": ["ccusage "], "commands": [ { "name": "session", "description": "Show usage grouped by session" } ] }, "pages": [ { "path": ["session"], "description": "Show usage grouped by session", "usage": "ccusage session ", "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 ")); } #[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": [ { "name": "agent", "description": "Show agent usage commands" } ] }, "pages": [ { "path": ["agent"], "description": "Usage reports for agent.", "usage": "ccusage agent ", "commands": [ { "name": "daily", "description": "Show usage by day" } ] }, { "path": ["agent", "daily"], "description": "Show usage by day", "usage": "ccusage agent daily ", "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 ", "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")); } }