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