use std::{
collections::HashSet,
path::{Path, PathBuf},
};
use mantra_schema::{
coverage::TestState,
requirements::{ReqId, Requirement},
Line,
};
use time::{OffsetDateTime, PrimitiveDateTime};
use crate::{
cfg::Project,
cmd::review::VerifiedRequirement,
db::{MantraDb, TracePk},
};
use super::{coverage::iso8601_str_to_offsetdatetime, review::Review};
#[derive(Debug, thiserror::Error)]
pub enum ReportError {
#[error("{}", .0)]
Db(sqlx::Error),
#[error("Failed to serialize report data.")]
Serialize,
#[error("Failed to generate the report. Make sure the report template is valid.")]
Tera,
#[error("Failed to format date/time for the report name.")]
Format,
#[error("Failed to write the report.")]
Write,
#[error("Failed to read the given template.")]
Template,
}
#[derive(Debug, Clone, clap::Args)]
pub struct ReportCliConfig {
pub path: PathBuf,
#[arg(long = "mantra-config")]
pub mantra_config: Option<PathBuf>,
#[command(flatten)]
pub template: ReportTemplate,
#[arg(long)]
pub formats: Vec<ReportFormat>,
#[command(flatten)]
pub project: Project,
#[command(flatten)]
pub tag: Tag,
}
impl ReportCliConfig {
pub async fn to_cfg(self) -> ReportConfig {
ReportConfig::from_cli(self).await
}
}
#[derive(Debug, Clone)]
pub struct ReportConfig {
pub path: PathBuf,
pub template: ReportTemplate,
pub formats: Vec<ReportFormat>,
pub project: Project,
pub tag: Tag,
}
impl ReportConfig {
pub async fn from_cli(mut value: ReportCliConfig) -> Self {
match tokio::fs::read_to_string(&value.mantra_config.unwrap_or("mantra.toml".into())).await {
Ok(content) => match toml::from_str::<crate::cfg::MantraConfigFile>(&content) {
Ok(mantra_cfg) => {
if value.template.base.is_none() && mantra_cfg.report_template.base.is_some() {
value.template.base = mantra_cfg.report_template.base;
}
if value.template.req_data.is_none()
&& mantra_cfg.report_template.req_data.is_some()
{
value.template.req_data = mantra_cfg.report_template.req_data;
}
if value.template.test_run_data.is_none()
&& mantra_cfg.report_template.test_run_data.is_some()
{
value.template.test_run_data = mantra_cfg.report_template.test_run_data;
}
if value.project.name.is_none() && mantra_cfg.project.name.is_some() {
value.project.name = mantra_cfg.project.name;
}
if value.project.version.is_none() && mantra_cfg.project.version.is_some() {
value.project.version = mantra_cfg.project.version;
}
if value.project.repository.is_none() && mantra_cfg.project.repository.is_some()
{
value.project.repository = mantra_cfg.project.repository;
}
if value.project.homepage.is_none() && mantra_cfg.project.homepage.is_some() {
value.project.homepage = mantra_cfg.project.homepage;
}
}
Err(err) => log::error!("Could not parse the mantra configuration. Cause: {}", err),
},
Err(err) => log::warn!("Could not read the mantra configuration. Only CLI arguments will be considered. Cause: {}", err),
}
Self {
path: value.path,
template: value.template,
formats: value.formats,
project: value.project,
tag: value.tag,
}
}
}
#[derive(
Default,
Debug,
Clone,
PartialEq,
serde::Serialize,
serde::Deserialize,
clap::Args,
schemars::JsonSchema,
)]
pub struct ReportTemplate {
#[arg(id = "base-template", long = "base-template")]
pub base: Option<PathBuf>,
#[arg(id = "req-data-template", long = "req-data-template")]
#[serde(alias = "req-data")]
pub req_data: Option<PathBuf>,
#[arg(id = "test-run-template", long = "test-run-template")]
#[serde(alias = "test-run-data")]
pub test_run_data: Option<PathBuf>,
}
impl ReportTemplate {
pub(crate) fn is_none(&self) -> bool {
self.base.is_none() && self.req_data.is_none() && self.test_run_data.is_none()
}
}
#[derive(
Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, clap::Args, schemars::JsonSchema,
)]
pub struct Tag {
#[arg(id = "tag-name", long = "tag-name")]
pub name: Option<String>,
#[arg(id = "tag-link", long = "tag-link")]
pub link: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, clap::ValueEnum)]
pub enum ReportFormat {
Html,
Json,
}
pub async fn report(db: &MantraDb, cfg: ReportConfig) -> Result<(), ReportError> {
let mut filepath = if cfg.path.extension().is_some() {
cfg.path
} else {
let now = OffsetDateTime::now_utc();
let format =
time::macros::format_description!("[year][month][day]_[hour]h[minute]m[second]s");
let filename = format!(
"{}_mantra_report.html",
now.format(format).map_err(|_| ReportError::Format)?
);
cfg.path.join(filename)
};
let formats: HashSet<ReportFormat> = HashSet::from_iter(cfg.formats.into_iter());
for format in formats {
let report = match format {
ReportFormat::Html => {
filepath.set_extension("html");
let template_content = match &cfg.template.base {
Some(template) => tokio::fs::read_to_string(template)
.await
.map_err(|_| ReportError::Template)?,
None => include_str!("report_default_template.html").to_string(),
};
create_tera_report(
db,
&cfg.project,
&cfg.tag,
cfg.template.req_data.as_deref(),
cfg.template.test_run_data.as_deref(),
&template_content,
)
.await?
}
ReportFormat::Json => {
filepath.set_extension("json");
create_json_report(
db,
&cfg.project,
&cfg.tag,
cfg.template.req_data.as_deref(),
cfg.template.test_run_data.as_deref(),
)
.await?
}
};
tokio::fs::write(&filepath, report)
.await
.map_err(|_| ReportError::Write)?;
}
Ok(())
}
pub async fn create_tera_report(
db: &MantraDb,
project: &Project,
tag: &Tag,
req_template: Option<&Path>,
test_run_template: Option<&Path>,
template: &str,
) -> Result<String, ReportError> {
let context = tera::Context::from_serialize(
ReportContext::try_from(db, project, tag, req_template, test_run_template).await?,
)
.map_err(|_| ReportError::Tera)?;
tera_one_off(template, &context)
}
pub async fn create_json_report(
db: &MantraDb,
project: &Project,
tag: &Tag,
req_template: Option<&Path>,
test_run_template: Option<&Path>,
) -> Result<String, ReportError> {
let report = ReportContext::try_from(db, project, tag, req_template, test_run_template).await?;
serde_json::to_string_pretty(&report).map_err(|_| ReportError::Serialize)
}
const REPORT_VERSION: &str = env!("CARGO_PKG_VERSION");
fn serialize_report_version<S>(_value: &Option<String>, ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
ser.serialize_str(REPORT_VERSION)
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct ReportContext {
#[serde(serialize_with = "serialize_report_version")]
pub version: Option<String>,
pub project: Project,
pub tag: Tag,
pub overview: RequirementsOverview,
pub requirements: Vec<RequirementInfo>,
pub tests: TestStatistics,
pub reviews: Vec<Review>,
pub trace_criteria: &'static str,
pub test_coverage_criteria: &'static str,
#[serde(
serialize_with = "time::serde::iso8601::serialize",
deserialize_with = "time::serde::iso8601::deserialize"
)]
#[schemars(with = "String")]
pub creation_date: OffsetDateTime,
pub validation: ValidationInfo,
pub unrelated: Unrelated,
}
impl ReportContext {
pub async fn try_from(
db: &MantraDb,
project: &Project,
tag: &Tag,
req_template: Option<&Path>,
test_run_template: Option<&Path>,
) -> Result<Self, ReportError> {
let overview = RequirementsOverview::try_from(db).await?;
let req_records = sqlx::query!("select id from Requirements order by id")
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let mut requirements = Vec::new();
for req in req_records {
requirements.push(RequirementInfo::try_from(db, req.id, req_template).await?);
}
let tests = TestStatistics::try_from(db, test_run_template).await?;
let review_records = sqlx::query!("select name, date from Reviews order by name, date")
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let mut reviews = Vec::new();
for review in review_records {
let date = PrimitiveDateTime::parse(&review.date, &super::REVIEW_DATE_FORMAT)
.expect("Review date was added to db in custom review-date format.");
reviews.push(Review::try_from(db, review.name, date).await?);
}
let trace_criteria = "Requirements are traced if one of the following criteria is met:
- A trace directly referring to the requirement exists (Directly traced)
- All of the sub-requirements of the requirement are traced (Indirectly traced)";
let test_coverage_criteria =
"Requirements are covered through a test if one of the following criteria is met:
- At least one direct trace to the requirement was reached during test execution (Direct coverage)
- All sub-requirements of the requirement were covered by the test (Indirect coverage)
Requirements are passed if all of the following criteria are met:
- The requirement is covered at least once
- All tests covering the requirement passed
- All tests covering child requirements of the requirement passed";
let creation_date = OffsetDateTime::now_utc();
let validation = ValidationInfo::try_from(db).await?;
let unrelated = Unrelated::try_from(db).await?;
Ok(Self {
version: Some(REPORT_VERSION.to_string()),
project: project.clone(),
tag: tag.clone(),
overview,
requirements,
tests,
reviews,
trace_criteria,
test_coverage_criteria,
creation_date,
validation,
unrelated,
})
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct ValidationInfo {
pub is_valid: bool,
pub criteria: &'static str,
pub invalid_reqs: Vec<String>,
}
impl ValidationInfo {
pub async fn try_from(db: &MantraDb) -> Result<Self, ReportError> {
let validation_criteria =
"The collected data is valid if no *deprecated* requirement is traced.";
let is_valid = db.is_valid().await.is_ok();
if is_valid {
Ok(Self {
is_valid,
criteria: validation_criteria,
invalid_reqs: vec![],
})
} else {
let invalid_records = sqlx::query!(r#"select id as "id!" from InvalidRequirements"#)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let invalid_reqs = invalid_records.into_iter().map(|r| r.id).collect();
Ok(Self {
is_valid,
criteria: validation_criteria,
invalid_reqs,
})
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct RequirementsOverview {
pub req_cnt: i64,
pub traced_cnt: i64,
pub traced_ratio: f64,
pub covered_cnt: i64,
pub covered_ratio: f64,
pub passed_cnt: i64,
pub passed_ratio: f64,
pub verified_cnt: Option<i64>,
pub verified_ratio: f64,
}
impl RequirementsOverview {
pub async fn try_from(db: &MantraDb) -> Result<Self, ReportError> {
let record = sqlx::query!(
r#"select
req_cnt,
traced_cnt,
traced_ratio,
covered_cnt,
covered_ratio,
passed_cnt,
passed_ratio,
verified_cnt as "verified_cnt?: i64",
verified_ratio
from RequirementCoverageOverview"#
)
.fetch_one(db.pool())
.await
.map_err(ReportError::Db)?;
Ok(Self {
req_cnt: record.req_cnt,
traced_cnt: record.traced_cnt,
traced_ratio: record.traced_ratio,
covered_cnt: record.covered_cnt,
covered_ratio: record.covered_ratio,
passed_cnt: record.passed_cnt,
passed_ratio: record.passed_ratio,
verified_cnt: record.verified_cnt,
verified_ratio: record.verified_ratio,
})
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct RequirementInfo {
#[serde(flatten)]
pub meta: Requirement,
pub rendered_data: Option<String>,
pub direct_children: Vec<String>,
pub leaf_statistic: Option<LeafChildrenStatistic>,
pub trace_info: RequirementTraceInfo,
pub test_coverage_info: RequirementTestCoverageInfo,
pub verified_info: Vec<VerifiedRequirementInfo>,
pub valid: bool,
}
impl RequirementInfo {
pub async fn try_from(
db: &MantraDb,
id: impl Into<ReqId>,
req_template: Option<&Path>,
) -> Result<Self, ReportError> {
let id: ReqId = id.into();
let record = sqlx::query!(r#"
select
title,
origin,
data,
case when id in (select id from DeprecatedRequirements) then true else false end as "deprecated!: bool",
case when id in (select id from ManualRequirements) then true else false end as "manual!: bool"
from Requirements
where id = $1
"#, id).fetch_one(db.pool()).await.map_err(ReportError::Db)?;
let title = record.title;
let origin = record.origin;
let data = record
.data
.map(|a| serde_json::from_str(&a).expect("Requirement data must be valid JSON."));
let deprecated = record.deprecated;
let manual = record.manual;
let mut record = sqlx::query!(
r#"
select parent_id
from RequirementHierarchies
where child_id = $1
"#,
id
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let parents: Vec<String> = record.iter_mut().map(|r| r.parent_id.clone()).collect();
let parents = if parents.is_empty() {
None
} else {
Some(parents)
};
let records = sqlx::query!(
r#"
select child_id
from RequirementHierarchies
where parent_id = $1
order by child_id
"#,
id
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let children = records.into_iter().map(|r| r.child_id).collect();
let leaf_statistic = LeafChildrenStatistic::try_from(db, &id).await?;
let trace_info = RequirementTraceInfo::try_from(db, &id).await?;
let test_coverage_info = RequirementTestCoverageInfo::try_from(db, &id).await?;
let records = sqlx::query!(
r#"
select review_name, review_date, comment
from ManuallyVerified
where req_id = $1
order by review_name, review_date
"#,
id
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let mut verified_info = Vec::with_capacity(records.len());
for record in records {
verified_info.push(VerifiedRequirementInfo {
review_name: record.review_name,
review_date: PrimitiveDateTime::parse(
&record.review_date,
super::REVIEW_DATE_FORMAT,
)
.expect("Review date was added to db in custom review-date format."),
comment: record.comment,
});
}
let valid = sqlx::query!(
r#"
select * from InvalidRequirements
where id = $1
"#,
id
)
.fetch_optional(db.pool())
.await
.map_err(ReportError::Db)?
.is_none();
let rendered_data = if let Some(template) = req_template {
let template_content = tokio::fs::read_to_string(template)
.await
.map_err(|_| ReportError::Template)?;
if let Some(value) = &data {
let context = tera::Context::from_serialize(value)
.expect("Requirement data value is valid JSON.");
Some(tera_one_off(&template_content, &context)?)
} else {
None
}
} else {
None
};
Ok(Self {
meta: Requirement {
id,
title,
origin,
manual,
deprecated,
data,
parents,
},
rendered_data,
direct_children: children,
leaf_statistic,
trace_info,
test_coverage_info,
verified_info,
valid,
})
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct LeafChildrenStatistic {
leaf_cnt: i64,
traced_leaf_cnt: i64,
traced_leaf_ratio: f64,
covered_leaf_cnt: i64,
covered_leaf_ratio: f64,
passed_covered_leaf_cnt: i64,
passed_covered_leaf_ratio: f64,
}
impl LeafChildrenStatistic {
pub async fn try_from(db: &MantraDb, id: &str) -> Result<Option<Self>, ReportError> {
sqlx::query_as!(
LeafChildrenStatistic,
r#"
select
leaf_cnt,
traced_leaf_cnt,
traced_leaf_ratio as "traced_leaf_ratio!: f64",
covered_leaf_cnt,
covered_leaf_ratio as "covered_leaf_ratio!: f64",
passed_covered_leaf_cnt,
passed_covered_leaf_ratio as "passed_covered_leaf_ratio!: f64"
from LeafChildOverview
where id = $1
"#,
id
)
.fetch_optional(db.pool())
.await
.map_err(ReportError::Db)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct RequirementTraceInfo {
pub traced: bool,
pub fully_traced: bool,
pub direct_traces: Vec<TraceInfo>,
pub indirect_traces: Vec<IndirectTraceInfo>,
}
impl RequirementTraceInfo {
pub async fn try_from(db: &MantraDb, id: &str) -> Result<Self, ReportError> {
let records = sqlx::query_as!(
TraceLocation,
r#"
select filepath, line as "line: Line"
from Traces
where req_id = $1
order by filepath, line
"#,
id
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let mut direct_traces = Vec::new();
for record in records {
let item_name = sqlx::query!(
r#"
select ti.name
from Traces t, TraceSpans ts, TracedItems ti
where t.req_id = $1 and t.filepath = $2
and t.line = $3 and t.req_id = ts.req_id
and t.filepath = ts.filepath and t.line = ts.line
and t.filepath = ti.filepath and ts.start = ti.line
"#,
id,
record.filepath,
record.line
)
.fetch_optional(db.pool())
.await
.map_err(ReportError::Db)?
.map(|r| r.name);
direct_traces.push(TraceInfo {
filepath: record.filepath,
line: record.line,
item_name,
});
}
let records = sqlx::query!(
r#"
select traced_id as "traced_id!", traces as "traces!: String"
from IndirectTraceTree
where id = $1
order by traced_id
"#,
id
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let mut indirect_traces = Vec::with_capacity(records.len());
for record in records {
indirect_traces.push(IndirectTraceInfo {
traced_id: record.traced_id,
traces: serde_json::from_str(&record.traces)
.expect("Traces extracted as JSON from DB."),
})
}
let fully_traced = sqlx::query!(
r#"
select *
from FullyTracedRequirements
where id = $1
"#,
id
)
.fetch_optional(db.pool())
.await
.map_err(ReportError::Db)?
.is_some();
Ok(Self {
traced: !direct_traces.is_empty() || !indirect_traces.is_empty(),
fully_traced,
direct_traces,
indirect_traces,
})
}
}
#[derive(
Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, sqlx::Type, schemars::JsonSchema,
)]
pub struct TraceLocation {
pub filepath: String,
pub line: Line,
}
#[derive(
Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, sqlx::Type, schemars::JsonSchema,
)]
pub struct TraceInfo {
pub filepath: String,
pub line: Line,
pub item_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct IndirectTraceInfo {
pub traced_id: String,
pub traces: Vec<TraceLocation>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct RequirementTestCoverageInfo {
pub covered: bool,
pub passed: bool,
pub fully_covered: bool,
pub direct_coverage: Vec<TestCoverageTestRunInfo>,
pub indirect_coverage: Vec<IndirectTestCoverageInfo>,
}
impl RequirementTestCoverageInfo {
pub async fn try_from(db: &MantraDb, id: &str) -> Result<Self, ReportError> {
let records = sqlx::query!(
r#"
select test_run_name, test_run_date, tests as "tests!: String"
from DirectCoverageTree
where id = $1
order by test_run_name, test_run_date
"#,
id
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let mut direct_coverage = Vec::with_capacity(records.len());
for record in records {
direct_coverage.push(TestCoverageTestRunInfo {
name: record.test_run_name,
date: iso8601_str_to_offsetdatetime(&record.test_run_date),
tests: serde_json::from_str(&record.tests)
.expect("Tests extracted as JSON from DB."),
})
}
let records = sqlx::query!(
r#"
select covered_id, test_runs as "test_runs!: String"
from IndirectTestCoverageTree
where id = $1
order by covered_id
"#,
id
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let mut indirect_coverage = Vec::with_capacity(records.len());
for record in records {
indirect_coverage.push(IndirectTestCoverageInfo {
covered_id: record.covered_id,
test_runs: serde_json::from_str(&record.test_runs)
.expect("Test runs extracted as JSON from DB."),
})
}
let passed = sqlx::query!(
"
select *
from PassedCoveredRequirements
where id = $1
",
id
)
.fetch_one(db.pool())
.await
.ok()
.is_some();
let fully_covered = sqlx::query!(
r#"
select *
from FullyCoveredRequirements
where id = $1
"#,
id
)
.fetch_optional(db.pool())
.await
.map_err(ReportError::Db)?
.is_some();
Ok(Self {
covered: !direct_coverage.is_empty() || !indirect_coverage.is_empty(),
passed,
fully_covered,
direct_coverage,
indirect_coverage,
})
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct TestCoverageTestRunInfo {
pub name: String,
#[serde(
serialize_with = "time::serde::iso8601::serialize",
deserialize_with = "time::serde::iso8601::deserialize"
)]
#[schemars(with = "String")]
pub date: OffsetDateTime,
pub tests: Vec<TestCoverageTestInfo>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct TestCoverageTestInfo {
pub name: String,
pub passed: bool,
pub traces: Vec<TraceLocation>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct IndirectTestCoverageInfo {
pub covered_id: String,
pub test_runs: Vec<TestCoverageTestRunInfo>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct VerifiedRequirementInfo {
pub review_name: String,
#[serde(with = "super::review_date_format")]
#[schemars(with = "String")]
pub review_date: PrimitiveDateTime,
pub comment: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct TestStatistics {
pub overview: TestsOverview,
pub test_runs: Vec<TestRunInfo>,
}
impl TestStatistics {
pub async fn try_from(
db: &MantraDb,
test_run_template: Option<&Path>,
) -> Result<Self, ReportError> {
let overview = TestsOverview::try_from(db).await?;
let test_run_records = sqlx::query!(
"
select name, date
from TestRuns
order by name, date
"
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let mut test_runs = Vec::new();
for test_run in test_run_records {
let date = iso8601_str_to_offsetdatetime(&test_run.date);
test_runs
.push(TestRunInfo::try_from(db, test_run.name, date, test_run_template).await?);
}
Ok(Self {
overview,
test_runs,
})
}
}
#[derive(
Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct TestsOverview {
pub test_cnt: i64,
pub ran_cnt: i64,
pub ran_ratio: f64,
pub passed_cnt: i64,
pub passed_ratio: f64,
pub failed_cnt: i64,
pub failed_ratio: f64,
pub skipped_cnt: i64,
pub skipped_ratio: f64,
}
impl TestsOverview {
pub async fn try_from(db: &MantraDb) -> Result<Self, ReportError> {
sqlx::query_as!(
TestsOverview,
r#"
select
test_cnt as "test_cnt!: i64",
ran_cnt as "ran_cnt!: i64",
ran_ratio as "ran_ratio!: f64",
passed_cnt as "passed_cnt!: i64",
passed_ratio as "passed_ratio!: f64",
failed_cnt as "failed_cnt!: i64",
failed_ratio as "failed_ratio!: f64",
skipped_cnt as "skipped_cnt!: i64",
skipped_ratio as "skipped_ratio!: f64"
from OverallTestOverview
"#
)
.fetch_one(db.pool())
.await
.map_err(ReportError::Db)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct TestRunInfo {
pub overview: TestRunOverview,
pub name: String,
#[serde(
serialize_with = "time::serde::iso8601::serialize",
deserialize_with = "time::serde::iso8601::deserialize"
)]
#[schemars(with = "String")]
pub date: OffsetDateTime,
pub data: Option<serde_json::Value>,
pub rendered_data: Option<String>,
pub logs: Option<String>,
pub tests: Vec<TestInfo>,
}
impl TestRunInfo {
pub async fn try_from(
db: &MantraDb,
name: impl Into<String>,
date: OffsetDateTime,
test_run_template: Option<&Path>,
) -> Result<Self, ReportError> {
let name: String = name.into();
let overview = TestRunOverview::try_from(db, &name, &date).await?;
let test_records = sqlx::query!(
r#"
select name, passed as "passed!: bool", skipped as "skipped!: bool", reason as "reason?: String", filepath, line as "line: Line" from (
select
name, filepath, line,
passed,
false as skipped,
null as reason
from Tests
where test_run_name = $1 and test_run_date = $2
union all
select
name, filepath, line,
false as passed,
true as skipped,
reason
from SkippedTests
where test_run_name = $1 and test_run_date = $2
)
order by name, filepath, line
"#,
name,
date
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let mut test_info = Vec::new();
for test in test_records {
let covers = sqlx::query!(
"
select distinct req_id from TestCoverage
where test_run_name = $1 and
test_run_date = $2 and
test_name = $3
order by req_id
",
name,
date,
test.name
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?
.into_iter()
.map(|r| r.req_id)
.collect();
let state = if test.passed {
TestState::Passed
} else if test.skipped {
TestState::Skipped {
reason: test.reason,
}
} else {
TestState::Failed
};
test_info.push(TestInfo {
covers,
name: test.name,
filepath: PathBuf::from(test.filepath),
line: test.line,
state,
})
}
let record = sqlx::query!(
r#"
select data, logs from TestRuns
where name = $1 and date = $2
"#,
name,
date
)
.fetch_one(db.pool())
.await
.map_err(ReportError::Db)?;
let data = record
.data
.map(|m| serde_json::from_str(&m).expect("Test run data data must be valid JSON."));
let rendered_data = if let Some(template) = test_run_template {
let template_content = tokio::fs::read_to_string(template)
.await
.map_err(|_| ReportError::Template)?;
if let Some(value) = &data {
let context = tera::Context::from_serialize(value)
.expect("Test-run data value is valid JSON.");
Some(tera_one_off(&template_content, &context)?)
} else {
None
}
} else {
None
};
Ok(Self {
overview,
name,
date,
data,
rendered_data,
logs: record.logs,
tests: test_info,
})
}
}
#[derive(
Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct TestRunOverview {
pub test_cnt: i64,
pub ran_cnt: i64,
pub ran_ratio: f64,
pub passed_cnt: i64,
pub passed_ratio: f64,
pub failed_cnt: i64,
pub failed_ratio: f64,
pub skipped_cnt: i64,
pub skipped_ratio: f64,
}
impl TestRunOverview {
pub async fn try_from(
db: &MantraDb,
name: &str,
date: &OffsetDateTime,
) -> Result<Self, ReportError> {
sqlx::query_as!(
TestRunOverview,
r#"
select
test_cnt,
ran_cnt,
ran_ratio as "ran_ratio!: f64",
passed_cnt,
passed_ratio as "passed_ratio!: f64",
failed_cnt,
failed_ratio as "failed_ratio!: f64",
skipped_cnt,
skipped_ratio as "skipped_ratio!: f64"
from TestRunOverview
where name = $1 and date = $2
"#,
name,
date
)
.fetch_one(db.pool())
.await
.map_err(ReportError::Db)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct TestInfo {
pub covers: Vec<String>,
pub name: String,
pub filepath: PathBuf,
pub line: Line,
pub state: TestState,
}
impl Review {
pub async fn try_from(
db: &MantraDb,
name: impl Into<String>,
date: PrimitiveDateTime,
) -> Result<Self, ReportError> {
let name: String = name.into();
let requirements = sqlx::query_as!(
VerifiedRequirement,
"
select req_id as id, comment
from ManuallyVerified
where review_name = $1 and review_date = $2
order by req_id
",
name,
date
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let record = sqlx::query!(
"
select reviewer, comment
from Reviews
where name = $1 and date = $2
",
name,
date
)
.fetch_one(db.pool())
.await
.map_err(ReportError::Db)?;
Ok(Review {
name,
date,
reviewer: record.reviewer,
comment: record.comment,
requirements,
})
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct Unrelated {
pub traces: Vec<TracePk>,
pub coverage: Vec<UnrelatedCoverage>,
pub verified_requirements: Vec<UnrelatedVerified>,
}
impl Unrelated {
pub async fn try_from(db: &MantraDb) -> Result<Self, ReportError> {
let traces = sqlx::query_as!(
TracePk,
r#"
select
req_id,
filepath,
line as "line!: u32"
from UnrelatedTraces
"#
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let coverage = UnrelatedCoverage::try_from(db).await?;
let verified = UnrelatedVerified::try_from(db).await?;
Ok(Self {
traces,
coverage,
verified_requirements: verified,
})
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct UnrelatedCoverage {
pub test_run_name: String,
#[serde(
serialize_with = "time::serde::iso8601::serialize",
deserialize_with = "time::serde::iso8601::deserialize"
)]
#[schemars(with = "String")]
pub test_run_date: OffsetDateTime,
pub test_name: String,
pub req_id: String,
pub trace_filepath: PathBuf,
pub trace_line: Line,
}
impl UnrelatedCoverage {
pub async fn try_from(db: &MantraDb) -> Result<Vec<Self>, ReportError> {
let records = sqlx::query!(
r#"
select
test_run_name,
test_run_date,
test_name,
req_id,
trace_filepath,
trace_line as "trace_line!: u32"
from UnrelatedTestCoverage
"#,
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let mut unrelated = Vec::new();
for record in records {
unrelated.push(UnrelatedCoverage {
test_run_name: record.test_run_name,
test_run_date: iso8601_str_to_offsetdatetime(&record.test_run_date),
test_name: record.test_name,
req_id: record.req_id,
trace_filepath: PathBuf::from(record.trace_filepath),
trace_line: record.trace_line,
})
}
Ok(unrelated)
}
}
time::serde::format_description!(
review_date_format,
PrimitiveDateTime,
mantra_schema::reviews::REVIEW_DATE_FORMAT
);
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct UnrelatedVerified {
pub review_name: String,
#[serde(with = "review_date_format")]
#[schemars(with = "String")]
pub review_date: PrimitiveDateTime,
pub req_id: String,
pub comment: Option<String>,
}
impl UnrelatedVerified {
pub async fn try_from(db: &MantraDb) -> Result<Vec<Self>, ReportError> {
let records = sqlx::query!(
r#"
select * from UnrelatedManuallyVerified
"#,
)
.fetch_all(db.pool())
.await
.map_err(ReportError::Db)?;
let mut unrelated = Vec::new();
for record in records {
unrelated.push(UnrelatedVerified {
review_name: record.review_name,
review_date: mantra_schema::reviews::date_from_str(&record.review_date)
.expect("Review date was serialized before."),
req_id: record.req_id,
comment: record.comment,
});
}
Ok(unrelated)
}
}
const ONE_OFF_TEMPLATE_NAME: &str = "__tera_one_off";
const TERA_CWD_FN: &str = "cwd";
fn tera_one_off(template_content: &str, context: &tera::Context) -> Result<String, ReportError> {
let mut tera = tera::Tera::default();
tera.register_function(TERA_CWD_FN, tera_cwd);
tera.autoescape_on(vec![ONE_OFF_TEMPLATE_NAME]);
let rendered = tera
.render_str(template_content, context)
.map_err(|_| ReportError::Tera)?;
Ok(rendered)
}
fn tera_cwd(
_map: &std::collections::HashMap<std::string::String, tera::Value>,
) -> Result<tera::Value, tera::Error> {
std::env::current_dir()
.map_err(tera::Error::msg)
.map(|v| tera::Value::String(v.display().to_string()))
}