Skip to main content

ironwork_rt/
files.rs

1//! Files. ASSIGN names a DD, and only the operator maps a DD to a host file, as JCL does, so a
2//! program reaches no file it was not given. A binary DD holds z/OS records byte for byte:
3//! fixed-length records back to back, or variable-length records each behind a 4-byte RDW. A text
4//! DD holds UTF-8 lines, converted through the program's code page, and placed as a printer would
5//! place them ([`Open::print`]). Sequential files stream;
6//! indexed and relative files are held in memory (see [`Keyed`]).
7
8use crate::vocab::OpenMode;
9use std::cmp::Ordering;
10use std::collections::{BTreeMap, BTreeSet, HashMap};
11use std::fs::{File, OpenOptions};
12use std::io::{self, BufRead, BufReader, BufWriter, Read, Write};
13use std::ops::Bound;
14use std::path::PathBuf;
15use zarch::ebcdic::{self, CodePage};
16
17pub use crate::abend::FileStatus;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum Format {
21    Fixed,
22    Variable,
23    Text,
24}
25
26impl Format {
27    pub fn from_keyword(word: &str) -> Option<Format> {
28        match word.to_ascii_lowercase().as_str() {
29            "text" => Some(Format::Text),
30            "fixed" | "f" => Some(Format::Fixed),
31            "variable" | "v" => Some(Format::Variable),
32            _ => None,
33        }
34    }
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct Dd {
39    pub path: PathBuf,
40    pub format: Option<Format>,
41}
42
43/// The DDs a run may use: given explicitly, or read from `DD_<NAME>` in the environment.
44#[derive(Clone, Debug, Default)]
45pub struct Dds {
46    given: HashMap<String, Dd>,
47    environment: bool,
48}
49
50fn dd_value(value: &str) -> Result<Dd, String> {
51    let (path, format) = value.rsplit_once(':').and_then(|(p, word)| Some((p, Some(Format::from_keyword(word)?)))).unwrap_or((value, None));
52    if path.is_empty() {
53        return Err(format!("DD {value}: no path"));
54    }
55    Ok(Dd { path: PathBuf::from(path), format })
56}
57
58impl Dds {
59    /// DDs from `NAME=path[:text|:fixed|:variable]` specs, and from the environment when `environment`.
60    pub fn new(specs: &[String], environment: bool) -> Result<Self, String> {
61        let mut given = HashMap::new();
62        for spec in specs {
63            let (name, value) = spec.split_once('=').ok_or_else(|| format!("--dd {spec}: expected NAME=path"))?;
64            given.insert(name.to_ascii_uppercase(), dd_value(value)?);
65        }
66        Ok(Self { given, environment })
67    }
68
69    pub fn get(&self, name: &str) -> Option<Dd> {
70        if let Some(dd) = self.given.get(name) {
71            return Some(dd.clone());
72        }
73        if !self.environment {
74            return None;
75        }
76        std::env::var(format!("DD_{name}")).ok().and_then(|v| dd_value(&v).ok())
77    }
78}
79
80enum Handle {
81    Reader(BufReader<File>),
82    Writer(BufWriter<File>),
83    Keyed(Box<Keyed>),
84    Empty,
85}
86
87/// Where a key lies in a record.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub struct KeySpan {
90    pub offset: usize,
91    pub len: usize,
92}
93
94impl KeySpan {
95    pub fn of(&self, record: &[u8]) -> Vec<u8> {
96        let mut key: Vec<u8> = record.iter().skip(self.offset).take(self.len).copied().collect();
97        key.resize(self.len, ebcdic::SPACE);
98        key
99    }
100}
101
102/// How the records of a file held in memory are keyed. Record numbers and positions are held as
103/// eight big-endian bytes, so that they sort.
104#[derive(Clone, Debug, PartialEq, Eq)]
105pub enum Keying {
106    /// A sequential file opened I-O, by position.
107    Position,
108    /// A relative file, by record number. In the DD an empty slot is a record of no bytes, or of
109    /// zero bytes only.
110    Relative,
111    /// An indexed file: the prime key, then each alternate key and whether it allows duplicates.
112    Indexed { prime: KeySpan, alternates: Vec<(KeySpan, bool)> },
113}
114
115/// The highest relative record number: past it WRITE is a boundary violation (status 24). It
116/// stands in for the space a cluster is defined with, so that a stray record number cannot make
117/// CLOSE write millions of empty slots.
118pub const MAX_RELATIVE: u64 = 16_777_215;
119
120pub fn record_number(n: u64) -> Vec<u8> {
121    n.to_be_bytes().to_vec()
122}
123
124pub fn number_of(key: &[u8]) -> u64 {
125    u64::from_be_bytes(key.try_into().unwrap_or([0; 8]))
126}
127
128/// An alternate index entry: alternate key, arrival number, prime key. Duplicates come back in
129/// the order they arrived, as VSAM keeps them; records loaded from a DD arrive in prime-key order,
130/// as BLDINDEX builds an alternate index.
131type Entry = (Vec<u8>, u64, Vec<u8>);
132
133struct Alternate {
134    span: KeySpan,
135    duplicates: bool,
136    index: BTreeSet<Entry>,
137    arrival: HashMap<Vec<u8>, u64>,
138}
139
140impl Alternate {
141    fn holders<'a>(&'a self, value: &'a [u8]) -> impl Iterator<Item = &'a Entry> {
142        self.index.range((value.to_vec(), 0, Vec::new())..).take_while(move |(v, ..)| v == value)
143    }
144}
145
146/// The file position indicator.
147#[derive(Clone, Debug)]
148enum Cursor {
149    /// After OPEN: READ NEXT reads the first record by the prime key.
150    First,
151    /// A key of reference (0 the prime key, then each alternate), a place in its index, and
152    /// whether the record there is itself next (after START) or was the last read.
153    At { which: usize, at: Entry, inclusive: bool },
154    /// After an AT END condition or a START that found nothing: READ NEXT fails with status 46.
155    Undefined,
156}
157
158/// A record READ found.
159pub struct Found {
160    pub key: Vec<u8>,
161    pub record: Vec<u8>,
162    /// The next record by the alternate key of reference has the same key (status 02).
163    pub duplicate: bool,
164}
165
166/// An indexed or relative file, or a sequential file opened I-O, held in memory from OPEN to
167/// CLOSE. The DD holds the records in key (or record-number) order, as an IDCAMS REPRO unload of a
168/// KSDS or RRDS does, and CLOSE writes them back that way when they changed.
169pub struct Keyed {
170    pub keying: Keying,
171    records: BTreeMap<Vec<u8>, Vec<u8>>,
172    alternates: Vec<Alternate>,
173    arrivals: u64,
174    cursor: Cursor,
175    /// The key of the record the last statement on the file, a successful READ, returned: what a
176    /// sequential REWRITE or DELETE acts on.
177    pub last_read: Option<Vec<u8>>,
178    dirty: bool,
179    path: Option<PathBuf>,
180    record_len: usize,
181    page: &'static CodePage,
182}
183
184impl Keyed {
185    fn new(keying: Keying, path: Option<PathBuf>, record_len: usize, page: &'static CodePage) -> Self {
186        let alternates = match &keying {
187            Keying::Indexed { alternates, .. } => {
188                alternates.iter().map(|&(span, duplicates)| Alternate { span, duplicates, index: BTreeSet::new(), arrival: HashMap::new() }).collect()
189            }
190            _ => Vec::new(),
191        };
192        Self { keying, records: BTreeMap::new(), alternates, arrivals: 0, cursor: Cursor::First, last_read: None, dirty: true, path, record_len, page }
193    }
194
195    pub fn prime_key(&self, record: &[u8]) -> Option<Vec<u8>> {
196        match &self.keying {
197            Keying::Indexed { prime, .. } => Some(prime.of(record)),
198            _ => None,
199        }
200    }
201
202    pub fn highest_key(&self) -> Option<&Vec<u8>> {
203        self.records.keys().next_back()
204    }
205
206    pub fn record(&self, key: &[u8]) -> Option<&Vec<u8>> {
207        self.records.get(key)
208    }
209
210    fn key_len(&self, which: usize) -> usize {
211        match (&self.keying, which) {
212            (Keying::Indexed { prime, .. }, 0) => prime.len,
213            (Keying::Indexed { .. }, n) => self.alternates[n - 1].span.len,
214            _ => 8,
215        }
216    }
217
218    /// The record whose key `which` is `value`: for an alternate key, the first of any duplicates.
219    pub fn get(&self, which: usize, value: &[u8]) -> Option<Found> {
220        if which == 0 {
221            return self.records.get(value).map(|r| Found { key: value.to_vec(), record: r.clone(), duplicate: false });
222        }
223        let mut hits = self.alternates[which - 1].holders(value);
224        let key = hits.next()?.2.clone();
225        Some(Found { record: self.records[&key].clone(), key, duplicate: hits.next().is_some() })
226    }
227
228    /// Adds a record, or replaces the one with its key: Err with status 22 when it would take a
229    /// unique alternate key another record holds. Ok(true) when it shares an alternate key that
230    /// allows duplicates (status 02).
231    fn put(&mut self, key: Vec<u8>, record: Vec<u8>) -> Result<bool, FileStatus> {
232        let mut shared = false;
233        for alt in &self.alternates {
234            let value = alt.span.of(&record);
235            let others = alt.holders(&value).any(|(_, _, k)| *k != key);
236            if others && !alt.duplicates {
237                return Err(FileStatus::DuplicateKey);
238            }
239            shared |= others;
240        }
241        let old = self.records.get(&key).cloned();
242        let mut arrivals = self.arrivals;
243        for alt in &mut self.alternates {
244            let now = alt.span.of(&record);
245            if let Some(old) = &old {
246                let was = alt.span.of(old);
247                if was == now {
248                    continue;
249                }
250                let seq = alt.arrival[&key];
251                alt.index.remove(&(was, seq, key.clone()));
252            }
253            alt.index.insert((now, arrivals, key.clone()));
254            alt.arrival.insert(key.clone(), arrivals);
255            arrivals += 1;
256        }
257        self.arrivals = arrivals;
258        self.records.insert(key, record);
259        self.dirty = true;
260        Ok(shared)
261    }
262
263    /// Adds a record: Err with status 22 when its prime key, or a unique alternate key, is already
264    /// there; Ok(true) when it shares an alternate key that allows duplicates (status 02).
265    pub fn insert(&mut self, key: Vec<u8>, record: Vec<u8>) -> Result<bool, FileStatus> {
266        if self.records.contains_key(&key) {
267            return Err(FileStatus::DuplicateKey);
268        }
269        self.put(key, record)
270    }
271
272    /// Replaces a record: Err with status 23 when none has its key, 22 as for [`Keyed::insert`].
273    pub fn replace(&mut self, key: Vec<u8>, record: Vec<u8>) -> Result<bool, FileStatus> {
274        if !self.records.contains_key(&key) {
275            return Err(FileStatus::NotFound);
276        }
277        self.put(key, record)
278    }
279
280    pub fn remove(&mut self, key: &[u8]) -> Option<Vec<u8>> {
281        let record = self.records.remove(key)?;
282        for alt in &mut self.alternates {
283            if let Some(seq) = alt.arrival.remove(key) {
284                alt.index.remove(&(alt.span.of(&record), seq, key.to_vec()));
285            }
286        }
287        self.dirty = true;
288        Some(record)
289    }
290
291    /// START: positions at the first record whose key `which`, compared over the length of `value`
292    /// (a longer `value` is cut to the key's length), is `wanted` to `value`, or equal when
293    /// `or_equal`. False, and no position, when there is none.
294    pub fn start(&mut self, which: usize, wanted: Ordering, or_equal: bool, value: &[u8]) -> bool {
295        let value = &value[..value.len().min(self.key_len(which))];
296        let fits = |k: &[u8]| {
297            let o = k[..value.len().min(k.len())].cmp(value);
298            o == wanted || (or_equal && o == Ordering::Equal)
299        };
300        let from = (value.to_vec(), 0, Vec::new());
301        let found = match which {
302            0 => self.records.range(from.0..).map(|(k, _)| k).find(|k| fits(k)).map(|k| (k.clone(), 0, Vec::new())),
303            n => self.alternates[n - 1].index.range(from..).find(|(k, ..)| fits(k)).cloned(),
304        };
305        self.last_read = None;
306        match found {
307            Some(at) => {
308                self.cursor = Cursor::At { which, at, inclusive: true };
309                true
310            }
311            None => {
312                self.lose_position();
313                false
314            }
315        }
316    }
317
318    /// After a READ that found nothing: READ NEXT has no valid next record.
319    pub fn lose_position(&mut self) {
320        self.cursor = Cursor::Undefined;
321        self.last_read = None;
322    }
323
324    /// Makes the record just read by key `which` the one READ NEXT goes on from.
325    pub fn read_at(&mut self, which: usize, key: &[u8]) {
326        let at = match which {
327            0 => (key.to_vec(), 0, Vec::new()),
328            n => {
329                let alt = &self.alternates[n - 1];
330                (alt.span.of(&self.records[key]), alt.arrival[key], key.to_vec())
331            }
332        };
333        self.cursor = Cursor::At { which, at, inclusive: false };
334        self.last_read = Some(key.to_vec());
335    }
336
337    /// The first record by prime key after `from` (at it too when `inclusive`), or before it when
338    /// `backward`: what a CICS browse reads, holding its own position rather than the file's.
339    pub fn seek(&self, from: &[u8], inclusive: bool, backward: bool) -> Option<(Vec<u8>, Vec<u8>)> {
340        let edge = if inclusive { Bound::Included(from.to_vec()) } else { Bound::Excluded(from.to_vec()) };
341        let mut hits = if backward { self.records.range((Bound::Unbounded, edge)) } else { self.records.range((edge, Bound::Unbounded)) };
342        let hit = if backward { hits.next_back() } else { hits.next() };
343        hit.map(|(k, r)| (k.clone(), r.clone()))
344    }
345
346    /// [`Keyed::read_next`] with the status as text, for SORT's reader.
347    pub fn step(&mut self, backward: bool) -> Result<Option<Found>, &'static str> {
348        self.read_next(backward).map_err(FileStatus::as_str)
349    }
350
351    /// READ NEXT, or READ PREVIOUS when `backward`: None at the end, Err(46) with no position.
352    pub fn read_next(&mut self, backward: bool) -> Result<Option<Found>, FileStatus> {
353        let (which, bound) = match &self.cursor {
354            Cursor::Undefined => return Err(FileStatus::NoNextRecord),
355            Cursor::First => (0, Bound::Unbounded),
356            Cursor::At { which, at, inclusive: true } => (*which, Bound::Included(at.clone())),
357            Cursor::At { which, at, inclusive: false } => (*which, Bound::Excluded(at.clone())),
358        };
359        let range = if backward { (Bound::Unbounded, bound) } else { (bound, Bound::Unbounded) };
360        let hit = if which == 0 {
361            let mut keys = self.records.range((range.0.map(|(k, ..)| k), range.1.map(|(k, ..)| k))).map(|(k, _)| k);
362            let key = if backward { keys.next_back() } else { keys.next() };
363            key.map(|k| (k.clone(), false))
364        } else {
365            let mut entries = self.alternates[which - 1].index.range(range);
366            let entry = if backward { entries.next_back() } else { entries.next() };
367            entry.map(|(value, _, key)| {
368                let after = if backward { entries.next_back() } else { entries.next() };
369                (key.clone(), after.is_some_and(|(v, ..)| v == value))
370            })
371        };
372        let Some((key, duplicate)) = hit else {
373            self.lose_position();
374            return Ok(None);
375        };
376        self.read_at(which, &key);
377        Ok(Some(Found { record: self.records[&key].clone(), key, duplicate }))
378    }
379
380    fn inbound(&self, raw: Vec<u8>, format: Format) -> Vec<u8> {
381        let mut record = match format {
382            Format::Text => self.page.encode_lossy(&String::from_utf8_lossy(&raw)),
383            _ => raw,
384        };
385        if format != Format::Variable {
386            record.resize(self.record_len.max(record.len()), ebcdic::SPACE);
387        }
388        record
389    }
390
391    fn outbound(&self, record: &[u8], format: Format) -> Vec<u8> {
392        match format {
393            Format::Text => format!("{}\n", self.page.decode(record).trim_end()).into_bytes(),
394            Format::Fixed => {
395                let mut r = record.to_vec();
396                r.resize(self.record_len, ebcdic::SPACE);
397                r
398            }
399            Format::Variable => record.to_vec(),
400        }
401    }
402
403    fn save(&self, format: Format) -> io::Result<()> {
404        let Some(path) = self.path.as_ref().filter(|_| self.dirty) else { return Ok(()) };
405        let mut out = Open { mode: OpenMode::Output, format, handle: Handle::Writer(BufWriter::new(File::create(path)?)), head: Head::Start, page: None };
406        let empty = match format {
407            Format::Fixed => vec![0; self.record_len],
408            Format::Variable => Vec::new(),
409            Format::Text => b"\n".to_vec(),
410        };
411        let mut next = 1;
412        for (key, record) in &self.records {
413            if self.keying == Keying::Relative {
414                for _ in next..number_of(key) {
415                    out.write(&empty)?;
416                }
417                next = number_of(key) + 1;
418            }
419            out.write(&self.outbound(record, format))?;
420        }
421        out.close()
422    }
423}
424
425pub struct Open {
426    pub mode: OpenMode,
427    pub format: Format,
428    handle: Handle,
429    head: Head,
430    /// The logical page of a LINAGE file opened OUTPUT or EXTEND.
431    pub page: Option<crate::linage::Page>,
432}
433
434/// A movement of the paper, as a text DD shows it: line feeds, or a form feed.
435#[derive(Clone, Copy, Debug, PartialEq, Eq)]
436pub enum Move {
437    Lines(u64),
438    Page,
439}
440
441/// Where a text DD's print position is: before its first line, on a line not yet ended (and
442/// whether anything shows on it), or at the start of a line.
443#[derive(Clone, Copy, Debug, PartialEq, Eq)]
444enum Head {
445    Start,
446    OnLine(bool),
447    Fresh,
448}
449
450/// What a READ found.
451pub enum Record {
452    Data(Vec<u8>),
453    /// A record whose length did not match: the bytes read, for FILE STATUS 04.
454    WrongLength(Vec<u8>),
455    End,
456}
457
458/// Opens a file held in memory. With no DD (an OPTIONAL file) or no file at the DD's path, it
459/// starts empty; OUTPUT always starts empty. `record_len` is the longest record.
460pub fn open_keyed(dd: Option<&Dd>, mode: OpenMode, format: Format, keying: Keying, record_len: usize, page: &'static CodePage) -> io::Result<Open> {
461    let path = dd.filter(|_| mode != OpenMode::Input).map(|d| d.path.clone());
462    let mut keyed = Keyed::new(keying, path, record_len, page);
463    if let Some(dd) = dd.filter(|d| mode != OpenMode::Output && d.path.exists()) {
464        let mut reader = open(dd, OpenMode::Input, format)?;
465        let mut number = 0u64;
466        loop {
467            let raw = match reader.read(record_len)? {
468                Record::End => break,
469                Record::Data(r) | Record::WrongLength(r) => r,
470            };
471            number += 1;
472            if keyed.keying == Keying::Relative && raw.iter().all(|&b| b == 0) {
473                continue;
474            }
475            let record = keyed.inbound(raw, format);
476            let key = keyed.prime_key(&record).unwrap_or_else(|| record_number(number));
477            keyed.insert(key, record).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, format!("{}: two records with one key", dd.path.display())))?;
478        }
479        keyed.dirty = false;
480    }
481    Ok(Open { mode, format, handle: Handle::Keyed(Box::new(keyed)), head: Head::Start, page: None })
482}
483
484pub fn open(dd: &Dd, mode: OpenMode, format: Format) -> io::Result<Open> {
485    let handle = match mode {
486        OpenMode::Input => Handle::Reader(BufReader::new(File::open(&dd.path)?)),
487        OpenMode::Output => Handle::Writer(BufWriter::new(File::create(&dd.path)?)),
488        OpenMode::Extend => Handle::Writer(BufWriter::new(OpenOptions::new().append(true).create(true).open(&dd.path)?)),
489        OpenMode::InputOutput => return Err(io::Error::new(io::ErrorKind::Unsupported, "OPEN I-O of a line-sequential file")),
490    };
491    Ok(Open { mode, format, handle, head: Head::Start, page: None })
492}
493
494/// An OPTIONAL input file with no DD: every READ is at end.
495pub fn absent() -> Open {
496    Open { mode: OpenMode::Input, format: Format::Fixed, handle: Handle::Empty, head: Head::Start, page: None }
497}
498
499impl Open {
500    pub fn keyed(&mut self) -> Option<&mut Keyed> {
501        match &mut self.handle {
502            Handle::Keyed(k) => Some(k),
503            _ => None,
504        }
505    }
506
507    pub fn is_keyed(&self) -> bool {
508        matches!(self.handle, Handle::Keyed(_))
509    }
510
511    pub fn read(&mut self, fixed_len: usize) -> io::Result<Record> {
512        let reader = match &mut self.handle {
513            Handle::Reader(r) => r,
514            Handle::Empty => return Ok(Record::End),
515            Handle::Writer(_) | Handle::Keyed(_) => return Err(io::Error::other("a sequential READ of a file not open for it")),
516        };
517        match self.format {
518            Format::Fixed => {
519                let mut buf = Vec::with_capacity(fixed_len);
520                reader.by_ref().take(fixed_len as u64).read_to_end(&mut buf)?;
521                Ok(match buf.len() {
522                    0 => Record::End,
523                    n if n == fixed_len => Record::Data(buf),
524                    _ => Record::WrongLength(buf),
525                })
526            }
527            Format::Variable => {
528                let mut rdw = [0u8; 4];
529                match reader.read_exact(&mut rdw) {
530                    Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(Record::End),
531                    other => other?,
532                }
533                let len = u16::from_be_bytes([rdw[0], rdw[1]]) as usize;
534                if len < 4 {
535                    return Err(io::Error::new(io::ErrorKind::InvalidData, format!("an RDW of length {len}")));
536                }
537                let mut buf = vec![0u8; len - 4];
538                reader.read_exact(&mut buf)?;
539                Ok(Record::Data(buf))
540            }
541            Format::Text => {
542                let mut line = Vec::new();
543                if reader.read_until(b'\n', &mut line)? == 0 {
544                    return Ok(Record::End);
545                }
546                while matches!(line.last(), Some(b'\n' | b'\r')) {
547                    line.pop();
548                }
549                Ok(Record::Data(line))
550            }
551        }
552    }
553
554    pub fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
555        let Handle::Writer(w) = &mut self.handle else {
556            return Err(io::Error::other("WRITE to a file not opened for output"));
557        };
558        match self.format {
559            Format::Fixed | Format::Text => w.write_all(bytes),
560            Format::Variable => {
561                let len = u16::try_from(bytes.len() + 4).map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "a record over 32,760 bytes"))?;
562                w.write_all(&len.to_be_bytes())?;
563                w.write_all(&[0, 0])?;
564                w.write_all(bytes)
565            }
566        }
567    }
568
569    /// Writes a line to a text DD where a printer would put it: after `before`, over a line not
570    /// yet ended when nothing moved the paper (after a carriage return, unless one of the two is
571    /// blank), then `after`. The first line of the DD takes one line of `before` as its own start,
572    /// as a printer at the top of a form prints a single-spaced first line on line 1.
573    pub fn print(&mut self, before: Option<Move>, line: &str, after: Option<Move>) -> io::Result<()> {
574        if let Some(m) = before {
575            self.feed(m)?;
576        }
577        let Handle::Writer(w) = &mut self.handle else {
578            return Err(io::Error::other("WRITE to a file not opened for output"));
579        };
580        let shown = matches!(self.head, Head::OnLine(true));
581        if shown && !line.is_empty() {
582            w.write_all(b"\r")?;
583        }
584        w.write_all(line.as_bytes())?;
585        self.head = Head::OnLine(shown || !line.is_empty());
586        match after {
587            Some(m) => self.feed(m),
588            None => Ok(()),
589        }
590    }
591
592    fn feed(&mut self, m: Move) -> io::Result<()> {
593        let Handle::Writer(w) = &mut self.handle else {
594            return Err(io::Error::other("WRITE to a file not opened for output"));
595        };
596        match (m, self.head) {
597            (Move::Lines(0), _) => return Ok(()),
598            (Move::Lines(n), head) => {
599                for _ in 0..n - u64::from(head == Head::Start) {
600                    w.write_all(b"\n")?;
601                }
602            }
603            (Move::Page, Head::OnLine(_)) => w.write_all(b"\n\x0c")?,
604            (Move::Page, _) => w.write_all(b"\x0c")?,
605        }
606        self.head = Head::Fresh;
607        Ok(())
608    }
609
610    pub fn close(self) -> io::Result<()> {
611        match self.handle {
612            Handle::Writer(mut w) => {
613                if matches!(self.head, Head::OnLine(_)) {
614                    w.write_all(b"\n")?;
615                }
616                w.flush()
617            }
618            Handle::Keyed(k) => k.save(self.format),
619            _ => Ok(()),
620        }
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn dd_specs() {
630        let dds = Dds::new(&["IN=/tmp/a.txt:text".into(), "out=/tmp/b.dat".into()], false).unwrap();
631        assert_eq!(dds.get("IN"), Some(Dd { path: "/tmp/a.txt".into(), format: Some(Format::Text) }));
632        assert_eq!(dds.get("OUT").unwrap().format, None);
633        assert!(dds.get("NONE").is_none());
634        assert!(Dds::new(&["NOEQUALS".into()], false).is_err());
635    }
636
637    #[test]
638    fn variable_records_round_trip_through_rdws() {
639        let path = std::env::temp_dir().join(format!("ironwork-vb-{}", std::process::id()));
640        let dd = Dd { path: path.clone(), format: None };
641        let mut out = open(&dd, OpenMode::Output, Format::Variable).unwrap();
642        out.write(b"AB").unwrap();
643        out.write(b"CDEF").unwrap();
644        out.close().unwrap();
645        assert_eq!(std::fs::read(&path).unwrap(), [0, 6, 0, 0, b'A', b'B', 0, 8, 0, 0, b'C', b'D', b'E', b'F']);
646        let mut input = open(&dd, OpenMode::Input, Format::Variable).unwrap();
647        assert!(matches!(input.read(0).unwrap(), Record::Data(d) if d == b"AB"));
648        assert!(matches!(input.read(0).unwrap(), Record::Data(d) if d == b"CDEF"));
649        assert!(matches!(input.read(0).unwrap(), Record::End));
650    }
651}