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