use crate::exit_codes::ExitCode;
use copybook_codec::RunSummary;
use copybook_core::{ParseOptions, Schema, parse_copybook_with_options};
use std::fmt::Write as FmtWrite;
use std::fs;
use std::io::{self, Read, Write};
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
use tempfile::NamedTempFile;
use tracing::{debug, info};
pub struct ErrorPolicy {
pub strict_mode: bool,
pub max_errors: Option<u64>,
}
#[must_use]
pub const fn effective_error_policy(
strict: bool,
fail_fast: bool,
max_errors: Option<u64>,
) -> ErrorPolicy {
ErrorPolicy {
strict_mode: strict || fail_fast,
max_errors: if fail_fast { Some(1) } else { max_errors },
}
}
pub fn log_strict_comments(strict_comments: bool) {
if strict_comments {
info!("Inline comments (*>) disabled (COBOL-85 compatibility)");
}
}
pub fn parse_projected_schema(
copybook: &Path,
config: &ParseOptionsConfig,
select_args: &[String],
) -> anyhow::Result<Schema> {
let copybook_text = read_file_or_stdin(copybook)?;
let parse_options = build_parse_options(config);
let schema = parse_copybook_with_options(©book_text, &parse_options)?;
apply_field_projection(schema, select_args)
}
pub fn parse_selectors(select_args: &[String]) -> Vec<String> {
use std::collections::BTreeSet;
select_args
.iter()
.flat_map(|s| s.split(','))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
pub fn apply_field_projection(schema: Schema, select_args: &[String]) -> anyhow::Result<Schema> {
if select_args.is_empty() {
return Ok(schema);
}
let selectors = parse_selectors(select_args);
info!(
"Applying field projection with {} selectors",
selectors.len()
);
copybook_core::project_schema(&schema, &selectors).map_err(|err| {
anyhow::anyhow!("Failed to apply field projection with selectors {selectors:?}: {err}")
})
}
pub struct ParseOptionsConfig<'a> {
pub strict: bool,
pub strict_comments: bool,
pub codepage: &'a str,
pub emit_filler: bool,
pub dialect: copybook_core::dialect::Dialect,
}
pub fn build_parse_options(config: &ParseOptionsConfig) -> ParseOptions {
ParseOptions {
strict_comments: config.strict_comments,
strict: config.strict,
codepage: config.codepage.to_string(),
emit_filler: config.emit_filler,
allow_inline_comments: !config.strict_comments,
dialect: config.dialect,
}
}
pub fn run_with_output<T, F>(
input: &Path,
output: &Path,
mut process: F,
) -> anyhow::Result<(T, bool)>
where
F: FnMut(fs::File, &mut dyn Write) -> anyhow::Result<T>,
{
let write_to_stdout = output == Path::new("-");
if write_to_stdout {
let input_file = fs::File::open(input)?;
let mut stdout = std::io::stdout().lock();
return Ok((process(input_file, &mut stdout)?, true));
}
let mut summary = None;
atomic_write(output, |output_writer| {
let input_file = fs::File::open(input).map_err(std::io::Error::other)?;
let run_summary = process(input_file, output_writer).map_err(std::io::Error::other)?;
summary = Some(run_summary);
Ok(())
})?;
let summary = summary.ok_or_else(|| {
anyhow::anyhow!("Internal error: summary not populated after successful processing")
})?;
Ok((summary, false))
}
#[derive(Clone, Copy)]
pub struct SummaryIssueCountStyle {
pub show_zero_counts: bool,
pub repeat_nonzero_counts: bool,
}
pub fn append_processing_summary(
output: &mut String,
title: &str,
summary: &RunSummary,
issue_style: SummaryIssueCountStyle,
) -> std::fmt::Result {
writeln!(output, "=== {title} Summary ===")?;
writeln!(output, "Records processed: {}", summary.records_processed)?;
if issue_style.show_zero_counts || summary.records_with_errors > 0 {
writeln!(
output,
"Records with errors: {}",
summary.records_with_errors
)?;
}
if issue_style.show_zero_counts || summary.warnings > 0 {
writeln!(output, "Warnings: {}", summary.warnings)?;
}
writeln!(output, "Processing time: {}ms", summary.processing_time_ms)?;
writeln!(output, "Bytes processed: {}", summary.bytes_processed)?;
writeln!(output, "Throughput: {:.2} MB/s", summary.throughput_mbps)?;
if issue_style.repeat_nonzero_counts {
if summary.has_warnings() {
writeln!(output, "Warnings: {}", summary.warnings)?;
}
if summary.has_errors() {
writeln!(
output,
"Records with errors: {}",
summary.records_with_errors
)?;
}
}
Ok(())
}
pub fn atomic_write<P: AsRef<Path>, F>(path: P, write_fn: F) -> io::Result<()>
where
F: FnOnce(&mut dyn Write) -> io::Result<()>,
{
let path = path.as_ref();
let temp_dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut temp_file = NamedTempFile::new_in(temp_dir)?;
debug!("Writing to temporary file: {:?}", temp_file.path());
write_fn(&mut temp_file)?;
temp_file.flush()?;
temp_file.as_file().sync_all()?;
debug!("Renaming {:?} to {:?}", temp_file.path(), path);
temp_file.persist(path)?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::expect_used)]
#[allow(clippy::unwrap_used)]
fn temp_path_for(target: &Path) -> PathBuf {
let mut temp_name = target
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("output"))
.to_os_string();
temp_name.push(".tmp");
if let Some(parent) = target.parent() {
parent.join(temp_name)
} else {
PathBuf::from(temp_name)
}
}
pub fn determine_exit_code(
has_warnings: bool,
has_errors: bool,
failure_code: ExitCode,
) -> ExitCode {
let _ = has_warnings; if has_errors {
failure_code
} else {
ExitCode::Ok
}
}
pub fn read_file_or_stdin<P: AsRef<Path>>(path: P) -> io::Result<String> {
let path = path.as_ref();
if path == Path::new("-") {
debug!("Reading from stdin");
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)?;
Ok(buffer)
} else {
debug!("Reading from file: {:?}", path);
std::fs::read_to_string(path)
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use anyhow::Result;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_atomic_write_success() -> Result<()> {
let temp_dir = tempdir()?;
let target_path = temp_dir.path().join("test.txt");
let result = atomic_write(&target_path, |writer| writer.write_all(b"Hello, world!"));
assert!(result.is_ok());
assert!(target_path.exists());
let content = fs::read_to_string(&target_path)?;
assert_eq!(content, "Hello, world!");
Ok(())
}
#[test]
fn test_atomic_write_failure_leaves_no_file() -> Result<()> {
let temp_dir = tempdir()?;
let target_path = temp_dir.path().join("test.txt");
let result = atomic_write(&target_path, |_writer| {
Err(io::Error::other("Simulated error"))
});
assert!(result.is_err());
assert!(!target_path.exists());
Ok(())
}
#[test]
fn test_determine_exit_code() {
assert_eq!(
determine_exit_code(false, false, ExitCode::Data),
ExitCode::Ok
); assert_eq!(
determine_exit_code(true, false, ExitCode::Data),
ExitCode::Ok
); assert_eq!(
determine_exit_code(false, true, ExitCode::Data),
ExitCode::Data
); assert_eq!(
determine_exit_code(true, true, ExitCode::Encode),
ExitCode::Encode
); }
#[test]
fn test_effective_error_policy() {
let lenient = effective_error_policy(false, false, Some(25));
assert!(!lenient.strict_mode);
assert_eq!(lenient.max_errors, Some(25));
let fail_fast = effective_error_policy(false, true, Some(25));
assert!(fail_fast.strict_mode);
assert_eq!(fail_fast.max_errors, Some(1));
let strict = effective_error_policy(true, false, None);
assert!(strict.strict_mode);
assert_eq!(strict.max_errors, None);
}
#[test]
fn test_append_processing_summary_styles() -> Result<()> {
let summary = RunSummary {
records_processed: 3,
records_with_errors: 1,
warnings: 2,
processing_time_ms: 42,
bytes_processed: 2048,
throughput_mbps: 1.5,
..RunSummary::default()
};
let mut encode_summary = String::new();
append_processing_summary(
&mut encode_summary,
"Encode",
&summary,
SummaryIssueCountStyle {
show_zero_counts: false,
repeat_nonzero_counts: false,
},
)?;
assert!(encode_summary.contains("=== Encode Summary ==="));
assert_eq!(encode_summary.matches("Warnings: 2").count(), 1);
assert_eq!(encode_summary.matches("Records with errors: 1").count(), 1);
let mut decode_summary = String::new();
append_processing_summary(
&mut decode_summary,
"Decode",
&summary,
SummaryIssueCountStyle {
show_zero_counts: true,
repeat_nonzero_counts: true,
},
)?;
assert!(decode_summary.contains("=== Decode Summary ==="));
assert_eq!(decode_summary.matches("Warnings: 2").count(), 2);
assert_eq!(decode_summary.matches("Records with errors: 1").count(), 2);
Ok(())
}
#[test]
fn test_append_processing_summary_zero_counts() -> Result<()> {
let summary = RunSummary {
records_processed: 3,
processing_time_ms: 42,
bytes_processed: 2048,
throughput_mbps: 1.5,
..RunSummary::default()
};
let mut encode_summary = String::new();
append_processing_summary(
&mut encode_summary,
"Encode",
&summary,
SummaryIssueCountStyle {
show_zero_counts: false,
repeat_nonzero_counts: false,
},
)?;
assert!(!encode_summary.contains("Warnings: 0"));
assert!(!encode_summary.contains("Records with errors: 0"));
let mut decode_summary = String::new();
append_processing_summary(
&mut decode_summary,
"Decode",
&summary,
SummaryIssueCountStyle {
show_zero_counts: true,
repeat_nonzero_counts: true,
},
)?;
assert_eq!(decode_summary.matches("Warnings: 0").count(), 1);
assert_eq!(decode_summary.matches("Records with errors: 0").count(), 1);
Ok(())
}
#[test]
fn test_temp_path_for() {
let target = Path::new("/path/to/output.jsonl");
let temp = temp_path_for(target);
assert_eq!(temp, Path::new("/path/to/output.jsonl.tmp"));
let target = Path::new("output.jsonl");
let temp = temp_path_for(target);
assert_eq!(temp, Path::new("output.jsonl.tmp"));
}
}