Skip to main content

command_surface/
lib.rs

1mod glyph;
2mod traits;
3
4pub use glyph::{Color, Glyph, Presentation};
5pub use traits::{Category, Scope, Tag};
6
7#[cfg(feature = "render")]
8pub mod render;
9
10use std::collections::HashMap;
11use std::io::{self, BufRead, Write};
12
13#[derive(Clone, Copy, Debug)]
14pub struct Example {
15    pub command: &'static str,
16    pub description: &'static str,
17}
18
19/// Pre-invocation confirmation gate.
20///
21/// Declared in the command manifest and checked by the CLI dispatch layer
22/// *before* the handler runs.  Has no effect on HTTP endpoints - the API
23/// is not interactive.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum Confirmation {
26    /// Prompt the user to type an exact token (e.g. `"RESET"`).
27    ///
28    /// The `message` is printed to stderr first, then the user is asked
29    /// to type `token`.  Any other input aborts.
30    TypeToken {
31        message: &'static str,
32        token: &'static str,
33    },
34    /// Simple yes/no prompt.  The `message` is shown, and the user must
35    /// type `y` or `yes` (case-insensitive) to proceed.
36    YesNo { message: &'static str },
37}
38
39impl Confirmation {
40    /// Run the confirmation prompt on the given reader/writer pair.
41    ///
42    /// Returns `Ok(true)` if the user confirmed, `Ok(false)` if they
43    /// declined, and `Err` on I/O failure.
44    pub fn prompt<R: BufRead, W: Write>(&self, reader: &mut R, writer: &mut W) -> io::Result<bool> {
45        match self {
46            Confirmation::TypeToken { message, token } => {
47                writeln!(writer, "{message}")?;
48                write!(writer, "Type {token} to continue: ")?;
49                writer.flush()?;
50                let mut line = String::new();
51                reader.read_line(&mut line)?;
52                Ok(line.trim() == *token)
53            }
54            Confirmation::YesNo { message } => {
55                write!(writer, "{message} [y/N] ")?;
56                writer.flush()?;
57                let mut line = String::new();
58                reader.read_line(&mut line)?;
59                let answer = line.trim().to_ascii_lowercase();
60                Ok(answer == "y" || answer == "yes")
61            }
62        }
63    }
64
65    /// Convenience: prompt on real stdin/stderr.
66    pub fn prompt_stdio(&self) -> io::Result<bool> {
67        let mut stdin = io::stdin().lock();
68        let mut stderr = io::stderr();
69        self.prompt(&mut stdin, &mut stderr)
70    }
71}
72
73/// An HTTP API endpoint reference for CLI help display.
74///
75/// Shows the HTTP equivalent of a CLI command (e.g. `GET /v1/mdns/discover`).
76/// Full OpenAPI metadata (summaries, schemas, query params) is owned by the
77/// domain crates via `#[utoipa::path]` annotations on their handlers.
78#[derive(Clone, Copy, Debug)]
79pub struct ApiEndpoint {
80    pub method: &'static str,
81    pub path: &'static str,
82}
83
84#[derive(Clone, Copy, Debug)]
85pub struct CommandDef<C: Category, T: Tag, S: Scope> {
86    pub name: &'static str,
87    pub summary: &'static str,
88    pub category: C,
89    pub tags: &'static [T],
90    pub scope: S,
91    pub examples: &'static [Example],
92    pub see_also: &'static [&'static str],
93    /// Multi-paragraph explanation shown by the `?` detail view.
94    /// Lines are separated by `\n`. Empty string means no detail available.
95    pub long_description: &'static str,
96    /// HTTP API equivalents. Empty slice means CLI-only.
97    pub api: &'static [ApiEndpoint],
98    /// Optional pre-invocation confirmation gate (CLI-only).
99    ///
100    /// When set, the CLI dispatch layer should call
101    /// [`Confirmation::prompt_stdio`] before running the command handler.
102    /// The HTTP API ignores this field entirely.
103    pub confirmation: Option<Confirmation>,
104}
105
106impl<C: Category, T: Tag, S: Scope> CommandDef<C, T, S> {
107    /// Returns `true` if this command requires interactive confirmation
108    /// before execution.
109    pub fn requires_confirmation(&self) -> bool {
110        self.confirmation.is_some()
111    }
112
113    /// Run the confirmation gate if one is defined.
114    ///
115    /// Returns `Ok(true)` if no confirmation is needed or the user confirmed,
116    /// `Ok(false)` if the user declined.
117    pub fn gate(&self) -> io::Result<bool> {
118        match &self.confirmation {
119            None => Ok(true),
120            Some(c) => c.prompt_stdio(),
121        }
122    }
123}
124
125#[derive(Default)]
126pub struct CommandManifest<C: Category, T: Tag, S: Scope> {
127    commands: HashMap<&'static str, CommandDef<C, T, S>>,
128}
129
130impl<C: Category, T: Tag, S: Scope> CommandManifest<C, T, S> {
131    pub fn new() -> Self {
132        Self {
133            commands: HashMap::new(),
134        }
135    }
136
137    pub fn add(&mut self, def: CommandDef<C, T, S>) -> &mut Self {
138        let previous = self.commands.insert(def.name, def);
139        debug_assert!(previous.is_none(), "duplicate command name: {}", def.name);
140        self
141    }
142
143    pub fn get(&self, name: &str) -> Option<&CommandDef<C, T, S>> {
144        self.commands.get(name)
145    }
146
147    pub fn all_sorted(&self) -> Vec<&CommandDef<C, T, S>> {
148        let mut items: Vec<_> = self.commands.values().collect();
149        items.sort_by_key(|def| (def.category.order(), def.name));
150        items
151    }
152
153    pub fn by_category(&self, cat: C) -> Vec<&CommandDef<C, T, S>> {
154        let mut items: Vec<_> = self
155            .commands
156            .values()
157            .filter(|def| def.category == cat)
158            .collect();
159        items.sort_by_key(|def| def.name);
160        items
161    }
162
163    pub fn by_tag(&self, tag: T) -> Vec<&CommandDef<C, T, S>> {
164        let mut items: Vec<_> = self
165            .commands
166            .values()
167            .filter(|def| def.tags.contains(&tag))
168            .collect();
169        items.sort_by_key(|def| def.name);
170        items
171    }
172
173    pub fn by_scope(&self, scope: S) -> Vec<&CommandDef<C, T, S>> {
174        let mut items: Vec<_> = self
175            .commands
176            .values()
177            .filter(|def| def.scope == scope)
178            .collect();
179        items.sort_by_key(|def| def.name);
180        items
181    }
182
183    pub fn categories_in_order(&self) -> Vec<C> {
184        let mut categories = Vec::new();
185        for def in self.commands.values() {
186            if !categories.contains(&def.category) {
187                categories.push(def.category);
188            }
189        }
190        categories.sort_by_key(|cat| cat.order());
191        categories
192    }
193}