use crate::crypto::{EncodedPassword, EncryptedFile, FileType};
fn test_decrypt(input_name: &Path, expected_name: &Path, password: &str, file_type: FileType) {
- let input_filename = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("src/crypto/testdata")
- .join(input_name);
+ let input_filename = Path::new("src/crypto/testdata").join(input_name);
let input = std::fs::read(&input_filename).unwrap();
let mut cursor = Cursor::new(&input);
let file = EncryptedFile::new(&mut cursor).unwrap();
let mut actual = Vec::new();
std::io::copy(&mut reader, &mut actual).unwrap();
- let expected_filename = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("src/crypto/testdata")
- .join(expected_name);
+ let expected_filename = Path::new("src/crypto/testdata").join(expected_name);
let expected = std::fs::read(&expected_filename).unwrap();
if actual != expected {
panic!();
};
fn test(name: &str) {
- let filename = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("src/format/testdata/display")
- .join(name);
+ let filename = Path::new("src/format/testdata/display").join(name);
let input = BufReader::new(File::open(&filename).unwrap());
let settings = Settings::default()
.with_cc(CC::A, ",,,".parse().unwrap())
}
fn test_binhex(name: &str) {
- let filename = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("src/format/testdata/display")
- .join(name);
+ let filename = Path::new("src/format/testdata/display").join(name);
let input = BufReader::new(File::open(&filename).unwrap());
let mut value = None;
let mut value_name = String::new();
}
fn test_times(format: Format, name: &str) {
- let directory = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/format/testdata/display");
+ let directory = Path::new("src/format/testdata/display");
let input_filename = directory.join("time-input.txt");
let input = BufReader::new(File::open(&input_filename).unwrap());
};
fn test(name: &str, type_: Type) {
- let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/format/testdata/parse");
+ let base = Path::new("src/format/testdata/parse");
let input_stream = BufReader::new(File::open(base.join("num-in.txt")).unwrap());
let expected_stream = BufReader::new(File::open(base.join(name)).unwrap());
for ((input, expected), line_number) in input_stream
mod write;
use serde::Serializer;
-pub use write::{Version, WriteOptions, Writer};
+pub use write::{SysfileVersion, WriteOptions, Writer};
#[cfg(test)]
mod test;
// this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
+ borrow::Cow,
fs::File,
io::{BufRead, BufReader, Cursor, Seek},
- path::Path,
+ path::{Path, PathBuf},
sync::Arc,
};
+use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
+use encoding_rs::UTF_8;
+use hexplay::HexView;
+
use crate::{
crypto::EncryptedFile,
+ data::{BorrowedDatum, Datum},
+ dictionary::{self, Dictionary, VarWidth, Variable},
endian::Endian,
+ identifier::Identifier,
output::{
pivot::{test::assert_lines_eq, Axis3, Dimension, Group, PivotTable, Value},
Details, Item, Text,
},
sys::{
cooked::ReadOptions,
- raw::{self, ErrorDetails},
+ raw::{self, records::Compression, ErrorDetails},
sack::sack,
+ ProductVersion, WriteOptions,
},
};
));
}
+#[test]
+fn write_numeric() {
+ for (compression, compression_string) in [
+ (None, "uncompressed"),
+ (Some(Compression::Simple), "simple"),
+ (Some(Compression::ZLib), "zlib"),
+ ] {
+ let mut dictionary = Dictionary::new(UTF_8);
+ for i in 0..4 {
+ let name = Identifier::new(format!("variable{i}")).unwrap();
+ dictionary
+ .add_var(Variable::new(name, VarWidth::Numeric, UTF_8))
+ .unwrap();
+ }
+ let mut cases = WriteOptions::new()
+ .with_compression(compression)
+ .with_timestamp(NaiveDateTime::new(
+ NaiveDate::from_ymd_opt(2025, 7, 30).unwrap(),
+ NaiveTime::from_hms_opt(15, 7, 55).unwrap(),
+ ))
+ .with_product_name(Cow::from("PSPP TEST DATA FILE"))
+ .with_product_version(ProductVersion(1, 2, 3))
+ .write_writer(&dictionary, Cursor::new(Vec::new()))
+ .unwrap();
+ for case in [
+ [1, 1, 1, 2],
+ [1, 1, 2, 30],
+ [1, 2, 1, 8],
+ [1, 2, 2, 20],
+ [2, 1, 1, 2],
+ [2, 1, 2, 22],
+ [2, 2, 1, 1],
+ [2, 2, 2, 3],
+ ] {
+ cases
+ .write_case(
+ case.into_iter()
+ .map(|number| BorrowedDatum::Number(Some(number as f64))),
+ )
+ .unwrap();
+ }
+ let sysfile = cases.finish().unwrap().unwrap().into_inner();
+ let expected_filename = PathBuf::from(&format!(
+ "src/sys/testdata/write-numeric-{compression_string}.expected"
+ ));
+ let expected = String::from_utf8(std::fs::read(&expected_filename).unwrap()).unwrap();
+ test_sysfile(Cursor::new(sysfile), &expected, &expected_filename);
+ }
+}
+
fn test_raw_sysfile(name: &str) {
- let input_filename = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("src/sys/testdata")
+ let input_filename = Path::new("src/sys/testdata")
.join(name)
.with_extension("sav");
let sysfile = BufReader::new(File::open(&input_filename).unwrap());
}
fn test_encrypted_sysfile(name: &str, password: &str) {
- let input_filename = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("src/sys/testdata")
+ let input_filename = Path::new("src/sys/testdata")
.join(name)
.with_extension("sav");
let sysfile = EncryptedFile::new(File::open(&input_filename).unwrap())
}
fn test_sack_sysfile(name: &str) {
- let input_filename = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("src/sys/testdata")
+ let input_filename = Path::new("src/sys/testdata")
.join(name)
.with_extension("sack");
let input = String::from_utf8(std::fs::read(&input_filename).unwrap()).unwrap();
--- /dev/null
+╭──────────────────────┬────────────────────╮
+│ Created │30-JUL-2025 15:07:55│
+├──────────────────────┼────────────────────┤
+│Writer Product │PSPP TEST DATA FILE │
+│ Version │1.2.3 │
+├──────────────────────┼────────────────────┤
+│ Compression │SAV │
+│ Number of Cases│ 8│
+╰──────────────────────┴────────────────────╯
+
+╭─────────┬─╮
+│Variables│4│
+╰─────────┴─╯
+
+╭─────────┬────────┬─────┬─────────────────┬─────┬─────┬─────────┬────────────┬────────────┬──────────────╮
+│ │Position│Label│Measurement Level│ Role│Width│Alignment│Print Format│Write Format│Missing Values│
+├─────────┼────────┼─────┼─────────────────┼─────┼─────┼─────────┼────────────┼────────────┼──────────────┤
+│variable0│ 1│ │ │Input│ 8│Right │F8.2 │F8.2 │ │
+│variable1│ 2│ │ │Input│ 8│Right │F8.2 │F8.2 │ │
+│variable2│ 3│ │ │Input│ 8│Right │F8.2 │F8.2 │ │
+│variable3│ 4│ │ │Input│ 8│Right │F8.2 │F8.2 │ │
+╰─────────┴────────┴─────┴─────────────────┴─────┴─────┴─────────┴────────────┴────────────┴──────────────╯
+
+╭────┬─────────┬─────────┬─────────┬─────────╮
+│Case│variable0│variable1│variable2│variable3│
+├────┼─────────┼─────────┼─────────┼─────────┤
+│1 │ 1.00│ 1.00│ 1.00│ 2.00│
+│2 │ 1.00│ 1.00│ 2.00│ 30.00│
+│3 │ 1.00│ 2.00│ 1.00│ 8.00│
+│4 │ 1.00│ 2.00│ 2.00│ 20.00│
+│5 │ 2.00│ 1.00│ 1.00│ 2.00│
+│6 │ 2.00│ 1.00│ 2.00│ 22.00│
+│7 │ 2.00│ 2.00│ 1.00│ 1.00│
+│8 │ 2.00│ 2.00│ 2.00│ 3.00│
+╰────┴─────────┴─────────┴─────────┴─────────╯
--- /dev/null
+╭──────────────────────┬────────────────────╮
+│ Created │30-JUL-2025 15:07:55│
+├──────────────────────┼────────────────────┤
+│Writer Product │PSPP TEST DATA FILE │
+│ Version │1.2.3 │
+├──────────────────────┼────────────────────┤
+│ Compression │None │
+│ Number of Cases│ 8│
+╰──────────────────────┴────────────────────╯
+
+╭─────────┬─╮
+│Variables│4│
+╰─────────┴─╯
+
+╭─────────┬────────┬─────┬─────────────────┬─────┬─────┬─────────┬────────────┬────────────┬──────────────╮
+│ │Position│Label│Measurement Level│ Role│Width│Alignment│Print Format│Write Format│Missing Values│
+├─────────┼────────┼─────┼─────────────────┼─────┼─────┼─────────┼────────────┼────────────┼──────────────┤
+│variable0│ 1│ │ │Input│ 8│Right │F8.2 │F8.2 │ │
+│variable1│ 2│ │ │Input│ 8│Right │F8.2 │F8.2 │ │
+│variable2│ 3│ │ │Input│ 8│Right │F8.2 │F8.2 │ │
+│variable3│ 4│ │ │Input│ 8│Right │F8.2 │F8.2 │ │
+╰─────────┴────────┴─────┴─────────────────┴─────┴─────┴─────────┴────────────┴────────────┴──────────────╯
+
+╭────┬─────────┬─────────┬─────────┬─────────╮
+│Case│variable0│variable1│variable2│variable3│
+├────┼─────────┼─────────┼─────────┼─────────┤
+│1 │ 1.00│ 1.00│ 1.00│ 2.00│
+│2 │ 1.00│ 1.00│ 2.00│ 30.00│
+│3 │ 1.00│ 2.00│ 1.00│ 8.00│
+│4 │ 1.00│ 2.00│ 2.00│ 20.00│
+│5 │ 2.00│ 1.00│ 1.00│ 2.00│
+│6 │ 2.00│ 1.00│ 2.00│ 22.00│
+│7 │ 2.00│ 2.00│ 1.00│ 1.00│
+│8 │ 2.00│ 2.00│ 2.00│ 3.00│
+╰────┴─────────┴─────────┴─────────┴─────────╯
--- /dev/null
+╭──────────────────────┬────────────────────╮
+│ Created │30-JUL-2025 15:07:55│
+├──────────────────────┼────────────────────┤
+│Writer Product │PSPP TEST DATA FILE │
+│ Version │1.2.3 │
+├──────────────────────┼────────────────────┤
+│ Compression │ZSAV │
+│ Number of Cases│ 8│
+╰──────────────────────┴────────────────────╯
+
+╭─────────┬─╮
+│Variables│4│
+╰─────────┴─╯
+
+╭─────────┬────────┬─────┬─────────────────┬─────┬─────┬─────────┬────────────┬────────────┬──────────────╮
+│ │Position│Label│Measurement Level│ Role│Width│Alignment│Print Format│Write Format│Missing Values│
+├─────────┼────────┼─────┼─────────────────┼─────┼─────┼─────────┼────────────┼────────────┼──────────────┤
+│variable0│ 1│ │ │Input│ 8│Right │F8.2 │F8.2 │ │
+│variable1│ 2│ │ │Input│ 8│Right │F8.2 │F8.2 │ │
+│variable2│ 3│ │ │Input│ 8│Right │F8.2 │F8.2 │ │
+│variable3│ 4│ │ │Input│ 8│Right │F8.2 │F8.2 │ │
+╰─────────┴────────┴─────┴─────────────────┴─────┴─────┴─────────┴────────────┴────────────┴──────────────╯
+
+╭────┬─────────┬─────────┬─────────┬─────────╮
+│Case│variable0│variable1│variable2│variable3│
+├────┼─────────┼─────────┼─────────┼─────────┤
+│1 │ 1.00│ 1.00│ 1.00│ 2.00│
+│2 │ 1.00│ 1.00│ 2.00│ 30.00│
+│3 │ 1.00│ 2.00│ 1.00│ 8.00│
+│4 │ 1.00│ 2.00│ 2.00│ 20.00│
+│5 │ 2.00│ 1.00│ 1.00│ 2.00│
+│6 │ 2.00│ 1.00│ 2.00│ 22.00│
+│7 │ 2.00│ 2.00│ 1.00│ 1.00│
+│8 │ 2.00│ 2.00│ 2.00│ 3.00│
+╰────┴─────────┴─────────┴─────────┴─────────╯
};
use binrw::{BinWrite, Endian, Error as BinError};
-use chrono::Local;
+use chrono::{Local, NaiveDateTime};
use either::Either;
use encoding_rs::Encoding;
use flate2::write::ZlibEncoder;
/// System file format version.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
-pub enum Version {
+pub enum SysfileVersion {
/// Obsolete version.
V2,
}
/// Options for writing a system file.
-#[derive(Copy, Clone, Debug)]
+#[derive(Clone, Debug)]
pub struct WriteOptions {
/// How to compress (if at all) data in the system file.
pub compression: Option<Compression>,
/// System file version to write.
- pub version: Version,
+ pub sysfile_version: SysfileVersion,
+
+ /// Date and time to write to the file.
+ pub timestamp: NaiveDateTime,
+
+ /// Product name.
+ ///
+ /// Only the first 40 bytes are written.
+ pub product_name: Cow<'static, str>,
+
+ /// Product version number.
+ ///
+ /// The default is taken from `CARGO_PKG_VERSION`.
+ pub product_version: ProductVersion,
}
impl Default for WriteOptions {
fn default() -> Self {
Self {
compression: Some(Compression::Simple),
- version: Default::default(),
+ sysfile_version: Default::default(),
+ timestamp: Local::now().naive_local(),
+ product_name: Cow::from(concat!("GNU PSPP (Rust) ", env!("CARGO_PKG_VERSION"))),
+ product_version: ProductVersion::VERSION,
}
}
}
}
}
- /// Returns `self` with the version set to `version`.
- pub fn with_version(self, version: Version) -> Self {
- Self { version, ..self }
+ pub fn with_timestamp(self, timestamp: NaiveDateTime) -> Self {
+ Self { timestamp, ..self }
+ }
+
+ /// Returns `self` with the system file version set to `sysfile_version`.
+ pub fn with_sysfile_version(self, sysfile_version: SysfileVersion) -> Self {
+ Self {
+ sysfile_version,
+ ..self
+ }
+ }
+
+ /// Returns `self` with the product name set to `product_name`.
+ pub fn with_product_name(self, product_name: Cow<'static, str>) -> Self {
+ Self {
+ product_name,
+ ..self
+ }
+ }
+
+ /// Returns `self` with the product version set to `product_version`.
+ pub fn with_product_version(self, product_version: ProductVersion) -> Self {
+ Self {
+ product_version,
+ ..self
+ }
}
/// Writes `dictionary` to `path` in system file format. Returns a [Writer]
{
let mut dict_writer = DictionaryWriter::new(&self, &mut writer, dictionary);
dict_writer.write()?;
- Writer::new(self, dict_writer.case_vars, writer)
+ let DictionaryWriter { case_vars, .. } = dict_writer;
+ Writer::new(self, case_vars, writer)
}
}
struct DictionaryWriter<'a, W> {
- compression: Option<Compression>,
- version: Version,
+ options: &'a WriteOptions,
short_names: Vec<SmallVec<[Identifier; 1]>>,
case_vars: Vec<CaseVar>,
writer: &'a mut W,
const BIAS: f64 = 100.0;
+fn encode_fixed_string<const N: usize>(s: &str, encoding: &'static Encoding) -> [u8; N] {
+ let mut encoded = encoding.encode(s).0.into_owned();
+ encoded.resize(N, b' ');
+ encoded.try_into().unwrap()
+}
+
impl<'a, W> DictionaryWriter<'a, W>
where
W: Write + Seek,
{
- pub fn new(options: &WriteOptions, writer: &'a mut W, dictionary: &'a Dictionary) -> Self {
+ pub fn new(options: &'a WriteOptions, writer: &'a mut W, dictionary: &'a Dictionary) -> Self {
Self {
- compression: options.compression,
- version: options.version,
+ options,
short_names: dictionary.short_names(),
case_vars: dictionary
.variables
bytes.try_into().unwrap()
}
- let now = Local::now();
let header = RawHeader {
- magic: if self.compression == Some(Compression::ZLib) {
+ magic: if self.options.compression == Some(Compression::ZLib) {
Magic::Zsav
} else {
Magic::Sav
}
.into(),
- eye_catcher: {
- as_byte_array(format!(
- "@(#) SPSS DATA FILE GNU pspp (Rust) {}",
- env!("CARGO_PKG_VERSION")
- ))
- },
+ eye_catcher: encode_fixed_string(
+ &format!("@(#) SPSS DATA FILE {}", &self.options.product_name),
+ self.dictionary.encoding(),
+ ),
layout_code: 2,
nominal_case_size: count_segments(&self.case_vars),
- compression_code: match self.compression {
+ compression_code: match self.options.compression {
Some(Compression::Simple) => 1,
Some(Compression::ZLib) => 2,
None => 0,
},
n_cases: u32::MAX,
bias: BIAS,
- creation_date: as_byte_array(now.format("%d %b %y").to_string()),
- creation_time: as_byte_array(now.format("%H:%M:%S").to_string()),
+ creation_date: as_byte_array(self.options.timestamp.format("%d %b %y").to_string()),
+ creation_time: as_byte_array(self.options.timestamp.format("%H:%M:%S").to_string()),
file_label: as_byte_array(self.dictionary.file_label.clone().unwrap_or_default()),
};
header.write_le(self.writer)
Ok(())
}
- fn encode_fixed_string<const N: usize>(s: &str, encoding: &'static Encoding) -> [u8; N] {
- let mut encoded = encoding.encode(s).0.into_owned();
- encoded.resize(N, b' ');
- encoded.try_into().unwrap()
- }
fn to_raw_format(mut format: Format, width: VarWidth) -> RawFormat {
format.resize(width);
RawFormat::try_from(format).unwrap()
4u32,
8u32,
RawIntegerInfoRecord {
- version: ProductVersion::VERSION,
+ version: self.options.product_version,
machine_code: -1,
floating_point_rep: 1,
compression_code: 1,
}
fn write_long_variable_names(&mut self) -> Result<(), BinError> {
- if self.version == Version::V2 {
+ if self.options.sysfile_version == SysfileVersion::V2 {
return Ok(());
}
}
fn write_data_file_attributes(&mut self) -> Result<(), BinError> {
- if self.version != Version::V2 {
+ if self.options.sysfile_version != SysfileVersion::V2 {
return Ok(());
}
let mut s = String::new();
}
fn write_variable_attributes(&mut self) -> Result<(), BinError> {
- if self.version != Version::V2 {
+ if self.options.sysfile_version != SysfileVersion::V2 {
return Ok(());
}
let mut s = String::new();
/// Finishes writing the file, flushing buffers and updating headers to
/// match the final case counts.
- pub fn finish(mut self) -> Result<(), BinError> {
+ pub fn finish(mut self) -> Result<Option<W>, BinError> {
self.try_finish()
}
/// # Panic
///
/// Attempts to write more cases after calling this function may will panic.
- pub fn try_finish(&mut self) -> Result<(), BinError> {
+ pub fn try_finish(&mut self) -> Result<Option<W>, BinError> {
let Some(inner) = self.inner.take() else {
- return Ok(());
+ return Ok(None);
};
let mut inner = match inner {
let _ = inner.write_all(&n_cases.to_le_bytes());
}
}
- Ok(())
+ Ok(Some(inner))
}
/// Writes `case` to the system file.