Skip to main content

vecslide_core/
validation.rs

1use std::collections::HashSet;
2
3use crate::{
4    error::VecslideError,
5    manifest::{Presentation, TranscriptMode},
6};
7
8/// Validates a presentation without touching the filesystem. WASM-safe.
9/// Returns all errors found, not just the first.
10pub fn validate(presentation: &Presentation) -> Vec<VecslideError> {
11    let mut errors = Vec::new();
12
13    // --- audio_track ---
14    if matches!(&presentation.audio_track, Some(s) if s.is_empty()) {
15        errors.push(VecslideError::EmptyAudioTrack);
16    }
17
18    // --- slides ---
19    if presentation.slides.is_empty() {
20        errors.push(VecslideError::EmptySlides);
21        return errors; // cannot check further with no slides
22    }
23
24    // Unique slide ids
25    let mut seen_ids: HashSet<&str> = HashSet::new();
26    for slide in &presentation.slides {
27        if !seen_ids.insert(slide.id.as_str()) {
28            errors.push(VecslideError::DuplicateSlideId { id: slide.id.clone() });
29        }
30    }
31
32    // Ascending timestamps + animations
33    let n = presentation.slides.len();
34    let mut prev_time = 0u64;
35    for (i, slide) in presentation.slides.iter().enumerate() {
36        if i > 0 && slide.time_start <= prev_time {
37            errors.push(VecslideError::TimestampOrder {
38                index: i,
39                detail: format!(
40                    "slide '{}' time_start {}ms <= previous {}ms",
41                    slide.id, slide.time_start, prev_time
42                ),
43            });
44        }
45
46        // Upper bound: next slide's time_start (or u64::MAX for the last slide)
47        let slide_end = if i + 1 < n {
48            presentation.slides[i + 1].time_start
49        } else {
50            u64::MAX
51        };
52
53        for (j, anim) in slide.animations.iter().enumerate() {
54            if anim.element_id.is_empty() {
55                errors.push(VecslideError::EmptyElementId {
56                    slide_id: slide.id.clone(),
57                    anim_index: j,
58                });
59            }
60            if anim.time_start < slide.time_start || anim.time_start >= slide_end {
61                errors.push(VecslideError::AnimationOutOfRange { slide_id: slide.id.clone() });
62            }
63        }
64
65        prev_time = slide.time_start;
66    }
67
68    // --- transcript ---
69    if let Some(ref transcript) = presentation.transcript {
70        // Synchronized mode requires audio_track
71        if matches!(transcript.mode, TranscriptMode::Synchronized)
72            && presentation.audio_track.is_none()
73        {
74            errors.push(VecslideError::EmptyAudioTrack);
75        }
76
77        let mut prev_end = 0u64;
78        for (i, seg) in transcript.segments.iter().enumerate() {
79            if seg.start_ms >= seg.end_ms {
80                errors.push(VecslideError::InvalidSegment { index: i });
81            }
82            if i > 0 && seg.start_ms < prev_end {
83                errors.push(VecslideError::OverlappingSegments { index: i });
84            }
85            prev_end = seg.end_ms;
86        }
87    }
88
89    errors
90}
91
92/// Validates a presentation and also checks that referenced SVG files exist on disk.
93/// Native only (requires filesystem access).
94#[cfg(feature = "native")]
95pub fn validate_with_dir(
96    presentation: &Presentation,
97    base_dir: &std::path::Path,
98) -> Vec<VecslideError> {
99    let mut errors = validate(presentation);
100
101    for slide in &presentation.slides {
102        if let Some(ref svg_file) = slide.svg_file {
103            let svg_path = base_dir.join(svg_file);
104            if !svg_path.exists() {
105                errors.push(VecslideError::MissingSvgFile {
106                    path: svg_file.clone(),
107                });
108            }
109        }
110    }
111
112    errors
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::manifest::{Animation, Slide, Transcript, TranscriptMode, TranscriptSegment};
119
120    fn make_slide(id: &str, time_start: u64) -> Slide {
121        Slide {
122            id: id.to_string(),
123            svg_file: Some(format!("{id}.svg")),
124            typst_file: None,
125            typst_inline: None,
126            time_start,
127            animations: vec![],
128            pointer_trail: None,
129            transition: None,
130        }
131    }
132
133    fn make_presentation(slides: Vec<Slide>) -> Presentation {
134        Presentation {
135            format_version: "1.0".to_string(),
136            title: "Test".to_string(),
137            author: None,
138            description: None,
139            date: None,
140            language: None,
141            audio_track: Some("audio/test.opus".to_string()),
142            typst_source: None,
143            slides,
144            annotations: vec![],
145            transcript: None,
146        }
147    }
148
149    #[test]
150    fn valid_presentation_has_no_errors() {
151        let pres = make_presentation(vec![
152            make_slide("s1", 0),
153            make_slide("s2", 5000),
154            make_slide("s3", 10000),
155        ]);
156        assert!(validate(&pres).is_empty());
157    }
158
159    #[test]
160    fn empty_audio_track_is_an_error() {
161        let mut pres = make_presentation(vec![make_slide("s1", 0)]);
162        pres.audio_track = Some(String::new());
163        let errors = validate(&pres);
164        assert!(errors
165            .iter()
166            .any(|e| matches!(e, VecslideError::EmptyAudioTrack)));
167    }
168
169    #[test]
170    fn empty_slides_is_an_error() {
171        let pres = make_presentation(vec![]);
172        let errors = validate(&pres);
173        assert!(errors
174            .iter()
175            .any(|e| matches!(e, VecslideError::EmptySlides)));
176    }
177
178    #[test]
179    fn non_ascending_timestamps_are_detected() {
180        let pres = make_presentation(vec![
181            make_slide("s1", 0),
182            make_slide("s2", 10000),
183            make_slide("s3", 5000), // goes backwards
184        ]);
185        let errors = validate(&pres);
186        assert!(errors
187            .iter()
188            .any(|e| matches!(e, VecslideError::TimestampOrder { index: 2, .. })));
189    }
190
191    #[test]
192    fn duplicate_slide_ids_are_detected() {
193        let pres = make_presentation(vec![
194            make_slide("s1", 0),
195            make_slide("s1", 5000), // duplicate id
196        ]);
197        let errors = validate(&pres);
198        assert!(errors
199            .iter()
200            .any(|e| matches!(e, VecslideError::DuplicateSlideId { .. })));
201    }
202
203    #[test]
204    fn empty_element_id_in_animation_is_detected() {
205        let mut slide = make_slide("s1", 0);
206        slide.animations.push(Animation {
207            element_id: String::new(),
208            time_start: 0,
209            duration: 500,
210            kind: "fade-in".to_string(),
211        });
212        let pres = make_presentation(vec![slide]);
213        let errors = validate(&pres);
214        assert!(errors
215            .iter()
216            .any(|e| matches!(e, VecslideError::EmptyElementId { .. })));
217    }
218
219    #[test]
220    fn animation_out_of_range_is_detected() {
221        let mut slide = make_slide("s1", 1000);
222        slide.animations.push(Animation {
223            element_id: "el".to_string(),
224            time_start: 500, // before slide.time_start
225            duration: 100,
226            kind: "fade-in".to_string(),
227        });
228        let pres = make_presentation(vec![slide]);
229        let errors = validate(&pres);
230        assert!(errors
231            .iter()
232            .any(|e| matches!(e, VecslideError::AnimationOutOfRange { .. })));
233    }
234
235    #[test]
236    fn invalid_transcript_segment_detected() {
237        let mut pres = make_presentation(vec![make_slide("s1", 0)]);
238        pres.transcript = Some(Transcript {
239            mode: TranscriptMode::Synchronized,
240            language: "it-IT".to_string(),
241            segments: vec![TranscriptSegment {
242                start_ms: 1000,
243                end_ms: 500, // end < start
244                text: "hello".to_string(),
245                slide_ref: None,
246                words: vec![],
247            }],
248        });
249        let errors = validate(&pres);
250        assert!(errors
251            .iter()
252            .any(|e| matches!(e, VecslideError::InvalidSegment { .. })));
253    }
254
255    #[test]
256    fn overlapping_transcript_segments_detected() {
257        let mut pres = make_presentation(vec![make_slide("s1", 0)]);
258        pres.transcript = Some(Transcript {
259            mode: TranscriptMode::Synchronized,
260            language: "it-IT".to_string(),
261            segments: vec![
262                TranscriptSegment {
263                    start_ms: 0,
264                    end_ms: 2000,
265                    text: "first".to_string(),
266                    slide_ref: None,
267                    words: vec![],
268                },
269                TranscriptSegment {
270                    start_ms: 1500, // overlaps with previous
271                    end_ms: 3000,
272                    text: "second".to_string(),
273                    slide_ref: None,
274                    words: vec![],
275                },
276            ],
277        });
278        let errors = validate(&pres);
279        assert!(errors
280            .iter()
281            .any(|e| matches!(e, VecslideError::OverlappingSegments { .. })));
282    }
283
284    #[test]
285    fn synchronized_transcript_requires_audio_track() {
286        let mut pres = make_presentation(vec![make_slide("s1", 0)]);
287        pres.audio_track = None;
288        pres.transcript = Some(Transcript {
289            mode: TranscriptMode::Synchronized,
290            language: "it-IT".to_string(),
291            segments: vec![],
292        });
293        let errors = validate(&pres);
294        assert!(errors
295            .iter()
296            .any(|e| matches!(e, VecslideError::EmptyAudioTrack)));
297    }
298}