Skip to main content

wyvern_schema/
report.rs

1//! Report command and result types (Phase H / ADR-0025).
2
3use std::fmt;
4use std::ops::Deref;
5
6use serde::{Deserialize, Deserializer, Serialize};
7
8/// Maximum review comments length (Unicode scalar values).
9///
10/// Bound from [xhtml-reporting-contract.md](../../docs/plans/phase-H/xhtml-reporting-contract.md)
11/// so finish JSON stays small and textarea input cannot balloon the session payload.
12pub const MAX_REVIEW_COMMENTS_CHARS: usize = 32_768;
13
14/// Maximum `panels` entries on a report command (schema `maxItems`).
15///
16/// Bound from [xhtml-reporting-contract.md](../../docs/plans/phase-H/xhtml-reporting-contract.md)
17/// and `review-manifest.schema.json`. Changing this changes the preexec and
18/// validate error inventory.
19pub const MAX_REPORT_PANELS: usize = 32;
20
21/// Maximum `panels[].label` length (Unicode scalar values).
22///
23/// Pane headings stay short (basename fallback is typically a filename). 256
24/// covers localized titles without letting finish JSON carry an unbounded string.
25pub const MAX_PANEL_LABEL_CHARS: usize = 256;
26
27/// Error when a report identity or path field is invalid.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ReportFieldError {
30    /// Value is empty.
31    Empty,
32    /// [`ReportPagePath`] does not end with `.html` or `.xhtml`.
33    InvalidPageSuffix,
34    /// [`ManifestPanelPath`] does not end with `.xhtml`.
35    InvalidPanelSuffix,
36    /// [`ReviewComments`] exceeds [`MAX_REVIEW_COMMENTS_CHARS`].
37    CommentsTooLong,
38    /// [`PanelLabel`] exceeds [`MAX_PANEL_LABEL_CHARS`].
39    LabelTooLong,
40}
41
42impl fmt::Display for ReportFieldError {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::Empty => f.write_str("report field must be a non-empty string"),
46            Self::InvalidPageSuffix => {
47                f.write_str("report page path must end with .html or .xhtml")
48            }
49            Self::InvalidPanelSuffix => f.write_str("manifest panel path must end with .xhtml"),
50            Self::CommentsTooLong => write!(
51                f,
52                "review comments must be at most {MAX_REVIEW_COMMENTS_CHARS} characters"
53            ),
54            Self::LabelTooLong => write!(
55                f,
56                "panel label must be at most {MAX_PANEL_LABEL_CHARS} characters"
57            ),
58        }
59    }
60}
61
62impl std::error::Error for ReportFieldError {}
63
64macro_rules! report_newtype {
65    ($(#[$meta:meta])* $name:ident, $doc:literal) => {
66        $(#[$meta])*
67        #[doc = $doc]
68        ///
69        /// Construct via [`Self::try_new`] at the validation boundary so downstream
70        /// code can treat the value as already checked non-empty.
71        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72        #[serde(transparent)]
73        pub struct $name(String);
74
75        impl $name {
76            /// Wrap a validated non-empty string.
77            ///
78            /// Prefer [`Self::try_new`] at trust boundaries; this constructor is for
79            /// already-validated values (e.g. after [`crate::validate`]).
80            pub fn new(value: impl Into<String>) -> Self {
81                Self(value.into())
82            }
83
84            /// Borrow as a string slice.
85            pub fn as_str(&self) -> &str {
86                &self.0
87            }
88
89            /// Consume and return the inner string.
90            pub fn into_inner(self) -> String {
91                self.0
92            }
93        }
94
95        impl Deref for $name {
96            type Target = str;
97
98            fn deref(&self) -> &Self::Target {
99                &self.0
100            }
101        }
102
103        impl AsRef<str> for $name {
104            fn as_ref(&self) -> &str {
105                self.as_str()
106            }
107        }
108
109        impl fmt::Display for $name {
110            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111                self.0.fmt(f)
112            }
113        }
114
115        impl PartialEq<str> for $name {
116            fn eq(&self, other: &str) -> bool {
117                self.0 == other
118            }
119        }
120
121        impl PartialEq<&str> for $name {
122            fn eq(&self, other: &&str) -> bool {
123                self.0 == *other
124            }
125        }
126    };
127}
128
129report_newtype!(
130    ReportPagePath,
131    "Validated report page path relative to `--ui-root` (`.html` or `.xhtml`)."
132);
133report_newtype!(ReportTitle, "Validated report window title (non-empty).");
134report_newtype!(
135    ManifestPanelPath,
136    "Validated manifest panel path (non-empty `.xhtml` relative path)."
137);
138
139impl ReportTitle {
140    /// Construct from a non-empty string.
141    ///
142    /// # Errors
143    ///
144    /// Returns [`ReportFieldError::Empty`] when `value` is empty.
145    pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
146        let value = value.into();
147        if value.is_empty() {
148            return Err(ReportFieldError::Empty);
149        }
150        Ok(Self(value))
151    }
152}
153
154impl ReportPagePath {
155    /// Construct from a non-empty `.html` or `.xhtml` path.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`ReportFieldError::Empty`] when `value` is empty, or
160    /// [`ReportFieldError::InvalidPageSuffix`] when the path does not end with
161    /// `.html` or `.xhtml` (ASCII case-insensitive).
162    pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
163        let value = value.into();
164        if value.is_empty() {
165            return Err(ReportFieldError::Empty);
166        }
167        if !has_html_or_xhtml_suffix(&value) {
168            return Err(ReportFieldError::InvalidPageSuffix);
169        }
170        Ok(Self(value))
171    }
172}
173
174impl ManifestPanelPath {
175    /// Construct from a non-empty `.xhtml` relative path.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`ReportFieldError::Empty`] when `value` is empty, or
180    /// [`ReportFieldError::InvalidPanelSuffix`] when the path does not end with
181    /// `.xhtml` (ASCII case-insensitive).
182    pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
183        let value = value.into();
184        if value.is_empty() {
185            return Err(ReportFieldError::Empty);
186        }
187        if !has_xhtml_suffix(&value) {
188            return Err(ReportFieldError::InvalidPanelSuffix);
189        }
190        Ok(Self(value))
191    }
192}
193
194fn has_html_or_xhtml_suffix(value: &str) -> bool {
195    let lower = value.to_ascii_lowercase();
196    lower.ends_with(".html") || lower.ends_with(".xhtml")
197}
198
199fn has_xhtml_suffix(value: &str) -> bool {
200    value.to_ascii_lowercase().ends_with(".xhtml")
201}
202
203/// Bounded review-finish comments (empty allowed, max 32 KiB scalars).
204///
205/// Construct via [`Self::try_new`] at HTTP/JSON trust boundaries so
206/// [`ReportFinishData`] cannot carry an unbounded `String`.
207#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
208#[serde(transparent)]
209pub struct ReviewComments(String);
210
211impl ReviewComments {
212    /// Wrap already-validated comments, including the empty string.
213    ///
214    /// Prefer [`Self::try_new`] at trust boundaries.
215    pub fn new(value: impl Into<String>) -> Self {
216        Self(value.into())
217    }
218
219    /// Construct comments that fit [`MAX_REVIEW_COMMENTS_CHARS`].
220    ///
221    /// Empty comments are allowed. Length is counted in Unicode scalar values.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`ReportFieldError::CommentsTooLong`] when `value` exceeds the bound.
226    pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
227        let value = value.into();
228        if value.chars().count() > MAX_REVIEW_COMMENTS_CHARS {
229            return Err(ReportFieldError::CommentsTooLong);
230        }
231        Ok(Self(value))
232    }
233
234    /// Borrow as a string slice.
235    pub fn as_str(&self) -> &str {
236        &self.0
237    }
238
239    /// Consume and return the inner string.
240    pub fn into_inner(self) -> String {
241        self.0
242    }
243}
244
245impl Deref for ReviewComments {
246    type Target = str;
247
248    fn deref(&self) -> &Self::Target {
249        &self.0
250    }
251}
252
253impl AsRef<str> for ReviewComments {
254    fn as_ref(&self) -> &str {
255        self.as_str()
256    }
257}
258
259impl fmt::Display for ReviewComments {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        self.0.fmt(f)
262    }
263}
264
265impl PartialEq<str> for ReviewComments {
266    fn eq(&self, other: &str) -> bool {
267        self.0 == other
268    }
269}
270
271impl PartialEq<&str> for ReviewComments {
272    fn eq(&self, other: &&str) -> bool {
273        self.0 == *other
274    }
275}
276
277impl<'de> Deserialize<'de> for ReviewComments {
278    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
279        let value = String::deserialize(deserializer)?;
280        Self::try_new(value).map_err(serde::de::Error::custom)
281    }
282}
283
284/// Bounded pane heading for a manifest panel (non-empty, max 256 scalars).
285///
286/// Construct via [`Self::try_new`] at the validation boundary so
287/// [`ReportPanelEntry::label`] cannot carry an unbounded `String`.
288#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
289#[serde(transparent)]
290pub struct PanelLabel(String);
291
292impl PanelLabel {
293    /// Wrap an already-validated pane heading.
294    ///
295    /// Prefer [`Self::try_new`] at trust boundaries.
296    pub fn new(value: impl Into<String>) -> Self {
297        Self(value.into())
298    }
299
300    /// Construct a non-empty label that fits [`MAX_PANEL_LABEL_CHARS`].
301    ///
302    /// # Errors
303    ///
304    /// Returns [`ReportFieldError::Empty`] when `value` is empty, or
305    /// [`ReportFieldError::LabelTooLong`] when it exceeds the bound.
306    pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
307        let value = value.into();
308        if value.is_empty() {
309            return Err(ReportFieldError::Empty);
310        }
311        if value.chars().count() > MAX_PANEL_LABEL_CHARS {
312            return Err(ReportFieldError::LabelTooLong);
313        }
314        Ok(Self(value))
315    }
316
317    /// Borrow as a string slice.
318    pub fn as_str(&self) -> &str {
319        &self.0
320    }
321
322    /// Consume and return the inner string.
323    pub fn into_inner(self) -> String {
324        self.0
325    }
326}
327
328impl Deref for PanelLabel {
329    type Target = str;
330
331    fn deref(&self) -> &Self::Target {
332        &self.0
333    }
334}
335
336impl AsRef<str> for PanelLabel {
337    fn as_ref(&self) -> &str {
338        self.as_str()
339    }
340}
341
342impl fmt::Display for PanelLabel {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        self.0.fmt(f)
345    }
346}
347
348impl From<String> for PanelLabel {
349    fn from(value: String) -> Self {
350        Self::new(value)
351    }
352}
353
354impl From<&str> for PanelLabel {
355    fn from(value: &str) -> Self {
356        Self::new(value)
357    }
358}
359
360impl PartialEq<str> for PanelLabel {
361    fn eq(&self, other: &str) -> bool {
362        self.0 == other
363    }
364}
365
366impl PartialEq<&str> for PanelLabel {
367    fn eq(&self, other: &&str) -> bool {
368        self.0 == *other
369    }
370}
371
372impl<'de> Deserialize<'de> for PanelLabel {
373    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
374        let value = String::deserialize(deserializer)?;
375        Self::try_new(value).map_err(serde::de::Error::custom)
376    }
377}
378
379/// Report session mode (`view` | `review`).
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
381#[serde(rename_all = "lowercase")]
382pub enum ReportMode {
383    /// Static document; OS-close / `/api/result` dismiss (h.1).
384    View,
385    /// Static document plus terminal Approve/Cancel (h.3).
386    Review,
387}
388
389impl ReportMode {
390    /// Parse a wire mode name (`view`, `review`).
391    pub fn parse(value: &str) -> Option<Self> {
392        match value {
393            "view" => Some(Self::View),
394            "review" => Some(Self::Review),
395            _ => None,
396        }
397    }
398
399    /// All valid wire names (for error messages / suggestions).
400    pub fn all_names() -> &'static [&'static str] {
401        &["view", "review"]
402    }
403
404    /// Wire name for this mode.
405    pub fn as_str(self) -> &'static str {
406        match self {
407            Self::View => "view",
408            Self::Review => "review",
409        }
410    }
411}
412
413/// CSS role on a stitched pane (`failure` | `proposal` | `info`).
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
415#[serde(rename_all = "lowercase")]
416pub enum PanelRole {
417    /// Failed-benchmark / error pane.
418    Failure,
419    /// Proposed-fix pane.
420    Proposal,
421    /// Informational pane.
422    Info,
423}
424
425impl PanelRole {
426    /// Parse a wire role name.
427    pub fn parse(value: &str) -> Option<Self> {
428        match value {
429            "failure" => Some(Self::Failure),
430            "proposal" => Some(Self::Proposal),
431            "info" => Some(Self::Info),
432            _ => None,
433        }
434    }
435
436    /// All valid wire names (for error messages / suggestions).
437    pub fn all_names() -> &'static [&'static str] {
438        &["failure", "proposal", "info"]
439    }
440
441    /// Wire name for this role.
442    pub fn as_str(self) -> &'static str {
443        match self {
444            Self::Failure => "failure",
445            Self::Proposal => "proposal",
446            Self::Info => "info",
447        }
448    }
449}
450
451/// One manifest panel entry on a report command (required when `mode` is review).
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453pub struct ReportPanelEntry {
454    /// `.xhtml` path relative to the manifest / `ui_root`.
455    pub path: ManifestPanelPath,
456    /// Optional pane heading (defaults to basename at stitch time).
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub label: Option<PanelLabel>,
459    /// Optional CSS role.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub role: Option<PanelRole>,
462}
463
464/// Validated report ingress after schema validation (REQ-0140).
465#[derive(Debug, Clone, PartialEq, Eq)]
466pub struct ReportCommand {
467    /// Window / viewer title.
468    pub title: ReportTitle,
469    /// Page path relative to `--ui-root` (`.html` or `.xhtml`).
470    pub page: ReportPagePath,
471    /// `view` (default) or `review`.
472    pub mode: ReportMode,
473    /// Manifest panel entries; required when [`ReportMode::Review`].
474    pub panels: Option<Vec<ReportPanelEntry>>,
475    /// Optional viewer width hint.
476    pub width: Option<u32>,
477    /// Optional viewer height hint.
478    pub height: Option<u32>,
479}
480
481/// Terminal buttons accepted on report stdout (view dismiss + review finish).
482#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
483#[serde(rename_all = "lowercase")]
484pub enum ReportTerminalButton {
485    /// Viewer dismissed / OS-close (no `data`).
486    Dismissed,
487    /// Review-mode Approve/Cancel finish (h.3).
488    Finish,
489}
490
491impl ReportTerminalButton {
492    /// Wire name for this button.
493    pub fn as_str(self) -> &'static str {
494        match self {
495            Self::Dismissed => "dismissed",
496            Self::Finish => "finish",
497        }
498    }
499}
500
501/// Review-mode finish payload (h.3). Absent on view dismiss.
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
503pub struct ReportFinishData {
504    /// `true` = Approve; `false` = Cancel.
505    pub approved: bool,
506    /// Free-text comments (may be empty; bounded by [`MAX_REVIEW_COMMENTS_CHARS`]).
507    pub comments: ReviewComments,
508    /// Echo of authoritative manifest panel entries.
509    pub panels: Vec<ReportPanelEntry>,
510}
511
512/// Report stdout / dismiss-or-finish body (REQ-0143 / REQ-0144).
513#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514pub struct ReportResult {
515    /// Terminal button (`dismissed` | `finish`).
516    pub button: ReportTerminalButton,
517    /// Review finish payload; omitted on view dismiss.
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub data: Option<ReportFinishData>,
520}
521
522impl ReportResult {
523    /// View-mode / OS-close dismiss with no finish `data` (REQ-0143).
524    pub fn dismissed() -> Self {
525        Self {
526            button: ReportTerminalButton::Dismissed,
527            data: None,
528        }
529    }
530
531    /// Review-mode Approve/Cancel finish with validated `data` (REQ-0144).
532    pub fn finished(data: ReportFinishData) -> Self {
533        Self {
534            button: ReportTerminalButton::Finish,
535            data: Some(data),
536        }
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn review_comments_try_new_enforces_bound() {
546        assert_eq!(ReviewComments::try_new("").unwrap().as_str(), "");
547        assert_eq!(
548            ReviewComments::try_new("x".repeat(MAX_REVIEW_COMMENTS_CHARS + 1)),
549            Err(ReportFieldError::CommentsTooLong)
550        );
551        assert!(ReviewComments::try_new("x".repeat(MAX_REVIEW_COMMENTS_CHARS)).is_ok());
552    }
553
554    #[test]
555    fn panel_label_try_new_rejects_empty_and_enforces_bound() {
556        assert_eq!(PanelLabel::try_new(""), Err(ReportFieldError::Empty));
557        assert_eq!(
558            PanelLabel::try_new("x".repeat(MAX_PANEL_LABEL_CHARS + 1)),
559            Err(ReportFieldError::LabelTooLong)
560        );
561        assert_eq!(PanelLabel::try_new("Fail 1").unwrap().as_str(), "Fail 1");
562        assert!(PanelLabel::try_new("x".repeat(MAX_PANEL_LABEL_CHARS)).is_ok());
563    }
564
565    #[test]
566    fn report_title_try_new_rejects_empty() {
567        assert_eq!(ReportTitle::try_new(""), Err(ReportFieldError::Empty));
568        assert_eq!(ReportTitle::try_new("ok").unwrap().as_str(), "ok");
569    }
570
571    #[test]
572    fn report_page_path_try_new_rejects_empty_and_bad_suffix() {
573        assert_eq!(ReportPagePath::try_new(""), Err(ReportFieldError::Empty));
574        assert_eq!(
575            ReportPagePath::try_new("pages/view.txt"),
576            Err(ReportFieldError::InvalidPageSuffix)
577        );
578        assert_eq!(
579            ReportPagePath::try_new("pages/view.xhtml")
580                .unwrap()
581                .as_str(),
582            "pages/view.xhtml"
583        );
584        assert_eq!(
585            ReportPagePath::try_new("pages/view.HTML").unwrap().as_str(),
586            "pages/view.HTML"
587        );
588    }
589
590    #[test]
591    fn manifest_panel_path_try_new_requires_xhtml_suffix() {
592        assert_eq!(ManifestPanelPath::try_new(""), Err(ReportFieldError::Empty));
593        assert_eq!(
594            ManifestPanelPath::try_new("panels/fail.html"),
595            Err(ReportFieldError::InvalidPanelSuffix)
596        );
597        assert_eq!(
598            ManifestPanelPath::try_new("panels/fail-1.xhtml")
599                .unwrap()
600                .as_str(),
601            "panels/fail-1.xhtml"
602        );
603        assert_eq!(
604            ManifestPanelPath::try_new("panels/fail-1.XHTML")
605                .unwrap()
606                .as_str(),
607            "panels/fail-1.XHTML"
608        );
609    }
610
611    #[test]
612    fn report_mode_parse_round_trip() {
613        for (wire, expected) in [("view", ReportMode::View), ("review", ReportMode::Review)] {
614            assert_eq!(ReportMode::parse(wire), Some(expected));
615            assert_eq!(expected.as_str(), wire);
616        }
617        assert!(ReportMode::parse("wizard").is_none());
618    }
619
620    #[test]
621    fn report_result_dismissed_omits_data() {
622        let json = serde_json::to_string(&ReportResult::dismissed()).expect("serialize");
623        assert_eq!(json, r#"{"button":"dismissed"}"#);
624    }
625
626    #[test]
627    fn report_result_finished_includes_data() {
628        let result = ReportResult::finished(ReportFinishData {
629            approved: false,
630            comments: ReviewComments::new(""),
631            panels: vec![ReportPanelEntry {
632                path: ManifestPanelPath::new("panels/fail.xhtml"),
633                label: Some("Fail 1".into()),
634                role: Some(PanelRole::Failure),
635            }],
636        });
637        let value: serde_json::Value =
638            serde_json::from_str(&serde_json::to_string(&result).expect("serialize"))
639                .expect("json");
640        assert_eq!(value["button"], "finish");
641        assert_eq!(value["data"]["approved"], false);
642        assert_eq!(value["data"]["comments"], "");
643        assert_eq!(value["data"]["panels"][0]["path"], "panels/fail.xhtml");
644    }
645
646    #[test]
647    fn report_terminal_button_wire_names() {
648        assert_eq!(ReportTerminalButton::Dismissed.as_str(), "dismissed");
649        assert_eq!(ReportTerminalButton::Finish.as_str(), "finish");
650    }
651}