xacli_core/application/
app.rs

1use std::io::Write;
2
3use super::parser::Parser;
4use crate::{application::AppContext, Command, Context, ContextInfo, Error, Result};
5
6pub struct App {
7    pub info: AppInfo,
8    pub root: Command,
9}
10
11#[derive(Debug, Clone, PartialEq)]
12pub struct AppInfo {
13    pub name: String,
14    pub version: String,
15    pub title: String,
16    pub description: String,
17}
18
19impl App {
20    /// Create a new application with name and version
21    ///
22    /// This is the recommended way to create an App. Use method chaining
23    /// to configure the application before calling `run()`.
24    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
25        let name = name.into();
26        let root_name = name.clone();
27        Self {
28            info: AppInfo {
29                name,
30                version: version.into(),
31                title: String::new(),
32                description: String::new(),
33            },
34            root: Command::new(root_name),
35        }
36    }
37
38    pub fn command(mut self, cmd: Command) -> Self {
39        self.root.subcommands.push(cmd);
40        self
41    }
42
43    /// Set the run handler for the root command
44    ///
45    /// This allows the app to execute logic directly without requiring subcommands.
46    /// Useful for simple CLIs or testing scenarios.
47    ///
48    /// # Example
49    ///
50    /// ```
51    /// use xacli_core::App;
52    ///
53    /// let app = App::new("myapp", "1.0.0")
54    ///     .run(Box::new(|ctx| {
55    ///         println!("Hello from root!");
56    ///         Ok(())
57    ///     }));
58    /// ```
59    pub fn run(mut self, f: Box<crate::command::RunFn>) -> Self {
60        self.root.hooks.run_fn = Some(f);
61        self
62    }
63
64    /// Set the pre_run handler for the root command
65    pub fn pre_run(mut self, f: Box<crate::command::RunFn>) -> Self {
66        self.root.hooks.pre_run_fn = Some(f);
67        self
68    }
69
70    /// Set the post_run handler for the root command
71    pub fn post_run(mut self, f: Box<crate::command::RunFn>) -> Self {
72        self.root.hooks.post_run_fn = Some(f);
73        self
74    }
75
76    /// Add an argument to the root command
77    ///
78    /// This allows adding arguments that apply when no subcommand is specified.
79    pub fn arg(mut self, arg: crate::Arg) -> Self {
80        self.root.args.push(arg);
81        self
82    }
83
84    pub fn parse(&self, args: Vec<String>) -> Result<ContextInfo> {
85        let parsed = Parser::new(self, args).parse()?;
86
87        // Find the command (if any)
88        let cmd = self.find_command_by_path(parsed.commands());
89
90        Ok(ContextInfo {
91            app: self.info.clone(),
92            command: cmd
93                .map(|c| c.info.clone())
94                .unwrap_or_else(|| crate::CommandInfo {
95                    name: self.info.name.clone(),
96                    aliases: Vec::new(),
97                    title: String::new(),
98                    description: String::new(),
99                }),
100            args: parsed,
101        })
102    }
103
104    /// Run the application with command line arguments
105    ///
106    /// This will:
107    /// 1. Parse command-line arguments from std::env::args
108    /// 2. Handle --version flag (print version and exit)
109    /// 3. Handle --help flag or no command (print help and exit)
110    /// 4. Find and execute the matched command
111    ///
112    /// Returns Ok(()) on success, or an error if:
113    /// - Argument parsing fails
114    /// - Command not found
115    /// - Command execution fails
116    pub fn execute(&self) -> Result<()> {
117        let args: Vec<String> = std::env::args().collect();
118
119        let context_info = self.parse(args)?;
120
121        // Create context
122        let mut ctx = AppContext::new(context_info);
123
124        self.execute_with_ctx(&mut ctx)
125    }
126
127    /// Run the application with a pre-configured context
128    ///
129    /// This is useful for testing where you want to use a MockContext
130    /// to capture output and inject input events.
131    ///
132    /// The context should already contain the parsed arguments.
133    pub fn execute_with_ctx(&self, ctx: &mut dyn Context) -> Result<()> {
134        // Extract values we need from ctx to avoid borrow conflicts
135        let is_version = ctx.info().args.is_version();
136        let is_help = ctx.info().args.is_help();
137        let commands = ctx.info().args.commands().to_vec();
138
139        let mut stdout = ctx.stdout();
140
141        // Handle --version
142        if is_version {
143            self.write_version(&mut stdout)?;
144            return Ok(());
145        }
146
147        // Handle --help
148        if is_help {
149            self.write_help_for_path(&mut stdout, &commands)?;
150            return Ok(());
151        }
152
153        // If only app name (no subcommands) and root has a run handler, execute it
154        if commands.len() == 1 {
155            if self.root.hooks.run_fn.is_some() {
156                // Drop stdout to release the borrow before executing
157                drop(stdout);
158                return self.root.execute(ctx);
159            } else {
160                // No root handler and no subcommands specified - show help
161                self.write_help_for_path(&mut stdout, &commands)?;
162                return Ok(());
163            }
164        }
165
166        // Drop stdout before finding and executing command
167        drop(stdout);
168
169        // Find the command
170        let cmd = self.find_command_by_path(&commands).ok_or_else(|| {
171            let available = self.available_commands().join(", ");
172            Error::CommandNotFound(format!(
173                "'{}'. Available commands: {}",
174                commands.last().unwrap_or(&String::new()),
175                available
176            ))
177        })?;
178
179        cmd.execute(ctx)
180    }
181
182    /// Find a command by its path
183    ///
184    /// The path is a slice of command names, starting from the app name.
185    /// For example, ["app", "get", "pod"] would find the "pod" subcommand
186    /// of the "get" command.
187    ///
188    /// Returns None if:
189    /// - The path is empty
190    /// - The first element doesn't match the app name
191    /// - Any command in the path is not found
192    pub fn find_command_by_path(&self, path: &[String]) -> Option<&Command> {
193        // Path must have at least app name + one command
194        if path.len() < 2 {
195            return None;
196        }
197
198        // First element must match app name
199        if path[0] != self.info.name {
200            return None;
201        }
202
203        // Find the first command in root.subcommands
204        let mut current_cmd = self
205            .root
206            .subcommands
207            .iter()
208            .find(|cmd| cmd.info.name == path[1] || cmd.info.aliases.contains(&path[1]))?;
209
210        // Navigate through subcommands for remaining path elements
211        for name in path.iter().skip(2) {
212            current_cmd = current_cmd
213                .subcommands
214                .iter()
215                .find(|cmd| cmd.info.name == *name || cmd.info.aliases.contains(name))?;
216        }
217
218        Some(current_cmd)
219    }
220
221    /// Get all top-level command names for error messages
222    pub fn available_commands(&self) -> Vec<&str> {
223        self.root
224            .subcommands
225            .iter()
226            .map(|c| c.info.name.as_str())
227            .collect()
228    }
229
230    /// Check the application configuration for errors
231    ///
232    /// This verifies that:
233    /// - All leaf commands (commands without subcommands) have a run_fn set
234    ///
235    /// Returns Ok(()) if all checks pass, or Error with details of what's wrong.
236    pub fn check(&self) -> Result<()> {
237        let mut errors: Vec<String> = Vec::new();
238
239        for cmd in &self.root.subcommands {
240            check_command(cmd, &mut errors, vec![self.info.name.clone()]);
241        }
242
243        if errors.is_empty() {
244            Ok(())
245        } else {
246            Err(Error::custom(format!(
247                "Application configuration errors:\n{}",
248                errors.join("\n")
249            )))
250        }
251    }
252
253    /// Print version information to stdout
254    pub fn print_version(&self) {
255        println!("{} {}", self.info.name, self.info.version);
256    }
257
258    /// Write version information to a writer
259    pub fn write_version(&self, w: &mut dyn Write) -> Result<()> {
260        writeln!(w, "{} {}", self.info.name, self.info.version)?;
261        Ok(())
262    }
263
264    /// Print help information for the application
265    pub fn print_help(&self) {
266        self.print_help_for_path(std::slice::from_ref(&self.info.name));
267    }
268
269    /// Print help information for a specific command path
270    pub fn print_help_for_path(&self, path: &[String]) {
271        // Use stdout as default writer
272        let _ = self.write_help_for_path(&mut std::io::stdout(), path);
273    }
274
275    /// Write help information for a specific command path to a writer
276    pub fn write_help_for_path(&self, w: &mut dyn Write, path: &[String]) -> Result<()> {
277        if path.is_empty() {
278            return Ok(());
279        }
280
281        // If only app name in path, print app-level help
282        if path.len() == 1 {
283            self.write_app_help(w)?;
284            return Ok(());
285        }
286
287        // Find the command and print its help
288        if let Some(cmd) = self.find_command_by_path(path) {
289            self.write_command_help(w, cmd, path)?;
290        } else {
291            // Fallback to app help if command not found
292            self.write_app_help(w)?;
293        }
294        Ok(())
295    }
296
297    /// Write app-level help to a writer
298    fn write_app_help(&self, w: &mut dyn Write) -> Result<()> {
299        // Title/description
300        if !self.info.title.is_empty() {
301            writeln!(w, "{}", self.info.title)?;
302        } else {
303            writeln!(w, "{} {}", self.info.name, self.info.version)?;
304        }
305
306        if !self.info.description.is_empty() {
307            writeln!(w)?;
308            writeln!(w, "{}", self.info.description)?;
309        }
310
311        // Usage
312        writeln!(w)?;
313        writeln!(w, "Usage: {} <command> [options]", self.info.name)?;
314
315        // Commands
316        if !self.root.subcommands.is_empty() {
317            writeln!(w)?;
318            writeln!(w, "Commands:")?;
319            for cmd in &self.root.subcommands {
320                let desc = if !cmd.info.title.is_empty() {
321                    &cmd.info.title
322                } else {
323                    &cmd.info.description
324                };
325                writeln!(w, "  {:20} {}", cmd.info.name, desc)?;
326            }
327        }
328
329        // Global options hint
330        writeln!(w)?;
331        writeln!(w, "Options:")?;
332        writeln!(w, "  {:20} Show help information", "-h, --help")?;
333        writeln!(w, "  {:20} Show version information", "-V, --version")?;
334        Ok(())
335    }
336
337    /// Write command-level help to a writer
338    fn write_command_help(&self, w: &mut dyn Write, cmd: &Command, path: &[String]) -> Result<()> {
339        // Title/description
340        if !cmd.info.title.is_empty() {
341            writeln!(w, "{}", cmd.info.title)?;
342        } else {
343            writeln!(w, "{}", path.join(" "))?;
344        }
345
346        if !cmd.info.description.is_empty() {
347            writeln!(w)?;
348            writeln!(w, "{}", cmd.info.description)?;
349        }
350
351        // Usage
352        writeln!(w)?;
353        let usage_path = path.join(" ");
354        if cmd.subcommands.is_empty() {
355            writeln!(w, "Usage: {} [options] [arguments]", usage_path)?;
356        } else {
357            writeln!(w, "Usage: {} <command> [options]", usage_path)?;
358        }
359
360        // Subcommands
361        if !cmd.subcommands.is_empty() {
362            writeln!(w)?;
363            writeln!(w, "Commands:")?;
364            for subcmd in &cmd.subcommands {
365                let desc = if !subcmd.info.title.is_empty() {
366                    &subcmd.info.title
367                } else {
368                    &subcmd.info.description
369                };
370                writeln!(w, "  {:20} {}", subcmd.info.name, desc)?;
371            }
372        }
373
374        // Arguments and options
375        let positional: Vec<_> = cmd
376            .args
377            .iter()
378            .filter(|a| matches!(a.info.kind, crate::ArgKind::Positional))
379            .collect();
380        let options: Vec<_> = cmd
381            .args
382            .iter()
383            .filter(|a| !matches!(a.info.kind, crate::ArgKind::Positional))
384            .collect();
385
386        if !positional.is_empty() {
387            writeln!(w)?;
388            writeln!(w, "Arguments:")?;
389            for arg in positional {
390                let required_marker = if arg.info.schema.required {
391                    " (required)"
392                } else {
393                    ""
394                };
395                writeln!(
396                    w,
397                    "  {:20} {}{}",
398                    arg.info.name, arg.info.description, required_marker
399                )?;
400            }
401        }
402
403        if !options.is_empty() {
404            writeln!(w)?;
405            writeln!(w, "Options:")?;
406            for arg in options {
407                let (short, long) = match &arg.info.kind {
408                    crate::ArgKind::Flag { short, long, .. }
409                    | crate::ArgKind::Option { short, long, .. } => {
410                        let short_str = short.map(|c| format!("-{}, ", c)).unwrap_or_default();
411                        let long_str = format!("--{}", long);
412                        (short_str, long_str)
413                    }
414                    crate::ArgKind::Positional => (String::new(), String::new()),
415                };
416                let flag_str = format!("{}{}", short, long);
417                writeln!(w, "  {:20} {}", flag_str, arg.info.description)?;
418            }
419        }
420
421        // Global options hint
422        writeln!(w)?;
423        writeln!(w, "  {:20} Show help information", "-h, --help")?;
424        Ok(())
425    }
426}
427
428/// Recursively check a command and its subcommands
429fn check_command(cmd: &Command, errors: &mut Vec<String>, mut path: Vec<String>) {
430    path.push(cmd.info.name.clone());
431    let path_str = path.join(" ");
432
433    if cmd.is_leaf() && cmd.hooks.run_fn.is_none() {
434        errors.push(format!("Leaf command '{}' has no run function", path_str));
435    }
436
437    for subcmd in &cmd.subcommands {
438        check_command(subcmd, errors, path.clone());
439    }
440}