use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::Args;
use rossi_build::pog::reconcile::reconcile_build_files;
use rossi_build::project::{duplicate_component_name, project_from_text_components};
use rossi_build::{BuildResult, Project, ScFile, build, is_normal_path_component};
use rossi::NamedComponent;
use super::build_common::{
build_archive_projects, eb019_result, error_diagnostic_count, gate_after_write,
gate_before_write, repack_results, report_diagnostics,
};
use super::eventb_io::{self, InputKind};
#[derive(Args)]
pub struct BuildArgs {
pub input: PathBuf,
#[arg(short, long)]
pub output: Option<PathBuf>,
}
pub fn run_build_command(args: BuildArgs) -> ExitCode {
match run_build(&args.input, args.output.as_deref()) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("rossi build: {e}");
ExitCode::from(1)
}
}
}
fn run_build(input: &Path, output: Option<&Path>) -> Result<(), Box<dyn std::error::Error>> {
let outcome = build_one(input)?;
let failed = gate_before_write(&outcome.results)?;
let default_out;
let out_path = match output {
Some(p) => p,
None => {
let stem = input
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("project");
default_out = input
.parent()
.unwrap_or_else(|| Path::new("."))
.join(format!("{stem}.regen.zip"));
&default_out
}
};
write_output(input, out_path, &outcome)?;
report_diagnostics(&outcome.results);
let errors = error_diagnostic_count(&outcome.results);
let files: usize = outcome.results.iter().map(|(_, r)| r.files.len()).sum();
eprintln!(
"rossi build: wrote {} -> {} ({} file(s) across {} project(s), {} error diagnostic(s))",
input.display(),
out_path.display(),
files,
outcome.results.len(),
errors
);
gate_after_write(&outcome.results, &failed, "checked output")
}
struct BuildOutcome {
results: Vec<(String, BuildResult)>,
archive_bytes: Option<Vec<u8>>,
}
fn build_one(input: &Path) -> Result<BuildOutcome, Box<dyn std::error::Error>> {
if input.is_dir() {
if !eventb_io::collect_rodin_xml_files(&[input.to_path_buf()])?.is_empty() {
let project = Project::from_directory(input)?;
let result = build(&project);
return Ok(BuildOutcome {
results: vec![(String::new(), result)],
archive_bytes: None,
});
}
let text_files = eventb_io::collect_eventb_files(&[input.to_path_buf()])?;
if text_files.is_empty() {
return Err(format!("no Event-B files found in {}", input.display()).into());
}
return build_from_text_files(&dir_project_name(input), &text_files);
}
match eventb_io::classify_file(input)? {
InputKind::Text => build_from_text_files(&file_project_name(input), &[input.to_path_buf()]),
InputKind::RodinXml => build_from_components(
&file_project_name(input),
vec![eventb_io::parse_rodin_xml_file(input)?],
),
InputKind::RodinZip => build_from_zip(input),
}
}
fn build_from_text_files(
name: &str,
files: &[PathBuf],
) -> Result<BuildOutcome, Box<dyn std::error::Error>> {
let mut components = Vec::new();
for path in files {
let source = std::fs::read_to_string(path)?;
components.extend(eventb_io::parse_text_components(
&path.display().to_string(),
&source,
)?);
}
build_from_components(name, components)
}
fn build_from_components(
name: &str,
components: Vec<NamedComponent>,
) -> Result<BuildOutcome, Box<dyn std::error::Error>> {
if components.is_empty() {
return Err("no Event-B components to build".into());
}
if duplicate_component_name(&components).is_some() {
return Ok(BuildOutcome {
results: vec![(String::new(), eb019_result(name, components))],
archive_bytes: None,
});
}
let (bytes, project) = project_from_text_components(name, &components)?;
Ok(BuildOutcome {
results: vec![(String::new(), build(&project))],
archive_bytes: Some(bytes),
})
}
fn build_from_zip(input: &Path) -> Result<BuildOutcome, Box<dyn std::error::Error>> {
let bytes = std::fs::read(input)?;
build_zip_bytes(&file_project_name(input), bytes)
}
fn build_zip_bytes(
fallback_name: &str,
bytes: Vec<u8>,
) -> Result<BuildOutcome, Box<dyn std::error::Error>> {
let results = build_archive_projects(&bytes, fallback_name)?;
if results.is_empty() {
return Err("no Event-B projects found in archive".into());
}
Ok(BuildOutcome {
results,
archive_bytes: Some(bytes),
})
}
fn file_project_name(input: &Path) -> String {
input
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("project")
.to_string()
}
fn dir_project_name(input: &Path) -> String {
input
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("project")
.to_string()
}
fn write_output(
input: &Path,
out_path: &Path,
outcome: &BuildOutcome,
) -> Result<(), Box<dyn std::error::Error>> {
let is_zip_out = out_path
.extension()
.and_then(|s| s.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("zip"));
if is_zip_out {
write_zip(input, out_path, outcome)
} else {
write_dir(out_path, outcome)
}
}
fn write_zip(
input: &Path,
out_path: &Path,
outcome: &BuildOutcome,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let bytes = match &outcome.archive_bytes {
Some(b) => repack_results(b, &outcome.results)?,
None => {
let empty = BuildResult {
files: vec![],
diagnostics: vec![],
};
let result = outcome.results.first().map_or(&empty, |(_, r)| r);
synthesize_flat_zip(input, result)?
}
};
std::fs::write(out_path, bytes)?;
Ok(())
}
fn write_dir(out_dir: &Path, outcome: &BuildOutcome) -> Result<(), Box<dyn std::error::Error>> {
let multi = outcome.results.len() > 1;
let mut pending = Vec::new();
let mut relative_paths = std::collections::BTreeSet::new();
for (prefix, result) in &outcome.results {
let project_dir = loose_project_dir(prefix)?;
for f in &result.files {
if !is_normal_path_component(&f.filename) {
return Err(format!(
"unsafe generated filename {:?}; loose output filenames must be one normal path component",
f.filename
)
.into());
}
let relative = if multi {
project_dir
.as_ref()
.map_or_else(|| PathBuf::from(&f.filename), |dir| dir.join(&f.filename))
} else {
PathBuf::from(&f.filename)
};
if !relative_paths.insert(relative.clone()) {
return Err(
format!("duplicate loose output destination {}", relative.display()).into(),
);
}
pending.push((relative, f.contents.clone()));
}
}
std::fs::create_dir_all(out_dir)?;
let canonical_root = std::fs::canonicalize(out_dir)?;
if !std::fs::metadata(&canonical_root)?.is_dir() {
return Err(format!("output path is not a directory: {}", out_dir.display()).into());
}
let parents: std::collections::BTreeSet<PathBuf> = pending
.iter()
.filter_map(|(relative, _)| out_dir.join(relative).parent().map(Path::to_path_buf))
.collect();
visit_resolved_output_paths(out_dir, &canonical_root, &parents, &pending, |_| {})?;
for parent in &parents {
std::fs::create_dir_all(parent)?;
}
let mut destinations = Vec::with_capacity(pending.len());
visit_resolved_output_paths(out_dir, &canonical_root, &parents, &pending, |path| {
destinations.push(path)
})?;
reconcile_pending(&mut pending, &destinations);
for ((_, contents), destination) in pending.iter().zip(destinations) {
std::fs::write(destination, contents)?;
}
Ok(())
}
fn reconcile_pending(pending: &mut [(PathBuf, String)], destinations: &[PathBuf]) {
let destination_of: std::collections::HashMap<String, &PathBuf> = pending
.iter()
.zip(destinations)
.map(|((relative, _), destination)| (relative.to_string_lossy().into_owned(), destination))
.collect();
let mut files: Vec<ScFile> = pending
.iter_mut()
.map(|(relative, contents)| ScFile {
filename: relative.to_string_lossy().into_owned(),
contents: std::mem::take(contents),
accurate: true,
})
.collect();
reconcile_build_files(&mut files, |name| {
destination_of
.get(name)
.and_then(|destination| std::fs::read_to_string(destination).ok())
});
for ((_, contents), file) in pending.iter_mut().zip(files) {
*contents = file.contents;
}
}
fn loose_project_dir(prefix: &str) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
if prefix.is_empty() {
return Ok(None);
}
let segment = prefix.strip_suffix('/').unwrap_or_default();
if !is_normal_path_component(segment) {
return Err(format!(
"unsafe archive prefix {prefix:?}; loose output requires an empty prefix or exactly one normal path component"
)
.into());
}
Ok(Some(PathBuf::from(segment)))
}
fn visit_resolved_output_paths(
out_dir: &Path,
canonical_root: &Path,
parents: &std::collections::BTreeSet<PathBuf>,
pending: &[(PathBuf, String)],
mut visit: impl FnMut(PathBuf),
) -> Result<(), Box<dyn std::error::Error>> {
let mut resolved_parents = std::collections::BTreeMap::new();
for parent in parents {
resolved_parents.insert(
parent.clone(),
resolve_output_parent(out_dir, canonical_root, parent)?,
);
}
let mut resolved_paths = std::collections::BTreeSet::new();
for (relative, _) in pending {
let lexical = out_dir.join(relative);
let parent = lexical
.parent()
.ok_or_else(|| format!("output destination has no parent: {}", lexical.display()))?;
let resolved_parent = resolved_parents
.get(parent)
.ok_or_else(|| format!("output destination has no parent: {}", lexical.display()))?;
if !resolved_parent.starts_with(canonical_root) {
return Err(format!(
"output destination {} escapes output directory {}",
lexical.display(),
out_dir.display()
)
.into());
}
let resolved = match std::fs::symlink_metadata(&lexical) {
Ok(_) => {
let path = std::fs::canonicalize(&lexical)?;
if !std::fs::metadata(&path)?.is_file() {
return Err(
format!("output destination is not a file: {}", lexical.display()).into(),
);
}
path
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
resolved_parent.join(lexical.file_name().ok_or_else(|| {
format!("output destination has no filename: {}", lexical.display())
})?)
}
Err(e) => return Err(e.into()),
};
if !resolved.starts_with(canonical_root) {
return Err(format!(
"output destination {} escapes output directory {}",
lexical.display(),
out_dir.display()
)
.into());
}
if !resolved_paths.insert(resolved.clone()) {
return Err(format!("duplicate loose output destination {}", lexical.display()).into());
}
visit(resolved);
}
Ok(())
}
fn resolve_output_parent(
out_dir: &Path,
canonical_root: &Path,
parent: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
match std::fs::symlink_metadata(parent) {
Ok(_) => {
let resolved = std::fs::canonicalize(parent)?;
if !std::fs::metadata(&resolved)?.is_dir() {
return Err(
format!("output parent is not a directory: {}", parent.display()).into(),
);
}
Ok(resolved)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let relative = parent.strip_prefix(out_dir)?;
Ok(canonical_root.join(relative))
}
Err(e) => Err(e.into()),
}
}
fn synthesize_flat_zip(
input: &Path,
result: &BuildResult,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
use std::io::Write;
use zip::write::{SimpleFileOptions, ZipWriter};
let prefix = input
.file_name()
.and_then(|s| s.to_str())
.map(|s| format!("{s}/"))
.unwrap_or_default();
let mut files = result.files.clone();
let mut proofs: Vec<PathBuf> = Vec::new();
if input.is_dir() {
reconcile_build_files(&mut files, |name| {
std::fs::read_to_string(input.join(name)).ok()
});
proofs = std::fs::read_dir(input)?
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.extension().and_then(|s| s.to_str()) == Some("bpr") && path.is_file()
})
.collect();
proofs.sort();
}
let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
let mut w = ZipWriter::new(&mut cursor);
let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for f in &files {
w.start_file(format!("{prefix}{}", f.filename), opts)?;
w.write_all(f.contents.as_bytes())?;
}
for path in proofs {
let Some(filename) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
w.start_file(format!("{prefix}{filename}"), opts)?;
w.write_all(&std::fs::read(&path)?)?;
}
w.finish()?;
Ok(cursor.into_inner())
}