diff --git a/README.md b/README.md index b77a441e..29819df4 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ compare against the true GNU coreutils version on the Linux-based tests first. | ✓ | cut | Print selected parts of lines | ✓ | | | date | Print or set system date and time | ✓ | | | dd | Convert and copy a file | ✓ | -| | df | Report file system disk space usage | | +| ✓ | df | Report file system disk space usage | | | | dir | Briefly list directory contents | | | ✓ | dircolors | Color setup for ls | ✓ | | ✓ | dirname | Strip last file name component | ✓ | diff --git a/common/df/args.v b/common/df/args.v new file mode 100644 index 00000000..a4d51354 --- /dev/null +++ b/common/df/args.v @@ -0,0 +1,201 @@ +module df + +import common + +pub fn parse_args(args []string) Settings { + mut s := Settings{} + mut operands := []string{} + + for i := 0; i < args.len; i++ { + a := args[i] + if a == '--' { + operands << args[i + 1..] + break + } + if a == '--help' { + print_help() + exit(0) + } + if a == '--version' { + println('df (V coreutils) ${common.version}') + exit(0) + } + if !a.starts_with('-') || a == '-' { + operands << a + continue + } + + if a.starts_with('--') { + name := a.all_before('=') + val := a.all_after('=') + has_val := a.contains('=') + match name { + '--all' { + s.all = true + } + '--human-readable' { + s.mode = .human + } + '--si' { + s.mode = .si_human + } + '--inodes' { + s.inodes = true + s.mode = .inodes + } + '--print-type' { + s.show_type = true + } + '--total' { + s.total = true + } + '--local' { + s.local = true + } + '--no-sync' {} + '--sync' {} + '--block-size' { + value, next := take_value(args, i, val, has_val, name) + i = next + s.mode = .blocks + s.block_size = parse_block_size(value) or { + common.exit_with_error_message('df', '--invalid argument ${value} for option -- block-size') + } + } + '--type' { + value, next := take_value(args, i, val, has_val, name) + i = next + s.include_types << value.split(',') + } + '--exclude-type' { + value, next := take_value(args, i, val, has_val, name) + i = next + s.exclude_types << value.split(',') + } + else { + common.exit_with_error_message('df', "unrecognized option '${a}'") + } + } + continue + } + + // Short options. Clusters are expanded here because a cluster such as + // -hT is common usage, and a value-taking option inside a cluster + // takes the rest of the same word as its value. + mut j := 1 + for j < a.len { + c := a[j] + match c { + `a` { + s.all = true + } + `h` { + s.mode = .human + } + `H` { + s.mode = .si_human + } + `i` { + s.inodes = true + s.mode = .inodes + } + `k` { + s.mode = .kibibytes + } + `m` { + s.mode = .mebibytes + } + `g` { + s.mode = .gibibytes + } + `T` { + s.show_type = true + } + `P` { + s.mode = .blocks + s.block_size = 512 + } + `l` { + s.local = true + } + `B`, `t`, `x` { + rest := a[j + 1..] + mut value := rest + if rest == '' { + if i + 1 >= args.len { + common.exit_with_error_message('df', "option requires an argument -- '${c.ascii_str()}'") + } + i++ + value = args[i] + } + if c == `B` { + s.mode = .blocks + s.block_size = parse_block_size(value) or { + common.exit_with_error_message('df', '--invalid argument ${value} for option -- block-size') + } + } else if c == `t` { + s.include_types << value.split(',') + } else { + s.exclude_types << value.split(',') + } + j = a.len + continue + } + else { + common.exit_with_error_message('df', "invalid option -- '${c.ascii_str()}'") + } + } + j++ + } + } + + s.operands = operands + return s +} + +fn take_value(args []string, i int, inline_val string, has_val bool, name string) (string, int) { + if has_val { + return inline_val, i + } + if i + 1 >= args.len { + common.exit_with_error_message('df', "option '${name}' requires an argument") + } + return args[i + 1], i + 1 +} + +// parse_block_size accepts the plain decimal form of --block-size. The +// suffixed forms (K, M, G, kB, ...) are accepted by GNU but are not +// implemented. +pub fn parse_block_size(s string) ?int { + if s == '' { + return none + } + for c in s { + if c < `0` || c > `9` { + return none + } + } + return s.int() +} + +fn print_help() { + println('Usage: df [OPTION]... [FILE]...') + println('Show information about the file system on which each FILE resides,') + println('or all file systems by default.') + println('') + println(' -a, --all include pseudo, duplicate, inaccessible file systems') + println(' -B, --block-size=SIZE scale sizes by SIZE before printing them') + println(' -h, --human-readable print sizes in powers of 1024 (e.g., 1023M)') + println(' -H, --si print sizes in powers of 1000 (e.g., 1.1G)') + println(' -i, --inodes list inode information instead of block usage') + println(' -k like --block-size=1K') + println(' -l, --local limit listing to local file systems') + println(' -P, --portability use the POSIX output format') + println(' --sync invoke sync before getting usage information') + println(' -T, --print-type print file system type') + println(' -t, --type=TYPE limit listing to file systems of type TYPE') + println(' -x, --exclude-type=TYPE limit listing to file systems not of type TYPE') + println(' --total produce a grand total') + println(' --help display this help and exit') + println(' --version output version information and exit') +} diff --git a/common/df/df.v b/common/df/df.v new file mode 100644 index 00000000..e7caa313 --- /dev/null +++ b/common/df/df.v @@ -0,0 +1,313 @@ +module df + +// Block-size output mode. -i replaces the size columns with inode columns +// and overrides -k/-B, which is why it is a separate mode instead of a +// flag tested alongside the others. +pub enum Mode { + human + si_human + kibibytes + mebibytes + gibibytes + blocks + inodes +} + +pub struct MountInfo { +pub mut: + device string + mounted string + fstype string + total_bytes u64 + used_bytes u64 + free_bytes u64 +} + +pub struct Settings { +pub mut: + mode Mode = .kibibytes + block_size int = 1024 + inodes bool + all bool + show_type bool + total bool + local bool + exclude_types []string + include_types []string + operands []string +} + +// network_types are the file-system types --local excludes. On POSIX these +// are the network mounts /proc/mounts reports; on Windows every volume is +// local, so nothing is excluded there. +const network_types = ['nfs', 'nfs4', 'cifs', 'smbfs', 'smb3', 'afs', 'ncpfs', 'lustre', 'fuse.sshfs', + 'sshfs', 'gfs', 'gfs2', 'glusterfs', 'ceph', '9p', 'davfs', 'webdav'] + +// report returns the text main should print, or a failure when the platform +// cannot honour the requested mode. +pub fn report(set Settings) !string { + if set.inodes && !inodes_supported { + // Windows exposes no per-volume inode counts, so the columns would + // have to be invented. GNU on POSIX reports them from statvfs. + return error("doesn't support -i option") + } + return render_table(collect_mounts(set), set) +} + +// collect_mounts turns the raw per-platform volume list into what the table +// should show. The filtering lives here rather than in the platform code so +// it is covered by the tests. +pub fn collect_mounts(set Settings) []MountInfo { + mut mounts := list_mounts() + + // Volumes the platform cannot size have no usable numbers. GNU hides + // these unless --all is given, because a row of zeros is never the + // information the reader came for. + if !set.all { + mounts = mounts.filter(it.total_bytes > 0) + } + + if set.include_types.len > 0 { + mounts = mounts.filter(it.fstype in set.include_types) + } + if set.exclude_types.len > 0 { + mounts = mounts.filter(it.fstype !in set.exclude_types) + } + if set.local { + mounts = mounts.filter(it.fstype !in network_types) + } + + if set.operands.len == 0 { + return mounts + } + + // With operands, only the file systems holding those paths are listed, + // each at most once and in the order they were first named. + mut matched := []MountInfo{} + mut seen := []string{} + for op in set.operands { + for m in mounts { + if m.device in seen { + continue + } + if holds_path(m, op) { + seen << m.device + matched << m + break + } + } + } + return matched +} + +fn holds_path(mount MountInfo, path string) bool { + return mount.mounted != '' && is_same_location(mount.mounted, path) +} + +fn is_same_location(mounted string, path string) bool { + mut target := path + // '..' and symlinks would both change which volume a name resolves to; + // both are dropped rather than resolved, so the comparison stays a pure + // string operation and a non-existent path cannot be turned into one. + for target.len > 1 && (target.ends_with('/') || target.ends_with('\\')) { + target = target[..target.len - 1] + } + if mounted == target { + return true + } + return mounted.len <= target.len && target[..mounted.len] == mounted + && (mounted.len == 1 || is_separator(target[mounted.len], mounted[0])) +} + +fn is_separator(c u8, first u8) bool { + if first == `\\` || first == `/` { + return c == `/` || c == `\\` + } + return c == `\\` +} + +// render_table is the whole presentation layer. It returns the text rather +// than printing it so the column layout can be asserted on without capturing +// standard output. +pub fn render_table(mounts []MountInfo, set Settings) string { + mut rows := []MountInfo{} + for m in mounts { + rows << m + } + if set.total { + mut t := MountInfo{ + device: 'total' + mounted: '-' + fstype: '-' + } + for m in mounts { + t.total_bytes += m.total_bytes + t.used_bytes += m.used_bytes + t.free_bytes += m.free_bytes + } + rows << t + } + + mut labels_size := if set.inodes { 'Inodes' } else { '1K-blocks' } + mut labels_used := if set.inodes { 'IUsed' } else { 'Used' } + mut labels_avail := if set.inodes { 'IFree' } else { 'Available' } + mut labels_pct := if set.inodes { 'IUse%' } else { 'Use%' } + + match set.mode { + .human, .si_human { + labels_size = 'Size' + labels_avail = 'Avail' + } + .mebibytes { + labels_size = '1M-blocks' + } + .gibibytes { + labels_size = '1G-blocks' + } + .blocks { + labels_size = '${set.block_size}-blocks' + } + else {} + } + + mut fields := [][]string{} + for r in rows { + fields << format_row(r, set).split('\t') + } + + // Column widths are the widest of the header, the data and a per-column + // minimum GNU reserves, so a value wider than its header pushes the + // header out to match. Measured against GNU 9.4: + // Filesystem minimum 14, then the widest device name + // Type minimum 4, right-justified + // Size/Used minimum 5 even when the header and data are shorter + // Use% minimum 4 + // The last column is left-justified and not padded, so GNU prints no + // trailing spaces when a mount point is short. + mut widths := [14, 5, 5, labels_avail.len, labels_pct.len, 0, 4] + headers := ['Filesystem', labels_size, labels_used, labels_avail, labels_pct, 'Mounted on', + 'Type'] + for i, h in headers { + if h.len > widths[i] { + widths[i] = h.len + } + } + for f in fields { + for i in 0 .. 7 { + if f[i].len > widths[i] { + widths[i] = f[i].len + } + } + } + + // The type column carries its own leading space so a line reads the same + // with and without --print-type. + type_header := if set.show_type { ' ' + rjust('Type', widths[6]) } else { '' } + + mut lines := []string{} + lines << '${ljust('Filesystem', widths[0])}${type_header} ${rjust(labels_size, widths[1])} ${rjust(labels_used, widths[2])} ${rjust(labels_avail, widths[3])} ${rjust(labels_pct, widths[4])} Mounted on' + + for f in fields { + type_col := if set.show_type { ' ' + rjust(f[6], widths[6]) } else { '' } + lines << '${ljust(f[0], widths[0])}${type_col} ${rjust(f[1], widths[1])} ${rjust(f[2], widths[2])} ${rjust(f[3], widths[3])} ${rjust(f[4], widths[4])} ${f[5]}' + } + return lines.join('\n') +} + +// format_row returns the six textual columns plus the file-system type, +// tab-separated. A tab is the delimiter because a device name, a mount point +// or a type string may contain spaces; render_table splits it back apart. +fn format_row(m MountInfo, set Settings) string { + mut size_str := '' + mut used_str := '' + mut avail_str := '' + + if set.inodes { + size_str = m.total_bytes.str() + used_str = m.used_bytes.str() + avail_str = m.free_bytes.str() + } else { + size_str = format_size(m.total_bytes, set) + used_str = format_size(m.used_bytes, set) + avail_str = format_size(m.free_bytes, set) + } + + // A volume whose total size is unreadable prints '-' rather than a + // percentage computed from zero. + pct_str := if m.total_bytes == 0 { + '-' + } else { + usage_pct(m.used_bytes, m.total_bytes) + } + + return '${m.device}\t${size_str}\t${used_str}\t${avail_str}\t${pct_str}\t${m.mounted}\t${m.fstype}' +} + +fn usage_pct(used u64, total u64) string { + return '${(used * 100 + total / 2) / total}%' +} + +pub fn format_size(bytes u64, set Settings) string { + match set.mode { + .human, .si_human { + return human_readable(bytes, set.mode == .si_human) + } + .blocks { + if set.block_size <= 0 { + return bytes.str() + } + return (bytes / u64(set.block_size)).str() + } + else { + mut div := u64(1024) + match set.mode { + .mebibytes { div = u64(1024) * 1024 } + .gibibytes { div = u64(1024) * 1024 * 1024 } + else {} + } + return (bytes / div).str() + } + } +} + +// human_readable prints at most four significant characters: one decimal +// below 10 and none at or above it. si selects powers of 1000 over 1024. +pub fn human_readable(bytes u64, si bool) string { + base := if si { u64(1000) } else { u64(1024) } + // --si keeps the SI 'k' but the conventional M, G, T for the rest; + // --human-readable uses K for the first unit. Measured at GNU 9.4: + // --si 132k 78M 1.1T + // -h 128K 74M 1007G + units := if si { 'kMGTPEZY' } else { 'KMGTPEZY' } + + mut value := f64(bytes) + mut idx := 0 + for idx < units.len && value >= f64(base) { + value /= f64(base) + idx++ + } + + mut body := if idx > 0 && value < 10.0 { + '${value:.1f}' + } else { + '${value:.0f}' + } + if idx > 0 { + body += units[idx - 1].ascii_str() + } + return body +} + +fn rjust(s string, w int) string { + if s.len >= w { + return s + } + return ' '.repeat(w - s.len) + s +} + +fn ljust(s string, w int) string { + if s.len >= w { + return s + } + return s + ' '.repeat(w - s.len) +} diff --git a/common/df/df_default.c.v b/common/df/df_default.c.v new file mode 100644 index 00000000..c62de7b8 --- /dev/null +++ b/common/df/df_default.c.v @@ -0,0 +1,8 @@ +module df + +pub const inodes_supported = false + +// list_mounts has no implementation on this platform. +pub fn list_mounts() []MountInfo { + return []MountInfo{} +} diff --git a/common/df/df_nix.c.v b/common/df/df_nix.c.v new file mode 100644 index 00000000..91d0ec66 --- /dev/null +++ b/common/df/df_nix.c.v @@ -0,0 +1,69 @@ +module df + +import os + +// inodes_supported is true on POSIX: statvfs reports a per-file-system inode +// count. +pub const inodes_supported = true + +// list_mounts reads the mount table and asks the kernel for each volume's +// usage. /proc/mounts is used in preference to getmntent because it is the +// table the kernel itself maintains, so it lists the same mounts df lists. +pub fn list_mounts() []MountInfo { + content := os.read_file('/proc/mounts') or { return []MountInfo{} } + mut out := []MountInfo{} + + for line in content.split_into_lines() { + if line == '' || line.starts_with('#') { + continue + } + // The kernel escapes any space, tab, newline or backslash in the + // device, mount point and type fields as an octal escape, so the + // line can be split on whitespace unconditionally. + fields := line.split(' ') + if fields.len < 3 { + continue + } + device := unescape_mount_field(fields[0]) + mounted := unescape_mount_field(fields[1]) + fstype := unescape_mount_field(fields[2]) + + // A mount the kernel will not size reports zeroes, which the + // filtering step hides unless --all is given. + usage := os.disk_usage(mounted) or { os.DiskUsage{} } + + out << MountInfo{ + device: device + mounted: mounted + fstype: fstype + total_bytes: usage.total + used_bytes: usage.used + free_bytes: usage.available + } + } + return out +} + +// unescape_mount_field decodes the octal escapes the kernel writes for the +// four characters that cannot appear literally in /proc/mounts. +fn unescape_mount_field(s string) string { + if !s.contains('\\') { + return s + } + mut out := []u8{} + mut i := 0 + for i < s.len { + c := s[i] + if c == `\\` && i + 3 < s.len && s[i + 1] >= `0` && s[i + 1] <= `7` + && s[i + 2] >= `0` && s[i + 2] <= `7` && s[i + 3] >= `0` && s[i + 3] <= `7` { + val := (int(s[i + 1]) - int(`0`)) * 64 + (int(s[i + 2]) - int(`0`)) * 8 + + (int(s[i + 3]) - int(`0`)) + out << u8(val) + i += 4 + } else { + out << c + i++ + } + } + return out.bytestr() +} diff --git a/common/df/df_windows.c.v b/common/df/df_windows.c.v new file mode 100644 index 00000000..84acac91 --- /dev/null +++ b/common/df/df_windows.c.v @@ -0,0 +1,64 @@ +module df + +#include + +fn C.GetLogicalDrives() u32 +fn C.GetVolumeInformationA(voidptr, voidptr, u32, voidptr, voidptr, voidptr, &char, u32) u32 +fn C.GetDiskFreeSpaceExA(voidptr, voidptr, &u64, &u64) u32 + +// inodes_supported is false on Windows: the APIs df needs report volume +// capacity but not the inode counts an -i listing is made of. +pub const inodes_supported = false + +// list_mounts enumerates the ready drives of this machine and reports their +// capacities. +// +// The device name is the drive letter because that is the name a Windows user +// has for the volume, and the mount point is the same letter as a directory, +// because a Windows volume has one root rather than a POSIX mount path. +pub fn list_mounts() []MountInfo { + mut out := []MountInfo{} + mask := C.GetLogicalDrives() + for i in 0 .. 26 { + if (mask & (u32(1) << u32(i))) == 0 { + continue + } + letter := u8(`A` + i) + root := '${letter.ascii_str()}:\\' + mut total := u64(0) + mut avail := u64(0) + if C.GetDiskFreeSpaceExA(&char(root.str), voidptr(unsafe { nil }), &total, &avail) == 0 { + // Drives that report no sizes are removable bays and network + // paths with no queryable capacity. + continue + } + out << MountInfo{ + device: root[..2] + mounted: root + fstype: volume_filesystem(root) + total_bytes: total + used_bytes: total - avail + free_bytes: avail + } + } + return out +} + +// volume_filesystem returns the file-system name of a ready drive, or an +// empty string when the volume cannot be described. +fn volume_filesystem(root string) string { + mut fs_buf := []u8{len: 64} + ret := C.GetVolumeInformationA(&char(root.str), voidptr(unsafe { nil }), 0, voidptr(unsafe { nil }), + voidptr(unsafe { nil }), voidptr(unsafe { nil }), voidptr(fs_buf.data), fs_buf.len) + if ret == 0 { + return '' + } + mut out := '' + for c in fs_buf { + if c == 0 { + break + } + out += c.ascii_str() + } + return out +} diff --git a/src/df/df.v b/src/df/df.v new file mode 100644 index 00000000..f22d5b9c --- /dev/null +++ b/src/df/df.v @@ -0,0 +1,11 @@ +import common.df as dfmod +import os + +fn main() { + set := dfmod.parse_args(os.args[1..]) + output := dfmod.report(set) or { + eprintln('df: ${err.msg()}') + exit(1) + } + println(output) +} diff --git a/src/df/df_test.v b/src/df/df_test.v new file mode 100644 index 00000000..e4838c36 --- /dev/null +++ b/src/df/df_test.v @@ -0,0 +1,202 @@ +// These tests are compiled on their own by `v test src/df/`, so the logic +// under test is imported from common/df rather than defined here. +import common.df as colondf +import common.testing + +const rig = testing.prepare_rig(util: 'df') + +fn mk_mount(device string, mounted string, fstype string, total u64, used u64, free u64) colondf.MountInfo { + return colondf.MountInfo{ + device: device + mounted: mounted + fstype: fstype + total_bytes: total + used_bytes: used + free_bytes: free + } +} + +const fs_a = mk_mount('/dev/sdb', '/', 'ext4', 100 * 1024 * 1024, 26 * 1024 * 1024, + 74 * 1024 * 1024) +const fs_b = mk_mount('/dev/sdc', '/mnt/data', 'ext4', 20 * 1024 * 1024, 2 * 1024 * 1024, + 18 * 1024 * 1024) +const fs_zero = mk_mount('/dev/sdz', '/mnt/empty', 'tmpfs', 0, 0, 0) + +fn testsuite_begin() { + rig.assert_platform_util() +} + +fn test_help_and_version() { + rig.assert_help_and_version_options_work() +} + +fn test_default_columns() { + set := colondf.parse_args([]) + out := colondf.render_table([fs_a, fs_b], set) + // Values are 1K blocks: 100 MiB is 102400 of them. + expected := 'Filesystem 1K-blocks Used Available Use% Mounted on\n' + + '/dev/sdb 102400 26624 75776 26% /\n' + + '/dev/sdc 20480 2048 18432 10% /mnt/data' + assert out == expected +} + +fn test_print_type() { + set := colondf.parse_args(['-T']) + out := colondf.render_table([fs_a], set) + expected := 'Filesystem Type 1K-blocks Used Available Use% Mounted on\n' + + '/dev/sdb ext4 102400 26624 75776 26% /' + assert out == expected +} + +fn test_human_readable() { + set := colondf.parse_args(['-h']) + out := colondf.render_table([fs_a], set) + expected := 'Filesystem Size Used Avail Use% Mounted on\n' + + '/dev/sdb 100M 26M 74M 26% /' + assert out == expected +} + +fn test_si_human_readable() { + set := colondf.parse_args(['-H']) + out := colondf.render_table([fs_a], set) + expected := 'Filesystem Size Used Avail Use% Mounted on\n' + + '/dev/sdb 105M 27M 78M 26% /' + assert out == expected +} + +fn test_human_readable_ranges() { + set := colondf.parse_args(['-h']) + // One decimal below ten, none at or above it. + assert colondf.format_size(0, set) == '0' + assert colondf.format_size(1, set) == '1' + assert colondf.format_size(1024 * 1024, set) == '1.0M' + assert colondf.format_size(1073741824, set) == '1.0G' + assert colondf.format_size(1536 * 1024 * 1024, set) == '1.5G' + assert colondf.format_size(26 * 1024 * 1024, set) == '26M' + assert colondf.format_size(100 * 1024 * 1024, set) == '100M' + + set_si := colondf.parse_args(['-H']) + assert colondf.format_size(100 * 1024 * 1024, set_si) == '105M' + // --si keeps the SI lowercase k for the first unit only. + assert colondf.format_size(132 * 1000, set_si) == '132k' + assert colondf.format_size(128 * 1024, colondf.parse_args(['-h'])) == '128K' +} + +fn test_block_size() { + set := colondf.parse_args(['-B', '512']) + assert set.mode == .blocks + assert set.block_size == 512 + + set2 := colondf.parse_args(['-B512']) + assert set2.block_size == 512 + + set3 := colondf.parse_args(['--block-size=1024']) + assert set3.mode == .blocks + assert set3.block_size == 1024 + + out := colondf.render_table([fs_a], set3) + // The device column is 14 wide, then one separator, then the label. + assert out.starts_with('Filesystem 1024-blocks') +} + +fn test_k_and_posix_modes() { + assert colondf.parse_args(['-k']).mode == .kibibytes + assert colondf.parse_args(['-m']).mode == .mebibytes + assert colondf.parse_args(['-g']).mode == .gibibytes + // -P is POSIX output: 512-byte blocks. + p := colondf.parse_args(['-P']) + assert p.mode == .blocks + assert p.block_size == 512 +} + +fn test_clustered_short_options() { + set := colondf.parse_args(['-hT']) + assert set.mode == .human + assert set.show_type + + set2 := colondf.parse_args(['-Th']) + assert set2.mode == .human + assert set2.show_type +} + +fn test_total() { + set := colondf.parse_args(['--total']) + out := colondf.render_table([fs_a, fs_b], set) + expected := 'Filesystem 1K-blocks Used Available Use% Mounted on\n' + + '/dev/sdb 102400 26624 75776 26% /\n' + + '/dev/sdc 20480 2048 18432 10% /mnt/data\n' + + 'total 122880 28672 94208 23% -' + assert out == expected +} + +fn test_type_filters() { + set := colondf.parse_args(['-t', 'ext4']) + assert set.include_types == ['ext4'] + + set2 := colondf.parse_args(['--type=ext4,tmpfs']) + assert set2.include_types == ['ext4', 'tmpfs'] + + set3 := colondf.parse_args(['-x', 'tmpfs']) + assert set3.exclude_types == ['tmpfs'] +} + +fn test_double_dash_ends_options() { + set := colondf.parse_args(['--', '-h']) + assert set.operands == ['-h'] + // A lone '-' is a FILE (standard input here), not an option. + set2 := colondf.parse_args(['-']) + assert set2.operands == ['-'] +} + +fn test_local_flag_parses() { + set := colondf.parse_args(['-l']) + assert set.local + assert colondf.parse_args(['--local']).local +} + +fn test_collect_mounts_filters_unsizable() { + // A volume the platform cannot size has no usable numbers and is hidden + // unless --all is given. + set := colondf.parse_args([]) + mounts := colondf.list_mounts() + filtered := colondf.collect_mounts(set) + assert filtered.len <= mounts.len + for m in filtered { + assert m.total_bytes > 0 + } +} + +fn test_collect_mounts_all_shows_unsizable() { + set := colondf.parse_args(['-a']) + mounts := colondf.list_mounts() + filtered := colondf.collect_mounts(set) + assert filtered.len == mounts.len +} + +fn test_collect_mounts_type_filter() { + all_set := colondf.parse_args(['-a']) + all := colondf.collect_mounts(all_set) + if all.len > 0 { + ftype := all[0].fstype + set := colondf.parse_args(['-t', ftype]) + kept := colondf.collect_mounts(set) + assert kept.len > 0 + for m in kept { + assert m.fstype == ftype + } + } +} + +fn test_zero_size_row_percentage() { + set := colondf.parse_args(['-a']) + out := colondf.render_table([fs_zero], set) + // A volume whose total size is unreadable has no computable percentage. + assert out.ends_with(' - /mnt/empty') +} + +fn test_parse_block_size_rejects_non_numeric() { + assert colondf.parse_block_size('512')? == 512 + assert colondf.parse_block_size('1K') == none + assert colondf.parse_block_size('') == none + assert colondf.parse_block_size('-1') == none +}