1use 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
15pub fn file_exists(filename: &str) -> bool {
22 Path::new(filename).exists()
23}
24
25fn get_int(s: &str, pos: &mut usize) -> Option<i64> {
35 let bytes = s.as_bytes();
36 let len = bytes.len();
37
38 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; }
62
63 while *pos < len && bytes[*pos] == b' ' {
65 *pos += 1;
66 }
67 if *pos < len && bytes[*pos] == b',' {
69 *pos += 1;
70 }
71
72 if is_neg {
73 value = -value;
74 }
75 Some(value)
76}
77
78fn 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
99fn 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); }
110
111 let trimmed = line.trim();
112 if let Some(path) = get_path(trimmed) {
113 paths.push(path);
114 } else {
115 reader.seek(SeekFrom::Start(pos_before))?;
117 return Ok(None);
118 }
119 }
120}
121
122#[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
155pub 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
175pub fn load_test(filename: &str) -> Option<ClipTestData> {
179 load_test_num(filename, 1)
180}
181
182fn 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; }
211
212 if test_num > 0 {
214 if line.contains("CAPTION:") {
215 test_num -= 1;
216 }
217 continue;
218 }
219
220 let trimmed = line.trim();
222
223 if trimmed.contains("CAPTION:") {
224 break; } 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 if test_num > 0 {
268 None
269 } else {
270 Some(data)
271 }
272}
273
274fn 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#[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 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 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 assert!(!file_exists("__nonexistent_test_file_xyz__.txt"));
425 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 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 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 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 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 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 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 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 assert!(load_test_num(filename, 1).is_some());
694 assert!(load_test_num(filename, 2).is_none());
696
697 let _ = fs::remove_file(&tmp_file);
698 }
699}