use crate::steps::{BUILTIN_STEPS, StepTarget};
use crate::{config, db, dirs, feature, http, unique, validate};
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Ok,
Failed,
Skipped,
}
#[derive(Debug, Serialize)]
pub struct Check {
pub stage: &'static str,
pub target: Option<String>,
pub status: Status,
pub probe: bool,
pub detail: String,
}
#[derive(Debug, Serialize)]
pub struct Report {
pub config: String,
pub config_source: Option<config::ConfigSource>,
pub app_env: String,
pub live: bool,
pub checks: Vec<Check>,
}
impl Report {
fn push(&mut self, stage: &'static str, target: Option<&str>, status: Status, detail: &str) {
self.row(stage, target, status, false, detail);
}
fn push_live(&mut self, stage: &'static str, target: &str, status: Status, detail: &str) {
self.row(stage, Some(target), status, true, detail);
}
fn row(
&mut self,
stage: &'static str,
target: Option<&str>,
status: Status,
probe: bool,
detail: &str,
) {
self.checks.push(Check {
stage,
target: target.map(str::to_string),
status,
probe,
detail: detail.to_string(),
});
}
pub fn failed(&self) -> usize {
self.checks
.iter()
.filter(|c| c.status == Status::Failed)
.count()
}
pub fn exit_code(&self) -> i32 {
if self.failed() == 0 { 0 } else { 1 }
}
pub fn render(&self) -> String {
let mut out = format!("config: {}\nAPP_ENV: {}\n\n", self.config, self.app_env);
for c in &self.checks {
let mark = match c.status {
Status::Ok => "✓",
Status::Failed => "✗",
Status::Skipped => "-",
};
let label = match &c.target {
Some(target) => format!("{} {target}", c.stage),
None => c.stage.to_string(),
};
if c.detail.contains('\n') {
out.push_str(&format!(" {mark} {label}\n"));
for line in c.detail.lines() {
out.push_str(&format!(" {line}\n"));
}
} else {
out.push_str(&format!(" {mark} {label:<20} {}\n", c.detail));
}
}
let failed = self.failed();
out.push_str(&if failed == 0 {
"\nno problems found\n".to_string()
} else {
format!("\n{failed} problem(s)\n")
});
if !self.live {
out.push_str("static checks only — pass --live to also probe the resources\n");
}
out
}
}
pub async fn check(
explicit_config: Option<&Path>,
env: Option<&str>,
live: bool,
dir_env: &dirs::Env,
report_paths: &[&Path],
) -> Report {
let resolved = config::resolve_config_path(explicit_config);
let mut report = Report {
config: "(none)".to_string(),
config_source: None,
app_env: "unknown".to_string(),
live,
checks: Vec::new(),
};
if let Ok(Some(resolved)) = &resolved {
report.config = format!("{} ({})", resolved.path.display(), resolved.source.label());
report.config_source = Some(resolved.source);
report.app_env =
config::app_env_for(&resolved.path, env).unwrap_or_else(|_| "unknown".to_string());
}
for path in report_paths {
let target = path.display().to_string();
match crate::report::prepare(path) {
Ok(()) => report.push("reports", Some(&target), Status::Ok, "created, empty"),
Err(error) => report.push(
"reports",
Some(&target),
Status::Failed,
&format!("{error:#}"),
),
}
}
let config_path = match resolved {
Ok(Some(resolved)) => resolved.path,
Ok(None) => {
report.push("config", None, Status::Failed, config::NO_CONFIG_FOUND);
return report;
}
Err(error) => {
report.push("config", None, Status::Failed, &format!("{error:#}"));
return report;
}
};
let config_path = config_path.as_path();
let cfg = match config::load(config_path, env) {
Ok(cfg) => {
report.push(
"config",
None,
Status::Ok,
"parsed, ${VAR} expanded, defaults resolved",
);
cfg
}
Err(error) => {
report.push("config", None, Status::Failed, &format!("{error:#}"));
return report;
}
};
match dirs::layers(dirs::Os::current(), dir_env, crate::config_dir(config_path)) {
Ok(layers) => {
let candidates = dirs::candidates(&layers, "plugins");
for c in &candidates {
let status = if c.path.is_file() {
Status::Ok
} else {
Status::Skipped
};
report.push(
"directories",
Some(&c.layer),
status,
&c.path.display().to_string(),
);
}
if let Ok(entries) = crate::plugin::lock::load(&candidates) {
for entry in &entries {
let status = if entry.path.is_file() {
Status::Ok
} else {
Status::Failed
};
let detail = format!("{} — {}", entry.layer, entry.path.display());
report.push("plugin_lock", Some(&entry.name), status, &detail);
}
}
}
Err(error) => {
report.push("directories", None, Status::Failed, &format!("{error:#}"));
}
}
let generator = unique::Generator::new();
let mut plugins_failed = false;
let plugins = match crate::load_plugins(config_path, &cfg, &generator, dir_env) {
Ok(Some(plugins)) => {
let detail = format!(
"{} step(s) over {} group(s)",
plugins.steps().len(),
plugins.group_names().len()
);
report.push("plugins", None, Status::Ok, &detail);
Some(plugins)
}
Ok(None) => {
report.push("plugins", None, Status::Ok, "none installed");
None
}
Err(error) => {
report.push("plugins", None, Status::Failed, &format!("{error:#}"));
plugins_failed = true;
None
}
};
let registry = match crate::build_registry(&cfg, plugins.as_ref()) {
Ok(registry) => {
report.push("macros", None, Status::Ok, "loaded, no conflict or cycle");
Some(registry)
}
Err(error) => {
report.push("macros", None, Status::Failed, &error);
None
}
};
let vocabulary = match (registry.as_ref(), plugins_failed) {
(Some(registry), false) => Ok(registry),
(Some(_), true) => {
Err("a plugin owning some of these steps did not load — see the plugins stage")
}
(None, _) => Err("the step registry did not build — see the macros stage"),
};
check_features(&mut report, &cfg, vocabulary, plugins.as_deref());
check_apis(&mut report, &cfg, live).await;
check_databases(&mut report, &cfg, live).await;
check_srp(&mut report, &cfg);
if let Some(plugins) = plugins.as_ref() {
check_plugin_instances(&mut report, plugins, live);
}
report
}
fn check_plugin_instances(report: &mut Report, plugins: &crate::plugin::Plugins, live: bool) {
for (group, instance) in plugins.declared_instances() {
let target = format!("{group}.{instance}");
if !live {
report.push_live("plugin", &target, Status::Skipped, "live probe skipped");
continue;
}
match plugins.probe_config(&group, &instance) {
Some(Ok(())) => report.push_live("plugin", &target, Status::Ok, "probed clean"),
Some(Err(error)) => report.push_live("plugin", &target, Status::Failed, &error),
None => report.push_live(
"plugin",
&target,
Status::Skipped,
"the plugin exports no bddkit_probe_config",
),
}
}
}
fn check_features(
report: &mut Report,
cfg: &config::Config,
registry: Result<&crate::steps::Registry, &'static str>,
plugins: Option<&crate::plugin::Plugins>,
) {
let paths = match feature::discover(&cfg.paths) {
Ok(paths) => paths,
Err(error) => {
report.push("steps", None, Status::Failed, &format!("{error:#}"));
report.push(
"scheduling",
None,
Status::Skipped,
"no feature file was discovered",
);
return;
}
};
let mut loaded = Vec::new();
let mut problems = Vec::new();
for path in paths {
match feature::load(&path) {
Ok(lf) => loaded.push(std::sync::Arc::new(lf)),
Err(error) => problems.push(format!("{}\n {error:#}", feature::display_path(&path))),
}
}
let filter = feature::TagFilter::new(&[]);
loaded.retain(|lf| lf.has_selected_scenario(&filter));
let skipped = match registry {
Ok(registry) => {
let borrowed: Vec<&feature::LoadedFeature> =
loaded.iter().map(std::sync::Arc::as_ref).collect();
let mut found = validate::check(&borrowed, registry, &filter);
found.extend(validate::unserved_resources(
&borrowed,
registry,
&filter,
|target| unserved_group(cfg, plugins, target),
));
problems.extend(found.iter().map(|p| {
format!(
"{}:{}\n {}",
feature::display_path(&p.file),
p.line,
p.message
)
}));
None
}
Err(reason) => Some(reason),
};
if !problems.is_empty() {
report.push("steps", None, Status::Failed, &problems.join("\n"));
} else if loaded.is_empty() {
report.push(
"steps",
None,
Status::Failed,
"no scenario to run: the config's `paths` select no feature file",
);
} else if let Some(reason) = skipped {
report.push("steps", None, Status::Skipped, reason);
} else {
let detail = format!("{} file(s), every step matched", loaded.len());
report.push("steps", None, Status::Ok, &detail);
}
match crate::runner::build_chains(loaded) {
Ok(chains) => {
let detail = format!("{} chain(s)", chains.len());
report.push("scheduling", None, Status::Ok, &detail);
}
Err(error) => report.push("scheduling", None, Status::Failed, &error),
}
}
fn unserved_group(
cfg: &config::Config,
plugins: Option<&crate::plugin::Plugins>,
target: &StepTarget,
) -> Option<String> {
let (group, served) = match target {
StepTarget::Builtin { id, .. } => {
let group = BUILTIN_STEPS.iter().find(|def| def.id == *id)?.group;
let served = match group {
"api" => !cfg.resources.api.is_empty(),
"db" => !cfg.resources.db.is_empty(),
"srp" => !cfg.resources.srp.is_empty(),
_ => true,
};
(group, served)
}
StepTarget::Plugin { lib, step, .. } => {
let plugins = plugins?;
let group = plugins.group_of_step(*lib, *step);
(group, plugins.has_instance(group))
}
StepTarget::Macro(_) => return None,
};
(!served).then(|| {
format!("this step needs a resource of the \"{group}\" group, and resources.{group} declares none")
})
}
pub fn check_api(api: &config::ApiConfig) -> Result<(http::ApiResource, String), String> {
let headers = api
.default_headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let resource = http::ApiResource::new(
&api.base_url,
api.timeout_secs,
headers,
api.effective_options.clone(),
)
.map_err(|error| format!("{error:#}"))?;
Ok((
resource,
format!("{} ({}s timeout)", api.base_url, api.timeout_secs),
))
}
pub async fn probe_api(resource: &http::ApiResource, base_url: &str) -> Result<String, String> {
let status = resource.probe().await?;
Ok(format!("GET {base_url} → {status}"))
}
pub async fn probe_db(name: &str, connection: &config::Connection) -> Result<String, String> {
let one = std::collections::BTreeMap::from([(name.to_string(), connection.clone())]);
let pool = db::Db::connect(&one, 1).await?;
let vendor = pool.platform(name).map(|p| p.name()).unwrap_or("?");
Ok(format!("connected, {vendor}"))
}
pub fn check_srp_resource(srp: &config::SrpConfig) -> Result<String, String> {
srp.to_params()
.map(|_| srp.variant.clone())
.map_err(|error| format!("{error:#}"))
}
async fn check_apis(report: &mut Report, cfg: &config::Config, live: bool) {
for (name, api) in &cfg.resources.api {
let resource = match check_api(api) {
Ok((resource, detail)) => {
report.push("api", Some(name), Status::Ok, &detail);
resource
}
Err(error) => {
report.push("api", Some(name), Status::Failed, &error);
continue;
}
};
if !live {
report.push_live("api", name, Status::Skipped, "live probe skipped");
continue;
}
match probe_api(&resource, &api.base_url).await {
Ok(detail) => report.push_live("api", name, Status::Ok, &detail),
Err(error) => report.push_live("api", name, Status::Failed, &error),
}
}
}
async fn check_databases(report: &mut Report, cfg: &config::Config, live: bool) {
for (name, connection) in &cfg.resources.db {
match db::check_dsn(connection) {
Ok(platform) => report.push("db", Some(name), Status::Ok, platform),
Err(error) => {
report.push("db", Some(name), Status::Failed, &error);
continue;
}
}
if !live {
report.push_live("db", name, Status::Skipped, "live probe skipped");
continue;
}
match probe_db(name, connection).await {
Ok(detail) => report.push_live("db", name, Status::Ok, &detail),
Err(error) => report.push_live("db", name, Status::Failed, &error),
}
}
}
fn check_srp(report: &mut Report, cfg: &config::Config) {
for (name, srp) in &cfg.resources.srp {
match check_srp_resource(srp) {
Ok(variant) => report.push("srp", Some(name), Status::Ok, &variant),
Err(error) => report.push("srp", Some(name), Status::Failed, &error),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn check(status: Status) -> Check {
Check {
stage: "config",
target: None,
status,
probe: false,
detail: "parsed".into(),
}
}
fn report(checks: Vec<Check>) -> Report {
Report {
config: "suite.yaml".into(),
config_source: Some(config::ConfigSource::Flag),
app_env: "dev".into(),
live: false,
checks,
}
}
#[test]
fn a_report_without_a_failed_check_exits_zero() {
let report = report(vec![check(Status::Ok), check(Status::Skipped)]);
assert_eq!(report.exit_code(), 0);
}
#[test]
fn any_failed_check_makes_the_report_exit_one() {
let report = report(vec![check(Status::Ok), check(Status::Failed)]);
assert_eq!(report.exit_code(), 1);
}
#[test]
fn the_header_names_the_config_and_the_selected_app_env() {
let mut report = report(vec![check(Status::Ok)]);
report.app_env = "ci".into();
let out = report.render();
assert!(out.contains("suite.yaml"), "{out}");
assert!(out.contains("APP_ENV: ci"), "{out}");
}
#[test]
fn a_static_report_says_that_live_would_also_probe_the_resources() {
let out = report(vec![check(Status::Ok)]).render();
assert!(out.contains("--live"), "{out}");
}
#[test]
fn a_live_report_does_not_repeat_the_live_hint() {
let mut report = report(vec![check(Status::Ok)]);
report.live = true;
let out = report.render();
assert!(!out.contains("--live"), "{out}");
}
#[test]
fn a_failed_check_renders_its_target_and_every_line_of_its_detail() {
let report = report(vec![Check {
stage: "db",
target: Some("primary".into()),
status: Status::Failed,
probe: true,
detail: "connection refused\nis the container running?".into(),
}]);
let out = report.render();
assert!(out.contains("db"), "{out}");
assert!(out.contains("primary"), "{out}");
assert!(out.contains("connection refused"), "{out}");
assert!(out.contains("is the container running?"), "{out}");
assert!(out.contains("1 problem"), "{out}");
}
}