Skip to main content

wyvern_schema/
command.rs

1//! Typed command surface for the current phase.
2
3use crate::chrome::{ChromeStatus, ChromeTitle};
4use crate::report::ReportCommand;
5use crate::wizard::WizardCommand;
6
7/// Standard button preset for dialog types (REQ Phase B).
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ButtonsPreset {
10    /// Single OK button.
11    Ok,
12    /// OK + Cancel.
13    OkCancel,
14    /// Yes + No.
15    YesNo,
16    /// Yes + No + Cancel.
17    YesNoCancel,
18    /// Retry + Cancel.
19    RetryCancel,
20    /// Caller-supplied labels via `custom_buttons`.
21    Custom,
22}
23
24impl ButtonsPreset {
25    /// Parse a wire preset name (`ok`, `ok_cancel`, …).
26    pub fn parse(value: &str) -> Option<Self> {
27        match value {
28            "ok" => Some(Self::Ok),
29            "ok_cancel" => Some(Self::OkCancel),
30            "yes_no" => Some(Self::YesNo),
31            "yes_no_cancel" => Some(Self::YesNoCancel),
32            "retry_cancel" => Some(Self::RetryCancel),
33            "custom" => Some(Self::Custom),
34            _ => None,
35        }
36    }
37
38    /// All valid wire names (for error messages / suggestions).
39    pub fn all_names() -> &'static [&'static str] {
40        &[
41            "ok",
42            "ok_cancel",
43            "yes_no",
44            "yes_no_cancel",
45            "retry_cancel",
46            "custom",
47        ]
48    }
49
50    /// Display labels shown in the HTML button bar (ipc-dialog-contract).
51    pub fn display_labels(self, custom_buttons: Option<&[String]>) -> Vec<String> {
52        match self {
53            Self::Ok => vec!["OK".into()],
54            Self::OkCancel => vec!["OK".into(), "Cancel".into()],
55            Self::YesNo => vec!["Yes".into(), "No".into()],
56            Self::YesNoCancel => vec!["Yes".into(), "No".into(), "Cancel".into()],
57            Self::RetryCancel => vec!["Retry".into(), "Cancel".into()],
58            Self::Custom => custom_buttons.unwrap_or(&[]).to_vec(),
59        }
60    }
61
62    /// Stdout / IPC wire labels corresponding 1:1 with [`Self::display_labels`].
63    pub fn wire_labels(self, custom_buttons: Option<&[String]>) -> Vec<String> {
64        match self {
65            Self::Ok => vec!["ok".into()],
66            Self::OkCancel => vec!["ok".into(), "cancel".into()],
67            Self::YesNo => vec!["yes".into(), "no".into()],
68            Self::YesNoCancel => vec!["yes".into(), "no".into(), "cancel".into()],
69            Self::RetryCancel => vec!["retry".into(), "cancel".into()],
70            Self::Custom => custom_buttons.unwrap_or(&[]).to_vec(),
71        }
72    }
73
74    /// Number of buttons for the active preset (or custom list).
75    pub fn button_count(self, custom_buttons: Option<&[String]>) -> usize {
76        self.wire_labels(custom_buttons).len()
77    }
78}
79
80/// Semantic severity for a message dialog (REQ-0012).
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum MessageLevel {
83    /// Informational notice.
84    Info,
85    /// Caution / non-fatal problem.
86    Warning,
87    /// Error condition.
88    Error,
89    /// Prompt requiring a decision.
90    Question,
91}
92
93impl MessageLevel {
94    /// Parse a wire level name (`info`, `warning`, …).
95    pub fn parse(value: &str) -> Option<Self> {
96        match value {
97            "info" => Some(Self::Info),
98            "warning" => Some(Self::Warning),
99            "error" => Some(Self::Error),
100            "question" => Some(Self::Question),
101            _ => None,
102        }
103    }
104
105    /// All valid wire names (for error messages / suggestions).
106    pub fn all_names() -> &'static [&'static str] {
107        &["info", "warning", "error", "question"]
108    }
109
110    /// Wire / asset name for this level.
111    pub fn as_str(self) -> &'static str {
112        match self {
113            Self::Info => "info",
114            Self::Warning => "warning",
115            Self::Error => "error",
116            Self::Question => "question",
117        }
118    }
119}
120
121/// Input dialog mode (REQ-0014).
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum InputMode {
124    /// Free-text field (default when `mode` is omitted).
125    Text,
126    /// Native file picker via `rfd` on the HTTP host (`POST /api/picker/file`).
127    File,
128    /// Native folder picker via `rfd` on the HTTP host (`POST /api/picker/folder`).
129    Folder,
130}
131
132impl InputMode {
133    /// Parse a wire mode name (`text`, `file`, `folder`).
134    pub fn parse(value: &str) -> Option<Self> {
135        match value {
136            "text" => Some(Self::Text),
137            "file" => Some(Self::File),
138            "folder" => Some(Self::Folder),
139            _ => None,
140        }
141    }
142
143    /// All valid wire names (for error messages / suggestions).
144    pub fn all_names() -> &'static [&'static str] {
145        &["text", "file", "folder"]
146    }
147
148    /// Wire name for this mode.
149    pub fn as_str(self) -> &'static str {
150        match self {
151            Self::Text => "text",
152            Self::File => "file",
153            Self::Folder => "folder",
154        }
155    }
156}
157
158/// Optional viewer window size in CSS pixels (embedded shell or browser window hint).
159#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
160pub struct WindowSizeHint {
161    /// Width in CSS pixels.
162    pub width: Option<u32>,
163    /// Height in CSS pixels.
164    pub height: Option<u32>,
165}
166
167impl WindowSizeHint {
168    /// True when either dimension is set.
169    pub fn is_some(&self) -> bool {
170        self.width.is_some() || self.height.is_some()
171    }
172}
173
174/// Executable command after successful validation.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum Command {
177    /// Foundation chrome frame: required `title`, optional `status`.
178    Chrome {
179        title: ChromeTitle,
180        status: Option<ChromeStatus>,
181        width: Option<u32>,
182        height: Option<u32>,
183    },
184    /// Modal message dialog (Phase B sprint b.1 / b.2).
185    Message {
186        title: ChromeTitle,
187        message: String,
188        status: Option<ChromeStatus>,
189        buttons: ButtonsPreset,
190        custom_buttons: Option<Vec<String>>,
191        default_button: Option<u32>,
192        level: Option<MessageLevel>,
193        icon: Option<crate::MediaRef>,
194        image: Option<crate::MediaRef>,
195        markdown: bool,
196        width: Option<u32>,
197        height: Option<u32>,
198    },
199    /// Modal input dialog — text / file / folder (REQ-0013 / REQ-0015).
200    Input {
201        title: ChromeTitle,
202        message: String,
203        status: Option<ChromeStatus>,
204        icon: Option<crate::MediaRef>,
205        markdown: bool,
206        multiline: bool,
207        placeholder: Option<String>,
208        default: Option<String>,
209        /// Mask the text field (`type=password`); text mode only (c.11).
210        password: bool,
211        mode: InputMode,
212        /// Extension patterns (`*.json`, …); file mode only (REQ-0015 / REQ-0059).
213        filter: Option<Vec<String>>,
214        /// Multi-file selection; file mode only (REQ-0015 / REQ-0059).
215        multiple: bool,
216        /// Initial picker directory; file or folder mode only (REQ-0059).
217        start_path: Option<String>,
218        buttons: ButtonsPreset,
219        width: Option<u32>,
220        height: Option<u32>,
221    },
222    /// Markdown viewer — exactly one of `file` or `content` (REQ-0016 / REQ-0058).
223    Markdown {
224        /// Window title; omitted → filename (file) or `"Markdown"` (inline).
225        title: Option<ChromeTitle>,
226        /// Path to a `.md` file (mutually exclusive with `content`).
227        file: Option<String>,
228        /// Inline markdown source (mutually exclusive with `file`).
229        content: Option<String>,
230        status: Option<ChromeStatus>,
231        /// Defaults to [`ButtonsPreset::Ok`] when omitted.
232        buttons: ButtonsPreset,
233        width: Option<u32>,
234        height: Option<u32>,
235    },
236    /// Question cards dialog (REQ-0061 / REQ-0062).
237    Question {
238        /// Typed cards used for rendering and host-side answer checks.
239        questions: Vec<QuestionCard>,
240        /// Verbatim `questions` array entries for stdout echo (REQ-0067).
241        questions_raw: Vec<serde_json::Value>,
242        width: Option<u32>,
243        height: Option<u32>,
244    },
245    /// Multi-page wizard (Phase D / REQ-0017 / REQ-0026).
246    Wizard(WizardCommand),
247    /// Static XHTML/HTML report (Phase H / REQ-0140 / ADR-0025).
248    Report(ReportCommand),
249}
250
251impl Command {
252    /// Optional viewer window width from command JSON (`width`).
253    pub fn window_width(&self) -> Option<u32> {
254        match self {
255            Self::Chrome { width, .. }
256            | Self::Message { width, .. }
257            | Self::Input { width, .. }
258            | Self::Markdown { width, .. }
259            | Self::Question { width, .. } => *width,
260            Self::Wizard(cmd) => cmd.width,
261            Self::Report(cmd) => cmd.width,
262        }
263    }
264
265    /// Optional viewer window height from command JSON (`height`).
266    pub fn window_height(&self) -> Option<u32> {
267        match self {
268            Self::Chrome { height, .. }
269            | Self::Message { height, .. }
270            | Self::Input { height, .. }
271            | Self::Markdown { height, .. }
272            | Self::Question { height, .. } => *height,
273            Self::Wizard(cmd) => cmd.height,
274            Self::Report(cmd) => cmd.height,
275        }
276    }
277
278    /// Combined optional window size hint.
279    pub fn window_size_hint(&self) -> WindowSizeHint {
280        WindowSizeHint {
281            width: self.window_width(),
282            height: self.window_height(),
283        }
284    }
285}
286
287/// One selectable option inside a [`QuestionCard`] (AskUserQuestion wire names).
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct QuestionOption {
290    /// Display / answer label.
291    pub label: String,
292    /// Secondary text under the label.
293    pub description: String,
294    /// Optional HTML/markdown preview fragment (rendered sanitized in b.8).
295    pub preview: Option<String>,
296}
297
298/// Why [`QuestionPrompt::try_new`] rejected a value.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum QuestionPromptError {
301    /// Prompt was empty.
302    Empty,
303}
304
305impl std::fmt::Display for QuestionPromptError {
306    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307        match self {
308            Self::Empty => f.write_str("question prompt must be a non-empty string"),
309        }
310    }
311}
312
313impl std::error::Error for QuestionPromptError {}
314
315/// Validated question-card prompt (non-empty; also the stdout `answers` key).
316///
317/// Construct via [`Self::try_new`] at the [`crate::validate`] boundary so
318/// [`QuestionCard::question`] cannot carry an unchecked `String`.
319#[derive(Debug, Clone, PartialEq, Eq, Hash)]
320pub struct QuestionPrompt(String);
321
322impl QuestionPrompt {
323    /// Wrap an already-validated prompt.
324    ///
325    /// Prefer [`Self::try_new`] at trust boundaries.
326    pub fn new(value: impl Into<String>) -> Self {
327        Self(value.into())
328    }
329
330    /// Construct a non-empty prompt.
331    ///
332    /// # Errors
333    ///
334    /// Returns [`QuestionPromptError::Empty`] when `value` is empty.
335    pub fn try_new(value: impl Into<String>) -> Result<Self, QuestionPromptError> {
336        let value = value.into();
337        if value.is_empty() {
338            return Err(QuestionPromptError::Empty);
339        }
340        Ok(Self(value))
341    }
342
343    /// Borrow as a string slice.
344    pub fn as_str(&self) -> &str {
345        &self.0
346    }
347
348    /// Consume and return the inner string.
349    pub fn into_inner(self) -> String {
350        self.0
351    }
352}
353
354impl std::ops::Deref for QuestionPrompt {
355    type Target = str;
356
357    fn deref(&self) -> &Self::Target {
358        &self.0
359    }
360}
361
362impl AsRef<str> for QuestionPrompt {
363    fn as_ref(&self) -> &str {
364        self.as_str()
365    }
366}
367
368impl std::fmt::Display for QuestionPrompt {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        self.0.fmt(f)
371    }
372}
373
374impl From<String> for QuestionPrompt {
375    fn from(value: String) -> Self {
376        Self::new(value)
377    }
378}
379
380impl From<&str> for QuestionPrompt {
381    fn from(value: &str) -> Self {
382        Self::new(value)
383    }
384}
385
386impl PartialEq<str> for QuestionPrompt {
387    fn eq(&self, other: &str) -> bool {
388        self.0 == other
389    }
390}
391
392impl PartialEq<&str> for QuestionPrompt {
393    fn eq(&self, other: &&str) -> bool {
394        self.0 == *other
395    }
396}
397
398/// One question card in a `type: "question"` command (REQ-0062).
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct QuestionCard {
401    /// Prompt text; also the key in the stdout `answers` map.
402    pub question: QuestionPrompt,
403    /// Short card header (max 12 characters).
404    pub header: String,
405    /// Selectable options (2–4 entries).
406    pub options: Vec<QuestionOption>,
407    /// When true, checkboxes and comma-joined labels; otherwise radio.
408    pub multi_select: bool,
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn preset_label_mapping_table() {
417        assert_eq!(ButtonsPreset::Ok.display_labels(None), ["OK"]);
418        assert_eq!(ButtonsPreset::Ok.wire_labels(None), ["ok"]);
419
420        assert_eq!(
421            ButtonsPreset::OkCancel.display_labels(None),
422            ["OK", "Cancel"]
423        );
424        assert_eq!(ButtonsPreset::OkCancel.wire_labels(None), ["ok", "cancel"]);
425
426        assert_eq!(ButtonsPreset::YesNo.display_labels(None), ["Yes", "No"]);
427        assert_eq!(ButtonsPreset::YesNo.wire_labels(None), ["yes", "no"]);
428
429        assert_eq!(
430            ButtonsPreset::YesNoCancel.display_labels(None),
431            ["Yes", "No", "Cancel"]
432        );
433        assert_eq!(
434            ButtonsPreset::YesNoCancel.wire_labels(None),
435            ["yes", "no", "cancel"]
436        );
437
438        assert_eq!(
439            ButtonsPreset::RetryCancel.display_labels(None),
440            ["Retry", "Cancel"]
441        );
442        assert_eq!(
443            ButtonsPreset::RetryCancel.wire_labels(None),
444            ["retry", "cancel"]
445        );
446    }
447
448    #[test]
449    fn custom_labels_are_verbatim() {
450        let custom = vec!["Save".into(), "Discard".into()];
451        assert_eq!(ButtonsPreset::Custom.display_labels(Some(&custom)), custom);
452        assert_eq!(ButtonsPreset::Custom.wire_labels(Some(&custom)), custom);
453    }
454
455    #[test]
456    fn question_prompt_try_new_rejects_empty() {
457        assert_eq!(QuestionPrompt::try_new(""), Err(QuestionPromptError::Empty));
458        assert_eq!(QuestionPrompt::try_new("Q?").unwrap().as_str(), "Q?");
459    }
460}