Skip to main content

reddb_server/cli/
help.rs

1/// Help text generation for the RedDB CLI.
2///
3/// Consolidates all help formatting into a single module with functions
4/// that accept structured data and produce consistent, well-aligned help
5/// output strings.
6use super::commands::{Flag, Route};
7
8/// Minimum column width for the flag/verb label column.
9const LABEL_WIDTH: usize = 20;
10
11/// Global help: list all domains with descriptions.
12///
13/// `domains` is a slice of `(name, description, aliases)`.
14pub fn format_global_help(domains: &[(String, String, Vec<String>)]) -> String {
15    let mut out = String::with_capacity(1024);
16
17    out.push_str("reddb -- unified multi-model database engine\n");
18    out.push('\n');
19    out.push_str("Usage: red <command> [args] [flags]\n");
20    out.push('\n');
21
22    out.push_str("Commands:\n");
23    for (name, description, aliases) in domains {
24        let alias_text = if aliases.is_empty() {
25            String::new()
26        } else {
27            format!(" [{}]", aliases.join(", "))
28        };
29        out.push_str(&format!("  {:<14} {}{}\n", name, description, alias_text));
30    }
31    out.push('\n');
32
33    out.push_str("Global flags:\n");
34    out.push_str(&format!("  {:<20} {}\n", "-h, --help", "Show help"));
35    out.push_str(&format!("  {:<20} {}\n", "-j, --json", "Force JSON output"));
36    out.push_str(&format!(
37        "  {:<20} {}\n",
38        "-o, --output FORMAT", "Output format [text|json|yaml]"
39    ));
40    out.push_str(&format!("  {:<20} {}\n", "-v, --verbose", "Verbose output"));
41    out.push_str(&format!(
42        "  {:<20} {}\n",
43        "    --no-color", "Disable colors"
44    ));
45    out.push_str(&format!("  {:<20} {}\n", "    --version", "Show version"));
46    out.push('\n');
47
48    out.push_str("Run 'red <command> help' for more information\n");
49    out
50}
51
52/// Domain help: list resources and their verbs for a domain.
53///
54/// `resources` is a slice of `(name, description, routes)`.
55pub fn format_domain_help(domain: &str, resources: &[(String, String, Vec<Route>)]) -> String {
56    let mut out = String::with_capacity(512);
57
58    out.push_str(&format!("red {} -- {}\n", domain, domain_label(domain)));
59    out.push('\n');
60
61    out.push_str("Resources:\n");
62    for (name, description, routes) in resources {
63        out.push_str(&format!("  {:<14} {}\n", name, description));
64        for route in routes {
65            out.push_str(&format!("    {:<12} {}\n", route.verb, route.summary));
66        }
67    }
68    out.push('\n');
69
70    out.push_str(&format!(
71        "Run 'red {} <resource> help' for more information\n",
72        domain
73    ));
74    out
75}
76
77/// Resource/command help: list verbs with flags for a specific resource.
78pub fn format_command_help(
79    domain: &str,
80    resource: &str,
81    routes: &[Route],
82    flags: &[Flag],
83) -> String {
84    let mut out = String::with_capacity(512);
85
86    out.push_str(&format!("red {} {} -- {}\n", domain, resource, resource));
87    out.push('\n');
88
89    if !routes.is_empty() {
90        out.push_str("Verbs:\n");
91        for route in routes {
92            out.push_str(&format!("  {:<14} {}\n", route.verb, route.summary));
93        }
94        out.push('\n');
95    }
96
97    let all_flags = merge_with_global_flags(flags);
98    if !all_flags.is_empty() {
99        out.push_str("Flags:\n");
100        for flag in &all_flags {
101            out.push_str(&format_flag(flag));
102            out.push('\n');
103        }
104        out.push('\n');
105    }
106
107    if !routes.is_empty() {
108        out.push_str("Examples:\n");
109        for route in routes {
110            if !route.usage.is_empty() {
111                out.push_str(&format!("  {}\n", route.usage));
112            }
113        }
114        if routes.iter().any(|r| !r.usage.is_empty()) {
115            out.push('\n');
116        }
117    }
118
119    out
120}
121
122/// Route help: detailed help for a single verb.
123pub fn format_route_help(domain: &str, resource: &str, route: &Route, flags: &[Flag]) -> String {
124    let mut out = String::with_capacity(512);
125
126    out.push_str(&format!(
127        "red {} {} {} -- {}\n",
128        domain, resource, route.verb, route.summary
129    ));
130    out.push('\n');
131
132    // Usage line
133    out.push_str(&format!(
134        "Usage: red {} {} {} <target>",
135        domain, resource, route.verb
136    ));
137    for flag in flags {
138        if let Some(ref arg_name) = flag.arg {
139            let token = if let Some(ch) = flag.short {
140                format!("-{}", ch)
141            } else {
142                format!("--{}", flag.long)
143            };
144            out.push_str(&format!(" [{}  {}]", token, arg_name.to_uppercase()));
145        }
146    }
147    out.push('\n');
148    out.push('\n');
149
150    // Command-specific flags
151    if !flags.is_empty() {
152        out.push_str("Flags:\n");
153        for flag in flags {
154            out.push_str(&format_flag(flag));
155            out.push('\n');
156        }
157        out.push('\n');
158    }
159
160    // Global flags section
161    out.push_str("Global flags:\n");
162    for flag in &global_flag_list() {
163        out.push_str(&format_flag(flag));
164        out.push('\n');
165    }
166    out.push('\n');
167
168    out
169}
170
171/// Format a single flag line for help display.
172fn format_flag(flag: &Flag) -> String {
173    let short_part = match flag.short {
174        Some(ch) => format!("-{}, ", ch),
175        None => "    ".to_string(),
176    };
177
178    let arg_part = match flag.arg {
179        Some(ref name) => format!(" {}", name.to_uppercase()),
180        None => String::new(),
181    };
182
183    let label = format!("{}--{}{}", short_part, flag.long, arg_part);
184
185    // Pad label to LABEL_WIDTH; if it exceeds, use the actual length + 2 spaces
186    let padding = if label.len() < LABEL_WIDTH {
187        LABEL_WIDTH - label.len()
188    } else {
189        2
190    };
191
192    let default_text = match flag.default {
193        Some(ref d) => format!(" (default: {})", d),
194        None => String::new(),
195    };
196
197    format!(
198        "  {}{}{}{}",
199        label,
200        " ".repeat(padding),
201        flag.description,
202        default_text,
203    )
204}
205
206/// Produce a minimal domain label from the domain name.
207fn domain_label(domain: &str) -> &str {
208    match domain {
209        "server" => "Start the database server/router",
210        "query" => "Execute queries",
211        "insert" => "Insert entities",
212        "get" => "Retrieve entities",
213        "delete" => "Delete entities",
214        "health" => "Health check",
215        "status" => "Replication status",
216        "version" => "Version information",
217        "data" => "Data operations",
218        "index" => "Index management",
219        "graph" => "Graph operations",
220        "replica" => "Start as read replica",
221        _ => domain,
222    }
223}
224
225/// Merge command-specific flags with global flags, avoiding duplicates.
226fn merge_with_global_flags(command_flags: &[Flag]) -> Vec<Flag> {
227    let mut result: Vec<Flag> = command_flags.to_vec();
228    let existing_longs: Vec<&str> = command_flags.iter().map(|f| f.long.as_str()).collect();
229
230    for gf in global_flag_list() {
231        if !existing_longs.contains(&gf.long.as_str()) {
232            result.push(gf);
233        }
234    }
235
236    result
237}
238
239/// Canonical global flags expressed as Flag instances.
240fn global_flag_list() -> Vec<Flag> {
241    vec![
242        Flag::new("help", "Show help").with_short('h'),
243        Flag::new("json", "Force JSON output").with_short('j'),
244        Flag::new("output", "Output format")
245            .with_short('o')
246            .with_arg("FORMAT"),
247        Flag::new("verbose", "Verbose output").with_short('v'),
248        Flag::new("no-color", "Disable colors"),
249        Flag::new("version", "Show version"),
250    ]
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_format_global_help() {
259        let domains = vec![
260            (
261                "server".to_string(),
262                "Start database server".to_string(),
263                vec![],
264            ),
265            (
266                "query".to_string(),
267                "Execute queries".to_string(),
268                vec!["q".to_string()],
269            ),
270        ];
271        let help = format_global_help(&domains);
272
273        assert!(help.contains("reddb"));
274        assert!(help.contains("Usage: red <command>"));
275        assert!(help.contains("Commands:"));
276        assert!(help.contains("server"));
277        assert!(help.contains("Start database server"));
278        assert!(help.contains("[q]"));
279        assert!(help.contains("query"));
280        assert!(help.contains("Global flags:"));
281        assert!(help.contains("-h, --help"));
282        assert!(help.contains("-j, --json"));
283        assert!(help.contains("Run 'red <command> help'"));
284    }
285
286    #[test]
287    fn test_format_domain_help() {
288        let resources = vec![(
289            "collection".to_string(),
290            "Collection operations".to_string(),
291            vec![Route {
292                verb: "list",
293                summary: "List all collections",
294                usage: "red data collection list",
295            }],
296        )];
297        let help = format_domain_help("data", &resources);
298
299        assert!(help.contains("red data"));
300        assert!(help.contains("Resources:"));
301        assert!(help.contains("collection"));
302        assert!(help.contains("Collection operations"));
303        assert!(help.contains("list"));
304        assert!(help.contains("List all collections"));
305        assert!(help.contains("Run 'red data <resource> help'"));
306    }
307
308    #[test]
309    fn test_format_command_help() {
310        let routes = vec![Route {
311            verb: "list",
312            summary: "List all collections",
313            usage: "red data collection list --output json",
314        }];
315        let flags = vec![Flag::new("format", "Output format")
316            .with_short('f')
317            .with_arg("FORMAT")];
318        let help = format_command_help("data", "collection", &routes, &flags);
319
320        assert!(help.contains("red data collection"));
321        assert!(help.contains("Verbs:"));
322        assert!(help.contains("list"));
323        assert!(help.contains("Flags:"));
324        assert!(help.contains("-f, --format FORMAT"));
325        assert!(help.contains("Output format"));
326        assert!(help.contains("Examples:"));
327        assert!(help.contains("red data collection list --output json"));
328    }
329
330    #[test]
331    fn test_format_route_help() {
332        let route = Route {
333            verb: "list",
334            summary: "List all collections",
335            usage: "red data collection list",
336        };
337        let flags = vec![Flag::new("format", "Output format")
338            .with_short('f')
339            .with_arg("FORMAT")];
340        let help = format_route_help("data", "collection", &route, &flags);
341
342        assert!(help.contains("red data collection list -- List all collections"));
343        assert!(help.contains("Usage: red data collection list <target>"));
344        assert!(help.contains("Flags:"));
345        assert!(help.contains("-f, --format FORMAT"));
346        assert!(help.contains("Global flags:"));
347        assert!(help.contains("-h, --help"));
348    }
349
350    #[test]
351    fn test_format_flag_with_short_and_arg() {
352        let flag = Flag::new("type", "Record type")
353            .with_short('t')
354            .with_arg("TYPE");
355        let formatted = format_flag(&flag);
356
357        assert!(formatted.contains("-t, --type TYPE"));
358        assert!(formatted.contains("Record type"));
359    }
360
361    #[test]
362    fn test_format_flag_boolean() {
363        let flag = Flag::new("verbose", "Enable verbose output");
364        let formatted = format_flag(&flag);
365
366        assert!(formatted.starts_with("      --verbose"));
367        assert!(formatted.contains("Enable verbose output"));
368        // No arg name after the flag
369        assert!(!formatted.contains("VERBOSE"));
370    }
371
372    #[test]
373    fn test_format_flag_with_default() {
374        let flag = Flag::new("format", "Output format")
375            .with_short('f')
376            .with_arg("FORMAT")
377            .with_default("text");
378        let formatted = format_flag(&flag);
379
380        assert!(formatted.contains("-f, --format FORMAT"));
381        assert!(formatted.contains("Output format"));
382        assert!(formatted.contains("(default: text)"));
383    }
384
385    #[test]
386    fn test_format_flag_long_only_no_arg() {
387        let flag = Flag::new("no-color", "Disable colors");
388        let formatted = format_flag(&flag);
389
390        // Long-only: 4-space indent where short flag would be
391        assert!(formatted.contains("    --no-color"));
392        assert!(formatted.contains("Disable colors"));
393    }
394
395    #[test]
396    fn test_format_global_help_empty_domains() {
397        let domains: Vec<(String, String, Vec<String>)> = vec![];
398        let help = format_global_help(&domains);
399        assert!(help.contains("Commands:"));
400        assert!(help.contains("Global flags:"));
401    }
402
403    #[test]
404    fn test_format_command_help_no_flags() {
405        let routes = vec![Route {
406            verb: "start",
407            summary: "Start the server",
408            usage: "red serve start",
409        }];
410        let help = format_command_help("server", "grpc", &routes, &[]);
411
412        assert!(help.contains("Verbs:"));
413        assert!(help.contains("start"));
414        // Global flags are always merged in
415        assert!(help.contains("Flags:"));
416        assert!(help.contains("--help"));
417    }
418}