1
//! File I/O wrapper for the desktop C API layer.
2
//!
3
//! Note: `layout/src/file.rs` provides a more complete file API with
4
//! proper error types (`FileError`) and a `FilePath` wrapper.
5

            
6
use alloc::sync::Arc;
7
use core::fmt;
8
use std::{
9
    fs,
10
    io::{Read, Write},
11
    sync::Mutex,
12
};
13

            
14
use azul_css::{impl_option, impl_option_inner, AzString, U8Vec};
15

            
16
/// Thread-safe file handle with path tracking for the C API.
17
#[repr(C)]
18
pub struct File {
19
    pub ptr: Box<Arc<Mutex<fs::File>>>,
20
    pub path: AzString,
21
    pub run_destructor: bool,
22
}
23

            
24
impl Clone for File {
25
4
    fn clone(&self) -> Self {
26
4
        Self {
27
4
            ptr: self.ptr.clone(),
28
4
            path: self.path.clone(),
29
4
            run_destructor: true,
30
4
        }
31
4
    }
32
}
33

            
34
impl Drop for File {
35
51
    fn drop(&mut self) {
36
51
        self.run_destructor = false;
37
51
    }
38
}
39

            
40
impl fmt::Debug for File {
41
12
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42
12
        write!(f, "{}", self.path.as_str())
43
12
    }
44
}
45

            
46
impl fmt::Display for File {
47
15
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48
15
        write!(f, "{}", self.path.as_str())
49
15
    }
50
}
51

            
52
impl PartialEq for File {
53
7
    fn eq(&self, other: &Self) -> bool {
54
7
        self.path.as_str().eq(other.path.as_str())
55
7
    }
56
}
57

            
58
impl Eq for File {}
59

            
60
impl PartialOrd for File {
61
7
    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
62
7
        self.path.as_str().partial_cmp(other.path.as_str())
63
7
    }
64
}
65

            
66
impl_option!(File, OptionFile, copy = false, [Clone, Debug]);
67

            
68
impl File {
69
47
    fn new(f: fs::File, path: AzString) -> Self {
70
47
        Self {
71
47
            ptr: Box::new(Arc::new(Mutex::new(f))),
72
47
            path,
73
47
            run_destructor: true,
74
47
        }
75
47
    }
76
    /// Opens a file in read-only mode, returning `None` on failure.
77
28
    #[must_use] pub fn open(path: &str) -> Option<Self> {
78
6
        Some(Self::new(
79
28
            fs::File::open(path).ok()?,
80
6
            path.to_string().into(),
81
        ))
82
28
    }
83
    /// Creates a file (truncating if it exists), returning `None` on failure.
84
23
    #[must_use] pub fn create(path: &str) -> Option<Self> {
85
16
        Some(Self::new(
86
23
            fs::File::create(path).ok()?,
87
16
            path.to_string().into(),
88
        ))
89
23
    }
90
    /// Reads the file at `self.path` into a string.
91
17
    pub fn read_to_string(&mut self) -> Option<AzString> {
92
17
        let file_string = fs::read_to_string(self.path.as_str()).ok()?;
93
12
        Some(file_string.into())
94
17
    }
95
    /// Reads the file at `self.path` into a byte vector.
96
9
    pub fn read_to_bytes(&mut self) -> Option<U8Vec> {
97
9
        let file_bytes = fs::read(self.path.as_str()).ok()?;
98
5
        Some(file_bytes.into())
99
9
    }
100
    /// Writes a string to the file handle. Returns `false` on failure.
101
19
    pub fn write_string(&mut self, string: &str) -> bool {
102
19
        self.write_bytes(string.as_bytes())
103
19
    }
104
    /// Writes bytes to the file handle and syncs to disk. Returns `false` on failure.
105
25
    pub fn write_bytes(&mut self, bytes: &[u8]) -> bool {
106
25
        let Ok(mut lock) = self.ptr.lock() else {
107
2
            return false;
108
        };
109
23
        lock.write_all(bytes).is_ok() && lock.sync_all().is_ok()
110
25
    }
