Skip to main content

mount_fstab/
types.rs

1//! Core fstab types: `Entry`, `Fstab`, `MountPoint`, `EntryBuilder`.
2//!
3//! These are the primary data structures used to represent an `/etc/fstab`
4//! file and its individual entries.
5
6use crate::error::{EntryBuilderError, MountPointError};
7use crate::fstype::FsType;
8use crate::options::Options;
9use crate::spec::Spec;
10use std::fmt;
11use std::path::{Path, PathBuf};
12
13/// Mount point — fstab(5) field 2.
14///
15/// An absolute path or the special value `none` for swap entries.
16///
17/// # Examples
18///
19/// ```
20/// # use mount_fstab::types::MountPoint;
21/// let mp = MountPoint::new("/").unwrap();
22/// assert!(mp.is_root());
23/// ```
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub struct MountPoint(PathBuf);
27
28impl MountPoint {
29    /// Create a mount point from a path.
30    ///
31    /// The path must be absolute (start with `/`) or be the special value
32    /// `none`.
33    ///
34    /// # Errors
35    ///
36    /// Returns [`MountPointError::Empty`] if the path is empty,
37    /// [`MountPointError::NotAbsolute`] if the path is relative.
38    pub fn new(path: impl Into<PathBuf>) -> Result<Self, MountPointError> {
39        let path = path.into();
40        let s = path.to_string_lossy();
41        if s.is_empty() {
42            return Err(MountPointError::Empty);
43        }
44        if s == "none" || s.starts_with('/') {
45            Ok(MountPoint(path))
46        } else {
47            Err(MountPointError::NotAbsolute)
48        }
49    }
50
51    /// Create the special `none` mount point for swap.
52    #[must_use]
53    pub fn swap() -> Self {
54        MountPoint(PathBuf::from("none"))
55    }
56
57    /// Whether this is a swap mount point (`none`).
58    #[must_use]
59    pub fn is_swap(&self) -> bool {
60        self.0.to_string_lossy() == "none"
61    }
62
63    /// Whether this is the root filesystem (`/`).
64    ///
65    /// Normalizes paths like `//` to `/` via `Path::components()`.
66    #[must_use]
67    pub fn is_root(&self) -> bool {
68        let normalized: PathBuf = self.0.components().collect();
69        normalized == Path::new("/")
70    }
71
72    /// View the mount point as a `Path`.
73    #[must_use]
74    pub fn as_path(&self) -> &Path {
75        &self.0
76    }
77}
78
79impl std::ops::Deref for MountPoint {
80    type Target = Path;
81
82    /// Dereference to the underlying `Path`.
83    ///
84    /// This allows `&MountPoint` to be used wherever `&Path` is expected.
85    fn deref(&self) -> &Self::Target {
86        &self.0
87    }
88}
89
90impl fmt::Display for MountPoint {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        write!(f, "{}", self.0.display())
93    }
94}
95
96impl AsRef<Path> for MountPoint {
97    fn as_ref(&self) -> &Path {
98        &self.0
99    }
100}
101
102/// Try to convert a string into a `MountPoint`.
103///
104/// Equivalent to [`MountPoint::new`].
105impl TryFrom<&str> for MountPoint {
106    type Error = MountPointError;
107
108    fn try_from(s: &str) -> Result<Self, Self::Error> {
109        MountPoint::new(s)
110    }
111}
112
113/// Try to convert a `String` into a `MountPoint`.
114///
115/// Equivalent to [`MountPoint::new`].
116impl TryFrom<String> for MountPoint {
117    type Error = MountPointError;
118
119    fn try_from(s: String) -> Result<Self, Self::Error> {
120        MountPoint::new(s)
121    }
122}
123
124/// A single fstab entry — corresponds to libmount `struct libmnt_fs`.
125///
126/// Each entry represents one line in `/etc/fstab`, consisting of six
127/// whitespace-separated fields plus an optional preceding comment block.
128///
129/// # Examples
130///
131/// ```
132/// # use mount_fstab::{Entry, Spec, MountPoint, FsType, Options};
133/// let entry = Entry {
134///     spec: Spec::parse("UUID=root").unwrap(),
135///     file: MountPoint::new("/").unwrap(),
136///     vfstype: FsType::parse("ext4").unwrap(),
137///     options: Options::parse("defaults").unwrap(),
138///     freq: 0,
139///     passno: 1,
140///     comment: None,
141/// };
142/// assert!(entry.is_root());
143/// ```
144#[derive(Debug, Clone, PartialEq, Eq, Hash)]
145#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
146pub struct Entry {
147    /// Filesystem source (device, UUID, label, etc.) — fstab(5) field 1.
148    pub spec: Spec,
149    /// Mount point path — fstab(5) field 2 (`fs_file`).
150    pub file: MountPoint,
151    /// Filesystem type — fstab(5) field 3 (`fs_vfstype`).
152    pub vfstype: FsType,
153    /// Mount options — fstab(5) field 4 (`fs_mntops`).
154    pub options: Options,
155    /// Dump frequency — fstab(5) field 5 (`fs_freq`). Default: 0.
156    pub freq: u32,
157    /// Filesystem check order — fstab(5) field 6 (`fs_passno`). Default: 0.
158    pub passno: u32,
159    /// Comment lines immediately above this entry.
160    pub comment: Option<String>,
161}
162
163impl Entry {
164    /// Create a new entry with the required fields.
165    ///
166    /// `freq` and `passno` default to 0, and `comment` defaults to `None`.
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// # use mount_fstab::{Entry, Spec, MountPoint, FsType, Options};
172    /// let entry = Entry::new(
173    ///     Spec::parse("UUID=root").unwrap(),
174    ///     MountPoint::new("/").unwrap(),
175    ///     FsType::parse("ext4").unwrap(),
176    ///     Options::parse("defaults").unwrap(),
177    /// );
178    /// assert!(entry.file.is_root());
179    /// assert_eq!(entry.freq, 0);
180    /// assert_eq!(entry.passno, 0);
181    /// assert!(entry.comment.is_none());
182    /// ```
183    #[must_use]
184    pub fn new(spec: Spec, file: MountPoint, vfstype: FsType, options: Options) -> Self {
185        Entry {
186            spec,
187            file,
188            vfstype,
189            options,
190            freq: 0,
191            passno: 0,
192            comment: None,
193        }
194    }
195
196    /// Create an [`EntryBuilder`] for ergonomic construction with optional fields.
197    ///
198    /// # Examples
199    ///
200    /// ```
201    /// # use mount_fstab::{Entry, Spec, MountPoint, FsType, Options};
202    /// let entry = Entry::builder()
203    ///     .spec(Spec::parse("UUID=root").unwrap())
204    ///     .file(MountPoint::new("/").unwrap())
205    ///     .vfstype(FsType::parse("ext4").unwrap())
206    ///     .options(Options::parse("defaults,noatime").unwrap())
207    ///     .freq(0)
208    ///     .passno(1)
209    ///     .comment("# Root filesystem")
210    ///     .build()
211    ///     .unwrap();
212    /// assert!(entry.is_root());
213    /// ```
214    #[must_use]
215    pub fn builder() -> EntryBuilder {
216        EntryBuilder::default()
217    }
218
219    /// Whether this is a swap entry.
220    #[must_use]
221    pub fn is_swap(&self) -> bool {
222        self.vfstype.is_swap()
223    }
224
225    /// Whether this is a bind mount.
226    #[must_use]
227    pub fn is_bind_mount(&self) -> bool {
228        self.vfstype.is_bind()
229    }
230
231    /// Whether this is the root filesystem entry.
232    ///
233    /// Returns `true` only when the mount point is `/` **and** `passno == 1`,
234    /// following the fstab(5) convention that the root filesystem must have
235    /// `fs_passno` set to 1. An entry at `/` with `passno != 1` is not
236    /// considered the root entry by this method.
237    #[must_use]
238    pub fn is_root(&self) -> bool {
239        self.file.is_root() && self.passno == 1
240    }
241
242    /// Whether this is a network filesystem.
243    #[must_use]
244    pub fn is_network(&self) -> bool {
245        self.vfstype.is_network() || self.options.is_netdev()
246    }
247
248    /// Get the comment lines before this entry, if any.
249    #[must_use]
250    pub fn comment(&self) -> Option<&str> {
251        self.comment.as_deref()
252    }
253}
254
255/// Builder for [`Entry`] with ergonomic construction of optional fields.
256///
257/// Created via [`Entry::builder()`]. All fields are optional except `spec`,
258/// `file`, and `vfstype`, which are required by [`build`](EntryBuilder::build).
259///
260/// # Examples
261///
262/// ```
263/// # use mount_fstab::{Entry, Spec, MountPoint, FsType, Options};
264/// let entry = Entry::builder()
265///     .spec(Spec::parse("/dev/sda1").unwrap())
266///     .file(MountPoint::new("/").unwrap())
267///     .vfstype(FsType::parse("ext4").unwrap())
268///     .comment("# Root")
269///     .build()
270///     .unwrap();
271/// ```
272#[derive(Debug, Clone, Default)]
273#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
274pub struct EntryBuilder {
275    spec: Option<Spec>,
276    file: Option<MountPoint>,
277    vfstype: Option<FsType>,
278    options: Options,
279    freq: u32,
280    passno: u32,
281    comment: Option<String>,
282}
283
284impl EntryBuilder {
285    /// Set the filesystem spec (required).
286    pub fn spec(mut self, spec: Spec) -> Self {
287        self.spec = Some(spec);
288        self
289    }
290    /// Set the mount point (required).
291    pub fn file(mut self, file: MountPoint) -> Self {
292        self.file = Some(file);
293        self
294    }
295    /// Set the filesystem type (required).
296    pub fn vfstype(mut self, vfstype: FsType) -> Self {
297        self.vfstype = Some(vfstype);
298        self
299    }
300    /// Set the mount options (default: empty).
301    pub fn options(mut self, options: Options) -> Self {
302        self.options = options;
303        self
304    }
305    /// Set the dump frequency (default: 0).
306    pub fn freq(mut self, freq: u32) -> Self {
307        self.freq = freq;
308        self
309    }
310    /// Set the filesystem check order (default: 0).
311    pub fn passno(mut self, passno: u32) -> Self {
312        self.passno = passno;
313        self
314    }
315    /// Set the comment preceding this entry.
316    pub fn comment(mut self, comment: impl Into<String>) -> Self {
317        self.comment = Some(comment.into());
318        self
319    }
320
321    /// Build the [`Entry`], consuming the builder.
322    ///
323    /// # Errors
324    ///
325    /// Returns [`EntryBuilderError`] if any required fields (`spec`, `file`,
326    /// `vfstype`) are missing.
327    pub fn build(self) -> Result<Entry, EntryBuilderError> {
328        Ok(Entry {
329            spec: self.spec.ok_or(EntryBuilderError::MissingSpec)?,
330            file: self.file.ok_or(EntryBuilderError::MissingFile)?,
331            vfstype: self.vfstype.ok_or(EntryBuilderError::MissingFsType)?,
332            options: self.options,
333            freq: self.freq,
334            passno: self.passno,
335            comment: self.comment,
336        })
337    }
338}
339
340/// A complete `/etc/fstab` file representation.
341///
342/// Corresponds to libmount `struct libmnt_table`. Contains the file's
343/// entries along with any leading (intro) and trailing comments.
344///
345/// # Examples
346///
347/// ```
348/// # use mount_fstab::Fstab;
349/// let input = "UUID=root / ext4 defaults 0 1\n";
350/// let fstab = Fstab::parse_str(input).unwrap();
351/// assert_eq!(fstab.len(), 1);
352/// ```
353#[derive(Debug, Clone, Default, PartialEq, Eq)]
354#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
355pub struct Fstab {
356    /// Comment block at the beginning of the file (before the first entry).
357    pub intro_comment: Option<String>,
358    /// All entries, in original file order.
359    pub entries: Vec<Entry>,
360    /// Comment block at the end of the file (after the last entry).
361    pub trailing_comment: Option<String>,
362}
363
364impl Fstab {
365    /// Create an empty fstab.
366    #[must_use]
367    pub fn new() -> Self {
368        Fstab::default()
369    }
370
371    /// Number of entries.
372    #[must_use]
373    pub fn len(&self) -> usize {
374        self.entries.len()
375    }
376
377    /// Whether the fstab has no entries.
378    #[must_use]
379    pub fn is_empty(&self) -> bool {
380        self.entries.is_empty()
381    }
382
383    /// Return a slice of all entries.
384    #[must_use]
385    pub fn entries(&self) -> &[Entry] {
386        &self.entries
387    }
388
389    /// Create an [`Fstab`] from a vector of entries.
390    ///
391    /// # Examples
392    ///
393    /// ```
394    /// # use mount_fstab::{Entry, Fstab, Spec, MountPoint, FsType, Options};
395    /// let entry = Entry::new(
396    ///     Spec::parse("/dev/sda1").unwrap(),
397    ///     MountPoint::new("/").unwrap(),
398    ///     FsType::parse("ext4").unwrap(),
399    ///     Options::defaults(),
400    /// );
401    /// let fstab = Fstab::from_entries(vec![entry]);
402    /// assert_eq!(fstab.len(), 1);
403    /// ```
404    #[must_use]
405    pub fn from_entries(entries: Vec<Entry>) -> Self {
406        Fstab {
407            entries,
408            intro_comment: None,
409            trailing_comment: None,
410        }
411    }
412
413    /// Consume the `Fstab` and return its entries.
414    ///
415    /// # Examples
416    ///
417    /// ```
418    /// # use mount_fstab::{Entry, Fstab, Spec, MountPoint, FsType, Options};
419    /// let mut fstab = Fstab::new();
420    /// fstab.add(Entry::new(
421    ///     Spec::parse("/dev/sda1").unwrap(),
422    ///     MountPoint::new("/").unwrap(),
423    ///     FsType::parse("ext4").unwrap(),
424    ///     Options::defaults(),
425    /// ));
426    /// let entries = fstab.into_entries();
427    /// assert_eq!(entries.len(), 1);
428    /// ```
429    #[must_use]
430    pub fn into_entries(self) -> Vec<Entry> {
431        self.entries
432    }
433
434    /// Add an entry to the end.
435    ///
436    /// Returns `&mut Self` for chaining.
437    ///
438    /// # Examples
439    ///
440    /// ```
441    /// # use mount_fstab::{Entry, Fstab, Spec, MountPoint, FsType, Options};
442    /// let mut fstab = Fstab::new();
443    /// let entry = Entry::new(
444    ///     Spec::parse("UUID=root").unwrap(),
445    ///     MountPoint::new("/").unwrap(),
446    ///     FsType::parse("ext4").unwrap(),
447    ///     Options::defaults(),
448    /// );
449    /// fstab.add(entry);
450    /// assert_eq!(fstab.len(), 1);
451    /// ```
452    pub fn add(&mut self, entry: Entry) -> &mut Self {
453        self.entries.push(entry);
454        self
455    }
456
457    /// Insert an entry at the given position.
458    ///
459    /// Returns `&mut Self` for chaining.
460    ///
461    /// # Errors
462    ///
463    /// Returns [`FstabError`](crate::error::FstabError) as
464    /// `IndexOutOfBounds(index, len)` if `index > len`.
465    pub fn insert(
466        &mut self,
467        index: usize,
468        entry: Entry,
469    ) -> Result<&mut Self, crate::error::FstabError> {
470        if index > self.entries.len() {
471            return Err(crate::error::FstabError::IndexOutOfBounds(
472                index,
473                self.entries.len(),
474            ));
475        }
476        self.entries.insert(index, entry);
477        Ok(self)
478    }
479
480    /// Remove an entry at the given position, returning it.
481    ///
482    /// # Examples
483    ///
484    /// ```
485    /// # use mount_fstab::{Entry, Fstab, Spec, MountPoint, FsType, Options};
486    /// let mut fstab = Fstab::new();
487    /// fstab.add(Entry::new(
488    ///     Spec::parse("UUID=root").unwrap(),
489    ///     MountPoint::new("/").unwrap(),
490    ///     FsType::parse("ext4").unwrap(),
491    ///     Options::defaults(),
492    /// ));
493    /// let removed = fstab.remove(0);
494    /// assert!(removed.is_some());
495    /// assert!(fstab.is_empty());
496    /// ```
497    pub fn remove(&mut self, index: usize) -> Option<Entry> {
498        if index < self.entries.len() {
499            Some(self.entries.remove(index))
500        } else {
501            None
502        }
503    }
504
505    /// Replace an entry at the given position, returning the old entry.
506    pub fn replace(&mut self, index: usize, entry: Entry) -> Option<Entry> {
507        if index < self.entries.len() {
508            Some(std::mem::replace(&mut self.entries[index], entry))
509        } else {
510            None
511        }
512    }
513
514    /// Remove all entries and comments.
515    pub fn clear(&mut self) {
516        self.entries.clear();
517        self.intro_comment = None;
518        self.trailing_comment = None;
519    }
520
521    /// Find entries by source string match (substring match on spec).
522    #[must_use]
523    pub fn find_by_source(&self, source: &str) -> Vec<&Entry> {
524        self.entries
525            .iter()
526            .filter(|e| e.spec.to_string().contains(source))
527            .collect()
528    }
529
530    /// Find an entry by mount point.
531    ///
532    /// # Examples
533    ///
534    /// ```
535    /// # use mount_fstab::{Entry, Fstab, Spec, MountPoint, FsType, Options};
536    /// # use std::path::Path;
537    /// let mut fstab = Fstab::new();
538    /// fstab.add(Entry::new(
539    ///     Spec::parse("UUID=root").unwrap(),
540    ///     MountPoint::new("/").unwrap(),
541    ///     FsType::parse("ext4").unwrap(),
542    ///     Options::defaults(),
543    /// ));
544    /// assert!(fstab.find_by_mountpoint(Path::new("/")).is_some());
545    /// assert!(fstab.find_by_mountpoint(Path::new("/nonexistent")).is_none());
546    /// ```
547    #[must_use]
548    pub fn find_by_mountpoint(&self, mp: &Path) -> Option<&Entry> {
549        self.entries.iter().find(|e| e.file.as_path() == mp)
550    }
551
552    /// Get the root filesystem entry (passno == 1 at `/`).
553    #[must_use]
554    pub fn root(&self) -> Option<&Entry> {
555        self.entries
556            .iter()
557            .find(|e| e.passno == 1 && e.file.is_root())
558    }
559}
560
561impl IntoIterator for Fstab {
562    type Item = Entry;
563    type IntoIter = std::vec::IntoIter<Entry>;
564
565    /// Consume the `Fstab` and iterate over its entries.
566    fn into_iter(self) -> Self::IntoIter {
567        self.entries.into_iter()
568    }
569}
570
571impl<'a> IntoIterator for &'a Fstab {
572    type Item = &'a Entry;
573    type IntoIter = std::slice::Iter<'a, Entry>;
574
575    /// Iterate over references to entries.
576    fn into_iter(self) -> Self::IntoIter {
577        self.entries.iter()
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use crate::error::EntryBuilderError;
585    use crate::fstype::FsType;
586    use crate::options::Options;
587    use crate::spec::Spec;
588    use std::path::Path;
589
590    // ── MountPoint tests ──
591
592    #[test]
593    fn mount_point_new_absolute_path() {
594        let mp = MountPoint::new("/mnt/data").unwrap();
595        assert_eq!(mp.as_path(), Path::new("/mnt/data"));
596        assert!(!mp.is_swap());
597        assert!(!mp.is_root());
598    }
599
600    #[test]
601    fn mount_point_new_root() {
602        let mp = MountPoint::new("/").unwrap();
603        assert!(mp.is_root());
604        assert!(!mp.is_swap());
605    }
606
607    #[test]
608    fn mount_point_new_none() {
609        let mp = MountPoint::new("none").unwrap();
610        assert!(mp.is_swap());
611        assert!(!mp.is_root());
612    }
613
614    #[test]
615    fn mount_point_new_empty() {
616        let err = MountPoint::new("").unwrap_err();
617        assert_eq!(err, MountPointError::Empty);
618    }
619
620    #[test]
621    fn mount_point_new_not_absolute() {
622        let err = MountPoint::new("relative/path").unwrap_err();
623        assert_eq!(err, MountPointError::NotAbsolute);
624    }
625
626    #[test]
627    fn mount_point_swap_constructor() {
628        let mp = MountPoint::swap();
629        assert!(mp.is_swap());
630        assert_eq!(mp.as_path(), Path::new("none"));
631    }
632
633    #[test]
634    fn mount_point_deref() {
635        let mp = MountPoint::new("/etc").unwrap();
636        let path: &Path = &*mp;
637        assert_eq!(path, Path::new("/etc"));
638        // Can call Path methods directly
639        assert!(mp.is_absolute());
640        assert!(mp.parent() == Some(Path::new("/")));
641    }
642
643    // ── Entry tests ──
644
645    #[test]
646    fn entry_new_defaults() {
647        let spec = Spec::Device("/dev/sda1".into());
648        let file = MountPoint::new("/").unwrap();
649        let fstype = FsType::new("ext4").unwrap();
650        let opts = Options::defaults();
651        let entry = Entry::new(spec.clone(), file.clone(), fstype.clone(), opts.clone());
652
653        assert_eq!(entry.spec, spec);
654        assert_eq!(entry.file, file);
655        assert_eq!(entry.vfstype, fstype);
656        assert_eq!(entry.options, opts);
657        assert_eq!(entry.freq, 0);
658        assert_eq!(entry.passno, 0);
659        assert_eq!(entry.comment, None);
660    }
661
662    #[test]
663    fn entry_is_swap() {
664        let entry = Entry {
665            spec: Spec::Keyword("none".into()),
666            file: MountPoint::new("none").unwrap(),
667            vfstype: FsType::swap(),
668            options: Options::defaults(),
669            freq: 0,
670            passno: 0,
671            comment: None,
672        };
673        assert!(entry.is_swap());
674    }
675
676    #[test]
677    fn entry_is_bind_mount() {
678        let entry = Entry {
679            spec: Spec::Device("/dev/sda1".into()),
680            file: MountPoint::new("/mnt/bind").unwrap(),
681            vfstype: FsType::bind(),
682            options: Options::defaults(),
683            freq: 0,
684            passno: 0,
685            comment: None,
686        };
687        assert!(entry.is_bind_mount());
688    }
689
690    #[test]
691    fn entry_is_root() {
692        let entry = Entry {
693            spec: Spec::Device("/dev/sda1".into()),
694            file: MountPoint::new("/").unwrap(),
695            vfstype: FsType::new("ext4").unwrap(),
696            options: Options::defaults(),
697            freq: 0,
698            passno: 1,
699            comment: None,
700        };
701        assert!(entry.is_root());
702    }
703
704    #[test]
705    fn entry_is_root_requires_passno() {
706        let entry = Entry {
707            spec: Spec::Device("/dev/sda1".into()),
708            file: MountPoint::new("/").unwrap(),
709            vfstype: FsType::new("ext4").unwrap(),
710            options: Options::defaults(),
711            freq: 0,
712            passno: 0,
713            comment: None,
714        };
715        assert!(!entry.is_root());
716    }
717
718    #[test]
719    fn entry_is_network_by_fstype() {
720        let entry = Entry {
721            spec: Spec::NetworkMount {
722                host: "server".into(),
723                path: "/export".into(),
724            },
725            file: MountPoint::new("/mnt/nfs").unwrap(),
726            vfstype: FsType::new("nfs").unwrap(),
727            options: Options::new(),
728            freq: 0,
729            passno: 0,
730            comment: None,
731        };
732        assert!(entry.is_network());
733    }
734
735    #[test]
736    fn entry_is_network_by_netdev_option() {
737        let entry = Entry {
738            spec: Spec::Device("/dev/sda1".into()),
739            file: MountPoint::new("/mnt/data").unwrap(),
740            vfstype: FsType::new("ext4").unwrap(),
741            options: Options::parse("_netdev").unwrap(),
742            freq: 0,
743            passno: 0,
744            comment: None,
745        };
746        assert!(entry.is_network());
747    }
748
749    #[test]
750    fn entry_builder_minimal() {
751        let entry = Entry::builder()
752            .spec(Spec::parse("/dev/sda1").unwrap())
753            .file(MountPoint::new("/").unwrap())
754            .vfstype(FsType::parse("ext4").unwrap())
755            .build()
756            .unwrap();
757        assert_eq!(entry.spec, Spec::Device("/dev/sda1".into()));
758        assert!(entry.file.is_root());
759        assert_eq!(entry.vfstype.as_str(), "ext4");
760        assert!(entry.options.is_empty());
761        assert_eq!(entry.freq, 0);
762        assert_eq!(entry.passno, 0);
763    }
764
765    #[test]
766    fn entry_builder_all_fields() {
767        let entry = Entry::builder()
768            .spec(Spec::parse("UUID=root").unwrap())
769            .file(MountPoint::new("/").unwrap())
770            .vfstype(FsType::parse("ext4").unwrap())
771            .options(Options::parse("defaults,noatime").unwrap())
772            .freq(0)
773            .passno(1)
774            .comment("# Root filesystem")
775            .build()
776            .unwrap();
777        assert_eq!(entry.spec, Spec::Uuid("root".into()));
778        assert!(entry.file.is_root());
779        assert_eq!(entry.passno, 1);
780        assert_eq!(entry.comment, Some("# Root filesystem".into()));
781    }
782
783    #[test]
784    fn entry_builder_missing_spec() {
785        let err = Entry::builder()
786            .file(MountPoint::new("/").unwrap())
787            .vfstype(FsType::parse("ext4").unwrap())
788            .build()
789            .unwrap_err();
790        assert_eq!(err, EntryBuilderError::MissingSpec);
791    }
792
793    #[test]
794    fn entry_builder_missing_file() {
795        let err = Entry::builder()
796            .spec(Spec::parse("/dev/sda1").unwrap())
797            .vfstype(FsType::parse("ext4").unwrap())
798            .build()
799            .unwrap_err();
800        assert_eq!(err, EntryBuilderError::MissingFile);
801    }
802
803    #[test]
804    fn entry_builder_missing_vfstype() {
805        let err = Entry::builder()
806            .spec(Spec::parse("/dev/sda1").unwrap())
807            .file(MountPoint::new("/").unwrap())
808            .build()
809            .unwrap_err();
810        assert_eq!(err, EntryBuilderError::MissingFsType);
811    }
812
813    #[test]
814    fn entry_comment_accessor() {
815        let entry = Entry {
816            comment: Some("# Test".into()),
817            ..Entry::new(
818                Spec::Device("/dev/sda1".into()),
819                MountPoint::new("/").unwrap(),
820                FsType::new("ext4").unwrap(),
821                Options::defaults(),
822            )
823        };
824        assert_eq!(entry.comment(), Some("# Test"));
825    }
826
827    // ── Fstab tests ──
828
829    #[test]
830    fn fstab_new_is_empty() {
831        let fstab = Fstab::new();
832        assert!(fstab.is_empty());
833        assert_eq!(fstab.len(), 0);
834    }
835
836    #[test]
837    fn fstab_add_entry() {
838        let mut fstab = Fstab::new();
839        let entry = Entry::new(
840            Spec::Device("/dev/sda1".into()),
841            MountPoint::new("/").unwrap(),
842            FsType::new("ext4").unwrap(),
843            Options::defaults(),
844        );
845        fstab.add(entry);
846        assert_eq!(fstab.len(), 1);
847        assert!(!fstab.is_empty());
848    }
849
850    #[test]
851    fn fstab_add_chaining() {
852        let mut fstab = Fstab::new();
853        let e1 = Entry::new(
854            Spec::Device("/dev/sda1".into()),
855            MountPoint::new("/").unwrap(),
856            FsType::new("ext4").unwrap(),
857            Options::defaults(),
858        );
859        let e2 = Entry::new(
860            Spec::Device("/dev/sda2".into()),
861            MountPoint::new("/home").unwrap(),
862            FsType::new("ext4").unwrap(),
863            Options::defaults(),
864        );
865        fstab.add(e1).add(e2);
866        assert_eq!(fstab.len(), 2);
867    }
868
869    #[test]
870    fn fstab_insert_valid() {
871        let mut fstab = Fstab::new();
872        let e1 = Entry::new(
873            Spec::Device("/dev/sda1".into()),
874            MountPoint::new("/").unwrap(),
875            FsType::new("ext4").unwrap(),
876            Options::defaults(),
877        );
878        let e2 = Entry::new(
879            Spec::Device("/dev/sda2".into()),
880            MountPoint::new("/home").unwrap(),
881            FsType::new("ext4").unwrap(),
882            Options::defaults(),
883        );
884        fstab.add(e1);
885        let e3 = Entry::new(
886            Spec::Device("/dev/sdb1".into()),
887            MountPoint::new("/mnt/data").unwrap(),
888            FsType::new("xfs").unwrap(),
889            Options::defaults(),
890        );
891        // Insert at end with chaining
892        assert!(fstab.insert(1, e3).is_ok());
893        assert_eq!(fstab.len(), 2);
894        // Insert at beginning
895        assert!(fstab.insert(0, e2).is_ok());
896        assert_eq!(fstab.len(), 3);
897    }
898
899    #[test]
900    fn fstab_insert_out_of_bounds() {
901        let mut fstab = Fstab::new();
902        let entry = Entry::new(
903            Spec::Device("/dev/sda1".into()),
904            MountPoint::new("/").unwrap(),
905            FsType::new("ext4").unwrap(),
906            Options::defaults(),
907        );
908        let result = fstab.insert(1, entry);
909        assert!(result.is_err());
910    }
911
912    #[test]
913    fn fstab_remove() {
914        let mut fstab = Fstab::new();
915        let entry = Entry::new(
916            Spec::Device("/dev/sda1".into()),
917            MountPoint::new("/").unwrap(),
918            FsType::new("ext4").unwrap(),
919            Options::defaults(),
920        );
921        fstab.add(entry);
922        let removed = fstab.remove(0);
923        assert!(removed.is_some());
924        assert!(fstab.is_empty());
925
926        let none = fstab.remove(0);
927        assert!(none.is_none());
928    }
929
930    #[test]
931    fn fstab_replace() {
932        let mut fstab = Fstab::new();
933        let e1 = Entry::new(
934            Spec::Device("/dev/sda1".into()),
935            MountPoint::new("/").unwrap(),
936            FsType::new("ext4").unwrap(),
937            Options::defaults(),
938        );
939        let e2 = Entry::new(
940            Spec::Device("/dev/sdb1".into()),
941            MountPoint::new("/mnt/data").unwrap(),
942            FsType::new("xfs").unwrap(),
943            Options::defaults(),
944        );
945        fstab.add(e1);
946        let old = fstab.replace(0, e2);
947        assert!(old.is_some());
948        assert_eq!(fstab.len(), 1);
949
950        let none = fstab.replace(5, old.unwrap());
951        assert!(none.is_none());
952    }
953
954    #[test]
955    fn fstab_clear() {
956        let mut fstab = Fstab::new();
957        fstab.intro_comment = Some("# intro".into());
958        fstab.add(Entry::new(
959            Spec::Device("/dev/sda1".into()),
960            MountPoint::new("/").unwrap(),
961            FsType::new("ext4").unwrap(),
962            Options::defaults(),
963        ));
964        fstab.trailing_comment = Some("# trailing".into());
965        fstab.clear();
966        assert!(fstab.is_empty());
967        assert!(fstab.intro_comment.is_none());
968        assert!(fstab.trailing_comment.is_none());
969    }
970
971    #[test]
972    fn fstab_find_by_source() {
973        let mut fstab = Fstab::new();
974        fstab.add(Entry::new(
975            Spec::Device("/dev/sda1".into()),
976            MountPoint::new("/").unwrap(),
977            FsType::new("ext4").unwrap(),
978            Options::defaults(),
979        ));
980        fstab.add(Entry::new(
981            Spec::Uuid("abc-123".into()),
982            MountPoint::new("/home").unwrap(),
983            FsType::new("ext4").unwrap(),
984            Options::defaults(),
985        ));
986        let results = fstab.find_by_source("sda1");
987        assert_eq!(results.len(), 1);
988        let results = fstab.find_by_source("ext4");
989        assert_eq!(results.len(), 0);
990    }
991
992    #[test]
993    fn fstab_find_by_source_label() {
994        let mut fstab = Fstab::new();
995        fstab.add(Entry::new(
996            Spec::Label("ROOT".into()),
997            MountPoint::new("/").unwrap(),
998            FsType::new("ext4").unwrap(),
999            Options::defaults(),
1000        ));
1001        let results = fstab.find_by_source("LABEL=ROOT");
1002        assert_eq!(results.len(), 1);
1003    }
1004
1005    #[test]
1006    fn fstab_find_by_mountpoint() {
1007        let mut fstab = Fstab::new();
1008        fstab.add(Entry::new(
1009            Spec::Device("/dev/sda1".into()),
1010            MountPoint::new("/").unwrap(),
1011            FsType::new("ext4").unwrap(),
1012            Options::defaults(),
1013        ));
1014        fstab.add(Entry::new(
1015            Spec::Device("/dev/sda2".into()),
1016            MountPoint::new("/home").unwrap(),
1017            FsType::new("ext4").unwrap(),
1018            Options::defaults(),
1019        ));
1020        let found = fstab.find_by_mountpoint(Path::new("/home"));
1021        assert!(found.is_some());
1022        assert!(
1023            fstab
1024                .find_by_mountpoint(Path::new("/nonexistent"))
1025                .is_none()
1026        );
1027    }
1028
1029    #[test]
1030    fn fstab_root() {
1031        let mut fstab = Fstab::new();
1032        let root_entry = Entry {
1033            spec: Spec::Device("/dev/sda1".into()),
1034            file: MountPoint::new("/").unwrap(),
1035            vfstype: FsType::new("ext4").unwrap(),
1036            options: Options::defaults(),
1037            freq: 0,
1038            passno: 1,
1039            comment: None,
1040        };
1041        fstab.add(Entry::new(
1042            Spec::Device("/dev/sda2".into()),
1043            MountPoint::new("/home").unwrap(),
1044            FsType::new("ext4").unwrap(),
1045            Options::defaults(),
1046        ));
1047        fstab.add(root_entry);
1048        let root = fstab.root();
1049        assert!(root.is_some());
1050        assert!(root.unwrap().is_root());
1051    }
1052
1053    #[test]
1054    fn fstab_root_no_root_entry() {
1055        let mut fstab = Fstab::new();
1056        fstab.add(Entry::new(
1057            Spec::Device("/dev/sda1".into()),
1058            MountPoint::new("/home").unwrap(),
1059            FsType::new("ext4").unwrap(),
1060            Options::defaults(),
1061        ));
1062        assert!(fstab.root().is_none());
1063    }
1064
1065    #[test]
1066    fn fstab_entries_slice() {
1067        let mut fstab = Fstab::new();
1068        fstab.add(Entry::new(
1069            Spec::Device("/dev/sda1".into()),
1070            MountPoint::new("/").unwrap(),
1071            FsType::new("ext4").unwrap(),
1072            Options::defaults(),
1073        ));
1074        assert_eq!(fstab.entries().len(), 1);
1075    }
1076
1077    #[test]
1078    fn fstab_into_iter() {
1079        let mut fstab = Fstab::new();
1080        fstab.add(Entry::new(
1081            Spec::Device("/dev/sda1".into()),
1082            MountPoint::new("/").unwrap(),
1083            FsType::new("ext4").unwrap(),
1084            Options::defaults(),
1085        ));
1086        fstab.add(Entry::new(
1087            Spec::Device("/dev/sda2".into()),
1088            MountPoint::new("/home").unwrap(),
1089            FsType::new("ext4").unwrap(),
1090            Options::defaults(),
1091        ));
1092
1093        let count = fstab.into_iter().count();
1094        assert_eq!(count, 2);
1095    }
1096
1097    #[test]
1098    fn fstab_ref_into_iter() {
1099        let mut fstab = Fstab::new();
1100        fstab.add(Entry::new(
1101            Spec::Device("/dev/sda1".into()),
1102            MountPoint::new("/").unwrap(),
1103            FsType::new("ext4").unwrap(),
1104            Options::defaults(),
1105        ));
1106
1107        let entries: Vec<&Entry> = (&fstab).into_iter().collect();
1108        assert_eq!(entries.len(), 1);
1109    }
1110
1111    // ── Spec Display tests ──
1112
1113    #[test]
1114    fn spec_display_device() {
1115        let spec = Spec::Device("/dev/sda1".into());
1116        assert_eq!(spec.to_string(), "/dev/sda1");
1117    }
1118
1119    #[test]
1120    fn spec_display_label() {
1121        let spec = Spec::Label("ROOT".into());
1122        assert_eq!(spec.to_string(), "LABEL=ROOT");
1123    }
1124
1125    #[test]
1126    fn spec_display_uuid() {
1127        let spec = Spec::Uuid("abc-123".into());
1128        assert_eq!(spec.to_string(), "UUID=abc-123");
1129    }
1130
1131    #[test]
1132    fn spec_display_partlabel() {
1133        let spec = Spec::PartLabel("System".into());
1134        assert_eq!(spec.to_string(), "PARTLABEL=System");
1135    }
1136
1137    #[test]
1138    fn spec_display_partuuid() {
1139        let spec = Spec::PartUuid("abc-def".into());
1140        assert_eq!(spec.to_string(), "PARTUUID=abc-def");
1141    }
1142
1143    #[test]
1144    fn spec_display_id() {
1145        #[allow(deprecated)]
1146        let spec = Spec::Id("wwn-0x50014ee2".into());
1147        assert_eq!(spec.to_string(), "ID=wwn-0x50014ee2");
1148    }
1149
1150    #[test]
1151    fn spec_display_network() {
1152        let spec = Spec::NetworkMount {
1153            host: "server".into(),
1154            path: "/export".into(),
1155        };
1156        assert_eq!(spec.to_string(), "server:/export");
1157    }
1158
1159    #[test]
1160    fn spec_display_keyword() {
1161        let spec = Spec::Keyword("proc".into());
1162        assert_eq!(spec.to_string(), "proc");
1163    }
1164}