|
| 1 | +//! CLI tips system for providing helpful suggestions to users. |
| 2 | +//! |
| 3 | +//! Tips are shown after command execution to help users discover features |
| 4 | +//! and shortcuts. |
| 5 | +
|
| 6 | +mod short_aliases; |
| 7 | +mod use_vpx_or_run; |
| 8 | + |
| 9 | +use clap::error::ErrorKind as ClapErrorKind; |
| 10 | + |
| 11 | +use self::{short_aliases::ShortAliases, use_vpx_or_run::UseVpxOrRun}; |
| 12 | + |
| 13 | +/// Execution context passed in from the CLI entry point. |
| 14 | +pub struct TipContext { |
| 15 | + /// CLI arguments as typed by the user, excluding the program name (`vp`). |
| 16 | + pub raw_args: Vec<String>, |
| 17 | + /// The exit code of the command (0 = success, non-zero = failure). |
| 18 | + pub exit_code: i32, |
| 19 | + /// The clap error if parsing failed. |
| 20 | + pub clap_error: Option<clap::Error>, |
| 21 | +} |
| 22 | + |
| 23 | +impl Default for TipContext { |
| 24 | + fn default() -> Self { |
| 25 | + TipContext { raw_args: Vec::new(), exit_code: 0, clap_error: None } |
| 26 | + } |
| 27 | +} |
| 28 | + |
| 29 | +impl TipContext { |
| 30 | + /// Whether the command completed successfully. |
| 31 | + #[expect(dead_code)] |
| 32 | + pub fn success(&self) -> bool { |
| 33 | + self.exit_code == 0 |
| 34 | + } |
| 35 | + |
| 36 | + #[expect(dead_code)] |
| 37 | + pub fn is_unknown_command_error(&self) -> bool { |
| 38 | + if let Some(err) = &self.clap_error { |
| 39 | + matches!(err.kind(), ClapErrorKind::InvalidSubcommand) |
| 40 | + } else { |
| 41 | + false |
| 42 | + } |
| 43 | + } |
| 44 | + |
| 45 | + /// Iterate positional args (skipping flags starting with `-`). |
| 46 | + fn positionals(&self) -> impl Iterator<Item = &str> { |
| 47 | + self.raw_args.iter().map(String::as_str).filter(|a| !a.starts_with('-')) |
| 48 | + } |
| 49 | + |
| 50 | + /// The subcommand (first positional arg, e.g., "ls", "build"). |
| 51 | + pub fn subcommand(&self) -> Option<&str> { |
| 52 | + self.positionals().next() |
| 53 | + } |
| 54 | + |
| 55 | + /// Whether the positional args start with the given command pattern. |
| 56 | + /// Pattern is space-separated: "pm list" matches even if flags are interspersed. |
| 57 | + #[expect(dead_code)] |
| 58 | + pub fn is_subcommand(&self, pattern: &str) -> bool { |
| 59 | + let mut positionals = self.positionals(); |
| 60 | + pattern.split_whitespace().all(|expected| positionals.next() == Some(expected)) |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +/// A tip that can be shown to the user after command execution. |
| 65 | +pub trait Tip { |
| 66 | + /// Whether this tip is relevant given the current execution context. |
| 67 | + fn matches(&self, ctx: &TipContext) -> bool; |
| 68 | + /// The tip text shown to the user. |
| 69 | + fn message(&self) -> &'static str; |
| 70 | +} |
| 71 | + |
| 72 | +/// Returns all registered tips. |
| 73 | +fn all() -> &'static [&'static dyn Tip] { |
| 74 | + &[&ShortAliases, &UseVpxOrRun] |
| 75 | +} |
| 76 | + |
| 77 | +/// Pick a random tip from those matching the current context. |
| 78 | +/// |
| 79 | +/// Returns `None` if: |
| 80 | +/// - The `VITE_PLUS_CLI_TEST` env var is set (test mode) |
| 81 | +/// - No tips match the given context |
| 82 | +pub fn get_tip(context: &TipContext) -> Option<&'static str> { |
| 83 | + if std::env::var_os("VITE_PLUS_CLI_TEST").is_some() { |
| 84 | + return None; |
| 85 | + } |
| 86 | + |
| 87 | + let now = |
| 88 | + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); |
| 89 | + |
| 90 | + let all = all(); |
| 91 | + let matching: Vec<&&dyn Tip> = all.iter().filter(|t| t.matches(context)).collect(); |
| 92 | + |
| 93 | + if matching.is_empty() { |
| 94 | + return None; |
| 95 | + } |
| 96 | + |
| 97 | + // Use subsec_nanos for random tip selection |
| 98 | + let nanos = now.subsec_nanos() as usize; |
| 99 | + Some(matching[nanos % matching.len()].message()) |
| 100 | +} |
| 101 | + |
| 102 | +/// Create a `TipContext` from a command string using real clap parsing. |
| 103 | +/// |
| 104 | +/// `command` is exactly what the user types in the terminal (e.g. `"vp list --flag"`). |
| 105 | +/// The first arg is treated as the program name and excluded from `raw_args`, |
| 106 | +/// matching how the real CLI uses `std::env::args()`. |
| 107 | +#[cfg(test)] |
| 108 | +pub fn tip_context_from_command(command: &str) -> TipContext { |
| 109 | + // Split simulates what the OS does with command line args |
| 110 | + let args: Vec<String> = command.split_whitespace().map(String::from).collect(); |
| 111 | + |
| 112 | + let (exit_code, clap_error) = match crate::try_parse_args_from(args.iter().cloned()) { |
| 113 | + Ok(_) => (0, None), |
| 114 | + Err(e) => (e.exit_code(), Some(e)), |
| 115 | + }; |
| 116 | + |
| 117 | + // raw_args excludes program name (args[0]), same as real CLI: args[1..].to_vec() |
| 118 | + let raw_args = args.get(1..).map(<[String]>::to_vec).unwrap_or_default(); |
| 119 | + |
| 120 | + TipContext { raw_args, exit_code, clap_error } |
| 121 | +} |
0 commit comments