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::{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    /// DISP=MOD: OPEN OUTPUT keeps the records already there and writes after them.
42    pub append: bool,
43}
44
45/// The DDs a run may use: given explicitly, or read from `DD_<NAME>` in the environment.
46#[derive(Clone, Debug, Default)]
47pub struct Dds {
48    given: HashMap<String, Dd>,
49    environment: bool,
50}
51
52fn dd_value(value: &str) -> Result<Dd, String> {
53    let (rest, append) = match value.rsplit_once(':') {
54        Some((rest, word)) if word.eq_ignore_ascii_case("mod") => (rest, true),
55        _ => (value, false),
56    };
57    let (path, format) = rest.rsplit_once(':').and_then(|(p, word)| Some((p, Some(Format::from_keyword(word)?)))).unwrap_or((rest, None));
58    if path.is_empty() {
59        return Err(format!("DD {value}: no path"));
60    }
61    Ok(Dd { path: PathBuf::from(path), format, append })
62}
63
64impl Dds {
65    /// DDs from `NAME=path[:text|:fixed|:variable][:mod]` specs, and from the environment when `environment`.
66    pub fn new(specs: &[String], environment: bool) -> Result<Self, String> {
67        let mut given = HashMap::new();
68        for spec in specs {
69            let (name, value) = spec.split_once('=').ok_or_else(|| format!("--dd {spec}: expected NAME=path"))?;
70            given.insert(name.to_ascii_uppercase(), dd_value(value)?);
71        }
72        Ok(Self { given, environment })
73    }
74
75    pub fn get(&self, name: &str) -> Option<Dd> {
76        if let Some(dd) = self.given.get(name) {
77            return Some(dd.clone());
78        }
79        if !self.environment {
80            return None;
81        }
82        std::env::var(format!("DD_{name}")).ok().and_then(|v| dd_value(&v).ok())
83    }
84
85    /// The DD a file's ASSIGN names: as written, or else the DD name after its label, which only
86    /// documents the device (`DA-MASTER` is DD MASTER; Language Reference, ASSIGN clause).
87    pub fn assigned(&self, assign: &str) -> Option<Dd> {
88        self.get(assign).or_else(|| {
89            let (_, name) = assign.rsplit_once('-')?;
90            let b = name.as_bytes();
91            let dd_name = !b.is_empty() && b.len() <= 8 && matches!(b[0], b'A'..=b'Z' | b'#' | b'$' | b'@') && b[1..].iter().all(|c| matches!(c, b'A'..=b'Z' | b'0'..=b'9' | b'#' | b'$' | b'@'));
92            if dd_name { self.get(name) } else { None }
93        })
94    }
95}
96
97enum Handle {
98    Reader(BufReader<File>),
99    Writer(BufWriter<File>),
100    Keyed(Box<Keyed>),
101    Empty,
102}
103
104/// Where a key lies in a record: `len` bytes from `offset`, or for Micro Focus's split key the
105/// pieces it joins, in order, `offset` the first's and `len` their sum.
106#[derive(Clone, Debug, Default, PartialEq, Eq)]
107pub struct KeySpan {
108    pub offset: usize,
109    pub len: usize,
110    pub split: Vec<(usize, usize)>,
111}
112
113impl KeySpan {
114    pub fn new(offset: usize, len: usize) -> Self {
115        Self { offset, len, split: Vec::new() }
116    }
117
118    /// The key joining `pieces`, each an offset and a length, in order.
119    pub fn joined(pieces: Vec<(usize, usize)>) -> Self {
120        Self { offset: pieces.first().map_or(0, |p| p.0), len: pieces.iter().map(|p| p.1).sum(), split: pieces }
121    }
122
123    pub fn of(&self, record: &[u8]) -> Vec<u8> {
124        let piece = |offset: usize, len: usize| {
125            let mut bytes: Vec<u8> = record.iter().skip(offset).take(len).copied().collect();
126            bytes.resize(len, ebcdic::SPACE);
127            bytes
128        };
129        if self.split.is_empty() {
130            return piece(self.offset, self.len);
131        }
132        self.split.iter().flat_map(|&(offset, len)| piece(offset, len)).collect()
133    }
134
135}
136
137/// How the records of a file held in memory are keyed. Record numbers and positions are held as
138/// eight big-endian bytes, so that they sort.
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub enum Keying {
141    /// A sequential file opened I-O, by position.
142    Position,
143    /// A relative file, by record number. In the DD an empty slot is a record of no bytes, or of
144    /// zero bytes only.
145    Relative,
146    /// An indexed file: the prime key, then each alternate key and whether it allows duplicates.
147    Indexed { prime: KeySpan, alternates: Vec<(KeySpan, bool)> },
148}
149
150impl Keying {
151    /// An indexed file's keys, the prime key first; none for another file.
152    pub fn keys(&self) -> Vec<&KeySpan> {
153        match self {
154            Self::Indexed { prime, alternates } => std::iter::once(prime).chain(alternates.iter().map(|(span, _)| span)).collect(),
155            _ => Vec::new(),
156        }
157    }
158}
159
160/// The highest relative record number: past it WRITE is a boundary violation (status 24). It
161/// stands in for the space a cluster is defined with, so that a stray record number cannot make
162/// CLOSE write millions of empty slots.
163pub const MAX_RELATIVE: u64 = 16_777_215;
164
165pub fn record_number(n: u64) -> Vec<u8> {
166    n.to_be_bytes().to_vec()
167}
168
169pub fn number_of(key: &[u8]) -> u64 {
170    u64::from_be_bytes(key.try_into().unwrap_or([0; 8]))
171}
172
173/// An alternate index entry: alternate key, arrival number, prime key. Duplicates come back in
174/// the order they arrived, as VSAM keeps them; records loaded from a DD arrive in prime-key order,
175/// as BLDINDEX builds an alternate index.
176type Entry = (Vec<u8>, u64, Vec<u8>);
177
178struct Alternate {
179    span: KeySpan,
180    duplicates: bool,
181    index: BTreeSet<Entry>,
182    arrival: HashMap<Vec<u8>, u64>,
183}
184
185impl Alternate {
186    fn holders<'a>(&'a self, value: &'a [u8]) -> impl Iterator<Item = &'a Entry> {
187        self.index.range((value.to_vec(), 0, Vec::new())..).take_while(move |(v, ..)| v == value)
188    }
189}
190
191/// The file position indicator.
192#[derive(Clone, Debug)]
193enum Cursor {
194    /// After OPEN: READ NEXT reads the first record by the prime key.
195    First,
196    /// A key of reference (0 the prime key, then each alternate), a place in its index, and
197    /// whether the record there is itself next (after START) or was the last read.
198    At { which: usize, at: Entry, inclusive: bool },
199    /// After an AT END condition or a START that found nothing: READ NEXT fails with status 46.
200    Undefined,
201}
202
203/// A record READ found.
204pub struct Found {
205    pub key: Vec<u8>,
206    pub record: Vec<u8>,
207    /// The next record by the alternate key of reference has the same key (status 02).
208    pub duplicate: bool,
209}
210
211/// An indexed or relative file, or a sequential file opened I-O, held in memory from OPEN to
212/// CLOSE. The DD holds the records in key (or record-number) order, as an IDCAMS REPRO unload of a
213/// KSDS or RRDS does, and CLOSE writes them back that way when they changed.
214pub struct Keyed {
215    pub keying: Keying,
216    records: BTreeMap<Vec<u8>, Vec<u8>>,
217    alternates: Vec<Alternate>,
218    arrivals: u64,
219    cursor: Cursor,
220    /// The key of the record the last statement on the file, a successful READ, returned: what a
221    /// sequential REWRITE or DELETE acts on.
222    pub last_read: Option<Vec<u8>>,
223    dirty: bool,
224    path: Option<PathBuf>,
225    record_len: usize,
226    page: &'static CodePage,
227    /// The open mark ([`open_mark`]) CLOSE removes.
228    mark: Option<PathBuf>,
229}
230
231impl Keyed {
232    fn new(keying: Keying, path: Option<PathBuf>, record_len: usize, page: &'static CodePage) -> Self {
233        let alternates = match &keying {
234            Keying::Indexed { alternates, .. } => {
235                alternates.iter().map(|(span, duplicates)| Alternate { span: span.clone(), duplicates: *duplicates, index: BTreeSet::new(), arrival: HashMap::new() }).collect()
236            }
237            _ => Vec::new(),
238        };
239        Self { keying, records: BTreeMap::new(), alternates, arrivals: 0, cursor: Cursor::First, last_read: None, dirty: true, path, record_len, page, mark: None }
240    }
241
242    pub fn prime_key(&self, record: &[u8]) -> Option<Vec<u8>> {
243        match &self.keying {
244            Keying::Indexed { prime, .. } => Some(prime.of(record)),
245            _ => None,
246        }
247    }
248
249    pub fn highest_key(&self) -> Option<&Vec<u8>> {
250        self.records.keys().next_back()
251    }
252
253    pub fn record(&self, key: &[u8]) -> Option<&Vec<u8>> {
254        self.records.get(key)
255    }
256
257    fn key_len(&self, which: usize) -> usize {
258        match (&self.keying, which) {
259            (Keying::Indexed { prime, .. }, 0) => prime.len,
260            (Keying::Indexed { .. }, n) => self.alternates[n - 1].span.len,
261            _ => 8,
262        }
263    }
264
265    /// The record whose key `which` is `value`: for an alternate key, the first of any duplicates.
266    pub fn get(&self, which: usize, value: &[u8]) -> Option<Found> {
267        if which == 0 {
268            return self.records.get(value).map(|r| Found { key: value.to_vec(), record: r.clone(), duplicate: false });
269        }
270        let mut hits = self.alternates[which - 1].holders(value);
271        let key = hits.next()?.2.clone();
272        Some(Found { record: self.records[&key].clone(), key, duplicate: hits.next().is_some() })
273    }
274
275    /// Adds a record, or replaces the one with its key: Err with status 22 when it would take a
276    /// unique alternate key another record holds. Ok(true) when it shares an alternate key that
277    /// allows duplicates (status 02).
278    fn put(&mut self, key: Vec<u8>, record: Vec<u8>) -> Result<bool, FileStatus> {
279        let mut shared = false;
280        for alt in &self.alternates {
281            let value = alt.span.of(&record);
282            let others = alt.holders(&value).any(|(_, _, k)| *k != key);
283            if others && !alt.duplicates {
284                return Err(FileStatus::DuplicateKey);
285            }
286            shared |= others;
287        }
288        let old = self.records.get(&key).cloned();
289        let mut arrivals = self.arrivals;
290        for alt in &mut self.alternates {
291            let now = alt.span.of(&record);
292            if let Some(old) = &old {
293                let was = alt.span.of(old);
294                if was == now {
295                    continue;
296                }
297                let seq = alt.arrival[&key];
298                alt.index.remove(&(was, seq, key.clone()));
299            }
300            alt.index.insert((now, arrivals, key.clone()));
301            alt.arrival.insert(key.clone(), arrivals);
302            arrivals += 1;
303        }
304        self.arrivals = arrivals;
305        self.records.insert(key, record);
306        self.dirty = true;
307        Ok(shared)
308    }
309
310    /// Adds a record: Err with status 22 when its prime key, or a unique alternate key, is already
311    /// there; Ok(true) when it shares an alternate key that allows duplicates (status 02).
312    pub fn insert(&mut self, key: Vec<u8>, record: Vec<u8>) -> Result<bool, FileStatus> {
313        if self.records.contains_key(&key) {
314            return Err(FileStatus::DuplicateKey);
315        }
316        self.put(key, record)
317    }
318
319    /// Replaces a record: Err with status 23 when none has its key, 22 as for [`Keyed::insert`].
320    pub fn replace(&mut self, key: Vec<u8>, record: Vec<u8>) -> Result<bool, FileStatus> {
321        if !self.records.contains_key(&key) {
322            return Err(FileStatus::NotFound);
323        }
324        self.put(key, record)
325    }
326
327    pub fn remove(&mut self, key: &[u8]) -> Option<Vec<u8>> {
328        let record = self.records.remove(key)?;
329        for alt in &mut self.alternates {
330            if let Some(seq) = alt.arrival.remove(key) {
331                alt.index.remove(&(alt.span.of(&record), seq, key.to_vec()));
332            }
333        }
334        self.dirty = true;
335        Some(record)
336    }
337
338    /// START: positions at the first record whose key `which`, compared over the length of `value`
339    /// (a longer `value` is cut to the key's length), is `wanted` to `value`, or equal when
340    /// `or_equal`; at the last such record when `wanted` is Less. False, and no position, when there
341    /// is none.
342    pub fn start(&mut self, which: usize, wanted: Ordering, or_equal: bool, value: &[u8]) -> bool {
343        let value = &value[..value.len().min(self.key_len(which))];
344        let fits = |k: &[u8]| {
345            let o = k[..value.len().min(k.len())].cmp(value);
346            o == wanted || (or_equal && o == Ordering::Equal)
347        };
348        let from = (value.to_vec(), 0, Vec::new());
349        let found = match (which, wanted) {
350            (0, Ordering::Less) => self.records.keys().rev().find(|k| fits(k)).map(|k| (k.clone(), 0, Vec::new())),
351            (0, _) => self.records.range(from.0..).map(|(k, _)| k).find(|k| fits(k)).map(|k| (k.clone(), 0, Vec::new())),
352            (n, Ordering::Less) => self.alternates[n - 1].index.iter().rev().find(|(k, ..)| fits(k)).cloned(),
353            (n, _) => self.alternates[n - 1].index.range(from..).find(|(k, ..)| fits(k)).cloned(),
354        };
355        self.last_read = None;
356        match found {
357            Some(at) => {
358                self.cursor = Cursor::At { which, at, inclusive: true };
359                true
360            }
361            None => {
362                self.lose_position();
363                false
364            }
365        }
366    }
367
368    /// After a READ that found nothing: READ NEXT has no valid next record.
369    pub fn lose_position(&mut self) {
370        self.cursor = Cursor::Undefined;
371        self.last_read = None;
372    }
373
374    /// Makes the record just read by key `which` the one READ NEXT goes on from.
375    pub fn read_at(&mut self, which: usize, key: &[u8]) {
376        let at = match which {
377            0 => (key.to_vec(), 0, Vec::new()),
378            n => {
379                let alt = &self.alternates[n - 1];
380                (alt.span.of(&self.records[key]), alt.arrival[key], key.to_vec())
381            }
382        };
383        self.cursor = Cursor::At { which, at, inclusive: false };
384        self.last_read = Some(key.to_vec());
385    }
386
387    /// The first record by prime key after `from` (at it too when `inclusive`), or before it when
388    /// `backward`: what a CICS browse reads, holding its own position rather than the file's.
389    pub fn seek(&self, from: &[u8], inclusive: bool, backward: bool) -> Option<(Vec<u8>, Vec<u8>)> {
390        let edge = if inclusive { Bound::Included(from.to_vec()) } else { Bound::Excluded(from.to_vec()) };
391        let mut hits = if backward { self.records.range((Bound::Unbounded, edge)) } else { self.records.range((edge, Bound::Unbounded)) };
392        let hit = if backward { hits.next_back() } else { hits.next() };
393        hit.map(|(k, r)| (k.clone(), r.clone()))
394    }
395
396    /// [`Keyed::read_next`] with the status as text, for SORT's reader.
397    pub fn step(&mut self, backward: bool) -> Result<Option<Found>, &'static str> {
398        self.read_next(backward).map_err(FileStatus::as_str)
399    }
400
401    /// READ NEXT, or READ PREVIOUS when `backward`: None at the end, Err(46) with no position.
402    pub fn read_next(&mut self, backward: bool) -> Result<Option<Found>, FileStatus> {
403        let (which, bound) = match &self.cursor {
404            Cursor::Undefined => return Err(FileStatus::NoNextRecord),
405            Cursor::First => (0, Bound::Unbounded),
406            Cursor::At { which, at, inclusive: true } => (*which, Bound::Included(at.clone())),
407            Cursor::At { which, at, inclusive: false } => (*which, Bound::Excluded(at.clone())),
408        };
409        let range = if backward { (Bound::Unbounded, bound) } else { (bound, Bound::Unbounded) };
410        let hit = if which == 0 {
411            let mut keys = self.records.range((range.0.map(|(k, ..)| k), range.1.map(|(k, ..)| k))).map(|(k, _)| k);
412            let key = if backward { keys.next_back() } else { keys.next() };
413            key.map(|k| (k.clone(), false))
414        } else {
415            let mut entries = self.alternates[which - 1].index.range(range);
416            let entry = if backward { entries.next_back() } else { entries.next() };
417            entry.map(|(value, _, key)| {
418                let after = if backward { entries.next_back() } else { entries.next() };
419                (key.clone(), after.is_some_and(|(v, ..)| v == value))
420            })
421        };
422        let Some((key, duplicate)) = hit else {
423            self.lose_position();
424            return Ok(None);
425        };
426        self.read_at(which, &key);
427        Ok(Some(Found { record: self.records[&key].clone(), key, duplicate }))
428    }
429
430    fn inbound(&self, raw: Vec<u8>, format: Format) -> Vec<u8> {
431        let mut record = match format {
432            Format::Text => self.page.encode_lossy(&String::from_utf8_lossy(&raw)),
433            _ => raw,
434        };
435        if format != Format::Variable {
436            record.resize(self.record_len.max(record.len()), ebcdic::SPACE);
437        }
438        record
439    }
440
441    fn outbound(&self, record: &[u8], format: Format) -> Vec<u8> {
442        match format {
443            Format::Text => format!("{}\n", self.page.decode(record).trim_end()).into_bytes(),
444            Format::Fixed => {
445                let mut r = record.to_vec();
446                r.resize(self.record_len, ebcdic::SPACE);
447                r
448            }
449            Format::Variable => record.to_vec(),
450        }
451    }
452
453    fn save(&self, format: Format) -> io::Result<()> {
454        let Some(path) = self.path.as_ref().filter(|_| self.dirty) else { return Ok(()) };
455        let mut out = Open { mode: OpenMode::Output, format, handle: Handle::Writer(BufWriter::new(File::create(path)?)), head: Head::Start, page: None, assigned: None };
456        let empty = match format {
457            Format::Fixed => vec![0; self.record_len],
458            Format::Variable => Vec::new(),
459            Format::Text => b"\n".to_vec(),
460        };
461        let mut next = 1;
462        for (key, record) in &self.records {
463            if self.keying == Keying::Relative {
464                for _ in next..number_of(key) {
465                    out.write(&empty)?;
466                }
467                next = number_of(key) + 1;
468            }
469            out.write(&self.outbound(record, format))?;
470        }
471        out.close()
472    }
473}
474
475/// The file beside a VSAM data set that marks it open for output, as the catalog's open-for-output
476/// indicator does: an OPEN OUTPUT, I-O or EXTEND makes it and the CLOSE after it removes it, so a
477/// data set a run left open keeps it for the next OPEN to verify
478/// ([`numeric::assumptions::VSAM_DATA_SET_LEFT_OPEN`]).
479pub fn open_mark(data_set: &Path) -> PathBuf {
480    let mut name = data_set.as_os_str().to_owned();
481    name.push(".open-for-output");
482    PathBuf::from(name)
483}
484
485/// Whether a run left the VSAM data set at `data_set` open for output.
486pub fn left_open(data_set: &Path) -> bool {
487    data_set.is_file() && open_mark(data_set).is_file()
488}
489
490/// Removes the data set's open mark, if it has one.
491pub fn clear_open_mark(data_set: &Path) -> io::Result<()> {
492    remove_mark(&open_mark(data_set))
493}
494
495fn remove_mark(mark: &Path) -> io::Result<()> {
496    match std::fs::remove_file(mark) {
497        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
498        other => other,
499    }
500}
501
502pub struct Open {
503    pub mode: OpenMode,
504    pub format: Format,
505    handle: Handle,
506    head: Head,
507    /// The logical page of a LINAGE file opened OUTPUT or EXTEND.
508    pub page: Option<crate::linage::Page>,
509    /// The DD a file whose ASSIGN names a data item was opened on.
510    pub assigned: Option<String>,
511}
512
513/// A movement of the paper, as a text DD shows it: line feeds, or a form feed.
514#[derive(Clone, Copy, Debug, PartialEq, Eq)]
515pub enum Move {
516    Lines(u64),
517    Page,
518}
519
520/// Where a text DD's print position is: before its first line, on a line not yet ended (and
521/// whether anything shows on it), or at the start of a line.
522#[derive(Clone, Copy, Debug, PartialEq, Eq)]
523enum Head {
524    Start,
525    OnLine(bool),
526    Fresh,
527}
528
529/// What a READ found.
530pub enum Record {
531    Data(Vec<u8>),
532    /// A record whose length did not match: the bytes read, for FILE STATUS 04.
533    WrongLength(Vec<u8>),
534    End,
535}
536
537/// Opens a file held in memory. With no DD (an OPTIONAL file) or no file at the DD's path, it
538/// starts empty; OUTPUT always starts empty. `record_len` is the longest record.
539pub fn open_keyed(dd: Option<&Dd>, mode: OpenMode, format: Format, keying: Keying, record_len: usize, page: &'static CodePage) -> io::Result<Open> {
540    let path = dd.filter(|_| mode != OpenMode::Input).map(|d| d.path.clone());
541    let mut keyed = Keyed::new(keying, path, record_len, page);
542    if let Some(dd) = dd.filter(|d| mode != OpenMode::Output && d.path.exists()) {
543        let mut reader = open(dd, OpenMode::Input, format)?;
544        let mut number = 0u64;
545        loop {
546            let raw = match reader.read(record_len)? {
547                Record::End => break,
548                Record::Data(r) | Record::WrongLength(r) => r,
549            };
550            number += 1;
551            if keyed.keying == Keying::Relative && raw.iter().all(|&b| b == 0) {
552                continue;
553            }
554            let record = keyed.inbound(raw, format);
555            let key = keyed.prime_key(&record).unwrap_or_else(|| record_number(number));
556            keyed.insert(key, record).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, format!("{}: two records with one key", dd.path.display())))?;
557        }
558        keyed.dirty = false;
559    }
560    Ok(Open { mode, format, handle: Handle::Keyed(Box::new(keyed)), head: Head::Start, page: None, assigned: None })
561}
562
563pub fn open(dd: &Dd, mode: OpenMode, format: Format) -> io::Result<Open> {
564    let handle = match mode {
565        // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- a DD names the file the job assigned
566        OpenMode::Input => Handle::Reader(BufReader::new(File::open(&dd.path)?)),
567        // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- a DD names the file the job assigned
568        OpenMode::Output if !dd.append => Handle::Writer(BufWriter::new(File::create(&dd.path)?)),
569        OpenMode::Output | OpenMode::Extend => Handle::Writer(BufWriter::new(OpenOptions::new().append(true).create(true).open(&dd.path)?)),
570        OpenMode::InputOutput => return Err(io::Error::new(io::ErrorKind::Unsupported, "OPEN I-O of a line-sequential file")),
571    };
572    Ok(Open { mode, format, handle, head: Head::Start, page: None, assigned: None })
573}
574
575/// An OPTIONAL input file with no DD: every READ is at end.
576pub fn absent() -> Open {
577    Open { mode: OpenMode::Input, format: Format::Fixed, handle: Handle::Empty, head: Head::Start, page: None, assigned: None }
578}
579
580impl Open {
581    pub fn keyed(&mut self) -> Option<&mut Keyed> {
582        match &mut self.handle {
583            Handle::Keyed(k) => Some(k),
584            _ => None,
585        }
586    }
587
588    pub fn is_keyed(&self) -> bool {
589        matches!(self.handle, Handle::Keyed(_))
590    }
591
592    /// Marks the VSAM data set this file holds open for output until CLOSE ([`open_mark`]).
593    pub fn mark_open(&mut self, data_set: &Path) -> io::Result<()> {
594        let Handle::Keyed(k) = &mut self.handle else { return Ok(()) };
595        let mark = open_mark(data_set);
596        std::fs::write(&mark, b"")?;
597        k.mark = Some(mark);
598        Ok(())
599    }
600
601    pub fn read(&mut self, fixed_len: usize) -> io::Result<Record> {
602        let reader = match &mut self.handle {
603            Handle::Reader(r) => r,
604            Handle::Empty => return Ok(Record::End),
605            Handle::Writer(_) | Handle::Keyed(_) => return Err(io::Error::other("a sequential READ of a file not open for it")),
606        };
607        match self.format {
608            Format::Fixed => {
609                let mut buf = Vec::with_capacity(fixed_len);
610                reader.by_ref().take(fixed_len as u64).read_to_end(&mut buf)?;
611                Ok(match buf.len() {
612                    0 => Record::End,
613                    n if n == fixed_len => Record::Data(buf),
614                    _ => Record::WrongLength(buf),
615                })
616            }
617            Format::Variable => {
618                let mut rdw = [0u8; 4];
619                match reader.read_exact(&mut rdw) {
620                    Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(Record::End),
621                    other => other?,
622                }
623                let len = u16::from_be_bytes([rdw[0], rdw[1]]) as usize;
624                if len < 4 {
625                    return Err(io::Error::new(io::ErrorKind::InvalidData, format!("an RDW of length {len}")));
626                }
627                let mut buf = vec![0u8; len - 4];
628                reader.read_exact(&mut buf)?;
629                Ok(Record::Data(buf))
630            }
631            Format::Text => {
632                let mut line = Vec::new();
633                if reader.read_until(b'\n', &mut line)? == 0 {
634                    return Ok(Record::End);
635                }
636                while matches!(line.last(), Some(b'\n' | b'\r')) {
637                    line.pop();
638                }
639                Ok(Record::Data(line))
640            }
641        }
642    }
643
644    pub fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
645        let Handle::Writer(w) = &mut self.handle else {
646            return Err(io::Error::other("WRITE to a file not opened for output"));
647        };
648        match self.format {
649            Format::Fixed | Format::Text => w.write_all(bytes),
650            Format::Variable => {
651                let len = u16::try_from(bytes.len() + 4).map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "a record over 32,760 bytes"))?;
652                w.write_all(&len.to_be_bytes())?;
653                w.write_all(&[0, 0])?;
654                w.write_all(bytes)
655            }
656        }
657    }
658
659    /// Writes a line to a text DD where a printer would put it: after `before`, over a line not
660    /// yet ended when nothing moved the paper (after a carriage return, unless one of the two is
661    /// blank), then `after`. The first line of the DD takes one line of `before` as its own start,
662    /// as a printer at the top of a form prints a single-spaced first line on line 1.
663    pub fn print(&mut self, before: Option<Move>, line: &str, after: Option<Move>) -> io::Result<()> {
664        if let Some(m) = before {
665            self.feed(m)?;
666        }
667        let Handle::Writer(w) = &mut self.handle else {
668            return Err(io::Error::other("WRITE to a file not opened for output"));
669        };
670        let shown = matches!(self.head, Head::OnLine(true));
671        if shown && !line.is_empty() {
672            w.write_all(b"\r")?;
673        }
674        w.write_all(line.as_bytes())?;
675        self.head = Head::OnLine(shown || !line.is_empty());
676        match after {
677            Some(m) => self.feed(m),
678            None => Ok(()),
679        }
680    }
681
682    fn feed(&mut self, m: Move) -> io::Result<()> {
683        let Handle::Writer(w) = &mut self.handle else {
684            return Err(io::Error::other("WRITE to a file not opened for output"));
685        };
686        match (m, self.head) {
687            (Move::Lines(0), _) => return Ok(()),
688            (Move::Lines(n), head) => {
689                for _ in 0..n - u64::from(head == Head::Start) {
690                    w.write_all(b"\n")?;
691                }
692            }
693            (Move::Page, Head::OnLine(_)) => w.write_all(b"\n\x0c")?,
694            (Move::Page, _) => w.write_all(b"\x0c")?,
695        }
696        self.head = Head::Fresh;
697        Ok(())
698    }
699
700    pub fn close(self) -> io::Result<()> {
701        match self.handle {
702            Handle::Writer(mut w) => {
703                if matches!(self.head, Head::OnLine(_)) {
704                    w.write_all(b"\n")?;
705                }
706                w.flush()
707            }
708            Handle::Keyed(k) => {
709                k.save(self.format)?;
710                k.mark.as_deref().map_or(Ok(()), remove_mark)
711            }
712            _ => Ok(()),
713        }
714    }
715
716    /// Ends the file as an abend that Language Environment is not told of leaves it: its records are
717    /// written as CLOSE writes them, and a VSAM data set keeps its open mark
718    /// ([`numeric::assumptions::TRAP_OFF_LEAVES_FILES_OPEN`]).
719    pub fn abandon(mut self) -> io::Result<()> {
720        if let Handle::Keyed(k) = &mut self.handle {
721            k.mark = None;
722        }
723        self.close()
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730
731    #[test]
732    fn dd_specs() {
733        let dds = Dds::new(&["IN=/tmp/a.txt:text".into(), "out=/tmp/b.dat".into()], false).unwrap();
734        assert_eq!(dds.get("IN"), Some(Dd { path: "/tmp/a.txt".into(), format: Some(Format::Text), append: false }));
735        assert_eq!(dds.get("OUT").unwrap().format, None);
736        assert!(dds.get("NONE").is_none());
737        let labelled = Dds::new(&["MASTER=/tmp/m".into(), "UT-S-X=/tmp/x".into()], false).unwrap();
738        assert_eq!(labelled.assigned("DA-MASTER").map(|d| d.path), Some("/tmp/m".into()));
739        assert_eq!(labelled.assigned("UT-S-X").map(|d| d.path), Some("/tmp/x".into()));
740        assert!(labelled.assigned("DA-NOTHERE").is_none() && labelled.assigned("A-TOOLONGNAME").is_none() && labelled.assigned("DA-9BAD").is_none());
741        assert!(Dds::new(&["NOEQUALS".into()], false).is_err());
742    }
743
744    #[test]
745    fn mod_suffix_follows_the_format_or_stands_alone() {
746        let dd = |value: &str| dd_value(value).unwrap();
747        assert_eq!(dd("a.dat:mod"), Dd { path: "a.dat".into(), format: None, append: true });
748        assert_eq!(dd("a.dat:text:mod"), Dd { path: "a.dat".into(), format: Some(Format::Text), append: true });
749        assert_eq!(dd("A.DAT:TEXT:MOD"), Dd { path: "A.DAT".into(), format: Some(Format::Text), append: true });
750        assert_eq!(dd("a.dat:text"), Dd { path: "a.dat".into(), format: Some(Format::Text), append: false });
751        assert_eq!(dd("c:\\a.dat"), Dd { path: "c:\\a.dat".into(), format: None, append: false });
752        assert!(dd_value(":mod").is_err());
753    }
754
755    #[test]
756    fn open_output_on_a_mod_dd_writes_after_the_records_there() {
757        let path = std::env::temp_dir().join(format!("ironwork-mod-{}", std::process::id()));
758        let write = |append: bool, records: &[&[u8]]| {
759            let mut out = open(&Dd { path: path.clone(), format: None, append }, OpenMode::Output, Format::Fixed).unwrap();
760            for record in records {
761                out.write(record).unwrap();
762            }
763            out.close().unwrap();
764        };
765        write(false, &[b"AA", b"BB"]);
766        write(true, &[b"CC"]);
767        assert_eq!(std::fs::read(&path).unwrap(), b"AABBCC");
768        write(false, &[b"DD"]);
769        assert_eq!(std::fs::read(&path).unwrap(), b"DD");
770        std::fs::remove_file(&path).unwrap();
771    }
772
773    #[test]
774    fn variable_records_round_trip_through_rdws() {
775        let path = std::env::temp_dir().join(format!("ironwork-vb-{}", std::process::id()));
776        let dd = Dd { path: path.clone(), format: None, append: false };
777        let mut out = open(&dd, OpenMode::Output, Format::Variable).unwrap();
778        out.write(b"AB").unwrap();
779        out.write(b"CDEF").unwrap();
780        out.close().unwrap();
781        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']);
782        let mut input = open(&dd, OpenMode::Input, Format::Variable).unwrap();
783        assert!(matches!(input.read(0).unwrap(), Record::Data(d) if d == b"AB"));
784        assert!(matches!(input.read(0).unwrap(), Record::Data(d) if d == b"CDEF"));
785        assert!(matches!(input.read(0).unwrap(), Record::End));
786    }
787}