Skip to main content

coreutils_rs/df/
core.rs

1use std::collections::HashSet;
2use std::io::{self, Write};
3
4// ──────────────────────────────────────────────────
5// Configuration
6// ──────────────────────────────────────────────────
7
8/// Configuration for the df command.
9pub struct DfConfig {
10    pub all: bool,
11    pub block_size: u64,
12    pub human_readable: bool,
13    pub si: bool,
14    pub inodes: bool,
15    pub local_only: bool,
16    pub portability: bool,
17    pub print_type: bool,
18    pub total: bool,
19    pub sync_before: bool,
20    pub type_filter: HashSet<String>,
21    pub exclude_type: HashSet<String>,
22    pub output_fields: Option<Vec<String>>,
23    pub files: Vec<String>,
24}
25
26impl Default for DfConfig {
27    fn default() -> Self {
28        Self {
29            all: false,
30            block_size: 1024,
31            human_readable: false,
32            si: false,
33            inodes: false,
34            local_only: false,
35            portability: false,
36            print_type: false,
37            total: false,
38            sync_before: false,
39            type_filter: HashSet::new(),
40            exclude_type: HashSet::new(),
41            output_fields: None,
42            files: Vec::new(),
43        }
44    }
45}
46
47// ──────────────────────────────────────────────────
48// Mount entry and filesystem info
49// ──────────────────────────────────────────────────
50
51/// A parsed mount entry from /proc/mounts.
52pub struct MountEntry {
53    pub source: String,
54    pub target: String,
55    pub fstype: String,
56}
57
58/// Filesystem information after calling statvfs.
59pub struct FsInfo {
60    pub source: String,
61    pub fstype: String,
62    pub target: String,
63    /// The command-line argument that matched this filesystem (for --output=file).
64    pub file: String,
65    pub total: u64,
66    pub used: u64,
67    pub available: u64,
68    pub use_percent: f64,
69    pub itotal: u64,
70    pub iused: u64,
71    pub iavail: u64,
72    pub iuse_percent: f64,
73}
74
75// Remote filesystem types that should be excluded with --local.
76const REMOTE_FS_TYPES: &[&str] = &[
77    "nfs",
78    "nfs4",
79    "cifs",
80    "smbfs",
81    "ncpfs",
82    "afs",
83    "coda",
84    "ftpfs",
85    "mfs",
86    "sshfs",
87    "fuse.sshfs",
88    "ncp",
89    "9p",
90];
91
92// Pseudo filesystem types filtered out unless --all is given.
93const PSEUDO_FS_TYPES: &[&str] = &[
94    "sysfs",
95    "proc",
96    "devtmpfs",
97    "devpts",
98    "securityfs",
99    "cgroup",
100    "cgroup2",
101    "pstore",
102    "efivarfs",
103    "bpf",
104    "autofs",
105    "mqueue",
106    "hugetlbfs",
107    "debugfs",
108    "tracefs",
109    "fusectl",
110    "configfs",
111    "ramfs",
112    "binfmt_misc",
113    "rpc_pipefs",
114    "nsfs",
115    "overlay",
116    "squashfs",
117];
118
119// ──────────────────────────────────────────────────
120// Reading mount entries
121// ──────────────────────────────────────────────────
122
123/// Read mount entries from /proc/mounts (falls back to /etc/mtab).
124pub fn read_mounts() -> Vec<MountEntry> {
125    let content = std::fs::read_to_string("/proc/mounts")
126        .or_else(|_| std::fs::read_to_string("/etc/mtab"))
127        .unwrap_or_default();
128    content
129        .lines()
130        .filter_map(|line| {
131            let parts: Vec<&str> = line.split_whitespace().collect();
132            if parts.len() >= 4 {
133                Some(MountEntry {
134                    source: unescape_octal(parts[0]),
135                    target: unescape_octal(parts[1]),
136                    fstype: parts[2].to_string(),
137                })
138            } else {
139                None
140            }
141        })
142        .collect()
143}
144
145/// Unescape octal sequences like \040 (space) in mount paths.
146fn unescape_octal(s: &str) -> String {
147    let mut result = String::with_capacity(s.len());
148    let bytes = s.as_bytes();
149    let mut i = 0;
150    while i < bytes.len() {
151        if bytes[i] == b'\\' && i + 3 < bytes.len() {
152            let d1 = bytes[i + 1];
153            let d2 = bytes[i + 2];
154            let d3 = bytes[i + 3];
155            if (b'0'..=b'3').contains(&d1)
156                && (b'0'..=b'7').contains(&d2)
157                && (b'0'..=b'7').contains(&d3)
158            {
159                let val = (d1 - b'0') * 64 + (d2 - b'0') * 8 + (d3 - b'0');
160                result.push(val as char);
161                i += 4;
162                continue;
163            }
164        }
165        result.push(bytes[i] as char);
166        i += 1;
167    }
168    result
169}
170
171// ──────────────────────────────────────────────────
172// Calling statvfs
173// ──────────────────────────────────────────────────
174
175/// Call statvfs(2) on a path and return filesystem info.
176#[cfg(unix)]
177fn statvfs_info(mount: &MountEntry) -> Option<FsInfo> {
178    use std::ffi::CString;
179
180    let path = CString::new(mount.target.as_bytes()).ok()?;
181    let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
182    let ret = unsafe { libc::statvfs(path.as_ptr(), &mut stat) };
183    if ret != 0 {
184        return None;
185    }
186
187    #[cfg(target_os = "linux")]
188    let block_size = stat.f_frsize as u64;
189    #[cfg(not(target_os = "linux"))]
190    let block_size = stat.f_bsize as u64;
191    let total = stat.f_blocks as u64 * block_size;
192    let free = stat.f_bfree as u64 * block_size;
193    let available = stat.f_bavail as u64 * block_size;
194    let used = total.saturating_sub(free);
195
196    // Use -1.0 as sentinel for "no percentage" (shown as "-" in output),
197    // matching GNU df's behavior for pseudo-filesystems with 0 total blocks.
198    let use_percent = if total == 0 {
199        -1.0
200    } else {
201        // GNU df calculates use% as: used / (used + available) * 100
202        let denom = used + available;
203        if denom == 0 {
204            0.0
205        } else {
206            (used as f64 / denom as f64) * 100.0
207        }
208    };
209
210    let itotal = stat.f_files as u64;
211    let ifree = stat.f_ffree as u64;
212    let iused = itotal.saturating_sub(ifree);
213    let iuse_percent = if itotal == 0 {
214        -1.0
215    } else {
216        (iused as f64 / itotal as f64) * 100.0
217    };
218
219    Some(FsInfo {
220        source: mount.source.clone(),
221        fstype: mount.fstype.clone(),
222        target: mount.target.clone(),
223        file: mount.target.clone(),
224        total,
225        used,
226        available,
227        use_percent,
228        itotal,
229        iused,
230        iavail: ifree,
231        iuse_percent,
232    })
233}
234
235#[cfg(not(unix))]
236fn statvfs_info(_mount: &MountEntry) -> Option<FsInfo> {
237    None
238}
239
240// ──────────────────────────────────────────────────
241// Finding filesystem for a specific file
242// ──────────────────────────────────────────────────
243
244/// Find the mount entry for a given file path by finding the longest
245/// matching mount target prefix.
246fn find_mount_for_file<'a>(path: &str, mounts: &'a [MountEntry]) -> Option<&'a MountEntry> {
247    let canonical = std::fs::canonicalize(path).ok()?;
248    let canonical_str = canonical.to_string_lossy();
249    let mut best: Option<&MountEntry> = None;
250    let mut best_len = 0;
251    for mount in mounts {
252        let target = &mount.target;
253        if canonical_str.starts_with(target.as_str())
254            && (canonical_str.len() == target.len()
255                || target == "/"
256                || canonical_str.as_bytes().get(target.len()) == Some(&b'/'))
257        {
258            if target.len() > best_len {
259                best_len = target.len();
260                best = Some(mount);
261            }
262        }
263    }
264    best
265}
266
267// ──────────────────────────────────────────────────
268// Getting filesystem info
269// ──────────────────────────────────────────────────
270
271/// Determine whether a filesystem type is remote.
272fn is_remote(fstype: &str) -> bool {
273    REMOTE_FS_TYPES.contains(&fstype)
274}
275
276/// Determine whether a filesystem type is pseudo.
277fn is_pseudo(fstype: &str) -> bool {
278    PSEUDO_FS_TYPES.contains(&fstype)
279}
280
281/// Get filesystem info for all relevant mount points.
282/// Returns (filesystems, had_error) where had_error is true if any file was not found.
283pub fn get_filesystems(config: &DfConfig) -> (Vec<FsInfo>, bool) {
284    let mounts = read_mounts();
285    let mut had_error = false;
286
287    // If specific files are given, find their mount points.
288    if !config.files.is_empty() {
289        let mut result = Vec::new();
290        // GNU df does NOT deduplicate when specific files are given.
291        for file in &config.files {
292            match find_mount_for_file(file, &mounts) {
293                Some(mount) => {
294                    if let Some(mut info) = statvfs_info(mount) {
295                        info.file = file.clone();
296                        result.push(info);
297                    }
298                }
299                None => {
300                    eprintln!("df: {}: No such file or directory", file);
301                    had_error = true;
302                }
303            }
304        }
305        return (result, had_error);
306    }
307
308    let mut result = Vec::new();
309    let mut seen_sources = HashSet::new();
310
311    for mount in &mounts {
312        // Filter by type.
313        if !config.type_filter.is_empty() && !config.type_filter.contains(&mount.fstype) {
314            continue;
315        }
316
317        // Exclude by type.
318        if config.exclude_type.contains(&mount.fstype) {
319            continue;
320        }
321
322        // Skip remote filesystems if --local.
323        if config.local_only && is_remote(&mount.fstype) {
324            continue;
325        }
326
327        // Skip pseudo filesystems unless --all.
328        if !config.all && is_pseudo(&mount.fstype) {
329            continue;
330        }
331
332        // Skip duplicate sources unless --all (keep last mount for a given device).
333        if !config.all {
334            if mount.source == "none" || mount.source == "tmpfs" || mount.source == "devtmpfs" {
335                // Allow these through; filter by fstype instead of source.
336            } else if !seen_sources.insert(mount.source.clone()) {
337                continue;
338            }
339        }
340
341        if let Some(info) = statvfs_info(mount) {
342            // Without --all, skip filesystems with 0 total blocks (pseudo/virtual).
343            if !config.all && info.total == 0 && config.type_filter.is_empty() {
344                continue;
345            }
346            result.push(info);
347        }
348    }
349
350    (result, had_error)
351}
352
353// ──────────────────────────────────────────────────
354// Size formatting
355// ──────────────────────────────────────────────────
356
357/// Format a byte count in human-readable form using powers of 1024.
358/// GNU df uses ceiling rounding for human-readable display.
359pub fn human_readable_1024(bytes: u64) -> String {
360    const UNITS: &[&str] = &["", "K", "M", "G", "T", "P", "E"];
361    if bytes == 0 {
362        return "0".to_string();
363    }
364    let mut value = bytes as f64;
365    for unit in UNITS {
366        if value < 1024.0 {
367            if value < 10.0 && !unit.is_empty() {
368                // Round up to 1 decimal place
369                let rounded = (value * 10.0).ceil() / 10.0;
370                if rounded >= 10.0 {
371                    return format!("{:.0}{}", rounded.ceil(), unit);
372                }
373                return format!("{:.1}{}", rounded, unit);
374            }
375            return format!("{:.0}{}", value.ceil(), unit);
376        }
377        value /= 1024.0;
378    }
379    format!("{:.0}E", value.ceil())
380}
381
382/// Format a byte count in human-readable form using powers of 1000.
383/// GNU df uses ceiling rounding for human-readable display.
384pub fn human_readable_1000(bytes: u64) -> String {
385    const UNITS: &[&str] = &["", "k", "M", "G", "T", "P", "E"];
386    if bytes == 0 {
387        return "0".to_string();
388    }
389    let mut value = bytes as f64;
390    for unit in UNITS {
391        if value < 1000.0 {
392            if value < 10.0 && !unit.is_empty() {
393                let rounded = (value * 10.0).ceil() / 10.0;
394                if rounded >= 10.0 {
395                    return format!("{:.0}{}", rounded.ceil(), unit);
396                }
397                return format!("{:.1}{}", rounded, unit);
398            }
399            return format!("{:.0}{}", value.ceil(), unit);
400        }
401        value /= 1000.0;
402    }
403    format!("{:.0}E", value.ceil())
404}
405
406/// Format a size value according to the config.
407pub fn format_size(bytes: u64, config: &DfConfig) -> String {
408    if config.human_readable {
409        human_readable_1024(bytes)
410    } else if config.si {
411        human_readable_1000(bytes)
412    } else {
413        // GNU df uses ceiling division for block counts (matches GNU coreutils behavior).
414        format!("{}", (bytes + config.block_size - 1) / config.block_size)
415    }
416}
417
418/// Format a percentage for display.
419/// Returns "-" when pct < 0.0 (sentinel for pseudo-filesystems with 0 blocks).
420fn format_percent(pct: f64) -> String {
421    if pct < 0.0 {
422        return "-".to_string();
423    }
424    if pct == 0.0 {
425        return "0%".to_string();
426    }
427    // GNU df rounds up: ceil the percentage.
428    let rounded = pct.ceil() as u64;
429    format!("{}%", rounded)
430}
431
432// ──────────────────────────────────────────────────
433// Block size parsing
434// ──────────────────────────────────────────────────
435
436/// Parse a block size string like "1K", "1M", "1G", etc.
437pub fn parse_block_size(s: &str) -> Result<u64, String> {
438    let s = s.trim();
439    if s.is_empty() {
440        return Err("invalid block size".to_string());
441    }
442
443    // Check for leading apostrophe (thousands grouping) - just strip it.
444    let s = s.strip_prefix('\'').unwrap_or(s);
445
446    let (num_str, suffix) = if s
447        .as_bytes()
448        .last()
449        .map_or(false, |b| b.is_ascii_alphabetic())
450    {
451        let last = s.len() - 1;
452        (&s[..last], &s[last..])
453    } else {
454        (s, "")
455    };
456
457    let num: u64 = if num_str.is_empty() {
458        1
459    } else {
460        num_str
461            .parse()
462            .map_err(|_| format!("invalid block size: '{}'", s))?
463    };
464
465    let multiplier = match suffix.to_uppercase().as_str() {
466        "" => 1u64,
467        "K" => 1024,
468        "M" => 1024 * 1024,
469        "G" => 1024 * 1024 * 1024,
470        "T" => 1024 * 1024 * 1024 * 1024,
471        "P" => 1024u64 * 1024 * 1024 * 1024 * 1024,
472        "E" => 1024u64 * 1024 * 1024 * 1024 * 1024 * 1024,
473        _ => return Err(format!("invalid suffix in block size: '{}'", s)),
474    };
475
476    Ok(num * multiplier)
477}
478
479// ──────────────────────────────────────────────────
480// Valid output field names
481// ──────────────────────────────────────────────────
482
483/// Valid field names for --output.
484pub const VALID_OUTPUT_FIELDS: &[&str] = &[
485    "source", "fstype", "itotal", "iused", "iavail", "ipcent", "size", "used", "avail", "pcent",
486    "file", "target",
487];
488
489/// Parse the --output field list.
490pub fn parse_output_fields(s: &str) -> Result<Vec<String>, String> {
491    let fields: Vec<String> = s.split(',').map(|f| f.trim().to_lowercase()).collect();
492    let mut seen = std::collections::HashSet::new();
493    for field in &fields {
494        if !VALID_OUTPUT_FIELDS.contains(&field.as_str()) {
495            return Err(format!("df: '{}': not a valid field for --output", field));
496        }
497        if !seen.insert(field.as_str()) {
498            return Err(format!(
499                "option --output: field '{}' used more than once",
500                field
501            ));
502        }
503    }
504    Ok(fields)
505}
506
507// ──────────────────────────────────────────────────
508// Output formatting (GNU-compatible auto-sized columns)
509// ──────────────────────────────────────────────────
510
511/// Determine the size column header.
512fn size_header(config: &DfConfig) -> String {
513    if config.human_readable || config.si {
514        "Size".to_string()
515    } else if config.portability {
516        "1024-blocks".to_string()
517    } else if config.block_size == 1024 {
518        "1K-blocks".to_string()
519    } else if config.block_size == 1024 * 1024 {
520        "1M-blocks".to_string()
521    } else {
522        format!("{}-blocks", config.block_size)
523    }
524}
525
526/// Build a row of string values for a filesystem entry.
527pub(crate) fn build_row(info: &FsInfo, config: &DfConfig) -> Vec<String> {
528    if let Some(ref fields) = config.output_fields {
529        return fields
530            .iter()
531            .map(|f| match f.as_str() {
532                "source" => info.source.clone(),
533                "fstype" => info.fstype.clone(),
534                "itotal" => format!("{}", info.itotal),
535                "iused" => format!("{}", info.iused),
536                "iavail" => format!("{}", info.iavail),
537                "ipcent" => format_percent(info.iuse_percent),
538                "size" => format_size(info.total, config),
539                "used" => format_size(info.used, config),
540                "avail" => format_size(info.available, config),
541                "pcent" => format_percent(info.use_percent),
542                "file" => info.file.clone(),
543                "target" => info.target.clone(),
544                _ => String::new(),
545            })
546            .collect();
547    }
548
549    if config.inodes {
550        vec![
551            info.source.clone(),
552            format!("{}", info.itotal),
553            format!("{}", info.iused),
554            format!("{}", info.iavail),
555            format_percent(info.iuse_percent),
556            info.target.clone(),
557        ]
558    } else if config.print_type {
559        vec![
560            info.source.clone(),
561            info.fstype.clone(),
562            format_size(info.total, config),
563            format_size(info.used, config),
564            format_size(info.available, config),
565            format_percent(info.use_percent),
566            info.target.clone(),
567        ]
568    } else {
569        vec![
570            info.source.clone(),
571            format_size(info.total, config),
572            format_size(info.used, config),
573            format_size(info.available, config),
574            format_percent(info.use_percent),
575            info.target.clone(),
576        ]
577    }
578}
579
580/// Build the header row.
581pub(crate) fn build_header_row(config: &DfConfig) -> Vec<String> {
582    if let Some(ref fields) = config.output_fields {
583        return fields
584            .iter()
585            .map(|f| match f.as_str() {
586                "source" => "Filesystem".to_string(),
587                "fstype" => "Type".to_string(),
588                "itotal" => "Inodes".to_string(),
589                "iused" => "IUsed".to_string(),
590                "iavail" => "IFree".to_string(),
591                "ipcent" => "IUse%".to_string(),
592                "size" => size_header(config),
593                "used" => "Used".to_string(),
594                "avail" => "Avail".to_string(),
595                "pcent" => "Use%".to_string(),
596                "file" => "File".to_string(),
597                "target" => "Mounted on".to_string(),
598                _ => f.clone(),
599            })
600            .collect();
601    }
602
603    let pct_header = if config.portability {
604        "Capacity"
605    } else if config.inodes {
606        "IUse%"
607    } else {
608        "Use%"
609    };
610
611    if config.inodes {
612        vec![
613            "Filesystem".to_string(),
614            "Inodes".to_string(),
615            "IUsed".to_string(),
616            "IFree".to_string(),
617            pct_header.to_string(),
618            "Mounted on".to_string(),
619        ]
620    } else if config.print_type {
621        let avail_header = if config.human_readable || config.si {
622            "Avail"
623        } else {
624            "Available"
625        };
626        vec![
627            "Filesystem".to_string(),
628            "Type".to_string(),
629            size_header(config),
630            "Used".to_string(),
631            avail_header.to_string(),
632            pct_header.to_string(),
633            "Mounted on".to_string(),
634        ]
635    } else {
636        let avail_header = if config.human_readable || config.si {
637            "Avail"
638        } else {
639            "Available"
640        };
641        vec![
642            "Filesystem".to_string(),
643            size_header(config),
644            "Used".to_string(),
645            avail_header.to_string(),
646            pct_header.to_string(),
647            "Mounted on".to_string(),
648        ]
649    }
650}
651
652/// Build a total row.
653fn build_total_row(filesystems: &[FsInfo], config: &DfConfig) -> Vec<String> {
654    let total_size: u64 = filesystems.iter().map(|f| f.total).sum();
655    let total_used: u64 = filesystems.iter().map(|f| f.used).sum();
656    let total_avail: u64 = filesystems.iter().map(|f| f.available).sum();
657    let total_itotal: u64 = filesystems.iter().map(|f| f.itotal).sum();
658    let total_iused: u64 = filesystems.iter().map(|f| f.iused).sum();
659    let total_iavail: u64 = filesystems.iter().map(|f| f.iavail).sum();
660
661    let use_pct = {
662        let denom = total_used + total_avail;
663        if denom == 0 {
664            0.0
665        } else {
666            (total_used as f64 / denom as f64) * 100.0
667        }
668    };
669    let iuse_pct = if total_itotal == 0 {
670        0.0
671    } else {
672        (total_iused as f64 / total_itotal as f64) * 100.0
673    };
674
675    if config.inodes {
676        vec![
677            "total".to_string(),
678            format!("{}", total_itotal),
679            format!("{}", total_iused),
680            format!("{}", total_iavail),
681            format_percent(iuse_pct),
682            "-".to_string(),
683        ]
684    } else if config.print_type {
685        vec![
686            "total".to_string(),
687            "-".to_string(),
688            format_size(total_size, config),
689            format_size(total_used, config),
690            format_size(total_avail, config),
691            format_percent(use_pct),
692            "-".to_string(),
693        ]
694    } else {
695        vec![
696            "total".to_string(),
697            format_size(total_size, config),
698            format_size(total_used, config),
699            format_size(total_avail, config),
700            format_percent(use_pct),
701            "-".to_string(),
702        ]
703    }
704}
705
706/// Column alignment type.
707enum ColAlign {
708    Left,
709    Right,
710    None, // last column, no padding
711}
712
713/// Get column alignment for the standard df output.
714fn get_col_alignments(config: &DfConfig, num_cols: usize) -> Vec<ColAlign> {
715    if num_cols == 0 {
716        return vec![];
717    }
718    let mut aligns = Vec::with_capacity(num_cols);
719
720    // Numeric --output fields that should be right-aligned even as the last column.
721    const NUMERIC_OUTPUT_FIELDS: &[&str] = &[
722        "itotal", "iused", "iavail", "ipcent", "size", "used", "avail", "pcent",
723    ];
724    if config.output_fields.is_some() {
725        // For --output, first column is left-aligned, rest right-aligned.
726        // Last column is right-aligned for numeric fields, no-pad for strings.
727        aligns.push(ColAlign::Left);
728        for _ in 1..num_cols.saturating_sub(1) {
729            aligns.push(ColAlign::Right);
730        }
731        if num_cols > 1 {
732            let last_field = config
733                .output_fields
734                .as_ref()
735                .and_then(|f| f.last())
736                .map(|s| s.as_str())
737                .unwrap_or("");
738            if NUMERIC_OUTPUT_FIELDS.contains(&last_field) {
739                aligns.push(ColAlign::Right);
740            } else {
741                aligns.push(ColAlign::None);
742            }
743        }
744    } else if config.print_type {
745        // Filesystem(left) Type(left) Size(right) Used(right) Avail(right) Use%(right) Mounted(none)
746        aligns.push(ColAlign::Left);
747        aligns.push(ColAlign::Left);
748        for _ in 2..num_cols.saturating_sub(1) {
749            aligns.push(ColAlign::Right);
750        }
751        if num_cols > 2 {
752            aligns.push(ColAlign::None);
753        }
754    } else {
755        // Filesystem(left) numeric(right)... last(none)
756        aligns.push(ColAlign::Left);
757        for _ in 1..num_cols.saturating_sub(1) {
758            aligns.push(ColAlign::Right);
759        }
760        if num_cols > 1 {
761            aligns.push(ColAlign::None);
762        }
763    }
764
765    aligns
766}
767
768/// Compute column widths from header and data rows, applying GNU df minimums.
769fn compute_widths(header: &[String], rows: &[Vec<String>], config: &DfConfig) -> Vec<usize> {
770    let num_cols = header.len();
771    let mut widths = vec![0usize; num_cols];
772    for (i, h) in header.iter().enumerate() {
773        widths[i] = widths[i].max(h.len());
774    }
775    for row in rows {
776        for (i, val) in row.iter().enumerate() {
777            if i < num_cols {
778                widths[i] = widths[i].max(val.len());
779            }
780        }
781    }
782
783    // GNU df applies minimum column widths regardless of output mode.
784    // Minimum width of 14 for the source (Filesystem) column, and minimum
785    // width of 5 for numeric size columns.
786    if let Some(ref fields) = config.output_fields {
787        for (i, field) in fields.iter().enumerate() {
788            if i >= num_cols {
789                break;
790            }
791            match field.as_str() {
792                "source" => widths[i] = widths[i].max(14),
793                "size" | "used" | "avail" | "itotal" | "iused" | "iavail" => {
794                    widths[i] = widths[i].max(5);
795                }
796                _ => {}
797            }
798        }
799    } else if !widths.is_empty() {
800        widths[0] = widths[0].max(14);
801        let start_col = if config.print_type { 2 } else { 1 };
802        for i in start_col..start_col + 3 {
803            if i < num_cols {
804                widths[i] = widths[i].max(5);
805            }
806        }
807    }
808
809    widths
810}
811
812/// Print all rows with auto-sized columns, matching GNU df output format.
813pub(crate) fn print_table(
814    header: &[String],
815    rows: &[Vec<String>],
816    config: &DfConfig,
817    out: &mut impl Write,
818) -> io::Result<()> {
819    let num_cols = header.len();
820    if num_cols == 0 {
821        return Ok(());
822    }
823
824    let widths = compute_widths(header, rows, config);
825    let aligns = get_col_alignments(config, num_cols);
826
827    print_row(header, &widths, &aligns, out)?;
828    for row in rows {
829        print_row(row, &widths, &aligns, out)?;
830    }
831
832    Ok(())
833}
834
835/// Print a single row with the given column widths and alignments.
836fn print_row(
837    row: &[String],
838    widths: &[usize],
839    aligns: &[ColAlign],
840    out: &mut impl Write,
841) -> io::Result<()> {
842    let num_cols = widths.len();
843    for (i, val) in row.iter().enumerate() {
844        if i < num_cols {
845            if i > 0 {
846                write!(out, " ")?;
847            }
848            let w = widths[i];
849            match aligns.get(i).unwrap_or(&ColAlign::Right) {
850                ColAlign::Left => write!(out, "{:<width$}", val, width = w)?,
851                ColAlign::Right => write!(out, "{:>width$}", val, width = w)?,
852                ColAlign::None => write!(out, "{}", val)?,
853            }
854        }
855    }
856    writeln!(out)?;
857    Ok(())
858}
859
860#[cfg(test)]
861#[allow(dead_code)]
862pub(crate) fn print_header(config: &DfConfig, out: &mut impl Write) -> io::Result<()> {
863    let header = build_header_row(config);
864    let widths = compute_widths(&header, &[], config);
865    let aligns = get_col_alignments(config, header.len());
866    print_row(&header, &widths, &aligns, out)
867}
868
869#[cfg(test)]
870#[allow(dead_code)]
871pub(crate) fn print_fs_line(
872    info: &FsInfo,
873    config: &DfConfig,
874    out: &mut impl Write,
875) -> io::Result<()> {
876    let header = build_header_row(config);
877    let row = build_row(info, config);
878    let rows = [row];
879    let widths = compute_widths(&header, &rows, config);
880    let aligns = get_col_alignments(config, header.len());
881    print_row(&rows[0], &widths, &aligns, out)
882}
883
884#[cfg(test)]
885#[allow(dead_code)]
886pub(crate) fn print_total_line(
887    filesystems: &[FsInfo],
888    config: &DfConfig,
889    out: &mut impl Write,
890) -> io::Result<()> {
891    let header = build_header_row(config);
892    let row = build_total_row(filesystems, config);
893    let rows = [row];
894    let widths = compute_widths(&header, &rows, config);
895    let aligns = get_col_alignments(config, header.len());
896    print_row(&rows[0], &widths, &aligns, out)
897}
898
899/// Run the df command and write output.
900pub fn run_df(config: &DfConfig) -> i32 {
901    let stdout = io::stdout();
902    let mut out = io::BufWriter::new(stdout.lock());
903
904    let (filesystems, had_error) = get_filesystems(config);
905
906    let header = build_header_row(config);
907    let mut rows: Vec<Vec<String>> = Vec::new();
908    for info in &filesystems {
909        rows.push(build_row(info, config));
910    }
911
912    if config.total {
913        rows.push(build_total_row(&filesystems, config));
914    }
915
916    if let Err(e) = print_table(&header, &rows, config, &mut out) {
917        if e.kind() == io::ErrorKind::BrokenPipe {
918            return 0;
919        }
920        eprintln!("df: write error: {}", e);
921        return 1;
922    }
923
924    let _ = out.flush();
925    if had_error { 1 } else { 0 }
926}