use std::{
collections::BTreeMap,
error::Error,
fmt::Write as _,
fs,
path::{Path, PathBuf},
process::ExitCode,
time::{SystemTime, UNIX_EPOCH},
};
mod compare;
mod digest;
mod geometry;
use axioval::{
bcf,
engine::{
DisciplineMap, DisciplineOrigin, DisciplineRule, EvidenceSession, IntegritySeverity,
LocationMethod, LocationPolicy, QUALIFIED_RULE_SEPARATOR, Runtime,
SourceIntegrityServiceHandle, SourceMetadata, UnmappedReason, compile_rulesets,
},
ifc::import_ifc_session,
ir::{
DateTime, Decision, DecisionStatus, Decisions, DefinitionPackage, Discipline, FindingId,
ObjectId, Project, Report, RuleSetPackage, SourceId, contract::SourceField,
},
};
use clap::{Args, Parser, Subcommand};
use digest::{CheckOutput, Filter, IntegrityRecord, Section};
#[derive(Parser)]
#[command(name = "axioval", version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Validate {
#[arg(long, required = true)]
definitions: Vec<PathBuf>,
#[arg(long = "ruleset", required = true)]
rulesets: Vec<PathBuf>,
},
Check(CheckArgs),
Compare(compare::CompareArgs),
Report(ReportArgs),
Decide(DecideArgs),
}
#[derive(Args)]
struct CheckArgs {
#[arg(long = "model", required = true, value_name = "PATH[:DISCIPLINE]", value_parser = model_arg)]
models: Vec<ModelArg>,
#[arg(long = "discipline-map", value_name = "FIELD:PATTERN=DISCIPLINE", value_parser = discipline_rule)]
discipline_map: Vec<DisciplineRule>,
#[arg(long, required = true)]
definitions: Vec<PathBuf>,
#[arg(long = "ruleset", required = true)]
rulesets: Vec<PathBuf>,
#[arg(long)]
geometry: bool,
#[arg(long, value_enum, default_value_t = Locate::None)]
locate: Locate,
#[arg(long)]
rule_status: bool,
#[arg(long, value_name = "FILE")]
decisions: Option<PathBuf>,
#[command(flatten)]
output: OutputArgs,
}
#[derive(Args)]
struct DecideArgs {
result: PathBuf,
#[arg(long, value_name = "FILE")]
decisions: PathBuf,
#[arg(long = "finding", required = true, value_name = "ID")]
findings: Vec<FindingId>,
#[arg(long, value_enum)]
status: StatusArg,
#[arg(long)]
author: String,
#[arg(long, default_value = "")]
comment: String,
#[arg(long)]
date: Option<DateTime>,
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum StatusArg {
Accepted,
Rejected,
Open,
}
impl From<StatusArg> for DecisionStatus {
fn from(status: StatusArg) -> Self {
match status {
StatusArg::Accepted => Self::Accepted,
StatusArg::Rejected => Self::Rejected,
StatusArg::Open => Self::Open,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
enum Locate {
None,
Storeys,
Containers,
Geometry,
}
impl Locate {
fn policy(self) -> Option<LocationPolicy> {
let method = match self {
Self::None => return None,
Self::Storeys => LocationMethod::Storeys,
Self::Containers => LocationMethod::Containers,
Self::Geometry => LocationMethod::Geometry,
};
Some(LocationPolicy {
method,
storey_kinds: vec!["IfcBuildingStorey".to_owned()],
space_kinds: vec!["IfcSpace".to_owned()],
containment: vec![
"IfcRelContainedInSpatialStructure:backward".to_owned(),
"IfcRelAggregates:backward".to_owned(),
],
name: Some((axioval::ir::ATTRIBUTE_SET.to_owned(), "Name".to_owned())),
})
}
}
#[derive(Args)]
struct OutputArgs {
#[arg(long)]
report: Option<PathBuf>,
#[arg(long)]
bcf: Option<PathBuf>,
#[arg(long, value_enum, default_value = "2.1", requires = "bcf")]
bcf_version: BcfVersion,
#[arg(long, default_value = "axioval", requires = "bcf")]
bcf_author: String,
#[arg(long, requires = "bcf")]
bcf_date: Option<String>,
#[command(flatten)]
bcf_view: BcfViewArgs,
#[arg(long)]
summary: bool,
#[arg(long, default_value_t = 10, requires = "summary")]
top: usize,
}
#[derive(Args)]
struct BcfViewArgs {
#[arg(long = "bcf-subject-color", value_name = "HEX", requires = "bcf")]
subject_color: Option<bcf::Color>,
#[arg(long = "bcf-related-color", value_name = "HEX", requires = "bcf")]
related_color: Option<bcf::Color>,
#[arg(
long = "bcf-no-color",
requires = "bcf",
conflicts_with_all = ["subject_color", "related_color"]
)]
no_color: bool,
#[arg(long = "bcf-isolate", requires = "bcf")]
isolate: bool,
#[arg(long = "bcf-section-box", requires = "bcf")]
section_box: bool,
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum BcfVersion {
#[value(name = "2.1")]
V2_1,
#[value(name = "3.0")]
V3_0,
}
impl From<BcfVersion> for bcf::Version {
fn from(version: BcfVersion) -> Self {
match version {
BcfVersion::V2_1 => Self::V2_1,
BcfVersion::V3_0 => Self::V3_0,
}
}
}
#[derive(Args)]
struct ReportArgs {
result: PathBuf,
#[arg(long, value_enum)]
section: Option<Section>,
#[arg(long)]
rule: Option<String>,
#[arg(long)]
code: Option<String>,
#[arg(long)]
object: Option<String>,
#[arg(long)]
location: Option<String>,
#[arg(long, value_enum)]
decision: Option<digest::DecisionFilter>,
#[arg(long)]
evidence: bool,
#[arg(long, default_value_t = 20)]
limit: usize,
#[arg(long, default_value_t = 0)]
offset: usize,
#[arg(long, default_value_t = 10)]
top: usize,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with_all = ["json", "section", "code", "object", "location"])]
csv: bool,
#[arg(long, requires = "csv")]
table: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ModelArg {
path: PathBuf,
discipline: Option<Discipline>,
}
fn model_arg(value: &str) -> Result<ModelArg, String> {
let whole = || ModelArg {
path: PathBuf::from(value),
discipline: None,
};
let Some((path, suffix)) = value.rsplit_once(':') else {
return Ok(whole());
};
if path.is_empty() {
return Ok(whole());
}
if suffix.is_empty() {
return Ok(ModelArg {
path: PathBuf::from(path),
discipline: None,
});
}
let name_like = suffix
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_');
if !name_like {
return Ok(whole());
}
let discipline = Discipline::new(suffix).map_err(|error| error.to_string())?;
Ok(ModelArg {
path: PathBuf::from(path),
discipline: Some(discipline),
})
}
fn discipline_rule(value: &str) -> Result<DisciplineRule, String> {
let usage = || format!("`{value}` is not FIELD:PATTERN=DISCIPLINE");
let (field, rest) = value.split_once(':').ok_or_else(usage)?;
let (pattern, discipline) = rest.rsplit_once('=').ok_or_else(usage)?;
let field = match field {
"application" => SourceField::Application,
"fileName" => SourceField::FileName,
"project" => SourceField::Project,
"schema" => SourceField::Schema,
"timestamp" => SourceField::Timestamp,
other => {
return Err(format!(
"`{other}` is not `application`, `fileName`, `project`, `schema` or `timestamp`"
));
}
};
let discipline = Discipline::new(discipline).map_err(|error| error.to_string())?;
DisciplineRule::new(field, pattern, discipline).map_err(|error| error.to_string())
}
fn source_infos(session: &EvidenceSession) -> Vec<digest::SourceInfo> {
session
.snapshots()
.map(|snapshot| {
let source = snapshot.source();
let origin = session.discipline_origin(source);
let (mapped_by, mapped_value) = match origin {
Some(DisciplineOrigin::Mapped { rule, value, .. }) => {
(Some(rule.clone()), Some(value.clone()))
}
_ => (None, None),
};
digest::SourceInfo {
source: source.to_string(),
discipline: session.discipline(source).map(ToString::to_string),
discipline_origin: origin.map(|origin| {
match origin {
DisciplineOrigin::Declared => "declared",
DisciplineOrigin::Mapped { .. } => "mapped",
}
.to_owned()
}),
mapped_by,
mapped_value,
unmapped: session.unmapped(source).map(|reason| match reason {
UnmappedReason::NoMatch => "no rule of the discipline map matches".to_owned(),
UnmappedReason::Unread(rule) => {
format!("the model does not state what `{rule}` reads")
}
}),
}
})
.collect()
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Outcome {
Passed,
Findings,
Incomplete,
}
impl Outcome {
fn of(report: &Report) -> Self {
if !report.findings().is_empty() {
Self::Findings
} else if !report.not_evaluated().is_empty() {
Self::Incomplete
} else {
Self::Passed
}
}
fn code(&self) -> ExitCode {
ExitCode::from(match self {
Self::Passed => 0,
Self::Findings => 3,
Self::Incomplete => 4,
})
}
}
fn load<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, Box<dyn Error>> {
let text = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?;
Ok(serde_json::from_str(&text).map_err(|error| format!("{}: {error}", path.display()))?)
}
fn packages(
definitions: &[PathBuf],
rulesets: &[PathBuf],
) -> Result<(Vec<DefinitionPackage>, Vec<RuleSetPackage>), Box<dyn Error>> {
let definitions = definitions
.iter()
.map(|path| load(path))
.collect::<Result<Vec<DefinitionPackage>, _>>()?;
let rulesets = rulesets
.iter()
.map(|path| load(path))
.collect::<Result<Vec<RuleSetPackage>, _>>()?;
Ok((definitions, rulesets))
}
fn run() -> Result<Outcome, Box<dyn Error>> {
match Cli::parse().command {
Command::Validate {
definitions,
rulesets,
} => {
let (definitions, rulesets) = packages(&definitions, &rulesets)?;
let plan = compile_rulesets(&axioval::default_registry()?, &definitions, &rulesets)?;
println!("validated {} executable rule(s)", plan.rules().len());
Ok(Outcome::Passed)
}
Command::Check(args) => check(args),
Command::Compare(args) => compare::compare(args),
Command::Report(args) => {
report(args)?;
Ok(Outcome::Passed)
}
Command::Decide(args) => {
decide(&args)?;
Ok(Outcome::Passed)
}
}
}
fn decide(args: &DecideArgs) -> Result<(), Box<dyn Error>> {
let output: CheckOutput = load(&args.result)?;
let mut decisions: Decisions = if args.decisions.exists() {
load(&args.decisions)?
} else {
Decisions::default()
};
let date = match args.date {
Some(date) => date,
None => i64::try_from(epoch_seconds()?)
.ok()
.and_then(DateTime::from_unix_seconds)
.ok_or("the current time is not a date-time in the years 0000 to 9999")?,
};
for id in &args.findings {
let finding = output
.report
.findings()
.iter()
.find(|finding| finding.id == Some(*id))
.ok_or_else(|| format!("{}: no finding has id {id}", args.result.display()))?;
let decision = Decision::new(*id, args.status.into(), &args.author, date)?
.with_comment(args.comment.trim())
.with_basis(finding);
decisions.record(decision)?;
}
let json = serde_json::to_string_pretty(&decisions)? + "\n";
write(&args.decisions, json.as_bytes())?;
println!(
"recorded {} decision(s) in {}",
args.findings.len(),
args.decisions.display()
);
Ok(())
}
fn check(args: CheckArgs) -> Result<Outcome, Box<dyn Error>> {
let (definitions, rulesets) = packages(&args.definitions, &args.rulesets)?;
let registry = axioval::default_registry()?;
let plan = compile_rulesets(®istry, &definitions, &rulesets)?;
if args.locate == Locate::Geometry && !args.geometry {
return Err("`--locate geometry` needs `--geometry` to derive spaces from bodies".into());
}
let labels = rule_labels(&rulesets);
let decisions: Option<Decisions> = args.decisions.as_deref().map(load).transpose()?;
let (session, bytes) = sources(&args.models)?;
let session = session.with_discipline_map(
&args
.discipline_map
.iter()
.cloned()
.fold(DisciplineMap::new(), DisciplineMap::with),
);
let (session, meshed) = if args.geometry {
let (session, report) =
geometry::attach(session, &bytes).map_err(|error| format!("geometry: {error}"))?;
(session, Some(report))
} else {
(session, None)
};
let mut runtime = Runtime::new(registry);
if let Some(policy) = args.locate.policy() {
runtime = runtime.with_locations(policy);
}
if args.rule_status {
runtime = runtime.with_rule_summaries();
}
let mut result = runtime.run_session(&session, plan)?;
result.identify_findings(session.project(), bcf::IFC_GLOBAL_ID_SCHEME)?;
if let Some(decisions) = &decisions {
result.apply_decisions(decisions)?;
}
let integrity = integrity(&session)?;
let geometry = meshed.map(|report| digest::GeometryRecord {
exact: report.exact,
tessellated: report.tessellated,
no_body: report.no_body,
unmeasured: report
.unmeasured
.into_iter()
.map(|(object, reason)| digest::Unmeasured { object, reason })
.collect(),
});
let output = CheckOutput::new(result, integrity, geometry, session.project())
.with_sources(source_infos(&session));
let bounds = args
.geometry
.then(|| geometry::bounds(&[&session], &output.report));
emit(&output, session.project(), bounds, labels, args.output)?;
Ok(Outcome::of(&output.report))
}
fn rule_labels(rulesets: &[RuleSetPackage]) -> BTreeMap<String, Vec<String>> {
let qualify = rulesets.len() > 1;
let mut labels = BTreeMap::new();
for ruleset in rulesets {
for (rule, own) in bcf::ruleset_labels(ruleset) {
let rule = if qualify {
format!("{}{QUALIFIED_RULE_SEPARATOR}{rule}", ruleset.package.id)
} else {
rule
};
labels.insert(rule, own);
}
}
labels
}
pub(crate) fn emit(
output: &CheckOutput,
project: &Project,
bounds: Option<BTreeMap<ObjectId, bcf::Bounds>>,
rule_labels: BTreeMap<String, Vec<String>>,
args: OutputArgs,
) -> Result<(), Box<dyn Error>> {
let json = serde_json::to_string_pretty(output)? + "\n";
let archive = match &args.bcf {
Some(path) => {
let date = match args.bcf_date {
Some(date) => date,
None => timestamp()?,
};
let colored = !args.bcf_view.no_color
&& (bounds.is_some()
|| args.bcf_view.subject_color.is_some()
|| args.bcf_view.related_color.is_some());
let defaults = bcf::Colors::default();
let colors = colored.then(|| bcf::Colors {
subject: args.bcf_view.subject_color.unwrap_or(defaults.subject),
related: args.bcf_view.related_color.unwrap_or(defaults.related),
});
let options = bcf::Options {
version: args.bcf_version.into(),
colors,
isolate: args.bcf_view.isolate,
section_box: args.bcf_view.section_box,
bounds,
rule_labels,
..bcf::Options::new(args.bcf_author, date)
};
let export = bcf::export(&output.report, project, &options)?;
Some((path, export.to_bytes()?, export.unanchored, export.unframed))
}
None => None,
};
let summary = args.summary.then(|| {
let saved = args
.report
.as_deref()
.map(|path| path.to_string_lossy().into_owned());
digest::render_summary(&digest::summarize(output, args.top, saved.as_deref()))
});
if let Some(path) = &args.report {
write(path, json.as_bytes())?;
}
match &summary {
Some(text) => print!("{text}"),
None if args.report.is_none() => print!("{json}"),
None => {}
}
if let Some((path, bytes, _, _)) = &archive {
write(path, bytes)?;
}
let unanchored: Vec<_> = archive
.iter()
.flat_map(|(_, _, unanchored, _)| unanchored)
.collect();
let unframed: Vec<_> = archive
.iter()
.flat_map(|(_, _, _, unframed)| unframed)
.collect();
warn(output, summary.is_some(), &unanchored, &unframed);
Ok(())
}
fn report(args: ReportArgs) -> Result<(), Box<dyn Error>> {
let output: CheckOutput = load(&args.result)?;
if args.csv {
print!("{}", table_csv(&output, &args)?);
return Ok(());
}
let path = args.result.to_string_lossy();
let filtered = args.section.is_some()
|| args.rule.is_some()
|| args.code.is_some()
|| args.object.is_some()
|| args.location.is_some()
|| args.decision.is_some();
let text = if filtered {
let command = listing_command(&path, &args);
let filter = Filter {
section: args.section,
rule: args.rule,
code: args.code,
object: args.object,
location: args.location,
decision: args.decision,
};
let listing = digest::list(
&output,
&filter,
args.offset,
args.limit,
args.evidence,
&command,
);
if args.json {
serde_json::to_string_pretty(&listing)? + "\n"
} else {
digest::render_listing(&listing)
}
} else {
let summary = digest::summarize(&output, args.top, Some(&path));
if args.json {
serde_json::to_string_pretty(&summary)? + "\n"
} else {
digest::render_summary(&summary)
}
};
print!("{text}");
Ok(())
}
fn table_csv(output: &CheckOutput, args: &ReportArgs) -> Result<String, Box<dyn Error>> {
let tables: Vec<_> = output
.report
.tables()
.iter()
.filter(|table| {
args.rule
.as_deref()
.is_none_or(|rule| rule == table.rule_id().to_string())
&& args
.table
.as_deref()
.is_none_or(|name| name == table.name())
})
.collect();
match tables.as_slice() {
[table] => Ok(digest::table_csv(table)),
tables => {
let names: Vec<String> = if tables.is_empty() {
output.report.tables().iter().collect::<Vec<_>>()
} else {
tables.to_vec()
}
.iter()
.map(|table| format!("--rule {} --table {}", table.rule_id(), table.name()))
.collect();
Err(format!(
"--csv needs exactly one table, but {} tables match; candidates: {}",
tables.len(),
if names.is_empty() {
"none, the result has no tables".to_owned()
} else {
names.join("; ")
}
)
.into())
}
}
}
fn warn(output: &CheckOutput, summarized: bool, unanchored: &[&ObjectId], unframed: &[&ObjectId]) {
if summarized {
if !unanchored.is_empty() {
eprintln!(
"warning: {} object(s) have no valid unique GlobalId; their BCF topics select no component",
unanchored.len()
);
}
if !unframed.is_empty() {
eprintln!(
"warning: {} object(s) have no measured bounds; their BCF viewpoints have no camera",
unframed.len()
);
}
} else {
for record in &output.integrity {
eprintln!("{}: {}: {}", record.severity, record.code, record.message);
}
for object in unanchored {
eprintln!(
"warning: {object} has no valid unique GlobalId; its BCF topic selects no component"
);
}
for object in unframed {
eprintln!("warning: {object} has no measured bounds; its BCF viewpoint has no camera");
}
if let Some(geometry) = &output.geometry {
eprintln!(
"geometry: {} exact, {} tessellated, {} without body, {} unmeasured",
geometry.exact,
geometry.tessellated,
geometry.no_body,
geometry.unmeasured.len()
);
for unmeasured in &geometry.unmeasured {
eprintln!(
"warning: {} was not meshed: {}",
unmeasured.object, unmeasured.reason
);
}
}
eprintln!(
"{} finding(s), {} not evaluated, {} integrity issue(s)",
output.report.findings().len(),
output.report.not_evaluated().len(),
output.integrity.len()
);
}
}
fn listing_command(path: &str, args: &ReportArgs) -> String {
let mut command = format!("axioval report {}", digest::shell_quote(path));
if let Some(section) = args.section {
let name = clap::ValueEnum::to_possible_value(§ion)
.map(|value| value.get_name().to_owned())
.unwrap_or_default();
let _ = write!(command, " --section {name}");
}
for (flag, value) in [
("--rule", &args.rule),
("--code", &args.code),
("--object", &args.object),
("--location", &args.location),
] {
if let Some(value) = value {
let _ = write!(command, " {flag} {}", digest::shell_quote(value));
}
}
if let Some(decision) = args.decision {
let name = clap::ValueEnum::to_possible_value(&decision)
.map(|value| value.get_name().to_owned())
.unwrap_or_default();
let _ = write!(command, " --decision {name}");
}
if args.evidence {
command.push_str(" --evidence");
}
let _ = write!(command, " --limit {}", args.limit);
if args.json {
command.push_str(" --json");
}
command
}
fn sources(models: &[ModelArg]) -> Result<(EvidenceSession, geometry::ModelBytes), Box<dyn Error>> {
let mut members = Vec::with_capacity(models.len());
let mut bytes = geometry::ModelBytes::new();
let mut paths: BTreeMap<SourceId, &Path> = BTreeMap::new();
for model in models {
let path = model.path.as_path();
let content = fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
let mut session = import(path, &content)?;
let source = session
.snapshots()
.next()
.map(|snapshot| snapshot.source().clone())
.ok_or_else(|| format!("{}: the import produced no source", path.display()))?;
if let Some(first) = paths.insert(source.clone(), path) {
return Err(format!(
"{} and {} share the file name `{}`, which names the source; rename one",
first.display(),
path.display(),
source.document
)
.into());
}
session = session
.with_source_metadata(
&source,
SourceMetadata::new().with(SourceField::FileName, [source.document.clone()]),
)
.map_err(|error| format!("{}: {error}", path.display()))?;
if let Some(discipline) = &model.discipline {
session = session
.with_discipline(&source, discipline.clone())
.map_err(|error| format!("{}: {error}", path.display()))?;
}
bytes.insert(source, content);
members.push(session);
}
Ok((EvidenceSession::federate(members)?, bytes))
}
fn import(model: &Path, bytes: &[u8]) -> Result<EvidenceSession, Box<dyn Error>> {
let document = model
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| format!("{}: model path has no UTF-8 file name", model.display()))?;
Ok(import_ifc_session(document, bytes)
.map_err(|error| format!("{}: {error}", model.display()))?)
}
pub(crate) fn integrity(session: &EvidenceSession) -> Result<Vec<IntegrityRecord>, Box<dyn Error>> {
let Some(service) = session.service::<SourceIntegrityServiceHandle>() else {
return Ok(vec![]);
};
let mut records = Vec::new();
for snapshot in session.snapshots() {
for issue in service.issues(snapshot.source())? {
records.push(IntegrityRecord {
code: issue.code,
severity: match issue.severity {
IntegritySeverity::Warning => "warning",
IntegritySeverity::Error => "error",
}
.to_owned(),
message: issue.message,
locator: issue.evidence.locator,
});
}
}
Ok(records)
}
fn write(path: &Path, bytes: &[u8]) -> Result<(), Box<dyn Error>> {
Ok(fs::write(path, bytes).map_err(|error| format!("{}: {error}", path.display()))?)
}
fn timestamp() -> Result<String, Box<dyn Error>> {
Ok(utc(epoch_seconds()?))
}
fn epoch_seconds() -> Result<u64, Box<dyn Error>> {
Ok(match std::env::var("SOURCE_DATE_EPOCH") {
Ok(value) => value
.parse::<u64>()
.map_err(|_| format!("SOURCE_DATE_EPOCH `{value}` is not a count of seconds"))?,
Err(_) => SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
})
}
fn utc(seconds: u64) -> String {
let days = i64::try_from(seconds / 86_400).unwrap_or(i64::MAX);
let time = seconds % 86_400;
let shifted = days + 719_468;
let era = shifted.div_euclid(146_097);
let day_of_era = shifted.rem_euclid(146_097);
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_index = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_index + 2) / 5 + 1;
let month = if month_index < 10 {
month_index + 3
} else {
month_index - 9
};
let year = year_of_era + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
time / 3_600,
time % 3_600 / 60,
time % 60
)
}
fn main() -> ExitCode {
match run() {
Ok(outcome) => outcome.code(),
Err(error) => {
eprintln!("axioval: {error}");
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod tests {
use super::{ModelArg, model_arg, utc};
use axioval::ir::Discipline;
use std::path::PathBuf;
fn parsed(path: &str, discipline: Option<&str>) -> ModelArg {
ModelArg {
path: PathBuf::from(path),
discipline: discipline.map(|name| Discipline::new(name).unwrap()),
}
}
#[test]
fn a_model_argument_splits_off_its_discipline() {
assert_eq!(model_arg("m.ifc"), Ok(parsed("m.ifc", None)));
assert_eq!(
model_arg("dir/m.ifc:structure"),
Ok(parsed("dir/m.ifc", Some("structure")))
);
assert_eq!(
model_arg("m.ifc:building_services"),
Ok(parsed("m.ifc", Some("building_services")))
);
assert_eq!(
model_arg("C:\\models\\m.ifc"),
Ok(parsed("C:\\models\\m.ifc", None))
);
assert_eq!(
model_arg("C:\\m.ifc:mep"),
Ok(parsed("C:\\m.ifc", Some("mep")))
);
assert_eq!(model_arg("a:b/m.ifc"), Ok(parsed("a:b/m.ifc", None)));
assert_eq!(model_arg("odd:arch:"), Ok(parsed("odd:arch", None)));
assert_eq!(model_arg(":arch"), Ok(parsed(":arch", None)));
}
#[test]
fn a_discipline_that_is_not_a_lowercase_token_is_refused() {
assert!(model_arg("m.ifc:Structure").is_err());
assert!(model_arg("m.ifc:-structure").is_err());
}
#[test]
fn utc_formats_known_instants() {
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
assert_eq!(utc(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(utc(1_790_416_799), "2026-09-26T09:59:59Z");
assert_eq!(utc(4_107_542_400), "2100-03-01T00:00:00Z");
}
}