use std::fs;
use std::path::Path;
use spec_spine_core::shard::{self, BY_SPEC_DIR};
use spec_spine_core::{
CompileOutcome, Freshness, compare_committed_registry, registry_dir, registry_shard_files,
};
use spec_spine_types::{
BUILD_META_SCHEMA_VERSION, BuildMeta, Error, Severity, Verdict, verdict::verb,
};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::load_repo_config;
use crate::out;
pub fn run(
repo: &Path,
check: bool,
json: bool,
spec: Option<&str>,
fail_on_warn: bool,
) -> Result<u8, Error> {
if let Some(id) = spec {
if check {
let err = Error::Config(
"compile --spec is incompatible with --check: --spec validates one spec \
against the committed registry, --check compares the whole shard tree \
(spec 056 3.1)"
.to_string(),
);
if json {
crate::emit_error_envelope(verb::COMPILE_SPEC, &err);
} else {
eprintln!("spec-spine: {err}");
}
return Ok(err.exit_code());
}
return run_one_spec(repo, id, json, fail_on_warn);
}
if json && !check {
let err = Error::Config(
"compile --json requires --check: the writing form has no machine-readable \
verdict (spec 037 4); use `compile --check --json`"
.to_string(),
);
crate::emit_error_envelope(verb::COMPILE_CHECK, &err);
return Ok(err.exit_code());
}
let cfg = load_repo_config(repo)?;
let outcome = spec_spine_core::compile(&cfg, repo)?;
if check {
if !outcome.validation_passed {
if json {
return Err(Error::Validation(
outcome.registry.validation.violations.clone(),
));
}
report_validation_failure(&outcome);
return Ok(1);
}
let freshness = compare_committed_registry(&cfg, repo, &outcome.shards)?;
let warn_refused = fail_on_warn && outcome.warning_count() > 0;
if json {
let code = if warn_refused {
1
} else if matches!(freshness, Freshness::Fresh) {
0
} else {
2
};
out::verdict(&Verdict::report(
verb::COMPILE_CHECK,
code,
freshness_report(&freshness),
))?;
return Ok(code);
}
if warn_refused {
report_warn_refusal(&outcome);
return Ok(1);
}
return match freshness {
Freshness::Fresh => {
outln!(
"spec-registry is fresh: {} shard(s) match the corpus",
outcome.registry.specs.len()
);
Ok(0)
}
Freshness::Stale { actual, .. } => {
eprintln!("{actual}");
eprintln!("spec-registry is STALE: run `spec-spine compile` and commit the result");
Ok(2)
}
};
}
let out_dir = registry_dir(&cfg, repo);
fs::create_dir_all(&out_dir)
.map_err(|e| Error::Io(format!("create {}: {e}", out_dir.display())))?;
let shard_files = registry_shard_files(&outcome.shards)?;
let by_spec = out_dir.join(BY_SPEC_DIR);
shard::sync_dir(&by_spec, &shard_files)?;
let legacy = out_dir.join("registry.json");
if legacy.exists() {
fs::remove_file(&legacy)
.map_err(|e| Error::Io(format!("remove {}: {e}", legacy.display())))?;
}
let meta = BuildMeta {
schema_version: BUILD_META_SCHEMA_VERSION.to_string(),
built_at: now_rfc3339(),
compiler_id: cfg.branding.compiler_id.clone(),
compiler_version: env!("CARGO_PKG_VERSION").to_string(),
};
let meta_json =
serde_json::to_string_pretty(&meta).map_err(|e| Error::Schema(e.to_string()))? + "\n";
let meta_path = out_dir.join("build-meta.json");
fs::write(&meta_path, meta_json)
.map_err(|e| Error::Io(format!("write {}: {e}", meta_path.display())))?;
let warnings = outcome.warning_count();
if outcome.validation_passed {
outln!(
"compiled {} spec(s) -> {} ({} warning(s))",
outcome.registry.specs.len(),
by_spec.display(),
warnings
);
if fail_on_warn && warnings > 0 {
report_warn_refusal(&outcome);
return Ok(1);
}
Ok(0)
} else {
report_validation_failure(&outcome);
Ok(1)
}
}
pub(crate) fn freshness_report(freshness: &Freshness) -> serde_json::Value {
match freshness {
Freshness::Fresh => serde_json::json!({ "fresh": true }),
Freshness::Stale { expected, actual } => {
serde_json::json!({ "fresh": false, "expected": expected, "actual": actual })
}
}
}
fn report_warn_refusal(outcome: &CompileOutcome) {
for v in &outcome.registry.validation.violations {
if v.severity == Severity::Warning {
let at = v.path.as_deref().unwrap_or("-");
eprintln!(" {} [{}] {}", v.code, at, v.message);
}
}
eprintln!(
"REFUSED: {} warning(s) across {} spec(s) (--fail-on-warn)",
outcome.warning_count(),
outcome.registry.specs.len()
);
}
fn report_validation_failure(outcome: &CompileOutcome) {
let violations = &outcome.registry.validation.violations;
for v in violations {
if v.severity == Severity::Error {
let at = v.path.as_deref().unwrap_or("-");
eprintln!(" {} [{}] {}", v.code, at, v.message);
}
}
let errors = violations
.iter()
.filter(|v| v.severity == Severity::Error)
.count();
let warnings = violations
.iter()
.filter(|v| v.severity == Severity::Warning)
.count();
eprintln!(
"validation FAILED: {errors} error(s), {warnings} warning(s) across {} spec(s)",
outcome.registry.specs.len()
);
}
fn now_rfc3339() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "unknown".to_string())
}
fn run_one_spec(repo: &Path, id: &str, json: bool, fail_on_warn: bool) -> Result<u8, Error> {
let cfg = load_repo_config(repo)?;
let report = spec_spine_core::compile_spec(&cfg, repo, id)?;
let warnings = report
.violations
.iter()
.filter(|v| v.severity == Severity::Warning)
.count();
let code = if !report.passed || (fail_on_warn && warnings > 0) {
1
} else {
0
};
if json {
let value = serde_json::to_value(&report).map_err(|e| Error::Schema(e.to_string()))?;
out::verdict(&Verdict::report(verb::COMPILE_SPEC, code, value))?;
return Ok(code);
}
for v in &report.violations {
eprintln!(" {} [{}] {}", v.code, report.spec_path, v.message);
}
if report.passed && code == 1 {
eprintln!(
"{}: REFUSED: {warnings} warning(s) (--fail-on-warn), nothing written",
report.spec_id
);
} else if report.passed {
outln!(
"{}: valid ({warnings} warning(s), nothing written)",
report.spec_id
);
} else {
eprintln!(
"{}: INVALID: {} error(s)",
report.spec_id,
report
.violations
.iter()
.filter(|v| v.severity == Severity::Error)
.count()
);
}
Ok(code)
}