Skip to main content

wyvern/
wizard_cmd.rs

1//! `wyvern wizard lint` — static lint analysis for wizard packages.
2//!
3//! Walks a wizard package (directory containing `wizard.json` or a path to
4//! `wizard.json` directly), builds the reachable page graph via static analysis
5//! of linked local JavaScript, and applies the normative nav-button rules from
6//! `ui/shared/wizard-nav.js` sprint d.7.
7//!
8//! # Exit codes
9//!
10//! | Code | Meaning                       |
11//! |------|-------------------------------|
12//! |  0   | All checked pages are clean   |
13//! |  1   | One or more findings reported |
14//! |  2   | Usage / bad arguments         |
15
16use std::collections::{HashMap, HashSet, VecDeque};
17use std::fmt;
18use std::path::{Component, Path, PathBuf};
19
20use wyvern_schema::{FieldName, WizardPageFieldError, WizardPageHtml, WizardPageId};
21use wyvern_wizard::{
22    add_edge, extract_local_script_srcs, extract_next_hops, extract_next_wizard_refs,
23    lint_dataflow, lint_page, merge_html_dataflow_overlay, parse_dataflow_from_json,
24    DataflowLintInput, DataflowSpec, GraphPage, LintFinding, PageInfo, PageRole, WizardPageGraph,
25};
26
27use crate::error::{BuiltinDomain, EmitError, UsageErrorKind};
28use crate::extensions::resolve_wyvern_share;
29use crate::workflow::Allowlist;
30
31// ── Public API ────────────────────────────────────────────────────────────────
32
33/// Result of a successful `wizard lint` invocation.
34#[derive(Debug)]
35pub enum WizardCmdResult {
36    /// All checked pages produced no findings. Carry the stdout summary.
37    Clean(String),
38    /// One or more findings were found. Carry the full report for stdout.
39    Findings(String),
40}
41
42/// Failure from `wyvern wizard …`.
43#[derive(Debug)]
44pub enum WizardCmdError {
45    /// Bad argv (exit 2).
46    Usage {
47        /// Discriminated usage class for structured stderr recovery.
48        kind: UsageErrorKind,
49        /// Plain-text usage message.
50        message: String,
51    },
52    /// I/O, parse, or field-validation failure — emit via [`emit_wizard_lint_stage_error`].
53    Stage(WizardLintStageError),
54    /// Emit-boundary serialize failure.
55    Emit(EmitError),
56}
57
58/// Structured `wizard lint` stage failure (RBP-001 / RBP-F001).
59///
60/// Carries path and field context so [`emit_wizard_lint_stage_error`] can map
61/// each variant to `IoError` / `ParseError` / `ValidationError` with a stable
62/// subcode and recovery steps.
63#[derive(Debug)]
64pub enum WizardLintStageError {
65    /// Filesystem read or path resolution failed.
66    Io {
67        /// Path that could not be read or resolved.
68        path: PathBuf,
69        /// Human-readable detail.
70        message: String,
71    },
72    /// `wizard.json` was not valid JSON.
73    Parse {
74        /// `wizard.json` path.
75        path: PathBuf,
76        /// Parser detail.
77        message: String,
78    },
79    /// `wizard.json` fields failed newtype or shape checks.
80    Validation {
81        /// `wizard.json` path.
82        path: PathBuf,
83        /// Field that failed (e.g. `page.id`).
84        field: FieldName,
85        /// Human-readable detail, including [`WizardPageFieldError`] when applicable.
86        message: String,
87    },
88}
89
90impl fmt::Display for WizardLintStageError {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        f.write_str(self.message())
93    }
94}
95
96impl std::error::Error for WizardLintStageError {}
97
98impl WizardLintStageError {
99    /// Human-readable detail for stdout / tests.
100    #[must_use]
101    pub fn message(&self) -> &str {
102        match self {
103            Self::Io { message, .. }
104            | Self::Parse { message, .. }
105            | Self::Validation { message, .. } => message,
106        }
107    }
108
109    /// Process exit code for `wyvern wizard lint` stage failures (always 1).
110    #[must_use]
111    pub const fn exit_code(&self) -> i32 {
112        1
113    }
114
115    /// Stable sub-discriminator under the mapped [`wyvern_schema::ErrorCode`].
116    #[must_use]
117    pub const fn subcode(&self) -> &'static str {
118        match self {
119            Self::Io { .. } => "wizard_lint_io",
120            Self::Parse { .. } => "wizard_lint_parse",
121            Self::Validation { .. } => "wizard_lint_validation",
122        }
123    }
124}
125
126fn combine_stage_errors(mut errors: Vec<WizardLintStageError>) -> WizardLintStageError {
127    if errors.len() == 1 {
128        return errors.remove(0);
129    }
130    let combined = errors
131        .iter()
132        .map(ToString::to_string)
133        .collect::<Vec<_>>()
134        .join("\n");
135    match errors.remove(0) {
136        WizardLintStageError::Io { path, .. } => WizardLintStageError::Io {
137            path,
138            message: combined,
139        },
140        WizardLintStageError::Parse { path, .. } => WizardLintStageError::Parse {
141            path,
142            message: combined,
143        },
144        WizardLintStageError::Validation { path, field, .. } => WizardLintStageError::Validation {
145            path,
146            field,
147            message: combined,
148        },
149    }
150}
151
152/// Usage text for `wyvern wizard --help` / `wyvern wizard lint --help`.
153#[must_use]
154pub fn wizard_usage_message() -> String {
155    concat!(
156        "Usage: wyvern wizard lint <path> [<path>...]\n",
157        "       wyvern wizard lint --help\n",
158        "\n",
159        "Commands:\n",
160        "  lint <path...>   Lint one or more wizard packages for missing nav buttons\n",
161        "\n",
162        "Arguments:\n",
163        "  <path>   Directory containing wizard.json, or path to wizard.json itself\n",
164        "\n",
165        "Exit codes:\n",
166        "  0   All pages are clean\n",
167        "  1   One or more findings\n",
168        "  2   Usage / bad arguments\n",
169        "\n",
170        "Findings codes:\n",
171        "  WIZARD-LINT-001  Non-entry page missing back button\n",
172        "  WIZARD-LINT-002  Terminal page missing cancel button\n",
173        "  WIZARD-LINT-003  wizard-nav.js chrome opt-in but no nav region\n",
174        "  WIZARD-LINT-004  Non-terminal page (with chrome opt-in) missing next button\n",
175        "  WIZARD-LINT-005  config.dataflow requires unsatisfied or type conflict\n",
176        "  WIZARD-LINT-006  Terminal post_input not covered by exports\n",
177        "  WIZARD-LINT-007  next_wizard input keys undeclared on target\n",
178        "  WIZARD-LINT-008  Local JS reads a key no page exports\n",
179        "\n",
180        "See also: wyvern --help\n",
181    )
182    .to_string()
183}
184
185/// Run `wyvern wizard …`; returns a [`WizardCmdResult`] on success.
186///
187/// # Errors
188///
189/// Returns [`WizardCmdError`] for bad argv or I/O failures.
190pub fn run_wizard_command(args: &[String]) -> Result<WizardCmdResult, WizardCmdError> {
191    if args
192        .first()
193        .is_some_and(|t| t == "--help" || t == "-h" || t == "help")
194    {
195        return Ok(WizardCmdResult::Clean(wizard_usage_message()));
196    }
197
198    match args.first().map(String::as_str) {
199        Some("lint") => run_lint(&args[1..]),
200        Some("--help") | Some("-h") | Some("help") | None => {
201            // Handled above; this arm is unreachable but keeps match exhaustive.
202            Ok(WizardCmdResult::Clean(wizard_usage_message()))
203        }
204        Some(other) => Err(WizardCmdError::Usage {
205            kind: UsageErrorKind::UnknownSubcommand {
206                domain: BuiltinDomain::Wizard,
207                token: other.to_string(),
208            },
209            message: format!(
210                "unknown wizard subcommand '{other}'\n{}",
211                wizard_usage_message()
212            ),
213        }),
214    }
215}
216
217// ── lint subcommand ───────────────────────────────────────────────────────────
218
219fn run_lint(args: &[String]) -> Result<WizardCmdResult, WizardCmdError> {
220    if args
221        .iter()
222        .any(|a| a == "--help" || a == "-h" || a == "help")
223    {
224        return Ok(WizardCmdResult::Clean(wizard_usage_message()));
225    }
226
227    if args.is_empty() {
228        return Err(WizardCmdError::Usage {
229            kind: UsageErrorKind::Generic,
230            message: format!(
231                "wyvern wizard lint requires at least one <path> argument\n{}",
232                wizard_usage_message()
233            ),
234        });
235    }
236
237    let mut all_findings: Vec<LintFinding> = Vec::new();
238    let mut errors: Vec<WizardLintStageError> = Vec::new();
239    let mut total_pages: usize = 0;
240
241    for path_str in args {
242        match lint_package(path_str) {
243            Ok((findings, page_count)) => {
244                total_pages += page_count;
245                all_findings.extend(findings);
246            }
247            Err(err) => {
248                errors.push(err);
249            }
250        }
251    }
252
253    // I/O / parse / validation errors are reported on stderr and set exit 1.
254    if !errors.is_empty() {
255        return Err(WizardCmdError::Stage(combine_stage_errors(errors)));
256    }
257
258    if all_findings.is_empty() {
259        let noun = if total_pages == 1 { "page" } else { "pages" };
260        let checked = if args.len() == 1 {
261            format!("Checked {total_pages} {noun} — no findings.\n")
262        } else {
263            format!(
264                "Checked {} package(s), {total_pages} {noun} total — no findings.\n",
265                args.len()
266            )
267        };
268        return Ok(WizardCmdResult::Clean(checked));
269    }
270
271    let report = format_findings(&all_findings, args.len(), total_pages);
272    Ok(WizardCmdResult::Findings(report))
273}
274
275// ── Package analysis ──────────────────────────────────────────────────────────
276
277/// Lint a single wizard package rooted at `path_str`.
278///
279/// Returns `(findings, page_count)` on success, or a structured stage error.
280fn lint_package(path_str: &str) -> Result<(Vec<LintFinding>, usize), WizardLintStageError> {
281    let input = Path::new(path_str);
282
283    // Resolve wizard.json and wizard root directory.
284    let (wizard_json_path, wizard_dir) = resolve_wizard_paths(input)?;
285
286    // Parse wizard.json to get the entry page descriptor.
287    let json_content =
288        std::fs::read_to_string(&wizard_json_path).map_err(|e| WizardLintStageError::Io {
289            path: wizard_json_path.clone(),
290            message: format!("error: cannot read '{}': {e}", wizard_json_path.display()),
291        })?;
292
293    let (entry_id, entry_html) = parse_wizard_json_entry(&json_content, &wizard_json_path)?;
294
295    // BFS to build the reachable page graph.
296    let (pages, graph) = build_page_graph(&wizard_dir, &entry_id, &entry_html)?;
297
298    // Nav lint for each page. Borrow newtypes as `&str` only at the wyvern_wizard boundary.
299    let mut findings: Vec<LintFinding> = pages
300        .iter()
301        .flat_map(|p| {
302            lint_page(&PageInfo {
303                id: p.id.as_str(),
304                file: p.rel_path.as_str(),
305                html: &p.html,
306                role: p.role,
307            })
308        })
309        .collect();
310
311    // Dataflow lint when config.dataflow is declared.
312    if let Some(mut spec) = parse_dataflow_from_json(&json_content) {
313        for page in &pages {
314            merge_html_dataflow_overlay(&mut spec, page.id.as_str(), &page.html);
315        }
316        let js_files = collect_local_js_files(&wizard_dir, &pages);
317        let has_workflow_post = wizard_json_has_workflow_post(&json_content);
318        let next_wizard_targets = build_next_wizard_targets(&wizard_dir, &js_files);
319        findings.extend(lint_dataflow(&DataflowLintInput {
320            spec: &spec,
321            graph: &graph,
322            js_files: &js_files,
323            has_workflow_post,
324            next_wizard_targets: &next_wizard_targets,
325        }));
326    }
327
328    let page_count = pages.len();
329    Ok((findings, page_count))
330}
331
332/// Resolve `input` to `(wizard_json_path, wizard_dir)`.
333fn resolve_wizard_paths(input: &Path) -> Result<(PathBuf, PathBuf), WizardLintStageError> {
334    if input.is_dir() {
335        let json = input.join("wizard.json");
336        if json.is_file() {
337            return Ok((json, input.to_path_buf()));
338        }
339        return Err(WizardLintStageError::Io {
340            path: json,
341            message: format!(
342                "error: '{}' is a directory but contains no wizard.json",
343                input.display()
344            ),
345        });
346    }
347    if input.is_file() {
348        let dir = input.parent().unwrap_or(Path::new("."));
349        return Ok((input.to_path_buf(), dir.to_path_buf()));
350    }
351    Err(WizardLintStageError::Io {
352        path: input.to_path_buf(),
353        message: format!(
354            "error: '{}' not found (expected directory or wizard.json path)",
355            input.display()
356        ),
357    })
358}
359
360/// Extract typed `(entry_id, entry_html)` from wizard.json content.
361///
362/// Keeps [`WizardPageId`] / [`WizardPageHtml`] so callers do not strip newtypes
363/// at the parse boundary (RBP-004 / RBP-F003).
364fn parse_wizard_json_entry(
365    json: &str,
366    path: &Path,
367) -> Result<(WizardPageId, WizardPageHtml), WizardLintStageError> {
368    let value: serde_json::Value =
369        serde_json::from_str(json).map_err(|e| WizardLintStageError::Parse {
370            path: path.to_path_buf(),
371            message: format!("error: '{}': invalid JSON: {e}", path.display()),
372        })?;
373
374    let page = value
375        .get("page")
376        .ok_or_else(|| WizardLintStageError::Validation {
377            path: path.to_path_buf(),
378            field: FieldName::new("page"),
379            message: format!(
380                "error: '{}': missing 'page' field in wizard.json",
381                path.display()
382            ),
383        })?;
384
385    let id = parse_page_newtype_field(page, path, "id", WizardPageId::try_new)?;
386    let html = parse_page_newtype_field(page, path, "html", WizardPageHtml::try_new)?;
387
388    Ok((id, html))
389}
390
391fn parse_page_newtype_field<T>(
392    page: &serde_json::Value,
393    path: &Path,
394    field: &str,
395    ctor: impl FnOnce(String) -> Result<T, WizardPageFieldError>,
396) -> Result<T, WizardLintStageError> {
397    let field_name = format!("page.{field}");
398    let raw = page.get(field).and_then(|v| v.as_str()).ok_or_else(|| {
399        WizardLintStageError::Validation {
400            path: path.to_path_buf(),
401            field: FieldName::new(field_name.clone()),
402            message: format!(
403                "error: '{}': wizard.json {field_name} must be a string",
404                path.display()
405            ),
406        }
407    })?;
408    ctor(raw.to_string()).map_err(|err| WizardLintStageError::Validation {
409        path: path.to_path_buf(),
410        field: FieldName::new(field_name.clone()),
411        message: format!(
412            "error: '{}': wizard.json {field_name}: {err}",
413            path.display()
414        ),
415    })
416}
417
418// ── BFS page graph ────────────────────────────────────────────────────────────
419
420struct PageNode {
421    id: WizardPageId,
422    /// Path relative to the wizard root (for display).
423    rel_path: WizardPageHtml,
424    html: String,
425    role: PageRole,
426}
427
428/// Build the reachable page graph via BFS, starting from the entry page.
429///
430/// Keeps [`WizardPageId`] / [`WizardPageHtml`] on [`PageNode`] and the BFS
431/// queue. String keys are produced with [`WizardPageId::as_str`] /
432/// [`WizardPageHtml::as_str`] only when filling [`WizardPageGraph`]
433/// (wyvern_wizard API boundary).
434fn build_page_graph(
435    wizard_dir: &Path,
436    entry_id: &WizardPageId,
437    entry_html: &WizardPageHtml,
438) -> Result<(Vec<PageNode>, WizardPageGraph), WizardLintStageError> {
439    let mut visited: HashSet<WizardPageHtml> = HashSet::new();
440    let mut queue: VecDeque<(WizardPageId, WizardPageHtml, PageRole, Option<WizardPageId>)> =
441        VecDeque::new();
442    let mut pages: Vec<PageNode> = Vec::new();
443    let mut graph = WizardPageGraph {
444        entry_id: entry_id.as_str().to_string(),
445        pages: HashMap::new(),
446        edges: HashMap::new(),
447    };
448
449    queue.push_back((entry_id.clone(), entry_html.clone(), PageRole::Entry, None));
450
451    while let Some((id, html_rel, role, from_id)) = queue.pop_front() {
452        if visited.contains(&html_rel) {
453            continue;
454        }
455        visited.insert(html_rel.clone());
456
457        if let Some(from) = from_id {
458            add_edge(&mut graph, from.as_str(), id.as_str());
459        }
460
461        let html_abs = wizard_dir.join(html_rel.as_str());
462        let html_content =
463            std::fs::read_to_string(&html_abs).map_err(|e| WizardLintStageError::Io {
464                path: html_abs.clone(),
465                message: format!("error: cannot read page '{}': {e}", html_abs.display()),
466            })?;
467
468        // Discover next-page hops from linked local JS files.
469        let html_dir = html_abs.parent().unwrap_or(wizard_dir);
470        let srcs = extract_local_script_srcs(&html_content);
471        for src in srcs {
472            let script_abs = normalize_path(&html_dir.join(&src));
473            let Ok(js) = std::fs::read_to_string(&script_abs) else {
474                continue;
475            };
476            for hop in extract_next_hops(&js) {
477                let Ok(hop_html) = WizardPageHtml::try_new(hop.html) else {
478                    continue;
479                };
480                if !visited.contains(&hop_html) {
481                    let Ok(hop_id) = WizardPageId::try_new(hop.id) else {
482                        continue;
483                    };
484                    queue.push_back((hop_id, hop_html, PageRole::Inner, Some(id.clone())));
485                }
486            }
487        }
488
489        graph.pages.insert(
490            id.as_str().to_string(),
491            GraphPage {
492                id: id.as_str().to_string(),
493                file: html_rel.as_str().to_string(),
494                html: html_content.clone(),
495            },
496        );
497
498        pages.push(PageNode {
499            id,
500            rel_path: html_rel,
501            html: html_content,
502            role,
503        });
504    }
505
506    Ok((pages, graph))
507}
508
509fn build_next_wizard_targets(
510    wizard_dir: &Path,
511    js_files: &HashMap<String, String>,
512) -> HashMap<String, DataflowSpec> {
513    let allowlist = Allowlist {
514        share_root: resolve_wyvern_share(),
515        cwd: std::env::current_dir().unwrap_or_else(|_| wizard_dir.to_path_buf()),
516        wizard_dir: wizard_dir.to_path_buf(),
517    };
518
519    let mut paths = HashSet::new();
520    for (file, js) in js_files {
521        for nw in extract_next_wizard_refs(js, file) {
522            paths.insert(nw.path);
523        }
524    }
525
526    let mut targets = HashMap::new();
527    for path in paths {
528        let Ok(wizard_json) = allowlist.resolve_allowed(&path) else {
529            continue;
530        };
531        let Ok(json) = std::fs::read_to_string(&wizard_json) else {
532            continue;
533        };
534        if let Some(spec) = parse_dataflow_from_json(&json) {
535            targets.insert(path, spec);
536        }
537    }
538    targets
539}
540
541fn collect_local_js_files(wizard_dir: &Path, pages: &[PageNode]) -> HashMap<String, String> {
542    let mut files: HashMap<String, String> = HashMap::new();
543    for page in pages {
544        let html_abs = wizard_dir.join(page.rel_path.as_str());
545        let html_dir = html_abs.parent().unwrap_or(wizard_dir);
546        let srcs = extract_local_script_srcs(&page.html);
547        for src in srcs {
548            let script_abs = normalize_path(&html_dir.join(&src));
549            let Ok(rel) = script_abs.strip_prefix(wizard_dir) else {
550                continue;
551            };
552            let rel_str = rel.to_string_lossy().replace('\\', "/");
553            if files.contains_key(&rel_str) {
554                continue;
555            }
556            if let Ok(js) = std::fs::read_to_string(&script_abs) {
557                files.insert(rel_str, js);
558            }
559        }
560    }
561    files
562}
563
564fn wizard_json_has_workflow_post(json: &str) -> bool {
565    let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
566        return false;
567    };
568    value
569        .get("workflow")
570        .and_then(|w| w.get("post"))
571        .and_then(|v| v.as_str())
572        .is_some_and(|s| !s.is_empty())
573}
574
575/// Normalize path components by resolving `..` without touching the filesystem.
576fn normalize_path(path: &Path) -> PathBuf {
577    let mut out: Vec<Component<'_>> = Vec::new();
578    for component in path.components() {
579        match component {
580            Component::ParentDir => {
581                // Only pop a non-root normal component.
582                match out.last() {
583                    Some(Component::Normal(_)) => {
584                        out.pop();
585                    }
586                    _ => out.push(component),
587                }
588            }
589            c => out.push(c),
590        }
591    }
592    out.iter().collect()
593}
594
595// ── Formatting ────────────────────────────────────────────────────────────────
596
597fn format_findings(findings: &[LintFinding], pkg_count: usize, page_count: usize) -> String {
598    let mut out = String::new();
599    for f in findings {
600        out.push_str(&f.display_line());
601        out.push('\n');
602    }
603    let pkg_noun = if pkg_count == 1 {
604        "package"
605    } else {
606        "packages"
607    };
608    let page_noun = if page_count == 1 { "page" } else { "pages" };
609    let finding_noun = if findings.len() == 1 {
610        "finding"
611    } else {
612        "findings"
613    };
614    out.push_str(&format!(
615        "\n{} {} in {} {} ({} {} checked)\n",
616        findings.len(),
617        finding_noun,
618        pkg_count,
619        pkg_noun,
620        page_count,
621        page_noun,
622    ));
623    out
624}
625
626// ── Tests ─────────────────────────────────────────────────────────────────────
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn wizard_help_flag_returns_usage() {
634        let result = run_wizard_command(&["--help".into()]).expect("ok");
635        match result {
636            WizardCmdResult::Clean(text) => {
637                assert!(text.contains("wyvern wizard lint"), "{text}");
638                assert!(text.contains("WIZARD-LINT-001"), "{text}");
639            }
640            WizardCmdResult::Findings(_) => panic!("expected clean / usage"),
641        }
642    }
643
644    #[test]
645    fn wizard_lint_help_returns_usage() {
646        let result = run_wizard_command(&["lint".into(), "--help".into()]).expect("ok");
647        match result {
648            WizardCmdResult::Clean(text) => {
649                assert!(text.contains("wyvern wizard lint"), "{text}");
650            }
651            WizardCmdResult::Findings(_) => panic!("expected clean"),
652        }
653    }
654
655    #[test]
656    fn unknown_wizard_subcommand_is_usage_error() {
657        let err = run_wizard_command(&["dump".into()]).expect_err("should err");
658        match err {
659            WizardCmdError::Usage { kind, message } => {
660                assert!(
661                    matches!(
662                        kind,
663                        UsageErrorKind::UnknownSubcommand { domain, ref token }
664                            if domain == BuiltinDomain::Wizard && token == "dump"
665                    ),
666                    "{kind:?}"
667                );
668                assert!(message.contains("unknown wizard subcommand"), "{message}");
669            }
670            other => panic!("expected Usage, got {other:?}"),
671        }
672    }
673
674    #[test]
675    fn wizard_lint_no_args_is_generic_usage_error() {
676        let err = run_wizard_command(&["lint".into()]).expect_err("should err");
677        match err {
678            WizardCmdError::Usage { kind, message } => {
679                assert_eq!(kind, UsageErrorKind::Generic);
680                assert!(message.contains("requires at least one"), "{message}");
681            }
682            other => panic!("expected Usage, got {other:?}"),
683        }
684    }
685
686    #[test]
687    fn wizard_usage_message_mentions_lint_codes() {
688        let text = wizard_usage_message();
689        assert!(text.contains("WIZARD-LINT-001"), "{text}");
690        assert!(text.contains("WIZARD-LINT-002"), "{text}");
691        assert!(text.contains("WIZARD-LINT-003"), "{text}");
692        assert!(text.contains("WIZARD-LINT-004"), "{text}");
693        assert!(text.contains("WIZARD-LINT-005"), "{text}");
694        assert!(text.contains("WIZARD-LINT-008"), "{text}");
695    }
696
697    #[test]
698    fn parse_wizard_json_entry_keeps_page_newtypes() {
699        let json = r#"{"page":{"id":"start","html":"pages/start.html"}}"#;
700        let (id, html) = parse_wizard_json_entry(json, Path::new("wizard.json")).expect("ok");
701        assert_eq!(id.as_str(), "start");
702        assert_eq!(html.as_str(), "pages/start.html");
703    }
704
705    #[test]
706    fn parse_wizard_json_entry_preserves_field_error_detail() {
707        let json = r#"{"page":{"id":"","html":"pages/start.html"}}"#;
708        let err = parse_wizard_json_entry(json, Path::new("wizard.json")).expect_err("empty id");
709        match err {
710            WizardLintStageError::Validation { field, message, .. } => {
711                assert_eq!(field.as_str(), "page.id");
712                assert!(
713                    message.contains("wizard page field must be a non-empty string"),
714                    "{message}"
715                );
716            }
717            other => panic!("expected Validation, got {other:?}"),
718        }
719    }
720
721    #[test]
722    fn parse_wizard_json_entry_invalid_json_is_parse() {
723        let err = parse_wizard_json_entry("{", Path::new("wizard.json")).expect_err("parse");
724        assert!(matches!(err, WizardLintStageError::Parse { .. }), "{err:?}");
725    }
726
727    #[test]
728    fn lint_missing_path_is_io_stage_error() {
729        let err =
730            run_wizard_command(&["lint".into(), "/no/such/wizard-pkg".into()]).expect_err("io");
731        match err {
732            WizardCmdError::Stage(stage) => {
733                assert!(
734                    matches!(stage, WizardLintStageError::Io { .. }),
735                    "{stage:?}"
736                );
737                assert!(stage.message().contains("not found"), "{}", stage.message());
738                assert_eq!(stage.exit_code(), 1);
739                assert_eq!(stage.subcode(), "wizard_lint_io");
740            }
741            other => panic!("expected Stage, got {other:?}"),
742        }
743    }
744}