use std::path::PathBuf;
use std::process::ExitCode;
use guibiao::{
Baseline, Coverage, Outcome, Report, ViolationId, apply_baseline, check_and_cover,
constitution_text, report_json, workspace_member_src_dirs,
};
use louke::audit_probe_coverage;
use crate::Constitution;
const EXIT_OK: u8 = 0;
const EXIT_CANNOT_JUDGE: u8 = 2;
mod projection;
use projection::*;
pub use projection::{constitution_markdown, projection_gate};
mod render;
use render::{report, report_coverage, report_sarif, report_violations};
mod term_color;
use term_color::Style;
#[derive(PartialEq, Eq)]
enum Command {
Check,
List,
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum Format {
Text,
Json,
Markdown,
Sarif,
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum ReportFormat {
Text,
Json,
Sarif,
}
pub fn run<I, S>(constitution: &Constitution, args: I) -> ExitCode
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
ExitCode::from(dispatch(constitution, args))
}
fn dispatch<I, S>(constitution: &Constitution, args: I) -> u8
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut manifest_path: Option<String> = None;
let mut baseline_path: Option<String> = None;
let mut write_baseline_path: Option<String> = None;
let mut format: Option<String> = None;
let mut warn_uncovered = false;
let mut args = args.into_iter().map(Into::into).skip(1).peekable();
let command = match args.peek().map(String::as_str) {
Some("list") => {
args.next();
Command::List
}
Some("check") => {
args.next();
Command::Check
}
_ => Command::Check,
};
macro_rules! value {
($flag:literal) => {
match args.next() {
Some(value) => value,
None => return usage(concat!($flag, " requires a value")),
}
};
}
while let Some(arg) = args.next() {
match arg.as_str() {
"--manifest-path" => manifest_path = Some(value!("--manifest-path")),
"--baseline" => baseline_path = Some(value!("--baseline")),
"--write-baseline" => write_baseline_path = Some(value!("--write-baseline")),
"--format" => format = Some(value!("--format")),
"--warn-uncovered" => warn_uncovered = true,
other => {
if let Some(path) = other.strip_prefix("--manifest-path=") {
manifest_path = Some(path.to_string());
} else if let Some(path) = other.strip_prefix("--baseline=") {
baseline_path = Some(path.to_string());
} else if let Some(path) = other.strip_prefix("--write-baseline=") {
write_baseline_path = Some(path.to_string());
} else if let Some(value) = other.strip_prefix("--format=") {
format = Some(value.to_string());
} else {
return usage(&format!("unrecognized argument '{other}'"));
}
}
}
}
let format = match format.as_deref() {
None | Some("text") => Format::Text,
Some("json") => Format::Json,
Some("markdown") => Format::Markdown,
Some("sarif") => Format::Sarif,
Some(other) => {
return usage(&format!(
"unknown --format '{other}' (expected text, json, markdown, or sarif)"
));
}
};
if command == Command::List {
if manifest_path.is_some()
|| baseline_path.is_some()
|| write_baseline_path.is_some()
|| warn_uncovered
{
return usage("list takes only --format; other flags are check-only");
}
let semantic = constitution.semantic_boundaries();
let runtime = constitution.runtime_boundaries();
match format {
Format::Json => {
println!(
"{}",
serde_json::to_string_pretty(&list_document(constitution))
.expect("a serde_json::Value is always serializable")
);
}
Format::Markdown => {
print!("{}", list_markdown(&list_document(constitution)));
}
Format::Text => {
println!("{}", constitution_text(constitution.static_boundaries()));
print!("{}", semantic_text(&semantic.signature));
print!("{}", trait_impl_text(&semantic.trait_impl));
print!("{}", visibility_text(&semantic.visibility));
print!("{}", forbidden_marker_text(&semantic.forbidden_marker));
print!("{}", dyn_trait_text(&semantic.dyn_trait));
print!("{}", impl_trait_text(&semantic.impl_trait));
print!("{}", async_exposure_text(&semantic.async_exposure));
print!("{}", unsafe_text(&semantic.unsafe_confinement));
print!("{}", runtime_text(runtime));
}
Format::Sarif => {
return usage(
"list supports --format text|json|markdown; sarif projects the reaction \
(a check output), not the declared law",
);
}
}
return EXIT_OK;
}
let report_format = match format {
Format::Text => ReportFormat::Text,
Format::Json => ReportFormat::Json,
Format::Sarif => ReportFormat::Sarif,
Format::Markdown => {
return usage(
"check supports --format text|json|sarif; markdown is a list-only \
projection of the declared law",
);
}
};
if baseline_path.is_some() && write_baseline_path.is_some() {
return usage("--baseline and --write-baseline are mutually exclusive");
}
let manifest_path = match manifest_path {
Some(path) => PathBuf::from(path),
None => match nearest_manifest() {
Some(path) => path,
None => {
let from = std::env::current_dir()
.map(|dir| dir.display().to_string())
.unwrap_or_else(|_| "the current directory".to_string());
eprintln!(
"Tianheng: no Cargo.toml found from {from} up to the root; \
pass --manifest-path <path>"
);
return EXIT_CANNOT_JUDGE;
}
},
};
let (static_outcome, observed_coverage) =
check_and_cover(constitution.static_boundaries(), &manifest_path);
let mut outcome = static_outcome;
if !matches!(outcome, Outcome::ConstitutionError(_))
&& !constitution.semantic_boundaries().is_empty()
{
outcome = merge_outcomes(
outcome,
hunyi::check_all(constitution.semantic_boundaries(), &manifest_path),
);
}
if !matches!(outcome, Outcome::ConstitutionError(_)) {
match workspace_member_src_dirs(&manifest_path) {
Ok(src_dirs) => {
outcome = merge_outcomes(
outcome,
audit_probe_coverage(constitution.runtime_boundaries(), &src_dirs),
);
}
Err(message) => {
outcome = merge_outcomes(outcome, Outcome::ConstitutionError(message));
}
}
}
if let Some(path) = write_baseline_path {
return write_baseline(&outcome, &path);
}
let coverage = match outcome {
Outcome::ConstitutionError(_) => None,
_ => observed_coverage,
};
if let Some(path) = baseline_path {
return gate(
&mut outcome,
&path,
report_format,
coverage.as_ref(),
warn_uncovered,
);
}
match report_format {
ReportFormat::Json => println!("{}", report_json(&outcome, &[], coverage.as_ref())),
ReportFormat::Sarif => println!("{}", report_sarif(&outcome)),
ReportFormat::Text => {
report(&outcome);
if let Some(coverage) = &coverage {
report_coverage(coverage, warn_uncovered);
}
}
}
outcome.exit_code()
}
fn usage(message: &str) -> u8 {
eprintln!(
"usage:\n \
tianheng check --manifest-path <path/to/Cargo.toml> \
[--baseline <file> | --write-baseline <file>] [--format text|json|sarif] \
[--warn-uncovered]\n \
tianheng list [--format text|json|markdown]"
);
eprintln!("error: {message}");
EXIT_CANNOT_JUDGE
}
fn nearest_manifest() -> Option<PathBuf> {
nearest_manifest_from(std::env::current_dir().ok()?)
}
fn nearest_manifest_from(start: PathBuf) -> Option<PathBuf> {
let mut dir = start;
loop {
let candidate = dir.join("Cargo.toml");
if candidate.is_file() {
return Some(candidate);
}
if !dir.pop() {
return None;
}
}
}
fn write_baseline(outcome: &Outcome, path: &str) -> u8 {
if let Outcome::ConstitutionError(message) = outcome {
eprintln!(
"{}",
Style::detect().error(&format!("Tianheng constitution error: {message}"))
);
eprintln!("refusing to write a baseline from a constitution that could not be evaluated");
return EXIT_CANNOT_JUDGE;
}
let empty = Report::empty();
let report = match outcome {
Outcome::Violations(report) => report,
_ => &empty,
};
let baseline = match std::fs::read_to_string(path) {
Ok(text) => match Baseline::from_json(&text) {
Ok(existing) => Baseline::of_preserving(report, &existing),
Err(err) => {
eprintln!(
"Tianheng: existing baseline {path} could not be parsed ({err}); writing a \
fresh baseline — owner/tracker metadata is not carried forward"
);
Baseline::of(report)
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Baseline::of(report),
Err(err) => {
eprintln!(
"Tianheng: existing baseline {path} could not be read ({err}); writing a fresh \
baseline — owner/tracker metadata is not carried forward"
);
Baseline::of(report)
}
};
match std::fs::write(path, baseline.to_json()) {
Ok(()) => {
eprintln!(
"Tianheng: wrote {} violation(s) to baseline {path}",
report.violations.len()
);
0
}
Err(err) => {
eprintln!("Tianheng: cannot write baseline {path}: {err}");
2
}
}
}
fn gate(
outcome: &mut Outcome,
path: &str,
format: ReportFormat,
coverage: Option<&Coverage>,
warn_uncovered: bool,
) -> u8 {
if let Outcome::ConstitutionError(message) = outcome {
match format {
ReportFormat::Json => println!("{}", report_json(outcome, &[], None)),
ReportFormat::Sarif => println!("{}", report_sarif(outcome)),
ReportFormat::Text => eprintln!(
"{}",
Style::detect().error(&format!("Tianheng constitution error: {message}"))
),
}
return EXIT_CANNOT_JUDGE;
}
let baseline = match std::fs::read_to_string(path) {
Ok(text) => match Baseline::from_json(&text) {
Ok(baseline) => baseline,
Err(err) => {
eprintln!("Tianheng: invalid baseline {path}: {err}");
return EXIT_CANNOT_JUDGE;
}
},
Err(err) => {
eprintln!("Tianheng: cannot read baseline {path}: {err}");
return EXIT_CANNOT_JUDGE;
}
};
if let Outcome::Violations(report) = outcome {
apply_baseline(report, &baseline);
}
let empty = Report::empty();
let report = match &*outcome {
Outcome::Violations(report) => report,
_ => &empty,
};
let stale: Vec<ViolationId> = baseline.stale(report).into_iter().cloned().collect();
match format {
ReportFormat::Json => println!("{}", report_json(outcome, &stale, coverage)),
ReportFormat::Sarif => println!("{}", report_sarif(outcome)),
ReportFormat::Text => {
report_violations(report);
for entry in &stale {
eprintln!(
"Tianheng: stale baseline entry (no longer violated): {} / {} / {}",
entry.target, entry.rule, entry.finding
);
}
if let Some(coverage) = coverage {
report_coverage(coverage, warn_uncovered);
}
}
}
outcome.exit_code()
}
fn merge_outcomes(static_outcome: Outcome, semantic_outcome: Outcome) -> Outcome {
if matches!(static_outcome, Outcome::ConstitutionError(_)) {
return static_outcome;
}
if matches!(semantic_outcome, Outcome::ConstitutionError(_)) {
return semantic_outcome;
}
let mut violations = Vec::new();
if let Outcome::Violations(report) = &static_outcome {
violations.extend(report.violations.iter().cloned());
}
if let Outcome::Violations(report) = &semantic_outcome {
violations.extend(report.violations.iter().cloned());
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
#[cfg(test)]
mod tests;