actions_core/
lib.rs

1//! ✅ Get inputs, set outputs, and other basic operations for GitHub Actions
2//!
3//! <table align=center><td>
4//!
5//! ```rs
6//! let name = core::get_input_with_options("name", &core::InputOptions {
7//!   required: true,
8//!   ..Default::default()
9//! })?;
10//! let favorite_color = core::get_input("favorite-color");
11//! core::info(format!("Hello {name}!"));
12//! core::set_output("message", format!("I like {favorite_color} too!"));
13//! ```
14//!
15//! </table>
16//!
17//! 👀 Looking for more GitHub Actions crates? Check out [the actions-toolkit.rs project](https://github.com/jcbhmr/actions-toolkit.rs).
18//!
19//! ## Installation
20//!
21//! ```sh
22//! cargo add actions-core2
23//! ```
24//!
25//! ⚠️ Use `use actions_core` in your Rust code. The package name differs from the crate name.
26//!
27//! ## Usage
28//!
29//! ![Rust](https://img.shields.io/static/v1?style=for-the-badge&message=Rust&color=000000&logo=Rust&logoColor=FFFFFF&label=)
30//!
31//! ```rs
32//! use actions_core as core;
33//! use std::error::Error;
34//!
35//! fn main() {
36//!   let result = || -> Result<(), Box<dyn Error>> {
37//!     let name = core::get_input_with_options("name", core::InputOptions {
38//!         required: true,
39//!         ..Default::default()
40//!     })?;
41//!     let favorite_color = core::get_input("favorite-color")?;
42//!     core::info!("Hello {name}!");
43//!     core::set_output("message", "Wow! Rust is awesome!");
44//!     Ok(())
45//!   }();
46//!   if let Err(error) = result {
47//!     core::set_failed!("{error}");
48//!   }
49//! }
50//! ```
51//!
52//! 🤔 But how do I actually use the generated executable in my `action.yml`? Check out [configure-executable-action](https://github.com/jcbhmr/configure-executable-action)!
53//!
54//! ## Development
55//!
56//! ![Rust](https://img.shields.io/static/v1?style=for-the-badge&message=Rust&color=000000&logo=Rust&logoColor=FFFFFF&label=)
57//! ![Cargo](https://img.shields.io/static/v1?style=for-the-badge&message=Cargo&color=e6b047&logo=Rust&logoColor=000000&label=)
58//! ![Docs.rs](https://img.shields.io/static/v1?style=for-the-badge&message=Docs.rs&color=000000&logo=Docs.rs&logoColor=FFFFFF&label=)
59//!
60//! This project is part of the [actions-toolkit.rs](https://github.com/jcbhmr/actions-toolkit.rs) project.
61//!
62//! 🆘 I'm not a very proficient Rust programmer. If you see something that could be better, please tell me! ❤️ You can open an Issue, Pull Request, or even just comment on a commit. You'll probably be granted write access. 😉
63//!
64//! Todo list:
65//!
66//! - [x] Replicate the public API surface from [@actions/core](https://www.npmjs.com/package/@actions/core). Falsey string behaviour included.
67//! - [ ] Decide on `get_input("name", Some(...))` vs `get_input_with_options("name", ...)` vs `get_input!("name", ...)`. Need to find existing Rust projects to see the convention.
68//! - [ ] Figure out when to use `AsRef<str>`, `&str`, `String`, `Cow<str>`, etc. for parameters and return types. I need to do some recon on existing Rust projects.
69//! - [ ] Publish this crate to crates.io. That also entails setting up GitHub Actions to publish the crate on each appropriate monorepo release.
70//! - [ ] Copy this content to the crate README.
71//! - [ ] Add examples. At least two.
72//! - [ ] Add documentation to the public API. Not just "get_input() gets the input".
73
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub struct InputOptions {
76    pub required: bool,
77    pub trim_whitespace: bool,
78}
79
80impl Default for InputOptions {
81    fn default() -> Self {
82        Self {
83            required: false,
84            trim_whitespace: true,
85        }
86    }
87}
88
89#[deprecated]
90pub enum ExitCode {
91    Success = 0,
92    Failure = 1,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
96pub struct AnnotationProperties<'a> {
97    pub title: &'a str,
98    pub file: &'a str,
99    pub start_line: u32,
100    pub end_line: u32,
101    pub start_column: u32,
102    pub end_column: u32,
103}
104
105impl std::fmt::Display for AnnotationProperties<'_> {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        let mut properties = Vec::new();
108        if !self.title.is_empty() {
109            properties.push(format!("title={}", encode_command_property(self.title)));
110        }
111        if !self.file.is_empty() {
112            properties.push(format!("file={}", encode_command_property(self.file)));
113        }
114        if self.start_line != 0 {
115            properties.push(format!("line={}", self.start_line));
116        }
117        if self.end_line != 0 {
118            properties.push(format!("endLine={}", self.end_line));
119        }
120        if self.start_column != 0 {
121            properties.push(format!("col={}", self.start_column));
122        }
123        if self.end_column != 0 {
124            properties.push(format!("endCol={}", self.end_column));
125        }
126        write!(f, "{}", properties.join(","))
127    }
128}
129
130fn encode_command_property(property: &str) -> String {
131    property
132        .replace('%', "%25")
133        .replace('\r', "%0D")
134        .replace('\n', "%0A")
135        .replace(':', "%3A")
136        .replace(',', "%2C")
137}
138
139fn encode_command_data(data: &str) -> String {
140    data.replace('%', "%25")
141        .replace('\r', "%0D")
142        .replace('\n', "%0A")
143}
144
145pub fn export_variable(name: impl AsRef<str>, value: impl std::fmt::Display) {
146    let name = name.as_ref();
147    let value = value.to_string();
148    let github_env = std::env::var("GITHUB_ENV").unwrap_or_default();
149    if github_env.is_empty() {
150        println!(
151            "::set-env name={}::{}",
152            encode_command_property(name),
153            encode_command_data(&value)
154        );
155    } else {
156        let mut file = std::fs::OpenOptions::new()
157            .append(true)
158            .open(github_env)
159            .unwrap();
160        let delimiter = uuid::Uuid::new_v4();
161        use std::io::Write;
162        writeln!(file, "{name}<<{delimiter}\n{value}\n{delimiter}").unwrap();
163    }
164}
165
166pub fn set_secret(secret: impl AsRef<str>) {
167    let secret = secret.as_ref();
168    println!("::add-mask::{}", encode_command_data(secret));
169}
170
171pub fn add_path(input_path: impl AsRef<str>) {
172    let input_path = input_path.as_ref();
173    let github_path = std::env::var("GITHUB_PATH").unwrap_or_default();
174    if github_path.is_empty() {
175        println!("::add-path::{}", encode_command_data(input_path));
176    } else {
177        let mut file = std::fs::OpenOptions::new()
178            .append(true)
179            .open(github_path)
180            .unwrap();
181        use std::io::Write;
182        writeln!(file, "{input_path}").unwrap();
183    }
184    let path = std::env::var("PATH").unwrap();
185    const PATH_DELIMITER: &str = if cfg!(windows) { ";" } else { ":" };
186    std::env::set_var("PATH", format!("{input_path}{PATH_DELIMITER}{path}"));
187}
188
189pub fn get_input(name: impl AsRef<str>) -> String {
190    get_input_with_options(name, &InputOptions::default()).unwrap()
191}
192
193pub fn get_input_with_options(
194    name: impl AsRef<str>,
195    options: &InputOptions,
196) -> Result<String, Box<dyn std::error::Error>> {
197    let name = name.as_ref();
198    let name_env = name.replace(' ', "_").to_uppercase();
199    let value = std::env::var(format!("INPUT_{name_env}")).unwrap_or_default();
200    if options.required && value.is_empty() {
201        return Err(format!("{name} is required").into());
202    }
203    if options.trim_whitespace {
204        Ok(value.trim().into())
205    } else {
206        Ok(value)
207    }
208}
209
210pub fn get_multiline_input(name: impl AsRef<str>) -> Vec<String> {
211    get_multiline_input_with_options(name, &InputOptions::default()).unwrap()
212}
213
214pub fn get_multiline_input_with_options(
215    name: impl AsRef<str>,
216    options: &InputOptions,
217) -> Result<Vec<String>, Box<dyn std::error::Error>> {
218    let value = get_input_with_options(name, options)?;
219    let lines: Vec<String> = value
220        .lines()
221        .filter_map(|x| if x.is_empty() { None } else { Some(x.into()) })
222        .collect();
223    if options.trim_whitespace {
224        Ok(lines.into_iter().map(|x| x.trim().into()).collect())
225    } else {
226        Ok(lines)
227    }
228}
229
230pub fn get_boolean_input(name: impl AsRef<str>) -> bool {
231    get_boolean_input_with_options(name, &InputOptions::default()).unwrap()
232}
233
234pub fn get_boolean_input_with_options(
235    name: impl AsRef<str>,
236    options: &InputOptions,
237) -> Result<bool, Box<dyn std::error::Error>> {
238    let name = name.as_ref();
239    let value = get_input_with_options(name, options)?;
240    const TRUE_VALUES: &[&str] = &["true", "True", "TRUE"];
241    const FALSE_VALUES: &[&str] = &["false", "False", "FALSE"];
242    if TRUE_VALUES.contains(&value.as_str()) {
243        Ok(true)
244    } else if FALSE_VALUES.contains(&value.as_str()) {
245        Ok(false)
246    } else {
247        Err(format!("{name} is not a valid boolean").into())
248    }
249}
250
251pub fn set_output(name: impl AsRef<str>, value: impl std::fmt::Display) {
252    let name = name.as_ref();
253    let value = value.to_string();
254    let github_output = std::env::var("GITHUB_OUTPUT").unwrap_or_default();
255    if github_output.is_empty() {
256        println!(
257            "::set-output name={}::{}",
258            encode_command_property(name),
259            encode_command_data(&value)
260        );
261    } else {
262        let mut file = std::fs::OpenOptions::new()
263            .append(true)
264            .open(github_output)
265            .unwrap();
266        let delimiter = uuid::Uuid::new_v4();
267        use std::io::Write;
268        writeln!(file, "{name}<<{delimiter}\n{value}\n{delimiter}").unwrap();
269    }
270}
271
272pub fn set_command_echo(enabled: bool) {
273    println!("::echo::{}", if enabled { "on" } else { "off" });
274}
275
276pub fn set_failed(message: impl std::fmt::Display) -> ! {
277    error(message);
278    panic!();
279}
280
281pub fn is_debug() -> bool {
282    std::env::var("RUNNER_DEBUG").is_ok_and(|x| x == "1")
283}
284
285pub fn debug(message: impl std::fmt::Display) {
286    debug_with_properties(message, &AnnotationProperties::default());
287}
288
289pub fn debug_with_properties(message: impl std::fmt::Display, properties: &AnnotationProperties) {
290    let message = message.to_string();
291    println!("::debug {}::{}", properties, encode_command_data(&message));
292}
293
294pub fn error(message: impl std::fmt::Display) {
295    error_with_properties(message, &AnnotationProperties::default());
296}
297
298pub fn error_with_properties(message: impl std::fmt::Display, properties: &AnnotationProperties) {
299    let message = message.to_string();
300    println!("::error {}::{}", properties, encode_command_data(&message));
301}
302
303pub fn warning(message: impl std::fmt::Display) {
304    warning_with_properties(message, &AnnotationProperties::default());
305}
306
307pub fn warning_with_properties(message: impl std::fmt::Display, properties: &AnnotationProperties) {
308    let message = message.to_string();
309    println!(
310        "::warning {}::{}",
311        properties,
312        encode_command_data(&message)
313    );
314}
315
316pub fn notice(message: impl std::fmt::Display) {
317    notice_with_properties(message, &AnnotationProperties::default());
318}
319
320pub fn notice_with_properties(message: impl std::fmt::Display, properties: &AnnotationProperties) {
321    let message = message.to_string();
322    println!("::notice {}::{}", properties, encode_command_data(&message));
323}
324
325pub fn info(message: impl std::fmt::Display) {
326    println!("{message}");
327}
328
329pub fn start_group(name: impl AsRef<str>) {
330    let name = name.as_ref();
331    println!("::group::{}", encode_command_data(name));
332}
333
334pub fn end_group() {
335    println!("::endgroup::");
336}
337
338pub fn group<T, F: FnOnce() -> T>(name: impl AsRef<str>, f: F) -> T {
339    // `drop()` still runs even if `f()` panics.
340    struct GroupResource;
341    impl Drop for GroupResource {
342        fn drop(&mut self) {
343            end_group();
344        }
345    }
346    start_group(name);
347    let _group = GroupResource {};
348    f()
349}
350
351pub fn save_state(name: impl AsRef<str>, value: impl std::fmt::Display) {
352    let name = name.as_ref();
353    let value = value.to_string();
354    let github_state = std::env::var("GITHUB_STATE").unwrap_or_default();
355    if github_state.is_empty() {
356        println!(
357            "::save-state name={}::{}",
358            encode_command_property(name),
359            encode_command_data(&value)
360        );
361    } else {
362        let mut file = std::fs::OpenOptions::new()
363            .append(true)
364            .open(github_state)
365            .unwrap();
366        let delimiter = uuid::Uuid::new_v4();
367        use std::io::Write;
368        writeln!(file, "{name}<<{delimiter}\n{value}\n{delimiter}").unwrap();
369    }
370}
371
372pub fn get_state(name: impl AsRef<str>) -> String {
373    let name = name.as_ref();
374    std::env::var(format!("STATE_{name}")).unwrap_or_default()
375}
376
377pub fn get_id_token() -> Result<String, Box<dyn std::error::Error>> {
378    get_id_token_with_audience("")
379}
380
381pub fn get_id_token_with_audience(
382    audience: impl AsRef<str>,
383) -> Result<String, Box<dyn std::error::Error>> {
384    #[derive(serde::Deserialize)]
385    struct TokenResponse {
386        value: String,
387    }
388    let audience = audience.as_ref();
389    let mut url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL")?;
390    if !audience.is_empty() {
391        url.push_str(&format!("&audience={audience}"));
392    }
393    let response = reqwest::blocking::get(url)?;
394    let json: TokenResponse = response.json()?;
395    let id_token = json.value;
396    set_secret(&id_token);
397    Ok(id_token)
398}
399
400pub fn to_posix_path(path: &str) -> String {
401    path.replace('\\', "/")
402}
403
404pub fn to_win32_path(path: &str) -> String {
405    path.replace('/', "\\")
406}
407
408pub fn to_platform_path(path: &str) -> String {
409    if cfg!(windows) {
410        to_win32_path(path)
411    } else {
412        to_posix_path(path)
413    }
414}
415
416pub const SUMMARY_ENV_VAR: &str = "GITHUB_STEP_SUMMARY";
417pub const SUMMARY_DOCS_URL: &str = "https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary";
418
419#[derive(Debug, Clone, PartialEq, Eq)]
420pub enum SummaryTableRowItem<'a> {
421    SummaryTableCell(SummaryTableCell<'a>),
422    String(String),
423}
424
425pub type SummaryTableRow<'a> = &'a [SummaryTableRowItem<'a>];
426
427#[derive(Debug, Clone, PartialEq, Eq)]
428pub struct SummaryTableCell<'a> {
429    pub data: &'a str,
430    pub header: bool,
431    pub colspan: &'a str,
432    pub rowspan: &'a str,
433}
434
435impl Default for SummaryTableCell<'_> {
436    fn default() -> Self {
437        Self {
438            data: "",
439            header: false,
440            colspan: "1",
441            rowspan: "1",
442        }
443    }
444}
445
446#[derive(Default, Debug, Clone, PartialEq, Eq)]
447pub struct SummaryImageOptions<'a> {
448    pub width: &'a str,
449    pub height: &'a str,
450}
451
452#[derive(Default, Debug, Clone, PartialEq, Eq)]
453pub struct SummaryWriteOptions {
454    pub overwrite: bool,
455}
456
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub struct Summary {
459    buffer: String,
460    path: String,
461}
462
463impl Default for Summary {
464    fn default() -> Self {
465        Self::new()
466    }
467}
468
469mod dom {
470    #[derive(Debug, Clone, PartialEq, Eq)]
471    pub struct HtmlElement {
472        pub tag_name: String,
473        pub attributes: std::collections::HashMap<String, String>,
474        pub child_nodes: Vec<Node>,
475    }
476
477    impl HtmlElement {
478        pub fn new(tag_name: impl AsRef<str>) -> Self {
479            Self {
480                tag_name: tag_name.as_ref().to_string(),
481                attributes: std::collections::HashMap::new(),
482                child_nodes: Vec::new(),
483            }
484        }
485
486        pub fn with_children(tag_name: impl AsRef<str>, child_nodes: impl AsRef<[Node]>) -> Self {
487            Self {
488                tag_name: tag_name.as_ref().to_string(),
489                attributes: std::collections::HashMap::new(),
490                child_nodes: child_nodes.as_ref().to_vec(),
491            }
492        }
493    }
494
495    impl std::fmt::Display for HtmlElement {
496        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
497            const VOID_ELEMENTS: &[&str] = &[
498                "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
499                "param", "source", "track", "wbr",
500            ];
501            write!(f, "<{}", self.tag_name)?;
502            for (key, value) in &self.attributes {
503                write!(f, " {}=\"{}\"", key, value)?;
504            }
505            if VOID_ELEMENTS.contains(&self.tag_name.as_str()) {
506                write!(f, " />")?;
507            } else {
508                write!(f, ">")?;
509                for child_node in &self.child_nodes {
510                    write!(f, "{}", child_node)?;
511                }
512                write!(f, "</{}>", self.tag_name)?;
513            }
514            Ok(())
515        }
516    }
517
518    #[derive(Debug, Clone, PartialEq, Eq)]
519    pub enum Node {
520        String(String),
521        HtmlElement(HtmlElement),
522    }
523
524    impl std::fmt::Display for Node {
525        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526            match self {
527                Node::String(string) => write!(f, "{}", string),
528                Node::HtmlElement(element) => write!(f, "{}", element),
529            }
530        }
531    }
532}
533
534impl Summary {
535    pub fn new() -> Self {
536        let path = std::env::var("GITHUB_STEP_SUMMARY").unwrap();
537        Self {
538            buffer: String::new(),
539            path,
540        }
541    }
542
543    pub fn write(&mut self) -> Result<&mut Self, Box<dyn std::error::Error>> {
544        self.write_with_options(&SummaryWriteOptions::default())
545    }
546
547    pub fn write_with_options(
548        &mut self,
549        options: &SummaryWriteOptions,
550    ) -> Result<&mut Self, Box<dyn std::error::Error>> {
551        if options.overwrite {
552            std::fs::write(&self.path, &self.buffer)?;
553        } else {
554            let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
555            use std::io::Write;
556            write!(file, "{}", self.buffer)?;
557        }
558        self.buffer = String::new();
559        Ok(self)
560    }
561
562    pub fn clear(&mut self) -> Result<&mut Self, Box<dyn std::error::Error>> {
563        self.buffer = String::new();
564        self.write()?;
565        Ok(self)
566    }
567
568    pub fn stringify(&self) -> String {
569        self.buffer.clone()
570    }
571
572    pub fn is_empty_buffer(&self) -> bool {
573        self.buffer.is_empty()
574    }
575
576    pub fn empty_buffer(&mut self) -> &mut Self {
577        self.buffer = String::new();
578        self
579    }
580
581    pub fn add_raw(&mut self, text: impl AsRef<str>) -> &mut Self {
582        self.add_raw_with_add_eol(text, false);
583        self
584    }
585
586    pub fn add_raw_with_add_eol(&mut self, text: impl AsRef<str>, add_eol: bool) -> &mut Self {
587        let text = text.as_ref();
588        self.buffer.push_str(text);
589        if add_eol {
590            self.buffer.push('\n');
591        }
592        self
593    }
594
595    pub fn add_eol(&mut self) -> &mut Self {
596        self.buffer.push('\n');
597        self
598    }
599
600    pub fn add_code_block(&mut self, code: impl AsRef<str>) -> &mut Self {
601        self.add_code_block_with_lang(code, "");
602        self
603    }
604
605    pub fn add_code_block_with_lang(
606        &mut self,
607        code: impl AsRef<str>,
608        lang: impl AsRef<str>,
609    ) -> &mut Self {
610        let code = code.as_ref();
611        let lang = lang.as_ref();
612        self.buffer.push_str(&format!("```{lang}\n{code}\n```"));
613        self.buffer.push('\n');
614        self
615    }
616
617    pub fn add_list(&mut self, items: &[impl AsRef<str>]) -> &mut Self {
618        self.add_list_with_ordered(items, false);
619        self
620    }
621
622    pub fn add_list_with_ordered(&mut self, items: &[impl AsRef<str>], ordered: bool) -> &mut Self {
623        let mut ul_or_ol = if ordered {
624            dom::HtmlElement::new("ol")
625        } else {
626            dom::HtmlElement::new("ul")
627        };
628        for item in items {
629            let item = item.as_ref().to_string();
630            let li = dom::HtmlElement::with_children("li", &[dom::Node::String(item)]);
631            ul_or_ol.child_nodes.push(dom::Node::HtmlElement(li));
632        }
633        self.buffer.push_str(&ul_or_ol.to_string());
634        self.buffer.push('\n');
635        self
636    }
637
638    pub fn add_table<'a>(&mut self, rows: impl AsRef<[SummaryTableRow<'a>]>) -> &mut Self {
639        let mut table = dom::HtmlElement::new("table");
640        for row in rows.as_ref() {
641            let mut tr = dom::HtmlElement::new("tr");
642            for item in row.iter() {
643                match item {
644                    SummaryTableRowItem::String(string) => {
645                        let td = dom::HtmlElement::with_children(
646                            "td",
647                            &[dom::Node::String(string.clone())],
648                        );
649                        tr.child_nodes.push(dom::Node::HtmlElement(td));
650                    }
651                    SummaryTableRowItem::SummaryTableCell(cell) => {
652                        let mut th_or_td = if cell.header {
653                            dom::HtmlElement::new("th")
654                        } else {
655                            dom::HtmlElement::new("td")
656                        };
657                        if !cell.colspan.is_empty() {
658                            th_or_td
659                                .attributes
660                                .insert("colspan".into(), cell.colspan.to_string());
661                        }
662                        if !cell.rowspan.is_empty() {
663                            th_or_td
664                                .attributes
665                                .insert("rowspan".into(), cell.rowspan.to_string());
666                        }
667                        th_or_td
668                            .child_nodes
669                            .push(dom::Node::String(cell.data.to_string()));
670                        tr.child_nodes.push(dom::Node::HtmlElement(th_or_td));
671                    }
672                }
673            }
674            table.child_nodes.push(dom::Node::HtmlElement(tr));
675        }
676        self.buffer.push_str(&table.to_string());
677        self.buffer.push('\n');
678        self
679    }
680
681    pub fn add_details(&mut self, label: impl AsRef<str>, content: impl AsRef<str>) -> &mut Self {
682        let label = label.as_ref();
683        let content = content.as_ref();
684        let mut details = dom::HtmlElement::new("details");
685        let summary = dom::HtmlElement::with_children("summary", [dom::Node::String(label.into())]);
686        details.child_nodes.push(dom::Node::HtmlElement(summary));
687        details.child_nodes.push(dom::Node::String(content.into()));
688        self.buffer.push_str(&details.to_string());
689        self.buffer.push('\n');
690        self
691    }
692
693    pub fn add_image(&mut self, src: impl AsRef<str>, alt: impl AsRef<str>) -> &mut Self {
694        self.add_image_with_options(src, alt, &SummaryImageOptions::default());
695        self
696    }
697
698    pub fn add_image_with_options(
699        &mut self,
700        src: impl AsRef<str>,
701        alt: impl AsRef<str>,
702        options: &SummaryImageOptions,
703    ) -> &mut Self {
704        let src = src.as_ref();
705        let alt = alt.as_ref();
706        let mut img = dom::HtmlElement::new("img");
707        img.attributes.insert("src".into(), src.into());
708        img.attributes.insert("alt".into(), alt.into());
709        if !options.width.is_empty() {
710            img.attributes.insert("width".into(), options.width.into());
711        }
712        if !options.height.is_empty() {
713            img.attributes
714                .insert("height".into(), options.height.into());
715        }
716        self.buffer.push_str(&img.to_string());
717        self.buffer.push('\n');
718        self
719    }
720
721    pub fn add_heading(&mut self, text: impl AsRef<str>) -> &mut Self {
722        self.add_heading_with_level(text, 1);
723        self
724    }
725
726    pub fn add_heading_with_level(&mut self, text: impl AsRef<str>, level: u8) -> &mut Self {
727        let text = text.as_ref();
728        let level = if [1, 2, 3, 4, 5, 6].contains(&level) {
729            level
730        } else {
731            1
732        };
733        let mut h = dom::HtmlElement::new(format!("h{}", level));
734        h.child_nodes.push(dom::Node::String(text.into()));
735        self.buffer.push_str(&h.to_string());
736        self.buffer.push('\n');
737        self
738    }
739
740    pub fn add_separator(&mut self) -> &mut Self {
741        self.buffer.push_str("<hr />");
742        self.buffer.push('\n');
743        self
744    }
745
746    pub fn add_break(&mut self) -> &mut Self {
747        self.buffer.push_str("<br />");
748        self.buffer.push('\n');
749        self
750    }
751
752    pub fn add_quote(&mut self, text: impl AsRef<str>) -> &mut Self {
753        self.add_quote_with_cite(text, "");
754        self
755    }
756
757    pub fn add_quote_with_cite(
758        &mut self,
759        text: impl AsRef<str>,
760        cite: impl AsRef<str>,
761    ) -> &mut Self {
762        let text = text.as_ref();
763        let cite = cite.as_ref();
764        let mut blockquote = dom::HtmlElement::new("blockquote");
765        if !cite.is_empty() {
766            blockquote.attributes.insert("cite".into(), cite.into());
767        }
768        blockquote.child_nodes.push(dom::Node::String(text.into()));
769        self.buffer.push_str(&blockquote.to_string());
770        self.buffer.push('\n');
771        self
772    }
773
774    pub fn add_link(&mut self, text: impl AsRef<str>, href: impl AsRef<str>) -> &mut Self {
775        let text = text.as_ref();
776        let href = href.as_ref();
777        let mut a = dom::HtmlElement::new("a");
778        a.attributes.insert("href".into(), href.into());
779        a.child_nodes.push(dom::Node::String(text.into()));
780        self.buffer.push_str(&a.to_string());
781        self.buffer.push('\n');
782        self
783    }
784}
785
786lazy_static::lazy_static! {
787    static ref SUMMARY: Summary = Summary::new();
788}
789lazy_static::lazy_static! {
790    /// #[deprecated]
791    static ref MARKDOWN_SUMMARY: &'static Summary = &*SUMMARY;
792}
793
794pub mod platform {
795    #[cfg(target_os = "windows")]
796    fn get_windows_info() -> Result<(String, String), Box<dyn std::error::Error>> {
797        let version = Command::new("powershell")
798            .arg("-command")
799            .arg("(Get-CimInstance -ClassName Win32_OperatingSystem).Version")
800            .output()?
801            .stdout;
802        let version = String::from_utf8(version)?;
803        let name = Command::new("powershell")
804            .arg("-command")
805            .arg("(Get-CimInstance -ClassName Win32_OperatingSystem).Caption")
806            .output()?
807            .stdout;
808        let name = String::from_utf8(name)?;
809        Ok((name.trim().to_string(), version.trim().to_string()))
810    }
811
812    #[cfg(target_os = "macos")]
813    fn get_macos_info() -> Result<(String, String), Box<dyn std::error::Error>> {
814        let version = Command::new("sw_vers")
815            .arg("-productVersion")
816            .output()?
817            .stdout;
818        let version = String::from_utf8(version)?;
819        let name = Command::new("sw_vers").arg("-productName").output()?.stdout;
820        let name = String::from_utf8(name)?;
821        Ok((name.trim().to_string(), version.trim().to_string()))
822    }
823
824    #[cfg(target_os = "linux")]
825    fn get_linux_info() -> Result<(String, String), Box<dyn std::error::Error>> {
826        let name = std::process::Command::new("lsb_release")
827            .arg("-is")
828            .output()?
829            .stdout;
830        let name = String::from_utf8(name)?;
831        let version = std::process::Command::new("lsb_release")
832            .arg("-rs")
833            .output()?
834            .stdout;
835        let version = String::from_utf8(version)?;
836        Ok((name.trim().to_string(), version.trim().to_string()))
837    }
838
839    #[cfg(target_os = "windows")]
840    pub const PLATFORM: &str = "win32";
841    #[cfg(target_os = "macos")]
842    pub const PLATFORM: &str = "darwin";
843    #[cfg(target_os = "linux")]
844    pub const PLATFORM: &str = "linux";
845    #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
846    compile_error!("unsupported target_os");
847
848    #[cfg(target_arch = "x86_64")]
849    pub const ARCH: &str = "x86_64";
850    #[cfg(target_arch = "x86")]
851    pub const ARCH: &str = "x86";
852    #[cfg(target_arch = "aarch64")]
853    pub const ARCH: &str = "arm64";
854    #[cfg(target_arch = "arm")]
855    pub const ARCH: &str = "arm";
856    #[cfg(not(any(
857        target_arch = "x86_64",
858        target_arch = "x86",
859        target_arch = "aarch64",
860        target_arch = "arm"
861    )))]
862    compile_error!("unsupported target_arch");
863
864    pub const IS_WINDOWS: bool = cfg!(target_os = "windows");
865    pub const IS_MACOS: bool = cfg!(target_os = "macos");
866    pub const IS_LINUX: bool = cfg!(target_os = "linux");
867
868    pub struct Details {
869        pub name: String,
870        pub platform: String,
871        pub arch: String,
872        pub version: String,
873        pub is_windows: bool,
874        pub is_macos: bool,
875        pub is_linux: bool,
876    }
877
878    pub fn get_details() -> Result<Details, Box<dyn std::error::Error>> {
879        #[cfg(target_os = "windows")]
880        let (name, version) = get_windows_info()?;
881        #[cfg(target_os = "macos")]
882        let (name, version) = get_macos_info()?;
883        #[cfg(target_os = "linux")]
884        let (name, version) = get_linux_info()?;
885        #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
886        compile_error!("unsupported target_os");
887        Ok(Details {
888            name,
889            platform: PLATFORM.to_string(),
890            arch: ARCH.to_string(),
891            version,
892            is_windows: IS_WINDOWS,
893            is_macos: IS_MACOS,
894            is_linux: IS_LINUX,
895        })
896    }
897}