facet_showcase/
runner.rs

1//! Showcase runner - the main API for creating showcases.
2
3use crate::highlighter::{Highlighter, Language, ansi_to_html};
4use crate::output::OutputMode;
5use miette::{Diagnostic, GraphicalReportHandler, GraphicalTheme};
6use owo_colors::OwoColorize;
7
8/// Build provenance information for tracking where showcase output came from.
9#[derive(Debug, Clone, Default)]
10pub struct Provenance {
11    /// Git commit SHA (full)
12    pub commit: Option<String>,
13    /// Git commit SHA (short, 7 chars)
14    pub commit_short: Option<String>,
15    /// Timestamp when generated (ISO 8601)
16    pub timestamp: Option<String>,
17    /// Rust compiler version
18    pub rustc_version: Option<String>,
19    /// GitHub repository (e.g., "facet-rs/facet")
20    pub github_repo: Option<String>,
21    /// Relative path to the source file from repo root
22    pub source_file: Option<String>,
23}
24
25impl Provenance {
26    /// Create provenance from environment variables set by xtask.
27    ///
28    /// Expected env vars:
29    /// - `FACET_SHOWCASE_COMMIT`: full git commit SHA
30    /// - `FACET_SHOWCASE_COMMIT_SHORT`: short git commit SHA
31    /// - `FACET_SHOWCASE_TIMESTAMP`: ISO 8601 timestamp
32    /// - `FACET_SHOWCASE_RUSTC_VERSION`: rustc version string
33    /// - `FACET_SHOWCASE_GITHUB_REPO`: GitHub repo (e.g., "facet-rs/facet")
34    /// - `FACET_SHOWCASE_SOURCE_FILE`: relative path to source file
35    pub fn from_env() -> Self {
36        Self {
37            commit: std::env::var("FACET_SHOWCASE_COMMIT").ok(),
38            commit_short: std::env::var("FACET_SHOWCASE_COMMIT_SHORT").ok(),
39            timestamp: std::env::var("FACET_SHOWCASE_TIMESTAMP").ok(),
40            rustc_version: std::env::var("FACET_SHOWCASE_RUSTC_VERSION").ok(),
41            github_repo: std::env::var("FACET_SHOWCASE_GITHUB_REPO").ok(),
42            source_file: std::env::var("FACET_SHOWCASE_SOURCE_FILE").ok(),
43        }
44    }
45
46    /// Generate a GitHub URL to the source file at the exact commit.
47    pub fn github_source_url(&self) -> Option<String> {
48        match (&self.github_repo, &self.commit, &self.source_file) {
49            (Some(repo), Some(commit), Some(file)) => {
50                Some(format!("https://github.com/{repo}/blob/{commit}/{file}"))
51            }
52            _ => None,
53        }
54    }
55
56    /// Check if we have meaningful provenance info.
57    pub fn has_info(&self) -> bool {
58        self.commit.is_some() || self.timestamp.is_some() || self.rustc_version.is_some()
59    }
60}
61
62/// Main entry point for running showcases.
63pub struct ShowcaseRunner {
64    /// Title of the showcase collection
65    title: String,
66    /// URL slug for Zola (optional)
67    slug: Option<String>,
68    /// Output mode (terminal or HTML)
69    mode: OutputMode,
70    /// Syntax highlighter
71    highlighter: Highlighter,
72    /// Primary language for this showcase (for error highlighting)
73    primary_language: Language,
74    /// Count of scenarios run
75    scenario_count: usize,
76    /// Whether we're currently inside a section (affects heading levels)
77    in_section: bool,
78    /// Filter for scenario names (case-insensitive contains)
79    filter: Option<String>,
80    /// Build provenance information
81    provenance: Provenance,
82}
83
84impl ShowcaseRunner {
85    /// Create a new showcase runner with the given title.
86    ///
87    /// The filter can be set via the `SHOWCASE_FILTER` environment variable.
88    /// Only scenarios whose names contain the filter string (case-insensitive) will be shown.
89    pub fn new(title: impl Into<String>) -> Self {
90        Self {
91            title: title.into(),
92            slug: None,
93            mode: OutputMode::from_env(),
94            highlighter: Highlighter::new(),
95            primary_language: Language::Json,
96            scenario_count: 0,
97            in_section: false,
98            filter: std::env::var("SHOWCASE_FILTER").ok(),
99            provenance: Provenance::from_env(),
100        }
101    }
102
103    /// Set a filter for scenario names (case-insensitive contains).
104    ///
105    /// Only scenarios whose names contain this string will be displayed.
106    pub fn filter(mut self, filter: impl Into<String>) -> Self {
107        self.filter = Some(filter.into());
108        self
109    }
110
111    /// Set the URL slug for Zola (overrides the default derived from filename).
112    pub fn slug(mut self, slug: impl Into<String>) -> Self {
113        self.slug = Some(slug.into());
114        self
115    }
116
117    /// Set the primary language for this showcase.
118    pub fn language(mut self, lang: Language) -> Self {
119        self.primary_language = lang;
120        self
121    }
122
123    /// Add KDL syntax support from a directory.
124    pub fn with_kdl_syntaxes(mut self, syntax_dir: &str) -> Self {
125        self.highlighter = std::mem::take(&mut self.highlighter).with_kdl_syntaxes(syntax_dir);
126        self
127    }
128
129    /// Print the showcase header.
130    pub fn header(&self) {
131        match self.mode {
132            OutputMode::Terminal => {
133                println!();
134                self.print_box(&self.title, "cyan");
135            }
136            OutputMode::Markdown => {
137                // Emit TOML frontmatter for Zola
138                println!("+++");
139                println!("title = \"{}\"", self.title);
140                if let Some(ref slug) = self.slug {
141                    println!("slug = \"{slug}\"");
142                }
143                println!("+++");
144                println!();
145                println!("<div class=\"showcase\">");
146            }
147        }
148    }
149
150    /// Print an intro paragraph after the header.
151    ///
152    /// This should be called immediately after `header()` to add context
153    /// about what this showcase demonstrates.
154    pub fn intro(&self, text: &str) {
155        match self.mode {
156            OutputMode::Terminal => {
157                println!();
158                println!("{}", text.dimmed());
159                println!();
160            }
161            OutputMode::Markdown => {
162                println!();
163                println!("{text}");
164                println!();
165            }
166        }
167    }
168
169    /// Start a new scenario.
170    ///
171    /// If a filter is set, scenarios that don't match are skipped (all methods become no-ops).
172    pub fn scenario(&mut self, name: impl Into<String>) -> Scenario<'_> {
173        let name = name.into();
174        let skipped = match &self.filter {
175            Some(filter) => !name.to_lowercase().contains(&filter.to_lowercase()),
176            None => false,
177        };
178        if !skipped {
179            self.scenario_count += 1;
180        }
181        Scenario::new(self, name, skipped)
182    }
183
184    /// Start a new section (h2 heading).
185    ///
186    /// When sections are used, scenarios within them become h3 headings.
187    /// This creates a nice hierarchy in the table of contents.
188    pub fn section(&mut self, name: &str) {
189        self.in_section = true;
190
191        match self.mode {
192            OutputMode::Terminal => {
193                println!();
194                println!();
195                println!("{}", "━".repeat(78).bold().yellow());
196                println!("  {}", name.bold().yellow());
197                println!("{}", "━".repeat(78).bold().yellow());
198            }
199            OutputMode::Markdown => {
200                println!();
201                println!("## {name}");
202                println!();
203            }
204        }
205    }
206
207    /// Finish the showcase and print footer.
208    pub fn footer(&self) {
209        match self.mode {
210            OutputMode::Terminal => {
211                println!();
212                self.print_box("END OF SHOWCASE", "green");
213                if self.provenance.has_info() {
214                    println!();
215                    println!("{}", "Provenance:".dimmed());
216                    if let Some(ref commit) = self.provenance.commit_short {
217                        println!("  {} {}", "Commit:".dimmed(), commit);
218                    }
219                    if let Some(ref ts) = self.provenance.timestamp {
220                        println!("  {} {}", "Generated:".dimmed(), ts);
221                    }
222                    if let Some(ref rustc) = self.provenance.rustc_version {
223                        println!("  {} {}", "Rustc:".dimmed(), rustc);
224                    }
225                    if let Some(url) = self.provenance.github_source_url() {
226                        println!("  {} {}", "Source:".dimmed(), url);
227                    }
228                }
229            }
230            OutputMode::Markdown => {
231                // Add provenance footer before closing the showcase div
232                if self.provenance.has_info() {
233                    println!();
234                    println!("<footer class=\"showcase-provenance\">");
235                    println!("<p>This showcase was auto-generated from source code.</p>");
236                    println!("<dl>");
237                    if let Some(url) = self.provenance.github_source_url()
238                        && let Some(ref file) = self.provenance.source_file
239                    {
240                        println!(
241                            "<dt>Source</dt><dd><a href=\"{url}\"><code>{file}</code></a></dd>"
242                        );
243                    }
244                    if let Some(ref commit) = self.provenance.commit_short {
245                        if let Some(ref repo) = self.provenance.github_repo {
246                            if let Some(ref full_commit) = self.provenance.commit {
247                                println!(
248                                    "<dt>Commit</dt><dd><a href=\"https://github.com/{repo}/commit/{full_commit}\"><code>{commit}</code></a></dd>"
249                                );
250                            }
251                        } else {
252                            println!("<dt>Commit</dt><dd><code>{commit}</code></dd>");
253                        }
254                    }
255                    if let Some(ref ts) = self.provenance.timestamp {
256                        println!("<dt>Generated</dt><dd><time datetime=\"{ts}\">{ts}</time></dd>");
257                    }
258                    if let Some(ref rustc) = self.provenance.rustc_version {
259                        println!("<dt>Compiler</dt><dd><code>{rustc}</code></dd>");
260                    }
261                    println!("</dl>");
262                    println!("</footer>");
263                }
264                println!("</div>");
265            }
266        }
267    }
268
269    /// Get a reference to the highlighter.
270    pub fn highlighter(&self) -> &Highlighter {
271        &self.highlighter
272    }
273
274    /// Get the output mode.
275    pub fn mode(&self) -> OutputMode {
276        self.mode
277    }
278
279    /// Get the primary language.
280    pub fn primary_language(&self) -> Language {
281        self.primary_language
282    }
283
284    /// Print a boxed header/footer (terminal mode).
285    fn print_box(&self, text: &str, color: &str) {
286        // Simple box using Unicode box-drawing characters
287        let width = 70;
288        let inner_width = width - 2; // Account for left/right borders
289
290        let top = format!("╭{}╮", "─".repeat(inner_width));
291        let bottom = format!("╰{}╯", "─".repeat(inner_width));
292        let empty_line = format!("│{}│", " ".repeat(inner_width));
293
294        // Center the text
295        let text_padding = (inner_width.saturating_sub(text.len())) / 2;
296        let text_line = format!(
297            "│{}{}{}│",
298            " ".repeat(text_padding),
299            text,
300            " ".repeat(inner_width - text_padding - text.len())
301        );
302
303        let output = match color {
304            "cyan" => {
305                format!(
306                    "{}\n{}\n{}\n{}\n{}",
307                    top.cyan(),
308                    empty_line.cyan(),
309                    text_line.cyan(),
310                    empty_line.cyan(),
311                    bottom.cyan()
312                )
313            }
314            "green" => {
315                format!(
316                    "{}\n{}\n{}\n{}\n{}",
317                    top.green(),
318                    empty_line.green(),
319                    text_line.green(),
320                    empty_line.green(),
321                    bottom.green()
322                )
323            }
324            _ => {
325                format!("{top}\n{empty_line}\n{text_line}\n{empty_line}\n{bottom}")
326            }
327        };
328        println!("{output}");
329    }
330}
331
332/// A single scenario within a showcase.
333pub struct Scenario<'a> {
334    runner: &'a mut ShowcaseRunner,
335    name: String,
336    description: Option<String>,
337    printed_header: bool,
338    /// Whether this scenario is skipped due to filtering
339    skipped: bool,
340}
341
342impl<'a> Scenario<'a> {
343    fn new(runner: &'a mut ShowcaseRunner, name: String, skipped: bool) -> Self {
344        Self {
345            runner,
346            name,
347            description: None,
348            printed_header: false,
349            skipped,
350        }
351    }
352
353    /// Set a description for this scenario.
354    pub fn description(mut self, desc: impl Into<String>) -> Self {
355        self.description = Some(desc.into());
356        self
357    }
358
359    /// Print the scenario header (called automatically on first content).
360    fn ensure_header(&mut self) {
361        if self.skipped || self.printed_header {
362            return;
363        }
364        self.printed_header = true;
365
366        match self.runner.mode {
367            OutputMode::Terminal => {
368                println!();
369                println!("{}", "═".repeat(78).dimmed());
370                println!("{} {}", "SCENARIO:".bold().cyan(), self.name.bold().white());
371                println!("{}", "─".repeat(78).dimmed());
372                if let Some(ref desc) = self.description {
373                    println!("{}", desc.dimmed());
374                }
375                println!("{}", "═".repeat(78).dimmed());
376            }
377            OutputMode::Markdown => {
378                // Emit heading as Markdown so Zola can build a table of contents
379                // Use h3 if we're inside a section, h2 otherwise
380                let heading = if self.runner.in_section { "###" } else { "##" };
381                println!();
382                println!("{} {}", heading, self.name);
383                println!();
384                println!("<section class=\"scenario\">");
385                if let Some(ref desc) = self.description {
386                    println!(
387                        "<p class=\"description\">{}</p>",
388                        markdown_inline_to_html(desc)
389                    );
390                }
391            }
392        }
393    }
394
395    /// Display input code with syntax highlighting.
396    pub fn input(mut self, lang: Language, code: &str) -> Self {
397        if self.skipped {
398            return self;
399        }
400        self.ensure_header();
401
402        match self.runner.mode {
403            OutputMode::Terminal => {
404                println!();
405                println!("{}", format!("{} Input:", lang.name()).bold().green());
406                println!("{}", "─".repeat(60).dimmed());
407                print!(
408                    "{}",
409                    self.runner
410                        .highlighter
411                        .highlight_to_terminal_with_line_numbers(code, lang)
412                );
413                println!("{}", "─".repeat(60).dimmed());
414            }
415            OutputMode::Markdown => {
416                println!("<div class=\"input\">");
417                println!("<h4>{} Input</h4>", lang.name());
418                // highlight_to_html returns a complete <pre> element with inline styles
419                println!("{}", self.runner.highlighter.highlight_to_html(code, lang));
420                println!("</div>");
421            }
422        }
423        self
424    }
425
426    /// Display a Facet value as input using facet-pretty.
427    pub fn input_value<'b, T: facet::Facet<'b>>(mut self, value: &'b T) -> Self {
428        if self.skipped {
429            return self;
430        }
431        self.ensure_header();
432
433        use facet_pretty::FacetPretty;
434
435        match self.runner.mode {
436            OutputMode::Terminal => {
437                println!();
438                println!("{}", "Value Input:".bold().green());
439                println!("{}", "─".repeat(60).dimmed());
440                println!("  {}", value.pretty());
441                println!("{}", "─".repeat(60).dimmed());
442            }
443            OutputMode::Markdown => {
444                let pretty_output = format!("{}", value.pretty());
445                println!("<div class=\"input\">");
446                println!("<h4>Value Input</h4>");
447                println!("<pre><code>{}</code></pre>", ansi_to_html(&pretty_output));
448                println!("</div>");
449            }
450        }
451        self
452    }
453
454    /// Display serialized output with syntax highlighting.
455    pub fn serialized_output(mut self, lang: Language, code: &str) -> Self {
456        if self.skipped {
457            return self;
458        }
459        self.ensure_header();
460
461        match self.runner.mode {
462            OutputMode::Terminal => {
463                println!();
464                println!("{}", format!("{} Output:", lang.name()).bold().magenta());
465                println!("{}", "─".repeat(60).dimmed());
466                print!(
467                    "{}",
468                    self.runner
469                        .highlighter
470                        .highlight_to_terminal_with_line_numbers(code, lang)
471                );
472                println!("{}", "─".repeat(60).dimmed());
473            }
474            OutputMode::Markdown => {
475                println!("<div class=\"serialized-output\">");
476                println!("<h4>{} Output</h4>", lang.name());
477                // highlight_to_html returns a complete <pre> element with inline styles
478                println!("{}", self.runner.highlighter.highlight_to_html(code, lang));
479                println!("</div>");
480            }
481        }
482        self
483    }
484
485    /// Display the target type definition using facet-pretty.
486    pub fn target_type<T: facet::Facet<'static>>(mut self) -> Self {
487        if self.skipped {
488            return self;
489        }
490        self.ensure_header();
491
492        let type_def = facet_pretty::format_shape(T::SHAPE);
493
494        match self.runner.mode {
495            OutputMode::Terminal => {
496                println!();
497                println!("{}", "Target Type:".bold().blue());
498                println!("{}", "─".repeat(60).dimmed());
499                print!(
500                    "{}",
501                    self.runner
502                        .highlighter
503                        .highlight_to_terminal(&type_def, Language::Rust)
504                );
505                println!("{}", "─".repeat(60).dimmed());
506            }
507            OutputMode::Markdown => {
508                println!("<details class=\"target-type\">");
509                println!("<summary>Target Type</summary>");
510                // highlight_to_html returns a complete <pre> element with inline styles
511                println!(
512                    "{}",
513                    self.runner
514                        .highlighter
515                        .highlight_to_html(&type_def, Language::Rust)
516                );
517                println!("</details>");
518            }
519        }
520        self
521    }
522
523    /// Display a custom type definition string.
524    pub fn target_type_str(mut self, type_def: &str) -> Self {
525        if self.skipped {
526            return self;
527        }
528        self.ensure_header();
529
530        match self.runner.mode {
531            OutputMode::Terminal => {
532                println!();
533                println!("{}", "Target Type:".bold().blue());
534                println!("{}", "─".repeat(60).dimmed());
535                print!(
536                    "{}",
537                    self.runner
538                        .highlighter
539                        .highlight_to_terminal(type_def, Language::Rust)
540                );
541                println!("{}", "─".repeat(60).dimmed());
542            }
543            OutputMode::Markdown => {
544                println!("<details class=\"target-type\">");
545                println!("<summary>Target Type</summary>");
546                // highlight_to_html returns a complete <pre> element with inline styles
547                println!(
548                    "{}",
549                    self.runner
550                        .highlighter
551                        .highlight_to_html(type_def, Language::Rust)
552                );
553                println!("</details>");
554            }
555        }
556        self
557    }
558
559    /// Display an error using miette's graphical reporter.
560    pub fn error(mut self, err: &dyn Diagnostic) -> Self {
561        if self.skipped {
562            return self;
563        }
564        self.ensure_header();
565
566        match self.runner.mode {
567            OutputMode::Terminal => {
568                println!();
569                println!("{}", "Error:".bold().red());
570
571                let mut output = String::new();
572                let highlighter = self
573                    .runner
574                    .highlighter
575                    .build_miette_highlighter(self.runner.primary_language);
576                let handler = GraphicalReportHandler::new_themed(GraphicalTheme::unicode())
577                    .with_syntax_highlighting(highlighter);
578                handler.render_report(&mut output, err).unwrap();
579                println!("{output}");
580            }
581            OutputMode::Markdown => {
582                // Render the error with ANSI colors, then convert to HTML
583                let mut output = String::new();
584                let highlighter = self
585                    .runner
586                    .highlighter
587                    .build_miette_highlighter(self.runner.primary_language);
588                let handler = GraphicalReportHandler::new_themed(GraphicalTheme::unicode())
589                    .with_syntax_highlighting(highlighter);
590                handler.render_report(&mut output, err).unwrap();
591
592                println!("<div class=\"error\">");
593                println!("<h4>Error</h4>");
594                println!("<pre><code>{}</code></pre>", ansi_to_html(&output));
595                println!("</div>");
596            }
597        }
598        self
599    }
600
601    /// Display a compiler error from raw ANSI output (e.g., from `cargo check`).
602    pub fn compiler_error(mut self, ansi_output: &str) -> Self {
603        if self.skipped {
604            return self;
605        }
606        self.ensure_header();
607
608        match self.runner.mode {
609            OutputMode::Terminal => {
610                println!();
611                println!("{}", "Compiler Error:".bold().red());
612                println!("{ansi_output}");
613            }
614            OutputMode::Markdown => {
615                println!("<div class=\"compiler-error\">");
616                println!("<h4>Compiler Error</h4>");
617                println!("<pre><code>{}</code></pre>", ansi_to_html(ansi_output));
618                println!("</div>");
619            }
620        }
621        self
622    }
623
624    /// Display a successful result.
625    pub fn success<'b, T: facet::Facet<'b>>(mut self, value: &'b T) -> Self {
626        if self.skipped {
627            return self;
628        }
629        self.ensure_header();
630
631        use facet_pretty::FacetPretty;
632
633        match self.runner.mode {
634            OutputMode::Terminal => {
635                println!();
636                println!("{}", "Success:".bold().green());
637                println!("  {}", value.pretty());
638            }
639            OutputMode::Markdown => {
640                let pretty_output = format!("{}", value.pretty());
641                println!("<div class=\"success\">");
642                println!("<h4>Success</h4>");
643                println!("<pre><code>{}</code></pre>", ansi_to_html(&pretty_output));
644                println!("</div>");
645            }
646        }
647        self
648    }
649
650    /// Display a result (either success or error).
651    pub fn result<'b, T: facet::Facet<'b>, E: Diagnostic>(self, result: &'b Result<T, E>) -> Self {
652        match result {
653            Ok(value) => self.success(value),
654            Err(err) => self.error(err),
655        }
656    }
657
658    /// Finish this scenario.
659    pub fn finish(mut self) {
660        if self.skipped {
661            return;
662        }
663        self.ensure_header();
664
665        if self.runner.mode == OutputMode::Markdown {
666            println!("</section>");
667        }
668    }
669}
670
671/// Convert inline markdown (backticks) to HTML.
672fn markdown_inline_to_html(text: &str) -> String {
673    let mut result = String::new();
674    let chars = text.chars();
675    let mut in_code = false;
676
677    for c in chars {
678        if c == '`' {
679            if in_code {
680                result.push_str("</code>");
681                in_code = false;
682            } else {
683                result.push_str("<code>");
684                in_code = true;
685            }
686        } else if c == '<' {
687            result.push_str("&lt;");
688        } else if c == '>' {
689            result.push_str("&gt;");
690        } else if c == '&' {
691            result.push_str("&amp;");
692        } else if c == '\n' {
693            result.push_str("<br>");
694        } else {
695            result.push(c);
696        }
697    }
698
699    if in_code {
700        result.push_str("</code>");
701    }
702
703    result
704}