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