Skip to main content

dicom_toolkit_data/
value.rs

1//! DICOM value types — the `Value` enum and its supporting types.
2//!
3//! Ports DCMTK's per-VR element classes into a single Rust enum, with dedicated
4//! structs for the richer DICOM scalar types (dates, times, person names).
5
6use crate::dataset::DataSet;
7use dicom_toolkit_core::error::{DcmError, DcmResult};
8use dicom_toolkit_dict::Tag;
9use std::fmt;
10
11// ── DicomDate ──────────────────────────────────────────────────────────────────
12
13/// A DICOM DA (Date) value: YYYYMMDD, with optional month and day.
14///
15/// Partial dates are represented by leaving `month` and/or `day` as `0`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
17pub struct DicomDate {
18    pub year: u16,
19    /// 0 when not specified (partial date contains year only).
20    pub month: u8,
21    /// 0 when not specified (partial date contains year+month only).
22    pub day: u8,
23}
24
25impl DicomDate {
26    /// Parse a DICOM DA string: YYYYMMDD, YYYYMM, or YYYY.
27    pub fn parse(s: &str) -> DcmResult<Self> {
28        let s = s.trim();
29        match s.len() {
30            4 => {
31                let year = parse_u16_str(&s[0..4])?;
32                Ok(Self {
33                    year,
34                    month: 0,
35                    day: 0,
36                })
37            }
38            6 => {
39                let year = parse_u16_str(&s[0..4])?;
40                let month = parse_u8_str(&s[4..6])?;
41                Ok(Self {
42                    year,
43                    month,
44                    day: 0,
45                })
46            }
47            8 => {
48                let year = parse_u16_str(&s[0..4])?;
49                let month = parse_u8_str(&s[4..6])?;
50                let day = parse_u8_str(&s[6..8])?;
51                Ok(Self { year, month, day })
52            }
53            _ => Err(DcmError::Other(format!("invalid DICOM date: {:?}", s))),
54        }
55    }
56
57    /// Parse a DICOM DA string, also accepting the legacy "YYYY.MM.DD" format.
58    pub fn from_da_str(s: &str) -> DcmResult<Self> {
59        let s = s.trim();
60        if s.len() == 10 && s.as_bytes().get(4) == Some(&b'.') && s.as_bytes().get(7) == Some(&b'.')
61        {
62            let year = parse_u16_str(&s[0..4])?;
63            let month = parse_u8_str(&s[5..7])?;
64            let day = parse_u8_str(&s[8..10])?;
65            return Ok(Self { year, month, day });
66        }
67        Self::parse(s)
68    }
69}
70
71impl fmt::Display for DicomDate {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        if self.month == 0 {
74            write!(f, "{:04}", self.year)
75        } else if self.day == 0 {
76            write!(f, "{:04}{:02}", self.year, self.month)
77        } else {
78            write!(f, "{:04}{:02}{:02}", self.year, self.month, self.day)
79        }
80    }
81}
82
83// ── DicomTime ──────────────────────────────────────────────────────────────────
84
85/// A DICOM TM (Time) value: HHMMSS.FFFFFF, with optional components.
86///
87/// Partial times are allowed: `HH`, `HHMM`, `HHMMSS`, `HHMMSS.F{1-6}`.
88/// Missing components are stored as `0`.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct DicomTime {
91    pub hour: u8,
92    pub minute: u8,
93    pub second: u8,
94    /// Fractional seconds in microseconds (0–999999).
95    pub fraction: u32,
96}
97
98impl DicomTime {
99    /// Parse a DICOM TM string.
100    pub fn parse(s: &str) -> DcmResult<Self> {
101        let s = s.trim();
102        if s.is_empty() {
103            return Err(DcmError::Other("empty DICOM time string".into()));
104        }
105
106        // Find the fractional part if present
107        let (time_part, fraction) = if let Some(dot_pos) = s.find('.') {
108            let frac_str = &s[dot_pos + 1..];
109            // Pad or truncate to 6 digits
110            let mut padded = String::from(frac_str);
111            while padded.len() < 6 {
112                padded.push('0');
113            }
114            let frac = parse_u32_str(&padded[..6])?;
115            (&s[..dot_pos], frac)
116        } else {
117            (s, 0u32)
118        };
119
120        match time_part.len() {
121            2 => {
122                let hour = parse_u8_str(&time_part[0..2])?;
123                if hour > 23 {
124                    return Err(DcmError::Other(format!(
125                        "invalid hour in DICOM time: {hour}"
126                    )));
127                }
128                Ok(Self {
129                    hour,
130                    minute: 0,
131                    second: 0,
132                    fraction: 0,
133                })
134            }
135            4 => {
136                let hour = parse_u8_str(&time_part[0..2])?;
137                let minute = parse_u8_str(&time_part[2..4])?;
138                if hour > 23 {
139                    return Err(DcmError::Other(format!(
140                        "invalid hour in DICOM time: {hour}"
141                    )));
142                }
143                if minute > 59 {
144                    return Err(DcmError::Other(format!(
145                        "invalid minute in DICOM time: {minute}"
146                    )));
147                }
148                Ok(Self {
149                    hour,
150                    minute,
151                    second: 0,
152                    fraction: 0,
153                })
154            }
155            6 => {
156                let hour = parse_u8_str(&time_part[0..2])?;
157                let minute = parse_u8_str(&time_part[2..4])?;
158                let second = parse_u8_str(&time_part[4..6])?;
159                if hour > 23 {
160                    return Err(DcmError::Other(format!(
161                        "invalid hour in DICOM time: {hour}"
162                    )));
163                }
164                if minute > 59 {
165                    return Err(DcmError::Other(format!(
166                        "invalid minute in DICOM time: {minute}"
167                    )));
168                }
169                if second > 59 {
170                    return Err(DcmError::Other(format!(
171                        "invalid second in DICOM time: {second}"
172                    )));
173                }
174                Ok(Self {
175                    hour,
176                    minute,
177                    second,
178                    fraction,
179                })
180            }
181            _ => Err(DcmError::Other(format!("invalid DICOM time: {:?}", s))),
182        }
183    }
184}
185
186impl fmt::Display for DicomTime {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        write!(f, "{:02}{:02}{:02}", self.hour, self.minute, self.second)?;
189        if self.fraction > 0 {
190            write!(f, ".{:06}", self.fraction)?;
191        }
192        Ok(())
193    }
194}
195
196// ── DicomDateTime ─────────────────────────────────────────────────────────────
197
198/// A DICOM DT (DateTime) value: YYYYMMDDHHMMSS.FFFFFF+ZZZZ.
199#[derive(Debug, Clone, PartialEq)]
200pub struct DicomDateTime {
201    pub date: DicomDate,
202    pub time: Option<DicomTime>,
203    /// UTC offset in minutes, e.g. +0530 → 330, -0500 → -300.
204    pub offset_minutes: Option<i16>,
205}
206
207impl DicomDateTime {
208    /// Parse a DICOM DT string.
209    pub fn parse(s: &str) -> DcmResult<Self> {
210        let s = s.trim();
211        if s.len() < 4 {
212            return Err(DcmError::Other(format!("invalid DICOM datetime: {:?}", s)));
213        }
214
215        // Separate UTC offset: find trailing +/- that belong to timezone
216        // Timezone is the last +HHMM or -HHMM
217        let (dt_part, offset_minutes) = extract_tz_offset(s)?;
218
219        // Date is always the first 8 chars (YYYYMMDD), but may be shorter
220        let date_len = dt_part.len().min(8);
221        let date_str = &dt_part[..date_len];
222        // Pad date string to at least 4 chars
223        let date = DicomDate::parse(date_str)?;
224
225        let time = if dt_part.len() > 8 {
226            Some(DicomTime::parse(&dt_part[8..])?)
227        } else {
228            None
229        };
230
231        Ok(Self {
232            date,
233            time,
234            offset_minutes,
235        })
236    }
237}
238
239/// Extracts an optional trailing timezone offset (+HHMM or -HHMM) from a DT string.
240fn extract_tz_offset(s: &str) -> DcmResult<(&str, Option<i16>)> {
241    // Look for + or - that is not part of the date/time portion.
242    // The date+time part is at most 21 chars (YYYYMMDDHHMMSS.FFFFFF),
243    // so any sign after position 4 is a potential offset.
244    let bytes = s.as_bytes();
245    for i in (1..s.len()).rev() {
246        if bytes[i] == b'+' || bytes[i] == b'-' {
247            let tz_str = &s[i..];
248            if tz_str.len() == 5 {
249                let sign: i16 = if bytes[i] == b'+' { 1 } else { -1 };
250                let hh = parse_u8_str(&tz_str[1..3])? as i16;
251                let mm = parse_u8_str(&tz_str[3..5])? as i16;
252                return Ok((&s[..i], Some(sign * (hh * 60 + mm))));
253            }
254        }
255    }
256    Ok((s, None))
257}
258
259impl fmt::Display for DicomDateTime {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        write!(f, "{}", self.date)?;
262        if let Some(ref t) = self.time {
263            write!(f, "{}", t)?;
264        }
265        if let Some(offset) = self.offset_minutes {
266            let sign = if offset >= 0 { '+' } else { '-' };
267            let abs = offset.unsigned_abs();
268            write!(f, "{}{:02}{:02}", sign, abs / 60, abs % 60)?;
269        }
270        Ok(())
271    }
272}
273
274// ── PersonName ────────────────────────────────────────────────────────────────
275
276/// A DICOM PN (Person Name) value.
277///
278/// Each name consists of up to three component groups (alphabetic, ideographic,
279/// phonetic) separated by `=`. Within each group, the five name components
280/// (family, given, middle, prefix, suffix) are separated by `^`.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct PersonName {
283    pub alphabetic: String,
284    pub ideographic: String,
285    pub phonetic: String,
286}
287
288impl PersonName {
289    /// Parse a DICOM PN string.
290    pub fn parse(s: &str) -> Self {
291        let mut parts = s.splitn(3, '=');
292        PersonName {
293            alphabetic: parts.next().unwrap_or("").to_string(),
294            ideographic: parts.next().unwrap_or("").to_string(),
295            phonetic: parts.next().unwrap_or("").to_string(),
296        }
297    }
298
299    fn component(group: &str, index: usize) -> &str {
300        group.split('^').nth(index).unwrap_or("")
301    }
302
303    pub fn last_name(&self) -> &str {
304        Self::component(&self.alphabetic, 0)
305    }
306
307    pub fn first_name(&self) -> &str {
308        Self::component(&self.alphabetic, 1)
309    }
310
311    pub fn middle_name(&self) -> &str {
312        Self::component(&self.alphabetic, 2)
313    }
314
315    pub fn prefix(&self) -> &str {
316        Self::component(&self.alphabetic, 3)
317    }
318
319    pub fn suffix(&self) -> &str {
320        Self::component(&self.alphabetic, 4)
321    }
322}
323
324impl fmt::Display for PersonName {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        // Emit only as many groups as are non-empty, trimming trailing empty groups.
327        if !self.phonetic.is_empty() {
328            write!(
329                f,
330                "{}={}={}",
331                self.alphabetic, self.ideographic, self.phonetic
332            )
333        } else if !self.ideographic.is_empty() {
334            write!(f, "{}={}", self.alphabetic, self.ideographic)
335        } else {
336            write!(f, "{}", self.alphabetic)
337        }
338    }
339}
340
341// ── PixelData ─────────────────────────────────────────────────────────────────
342
343/// Pixel data stored either as native (uncompressed) bytes or encapsulated
344/// (compressed) fragments.
345#[derive(Debug, Clone, PartialEq)]
346pub enum PixelData {
347    /// Uncompressed pixel data.
348    Native { bytes: Vec<u8> },
349    /// Encapsulated (compressed) pixel data with optional offset table.
350    Encapsulated {
351        offset_table: Vec<u32>,
352        fragments: Vec<Vec<u8>>,
353    },
354}
355
356// ── Value ─────────────────────────────────────────────────────────────────────
357
358/// The value held by a DICOM data element.
359///
360/// Each variant corresponds to one or more DICOM VRs. Numeric string VRs
361/// (DS, IS) are stored already decoded as `f64`/`i64`.
362#[derive(Debug, Clone, PartialEq)]
363pub enum Value {
364    /// No value (zero-length element).
365    Empty,
366    /// AE, CS, LO, LT, SH, ST, UC, UR, UT — multi-valued via backslash.
367    Strings(Vec<String>),
368    /// PN — person name with up to three component groups.
369    PersonNames(Vec<PersonName>),
370    /// UI — UID string.
371    Uid(String),
372    /// DA — date values.
373    Date(Vec<DicomDate>),
374    /// TM — time values.
375    Time(Vec<DicomTime>),
376    /// DT — datetime values.
377    DateTime(Vec<DicomDateTime>),
378    /// IS — integer string, decoded.
379    Ints(Vec<i64>),
380    /// DS — decimal string, decoded.
381    Decimals(Vec<f64>),
382    /// OB, UN — raw bytes.
383    U8(Vec<u8>),
384    /// US, OW — raw 16-bit words (interpret by VR).
385    U16(Vec<u16>),
386    /// SS — signed 16-bit integers.
387    I16(Vec<i16>),
388    /// UL, OL — 32-bit unsigned integers.
389    U32(Vec<u32>),
390    /// SL — 32-bit signed integers.
391    I32(Vec<i32>),
392    /// UV, OV — 64-bit unsigned integers.
393    U64(Vec<u64>),
394    /// SV — 64-bit signed integers.
395    I64(Vec<i64>),
396    /// FL, OF — 32-bit floats.
397    F32(Vec<f32>),
398    /// FD, OD — 64-bit floats.
399    F64(Vec<f64>),
400    /// AT — attribute tag pairs.
401    Tags(Vec<Tag>),
402    /// SQ — sequence of items (datasets).
403    Sequence(Vec<DataSet>),
404    /// Pixel data — (7FE0,0010).
405    PixelData(PixelData),
406}
407
408impl Value {
409    /// Returns the number of values (VM).
410    pub fn multiplicity(&self) -> usize {
411        match self {
412            Value::Empty => 0,
413            Value::Strings(v) => v.len(),
414            Value::PersonNames(v) => v.len(),
415            Value::Uid(_) => 1,
416            Value::Date(v) => v.len(),
417            Value::Time(v) => v.len(),
418            Value::DateTime(v) => v.len(),
419            Value::Ints(v) => v.len(),
420            Value::Decimals(v) => v.len(),
421            Value::U8(v) => v.len(),
422            Value::U16(v) => v.len(),
423            Value::I16(v) => v.len(),
424            Value::U32(v) => v.len(),
425            Value::I32(v) => v.len(),
426            Value::U64(v) => v.len(),
427            Value::I64(v) => v.len(),
428            Value::F32(v) => v.len(),
429            Value::F64(v) => v.len(),
430            Value::Tags(v) => v.len(),
431            Value::Sequence(v) => v.len(),
432            Value::PixelData(_) => 1,
433        }
434    }
435
436    pub fn is_empty(&self) -> bool {
437        self.multiplicity() == 0
438    }
439
440    /// Returns the first string value, if this is a `Strings` or `Uid` variant.
441    pub fn as_string(&self) -> Option<&str> {
442        match self {
443            Value::Strings(v) => v.first().map(|s| s.as_str()),
444            Value::Uid(s) => Some(s.as_str()),
445            Value::PersonNames(v) => v.first().map(|p| p.alphabetic.as_str()),
446            _ => None,
447        }
448    }
449
450    pub fn as_strings(&self) -> Option<&[String]> {
451        match self {
452            Value::Strings(v) => Some(v.as_slice()),
453            _ => None,
454        }
455    }
456
457    pub fn as_u16(&self) -> Option<u16> {
458        match self {
459            Value::U16(v) => v.first().copied(),
460            _ => None,
461        }
462    }
463
464    pub fn as_u32(&self) -> Option<u32> {
465        match self {
466            Value::U32(v) => v.first().copied(),
467            _ => None,
468        }
469    }
470
471    pub fn as_i32(&self) -> Option<i32> {
472        match self {
473            Value::I32(v) => v.first().copied(),
474            _ => None,
475        }
476    }
477
478    pub fn as_f64(&self) -> Option<f64> {
479        match self {
480            Value::F64(v) => v.first().copied(),
481            Value::Decimals(v) => v.first().copied(),
482            _ => None,
483        }
484    }
485
486    pub fn as_bytes(&self) -> Option<&[u8]> {
487        match self {
488            Value::U8(v) => Some(v.as_slice()),
489            Value::PixelData(PixelData::Native { bytes }) => Some(bytes.as_slice()),
490            _ => None,
491        }
492    }
493
494    /// Returns a human-readable string representation (like dcmdump output).
495    pub fn to_display_string(&self) -> String {
496        match self {
497            Value::Empty => String::new(),
498            Value::Strings(v) => v.join("\\"),
499            Value::PersonNames(v) => v
500                .iter()
501                .map(|p| p.to_string())
502                .collect::<Vec<_>>()
503                .join("\\"),
504            Value::Uid(s) => s.clone(),
505            Value::Date(v) => v
506                .iter()
507                .map(|d| d.to_string())
508                .collect::<Vec<_>>()
509                .join("\\"),
510            Value::Time(v) => v
511                .iter()
512                .map(|t| t.to_string())
513                .collect::<Vec<_>>()
514                .join("\\"),
515            Value::DateTime(v) => v
516                .iter()
517                .map(|dt| dt.to_string())
518                .collect::<Vec<_>>()
519                .join("\\"),
520            Value::Ints(v) => v
521                .iter()
522                .map(|n| n.to_string())
523                .collect::<Vec<_>>()
524                .join("\\"),
525            Value::Decimals(v) => v
526                .iter()
527                .map(|n| format_f64(*n))
528                .collect::<Vec<_>>()
529                .join("\\"),
530            Value::U8(v) => format!("({} bytes)", v.len()),
531            Value::U16(v) => v
532                .iter()
533                .map(|n| n.to_string())
534                .collect::<Vec<_>>()
535                .join("\\"),
536            Value::I16(v) => v
537                .iter()
538                .map(|n| n.to_string())
539                .collect::<Vec<_>>()
540                .join("\\"),
541            Value::U32(v) => v
542                .iter()
543                .map(|n| n.to_string())
544                .collect::<Vec<_>>()
545                .join("\\"),
546            Value::I32(v) => v
547                .iter()
548                .map(|n| n.to_string())
549                .collect::<Vec<_>>()
550                .join("\\"),
551            Value::U64(v) => v
552                .iter()
553                .map(|n| n.to_string())
554                .collect::<Vec<_>>()
555                .join("\\"),
556            Value::I64(v) => v
557                .iter()
558                .map(|n| n.to_string())
559                .collect::<Vec<_>>()
560                .join("\\"),
561            Value::F32(v) => v
562                .iter()
563                .map(|n| format!("{}", n))
564                .collect::<Vec<_>>()
565                .join("\\"),
566            Value::F64(v) => v
567                .iter()
568                .map(|n| format_f64(*n))
569                .collect::<Vec<_>>()
570                .join("\\"),
571            Value::Tags(v) => v
572                .iter()
573                .map(|t| format!("({:04X},{:04X})", t.group, t.element))
574                .collect::<Vec<_>>()
575                .join("\\"),
576            Value::Sequence(v) => format!("(Sequence with {} item(s))", v.len()),
577            Value::PixelData(PixelData::Native { bytes }) => {
578                format!("(PixelData, {} bytes)", bytes.len())
579            }
580            Value::PixelData(PixelData::Encapsulated { fragments, .. }) => {
581                format!("(PixelData, {} fragment(s))", fragments.len())
582            }
583        }
584    }
585
586    /// Approximate encoded byte length (for dcmdump `# length` field).
587    pub(crate) fn encoded_len(&self) -> usize {
588        match self {
589            Value::Empty => 0,
590            Value::Strings(v) => {
591                let total: usize = v.iter().map(|s| s.len()).sum();
592                total + v.len().saturating_sub(1)
593            }
594            Value::PersonNames(v) => {
595                let total: usize = v.iter().map(|p| p.to_string().len()).sum();
596                total + v.len().saturating_sub(1)
597            }
598            Value::Uid(s) => s.len(),
599            Value::Date(v) => v.len() * 8,
600            Value::Time(v) => v.len() * 14,
601            Value::DateTime(v) => v.len() * 26,
602            Value::Ints(v) => {
603                v.iter().map(|n| n.to_string().len()).sum::<usize>() + v.len().saturating_sub(1)
604            }
605            Value::Decimals(v) => {
606                v.iter().map(|n| format_f64(*n).len()).sum::<usize>() + v.len().saturating_sub(1)
607            }
608            Value::U8(v) => v.len(),
609            Value::U16(v) => v.len() * 2,
610            Value::I16(v) => v.len() * 2,
611            Value::U32(v) => v.len() * 4,
612            Value::I32(v) => v.len() * 4,
613            Value::U64(v) => v.len() * 8,
614            Value::I64(v) => v.len() * 8,
615            Value::F32(v) => v.len() * 4,
616            Value::F64(v) => v.len() * 8,
617            Value::Tags(v) => v.len() * 4,
618            Value::Sequence(_) => 0,
619            Value::PixelData(PixelData::Native { bytes }) => bytes.len(),
620            Value::PixelData(PixelData::Encapsulated { fragments, .. }) => {
621                fragments.iter().map(|f| f.len()).sum()
622            }
623        }
624    }
625}
626
627// ── Helpers ───────────────────────────────────────────────────────────────────
628
629fn parse_u8_str(s: &str) -> DcmResult<u8> {
630    s.parse::<u8>()
631        .map_err(|_| DcmError::Other(format!("expected u8, got {:?}", s)))
632}
633
634fn parse_u16_str(s: &str) -> DcmResult<u16> {
635    s.parse::<u16>()
636        .map_err(|_| DcmError::Other(format!("expected u16, got {:?}", s)))
637}
638
639fn parse_u32_str(s: &str) -> DcmResult<u32> {
640    s.parse::<u32>()
641        .map_err(|_| DcmError::Other(format!("expected u32, got {:?}", s)))
642}
643
644/// Format an f64 without trailing zeros but with at least one decimal place.
645fn format_f64(v: f64) -> String {
646    if v.fract() == 0.0 && v.abs() < 1e15 {
647        format!("{:.1}", v)
648    } else {
649        format!("{}", v)
650    }
651}
652
653// ── Tests ─────────────────────────────────────────────────────────────────────
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    // ── DicomDate ───────────────────────────────────────────────────────
660
661    #[test]
662    fn date_full_parse() {
663        let d = DicomDate::parse("20231215").unwrap();
664        assert_eq!(d.year, 2023);
665        assert_eq!(d.month, 12);
666        assert_eq!(d.day, 15);
667    }
668
669    #[test]
670    fn date_year_only() {
671        let d = DicomDate::parse("2023").unwrap();
672        assert_eq!(d.year, 2023);
673        assert_eq!(d.month, 0);
674        assert_eq!(d.day, 0);
675    }
676
677    #[test]
678    fn date_year_month() {
679        let d = DicomDate::parse("202312").unwrap();
680        assert_eq!(d.year, 2023);
681        assert_eq!(d.month, 12);
682        assert_eq!(d.day, 0);
683    }
684
685    #[test]
686    fn date_display_full() {
687        let d = DicomDate {
688            year: 2023,
689            month: 12,
690            day: 15,
691        };
692        assert_eq!(d.to_string(), "20231215");
693    }
694
695    #[test]
696    fn date_display_partial_year() {
697        let d = DicomDate {
698            year: 2023,
699            month: 0,
700            day: 0,
701        };
702        assert_eq!(d.to_string(), "2023");
703    }
704
705    #[test]
706    fn date_display_partial_year_month() {
707        let d = DicomDate {
708            year: 2023,
709            month: 12,
710            day: 0,
711        };
712        assert_eq!(d.to_string(), "202312");
713    }
714
715    #[test]
716    fn date_legacy_format() {
717        let d = DicomDate::from_da_str("2023.12.15").unwrap();
718        assert_eq!(d.year, 2023);
719        assert_eq!(d.month, 12);
720        assert_eq!(d.day, 15);
721    }
722
723    #[test]
724    fn date_invalid() {
725        assert!(DicomDate::parse("20231").is_err());
726        assert!(DicomDate::parse("2023121").is_err());
727        assert!(DicomDate::parse("abcdefgh").is_err());
728    }
729
730    // ── DicomTime ───────────────────────────────────────────────────────
731
732    #[test]
733    fn time_full_parse() {
734        let t = DicomTime::parse("143022.500000").unwrap();
735        assert_eq!(t.hour, 14);
736        assert_eq!(t.minute, 30);
737        assert_eq!(t.second, 22);
738        assert_eq!(t.fraction, 500000);
739    }
740
741    #[test]
742    fn time_partial_hour() {
743        let t = DicomTime::parse("14").unwrap();
744        assert_eq!(t.hour, 14);
745        assert_eq!(t.minute, 0);
746        assert_eq!(t.second, 0);
747        assert_eq!(t.fraction, 0);
748    }
749
750    #[test]
751    fn time_partial_hour_minute() {
752        let t = DicomTime::parse("1430").unwrap();
753        assert_eq!(t.hour, 14);
754        assert_eq!(t.minute, 30);
755        assert_eq!(t.second, 0);
756    }
757
758    #[test]
759    fn time_partial_no_fraction() {
760        let t = DicomTime::parse("143022").unwrap();
761        assert_eq!(t.hour, 14);
762        assert_eq!(t.minute, 30);
763        assert_eq!(t.second, 22);
764        assert_eq!(t.fraction, 0);
765    }
766
767    #[test]
768    fn time_fraction_short() {
769        // Short fraction is zero-padded on the right
770        let t = DicomTime::parse("143022.5").unwrap();
771        assert_eq!(t.fraction, 500000);
772    }
773
774    #[test]
775    fn time_display() {
776        let t = DicomTime {
777            hour: 14,
778            minute: 30,
779            second: 22,
780            fraction: 500000,
781        };
782        assert_eq!(t.to_string(), "143022.500000");
783    }
784
785    #[test]
786    fn time_display_no_fraction() {
787        let t = DicomTime {
788            hour: 14,
789            minute: 30,
790            second: 22,
791            fraction: 0,
792        };
793        assert_eq!(t.to_string(), "143022");
794    }
795
796    // ── DicomDateTime ───────────────────────────────────────────────────
797
798    #[test]
799    fn datetime_full_parse() {
800        let dt = DicomDateTime::parse("20231215143022.000000+0530").unwrap();
801        assert_eq!(dt.date.year, 2023);
802        assert_eq!(dt.date.month, 12);
803        assert_eq!(dt.date.day, 15);
804        let t = dt.time.unwrap();
805        assert_eq!(t.hour, 14);
806        assert_eq!(t.minute, 30);
807        assert_eq!(t.second, 22);
808        assert_eq!(dt.offset_minutes, Some(330)); // +05:30 = 5*60+30 = 330
809    }
810
811    #[test]
812    fn datetime_negative_offset() {
813        let dt = DicomDateTime::parse("20231215143022.000000-0500").unwrap();
814        assert_eq!(dt.offset_minutes, Some(-300));
815    }
816
817    #[test]
818    fn datetime_no_time() {
819        let dt = DicomDateTime::parse("20231215").unwrap();
820        assert_eq!(dt.date.year, 2023);
821        assert!(dt.time.is_none());
822        assert!(dt.offset_minutes.is_none());
823    }
824
825    #[test]
826    fn datetime_display_roundtrip() {
827        // Use non-zero fraction so Display includes it, enabling exact round-trip.
828        let s = "20231215143022.500000+0530";
829        let dt = DicomDateTime::parse(s).unwrap();
830        assert_eq!(dt.to_string(), s);
831    }
832
833    #[test]
834    fn datetime_display_roundtrip_no_fraction() {
835        // Without a fractional second the display omits the decimal.
836        let s = "20231215143022+0530";
837        let dt = DicomDateTime::parse(s).unwrap();
838        assert_eq!(dt.to_string(), s);
839    }
840
841    // ── PersonName ──────────────────────────────────────────────────────
842
843    #[test]
844    fn pn_simple() {
845        let pn = PersonName::parse("Eichelberg^Marco^^Dr.");
846        assert_eq!(pn.last_name(), "Eichelberg");
847        assert_eq!(pn.first_name(), "Marco");
848        assert_eq!(pn.middle_name(), "");
849        assert_eq!(pn.prefix(), "Dr.");
850        assert_eq!(pn.suffix(), "");
851    }
852
853    #[test]
854    fn pn_multi_component() {
855        let pn = PersonName::parse("Smith^John=\u{5C71}\u{7530}^\u{592A}\u{90CE}=\u{3084}\u{307E}\u{3060}^\u{305F}\u{308D}\u{3046}");
856        assert_eq!(pn.last_name(), "Smith");
857        assert_eq!(pn.first_name(), "John");
858        assert!(!pn.ideographic.is_empty());
859        assert!(!pn.phonetic.is_empty());
860    }
861
862    #[test]
863    fn pn_display_single_group() {
864        let pn = PersonName::parse("Smith^John");
865        assert_eq!(pn.to_string(), "Smith^John");
866    }
867
868    #[test]
869    fn pn_display_two_groups() {
870        let pn = PersonName::parse("Smith^John=SJ");
871        assert_eq!(pn.to_string(), "Smith^John=SJ");
872    }
873
874    // ── Value ───────────────────────────────────────────────────────────
875
876    #[test]
877    fn value_multiplicity() {
878        assert_eq!(Value::Empty.multiplicity(), 0);
879        assert_eq!(
880            Value::Strings(vec!["a".into(), "b".into()]).multiplicity(),
881            2
882        );
883        assert_eq!(Value::U16(vec![1, 2, 3]).multiplicity(), 3);
884        assert_eq!(Value::Uid("1.2.3".into()).multiplicity(), 1);
885        assert_eq!(Value::Sequence(vec![]).multiplicity(), 0);
886    }
887
888    #[test]
889    fn value_is_empty() {
890        assert!(Value::Empty.is_empty());
891        assert!(Value::Strings(vec![]).is_empty());
892        assert!(!Value::Strings(vec!["x".into()]).is_empty());
893    }
894
895    #[test]
896    fn value_as_string() {
897        let v = Value::Strings(vec!["hello".into(), "world".into()]);
898        assert_eq!(v.as_string(), Some("hello"));
899        assert_eq!(v.as_strings().unwrap().len(), 2);
900    }
901
902    #[test]
903    fn value_as_uid() {
904        let v = Value::Uid("1.2.840.10008.1.1".into());
905        assert_eq!(v.as_string(), Some("1.2.840.10008.1.1"));
906    }
907
908    #[test]
909    fn value_as_numeric() {
910        let v = Value::U16(vec![512]);
911        assert_eq!(v.as_u16(), Some(512));
912
913        let v = Value::U32(vec![65536]);
914        assert_eq!(v.as_u32(), Some(65536));
915
916        let v = Value::I32(vec![-1]);
917        assert_eq!(v.as_i32(), Some(-1));
918
919        let v = Value::F64(vec![2.78]);
920        assert_eq!(v.as_f64(), Some(2.78));
921    }
922
923    #[test]
924    fn value_to_display_string_strings() {
925        let v = Value::Strings(vec!["foo".into(), "bar".into()]);
926        assert_eq!(v.to_display_string(), "foo\\bar");
927    }
928
929    #[test]
930    fn value_to_display_string_u16() {
931        let v = Value::U16(vec![512, 256]);
932        assert_eq!(v.to_display_string(), "512\\256");
933    }
934
935    #[test]
936    fn value_to_display_string_sequence() {
937        let v = Value::Sequence(vec![]);
938        assert_eq!(v.to_display_string(), "(Sequence with 0 item(s))");
939    }
940
941    #[test]
942    fn value_as_bytes() {
943        let v = Value::U8(vec![1, 2, 3]);
944        assert_eq!(v.as_bytes(), Some(&[1u8, 2, 3][..]));
945    }
946}