mod checks;
mod findings;
mod scan;
mod spec;
use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
pub use self::findings::{Finding, Rule};
use super::Labels;
use crate::convert::openspec::ext;
use crate::convert::openspec::layout::{SPEC_FILE, SPECS};
use crate::convert::openspec::read::without_closing_run;
use crate::document::Document;
use crate::identity::Key;
use crate::ids::{ElementId, FeatureId};
use crate::index::IndexErrors;
use crate::node::Node;
use crate::tree::{Feature, FeatureGroup};
pub const RENDER_MAP: &str = "loft-render-map.json";
const MAP_SCHEMA: &str = "loft-render-map/1";
const PURPOSE_LENGTH: usize = 50;
const REQUIREMENT_LENGTH: usize = 500;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RenderError {
#[error("the document does not index: {0:?}")]
Index(IndexErrors),
#[error("{path}: two Features would be written here, {} and {}", .features[0], .features[1])]
Collision { path: String, features: [String; 2] },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenspecProjection {
pub roots: Vec<String>,
pub files: BTreeMap<String, String>,
pub map: String,
pub findings: Vec<Finding>,
}
pub fn openspec(document: &Document) -> Result<OpenspecProjection, RenderError> {
document.index().map_err(RenderError::Index)?;
let labels = Labels::of(document);
let several = document.products.len() > 1;
let mut roots = Vec::new();
let mut files = BTreeMap::new();
let mut entries = Vec::new();
let mut findings = Vec::new();
let mut taken: BTreeMap<String, String> = BTreeMap::new();
for product in &document.products {
let root = if several {
format!("{}/{SPECS}", product.slug.as_str())
} else {
SPECS.to_owned()
};
roots.push(root.clone());
let features = product
.feature_groups
.iter()
.flat_map(|group| group.features.iter().map(move |feature| (group, feature)));
for (group, feature) in features {
let path = format!("{root}/{}/{SPEC_FILE}", capability(group, feature));
let name = feature
.id
.map_or_else(|| feature.name.clone(), |id| labels.name(id));
claim(&mut taken, &path, name)?;
let spec = spec::write(feature, &labels);
findings.extend(feature_findings(feature, &spec, &path));
findings.extend(checks::findings(feature, &spec, &path));
entries.extend(spec.marked.iter().map(|marked| MapEntry {
file: path.clone(),
key: marked.element.map(ElementId::key),
kind: marked.mark,
label: marked.element.and_then(|element| labels.label(element)),
line: marked.line,
}));
let mut text = spec.lines.join("\n");
text.push('\n');
files.insert(path, text);
}
}
findings.extend(left_out(document));
let map = RenderMap {
entries,
schema: MAP_SCHEMA,
};
let mut map = serde_json::to_string_pretty(&map).expect("a render map serialises");
map.push('\n');
Ok(OpenspecProjection {
roots,
files,
map,
findings,
})
}
fn claim(
taken: &mut BTreeMap<String, String>,
path: &str,
name: String,
) -> Result<(), RenderError> {
if let Some(first) = taken.get(&path.to_lowercase()) {
let features = [first.clone(), name];
let path = path.to_owned();
return Err(RenderError::Collision { path, features });
}
taken.insert(path.to_lowercase(), name);
Ok(())
}
#[derive(Serialize)]
struct RenderMap<'a> {
entries: Vec<MapEntry<'a>>,
schema: &'static str,
}
#[derive(Serialize)]
struct MapEntry<'a> {
file: String,
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<Key>,
kind: spec::Mark,
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<&'a str>,
line: usize,
}
fn capability(group: &FeatureGroup, feature: &Feature) -> String {
ext::capability(&feature.details.ext).map_or_else(
|| format!("{}/{}", group.slug.as_str(), feature.slug.as_str()),
str::to_owned,
)
}
fn feature_findings(feature: &Feature, spec: &spec::Spec, path: &str) -> Vec<Finding> {
let at =
|rule, line, key, detail| Finding::at(rule, path.to_owned(), line, key).with_detail(detail);
let mut findings = Vec::new();
let purpose = feature
.details
.body
.as_deref()
.map_or(0, |body| body.trim().chars().count());
if purpose < PURPOSE_LENGTH {
let key = feature.id.map(FeatureId::key);
let detail = Some(format!("{purpose} characters"));
findings.push(at(Rule::ShortPurpose, spec.purpose_line, key, detail));
}
let mut names = BTreeSet::new();
for written in &spec.requirements {
let read = scan::read(&spec.lines[written.lines.clone()]);
let at = |rule, detail| at(rule, written.line, written.key, detail);
if !read.modal() {
findings.push(at(Rule::NoModal, None));
}
if !read.scenario {
findings.push(at(Rule::NoScenario, None));
}
let length = read.length();
if length > REQUIREMENT_LENGTH {
let detail = Some(format!("{length} characters"));
findings.push(at(Rule::LongRequirement, detail));
}
if !names.insert(without_closing_run(&written.name).trim()) {
findings.push(at(Rule::DuplicateName, Some(written.name.clone())));
}
}
findings
}
fn left_out(document: &Document) -> Vec<Finding> {
let (mut components, mut constraints, mut qualities) = (0, 0, 0);
document.walk(&mut |node| match node {
Node::Component(_) => components += 1,
Node::Constraint(_) => constraints += 1,
Node::QualityAttribute(_) => qualities += 1,
_ => {}
});
[
("Components", components),
("Constraints", constraints),
("Quality Attributes", qualities),
("Decisions", document.decisions.len()),
("edges", document.edges.len()),
("tags", document.tags.len()),
]
.into_iter()
.filter(|(_, count)| *count > 0)
.map(|(kind, count)| Finding::about(Rule::LeftOut, format!("{count} {kind}")))
.collect()
}