Skip to main content

ironwork_rt/cics/
mod.rs

1//! The CICS region a harness run stands in for: one task, its files, its temporary-storage and
2//! transient-data queues, and the time services; and the commands a program runs against it, as
3//! `CicsCommand`s the executor builds and `run` carries out.
4
5mod command;
6mod file_control;
7mod maps;
8mod program;
9mod run;
10mod services;
11
12pub use command::{Assign, Cics, CicsCommand, Control, Datum, FileControl, FileOptions, Handles, Opt, Record, Resp, Transfer};
13pub use run::{
14    At, CicsHost, EIBAID, EIBCALEN, EIBCPOSN, EIBDATE, EIBFN, EIBRESP, EIBRESP2, EIBRSRCE, EIBTASKN, EIBTIME, EIBTRMID,
15    EIBTRNID, Flow, Handler, Handlers, begin_command, begin_task, bytes, in_task, ok, raise, run, unsupported,
16};
17
18use crate::files::{Dd, Format, KeySpan, Keying};
19use crate::calendar::{civil, EPOCH_1900_TO_1970_MILLIS, EPOCH_1900_TO_1970_SECONDS, SECONDS_PER_DAY};
20pub use crate::terminal::Terminal;
21use std::collections::{BTreeMap, HashMap};
22use std::fs::OpenOptions;
23use std::io::Write;
24use std::path::PathBuf;
25
26/// The EXEC interface block's length: EIBRLDBK, its last field, is at X'54'.
27pub const EIB_LEN: usize = 85;
28
29macro_rules! conditions {
30    ($($name:ident)*) => {
31        /// A CICS exception condition, one variant per name DFHRESP knows, spelt as IBM spells it.
32        #[allow(clippy::upper_case_acronyms)]
33        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34        pub enum Condition {
35            $($name,)*
36        }
37
38        impl Condition {
39            pub const ALL: &[Self] = &[$(Self::$name,)*];
40
41            pub fn name(self) -> &'static str {
42                match self {
43                    $(Self::$name => stringify!($name),)*
44                }
45            }
46        }
47    };
48}
49
50conditions! {
51    NORMAL ERROR RDATT WRBRK EOF EODS EOC INBFMH ENDINPT NONVAL NOSTART TERMIDERR FILENOTFOUND
52    NOTFND DUPREC DUPKEY INVREQ IOERR NOSPACE NOTOPEN ENDFILE ILLOGIC LENGERR QZERO SIGNAL QBUSY
53    ITEMERR PGMIDERR TRANSIDERR ENDDATA INVTSREQ EXPIRED RETPAGE RTEFAIL RTESOME TSIOERR MAPFAIL
54    INVERRTERM INVMPSZ IGREQID OVERFLOW INVLDC NOSTG JIDERR QIDERR NOJBUFSP DSSTAT SELNERR FUNCERR
55    UNEXPIN NOPASSBKRD NOPASSBKWR SEGIDERR SYSIDERR ISCINVREQ ENQBUSY ENVDEFERR IGREQCD SESSIONERR
56    SYSBUSY SESSBUSY NOTALLOC CBIDERR INVEXITREQ INVPARTNSET INVPARTN PARTNFAIL USERIDERR NOTAUTH
57    VOLIDERR SUPPRESSED RESIDERR NOSPOOL TERMERR ROLLEDBACK END DISABLED ALLOCERR STRELERR OPENERR
58    SPOLBUSY SPOLERR NODEIDERR TASKIDERR TCIDERR DSNNOTFOUND LOADING MODELIDERR OUTDESCRERR
59    PARTNERIDERR PROFILEIDERR NETNAMEIDERR LOCKED RECORDBUSY UOWNOTFOUND UOWLNOTFOUND LINKABEND
60    CHANGED PROCESSBUSY ACTIVITYBUSY PROCESSERR ACTIVITYERR CONTAINERERR EVENTERR TOKENERR
61    NOTFINISHED POOLERR TIMERERR SYMBOLERR TEMPLATERR NOTSUPERUSER CSDERR DUPRES RESUNAVAIL
62    CHANNELERR CCSIDERR TIMEDOUT CODEPAGEERR INCOMPLETE APPNOTFOUND BUSY
63}
64
65/// Names a condition also goes by: DSIDERR is FILENOTFOUND's older name.
66const ALIASES: &[(&str, Condition)] = &[("DSIDERR", Condition::FILENOTFOUND)];
67
68/// The transaction abend CICS issues when a condition is raised and nothing handles it; a
69/// condition not listed abends AEIP.
70const DEFAULT_ABENDS: &[(Condition, &str)] = &[
71    (Condition::NOTFND, "AEIM"),
72    (Condition::DUPREC, "AEIN"),
73    (Condition::DUPKEY, "AEIO"),
74    (Condition::IOERR, "AEIQ"),
75    (Condition::NOSPACE, "AEIR"),
76    (Condition::NOTOPEN, "AEIS"),
77    (Condition::ENDFILE, "AEIT"),
78    (Condition::ILLOGIC, "AEIU"),
79    (Condition::LENGERR, "AEIV"),
80    (Condition::QZERO, "AEIW"),
81    (Condition::ITEMERR, "AEIZ"),
82    (Condition::PGMIDERR, "AEI0"),
83    (Condition::TRANSIDERR, "AEI1"),
84    (Condition::ENDDATA, "AEI2"),
85    (Condition::INVTSREQ, "AEI3"),
86    (Condition::EXPIRED, "AEI4"),
87    (Condition::TSIOERR, "AEI8"),
88    (Condition::MAPFAIL, "AEI9"),
89    (Condition::ERROR, "AEIA"),
90    (Condition::EOF, "AEID"),
91    (Condition::EODS, "AEIE"),
92    (Condition::INBFMH, "AEIG"),
93    (Condition::ENDINPT, "AEIH"),
94    (Condition::NONVAL, "AEII"),
95    (Condition::NOSTART, "AEIJ"),
96    (Condition::TERMIDERR, "AEIK"),
97    (Condition::FILENOTFOUND, "AEIL"),
98    (Condition::DISABLED, "AEXL"),
99    (Condition::ROLLEDBACK, "AEXJ"),
100    (Condition::LOCKED, "AEX8"),
101    (Condition::RECORDBUSY, "AEX9"),
102    (Condition::QIDERR, "AEYH"),
103    (Condition::SYSIDERR, "AEYQ"),
104    (Condition::NOTAUTH, "AEY7"),
105    (Condition::USERIDERR, "AEYX"),
106    (Condition::CONTAINERERR, "AEZJ"),
107    (Condition::CHANNELERR, "AEZV"),
108];
109
110impl Condition {
111    /// The condition DFHRESP(name) names, by either of its names, in any case.
112    pub fn from_name(name: &str) -> Option<Self> {
113        let alias = ALIASES.iter().find(|(a, _)| a.eq_ignore_ascii_case(name)).map(|&(_, c)| c);
114        alias.or_else(|| Self::ALL.iter().copied().find(|c| c.name().eq_ignore_ascii_case(name)))
115    }
116
117    /// EIBRESP's value for the condition, from IBM's DFHRESP table.
118    pub fn resp(self) -> i32 {
119        crate::cics_tables::resp(self.name()).unwrap_or_else(|| panic!("DFHRESP has no {}", self.name()))
120    }
121
122    pub fn default_abend(self) -> &'static str {
123        DEFAULT_ABENDS.iter().find(|&&(c, _)| c == self).map_or("AEIP", |&(_, abend)| abend)
124    }
125}
126
127/// What kind of VSAM data set a CICS file is.
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub enum DataSet {
130    Ksds { key: KeySpan },
131    Rrds,
132}
133
134/// A file-control table entry: a CICS FILE name's data set, given with --file.
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub struct FileDef {
137    pub dd: Dd,
138    pub data_set: DataSet,
139    pub record_len: usize,
140}
141
142impl FileDef {
143    /// How files::open_keyed keys the data set.
144    pub fn keying(&self) -> Keying {
145        match &self.data_set {
146            DataSet::Ksds { key } => Keying::Indexed { prime: *key, alternates: vec![] },
147            DataSet::Rrds => Keying::Relative,
148        }
149    }
150
151    pub fn format(&self) -> Format {
152        self.dd.format.unwrap_or(Format::Fixed)
153    }
154}
155
156/// Parses `NAME=path,KSDS,key=OFFSET:LENGTH,len=RECLEN[,text|,fixed|,variable]` or
157/// `NAME=path,RRDS,len=RECLEN[,text|,fixed|,variable]`.
158pub fn parse_file(spec: &str) -> Result<(String, FileDef), String> {
159    let parts: Vec<&str> = spec.split(',').collect();
160    if parts.len() < 3 {
161        return Err(format!("{spec}: expected NAME=path,KIND,…"));
162    }
163
164    let (name, path) = parts[0]
165        .split_once('=')
166        .ok_or_else(|| format!("{spec}: expected NAME=path"))?;
167    let name = name.to_ascii_uppercase();
168    let kind = parts[1].to_ascii_uppercase();
169
170    let mut key: Option<KeySpan> = None;
171    let mut record_len: Option<usize> = None;
172    let mut format: Option<Format> = None;
173
174    for part in &parts[2..] {
175        let part = part.trim();
176        if let Some(val) = part.strip_prefix("key=") {
177            let (off, len) = val
178                .split_once(':')
179                .ok_or_else(|| format!("{spec}: bad key spec {val}"))?;
180            let off: usize = off.parse().map_err(|_| format!("{spec}: bad key offset {off}"))?;
181            let len: usize = len.parse().map_err(|_| format!("{spec}: bad key length {len}"))?;
182            key = Some(KeySpan { offset: off, len });
183        } else if let Some(val) = part.strip_prefix("len=") {
184            record_len = Some(
185                val.parse()
186                    .map_err(|_| format!("{spec}: bad record length {val}"))?,
187            );
188        } else if let Some(f) = Format::from_keyword(part) {
189            format = Some(f);
190        } else {
191            return Err(format!("{spec}: unknown field {part}"));
192        }
193    }
194
195    let data_set = match kind.as_str() {
196        "KSDS" => {
197            let key = key.ok_or_else(|| format!("{spec}: KSDS requires key=OFFSET:LENGTH"))?;
198            DataSet::Ksds { key }
199        }
200        "RRDS" => DataSet::Rrds,
201        _ => return Err(format!("{spec}: unknown data set kind {kind}")),
202    };
203
204    let record_len = record_len.ok_or_else(|| format!("{spec}: missing len=RECLEN"))?;
205    let dd = Dd { path: PathBuf::from(path), format, append: false };
206    Ok((name, FileDef { dd, data_set, record_len }))
207}
208
209/// A temporary-storage queue: its items (item numbers start at 1) and where READQ TS NEXT is.
210#[derive(Clone, Debug, Default, PartialEq, Eq)]
211pub struct TsQueue {
212    pub items: Vec<Vec<u8>>,
213    pub next: usize,
214}
215
216/// A browse's position: the key it is at, and whether the record there is itself next (after
217/// STARTBR or RESETBR) or was the last returned.
218#[derive(Clone, Debug, Default, PartialEq, Eq)]
219pub struct Browse {
220    pub at: Vec<u8>,
221    pub inclusive: bool,
222}
223
224/// The task: who and what started it, and the resources it has touched.
225#[derive(Debug, Default)]
226pub struct Task {
227    pub transid: String,
228    pub termid: String,
229    pub userid: String,
230    pub applid: String,
231    pub sysid: String,
232    pub number: u32,
233    /// The COMMAREA the task starts with; EIBCALEN is its length.
234    pub commarea: Option<Vec<u8>>,
235    pub files: HashMap<String, FileDef>,
236    pub ts: BTreeMap<String, TsQueue>,
237    pub td: BTreeMap<String, Vec<Vec<u8>>>,
238    /// TD queues whose items are appended to a host file as text lines when the task ends.
239    pub td_files: HashMap<String, PathBuf>,
240    /// The key each file's READ UPDATE holds, for REWRITE, DELETE and UNLOCK.
241    pub held: HashMap<String, Vec<u8>>,
242    /// Open browses by file and REQID.
243    pub browses: HashMap<(String, i64), Browse>,
244    pub terminal: Option<Box<dyn Terminal>>,
245    /// The AID key whose input started the task, as EIBAID shows it before any RECEIVE.
246    pub initial_aid: Option<u8>,
247    /// Mapsets already read from the copy libraries, by name.
248    pub mapsets: HashMap<String, crate::bms::Mapset>,
249    /// RETURN TRANSID and COMMAREA, when the task ended that way.
250    pub next_transid: Option<String>,
251    pub returned_commarea: Option<Vec<u8>>,
252}
253
254impl Task {
255    /// WRITEQ TS: appends and returns the new item's number; with Some(item) (REWRITE) replaces
256    /// that item and returns it.
257    pub fn writeq_ts(
258        &mut self,
259        queue: &str,
260        rewrite: Option<usize>,
261        data: &[u8],
262    ) -> Result<usize, Condition> {
263        let queue = queue.trim_end();
264        match rewrite {
265            None => {
266                let q = self
267                    .ts
268                    .entry(queue.to_string())
269                    .or_default();
270                q.items.push(data.to_vec());
271                Ok(q.items.len())
272            }
273            Some(item) => {
274                let q = self.ts.get_mut(queue).ok_or(Condition::QIDERR)?;
275                if item == 0 || item > q.items.len() {
276                    return Err(Condition::ITEMERR);
277                }
278                q.items[item - 1] = data.to_vec();
279                Ok(item)
280            }
281        }
282    }
283
284    /// READQ TS: Some(item) reads that item; None reads the next item after the last read.
285    pub fn readq_ts(
286        &mut self,
287        queue: &str,
288        item: Option<usize>,
289    ) -> Result<(Vec<u8>, usize), Condition> {
290        let queue = queue.trim_end();
291        let q = self.ts.get_mut(queue).ok_or(Condition::QIDERR)?;
292        let item_num = match item {
293            Some(n) => {
294                if n == 0 || n > q.items.len() {
295                    return Err(Condition::ITEMERR);
296                }
297                n
298            }
299            None => {
300                let n = if q.next == 0 { 1 } else { q.next + 1 };
301                if n > q.items.len() {
302                    return Err(Condition::ITEMERR);
303                }
304                n
305            }
306        };
307        let data = q.items[item_num - 1].clone();
308        q.next = item_num;
309        Ok((data, q.items.len()))
310    }
311
312    /// DELETEQ TS.
313    pub fn deleteq_ts(&mut self, queue: &str) -> Result<(), Condition> {
314        let queue = queue.trim_end();
315        self.ts.remove(queue).map(|_| ()).ok_or(Condition::QIDERR)
316    }
317
318    /// WRITEQ TD: appends (creating the queue).
319    pub fn writeq_td(&mut self, queue: &str, data: &[u8]) {
320        let queue = queue.trim_end();
321        self.td.entry(queue.to_string()).or_default().push(data.to_vec());
322    }
323
324    /// READQ TD: removes and returns the oldest item.
325    pub fn readq_td(&mut self, queue: &str) -> Result<Vec<u8>, Condition> {
326        let queue = queue.trim_end();
327        let items = self.td.get_mut(queue).ok_or(Condition::QZERO)?;
328        if items.is_empty() {
329            return Err(Condition::QZERO);
330        }
331        Ok(items.remove(0))
332    }
333
334    /// DELETEQ TD: empties the queue (no error when absent).
335    pub fn deleteq_td(&mut self, queue: &str) {
336        let queue = queue.trim_end();
337        if let Some(items) = self.td.get_mut(queue) {
338            items.clear();
339        }
340    }
341
342    /// Appends each TD queue that has a td_files entry to its file, one line per item, each item
343    /// decoded through `page` with trailing spaces trimmed; then empties those queues.
344    pub fn flush_td(&mut self, page: &zarch::ebcdic::CodePage) -> std::io::Result<()> {
345        let queues: Vec<String> = self.td_files.keys().cloned().collect();
346        for queue in &queues {
347            let Some(path) = self.td_files.get(queue) else { continue };
348            let items: Vec<Vec<u8>> = self
349                .td
350                .get(queue)
351                .cloned()
352                .unwrap_or_default();
353            if items.is_empty() {
354                continue;
355            }
356            let mut file = OpenOptions::new().create(true).append(true).open(path)?;
357            for item in &items {
358                let text = page.decode(item);
359                let line = format!("{}\n", text.trim_end());
360                file.write_all(line.as_bytes())?;
361            }
362            if let Some(q) = self.td.get_mut(queue) {
363                q.clear();
364            }
365        }
366        Ok(())
367    }
368}
369
370/// Milliseconds from 1900-01-01T00:00:00 to a Unix time given as seconds and hundredths.
371pub fn abstime(seconds: i64, hundredths: u32) -> i64 {
372    EPOCH_1900_TO_1970_MILLIS + seconds * 1000 + hundredths as i64 * 10
373}
374
375/// Unix seconds (rounded down) of an ABSTIME.
376pub fn unix_seconds(abstime: i64) -> i64 {
377    (abstime - EPOCH_1900_TO_1970_MILLIS).div_euclid(1000)
378}
379
380/// EIBDATE's value for an ABSTIME: 0CYYDDD as a decimal number.
381pub fn eib_date(abstime: i64) -> i64 {
382    let secs = unix_seconds(abstime);
383    let date = civil(secs);
384    let c = if date.year >= 2000 { 1 } else { 0 };
385    let yy = date.year % 100;
386    c * 100_000 + yy * 1_000 + i64::from(date.day_of_year)
387}
388
389/// EIBTIME's value for an ABSTIME: 0HHMMSS as a decimal number.
390pub fn eib_time(abstime: i64) -> i64 {
391    let secs = unix_seconds(abstime);
392    let time = civil(secs);
393    i64::from(time.hour) * 10_000 + i64::from(time.minute) * 100 + i64::from(time.second)
394}
395
396/// A FORMATTIME output: text for the date and time forms, a number for the counts.
397#[derive(Clone, Debug, PartialEq, Eq)]
398pub enum FormatValue {
399    Text(String),
400    Number(i64),
401}
402
403/// One FORMATTIME option's value for an ABSTIME, or None for an option this does not produce.
404/// `datesep` and `timesep` are the separators when DATESEP / TIMESEP were given.
405pub fn format_time(abstime: i64, option: &str, datesep: Option<char>, timesep: Option<char>) -> Option<FormatValue> {
406    let secs = unix_seconds(abstime);
407    let c = civil(secs);
408    let (year, month, day, hour, minute, second, doy) = (c.year, c.month, c.day, c.hour, c.minute, c.second, c.day_of_year);
409
410    let yy = format!("{:02}", year % 100);
411    let yyyy = format!("{:04}", year);
412    let mm = format!("{:02}", month);
413    let dd = format!("{:02}", day);
414    let hh = format!("{:02}", hour);
415    let mi = format!("{:02}", minute);
416    let ss = format!("{:02}", second);
417    let ddd = format!("{:03}", doy);
418
419    let join = |parts: &[&str], sep: Option<char>| match sep {
420        Some(c) => {
421            let mut s = String::new();
422            for (i, p) in parts.iter().enumerate() {
423                if i > 0 {
424                    s.push(c);
425                }
426                s.push_str(p);
427            }
428            s
429        }
430        None => parts.concat(),
431    };
432
433    let option = option.to_ascii_uppercase();
434
435    match option.as_str() {
436        "YYYYMMDD" => Some(FormatValue::Text(join(&[&yyyy, &mm, &dd], datesep))),
437        "YYMMDD" => Some(FormatValue::Text(join(&[&yy, &mm, &dd], datesep))),
438        "YYDDMM" => Some(FormatValue::Text(join(&[&yy, &dd, &mm], datesep))),
439        "YYYYDDMM" => Some(FormatValue::Text(join(&[&yyyy, &dd, &mm], datesep))),
440        "DDMMYY" => Some(FormatValue::Text(join(&[&dd, &mm, &yy], datesep))),
441        "DDMMYYYY" => Some(FormatValue::Text(join(&[&dd, &mm, &yyyy], datesep))),
442        "MMDDYY" | "DATE" => Some(FormatValue::Text(join(&[&mm, &dd, &yy], datesep))),
443        "MMDDYYYY" | "FULLDATE" => Some(FormatValue::Text(join(&[&mm, &dd, &yyyy], datesep))),
444        "YYDDD" => Some(FormatValue::Text(join(&[&yy, &ddd], datesep))),
445        "YYYYDDD" => Some(FormatValue::Text(join(&[&yyyy, &ddd], datesep))),
446        "DATEFORM" => Some(FormatValue::Text("MMDDYY".to_string())),
447        "TIME" => Some(FormatValue::Text(join(&[&hh, &mi, &ss], timesep))),
448        "DAYCOUNT" => Some(FormatValue::Number((secs + EPOCH_1900_TO_1970_SECONDS) / SECONDS_PER_DAY)),
449        "DAYOFWEEK" => Some(FormatValue::Number(c.cics_weekday())),
450        "DAYOFMONTH" => Some(FormatValue::Number(day as i64)),
451        "MONTHOFYEAR" => Some(FormatValue::Number(month as i64)),
452        "YEAR" => Some(FormatValue::Number(year)),
453        "MILLISECONDS" => Some(FormatValue::Number(abstime.rem_euclid(1000))),
454        _ => None,
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn every_condition_is_a_dfhresp_row_with_its_number() {
464        for &c in Condition::ALL {
465            assert_eq!(Some(c.resp()), crate::cics_tables::resp(c.name()), "{c:?}");
466            assert_eq!(Condition::from_name(c.name()), Some(c));
467        }
468        let rows: Vec<&str> = crate::cics_tables::resp_table().iter().map(|r| r.condition).collect();
469        let names: Vec<&str> = Condition::ALL.iter().map(|c| c.name()).collect();
470        assert_eq!(names, rows, "one variant per DFHRESP row, in its order");
471    }
472
473    #[test]
474    fn every_default_abend_is_for_a_dfhresp_condition_and_listed_once() {
475        for (i, &(c, abend)) in DEFAULT_ABENDS.iter().enumerate() {
476            assert!(crate::cics_tables::resp(c.name()).is_some(), "{c:?}");
477            assert!(DEFAULT_ABENDS[..i].iter().all(|&(d, _)| d != c), "{c:?} is listed twice");
478            assert_eq!(c.default_abend(), abend);
479        }
480        assert_eq!(DEFAULT_ABENDS.len(), 37);
481        let codes = [Condition::QIDERR, Condition::ROLLEDBACK, Condition::FILENOTFOUND, Condition::INVREQ, Condition::INVMPSZ].map(Condition::default_abend);
482        assert_eq!(codes, ["AEYH", "AEXJ", "AEIL", "AEIP", "AEIP"]);
483    }
484
485    #[test]
486    fn dsiderr_is_filenotfound_by_its_older_name() {
487        let c = Condition::from_name("dsiderr").unwrap();
488        assert_eq!((c, c.name(), c.resp()), (Condition::FILENOTFOUND, "FILENOTFOUND", 12));
489        assert_eq!(Condition::from_name("NOSUCH"), None);
490    }
491
492    #[test]
493    fn parse_file_ksds() {
494        let (name, def) = parse_file("MYFILE=/tmp/data,KSDS,key=0:8,len=80").unwrap();
495        assert_eq!(name, "MYFILE");
496        assert_eq!(def.record_len, 80);
497        assert!(matches!(&def.data_set, DataSet::Ksds { key } if key.offset == 0 && key.len == 8));
498    }
499
500    #[test]
501    fn parse_file_rrds_text() {
502        let (name, def) = parse_file("myfile=/tmp/data,rrds,len=120,text").unwrap();
503        assert_eq!(name, "MYFILE");
504        assert_eq!(def.record_len, 120);
505        assert!(matches!(def.data_set, DataSet::Rrds));
506        assert_eq!(def.dd.format, Some(Format::Text));
507    }
508
509    #[test]
510    fn parse_file_errors() {
511        assert!(parse_file("F=/tmp/d,KSDS,len=80").is_err());
512        assert!(parse_file("F=/tmp/d,RRDS,len=abc").is_err());
513        assert!(parse_file("F=/tmp/d,FOO,len=80").is_err());
514    }
515
516    #[test]
517    fn ts_queue() {
518        let mut task = Task::default();
519        task.writeq_ts("Q1", None, b"one").unwrap();
520        task.writeq_ts("Q1", None, b"two").unwrap();
521        task.writeq_ts("Q1", None, b"three").unwrap();
522
523        let (data, count) = task.readq_ts("Q1", Some(2)).unwrap();
524        assert_eq!(data, b"two");
525        assert_eq!(count, 3);
526
527        let (data, count) = task.readq_ts("Q1", None).unwrap();
528        assert_eq!(data, b"three");
529        assert_eq!(count, 3);
530
531        assert_eq!(task.readq_ts("Q1", None), Err(Condition::ITEMERR));
532
533        task.writeq_ts("Q1", Some(1), b"ONE").unwrap();
534        let (data, _) = task.readq_ts("Q1", Some(1)).unwrap();
535        assert_eq!(data, b"ONE");
536
537        assert_eq!(task.writeq_ts("Q1", Some(9), b"x"), Err(Condition::ITEMERR));
538        assert_eq!(task.readq_ts("NOPE", None), Err(Condition::QIDERR));
539
540        task.deleteq_ts("Q1").unwrap();
541        assert_eq!(task.readq_ts("Q1", None), Err(Condition::QIDERR));
542    }
543
544    #[test]
545    fn td_queue() {
546        let mut task = Task::default();
547        task.writeq_td("Q1", b"first");
548        task.writeq_td("Q1", b"second");
549        task.writeq_td("Q1", b"third");
550
551        assert_eq!(task.readq_td("Q1").unwrap(), b"first");
552        assert_eq!(task.readq_td("Q1").unwrap(), b"second");
553        assert_eq!(task.readq_td("Q1").unwrap(), b"third");
554        assert_eq!(task.readq_td("Q1"), Err(Condition::QZERO));
555    }
556
557    #[test]
558    fn abstime_and_unix() {
559        assert_eq!(abstime(0, 0), 2_208_988_800_000);
560        assert_eq!(abstime(1, 50), 2_208_988_801_500);
561        assert_eq!(unix_seconds(abstime(1, 50)), 1);
562        assert_eq!(unix_seconds(abstime(0, 0)), 0);
563    }
564
565    #[test]
566    fn eib_date_and_time() {
567        // 2026-09-27 13:05:09 UTC
568        let at = abstime(1_790_514_309, 0);
569        assert_eq!(eib_date(at), 126_270);
570        assert_eq!(eib_time(at), 130_509);
571
572        // 1999-12-31 00:00:00 UTC
573        let at = abstime(946_598_400, 0);
574        assert_eq!(eib_date(at), 99_365);
575        assert_eq!(eib_time(at), 0);
576    }
577
578    #[test]
579    fn format_time_options() {
580        let at = abstime(1_790_514_309, 25);
581
582        assert_eq!(format_time(at, "YYYYMMDD", Some('/'), None), Some(FormatValue::Text("2026/09/27".into())));
583        assert_eq!(format_time(at, "MMDDYY", None, None), Some(FormatValue::Text("092726".into())));
584        assert_eq!(format_time(at, "YYDDD", Some('-'), None), Some(FormatValue::Text("26-270".into())));
585        assert_eq!(format_time(at, "TIME", None, Some(':')), Some(FormatValue::Text("13:05:09".into())));
586        assert_eq!(format_time(at, "DAYOFWEEK", None, None), Some(FormatValue::Number(0)));
587        assert_eq!(format_time(at, "DAYOFMONTH", None, None), Some(FormatValue::Number(27)));
588        assert_eq!(format_time(at, "MONTHOFYEAR", None, None), Some(FormatValue::Number(9)));
589        assert_eq!(format_time(at, "YEAR", None, None), Some(FormatValue::Number(2026)));
590        assert_eq!(format_time(at, "MILLISECONDS", None, None), Some(FormatValue::Number(250)));
591        assert_eq!(format_time(at, "DAYCOUNT", None, None), Some(FormatValue::Number(46_290)));
592        assert_eq!(format_time(at, "STRINGFORMAT", None, None), None);
593    }
594}