use std::collections::{BTreeMap, BTreeSet};
use super::DiscoveredCrate;
use super::error::Diagnostic;
use super::metadata::{CrateMetadata, ReleaseMetadata};
const SHARED_RELEASE_UNITS: &[&str] = &["core"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ValidationReport {
pub managed_crates: usize,
pub release_units: usize,
pub arcature_workspace: bool,
}
pub(crate) fn validate(crates: &[DiscoveredCrate]) -> Result<ValidationReport, Vec<Diagnostic>> {
let has_any_metadata = crates
.iter()
.any(|c| !matches!(c.metadata, CrateMetadata::Absent));
if !has_any_metadata {
return Ok(ValidationReport {
managed_crates: 0,
release_units: 0,
arcature_workspace: false,
});
}
let mut diagnostics = Vec::new();
let mut units: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut managed = 0usize;
for c in crates {
match &c.metadata {
CrateMetadata::Absent => {
if c.publishable {
diagnostics.push(Diagnostic {
crate_name: c.name.clone(),
message: "publishable crate is missing [package.metadata.arcature]"
.to_string(),
});
}
}
CrateMetadata::Malformed(message) => {
diagnostics.push(Diagnostic {
crate_name: c.name.clone(),
message: format!("malformed [package.metadata.arcature]: {message}"),
});
}
CrateMetadata::Valid(metadata) => {
validate_metadata(c, metadata, &mut diagnostics);
if metadata.publish {
managed += 1;
if let Some(unit) = &metadata.release_unit {
units
.entry(unit.clone())
.or_default()
.insert(c.name.clone());
}
}
}
}
}
validate_release_units(&units, &mut diagnostics);
if diagnostics.is_empty() {
Ok(ValidationReport {
managed_crates: managed,
release_units: units.len(),
arcature_workspace: true,
})
} else {
diagnostics.sort_by(|a, b| {
a.crate_name
.cmp(&b.crate_name)
.then(a.message.cmp(&b.message))
});
Err(diagnostics)
}
}
fn validate_metadata(c: &DiscoveredCrate, m: &ReleaseMetadata, diagnostics: &mut Vec<Diagnostic>) {
if m.publish != c.publishable {
diagnostics.push(Diagnostic {
crate_name: c.name.clone(),
message: format!(
"metadata.publish={} disagrees with package.publish (publishable={})",
m.publish, c.publishable
),
});
return;
}
if !m.publish {
return;
}
if m.role.is_none() {
diagnostics.push(Diagnostic {
crate_name: c.name.clone(),
message: "publishable crate metadata must declare `role`".to_string(),
});
}
match &m.release_unit {
None => {
diagnostics.push(Diagnostic {
crate_name: c.name.clone(),
message: "publishable crate metadata must declare `release-unit`".to_string(),
});
}
Some(unit) if unit.is_empty() => {
diagnostics.push(Diagnostic {
crate_name: c.name.clone(),
message: "`release-unit` must not be empty".to_string(),
});
}
Some(_) => {}
}
}
fn validate_release_units(
units: &BTreeMap<String, BTreeSet<String>>,
diagnostics: &mut Vec<Diagnostic>,
) {
for (unit, members) in units {
if members.len() > 1 && !SHARED_RELEASE_UNITS.contains(&unit.as_str()) {
diagnostics.push(Diagnostic {
crate_name: unit.clone(),
message: format!(
"multi-crate release unit `{unit}` is not a recognized shared unit \
(only `core` may group crates); members: {}",
members.iter().cloned().collect::<Vec<_>>().join(", ")
),
});
}
if unit == "core" {
validate_core_unit(members, diagnostics);
}
}
}
fn validate_core_unit(members: &BTreeSet<String>, diagnostics: &mut Vec<Diagnostic>) {
if members.len() != 2 {
diagnostics.push(Diagnostic {
crate_name: "core".to_string(),
message: format!(
"release unit `core` must contain exactly the facade and proc-macro crates; \
found {}: {}",
members.len(),
members.iter().cloned().collect::<Vec<_>>().join(", ")
),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::release::discovered::DiscoveredCrate;
use crate::release::metadata::Role;
use std::path::PathBuf;
fn crate_named(name: &str, publishable: bool, metadata: CrateMetadata) -> DiscoveredCrate {
DiscoveredCrate {
name: name.to_string(),
manifest_path: PathBuf::from(format!("/repo/crates/{name}/Cargo.toml")),
publishable,
proc_macro: false,
metadata,
}
}
fn valid_md(publish: bool, role: Option<Role>, unit: Option<&str>) -> CrateMetadata {
CrateMetadata::Valid(ReleaseMetadata {
publish,
role,
release_unit: unit.map(str::to_string),
})
}
#[test]
fn empty_workspace_is_non_arcature() {
let report = validate(&[]).expect("empty workspace validates");
assert_eq!(report.managed_crates, 0);
assert!(!report.arcature_workspace);
}
#[test]
fn no_metadata_anywhere_is_non_arcature() {
let crates = vec![crate_named("app", true, CrateMetadata::Absent)];
let report = validate(&crates).expect("no metadata โ soft pass");
assert!(!report.arcature_workspace);
assert_eq!(report.managed_crates, 0);
}
#[test]
fn publishable_crate_missing_metadata_fails() {
let crates = vec![
crate_named(
"arcature-auth",
true,
valid_md(true, Some(Role::Subsystem), Some("arcature-auth")),
),
crate_named("arcature-db", true, CrateMetadata::Absent),
];
let errors = validate(&crates).expect_err("missing metadata must fail");
let db_error = errors
.iter()
.find(|d| d.crate_name == "arcature-db")
.expect("db diagnostic present");
assert!(
db_error
.message
.contains("missing [package.metadata.arcature]")
);
}
#[test]
fn non_publishable_crate_without_metadata_is_ok() {
let crates = vec![
crate_named(
"arcature-auth",
true,
valid_md(true, Some(Role::Subsystem), Some("arcature-auth")),
),
crate_named("internal-tool", false, CrateMetadata::Absent),
];
let report = validate(&crates).expect("non-publishable without metadata is fine");
assert_eq!(report.managed_crates, 1);
}
#[test]
fn publish_disagreement_fails() {
let crates = vec![crate_named(
"x",
false,
valid_md(true, Some(Role::Subsystem), Some("x")),
)];
let errors = validate(&crates).expect_err("disagreement must fail");
assert!(errors[0].message.contains("disagrees"));
}
#[test]
fn publishable_with_publish_false_metadata_fails() {
let crates = vec![crate_named("x", true, valid_md(false, None, None))];
let errors = validate(&crates).expect_err("disagreement must fail");
assert!(errors[0].message.contains("disagrees"));
}
#[test]
fn publishable_missing_role_fails() {
let crates = vec![crate_named("x", true, valid_md(true, None, Some("x")))];
let errors = validate(&crates).expect_err("missing role must fail");
assert!(errors[0].message.contains("must declare `role`"));
}
#[test]
fn publishable_missing_release_unit_fails() {
let crates = vec![crate_named(
"x",
true,
valid_md(true, Some(Role::Subsystem), None),
)];
let errors = validate(&crates).expect_err("missing unit must fail");
assert!(errors[0].message.contains("must declare `release-unit`"));
}
#[test]
fn empty_release_unit_fails() {
let crates = vec![crate_named(
"x",
true,
valid_md(true, Some(Role::Subsystem), Some("")),
)];
let errors = validate(&crates).expect_err("empty unit must fail");
assert!(errors[0].message.contains("must not be empty"));
}
#[test]
fn malformed_metadata_fails() {
let crates = vec![crate_named(
"x",
true,
CrateMetadata::Malformed("unknown variant `glue`".to_string()),
)];
let errors = validate(&crates).expect_err("malformed must fail");
assert!(errors[0].message.contains("malformed"));
assert!(errors[0].message.contains("glue"));
}
#[test]
fn unrecognized_multi_crate_unit_fails() {
let crates = vec![
crate_named(
"a",
true,
valid_md(true, Some(Role::Subsystem), Some("weird-unit")),
),
crate_named(
"b",
true,
valid_md(true, Some(Role::Subsystem), Some("weird-unit")),
),
];
let errors = validate(&crates).expect_err("undocumented shared unit must fail");
let unit_error = errors
.iter()
.find(|d| d.crate_name == "weird-unit")
.expect("unit diagnostic present");
assert!(unit_error.message.contains("not a recognized shared unit"));
}
#[test]
fn single_crate_unit_with_any_name_is_ok() {
let crates = vec![
crate_named(
"arcature-auth",
true,
valid_md(true, Some(Role::Subsystem), Some("arcature-auth")),
),
crate_named(
"arcature-db",
true,
valid_md(true, Some(Role::Subsystem), Some("arcature-db")),
),
];
let report = validate(&crates).expect("independent single-crate units are valid");
assert_eq!(report.managed_crates, 2);
assert_eq!(report.release_units, 2);
}
#[test]
fn core_with_facade_and_proc_macro_is_ok() {
let crates = vec![
crate_named(
"arcature",
true,
valid_md(true, Some(Role::Facade), Some("core")),
),
crate_named(
"arcature-dx",
true,
valid_md(true, Some(Role::ProcMacro), Some("core")),
),
];
let report = validate(&crates).expect("complete core is valid");
assert_eq!(report.managed_crates, 2);
assert_eq!(report.release_units, 1);
}
#[test]
fn incomplete_core_fails() {
let crates = vec![crate_named(
"arcature",
true,
valid_md(true, Some(Role::Facade), Some("core")),
)];
let errors = validate(&crates).expect_err("half of core must fail");
assert!(errors[0].message.contains("must contain exactly"));
}
#[test]
fn core_with_three_members_fails() {
let crates = vec![
crate_named(
"arcature",
true,
valid_md(true, Some(Role::Facade), Some("core")),
),
crate_named(
"arcature-dx",
true,
valid_md(true, Some(Role::ProcMacro), Some("core")),
),
crate_named(
"arcature-extra",
true,
valid_md(true, Some(Role::Subsystem), Some("core")),
),
];
let errors = validate(&crates).expect_err("over-full core must fail");
assert!(errors[0].message.contains("must contain exactly"));
}
#[test]
fn diagnostics_are_deterministically_ordered() {
let crates = vec![
crate_named("zeta", true, CrateMetadata::Absent),
crate_named("alpha", true, CrateMetadata::Absent),
crate_named(
"arcature-auth",
true,
valid_md(true, Some(Role::Subsystem), Some("arcature-auth")),
),
];
let errors = validate(&crates).expect_err("both missing");
assert_eq!(errors[0].crate_name, "alpha");
assert_eq!(errors[1].crate_name, "zeta");
}
}