Skip to main content

clipper2_rust/utils/
file_io.rs

1// Copyright 2025 - Clipper2 Rust port
2// Direct port of ClipFileLoad.h / ClipFileLoad.cpp / ClipFileSave.h / ClipFileSave.cpp
3// by Angus Johnson
4// License: https://www.boost.org/LICENSE_1_0.txt
5//
6// Purpose: Test data file loading and saving for clipper operations
7
8use crate::core::{Path64, Paths64, Point64};
9use crate::engine::ClipType;
10use crate::FillRule;
11use std::fs;
12use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write};
13use std::path::Path;
14
15// ============================================================================
16// Helper: file existence check
17// ============================================================================
18
19/// Check if a file exists.
20/// Direct port from C++ `FileExists()`.
21pub fn file_exists(filename: &str) -> bool {
22    Path::new(filename).exists()
23}
24
25// ============================================================================
26// Internal parsing helpers
27// ============================================================================
28
29/// Parse an i64 integer from a string iterator position.
30/// Direct port from C++ `GetInt()`.
31///
32/// Skips leading whitespace, parses an optional sign and digits,
33/// then skips trailing whitespace and an optional comma.
34fn get_int(s: &str, pos: &mut usize) -> Option<i64> {
35    let bytes = s.as_bytes();
36    let len = bytes.len();
37
38    // Skip leading whitespace
39    while *pos < len && bytes[*pos] == b' ' {
40        *pos += 1;
41    }
42    if *pos >= len {
43        return None;
44    }
45
46    let is_neg = bytes[*pos] == b'-';
47    if is_neg {
48        *pos += 1;
49    }
50
51    let start = *pos;
52    let mut value: i64 = 0;
53
54    while *pos < len && bytes[*pos] >= b'0' && bytes[*pos] <= b'9' {
55        value = value * 10 + (bytes[*pos] - b'0') as i64;
56        *pos += 1;
57    }
58
59    if *pos == start {
60        return None; // no digits found
61    }
62
63    // Trim trailing whitespace
64    while *pos < len && bytes[*pos] == b' ' {
65        *pos += 1;
66    }
67    // Skip a comma if present
68    if *pos < len && bytes[*pos] == b',' {
69        *pos += 1;
70    }
71
72    if is_neg {
73        value = -value;
74    }
75    Some(value)
76}
77
78/// Parse a line of integer coordinate pairs into a Path64.
79/// Direct port from C++ `GetPath()`.
80fn get_path(line: &str) -> Option<Path64> {
81    let mut path = Path64::new();
82    let mut pos = 0;
83
84    while let Some(x) = get_int(line, &mut pos) {
85        if let Some(y) = get_int(line, &mut pos) {
86            path.push(Point64::new(x, y));
87        } else {
88            break;
89        }
90    }
91
92    if path.is_empty() {
93        None
94    } else {
95        Some(path)
96    }
97}
98
99/// Read consecutive path lines from a buffered reader until a non-path line.
100/// Direct port from C++ `GetPaths()`.
101fn get_paths(reader: &mut BufReader<fs::File>, paths: &mut Paths64) -> io::Result<Option<String>> {
102    let mut line = String::new();
103    loop {
104        let pos_before = reader.stream_position()?;
105        line.clear();
106        let bytes_read = reader.read_line(&mut line)?;
107        if bytes_read == 0 {
108            return Ok(None); // EOF
109        }
110
111        let trimmed = line.trim();
112        if let Some(path) = get_path(trimmed) {
113            paths.push(path);
114        } else {
115            // Not a path line - seek back so the caller can re-read this line
116            reader.seek(SeekFrom::Start(pos_before))?;
117            return Ok(None);
118        }
119    }
120}
121
122// ============================================================================
123// Test result struct
124// ============================================================================
125
126/// Result of loading a test from a test data file.
127///
128/// Contains the subject, open subject, and clip paths along with
129/// expected results and operation parameters.
130#[derive(Debug, Clone)]
131pub struct ClipTestData {
132    pub subj: Paths64,
133    pub subj_open: Paths64,
134    pub clip: Paths64,
135    pub area: i64,
136    pub count: i64,
137    pub clip_type: ClipType,
138    pub fill_rule: FillRule,
139}
140
141impl Default for ClipTestData {
142    fn default() -> Self {
143        Self {
144            subj: Paths64::new(),
145            subj_open: Paths64::new(),
146            clip: Paths64::new(),
147            area: 0,
148            count: 0,
149            clip_type: ClipType::Intersection,
150            fill_rule: FillRule::EvenOdd,
151        }
152    }
153}
154
155// ============================================================================
156// Loading functions
157// ============================================================================
158
159/// Load a specific test number from a test data file.
160///
161/// Direct port from C++ `LoadTestNum()`.
162///
163/// # Arguments
164/// * `filename` - Path to the test data file
165/// * `test_num` - 1-based test number to load
166///
167/// # Returns
168/// `Some(ClipTestData)` if the test was found, `None` otherwise.
169pub fn load_test_num(filename: &str, test_num: usize) -> Option<ClipTestData> {
170    let file = fs::File::open(filename).ok()?;
171    let mut reader = BufReader::new(file);
172    load_test_num_from_reader(&mut reader, test_num)
173}
174
175/// Load the first test from a test data file.
176///
177/// Direct port from C++ `LoadTest()`.
178pub fn load_test(filename: &str) -> Option<ClipTestData> {
179    load_test_num(filename, 1)
180}
181
182/// Internal: Load a test from an already-opened reader.
183/// Direct port from C++ `LoadTestNum()`.
184///
185/// The C++ logic:
186///   while (getline(source, line)) {
187///     if (test_num) {          // still searching for the right CAPTION
188///       if (line.find("CAPTION:") != npos) --test_num;
189///       continue;              // skip everything until test_num == 0
190///     }
191///     if (line.find("CAPTION:") != npos) break;  // next test, stop
192///     // ... parse data lines ...
193///   }
194///   return !test_num;          // true if we found the right CAPTION
195fn load_test_num_from_reader(
196    reader: &mut BufReader<fs::File>,
197    test_num: usize,
198) -> Option<ClipTestData> {
199    let mut test_num = test_num.max(1) as i64;
200    reader.seek(SeekFrom::Start(0)).ok()?;
201
202    let mut data = ClipTestData::default();
203    let mut line = String::new();
204
205    loop {
206        line.clear();
207        let bytes = reader.read_line(&mut line).ok()?;
208        if bytes == 0 {
209            break; // EOF
210        }
211
212        // Phase 1: Skip to the correct CAPTION (matching C++ `if (test_num)` block)
213        if test_num > 0 {
214            if line.contains("CAPTION:") {
215                test_num -= 1;
216            }
217            continue;
218        }
219
220        // Phase 2: Parse test data
221        let trimmed = line.trim();
222
223        if trimmed.contains("CAPTION:") {
224            break; // next test - stop
225        } else if trimmed.contains("INTERSECTION") {
226            data.clip_type = ClipType::Intersection;
227        } else if trimmed.contains("UNION") {
228            data.clip_type = ClipType::Union;
229        } else if trimmed.contains("DIFFERENCE") {
230            data.clip_type = ClipType::Difference;
231        } else if trimmed.contains("XOR") {
232            data.clip_type = ClipType::Xor;
233        } else if trimmed.contains("EVENODD") {
234            data.fill_rule = FillRule::EvenOdd;
235        } else if trimmed.contains("NONZERO") {
236            data.fill_rule = FillRule::NonZero;
237        } else if trimmed.contains("POSITIVE") {
238            data.fill_rule = FillRule::Positive;
239        } else if trimmed.contains("NEGATIVE") {
240            data.fill_rule = FillRule::Negative;
241        } else if trimmed.contains("SOL_AREA") {
242            if let Some(colon_pos) = trimmed.find(':') {
243                let val_str = trimmed[colon_pos + 1..].trim();
244                let mut pos = 0;
245                if let Some(val) = get_int(val_str, &mut pos) {
246                    data.area = val;
247                }
248            }
249        } else if trimmed.contains("SOL_COUNT") {
250            if let Some(colon_pos) = trimmed.find(':') {
251                let val_str = trimmed[colon_pos + 1..].trim();
252                let mut pos = 0;
253                if let Some(val) = get_int(val_str, &mut pos) {
254                    data.count = val;
255                }
256            }
257        } else if trimmed.contains("SUBJECTS_OPEN") {
258            let _ = get_paths(reader, &mut data.subj_open);
259        } else if trimmed.contains("SUBJECTS") {
260            let _ = get_paths(reader, &mut data.subj);
261        } else if trimmed.contains("CLIPS") {
262            let _ = get_paths(reader, &mut data.clip);
263        }
264    }
265
266    // C++ returns !test_num (true if we found and consumed the target CAPTION)
267    if test_num > 0 {
268        None
269    } else {
270        Some(data)
271    }
272}
273
274// ============================================================================
275// Saving functions
276// ============================================================================
277
278/// Write paths as coordinate text to a writer.
279/// Direct port from C++ `PathsToStream()`.
280fn paths_to_stream(paths: &Paths64, writer: &mut dyn Write) -> io::Result<()> {
281    for path in paths {
282        if path.is_empty() {
283            continue;
284        }
285        let last_idx = path.len() - 1;
286        for (i, pt) in path.iter().enumerate() {
287            if i < last_idx {
288                write!(writer, "{},{}, ", pt.x, pt.y)?;
289            } else {
290                writeln!(writer, "{},{}", pt.x, pt.y)?;
291            }
292        }
293    }
294    Ok(())
295}
296
297/// Save a test to a file.
298///
299/// Direct port from C++ `SaveTest()`.
300///
301/// # Arguments
302/// * `filename` - Path to the output file
303/// * `append` - If true, append to existing file and auto-number tests
304/// * `subj` - Optional subject paths
305/// * `subj_open` - Optional open subject paths
306/// * `clip` - Optional clip paths
307/// * `area` - Expected solution area
308/// * `count` - Expected solution count
309/// * `ct` - Clip type
310/// * `fr` - Fill rule
311#[allow(clippy::too_many_arguments)]
312pub fn save_test(
313    filename: &str,
314    append: bool,
315    subj: Option<&Paths64>,
316    subj_open: Option<&Paths64>,
317    clip: Option<&Paths64>,
318    area: i64,
319    count: i64,
320    ct: ClipType,
321    fr: FillRule,
322) -> bool {
323    let mut last_test_no: i64 = 0;
324
325    if append && file_exists(filename) {
326        // Find the last CAPTION number
327        if let Ok(content) = fs::read_to_string(filename) {
328            for line in content.lines().rev() {
329                if let Some(cap_pos) = line.find("CAPTION:") {
330                    let after = line[cap_pos + 8..].trim();
331                    // Parse the test number (strip trailing period/dot)
332                    let num_str = after.trim_end_matches('.');
333                    if let Ok(n) = num_str.trim().parse::<i64>() {
334                        last_test_no = n;
335                    }
336                    break;
337                }
338            }
339        }
340    } else if file_exists(filename) {
341        let _ = fs::remove_file(filename);
342    }
343
344    last_test_no += 1;
345
346    let file = if append && file_exists(filename) {
347        fs::OpenOptions::new().append(true).open(filename)
348    } else {
349        fs::File::create(filename)
350    };
351
352    let mut file = match file {
353        Ok(f) => f,
354        Err(_) => return false,
355    };
356
357    let cliptype_string = match ct {
358        ClipType::NoClip => "NOCLIP",
359        ClipType::Intersection => "INTERSECTION",
360        ClipType::Union => "UNION",
361        ClipType::Difference => "DIFFERENCE",
362        ClipType::Xor => "XOR",
363    };
364
365    let fillrule_string = match fr {
366        FillRule::EvenOdd => "EVENODD",
367        FillRule::NonZero => "NONZERO",
368        FillRule::Positive => "POSITIVE",
369        FillRule::Negative => "NEGATIVE",
370    };
371
372    let header = format!(
373        "CAPTION: {}.\nCLIPTYPE: {}\nFILLRULE: {}\nSOL_AREA: {}\nSOL_COUNT: {}\n",
374        last_test_no, cliptype_string, fillrule_string, area, count
375    );
376
377    if write!(file, "{}", header).is_err() {
378        return false;
379    }
380
381    if let Some(subj) = subj {
382        if writeln!(file, "SUBJECTS").is_err() {
383            return false;
384        }
385        if paths_to_stream(subj, &mut file).is_err() {
386            return false;
387        }
388    }
389
390    if let Some(subj_open) = subj_open {
391        if writeln!(file, "SUBJECTS_OPEN").is_err() {
392            return false;
393        }
394        if paths_to_stream(subj_open, &mut file).is_err() {
395            return false;
396        }
397    }
398
399    if let Some(clip) = clip {
400        if !clip.is_empty() {
401            if writeln!(file, "CLIPS").is_err() {
402                return false;
403            }
404            if paths_to_stream(clip, &mut file).is_err() {
405                return false;
406            }
407        }
408    }
409
410    if writeln!(file).is_err() {
411        return false;
412    }
413
414    true
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_file_exists() {
423        // A file that definitely doesn't exist
424        assert!(!file_exists("__nonexistent_test_file_xyz__.txt"));
425        // A file we know exists (Cargo.toml in project root)
426        assert!(file_exists("Cargo.toml"));
427    }
428
429    #[test]
430    fn test_get_int_basic() {
431        let s = "123, -456, 789";
432        let mut pos = 0;
433        assert_eq!(get_int(s, &mut pos), Some(123));
434        assert_eq!(get_int(s, &mut pos), Some(-456));
435        assert_eq!(get_int(s, &mut pos), Some(789));
436        assert_eq!(get_int(s, &mut pos), None);
437    }
438
439    #[test]
440    fn test_get_int_with_whitespace() {
441        let s = "  42  ";
442        let mut pos = 0;
443        assert_eq!(get_int(s, &mut pos), Some(42));
444    }
445
446    #[test]
447    fn test_get_int_empty() {
448        let s = "   ";
449        let mut pos = 0;
450        assert_eq!(get_int(s, &mut pos), None);
451    }
452
453    #[test]
454    fn test_get_path_basic() {
455        let line = "10,20, 30,40, 50,60";
456        let path = get_path(line).unwrap();
457        assert_eq!(path.len(), 3);
458        assert_eq!(path[0], Point64::new(10, 20));
459        assert_eq!(path[1], Point64::new(30, 40));
460        assert_eq!(path[2], Point64::new(50, 60));
461    }
462
463    #[test]
464    fn test_get_path_negative() {
465        let line = "-10,-20, 30,-40";
466        let path = get_path(line).unwrap();
467        assert_eq!(path.len(), 2);
468        assert_eq!(path[0], Point64::new(-10, -20));
469        assert_eq!(path[1], Point64::new(30, -40));
470    }
471
472    #[test]
473    fn test_get_path_empty() {
474        let line = "SUBJECTS";
475        assert!(get_path(line).is_none());
476    }
477
478    #[test]
479    fn test_save_and_load_roundtrip() {
480        let tmp_file = std::env::temp_dir().join("clipper2_test_fileio.txt");
481        let filename = tmp_file.to_str().unwrap();
482
483        let subj = vec![vec![
484            Point64::new(0, 0),
485            Point64::new(100, 0),
486            Point64::new(100, 100),
487            Point64::new(0, 100),
488        ]];
489        let clip = vec![vec![
490            Point64::new(50, 50),
491            Point64::new(150, 50),
492            Point64::new(150, 150),
493            Point64::new(50, 150),
494        ]];
495
496        // Save
497        let result = save_test(
498            filename,
499            false,
500            Some(&subj),
501            None,
502            Some(&clip),
503            2500,
504            1,
505            ClipType::Intersection,
506            FillRule::EvenOdd,
507        );
508        assert!(result);
509        assert!(file_exists(filename));
510
511        // Load
512        let data = load_test(filename).unwrap();
513        assert_eq!(data.clip_type, ClipType::Intersection);
514        assert_eq!(data.fill_rule, FillRule::EvenOdd);
515        assert_eq!(data.area, 2500);
516        assert_eq!(data.count, 1);
517        assert_eq!(data.subj.len(), 1);
518        assert_eq!(data.subj[0].len(), 4);
519        assert_eq!(data.clip.len(), 1);
520        assert_eq!(data.clip[0].len(), 4);
521
522        // Verify coordinates
523        assert_eq!(data.subj[0][0], Point64::new(0, 0));
524        assert_eq!(data.subj[0][1], Point64::new(100, 0));
525        assert_eq!(data.clip[0][0], Point64::new(50, 50));
526
527        let _ = fs::remove_file(&tmp_file);
528    }
529
530    #[test]
531    fn test_save_append_increments_test_number() {
532        let tmp_file = std::env::temp_dir().join("clipper2_test_append.txt");
533        let filename = tmp_file.to_str().unwrap();
534
535        // Remove if exists from previous run
536        let _ = fs::remove_file(&tmp_file);
537
538        let subj = vec![vec![
539            Point64::new(0, 0),
540            Point64::new(10, 0),
541            Point64::new(10, 10),
542        ]];
543
544        // First save (test 1)
545        assert!(save_test(
546            filename,
547            false,
548            Some(&subj),
549            None,
550            None,
551            100,
552            1,
553            ClipType::Union,
554            FillRule::NonZero,
555        ));
556
557        // Second save (append, should be test 2)
558        assert!(save_test(
559            filename,
560            true,
561            Some(&subj),
562            None,
563            None,
564            200,
565            2,
566            ClipType::Difference,
567            FillRule::EvenOdd,
568        ));
569
570        // Verify file contains both captions
571        let content = fs::read_to_string(&tmp_file).unwrap();
572        assert!(content.contains("CAPTION: 1."));
573        assert!(content.contains("CAPTION: 2."));
574
575        let _ = fs::remove_file(&tmp_file);
576    }
577
578    #[test]
579    fn test_save_all_clip_types() {
580        let tmp_file = std::env::temp_dir().join("clipper2_test_clip_types.txt");
581        let filename = tmp_file.to_str().unwrap();
582
583        for (ct, expected) in [
584            (ClipType::Intersection, "INTERSECTION"),
585            (ClipType::Union, "UNION"),
586            (ClipType::Difference, "DIFFERENCE"),
587            (ClipType::Xor, "XOR"),
588            (ClipType::NoClip, "NOCLIP"),
589        ] {
590            assert!(save_test(
591                filename,
592                false,
593                None,
594                None,
595                None,
596                0,
597                0,
598                ct,
599                FillRule::EvenOdd,
600            ));
601            let content = fs::read_to_string(&tmp_file).unwrap();
602            assert!(content.contains(expected), "Missing {}", expected);
603        }
604
605        let _ = fs::remove_file(&tmp_file);
606    }
607
608    #[test]
609    fn test_save_all_fill_rules() {
610        let tmp_file = std::env::temp_dir().join("clipper2_test_fill_rules.txt");
611        let filename = tmp_file.to_str().unwrap();
612
613        for (fr, expected) in [
614            (FillRule::EvenOdd, "EVENODD"),
615            (FillRule::NonZero, "NONZERO"),
616            (FillRule::Positive, "POSITIVE"),
617            (FillRule::Negative, "NEGATIVE"),
618        ] {
619            assert!(save_test(
620                filename,
621                false,
622                None,
623                None,
624                None,
625                0,
626                0,
627                ClipType::Union,
628                fr,
629            ));
630            let content = fs::read_to_string(&tmp_file).unwrap();
631            assert!(content.contains(expected), "Missing {}", expected);
632        }
633
634        let _ = fs::remove_file(&tmp_file);
635    }
636
637    #[test]
638    fn test_save_with_open_subjects() {
639        let tmp_file = std::env::temp_dir().join("clipper2_test_open_subj.txt");
640        let filename = tmp_file.to_str().unwrap();
641
642        let subj_open = vec![vec![Point64::new(0, 0), Point64::new(100, 100)]];
643
644        assert!(save_test(
645            filename,
646            false,
647            None,
648            Some(&subj_open),
649            None,
650            0,
651            0,
652            ClipType::Union,
653            FillRule::NonZero,
654        ));
655
656        let content = fs::read_to_string(&tmp_file).unwrap();
657        assert!(content.contains("SUBJECTS_OPEN"));
658        assert!(content.contains("0,0"));
659        assert!(content.contains("100,100"));
660
661        let _ = fs::remove_file(&tmp_file);
662    }
663
664    #[test]
665    fn test_load_nonexistent_file() {
666        assert!(load_test("__nonexistent_file_xyz__.txt").is_none());
667    }
668
669    #[test]
670    fn test_load_test_num_out_of_range() {
671        let tmp_file = std::env::temp_dir().join("clipper2_test_range.txt");
672        let filename = tmp_file.to_str().unwrap();
673
674        let subj = vec![vec![
675            Point64::new(0, 0),
676            Point64::new(10, 0),
677            Point64::new(10, 10),
678        ]];
679
680        assert!(save_test(
681            filename,
682            false,
683            Some(&subj),
684            None,
685            None,
686            50,
687            1,
688            ClipType::Union,
689            FillRule::NonZero,
690        ));
691
692        // Test 1 should exist
693        assert!(load_test_num(filename, 1).is_some());
694        // Test 2 should not exist
695        assert!(load_test_num(filename, 2).is_none());
696
697        let _ = fs::remove_file(&tmp_file);
698    }
699}