acorn_lib/analyzer/
mod.rs

1//! # Prose analyzer module
2//!
3//! This is where we keep functions and interfaces necessary to execute ACORN's automated editorial style guide as well as content readability analyzer.
4//!
5use crate::analyzer::vale::{ValeOutput, ValeOutputItem};
6use crate::constants::{
7    APPLICATION, CUSTOM_VALE_PACKAGE_NAME, DEFAULT_VALE_PACKAGE_URL, DEFAULT_VALE_ROOT, DISABLED_VALE_RULES, ENABLED_VALE_PACKAGES, ORGANIZATION,
8    VALE_RELEASES_URL, VALE_VERSION,
9};
10use crate::schema::ProgrammingLanguage;
11use crate::util::*;
12use crate::{Location, Repository};
13use bat::PrettyPrinter;
14use bon::Builder;
15use color_eyre::owo_colors::OwoColorize;
16use convert_case::{Case, Casing};
17use derive_more::Display;
18use duct::cmd;
19use flate2::read::GzDecoder;
20use ini::Ini;
21use lychee_lib::{CacheStatus, Response, Status};
22use polars::datatypes::PlSmallStr;
23use polars::frame::row::Row;
24use polars::prelude::{AnyValue, DataFrame, PolarsResult};
25use std::collections::HashMap;
26use std::fs::File;
27use std::fs::{create_dir_all, remove_file};
28use std::io::prelude::*;
29use std::path::PathBuf;
30use tar::Archive;
31use tracing::{debug, error, info, trace, warn};
32use validator::ValidationErrorsKind;
33use which::which;
34
35pub mod readability;
36pub mod vale;
37
38use readability::ReadabilityType;
39use vale::{Vale, ValeConfig};
40
41/// Trait for converting to a ([Polars]) row
42///
43/// [Polars]: https://docs.rs/polars/latest/polars/
44pub trait IntoRow<'a> {
45    /// Convert to a (Polars) row
46    fn to_row<T>(self) -> Row<'a>;
47}
48/// Trait for static analyzers (e.g. Vale)
49pub trait StaticAnalyzer<Config: StaticAnalyzerConfig> {
50    /// Get command name (e.g. "vale")
51    fn command(self) -> String;
52    /// Download binary
53    fn download(self, config: Option<Config>, skip_verify_checksum: bool) -> Self;
54    /// Download checksum values
55    fn download_checksums(self) -> Result<HashMap<String, String>, String>;
56    /// Extract binary
57    fn extract(self, path: PathBuf, destination: Option<PathBuf>) -> PathBuf;
58    /// Resolve analyzer
59    fn resolve(_config: Config, _is_offline: bool, _skip_verify_checksum: bool) -> Self;
60    /// Run analyzer on content
61    fn run(&self, id: String, content: String, output: Option<String>) -> Check;
62    /// Perform sync operation (only applies to Vale)
63    fn sync(self, is_offline: bool) -> Result<(), std::io::Error>;
64    /// Set binary
65    fn with_binary<P>(self, path: P) -> Self
66    where
67        P: Into<PathBuf>;
68    /// Set config
69    fn with_config(self, value: Config) -> Self;
70    /// Set system command
71    fn with_system_command(self) -> Self;
72    /// Set version
73    fn with_version(self, value: String) -> Self;
74}
75/// Trait for static analyzer configuration (e.g. .vale.ini)
76pub trait StaticAnalyzerConfig {
77    /// Get default configuration
78    fn default() -> Self;
79    /// Convert to INI
80    fn ini(self) -> Ini;
81    /// Save configuration
82    fn save(self) -> Self;
83    /// Set parent path of configuration
84    fn with_path(self, path: PathBuf) -> Self;
85}
86/// Various check categories available for validating research activity data
87#[derive(Clone, Debug, Display, PartialEq)]
88pub enum CheckCategory {
89    /// Website avaialability check
90    #[display("link")]
91    Link,
92    /// Static analysis of prose
93    #[display("prose")]
94    Prose,
95    /// Readability check using one of several metrics
96    #[display("readability")]
97    Readability,
98    /// Schema validation check
99    #[display("schema")]
100    Schema,
101}
102/// Error kind
103#[derive(Clone, Debug)]
104pub enum ErrorKind {
105    /// Readability issue where calculated index exceeds threshold of associated metric
106    Readability((f64, ReadabilityType)),
107    /// Prose issue found by Vale
108    Vale(Vec<ValeOutputItem>),
109    /// Schema validation issue found by [validator crate]
110    ///
111    /// [validator crate]: https://crates.io/crates/validator
112    Validator(ValidationErrorsKind),
113}
114/// Data structure for holding the result of a schema validation check
115#[derive(Builder, Clone, Debug, Display)]
116#[builder(start_fn = init)]
117#[display("{message}")]
118pub struct Check {
119    /// Check category
120    pub category: CheckCategory,
121    /// Textual context of check (e.g., paragraph where prose issues were found)
122    pub context: Option<String>,
123    /// Whether or not the check was successful
124    #[builder(default = false)]
125    pub success: bool,
126    /// HTTP status code
127    pub status_code: Option<String>,
128    /// Errors and issues found during check
129    pub errors: Option<ErrorKind>,
130    /// Path of file being validated
131    pub uri: Option<String>,
132    /// Message related to or description of validation issue (e.g., key name of invalid value, result of validation, etc.)
133    #[builder(default = "".to_string())]
134    pub message: String,
135}
136impl Check {
137    /// Returns the number of errors
138    pub fn issue_count(&self) -> usize {
139        match self.category {
140            | CheckCategory::Link => 1,
141            | CheckCategory::Prose => {
142                if let Some(kind) = &self.errors {
143                    match kind {
144                        | ErrorKind::Vale(values) => values.len(),
145                        | _ => 0,
146                    }
147                } else {
148                    0
149                }
150            }
151            | CheckCategory::Readability => 1,
152            | CheckCategory::Schema => {
153                if let Some(kind) = &self.errors {
154                    match kind {
155                        | ErrorKind::Validator(values) => match values {
156                            | ValidationErrorsKind::Field(_) => 1,
157                            | ValidationErrorsKind::Struct(values) => values.clone().into_errors().len(),
158                            | ValidationErrorsKind::List(_) => 0,
159                        },
160                        | _ => 0,
161                    }
162                } else {
163                    0
164                }
165            }
166        }
167    }
168    /// Print the schema check results
169    pub fn print(self) {
170        match self.category {
171            | CheckCategory::Link => {
172                let code = match self.status_code {
173                    | Some(code) => format!(" ({code})").dimmed().to_string(),
174                    | None => "".to_string(),
175                };
176                let url = match self.uri {
177                    | Some(value) => value.underline().italic().to_string(),
178                    | None => "Missing".italic().to_string(),
179                };
180                if self.success {
181                    let message = &self.message.to_case(Case::Title).green().bold().to_string();
182                    info!("=> {} \"{url}\" {message}{code}", Label::valid());
183                } else {
184                    let message = &self.message.to_case(Case::Title).red().bold().to_string();
185                    error!("=> {} \"{url}\" {message}{code}", Label::invalid());
186                }
187            }
188            | CheckCategory::Prose => {
189                let Check {
190                    context, errors, message, ..
191                } = self;
192                match &errors {
193                    | Some(ErrorKind::Vale(values)) => {
194                        error!("=> {} {} issues found in {}", Label::fail(), values.len(), message.underline());
195                        for item in values {
196                            let ValeOutputItem {
197                                check,
198                                line,
199                                message,
200                                severity,
201                                span,
202                                ..
203                            } = item;
204                            let location = format!("Line {}, Character {}", line, span[0]);
205                            println!("  {:<24} {:<21} {} {}", location, severity.colored(), message, check.dimmed());
206                        }
207                        let highlight = values.clone().into_iter().map(|item| item.line as usize).collect::<Vec<_>>();
208                        if let Some(content) = &context {
209                            println!();
210                            pretty_print(content, ProgrammingLanguage::Markdown, highlight);
211                            println!("\n");
212                        }
213                    }
214                    | None | Some(_) => {
215                        let message = format!("=> {} {} has {}", Label::pass(), message.underline(), "no prose issues".green(),);
216                        info!("{}", message);
217                    }
218                }
219            }
220            | CheckCategory::Readability => {
221                let Check {
222                    context, errors, message, ..
223                } = self;
224                match &errors {
225                    | Some(ErrorKind::Readability(values)) => {
226                        let (index, readability_type) = values;
227                        error!(
228                            "=> {} {} has {} value of {} (should be less than {})",
229                            Label::fail(),
230                            message,
231                            readability_type.to_string().to_uppercase(),
232                            index.red().bold(),
233                            context.unwrap().cyan(),
234                        );
235                    }
236                    | None | Some(_) => {
237                        if let Some(context) = &context {
238                            info!(
239                                "=> {} {} has {} {}",
240                                Label::pass(),
241                                message,
242                                "no readability issues".green().bold(),
243                                context.dimmed()
244                            );
245                        }
246                    }
247                }
248            }
249            | CheckCategory::Schema => {
250                let path = self.clone().uri.unwrap();
251                if self.success {
252                    info!("=> {} {} has {}", Label::pass(), path, "no schema validation issues".green().bold());
253                } else {
254                    let count = self.issue_count();
255                    error!(
256                        "=> {} Found {} schema validation issue{} in {}: \n{:#?}",
257                        Label::fail(),
258                        count.red(),
259                        suffix(count),
260                        path.italic().underline(),
261                        self.errors.unwrap()
262                    );
263                }
264            }
265        }
266    }
267    /// Returns a new LinkCheckResult with the given URL
268    pub fn with_uri(self, value: String) -> Self {
269        Check::init()
270            .category(self.category)
271            .success(self.success)
272            .uri(value)
273            .message(self.message)
274            .maybe_status_code(self.status_code)
275            .maybe_errors(self.errors)
276            .build()
277    }
278}
279impl<'a> IntoRow<'a> for Check {
280    fn to_row<Check>(self) -> Row<'a> {
281        let Self {
282            success,
283            category,
284            message,
285            uri,
286            status_code,
287            context,
288            ..
289        } = self;
290        let data = [
291            if success { "pass" } else { "fail" },
292            &category.to_string(),
293            &message,
294            &uri.unwrap_or_default(),
295            &status_code.unwrap_or_default(),
296            &context.unwrap_or_default(),
297        ];
298        Row::new(data.into_iter().map(|x| AnyValue::String(x).into_static()).collect::<Vec<_>>())
299    }
300}
301impl StaticAnalyzer<ValeConfig> for Vale {
302    fn command(self) -> String {
303        "vale".to_string()
304    }
305    /// Resolve Vale
306    /// ### Notes
307    /// - Will use system `vale` command if available
308    /// - Will use local `vale` binary if available (will expect local binary if offline)
309    /// - Will download `vale` binary if not available by other means
310    fn resolve(config: ValeConfig, is_offline: bool, skip_verify_checksum: bool) -> Vale {
311        fn any_exist<S>(paths: Vec<S>) -> bool
312        where
313            S: Into<PathBuf>,
314        {
315            paths.into_iter().any(|s| s.into().exists())
316        }
317        let root = DEFAULT_VALE_ROOT;
318        let name = "vale";
319        let init = Vale::init().build();
320        let vale = if command_exists(name) {
321            init.with_config(config).with_system_command()
322        } else if is_offline || any_exist(vec![format!("{root}{name}"), format!("{root}{name}.exe")]) {
323            info!("=> {} Local {} binary", Label::using(), name.green().bold());
324            #[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
325            {
326                init.with_config(config).with_binary(format!("{root}{name}"))
327            }
328            #[cfg(windows)]
329            {
330                init.with_config(config).with_binary(format!("{root}{name}.exe"))
331            }
332        } else {
333            init.download(Some(config), skip_verify_checksum)
334        };
335        vale
336    }
337    fn run(&self, id: String, content: String, output: Option<String>) -> Check {
338        let root = standard_project_folder("check", None);
339        match create_dir_all(root.clone()) {
340            | Ok(_) => {}
341            | Err(why) => error!(path = to_absolute_string(root.clone()), "=> {} Create - {}", Label::fail(), why),
342        }
343        let path = root.join(&id);
344        let mut file = match File::create(&path) {
345            | Ok(file) => file,
346            | Err(why) => panic!("=> {} Create file {} - {}", Label::fail(), path.display(), why),
347        };
348        file.write_all(content.as_bytes())
349            .expect("Unable to write to cache directory project file");
350        let binary = match &self.binary {
351            | Some(value) => value,
352            | None => {
353                error!("=> {} {} binary", Label::not_found(), self.clone().command());
354                std::process::exit(exitcode::UNAVAILABLE);
355            }
356        };
357        match &self.config {
358            | Some(config) => {
359                let result = match output {
360                    | Some(value) => cmd!(
361                        binary,
362                        "--no-wrap",
363                        "--config",
364                        config.clone().path,
365                        "--output",
366                        value,
367                        path.clone(),
368                        "--ext",
369                        ".md",
370                        "--no-exit",
371                    )
372                    .read(),
373                    | None => cmd!(
374                        binary,
375                        "--no-wrap",
376                        "--config",
377                        config.clone().path,
378                        path.clone(),
379                        "--ext",
380                        ".md",
381                        "--no-exit"
382                    )
383                    .read(),
384                };
385                match result {
386                    | Ok(output) => {
387                        let parsed = ValeOutput::parse(&output, path);
388                        if parsed.is_empty() {
389                            Check::init().category(CheckCategory::Prose).success(true).message(id).build()
390                        } else {
391                            Check::init()
392                                .category(CheckCategory::Prose)
393                                .success(false)
394                                .message(id)
395                                .errors(ErrorKind::Vale(parsed))
396                                .context(content)
397                                .build()
398                        }
399                    }
400                    | Err(output) => {
401                        error!("=> {} Analyze - {}", Label::fail(), output);
402                        Check::init().category(CheckCategory::Prose).success(false).message(id).build()
403                    }
404                }
405            }
406            | None => {
407                let title = self.clone().command().to_case(Case::Title);
408                error!("=> {} {} configuration", Label::not_found(), title);
409                std::process::exit(exitcode::UNAVAILABLE);
410            }
411        }
412    }
413    fn download(self, config: Option<ValeConfig>, skip_verify_checksum: bool) -> Vale {
414        // https://doc.rust-lang.org/std/env/consts/constant.OS.html
415        let os = std::env::consts::OS.to_lowercase();
416        let platform = match os.as_str() {
417            | "linux" => "Linux_64-bit.tar.gz",
418            | "macos" | "apple" => "macOS_64-bit.tar.gz",
419            | "windows" => "Windows_64-bit.zip",
420            | _ => {
421                error!(os, "=> {}", Label::not_found());
422                std::process::exit(exitcode::UNAVAILABLE);
423            }
424        };
425        let release = match self.version {
426            | Some(value) => value,
427            | None => SemanticVersion::from_string(VALE_VERSION),
428        };
429        let url = format!("{VALE_RELEASES_URL}/download/v{release}/{}_{release}_{platform}", self.clone().command());
430        info!(url, "=> {} Vale release v{release}", Label::using());
431        let binary = match download_binary(&url, ".") {
432            | Ok(path) => {
433                if !skip_verify_checksum {
434                    let dowloaded_checksum = match self.clone().download_checksums() {
435                        | Ok(value) => value.get(platform).unwrap().to_string(),
436                        | Err(_) => "".to_string(),
437                    };
438                    if let Some(calculated) = checksum(path.clone()) {
439                        if !dowloaded_checksum.eq(&calculated) {
440                            error!(dowloaded_checksum, calculated, "=> {}", Label::invalid());
441                            let _cleanup = remove_file(path);
442                            std::process::exit(exitcode::USAGE);
443                        } else {
444                            info!(checksum = dowloaded_checksum, "=> {} Checksum verification", Label::pass());
445                        }
446                    };
447                } else {
448                    warn!("=> {} Checksum verification", Label::skip());
449                }
450                let destination = match config.clone() {
451                    | Some(value) => value.path.parent().unwrap().to_path_buf(),
452                    | None => PathBuf::from("./.vale/"),
453                };
454                let binary = self.clone().extract(path.clone(), Some(destination));
455                if make_executable(&binary) {
456                    let _cleanup = remove_file(path);
457                    Some(binary)
458                } else {
459                    error!("=> {} {} not executable", Label::fail(), self.command());
460                    None
461                }
462            }
463            | Err(error) => {
464                error!(error, url, "=> {} {} download", Label::fail(), self.command());
465                None
466            }
467        };
468        let builder = Vale::init().version(release).maybe_binary(binary);
469        match config {
470            | Some(value) => builder.config(value).build(),
471            | None => {
472                let config = ValeConfig::default();
473                builder.config(config).build()
474            }
475        }
476    }
477    fn download_checksums(self) -> Result<HashMap<String, String>, String> {
478        let release = match self.version {
479            | Some(value) => value,
480            | None => SemanticVersion::from_string(VALE_VERSION),
481        };
482        let url = format!(
483            "{VALE_RELEASES_URL}/download/v{release}/{}_{release}_checksums.txt",
484            self.clone().command()
485        );
486        let client = reqwest::blocking::Client::new();
487        let response = client.get(url).send().unwrap();
488        let content = response.text().unwrap();
489        let checksums = content.lines().clone().fold(HashMap::new(), |mut acc: HashMap<String, String>, line| {
490            let mut values = line.split("  ").collect::<Vec<&str>>();
491            let key = values.pop().unwrap()["vale_#.#.#_".len()..].to_string();
492            let value = values.pop().unwrap().to_string();
493            acc.insert(key, value);
494            acc
495        });
496        debug!(
497            "=> {} {} checksums {:#?}",
498            Label::using(),
499            self.command().to_case(Case::Title),
500            checksums.dimmed().cyan()
501        );
502        Ok(checksums)
503    }
504    fn extract(self, path: PathBuf, destination: Option<PathBuf>) -> PathBuf {
505        let command = self.clone().command();
506        let parent = match destination {
507            | Some(value) => to_absolute_string(value),
508            | None => format!("./.{command}/"),
509        };
510        match extension(&path).as_str() {
511            | "zip" => match extract_zip(path, Some(parent.into())) {
512                | Ok(value) => value.join(command),
513                | Err(why) => {
514                    error!("=> {} {command} extract - {why}", Label::fail());
515                    std::process::exit(exitcode::UNAVAILABLE);
516                }
517            },
518            | "gz" => {
519                let tar_gz = File::open(path).unwrap();
520                let tar = GzDecoder::new(tar_gz);
521                let mut archive = Archive::new(tar);
522                let message = format!("Unable to extract {command} binary");
523                archive.unpack(parent.clone()).expect(&message);
524                debug!(parent, "=> {} Extracted {command} binary", Label::using());
525                PathBuf::from(format!("{parent}/{command}"))
526            }
527            | _ => {
528                error!("=> {} {command} extract - Unsupported format", Label::fail());
529                std::process::exit(exitcode::UNAVAILABLE);
530            }
531        }
532    }
533    fn sync(self, is_offline: bool) -> Result<(), std::io::Error> {
534        let path = match self.binary {
535            | Some(value) => value,
536            | None => {
537                error!("=> {} {} binary", Label::not_found(), self.command());
538                std::process::exit(exitcode::UNAVAILABLE);
539            }
540        };
541        let config_path = self.config.unwrap().path;
542        let result = if is_offline {
543            todo!("Support pointing to local vale package files");
544        } else {
545            cmd!(path.clone(), "--config", config_path.clone(), "sync").run()
546        };
547        match result {
548            | Ok(_) => {
549                let parent = format!("{}/styles/config/vocabularies/{}", config_path.parent().unwrap().display(), APPLICATION);
550                debug!(parent, "=> {} Vocabularies", Label::using());
551                match create_dir_all(parent.clone()) {
552                    | Ok(_) => {}
553                    | Err(why) => error!(directory = parent, "=> {} Create - {why}", Label::fail()),
554                }
555                match File::create(format!("{parent}/accept.txt")) {
556                    | Ok(mut file) => {
557                        // TODO: Concatenate organization alternative names to accept file
558                        let acronyms = Constant::last_values("acronyms");
559                        let partners = Constant::last_values("partners");
560                        let sponsors = Constant::last_values("sponsors");
561                        let words = Constant::read_lines("accept.txt");
562                        let content = acronyms.chain(partners).chain(sponsors).chain(words).collect::<Vec<String>>().join("\n");
563                        file.write_all(content.as_bytes()).expect("Unable to write to accept.txt");
564                    }
565                    | Err(why) => panic!("=> {} Create accept.txt - {}", Label::fail(), why),
566                }
567                match File::create(format!("{parent}/reject.txt")) {
568                    | Ok(mut file) => {
569                        let content = Constant::read_lines("reject.txt").join("\n");
570                        file.write_all(content.as_bytes()).expect("Unable to write to reject.txt");
571                    }
572                    | Err(why) => panic!("=> {} Create reject.txt - {}", Label::fail(), why),
573                }
574                Ok(())
575            }
576            | Err(why) => {
577                error!(config = to_absolute_string(config_path), "=> {} Vale sync - {}", Label::fail(), why);
578                std::process::exit(exitcode::SOFTWARE);
579            }
580        }
581    }
582    fn with_binary<P>(mut self, path: P) -> Self
583    where
584        P: Into<PathBuf>,
585    {
586        self.binary = Some(path.into());
587        self
588    }
589    fn with_config(mut self, value: ValeConfig) -> Self {
590        self.config = Some(value);
591        self
592    }
593    fn with_system_command(mut self) -> Self {
594        let name = self.clone().command();
595        if command_exists(name.clone()) {
596            let path = which(name.clone()).unwrap().to_path_buf();
597            self.binary = Some(path.clone());
598            let offset = "vale version ".len();
599            let version = cmd!(name.clone(), "--version").read().unwrap()[offset..].to_string();
600            self.version = Some(SemanticVersion::from_string(&version));
601            debug!(
602                path = to_absolute_string(path),
603                "=> {} System {} (v{}) command",
604                Label::using(),
605                name.green().bold(),
606                version
607            );
608        }
609        self
610    }
611    fn with_version(mut self, value: String) -> Self {
612        self.version = Some(SemanticVersion::from_string(&value));
613        self
614    }
615}
616impl StaticAnalyzerConfig for ValeConfig {
617    fn default() -> Self {
618        let config = ValeConfig::init()
619            .packages(to_string(ENABLED_VALE_PACKAGES.to_vec()))
620            .vocabularies(to_string(vec![&ORGANIZATION.to_uppercase(), APPLICATION]))
621            .disabled(to_string(DISABLED_VALE_RULES.to_vec()))
622            .build();
623        trace!("=> {} Default - {:#?}", Label::using(), config.dimmed().cyan());
624        config
625    }
626    fn ini(self) -> Ini {
627        let ValeConfig {
628            packages,
629            vocabularies,
630            disabled,
631            ..
632        } = self;
633        let mut conf = Ini::new();
634        let package_repository = Repository::GitLab {
635            id: None,
636            location: Location::Simple("https://code.ornl.gov/research-enablement/vale-package".to_string()),
637        };
638        let package_url = match package_repository.latest_release() {
639            | Some(release) => {
640                let tag = release.tag_name;
641                format!("https://code.ornl.gov/research-enablement/vale-package/-/archive/{tag}/vale-package-{tag}.zip")
642            }
643            | None => DEFAULT_VALE_PACKAGE_URL.to_string(),
644        };
645        // CAUTION: Order of attributes in INI file matter. "StylesPath" must come before "Vocab"
646        conf.with_section::<String>(None)
647            .set("StylesPath", "styles")
648            .set("Vocab", vocabularies.join(", "))
649            .set("Packages", format!("{}, {}", packages.join(", "), package_url));
650        conf.with_section(Some("*"))
651            .set("BasedOnStyles", format!("Vale, {}, {}", CUSTOM_VALE_PACKAGE_NAME, packages.join(", ")));
652        disabled.iter().for_each(|rule| {
653            conf.with_section(Some("*")).set(rule, "NO");
654        });
655        conf
656    }
657    fn save(self) -> ValeConfig {
658        let path = self.clone().path;
659        let parent = path.parent().unwrap().to_path_buf();
660        match create_dir_all(parent.clone()) {
661            | Ok(_) => {}
662            | Err(why) => error!(directory = parent.to_absolute_string(), "=> {} Create - {why}", Label::fail()),
663        }
664        match self.clone().ini().write_to_file(path.clone()) {
665            | Ok(_) => {
666                debug!(path = to_absolute_string(path), "=> {} Saved configuration", Label::using());
667            }
668            | Err(why) => {
669                error!("=> {} Save configuration - {why}", Label::fail());
670                std::process::exit(exitcode::SOFTWARE);
671            }
672        }
673        self
674    }
675    fn with_path(mut self, path: PathBuf) -> Self {
676        self.path = path;
677        self
678    }
679}
680/// Convert Lychee response to [`Check`]
681pub fn convert_lychee_response(value: Response) -> Check {
682    match value.status() {
683        | Status::Ok(code) | Status::Redirected(code) => Check::init()
684            .category(CheckCategory::Link)
685            .success(true)
686            .status_code(code.to_string())
687            .message("has no HTTP errors".to_string())
688            .build(),
689        | Status::Cached(status) => match status {
690            | CacheStatus::Ok(code) => Check::init()
691                .category(CheckCategory::Link)
692                .success(true)
693                .status_code(code.to_string())
694                .message("has no HTTP errors".to_string())
695                .build(),
696            | CacheStatus::Error(Some(code)) => Check::init()
697                .category(CheckCategory::Link)
698                .success(false)
699                .status_code(code.to_string())
700                .message("has cached HTTP errors".to_string())
701                .build(),
702            | CacheStatus::Unsupported => Check::init()
703                .category(CheckCategory::Link)
704                .success(false)
705                .message("unsupported cached response".to_string())
706                .build(),
707            | _ => Check::init()
708                .category(CheckCategory::Link)
709                .success(true)
710                .message("ignored or otherwise successful (cached response)".to_string())
711                .build(),
712        },
713        | Status::Error(code) => Check::init()
714            .category(CheckCategory::Link)
715            .success(false)
716            .status_code(code.to_string())
717            .message("has HTTP errors".to_string())
718            .build(),
719        | Status::Unsupported(why) => Check::init()
720            .category(CheckCategory::Link)
721            .success(false)
722            .message(format!("unsupported HTTP response - {why}"))
723            .build(),
724        | Status::UnknownStatusCode(code) => Check::init()
725            .category(CheckCategory::Link)
726            .success(false)
727            .status_code(code.to_string())
728            .message("unknown HTTP response".to_string())
729            .build(),
730        | Status::Timeout(_) => Check::init()
731            .category(CheckCategory::Link)
732            .success(false)
733            .message("HTTP timeout".to_string())
734            .build(),
735        | _ => Check::init()
736            .category(CheckCategory::Link)
737            .success(true)
738            .message("ignored or otherwise successful".to_string())
739            .build(),
740    }
741}
742/// Perform link check on given URL using Lychee
743pub async fn link_check(uri: Option<String>) -> Check {
744    match uri {
745        | Some(value) => {
746            let result = lychee_lib::check(value.as_str()).await;
747            match result {
748                | Ok(response) => convert_lychee_response(response).with_uri(value),
749                | Err(_) => Check::init()
750                    .category(CheckCategory::Link)
751                    .success(false)
752                    .uri(value)
753                    .message("unreachable".to_string())
754                    .build(),
755            }
756        }
757        | None => Check::init()
758            .category(CheckCategory::Link)
759            .success(false)
760            .message("missing URL".to_string())
761            .build(),
762    }
763}
764/// Convert vector of [`Check`] values to a Polars [DataFrame]
765pub fn checks_to_dataframe(values: Vec<Check>) -> PolarsResult<DataFrame> {
766    let names = ["success", "category", "message", "uri", "status_code", "context"];
767    to_dataframe::<Check, _, &str>(values, names)
768}
769/// Prints `text` to stdout using syntax highlighting for the specified `syntax`.
770///
771/// `highlight` is an iterator of line numbers to highlight in the output.
772pub fn pretty_print<I: IntoIterator<Item = usize>>(text: &str, syntax: ProgrammingLanguage, highlight: I) {
773    let input = format!("{text}\n");
774    let language = syntax.to_string();
775    let mut printer = PrettyPrinter::new();
776    printer
777        .input_from_bytes(input.as_bytes())
778        .theme("zenburn")
779        .language(&language)
780        .line_numbers(true);
781    for line in highlight {
782        printer.highlight(line);
783    }
784    printer.print().unwrap();
785}
786/// Create summary data table from given issues
787pub fn summary(issues: Vec<Check>) -> Vec<Vec<String>> {
788    [
789        CheckCategory::Schema,
790        CheckCategory::Link,
791        CheckCategory::Prose,
792        CheckCategory::Readability,
793    ]
794    .iter()
795    .map(|category| {
796        let count = issues
797            .iter()
798            .filter(|issue| issue.category == *category)
799            .map(|issue| issue.issue_count())
800            .sum::<usize>()
801            .to_string();
802        to_string(vec![&category.to_string(), &count])
803    })
804    .collect::<Vec<_>>()
805}
806/// Convert vector of values of a given type to a Polars [DataFrame]
807/// ### Example
808/// ```ignore
809/// let df = to_dataframe::<i32, _, str>(vec![1, 2, 3], ["a", "b", "c"]);
810/// ```
811///
812/// [DataFrame]: https://docs.rs/polars/latest/polars/prelude/struct.DataFrame.html
813pub fn to_dataframe<'a, T, I, H>(values: Vec<T>, names: I) -> PolarsResult<DataFrame>
814where
815    T: IntoRow<'a>,
816    H: Into<PlSmallStr>,
817    I: IntoIterator<Item = H>,
818{
819    let rows = values.into_iter().map(|value| value.to_row::<T>()).collect::<Vec<_>>();
820    match DataFrame::from_rows(&rows) {
821        | Ok(mut df) => match df.set_column_names(names) {
822            | Ok(_) => Ok(df),
823            | Err(why) => Err(why),
824        },
825        | Err(why) => Err(why),
826    }
827}
828
829#[cfg(test)]
830mod tests;