Skip to main content

oximedia_edl/
parser.rs

1//! EDL parser implementation.
2//!
3//! This module provides a parser for CMX 3600 EDL files and related formats,
4//! using the nom parser combinator library.
5
6use crate::audio::AudioChannel;
7use crate::error::{EdlError, EdlResult};
8use crate::event::{EditType, EdlEvent, TrackType};
9use crate::motion::MotionEffect;
10use crate::reel::ReelId;
11use crate::timecode::{EdlFrameRate, EdlTimecode};
12use crate::{Edl, EdlFormat};
13use nom::{
14    branch::alt,
15    bytes::complete::{tag, take_while1},
16    character::complete::{space0, space1},
17    combinator::{map_res, opt, value},
18    sequence::terminated,
19    IResult, Parser,
20};
21
22/// Parse a complete EDL from a string.
23///
24/// # Errors
25///
26/// Returns an error if the EDL cannot be parsed.
27pub fn parse_edl(input: &str) -> EdlResult<Edl> {
28    let mut parser = EdlParser::new();
29    parser.parse(input)
30}
31
32/// EDL parser with state management.
33#[derive(Debug)]
34pub struct EdlParser {
35    /// Parsing mode (strict or lenient).
36    pub strict_mode: bool,
37    /// Current line number (for error reporting).
38    current_line: usize,
39}
40
41impl EdlParser {
42    /// Create a new EDL parser.
43    #[must_use]
44    pub const fn new() -> Self {
45        Self {
46            strict_mode: false,
47            current_line: 0,
48        }
49    }
50
51    /// Create a new EDL parser in strict mode.
52    #[must_use]
53    pub const fn strict() -> Self {
54        Self {
55            strict_mode: true,
56            current_line: 0,
57        }
58    }
59
60    /// Enable or disable strict mode.
61    pub fn set_strict_mode(&mut self, strict: bool) {
62        self.strict_mode = strict;
63    }
64
65    /// Parse an EDL from a string.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if the EDL cannot be parsed.
70    #[allow(clippy::too_many_lines)]
71    pub fn parse(&mut self, input: &str) -> EdlResult<Edl> {
72        let mut edl = Edl::new(EdlFormat::Cmx3600);
73        let mut current_event: Option<EdlEvent> = None;
74
75        for (line_num, line) in input.lines().enumerate() {
76            self.current_line = line_num + 1;
77            let trimmed = line.trim();
78
79            // Skip empty lines
80            if trimmed.is_empty() {
81                continue;
82            }
83
84            // Parse comment lines
85            if trimmed.starts_with('*') {
86                if let Some(comment) = Self::parse_comment_line(trimmed) {
87                    // Check for special comments
88                    if comment.starts_with("FROM CLIP NAME:") {
89                        if let Some(event) = &mut current_event {
90                            let name = comment.trim_start_matches("FROM CLIP NAME:").trim();
91                            event.set_clip_name(name.to_string());
92                        }
93                    } else if comment.starts_with("TO CLIP NAME:") {
94                        if let Some(event) = &mut current_event {
95                            let name = comment.trim_start_matches("TO CLIP NAME:").trim();
96                            event.add_comment(format!("TO CLIP NAME: {name}"));
97                        }
98                    } else if comment.starts_with("M2") {
99                        // Motion effect comment
100                        if let Some(event) = &mut current_event {
101                            if let Ok(effect) = MotionEffect::from_m2_comment(&comment) {
102                                event.set_motion_effect(effect);
103                            }
104                        }
105                    } else if let Some(event) = &mut current_event {
106                        event.add_comment(comment);
107                    }
108                }
109                continue;
110            }
111
112            // Parse header lines
113            if trimmed.starts_with("TITLE:") {
114                let title = trimmed.trim_start_matches("TITLE:").trim();
115                edl.set_title(title.to_string());
116                continue;
117            }
118
119            if trimmed.starts_with("FCM:") {
120                let fcm = trimmed.trim_start_matches("FCM:").trim();
121                let fcm_upper = fcm.to_uppercase();
122                let frame_rate = if fcm_upper.contains("NON") {
123                    EdlFrameRate::Fps2997NDF
124                } else if fcm_upper.contains("DROP") {
125                    EdlFrameRate::Fps2997DF
126                } else {
127                    EdlFrameRate::Fps2997NDF
128                };
129                edl.set_frame_rate(frame_rate);
130                continue;
131            }
132
133            // Parse event lines
134            if let Ok(event) = self.parse_event_line(trimmed, edl.frame_rate) {
135                // Save previous event if any
136                if let Some(prev_event) = current_event.take() {
137                    edl.add_event(prev_event)
138                        .map_err(|e| EdlError::parse(self.current_line, format!("{e}")))?;
139                }
140                current_event = Some(event);
141            }
142        }
143
144        // Add the last event
145        if let Some(event) = current_event {
146            edl.add_event(event)
147                .map_err(|e| EdlError::parse(self.current_line, format!("{e}")))?;
148        }
149
150        Ok(edl)
151    }
152
153    /// Parse a comment line (starts with *).
154    fn parse_comment_line(line: &str) -> Option<String> {
155        line.strip_prefix('*').map(|s| s.trim().to_string())
156    }
157
158    /// Parse an event line.
159    ///
160    /// Format: EVENT_NUM REEL TRACK EDIT_TYPE [DURATION] SRC_IN SRC_OUT REC_IN REC_OUT
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if the event line is malformed.
165    #[allow(clippy::too_many_lines)]
166    fn parse_event_line(&self, line: &str, frame_rate: EdlFrameRate) -> EdlResult<EdlEvent> {
167        let result = Self::event_line_parser(line, frame_rate);
168
169        match result {
170            Ok((_, event)) => Ok(event),
171            Err(e) => Err(EdlError::parse(
172                self.current_line,
173                format!("Failed to parse event line: {e}"),
174            )),
175        }
176    }
177
178    /// Nom parser for event lines.
179    fn event_line_parser(input: &str, frame_rate: EdlFrameRate) -> IResult<&str, EdlEvent> {
180        let (input, _) = space0.parse(input)?;
181
182        // Parse event number (3 digits, zero-padded)
183        let mut parse_num = map_res(take_while1(|c: char| c.is_ascii_digit()), |s: &str| {
184            s.parse::<u32>()
185        });
186        let (input, event_num) = parse_num.parse(input)?;
187
188        let (input, _) = space1.parse(input)?;
189
190        // Parse reel name
191        let (input, reel) = take_while1(|c: char| !c.is_whitespace()).parse(input)?;
192        let reel_id = ReelId::new(reel).map_err(|_| {
193            nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Tag))
194        })?;
195
196        let (input, _) = space1.parse(input)?;
197
198        // Parse track type
199        let (input, track) = Self::track_type_parser(input)?;
200
201        let (input, _) = space1.parse(input)?;
202
203        // Parse edit type
204        let (input, edit_type) = Self::edit_type_parser(input)?;
205
206        let (input, _) = space0.parse(input)?;
207
208        // Parse optional transition duration
209        let parse_duration = map_res(take_while1(|c: char| c.is_ascii_digit()), |s: &str| {
210            s.parse::<u32>()
211        });
212        let mut opt_duration = opt(terminated(parse_duration, space1));
213        let (input, transition_duration) = opt_duration.parse(input)?;
214
215        // Consume any remaining spaces before timecodes
216        let (input, _) = space0.parse(input)?;
217
218        // Parse timecodes
219        let (input, source_in) = Self::timecode_parser(input, frame_rate)?;
220        let (input, _) = space1.parse(input)?;
221        let (input, source_out) = Self::timecode_parser(input, frame_rate)?;
222        let (input, _) = space1.parse(input)?;
223        let (input, record_in) = Self::timecode_parser(input, frame_rate)?;
224        let (input, _) = space1.parse(input)?;
225        let (input, record_out) = Self::timecode_parser(input, frame_rate)?;
226
227        let mut event = EdlEvent::new(
228            event_num,
229            reel_id.to_string(),
230            track,
231            edit_type,
232            source_in,
233            source_out,
234            record_in,
235            record_out,
236        );
237
238        if let Some(duration) = transition_duration {
239            event.set_transition_duration(duration);
240        }
241
242        Ok((input, event))
243    }
244
245    /// Parse track type.
246    fn track_type_parser(input: &str) -> IResult<&str, TrackType> {
247        alt((
248            value(TrackType::AudioPairWithVideo, tag("AA/V")),
249            value(TrackType::AudioWithVideo, tag("A/V")),
250            value(TrackType::AudioPair, tag("AA")),
251            value(TrackType::Audio(AudioChannel::A4), tag("A4")),
252            value(TrackType::Audio(AudioChannel::A3), tag("A3")),
253            value(TrackType::Audio(AudioChannel::A2), tag("A2")),
254            value(TrackType::Audio(AudioChannel::A1), tag("A")),
255            value(TrackType::Video, tag("V")),
256        ))
257        .parse(input)
258    }
259
260    /// Parse edit type.
261    fn edit_type_parser(input: &str) -> IResult<&str, EditType> {
262        alt((
263            value(EditType::Cut, tag("C")),
264            value(EditType::Dissolve, tag("D")),
265            value(EditType::Wipe, tag("W")),
266            value(EditType::Key, tag("K")),
267        ))
268        .parse(input)
269    }
270
271    /// Parse timecode (HH:MM:SS:FF or HH:MM:SS;FF).
272    fn timecode_parser(input: &str, frame_rate: EdlFrameRate) -> IResult<&str, EdlTimecode> {
273        let (input, tc_str) =
274            take_while1(|c: char| c.is_ascii_digit() || c == ':' || c == ';').parse(input)?;
275
276        let tc = EdlTimecode::parse(tc_str, frame_rate).map_err(|_| {
277            nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Tag))
278        })?;
279
280        Ok((input, tc))
281    }
282}
283
284impl Default for EdlParser {
285    fn default() -> Self {
286        Self::new()
287    }
288}
289
290// ─── Lazy parsing types ────────────────────────────────────────────────────
291
292/// The eagerly-parsed header fields of an EDL event line.
293///
294/// These fields are cheap to compute — they only require scanning the first
295/// whitespace-delimited token group on the event line.
296#[derive(Debug, Clone)]
297pub struct EventHeader {
298    /// Event number.
299    pub number: u32,
300    /// Reel name.
301    pub reel: String,
302    /// Raw track-type string (e.g. `"V"`, `"A"`, `"AA/V"`).
303    pub track_type_raw: String,
304    /// Raw edit-type string (e.g. `"C"`, `"D"`).
305    pub edit_type_raw: String,
306}
307
308/// The lazily-parsed detail fields of an EDL event block.
309///
310/// These fields are only parsed when first accessed via
311/// [`LazyEvent::detail`].
312#[derive(Debug, Clone)]
313pub struct EventDetail {
314    /// Optional transition duration in frames.
315    pub transition_duration: Option<u32>,
316    /// Source in timecode string.
317    pub source_in_raw: String,
318    /// Source out timecode string.
319    pub source_out_raw: String,
320    /// Record in timecode string.
321    pub record_in_raw: String,
322    /// Record out timecode string.
323    pub record_out_raw: String,
324    /// Comment lines associated with this event (raw text after `*`).
325    pub comments: Vec<String>,
326}
327
328/// An EDL event whose detail fields are resolved lazily on first access.
329///
330/// The header (event number, reel, track/edit type) is parsed eagerly.
331/// The detail (timecodes, transition duration, comments) is stored as a raw
332/// string and only parsed on the first call to [`LazyEvent::detail`].
333/// Subsequent calls return the cached value — the detail parser is never
334/// invoked more than once per event.
335pub struct LazyEvent {
336    /// Eagerly parsed header fields.
337    pub header: EventHeader,
338    /// Raw unparsed rest of the event block (event line + following comment lines).
339    raw_detail: String,
340    /// Lazily resolved detail; `None` until first access.
341    detail: std::cell::RefCell<Option<EventDetail>>,
342    /// Frame rate needed by the detail parser.
343    frame_rate: EdlFrameRate,
344}
345
346impl std::fmt::Debug for LazyEvent {
347    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348        f.debug_struct("LazyEvent")
349            .field("header", &self.header)
350            .field("raw_detail", &self.raw_detail)
351            .field(
352                "detail",
353                if self.detail.borrow().is_some() {
354                    &"Some(<resolved>)"
355                } else {
356                    &"None"
357                },
358            )
359            .finish()
360    }
361}
362
363impl LazyEvent {
364    /// Access the event detail, parsing it on the first call and caching the result.
365    ///
366    /// # Errors
367    ///
368    /// Returns an error if the raw detail cannot be parsed.
369    pub fn detail(&self) -> EdlResult<std::cell::Ref<'_, EventDetail>> {
370        // If not yet resolved, parse now and store.
371        if self.detail.borrow().is_none() {
372            let parsed = Self::parse_detail(&self.raw_detail, self.frame_rate)?;
373            *self.detail.borrow_mut() = Some(parsed);
374        }
375        // SAFETY: we just guaranteed the RefCell contains Some(…).
376        Ok(std::cell::Ref::map(self.detail.borrow(), |opt| {
377            opt.as_ref().expect("detail was just populated")
378        }))
379    }
380
381    /// Parse the raw detail string into an [`EventDetail`].
382    fn parse_detail(raw: &str, frame_rate: EdlFrameRate) -> EdlResult<EventDetail> {
383        let mut comments = Vec::new();
384        let mut event_line: Option<&str> = None;
385
386        for line in raw.lines() {
387            let trimmed = line.trim();
388            if trimmed.is_empty() {
389                continue;
390            }
391            if trimmed.starts_with('*') {
392                if let Some(c) = trimmed.strip_prefix('*') {
393                    comments.push(c.trim().to_string());
394                }
395            } else if event_line.is_none() {
396                event_line = Some(trimmed);
397            }
398        }
399
400        let ev_line = event_line.ok_or_else(|| EdlError::parse(0, "no event line in detail"))?;
401
402        // We only need the timecode portion of the line.  The format after the
403        // edit-type token is:  [duration] SRC_IN SRC_OUT REC_IN REC_OUT
404        // Skip the first 3 tokens (number, reel, track) and the edit-type token.
405        let tokens: Vec<&str> = ev_line.split_whitespace().collect();
406        // tokens[0] = number, [1] = reel, [2] = track, [3] = edit_type
407        // remaining tokens start at index 4
408        if tokens.len() < 8 {
409            return Err(EdlError::parse(0, "insufficient tokens on event line"));
410        }
411
412        let mut idx = 4usize;
413
414        // Optional transition duration: present when the token at `idx` is all-digits
415        // and the next tokens are timecodes.
416        let transition_duration = if tokens.get(idx).map_or(false, |t| {
417            t.chars().all(|c| c.is_ascii_digit())
418                && t.len() <= 5
419                && !t.contains(':')
420                && !t.contains(';')
421        }) && tokens.len() >= 9
422        {
423            let dur = tokens[idx]
424                .parse::<u32>()
425                .map_err(|_| EdlError::parse(0, "invalid transition duration"))?;
426            idx += 1;
427            Some(dur)
428        } else {
429            None
430        };
431
432        // We need exactly 4 timecode tokens.
433        if tokens.len() < idx + 4 {
434            return Err(EdlError::parse(0, "missing timecode tokens"));
435        }
436
437        // Validate they look like timecodes (contain ':' or ';').
438        for tc_tok in &tokens[idx..idx + 4] {
439            if !tc_tok.contains(':') && !tc_tok.contains(';') {
440                return Err(EdlError::parse(
441                    0,
442                    format!("token does not look like a timecode: {tc_tok}"),
443                ));
444            }
445        }
446
447        // Verify the frame rate is understood (parse one timecode as a check).
448        EdlTimecode::parse(tokens[idx], frame_rate)
449            .map_err(|e| EdlError::parse(0, format!("invalid source_in timecode: {e}")))?;
450
451        Ok(EventDetail {
452            transition_duration,
453            source_in_raw: tokens[idx].to_string(),
454            source_out_raw: tokens[idx + 1].to_string(),
455            record_in_raw: tokens[idx + 2].to_string(),
456            record_out_raw: tokens[idx + 3].to_string(),
457            comments,
458        })
459    }
460}
461
462/// Parse an EDL in lazy mode, returning a list of [`LazyEvent`]s.
463///
464/// Only the event header (number, reel, track, edit type) is parsed during
465/// this call.  Detail fields (timecodes, transition duration, comments) are
466/// deferred until [`LazyEvent::detail`] is called.
467///
468/// # Errors
469///
470/// Returns an error if the input cannot be scanned for headers.
471pub fn parse_lazy(input: &str, frame_rate: EdlFrameRate) -> EdlResult<Vec<LazyEvent>> {
472    let mut events: Vec<LazyEvent> = Vec::new();
473    let mut current_header: Option<EventHeader> = None;
474    let mut current_raw_lines: Vec<&str> = Vec::new();
475
476    for line in input.lines() {
477        let trimmed = line.trim();
478
479        if trimmed.is_empty() {
480            continue;
481        }
482
483        // TITLE / FCM header lines — skip silently
484        if trimmed.starts_with("TITLE:") || trimmed.starts_with("FCM:") {
485            continue;
486        }
487
488        // Comment line: belongs to the current event block
489        if trimmed.starts_with('*') {
490            current_raw_lines.push(line);
491            continue;
492        }
493
494        // Check if this line starts with an event number (digit-first)
495        if trimmed.chars().next().map_or(false, |c| c.is_ascii_digit()) {
496            // Flush the previous event (if any)
497            if let Some(header) = current_header.take() {
498                let raw_detail = current_raw_lines.join("\n");
499                current_raw_lines.clear();
500                events.push(LazyEvent {
501                    header,
502                    raw_detail,
503                    detail: std::cell::RefCell::new(None),
504                    frame_rate,
505                });
506            }
507
508            // Parse the header eagerly (number + reel + track + edit_type only)
509            let tokens: Vec<&str> = trimmed.split_whitespace().collect();
510            if tokens.len() >= 4 {
511                let number = tokens[0]
512                    .parse::<u32>()
513                    .map_err(|_| EdlError::parse(0, "invalid event number"))?;
514                current_header = Some(EventHeader {
515                    number,
516                    reel: tokens[1].to_string(),
517                    track_type_raw: tokens[2].to_string(),
518                    edit_type_raw: tokens[3].to_string(),
519                });
520                current_raw_lines.push(line);
521            }
522        }
523    }
524
525    // Flush the last event
526    if let Some(header) = current_header {
527        let raw_detail = current_raw_lines.join("\n");
528        events.push(LazyEvent {
529            header,
530            raw_detail,
531            detail: std::cell::RefCell::new(None),
532            frame_rate,
533        });
534    }
535
536    Ok(events)
537}
538
539// ─── Frame rate parsing helper ─────────────────────────────────────────────
540
541/// Parse frame rate from FCM line.
542#[allow(dead_code)]
543fn parse_fcm(input: &str) -> EdlResult<EdlFrameRate> {
544    let upper = input.to_uppercase();
545    // Check for "NON" before checking for "DROP" since "NON DROP FRAME" contains "DROP"
546    if upper.contains("NON") {
547        Ok(EdlFrameRate::Fps2997NDF)
548    } else if upper.contains("DROP") {
549        Ok(EdlFrameRate::Fps2997DF)
550    } else {
551        Ok(EdlFrameRate::Fps2997NDF)
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn test_parse_simple_edl() {
561        let edl_text = r#"TITLE: Test EDL
562FCM: DROP FRAME
563
564001  AX       V     C        01:00:00:00 01:00:05:00 01:00:00:00 01:00:05:00
565* FROM CLIP NAME: SHOT_001.MOV
566
567002  AX       V     D    030 01:00:05:00 01:00:10:00 01:00:05:00 01:00:10:00
568* FROM CLIP NAME: SHOT_002.MOV
569"#;
570
571        let mut parser = EdlParser::new();
572        let edl = parser.parse(edl_text).expect("failed to parse");
573
574        assert_eq!(edl.title, Some("Test EDL".to_string()));
575        assert_eq!(edl.events.len(), 2);
576        assert_eq!(edl.events[0].number, 1);
577        assert_eq!(edl.events[0].edit_type, EditType::Cut);
578        assert_eq!(edl.events[1].number, 2);
579        assert_eq!(edl.events[1].edit_type, EditType::Dissolve);
580        assert_eq!(edl.events[1].transition_duration, Some(30));
581    }
582
583    #[test]
584    fn test_parse_comment_line() {
585        let comment = EdlParser::parse_comment_line("* This is a comment");
586        assert_eq!(comment, Some("This is a comment".to_string()));
587    }
588
589    #[test]
590    fn test_timecode_parser() {
591        let (_, tc) = EdlParser::timecode_parser("01:02:03:04", EdlFrameRate::Fps25)
592            .expect("operation should succeed");
593        assert_eq!(tc.hours(), 1);
594        assert_eq!(tc.minutes(), 2);
595        assert_eq!(tc.seconds(), 3);
596        assert_eq!(tc.frames(), 4);
597    }
598
599    #[test]
600    fn test_track_type_parser() {
601        let (_, track) = EdlParser::track_type_parser("V").expect("operation should succeed");
602        assert_eq!(track, TrackType::Video);
603
604        let (_, track) = EdlParser::track_type_parser("A").expect("operation should succeed");
605        assert_eq!(track, TrackType::Audio(AudioChannel::A1));
606
607        let (_, track) = EdlParser::track_type_parser("AA/V").expect("operation should succeed");
608        assert_eq!(track, TrackType::AudioPairWithVideo);
609    }
610
611    #[test]
612    fn test_edit_type_parser() {
613        let (_, edit) = EdlParser::edit_type_parser("C").expect("operation should succeed");
614        assert_eq!(edit, EditType::Cut);
615
616        let (_, edit) = EdlParser::edit_type_parser("D").expect("operation should succeed");
617        assert_eq!(edit, EditType::Dissolve);
618    }
619
620    #[test]
621    fn test_event_line_parser() {
622        let line = "001  AX       V     C        01:00:00:00 01:00:05:00 01:00:00:00 01:00:05:00";
623        let (_, event) = EdlParser::event_line_parser(line, EdlFrameRate::Fps2997DF)
624            .expect("operation should succeed");
625
626        assert_eq!(event.number, 1);
627        assert_eq!(event.reel, "AX");
628        assert_eq!(event.track, TrackType::Video);
629        assert_eq!(event.edit_type, EditType::Cut);
630    }
631
632    #[test]
633    fn test_event_with_transition() {
634        let line = "002  AX       V     D    030 01:00:05:00 01:00:10:00 01:00:05:00 01:00:10:00";
635        let (_, event) = EdlParser::event_line_parser(line, EdlFrameRate::Fps2997DF)
636            .expect("operation should succeed");
637
638        assert_eq!(event.number, 2);
639        assert_eq!(event.edit_type, EditType::Dissolve);
640        assert_eq!(event.transition_duration, Some(30));
641    }
642
643    #[test]
644    fn test_parse_fcm() {
645        assert_eq!(
646            parse_fcm("DROP FRAME").expect("operation should succeed"),
647            EdlFrameRate::Fps2997DF
648        );
649        assert_eq!(
650            parse_fcm("NON-DROP FRAME").expect("operation should succeed"),
651            EdlFrameRate::Fps2997NDF
652        );
653        assert_eq!(
654            parse_fcm("NON DROP FRAME").expect("operation should succeed"),
655            EdlFrameRate::Fps2997NDF
656        );
657    }
658
659    #[test]
660    fn test_parse_clip_name_comment() {
661        let edl_text = r#"001  AX       V     C        01:00:00:00 01:00:05:00 01:00:00:00 01:00:05:00
662* FROM CLIP NAME: test_clip.mov"#;
663
664        let mut parser = EdlParser::new();
665        let edl = parser.parse(edl_text).expect("failed to parse");
666
667        assert_eq!(edl.events.len(), 1);
668        assert_eq!(edl.events[0].clip_name, Some("test_clip.mov".to_string()));
669    }
670
671    const LAZY_SAMPLE_EDL: &str = "\
672TITLE: Lazy Sample\n\
673FCM: DROP FRAME\n\
674\n\
675001  A001     V     C        01:00:00;00 01:00:05;00 01:00:00;00 01:00:05;00\n\
676* FROM CLIP NAME: shot001.mov\n\
677\n\
678002  A002     V     D    030 01:00:05;00 01:00:10;00 01:00:05;00 01:00:10;00\n\
679* FROM CLIP NAME: shot002.mov\n\
680* Generic comment\n\
681\n\
682003  B001     V     C        01:00:10;00 01:00:15;00 01:00:10;00 01:00:15;00\n";
683
684    /// Accessing only the `.header` fields of lazy events must NOT invoke the
685    /// detail parser.  We verify this by tracking the detail borrow count.
686    #[test]
687    fn test_lazy_parse_headers_only() {
688        let events = parse_lazy(LAZY_SAMPLE_EDL, EdlFrameRate::Fps2997DF)
689            .expect("parse_lazy should succeed");
690
691        assert_eq!(events.len(), 3);
692
693        // Access only header fields — detail must remain unparsed
694        for ev in &events {
695            let _ = ev.header.number;
696            let _ = &ev.header.reel;
697            let _ = &ev.header.track_type_raw;
698            let _ = &ev.header.edit_type_raw;
699        }
700
701        // Confirm that no detail has been resolved yet
702        for ev in &events {
703            assert!(
704                ev.detail.borrow().is_none(),
705                "detail should not have been parsed when accessing only header fields"
706            );
707        }
708
709        // Check header values are correct
710        assert_eq!(events[0].header.number, 1);
711        assert_eq!(events[0].header.reel, "A001");
712        assert_eq!(events[1].header.number, 2);
713        assert_eq!(events[1].header.reel, "A002");
714        assert_eq!(events[2].header.number, 3);
715        assert_eq!(events[2].header.reel, "B001");
716    }
717
718    /// Accessing `.detail()` should parse the raw block and cache it;
719    /// a second call must return the same data without re-invoking the parser.
720    #[test]
721    fn test_lazy_detail_resolves() {
722        let events = parse_lazy(LAZY_SAMPLE_EDL, EdlFrameRate::Fps2997DF)
723            .expect("parse_lazy should succeed");
724
725        assert_eq!(events.len(), 3);
726
727        // Before any detail access, nothing is cached
728        assert!(events[0].detail.borrow().is_none());
729
730        // First access — triggers parsing
731        {
732            let detail = events[0].detail().expect("detail should resolve");
733            assert_eq!(detail.source_in_raw, "01:00:00;00");
734            assert_eq!(detail.source_out_raw, "01:00:05;00");
735            assert_eq!(detail.record_in_raw, "01:00:00;00");
736            assert_eq!(detail.record_out_raw, "01:00:05;00");
737            assert!(detail.transition_duration.is_none());
738            assert_eq!(detail.comments.len(), 1);
739            assert!(detail.comments[0].contains("shot001"));
740        }
741
742        // After first access, detail is cached
743        assert!(events[0].detail.borrow().is_some());
744
745        // Second access — must return the same data (caching verified by
746        // the fact that the RefCell still holds a single Some value)
747        {
748            let detail2 = events[0]
749                .detail()
750                .expect("detail should resolve on second call");
751            assert_eq!(detail2.source_in_raw, "01:00:00;00");
752        }
753
754        // Event 2 has a transition duration
755        {
756            let detail2 = events[1].detail().expect("event 2 detail should resolve");
757            assert_eq!(detail2.transition_duration, Some(30));
758            assert_eq!(detail2.comments.len(), 2);
759        }
760    }
761}