use crate::command::run_command_line;
use crate::INDENT;
use anyhow::{bail, Result};
use colored::Colorize;
use different::{line_diff, Diff, DiffSettings};
use log::debug;
use minijinja::Environment;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr};
use std::collections::HashMap;
use std::fmt::Display;
use std::path::{Path, PathBuf};
use std::time::Duration;
use std::{env, fs};
fn default_exit_code() -> i32 {
exitcode::OK
}
fn default_http_status() -> StatusCode {
StatusCode::OK
}
fn str_compare<'a>(
expected: &'a str,
actual: &'a str,
diff_settings: &'a DiffSettings,
) -> Option<String> {
let expected = expected.trim();
let actual = actual.trim();
let diff = line_diff(expected, actual, diff_settings);
match diff {
Diff::Same => None,
Diff::Diff { .. } => Some(diff.to_string()),
}
}
fn paths_to_string(input: &[PathBuf]) -> String {
let input: Vec<String> = input.iter().map(|p| p.display().to_string()).collect();
input.join("\n")
}
fn dir_compare(
expected: &[PathBuf],
actual: &[PathBuf],
diff_settings: &DiffSettings,
) -> Option<String> {
let expected = paths_to_string(expected);
let actual = paths_to_string(actual);
str_compare(&expected, &actual, diff_settings).map(|x| x.to_string())
}
#[derive(
Debug, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, derive_more::Display, Hash,
)]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Head,
Deleate,
Connect,
Options,
Trace,
Patch,
}
pub trait CheckTrait {
fn do_check(
&self,
diff_settings: &DiffSettings,
env: &Environment,
this_file_path: &Path,
vars: &HashMap<String, String>,
) -> Result<Status>;
fn describe(&self) -> String;
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct FileCheck {
path: PathBuf,
contents: Option<String>,
#[serde(default)]
contains: Vec<String>,
template: Option<PathBuf>,
}
impl FileCheck {
pub fn path(&self) -> &Path {
&self.path
}
}
impl CheckTrait for FileCheck {
fn describe(&self) -> String {
let mut s = format!("File {}: must exist", self.path.display());
if !self.contains.is_empty() {
s.push_str(&format!(", must contain {:?}", self.contains));
}
if let Some(contents) = &self.contents {
s.push_str(&format!(", contents must exactly match {:?}", contents));
}
s
}
fn do_check(
&self,
diff_settings: &DiffSettings,
env: &Environment,
this_file_path: &Path,
vars: &HashMap<String, String>,
) -> Result<Status> {
if !self.path.is_file() {
return Ok(Status::fail(
String::from("Path is not a valid file"),
Some(self.path.display().to_string()),
));
}
let actual_contents = fs::read_to_string(&self.path)?;
if let Some(expected_contents) = &self.contents {
if let Some(diff) = str_compare(expected_contents, &actual_contents, diff_settings) {
return Ok(Status::fail(
"Contents differ".to_string(),
Some(diff.to_string()),
));
}
}
if !self.contains.is_empty() {
for expected_fragment in &self.contains {
if !actual_contents.contains(expected_fragment) {
return Ok(Status::fail(
String::from("Expected fragment not found in file"),
Some(format!("{}\n{expected_fragment}", self.path.display())),
));
}
}
}
if let Some(template) = &self.template {
let template = if template.is_relative() {
let base = this_file_path.parent().unwrap();
base.join(template)
} else {
template.to_owned()
};
let template_name = &template.display().to_string();
let templ = env.get_template(template_name)?;
debug!(
"Checking '{}' against template '{}'",
self.path.display(),
template_name
);
let expected = templ.render(vars)?; if let Some(diff) = str_compare(&expected, &actual_contents, diff_settings) {
return Ok(Status::fail(
String::from("Populated template does not match file"),
Some(diff.to_string()),
));
}
}
Ok(Status::new(false, StatusStatus::Pass))
}
}
fn dir_contents(dir: &Path) -> Result<Vec<PathBuf>> {
let mut dirs = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
dirs.push(path);
}
Ok(dirs)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct DirectoryCheck {
path: PathBuf,
#[serde(default)]
contents: Vec<String>,
#[serde(default)]
contains: Vec<String>,
}
impl CheckTrait for DirectoryCheck {
fn describe(&self) -> String {
let mut s = format!("Directory {}: must exist", &self.path.display());
if !self.contains.is_empty() {
s.push_str(&format!(", must contain {:?}", self.contains));
}
if !self.contents.is_empty() {
s.push_str(&format!(
", contents must exactly match {:?}",
self.contents
));
}
s
}
fn do_check(
&self,
diff_settings: &DiffSettings,
env: &Environment,
this_file_path: &Path,
vars: &HashMap<String, String>,
) -> Result<Status> {
if !self.path.is_dir() {
return Ok(Status::fail(
String::from("Path is not a valid directory"),
Some(self.path.display().to_string()),
));
}
let actual_contents = dir_contents(&self.path)?;
if !self.contents.is_empty() {
let expected_contents: Vec<PathBuf> = self
.contents
.iter()
.map(|name| self.path.join(name))
.collect();
if let Some(diff) = dir_compare(&expected_contents, &actual_contents, diff_settings) {
return Ok(Status::fail(
String::from("Contents differ"),
Some(diff.to_string()),
));
}
}
if !self.contains.is_empty() {
for name in &self.contains {
let expected_path = self.path.join(name);
if !actual_contents.contains(&expected_path) {
return Ok(Status::fail(
String::from("Expected entry not found in directory"),
Some(format!(
"dir: {}, path: {}",
self.path.display(),
expected_path.display()
)),
));
}
}
}
Ok(Status::new(false, StatusStatus::Pass))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct CommandCheck {
cmd: String,
#[serde(default = "default_exit_code")]
code: i32,
expected_stdout: Option<String>,
expected_stderr: Option<String>,
#[serde(default)]
stdout_contains: Vec<String>,
#[serde(default)]
stderr_contains: Vec<String>,
}
impl CheckTrait for CommandCheck {
fn describe(&self) -> String {
todo!();
}
fn do_check(
&self,
diff_settings: &DiffSettings,
env: &Environment,
this_file_path: &Path,
vars: &HashMap<String, String>,
) -> Result<Status> {
todo!();
}
}
#[serde_as]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
pub struct HttpCheck {
method: HttpMethod,
#[serde(default = "default_http_status")]
#[serde_as(as = "DisplayFromStr")]
code: StatusCode,
url: String,
#[serde(default)]
body_contains: Vec<String>,
expected_body: Option<String>,
}
impl CheckTrait for HttpCheck {
fn describe(&self) -> String {
let mut s = format!(
"Http {} request to {} must return {}",
self.method, self.url, self.code
);
if let Some(expected_body) = &self.expected_body {
s.push_str(&format!(", body must match '{expected_body}'"));
}
if !self.body_contains.is_empty() {
s.push_str(&format!(", body must contain {:?}", self.body_contains));
}
s
}
fn do_check(
&self,
diff_settings: &DiffSettings,
env: &Environment,
this_file_path: &Path,
vars: &HashMap<String, String>,
) -> Result<Status> {
todo!();
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct VarCheck {
key: String,
value: Option<String>,
}
impl CheckTrait for VarCheck {
fn describe(&self) -> String {
let mut s = format!("Var {} ", self.key);
if let Some(value) = &self.value {
s.push_str(&format!(" must be set to {value}"));
} else {
s.push_str("must be set");
}
s
}
fn do_check(
&self,
diff_settings: &DiffSettings,
env: &Environment,
this_file_path: &Path,
vars: &HashMap<String, String>,
) -> Result<Status> {
todo!();
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum CheckType {
File(FileCheck),
Directory(DirectoryCheck),
Command(CommandCheck),
Http(HttpCheck),
VarSet(VarCheck),
}
impl CheckType {
fn describe(&self) -> String {
match self {
Self::File(f) => f.describe(),
Self::Directory(d) => d.describe(),
Self::Command(c) => c.describe(),
Self::Http(h) => h.describe(),
Self::VarSet(v) => v.describe(),
}
}
fn do_check(
&self,
diff_settings: &DiffSettings,
env: &Environment,
this_file_path: &Path,
vars: &HashMap<String, String>,
) -> Result<Status> {
match self {
Self::File(f) => f.do_check(diff_settings, env, this_file_path, vars),
Self::Directory(d) => d.do_check(diff_settings, env, this_file_path, vars),
Self::Command(c) => c.do_check(diff_settings, env, this_file_path, vars),
Self::Http(h) => h.do_check(diff_settings, env, this_file_path, vars),
Self::VarSet(v) => v.do_check(diff_settings, env, this_file_path, vars),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
struct Condition {
description: Option<String>,
#[serde(flatten)]
condition: CheckType,
}
impl CheckTrait for Condition {
fn do_check(
&self,
diff_settings: &DiffSettings,
env: &Environment,
this_file_path: &Path,
vars: &HashMap<String, String>,
) -> Result<Status> {
self.condition
.do_check(diff_settings, env, this_file_path, vars)
}
fn describe(&self) -> String {
todo!();
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
pub struct Check {
#[serde(flatten)]
check: CheckType,
description: Option<String>,
#[serde(default)]
conditions: Vec<Condition>,
#[serde(default)]
requirements: Vec<Requirement>,
}
impl Check {
pub fn description(&self) -> String {
if let Some(description) = &self.description {
return description.clone();
}
self.check.describe()
}
pub fn do_check(
&self,
diff_settings: &DiffSettings,
env: &Environment,
this_file_path: &Path,
vars: &HashMap<String, String>,
) -> Result<Status> {
for condition in &self.conditions {
let status = condition.do_check(diff_settings, env, this_file_path, vars)?;
if status.is_skipped() {
return Ok(status);
}
}
for requirement in &self.requirements {
let status = requirement.do_check(diff_settings, env, this_file_path, vars)?;
if status.is_failure() {
return Ok(status);
}
}
self.check
.do_check(diff_settings, env, this_file_path, vars)
}
pub fn ttype(&self) -> &CheckType {
&self.check
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum FactValue {
#[serde(rename = "eval-command")]
Command { command: String },
#[serde(rename = "literal")]
Literal { value: String },
#[serde(rename = "env-var")]
Env { key: String },
}
impl FactValue {
fn value(&self, vars: &HashMap<String, String>) -> Result<String> {
let value = match self {
Self::Command { command } => {
let output = run_command_line(command, Some(vars))?;
let Some(stdout) = output.stdout() else {
bail!("Command produced empty output");
};
stdout.clone()
}
Self::Literal { value } => value.to_string(),
Self::Env { key } => {
let Ok(value) = env::var(key) else {
bail!("Env var '{key}' not set");
};
value
}
};
Ok(value)
}
}
#[derive(Debug, Deserialize)]
pub struct Fact {
key: String,
#[serde(flatten)]
value: FactValue,
#[serde(default, rename = "requires")]
requirements: Vec<Requirement>,
}
impl Fact {
pub fn key(&self) -> String {
self.key.clone()
}
pub fn value(&self, vars: &HashMap<String, String>) -> Result<String> {
self.value.value(vars)
}
pub fn requirements(&self) -> &[Requirement] {
&self.requirements
}
}
#[derive(Debug, Deserialize, PartialEq, Eq, Hash, Clone, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Requirement {
Command { command: String },
Env { key: String },
}
impl CheckTrait for Requirement {
fn do_check(
&self,
diff_settings: &DiffSettings,
env: &Environment,
this_file_path: &Path,
vars: &HashMap<String, String>,
) -> Result<Status> {
let status = match self {
Self::Command { command } => match which::which(command) {
Ok(_) => Status::new(false, StatusStatus::Pass),
Err(_) => Status::new(
false,
StatusStatus::Fail {
reason: Reason {
main: format!("Command not found '{command}'"),
secondary: Some(format!(
"Required for a check in {}",
this_file_path.display()
)),
},
},
),
},
Self::Env { key } => match env::var(key) {
Ok(_) => Status::new(false, StatusStatus::Pass),
Err(_) => Status::new(
false,
StatusStatus::Fail {
reason: Reason {
main: format!("Env var '{key}' not set"),
secondary: Some(format!(
"Required for a check in {}",
this_file_path.display()
)),
},
},
),
},
};
Ok(status)
}
fn describe(&self) -> String {
todo!();
}
}
#[derive(Debug, Deserialize)]
struct ChecklistFileContents {
#[serde(rename = "fact", default)]
facts: Vec<Fact>,
#[serde(rename = "condition", default)]
conditions: Vec<Condition>,
#[serde(rename = "check", default)]
checks: Vec<Check>,
#[serde(rename = "requires", default)]
requirements: Vec<Requirement>,
}
#[derive(Debug)]
pub struct Checklist {
path: PathBuf,
checks: ChecklistFileContents,
}
impl Checklist {
pub fn from_path(path: PathBuf) -> Result<Self> {
let contents = fs::read_to_string(&path)?;
let checks: ChecklistFileContents = toml::from_str(&contents)?;
Ok(Self { checks, path })
}
pub fn checks(&self) -> &[Check] {
&self.checks.checks
}
pub fn name(&self) -> Result<String> {
let Some(name) = self.path.as_os_str().to_str() else {
bail!("Unable to get name from path {}", self.path.display());
};
Ok(name.to_string())
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn templates(&self) -> Vec<PathBuf> {
let mut templates = Vec::new();
let checklist_path = &self.path;
for check in &self.checks.checks {
match &check.check {
CheckType::File(f) => {
if let Some(name) = &f.template {
let template = rel_to(checklist_path.parent().unwrap(), name);
debug!("found template {}", template.display());
templates.push(template);
}
}
CheckType::Directory(d) => {
}
CheckType::Command(c) => {
}
CheckType::Http(h) => {
}
CheckType::VarSet(v) => {
}
}
}
templates
}
pub fn facts(&self) -> &[Fact] {
&self.checks.facts
}
}
fn rel_to(a: &Path, b: &Path) -> PathBuf {
a.join(b)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Reason {
main: String,
secondary: Option<String>,
}
impl Reason {
pub fn new(main: String, secondary: Option<String>) -> Self {
Self { main, secondary }
}
pub fn main(&self) -> &str {
&self.main
}
pub fn secondary(&self) -> Option<&String> {
self.secondary.as_ref()
}
}
impl Display for Reason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = self.main.to_string();
if let Some(secondary) = &self.secondary {
s.push_str(&format!(": {secondary}"));
};
write!(f, "{s}")
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum StatusStatus {
Pass,
Skip { reason: Reason },
Fail { reason: Reason },
}
impl StatusStatus {
pub fn is_skipped(&self) -> bool {
match self {
StatusStatus::Skip { .. } => true,
_ => false,
}
}
pub fn is_success(&self) -> bool {
match self {
StatusStatus::Pass => true,
_ => false,
}
}
pub fn is_failure(&self) -> bool {
match self {
StatusStatus::Fail { .. } => true,
_ => false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Status {
status: StatusStatus,
cached: bool,
}
impl Status {
pub fn new(cached: bool, status: StatusStatus) -> Self {
Self { cached, status }
}
pub fn fail(main: String, secondary: Option<String>) -> Self {
Self::new(
false,
StatusStatus::Fail {
reason: Reason::new(main, secondary),
},
)
}
pub fn mark_as_cached(&mut self) {
self.cached = true;
}
pub fn is_cached(&self) -> bool {
self.cached
}
pub fn is_skipped(&self) -> bool {
self.status.is_skipped()
}
pub fn is_success(&self) -> bool {
self.status.is_success()
}
pub fn is_failure(&self) -> bool {
self.status.is_failure()
}
pub fn status(&self) -> &StatusStatus {
&self.status
}
}
impl Display for Status {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self.status() {
StatusStatus::Pass => "Pass",
StatusStatus::Skip { reason } => &format!("Skipped ({reason})"),
StatusStatus::Fail { reason } => &format!("Failed ({reason})"),
};
write!(f, "{s}")
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Statuses {
map: HashMap<PathBuf, HashMap<String, Status>>,
}
impl Default for Statuses {
fn default() -> Self {
Self::new()
}
}
impl Statuses {
pub fn new() -> Self {
let map = HashMap::new();
Self { map }
}
pub fn exit_code(&self) -> i32 {
let mut code = 0;
for checklist in self.map.values() {
for status in checklist.values() {
if *status.status() != StatusStatus::Pass {
code = 1;
break;
}
}
}
code
}
pub fn insert(&mut self, checklist_path: PathBuf, job_name: String, job_status: Status) {
if !self.map.contains_key(&checklist_path) {
self.map.insert(checklist_path.clone(), HashMap::new());
}
let inner = self.map.get_mut(&checklist_path).unwrap();
inner.insert(job_name, job_status);
}
pub fn json(&self) -> Result<String> {
let json = serde_json::to_string_pretty(&self)?;
Ok(json)
}
pub fn print(&self) {
let last_index = self.map.len() - 1;
for (i, (checklist_path, checks)) in self.map.iter().enumerate() {
let checklist_name = checklist_path.file_name().unwrap().to_str().unwrap();
print_section_header(checklist_name);
for (name, status) in checks {
print_status(status, name, None); }
if i < last_index {
println!();
}
}
}
}
fn format_duration(d: Duration) -> String {
let truncated = Duration::from_millis(d.as_millis() as u64);
if truncated == Duration::ZERO {
"< 1ms".to_string()
} else {
humantime::format_duration(truncated).to_string()
}
}
fn print_section_header(name: &str) {
println!("> Checklist '{}'", name.cyan());
}
fn print_status(status: &Status, desc: &str, duration: Option<Duration>) {
let (status_str, reason) = match status.status() {
StatusStatus::Skip { reason } => ("SKIP".yellow(), Some(reason)),
StatusStatus::Pass => ("PASS".green(), None),
StatusStatus::Fail { reason } => ("FAIL".red(), Some(reason)),
};
let cached = if status.is_cached() { " (cached)" } else { "" };
let duration = if let Some(duration) = duration {
&format!(" - took {}", format_duration(duration))
} else {
""
};
println!(
"{INDENT}[{}] {desc}{}{}",
status_str.bold(),
cached.dimmed(),
duration.dimmed()
);
if let Some(reason) = reason {
let subindent = format!("{INDENT} ");
print!("{INDENT}{subindent}- {}", reason.main().purple());
if let Some(secondary) = reason.secondary() {
println!(":\n{secondary}");
} else {
println!();
}
}
}
pub use remote_checklist::RemoteFile;
mod remote_checklist {
use serde::Deserialize;
use serde::Serialize;
use std::fmt::Display;
use std::str::FromStr;
use winnow::ascii::dec_uint;
use winnow::combinator::alt;
use winnow::combinator::opt;
use winnow::combinator::seq;
use winnow::prelude::*;
use winnow::token::rest;
use winnow::token::take_till;
use winnow::token::take_until;
use winnow::Result;
fn last_component_of(s: &str) -> String {
s.split("/").last().unwrap().to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Url {
scheme: String,
host: String,
port: Option<u32>,
path: Option<String>,
fragment: Option<String>,
}
impl Url {
pub fn name(&self) -> String {
match &self.path {
Some(path) => last_component_of(path),
None => {
self.host.clone()
}
}
}
}
impl Display for Url {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let port = match &self.port {
Some(port) => format!(":{port}"),
None => String::new(),
};
let path = match &self.path {
Some(path) => path.clone(),
None => String::new(),
};
let fragment = match &self.fragment {
Some(fragment) => format!("#{fragment}"),
None => String::new(),
};
write!(f, "{}://{}{port}{path}{fragment}", self.scheme, self.host)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteFile {
url: Url,
hash: Option<String>,
}
impl RemoteFile {
pub fn url(&self) -> &Url {
&self.url
}
pub fn hash(&self) -> Option<&String> {
self.hash.as_ref()
}
}
fn scheme(s: &mut &str) -> Result<String> {
take_until(1.., "://")
.map(|s: &str| s.to_string())
.parse_next(s)
}
fn host(s: &mut &str) -> Result<String> {
alt((take_till(1.., |c: char| c == ':' || c == '/'), rest))
.map(|s: &str| s.to_string())
.parse_next(s)
}
fn port(s: &mut &str) -> Result<u32> {
let _ = ":".parse_next(s)?;
dec_uint.parse_next(s)
}
fn fragment(s: &mut &str) -> Result<String> {
let _ = "#".parse_next(s)?;
take_until(1.., "::")
.map(|s: &str| s.to_string())
.parse_next(s)
}
fn url(s: &mut &str) -> Result<Url> {
seq! {Url {
scheme: scheme,
_: "://",
host: host,
port: opt(port),
path: opt(path),
fragment: opt(fragment)
}}
.parse_next(s)
}
fn path(s: &mut &str) -> Result<String> {
alt((take_till(0.., |c: char| c == '?' || c == '#'), rest))
.map(|s: &str| s.to_string())
.parse_next(s)
}
fn hash(s: &mut &str) -> Result<String> {
let _ = "::".parse_next(s)?;
rest.map(|s: &str| s.to_string()).parse_next(s)
}
fn remote_checklist(s: &mut &str) -> Result<RemoteFile> {
let url = url.parse_next(s)?;
let hash = opt(hash).parse_next(s)?;
Ok(RemoteFile { url, hash })
}
use winnow_parse_error::ParseError;
impl FromStr for RemoteFile {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
remote_checklist
.parse(s)
.map_err(|e| ParseError::from_parse(e))
}
}
}