111
    /// Closes the file by dropping the handle. Provided for C API symmetry.
112
6
    pub fn close(self) {}
113
}
114

            
115
#[cfg(test)]
116
mod autotest_generated {
117
    use core::sync::atomic::{AtomicUsize, Ordering};
118
    use std::path::PathBuf;
119

            
120
    use super::*;
121

            
122
    // NOTE ON THE TWO HALVES OF THIS TYPE
123
    //
124
    // `File` is really two things glued together:
125
    //   * a live `fs::File` handle behind `Arc<Mutex<_>>` -- this is what `write_bytes`
126
    //     (and therefore `write_string`) talks to, and
127
    //   * a `path: AzString` -- this is what `read_to_string` / `read_to_bytes` /
128
    //     `Debug` / `Display` / `PartialEq` / `PartialOrd` talk to.
129
    //
130
    // The two halves can disagree (path deleted under an open handle, path never
131
    // pointing at the handle at all -- `File::new` accepts *any* AzString), so most
132
    // tests below deliberately probe the seam rather than the happy path.
133

            
134
    /// A temp path that unlinks itself on drop, so a failing assert can't leak files.
135
    struct TempPath(PathBuf);
136

            
137
    impl TempPath {
138
        fn new(tag: &str) -> Self {
139
            Self(unique_temp_path(tag))
140
        }
141
        fn as_str(&self) -> &str {
142
            self.0.to_str().expect("temp dir must be valid UTF-8")
143
        }
144
        fn exists(&self) -> bool {
145
            self.0.exists()
146
        }
147
    }
148

            
149
    impl Drop for TempPath {
150
        fn drop(&mut self) {
151
            let _ = fs::remove_file(&self.0);
152
        }
153
    }
154

            
155
    fn unique_temp_path(tag: &str) -> PathBuf {
156
        static COUNTER: AtomicUsize = AtomicUsize::new(0);
157
        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
158
        let nanos = std::time::SystemTime::now()
159
            .duration_since(std::time::UNIX_EPOCH)
160
            .map_or(0, |d| d.as_nanos());
161
        std::env::temp_dir().join(format!(
162
            "azul_autotest_file_{}_{}_{}_{}",
163
            std::process::id(),
164
            nanos,
165
            n,
166
            tag
167
        ))
168
    }
169

            
170
    /// A real, open `fs::File` handle whose path is already gone -- lets us build a
171
    /// `File` with a *completely arbitrary* `path` field for the fmt/cmp tests.
172
    fn detached_handle() -> fs::File {
173
        let path = unique_temp_path("detached");
174
        let handle = fs::File::create(&path).expect("temp dir must be writable");
175
        let _ = fs::remove_file(&path);
176
        handle
177
    }
178

            
179
    fn with_path(path: &str) -> File {
180
        File::new(detached_handle(), path.to_string().into())
181
    }
182

            
183
    // ---------------------------------------------------------------- File::new
184

            
185
    #[test]
186
    fn new_sets_fields_and_arms_the_destructor() {
187
        let temp = TempPath::new("new_fields");
188
        let handle = fs::File::create(temp.as_str()).expect("create");
189
        let file = File::new(handle, temp.as_str().to_string().into());
190

            
191
        assert_eq!(file.path.as_str(), temp.as_str());
192
        assert!(file.run_destructor, "a freshly built File must own its handle");
193
        assert_eq!(
194
            Arc::strong_count(&file.ptr),
195
            1,
196
            "an un-cloned File must be the sole owner of the handle"
197
        );
198
    }
199

            
200
    #[test]
201
    fn new_accepts_extreme_paths_without_panicking() {
202
        // `new` never touches the filesystem, so even a path that could never exist
203
        // must be stored verbatim rather than validated/normalized/truncated.
204
        for path in [
205
            "",
206
            "   ",
207
            "\u{0}embedded-nul",
208
            "\u{1F600}\u{0301}",
209
            "relative/../../../etc/passwd",
210
        ] {
211
            let file = with_path(path);
212
            assert_eq!(file.path.as_str(), path, "path must be stored verbatim");
213
            assert!(file.run_destructor);
214
        }
215

            
216
        let huge = "x".repeat(100_000);
217
        assert_eq!(with_path(&huge).path.as_str().len(), 100_000);
218
    }
219

            
220
    // --------------------------------------------------------------- File::open
221

            
222
    #[test]
223
    fn open_empty_path_returns_none() {
224
        assert!(File::open("").is_none());
225
    }
226

            
227
    #[test]
228
    fn open_whitespace_only_path_is_not_trimmed_into_something_valid() {
229
        for path in ["   ", "\t\n", " ", "\r\n\t "] {
230
            assert!(
231
                File::open(path).is_none(),
232
                "whitespace-only path {path:?} must not open anything"
233
            );
234
        }
235
    }
236

            
237
    #[test]
238
    fn open_missing_path_returns_none() {
239
        let temp = TempPath::new("never_created");
240
        assert!(!temp.exists());
241
        assert!(File::open(temp.as_str()).is_none());
242
    }
243

            
244
    #[test]
245
    fn open_garbage_path_returns_none_without_panicking() {
246
        // Interior NUL cannot be turned into a CString -> must surface as None, not a panic.
247
        for path in [
248
            "\u{0}",
249
            "abc\u{0}def",
250
            // NOTE: "//////" is deliberately NOT here -- POSIX collapses a run of
251
            // slashes to "/", and opening the root directory read-only legitimately
252
            // succeeds. It is not garbage.
253
            "::**?<>|\"",
254
            "\u{FFFD}\u{202E}\u{200B}",
255
        ] {
256
            assert!(
257
                File::open(path).is_none(),
258
                "garbage path {path:?} must return None"
259
            );
260
        }
261
    }
262

            
263
    #[test]
264
    fn open_boundary_number_paths_return_none() {
265
        for name in [
266
            "0",
267
            "-0",
268
            "9223372036854775807",
269
            "-9223372036854775808",
270
            "18446744073709551616",
271
            "NaN",
272
            "inf",
273
            "-inf",
274
            "1e400",
275
            "0.0000000000000000000001",
276
        ] {
277
            let path = std::env::temp_dir().join(format!("azul_autotest_missing_{name}"));
278
            let path = path.to_str().expect("utf-8 temp dir");
279
            assert!(
280
                File::open(path).is_none(),
281
                "numeric-looking missing path {path:?} must return None"
282
            );
283
        }
284
    }
285

            
286
    #[test]
287
    fn open_extremely_long_path_returns_none_and_terminates() {
288
        let path = "a".repeat(1_000_000);
289
        assert!(
290
            File::open(&path).is_none(),
291
            "a 1M-char path is over every OS limit and must fail cleanly"
292
        );
293
    }
294

            
295
    #[test]
296
    fn open_deeply_nested_path_does_not_stack_overflow() {
297
        // 10_000 nested segments -- exercises the "recursive input" case for a path parser.
298
        let mut path = String::with_capacity(20_002);
299
        for _ in 0..10_000 {
300
            path.push_str("a/");
301
        }
302
        path.push('x');
303
        assert!(File::open(&path).is_none());
304
    }
305

            
306
    #[test]
307
    fn open_directory_never_yields_readable_content() {
308
        // On unix, open(2) on a directory succeeds, so `File::open` hands back a `File`
309
        // whose handle is a directory. Reading through it must still fail cleanly.
310
        let dir = std::env::temp_dir();
311
        let dir = dir.to_str().expect("utf-8 temp dir");
312
        if let Some(mut file) = File::open(dir) {
313
            assert_eq!(file.path.as_str(), dir);
314
            assert!(
315
                file.read_to_string().is_none(),
316
                "a directory must not read back as a string"
317
            );
318
            assert!(
319
                file.read_to_bytes().is_none(),
320
                "a directory must not read back as bytes"
321
            );
322
        }
323
    }
324

            
325
    #[test]
326
    fn open_is_read_only_so_writes_fail_and_leave_content_intact() {
327
        let temp = TempPath::new("readonly");
328
        {
329
            let mut file = File::create(temp.as_str()).expect("create");
330
            assert!(file.write_string("original"));
331
        }
332

            
333
        let mut file = File::open(temp.as_str()).expect("open existing file");
334
        assert!(
335
            !file.write_string("clobbered"),
336
            "writing through a read-only handle must return false, not panic"
337
        );
338
        assert!(!file.write_bytes(b"clobbered"));
339
        assert_eq!(
340
            file.read_to_string().expect("read back").as_str(),
341
            "original",
342
            "a failed write must not have corrupted the file"
343
        );
344
    }
345

            
346
    // ------------------------------------------------------------- File::create
347

            
348
    #[test]
349
    fn create_empty_path_returns_none() {
350
        assert!(File::create("").is_none());
351
    }
352

            
353
    #[test]
354
    fn create_in_missing_directory_returns_none() {
355
        let dir = unique_temp_path("missing_dir");
356
        let path = dir.join("child.txt");
357
        let path = path.to_str().expect("utf-8 temp dir");
358
        assert!(File::create(path).is_none());
359
    }
360

            
361
    #[test]
362
    fn create_over_a_directory_returns_none() {
363
        let dir = std::env::temp_dir();
364
        let dir = dir.to_str().expect("utf-8 temp dir");
365
        assert!(
366
            File::create(dir).is_none(),
367
            "create() must refuse to truncate a directory"
368
        );
369
    }
370

            
371
    #[test]
372
    fn create_garbage_path_returns_none_without_panicking() {
373
        let over_long = "z".repeat(1_000_000);
374
        for path in ["\u{0}", "bad\u{0}name", "", over_long.as_str()] {
375
            assert!(
376
                File::create(path).is_none(),
377
                "create() of a {}-char garbage path must return None",
378
                path.len()
379
            );
380
        }
381
    }
382

            
383
    #[test]
384
    fn create_truncates_existing_content() {
385
        let temp = TempPath::new("truncate");
386
        {
387
            let mut file = File::create(temp.as_str()).expect("create");
388
            assert!(file.write_string("a fairly long pre-existing payload"));
389
        }
390

            
391
        let mut file = File::create(temp.as_str()).expect("re-create");
392
        assert_eq!(
393
            file.read_to_string().expect("read back").as_str(),
394
            "",
395
            "create() must truncate an existing file to zero bytes"
396
        );
397
    }
398

            
399
    // ------------------------------------------------------- write/read round-trips
400

            
401
    #[test]
402
    fn round_trip_representative_string() {
403
        let temp = TempPath::new("round_trip");
404
        let mut file = File::create(temp.as_str()).expect("create");
405
        assert!(file.write_string("hello world"));
406
        assert_eq!(file.read_to_string().expect("read").as_str(), "hello world");
407
        assert_eq!(file.read_to_bytes().expect("read").as_slice(), b"hello world");
408

            
409
        // ...and the same content survives a close + reopen cycle.
410
        file.close();
411
        let mut reopened = File::open(temp.as_str()).expect("reopen");
412
        assert_eq!(reopened.read_to_string().expect("read").as_str(), "hello world");
413
    }
414

            
415
    #[test]
416
    fn round_trip_empty_write_produces_an_empty_file() {
417
        let temp = TempPath::new("empty_write");
418
        let mut file = File::create(temp.as_str()).expect("create");
419

            
420
        assert!(file.write_bytes(&[]), "writing zero bytes must still succeed");
421
        assert!(file.write_string(""));
422
        assert_eq!(file.read_to_string().expect("read").as_str(), "");
423
        assert_eq!(file.read_to_bytes().expect("read").len(), 0);
424
    }
425

            
426
    #[test]
427
    fn round_trip_unicode_content() {
428
        let content = "\u{1F600} héllo e\u{0301} \u{202E}rtl\u{202C} \u{0}nul \r\n mixed";
429
        let temp = TempPath::new("unicode_content");
430
        let mut file = File::create(temp.as_str()).expect("create");
431

            
432
        assert!(file.write_string(content));
433
        assert_eq!(file.read_to_string().expect("read").as_str(), content);
434
        assert_eq!(
435
            file.read_to_bytes().expect("read").as_slice(),
436
            content.as_bytes(),
437
            "byte round-trip must be exact for multibyte content"
438
        );
439
    }
440

            
441
    #[test]
442
    fn round_trip_unicode_path() {
443
        let temp = TempPath::new("p\u{1F600}_h\u{0301}ll\u{00F6}");
444
        let mut file = File::create(temp.as_str()).expect("create with unicode path");
445

            
446
        assert_eq!(file.path.as_str(), temp.as_str());
447
        assert!(file.write_string("ok"));
448
        assert_eq!(file.read_to_string().expect("read").as_str(), "ok");
449

            
450
        let reopened = File::open(temp.as_str()).expect("reopen unicode path");
451
        assert_eq!(reopened.path.as_str(), temp.as_str());
452
        assert_eq!(reopened, file, "equality is path-based, so these must match");
453
    }
454

            
455
    #[test]
456
    fn round_trip_all_256_byte_values_and_invalid_utf8_reads_as_none() {
457
        let bytes: Vec<u8> = (0..=255u8).collect();
458
        let temp = TempPath::new("all_bytes");
459
        let mut file = File::create(temp.as_str()).expect("create");
460

            
461
        assert!(file.write_bytes(&bytes));
462
        assert_eq!(
463
            file.read_to_bytes().expect("read").as_slice(),
464
            bytes.as_slice(),
465
            "every byte value must survive the round-trip untouched"
466
        );
467
        assert!(
468
            file.read_to_string().is_none(),
469
            "non-UTF-8 content must return None, never a lossy string or a panic"
470
        );
471
    }
472

            
473
    #[test]
474
    fn round_trip_one_mib_payload() {
475
        let bytes = vec![0xABu8; 1024 * 1024];
476
        let temp = TempPath::new("one_mib");
477
        let mut file = File::create(temp.as_str()).expect("create");
478

            
479
        assert!(file.write_bytes(&bytes));
480
        let read = file.read_to_bytes().expect("read");
481
        assert_eq!(read.len(), bytes.len());
482
        assert_eq!(read.as_slice(), bytes.as_slice());
483
    }
484

            
485
    #[test]
486
    fn writes_append_at_the_handle_cursor_rather_than_overwriting() {
487
        let temp = TempPath::new("append_cursor");
488
        let mut file = File::create(temp.as_str()).expect("create");
489

            
490
        assert!(file.write_string("a"));
491
        assert!(file.write_string("bc"));
492
        assert!(file.write_bytes(b"d"));
493
        assert_eq!(
494
            file.read_to_string().expect("read").as_str(),
495
            "abcd",
496
            "consecutive writes advance the cursor; none of them rewind"
497
        );
498
    }
499

            
500
    #[test]
501
    fn reads_follow_the_path_not_the_handle() {
502
        // The seam: `read_*` re-opens `self.path` from scratch, so out-of-band changes
503
        // to the path are visible and the handle's cursor position is irrelevant.
504
        let temp = TempPath::new("path_vs_handle");
505
        let mut file = File::create(temp.as_str()).expect("create");
506
        assert!(file.write_string("written through the handle"));
507

            
508
        fs::write(temp.as_str(), "replaced out of band").expect("out-of-band write");
509
        assert_eq!(
510
            file.read_to_string().expect("read").as_str(),
511
            "replaced out of band"
512
        );
513
    }
514

            
515
    #[test]
516
    fn reads_return_none_after_the_path_is_deleted_under_an_open_handle() {
517
        let temp = TempPath::new("deleted_under_handle");
518
        let mut file = File::create(temp.as_str()).expect("create");
519
        assert!(file.write_string("content"));
520

            
521
        fs::remove_file(temp.as_str()).expect("unlink");
522
        assert!(
523
            file.read_to_string().is_none(),
524
            "reads go through the path, which is now gone"
525
        );
526
        assert!(file.read_to_bytes().is_none());
527
        // The handle itself is still alive, so writes to it must still succeed.
528
        assert!(
529
            file.write_string("still writable"),
530
            "an unlinked-but-open handle is still writable on unix"
531
        );
532
    }
533

            
534
    #[test]
535
    fn read_and_write_on_a_file_whose_path_never_existed() {
536
        // `File::new` is happy to pair a live handle with a bogus path. Reads must then
537
        // fail cleanly while writes (which use the handle) still work.
538
        let mut file = with_path("/this/path/does/not/exist\u{1F600}");
539
        assert!(file.read_to_string().is_none());
540
        assert!(file.read_to_bytes().is_none());
541
        assert!(file.write_string("goes to the detached handle"));
542

            
543
        let mut empty_path = with_path("");
544
        assert!(empty_path.read_to_string().is_none());
545
        assert!(empty_path.read_to_bytes().is_none());
546
    }
547

            
548
    #[test]
549
    fn write_bytes_returns_false_on_a_poisoned_mutex() {
550
        let temp = TempPath::new("poisoned");
551
        let mut file = File::create(temp.as_str()).expect("create");
552

            
553
        let arc = (*file.ptr).clone();
554
        let joined = std::thread::spawn(move || {
555
            let _guard = arc.lock().expect("first lock cannot be poisoned");
556
            panic!("intentional panic to poison the file mutex");
557
        })
558
        .join();
559
        assert!(joined.is_err(), "the helper thread must have panicked");
560

            
561
        assert!(
562
            !file.write_bytes(b"nope"),
563
            "a poisoned mutex must surface as `false`, not as an unwrap panic"
564
        );
565
        assert!(!file.write_string("nope"));
566
    }
567

            
568
    // --------------------------------------------------- Clone / Drop / close
569

            
570
    #[test]
571
    fn clone_shares_the_handle_and_the_path() {
572
        let temp = TempPath::new("clone_shares");
573
        let mut file = File::create(temp.as_str()).expect("create");
574
        let mut clone = file.clone();
575

            
576
        assert_eq!(Arc::strong_count(&file.ptr), 2, "clone must share the Arc");
577
        assert!(clone.run_destructor);
578
        assert_eq!(clone, file);
579

            
580
        // Both halves write into the same cursor, so the writes interleave in order.
581
        assert!(file.write_string("a"));
582
        assert!(clone.write_string("b"));
583
        assert!(file.write_string("c"));
584
        assert_eq!(file.read_to_string().expect("read").as_str(), "abc");
585
    }
586

            
587
    #[test]
588
    fn close_drops_only_one_owner_and_leaves_the_content_on_disk() {
589
        let temp = TempPath::new("close");
590
        let mut file = File::create(temp.as_str()).expect("create");
591
        assert!(file.write_string("persisted"));
592

            
593
        let clone = file.clone();
594
        clone.close();
595
        assert_eq!(
596
            Arc::strong_count(&file.ptr),
597
            1,
598
            "closing a clone must release exactly one Arc reference"
599
        );
600

            
601
        // The surviving handle still works, and the bytes are still on disk.
602
        assert!(file.write_string("!"));
603
        assert_eq!(file.read_to_string().expect("read").as_str(), "persisted!");
604

            
605
        file.close();
606
        let mut reopened = File::open(temp.as_str()).expect("reopen after close");
607
        assert_eq!(reopened.read_to_string().expect("read").as_str(), "persisted!");
608
    }
609

            
610
    #[test]
611
    fn close_on_an_extreme_file_does_not_panic() {
612
        with_path("").close();
613
        with_path(&"q".repeat(100_000)).close();
614
        with_path("\u{1F600}\u{0}\u{202E}").close();
615
    }
616

            
617
    // ------------------------------------------------- Debug / Display / Eq / Ord
618

            
619
    #[test]
620
    fn debug_and_display_render_exactly_the_path() {
621
        // Includes format-specifier-looking payloads: these must be printed literally,
622
        // never interpreted, and never truncated.
623
        for path in [
624
            "/tmp/plain.txt",
625
            "",
626
            "{}",
627
            "{0} {:?} %s %n",
628
            "\u{1F600} h\u{00E9}llo e\u{0301}",
629
            "line\nbreak\ttab",
630
        ] {
631
            let file = with_path(path);
632
            assert_eq!(format!("{file}"), path, "Display must echo the path verbatim");
633
            assert_eq!(format!("{file:?}"), path, "Debug must echo the path verbatim");
634
            assert_eq!(
635
                format!("{file}"),
636
                format!("{file:?}"),
637
                "Debug and Display must agree"
638
            );
639
        }
640
    }
641

            
642
    #[test]
643
    fn display_is_non_empty_for_a_real_file_and_idempotent() {
644
        let temp = TempPath::new("display_stable");
645
        let file = File::create(temp.as_str()).expect("create");
646

            
647
        let once = format!("{file}");
648
        assert!(!once.is_empty());
649
        assert_eq!(once, temp.as_str());
650
        // Re-rendering the re-parsed value is stable (serialize . parse . serialize == serialize).
651
        let reopened = File::open(&once).expect("the rendered path must reopen the same file");
652
        assert_eq!(format!("{reopened}"), once);
653
        assert_eq!(reopened, file);
654
    }
655

            
656
    #[test]
657
    fn display_of_a_huge_path_is_not_truncated() {
658
        let path = "w".repeat(300_000);
659
        let file = with_path(&path);
660
        assert_eq!(format!("{file}").len(), 300_000);
661
    }
662

            
663
    #[test]
664
    fn eq_and_ord_are_purely_path_based() {
665
        let a1 = with_path("a");
666
        let a2 = with_path("a"); // different handle, same path
667
        let b = with_path("b");
668

            
669
        assert_eq!(a1, a2, "equality ignores the handle entirely");
670
        assert_ne!(a1, b);
671
        assert_eq!(a1.partial_cmp(&a2), Some(core::cmp::Ordering::Equal));
672
        assert_eq!(a1.partial_cmp(&b), Some(core::cmp::Ordering::Less));
673
        assert_eq!(b.partial_cmp(&a1), Some(core::cmp::Ordering::Greater));
674
        assert!(a1 < b);
675

            
676
        // Ordering must track `str` ordering (byte-wise), including for multibyte paths.
677
        let empty = with_path("");
678
        let emoji = with_path("\u{1F600}");
679
        assert_eq!(empty.partial_cmp(&emoji), "".partial_cmp("\u{1F600}"));
680
        assert_eq!(emoji.partial_cmp(&empty), "\u{1F600}".partial_cmp(""));
681
        assert_eq!(emoji.partial_cmp(&emoji.clone()), Some(core::cmp::Ordering::Equal));
682

            
683
        // Reflexive / symmetric on a clone (which shares the handle).
684
        let cloned = a1.clone();
685
        assert_eq!(a1, cloned);
686
        assert_eq!(cloned, a1);
687
    }
688

            
689
    #[test]
690
    fn option_file_round_trips_through_the_ffi_option() {
691
        assert!(matches!(OptionFile::default(), OptionFile::None));
692
        assert!(OptionFile::default().as_option().is_none());
693

            
694
        let none: OptionFile = Option::<File>::None.into();
695
        assert!(Option::<File>::from(none).is_none());
696

            
697
        let file = with_path("\u{1F600}/round/trip");
698
        let ffi: OptionFile = Some(file).into();
699
        assert_eq!(
700
            ffi.as_option().expect("Some").path.as_str(),
701
            "\u{1F600}/round/trip"
702
        );
703

            
704
        let back = Option::<File>::from(ffi).expect("Some survives the round-trip");
705
        assert_eq!(back.path.as_str(), "\u{1F600}/round/trip");
706
        assert!(back.run_destructor);
707
    }
708
}