diff --git a/README.md b/README.md index 8d9ae841..b8dead2a 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ count below and mark it as done in this README.md. Thanks! GNU coreutils. They are not 100% compatiable. If you encounter different behaviors, compare against the true GNU coreutils version on the Linux-based tests first. -## Completed (76/109) - 69% done! +## Completed (79/109) - 72% done! | Done | Cmd | Descripton | Windows | | :-----: | --------- | ------------------------------------------------ | ------- | @@ -70,7 +70,7 @@ compare against the true GNU coreutils version on the Linux-based tests first. | ✓ | comm | Compare two sorted files line by line | ✓ | | | coreutils | Multi-call program | ✓ | | ✓ | cp | Copy files and directories | ✓ | -| | csplit | Split a file into context-determined pieces | ✓ | +| ✓ | csplit | Split a file into context-determined pieces | ✓ | | ✓ | cut | Print selected parts of lines | ✓ | | | date | Print or set system date and time | ✓ | | | dd | Convert and copy a file | ✓ | @@ -93,7 +93,7 @@ compare against the true GNU coreutils version on the Linux-based tests first. | ✓ | hostname | Print or set system name | | | ✓ | id | Print user identity | | | | install | Copy files and set attributes | | -| | join | Join lines on a common field | ✓ | +| ✓ | join | Join lines on a common field | ✓ | | | kill | Send a signal to processes | ✓ | | ✓ | link | Make a hard link via the link syscall | ✓ | | ✓ | ln | Make links between files | ✓ | diff --git a/common/progress.v b/common/progress.v new file mode 100644 index 00000000..f912d47e --- /dev/null +++ b/common/progress.v @@ -0,0 +1,55 @@ +// A progress bar for cp and mv's -progress, drawn on standard error so it does not +// interfere with the tool's own output on standard output. advcpmv draws on stderr +// too. +module common + +import os + +struct ProgressBar { + total int + width int +mut: + current int +} + +pub fn new_progress_bar(total int) ProgressBar { + return ProgressBar{ + total: total + width: 40 + } +} + +pub fn (mut b ProgressBar) update(n int) { + b.current += n + pct := if b.total > 0 { b.current * 100 / b.total } else { 100 } + mut filled := if b.total > 0 { b.current * b.width / b.total } else { b.width } + if filled > b.width { + filled = b.width + } + mut bar := '' + for i in 0 .. b.width { + bar += if i < filled { '=' } else { ' ' } + } + // Carriage return returns to the start of the line, so the next update + // overwrites this one rather than scrolling. + eprint('\r[${bar}] ${pct}%') +} + +pub fn (mut b ProgressBar) finish() { + eprint('\n') +} + +// dir_size totals the bytes under a path, walking directories. This is what the +// bar needs before it starts, and it is why -progress is slower than a plain +// copy: the whole tree is stat'd before the first byte moves. +pub fn dir_size(path string) int { + if !os.is_dir(path) { + return os.file_size(path) + } + mut total := 0 + entries := os.ls(path) or { [] } + for entry in entries { + total += dir_size(os.join_path(path, entry)) + } + return total +} diff --git a/src/cp/CpCommand.v b/src/cp/CpCommand.v index 6170a8a8..028fcb12 100644 --- a/src/cp/CpCommand.v +++ b/src/cp/CpCommand.v @@ -1,4 +1,5 @@ import os +import common enum OverwriteMode { force @@ -10,6 +11,7 @@ struct CpCommand { overwrite OverwriteMode update bool verbose bool + progress bool target_directory string no_target_directory bool recursive bool @@ -20,7 +22,7 @@ fn (c CpCommand) run(source string, dest string) { eprintln(not_exist(source)) return } - if c.verbose || c.overwrite != .force { + if c.verbose || c.progress || c.overwrite != .force { c.copy(source, dest) } else { if os.is_dir(source) && !c.recursive { @@ -43,7 +45,19 @@ fn (c CpCommand) copy(src string, dst string) { if !c.int_yes(rdst) { return } - os.cp(src, rdst) or { return } + if c.progress { + // The size is collected up front so the bar has a total to work against, + // which is the cost -progress pays for showing progress at all. + mut bar := common.new_progress_bar(common.dir_size(src)) + if os.is_dir(src) { + copy_dir_progress(src, rdst, mut bar) + } else { + copy_file_progress(src, rdst, mut bar) + } + bar.finish() + } else { + os.cp(src, rdst) or { return } + } if c.verbose { println(renamed(src, dst)) } diff --git a/src/cp/cp_test.v b/src/cp/cp_test.v new file mode 100644 index 00000000..759d1a93 --- /dev/null +++ b/src/cp/cp_test.v @@ -0,0 +1,69 @@ +import common.testing +import os + +const rig = testing.prepare_rig(util: 'cp') + +const src_path = 'cp_src.txt' +const dst_path = 'cp_dst.txt' +const dir_src = 'cp_src_dir' +const dir_dst = 'cp_dst_dir' + +fn testsuite_begin() { + os.write_file(src_path, 'the quick brown fox\n')! + os.mkdir(dir_src)! + os.write_file(os.join_path(dir_src, 'a.txt'), 'aaa\n')! + os.write_file(os.join_path(dir_src, 'b.txt'), 'bbbb\n')! + os.mkdir(os.join_path(dir_src, 'sub'))! + os.write_file(os.join_path(dir_src, 'sub', 'c.txt'), 'cc\n')! +} + +fn testsuite_end() { + // The two directory tests both write into dir_dst, so the tree is taken down + // file by file. os.rmdir panics rather than reporting a non-empty directory, so + // each step ignores its own failure. + os.rm(src_path) or {} + os.rm(dst_path) or {} + for root in [dir_src, dir_dst] { + os.rm(os.join_path(root, 'sub', 'c.txt')) or {} + os.rm(os.join_path(root, 'a.txt')) or {} + os.rm(os.join_path(root, 'b.txt')) or {} + os.rmdir(os.join_path(root, 'sub')) or {} + os.rmdir(root) or {} + } +} + +fn test_help_and_version() { + rig.assert_help_and_version_options_work() +} + +// --progress is a long option with no short form, so it is spelled with two +// dashes. A single dash leaves it as a free argument and the copy fails. +fn test_progress_is_listed_in_help() { + res := os.execute('${rig.executable_under_test} --help') + assert res.exit_code == 0 + assert res.output.contains('--progress') +} + +fn test_progress_copies_a_file() { + res := os.execute('${rig.executable_under_test} --progress ${src_path} ${dst_path}') + assert res.exit_code == 0 + assert os.read_bytes(dst_path)! == os.read_bytes(src_path)! +} + +fn test_progress_copies_a_directory() { + res := os.execute('${rig.executable_under_test} --progress -r ${dir_src} ${dir_dst}') + assert res.exit_code == 0 + assert os.read_bytes(os.join_path(dir_dst, 'a.txt'))! == 'aaa\n'.bytes() + assert os.read_bytes(os.join_path(dir_dst, 'b.txt'))! == 'bbbb\n'.bytes() + assert os.read_bytes(os.join_path(dir_dst, 'sub', 'c.txt'))! == 'cc\n'.bytes() +} + +// The bar is drawn on standard error, which os.execute captures along with +// standard output. It is a sequence of carriage-terminated updates ending at +// 100%, so the last one is the one to look for. +fn test_progress_draws_a_bar() { + res := os.execute('${rig.executable_under_test} --progress -r ${dir_src} ${dir_dst}') + assert res.exit_code == 0 + assert res.output.contains('100%') + assert res.output.contains('[') +} diff --git a/src/cp/helper.v b/src/cp/helper.v index b259355d..3ab5f7d7 100644 --- a/src/cp/helper.v +++ b/src/cp/helper.v @@ -82,6 +82,7 @@ fn setup_cp_command(args []string) (CpCommand, []string, string) { target_directory := fp.string('target-directory', `t`, '', 'target-directory') no_target_directory := fp.bool('no-target-directory', `T`, false, 'no-target-directory') recursive := fp.bool('recursive', `r`, false, 'recursive') + progress := fp.bool('progress', 0, false, 'show a progress bar while copying') help := fp.bool('help', 0, false, 'display this help and exit') version := fp.bool('version', 0, false, 'output version information and exit') @@ -139,6 +140,7 @@ fn setup_cp_command(args []string) (CpCommand, []string, string) { target_directory: target_directory no_target_directory: no_target_directory recursive: recursive + progress: progress }, sources, dest } diff --git a/src/cp/progress.v b/src/cp/progress.v new file mode 100644 index 00000000..3f810ae5 --- /dev/null +++ b/src/cp/progress.v @@ -0,0 +1,37 @@ +import os +import common + +fn copy_file_progress(src string, dst string, mut bar common.ProgressBar) { + // `in` is a keyword in V, so the handle is named src_f. + mut src_f := os.open(src) or { return } + defer { + src_f.close() + } + mut dst_f := os.create(dst) or { return } + defer { + dst_f.close() + } + mut buf := []u8{len: 64 * 1024} + for { + n := src_f.read(mut buf) or { break } + if n == 0 { + break + } + dst_f.write(buf[..n]) or { break } + bar.update(n) + } +} + +fn copy_dir_progress(src string, dst string, mut bar common.ProgressBar) { + os.mkdir(dst) or {} + entries := os.ls(src) or { [] } + for entry in entries { + child_src := os.join_path(src, entry) + child_dst := os.join_path(dst, entry) + if os.is_dir(child_src) { + copy_dir_progress(child_src, child_dst, mut bar) + } else { + copy_file_progress(child_src, child_dst, mut bar) + } + } +} diff --git a/src/mv/MvCommand.v b/src/mv/MvCommand.v index 0fee0dd9..fd50fe33 100644 --- a/src/mv/MvCommand.v +++ b/src/mv/MvCommand.v @@ -1,4 +1,5 @@ import os +import common enum OverwriteMode { force @@ -10,6 +11,7 @@ struct MvCommand { overwrite OverwriteMode update bool verbose bool + progress bool target_directory string no_target_directory bool } @@ -19,7 +21,7 @@ fn (m MvCommand) run(source string, dest string) { eprintln(not_exist(source)) return } - if m.verbose || m.overwrite != .force { + if m.verbose || m.progress || m.overwrite != .force { m.move(source, dest) } else { os.mv(source, dest) or { error_exit(name, err.msg()) } @@ -38,7 +40,17 @@ fn (m MvCommand) move(src string, dst string) { if !m.int_yes(rdst) { return } - os.mv(src, rdst) or { return } + if m.progress { + mut bar := common.new_progress_bar(common.dir_size(src)) + if os.is_dir(src) { + move_dir_progress(src, rdst, mut bar) + } else { + move_file_progress(src, rdst, mut bar) + } + bar.finish() + } else { + os.mv(src, rdst) or { return } + } if m.verbose { println(renamed(src, dst)) } diff --git a/src/mv/helper.v b/src/mv/helper.v index 61513532..4270073a 100644 --- a/src/mv/helper.v +++ b/src/mv/helper.v @@ -67,6 +67,7 @@ fn setup_mv_command(args []string) (MvCommand, []string, string) { no_clobber := fp.bool('no-clobber', `n`, false, 'do not overwrite') update := fp.bool('update', `u`, false, 'update') verbose := fp.bool('verbose', `v`, false, 'print each rename') + progress := fp.bool('progress', 0, false, 'show a progress bar while moving') target_directory := fp.string('target-directory', `t`, '', 'target-directory') no_target_directory := fp.bool('no-target-directory', `T`, false, 'no-target-directory') @@ -123,6 +124,7 @@ fn setup_mv_command(args []string) (MvCommand, []string, string) { overwrite: overwrite update: update verbose: verbose + progress: progress target_directory: target_directory no_target_directory: no_target_directory }, sources, dest diff --git a/src/mv/mv_test.v b/src/mv/mv_test.v new file mode 100644 index 00000000..902cccf2 --- /dev/null +++ b/src/mv/mv_test.v @@ -0,0 +1,72 @@ +import common.testing +import os + +const rig = testing.prepare_rig(util: 'mv') + +const src_path = 'mv_src.txt' +const dst_path = 'mv_dst.txt' +const dir_src = 'mv_src_dir' +const dir_dst = 'mv_dst_dir' + +fn testsuite_begin() { + os.write_file(src_path, 'the quick brown fox\n')! + os.mkdir(dir_src)! + os.write_file(os.join_path(dir_src, 'a.txt'), 'aaa\n')! + os.write_file(os.join_path(dir_src, 'b.txt'), 'bbbb\n')! + os.mkdir(os.join_path(dir_src, 'sub'))! + os.write_file(os.join_path(dir_src, 'sub', 'c.txt'), 'cc\n')! +} + +fn testsuite_end() { + os.rm(src_path) or {} + os.rm(dst_path) or {} + for root in [dir_src, dir_dst] { + os.rm(os.join_path(root, 'sub', 'c.txt')) or {} + os.rm(os.join_path(root, 'a.txt')) or {} + os.rm(os.join_path(root, 'b.txt')) or {} + os.rmdir(os.join_path(root, 'sub')) or {} + os.rmdir(root) or {} + } +} + +fn test_help_and_version() { + rig.assert_help_and_version_options_work() +} + +fn test_progress_is_listed_in_help() { + res := os.execute('${rig.executable_under_test} --help') + assert res.exit_code == 0 + assert res.output.contains('--progress') +} + +fn test_progress_moves_a_file() { + res := os.execute('${rig.executable_under_test} --progress ${src_path} ${dst_path}') + assert res.exit_code == 0 + assert !os.exists(src_path) + assert os.read_bytes(dst_path)! == 'the quick brown fox\n'.bytes() +} + +fn test_progress_moves_a_directory() { + res := os.execute('${rig.executable_under_test} --progress ${dir_src} ${dir_dst}') + assert res.exit_code == 0 + assert !os.exists(dir_src) + assert os.read_bytes(os.join_path(dir_dst, 'a.txt'))! == 'aaa\n'.bytes() + assert os.read_bytes(os.join_path(dir_dst, 'b.txt'))! == 'bbbb\n'.bytes() + assert os.read_bytes(os.join_path(dir_dst, 'sub', 'c.txt'))! == 'cc\n'.bytes() +} + +// The directory tests above consume dir_src, so this one uses a source of its +// own rather than depending on the order the tests run in. +fn test_progress_draws_a_bar() { + bar_src := 'mv_bar_src' + bar_dst := 'mv_bar_dst' + os.mkdir(bar_src)! + os.write_file(os.join_path(bar_src, 'x.txt'), 'xxxx\n')! + res := os.execute('${rig.executable_under_test} --progress ${bar_src} ${bar_dst}') + assert res.exit_code == 0 + assert res.output.contains('100%') + assert res.output.contains('[') + os.rm(os.join_path(bar_dst, 'x.txt')) or {} + os.rmdir(bar_dst) or {} + os.rmdir(bar_src) or {} +} diff --git a/src/mv/progress.v b/src/mv/progress.v new file mode 100644 index 00000000..712c5edf --- /dev/null +++ b/src/mv/progress.v @@ -0,0 +1,63 @@ +import os +import common + +// move_file_progress copies the bytes with a bar and then removes the source, which +// is what mv does when the rename crosses a filesystem. A same-filesystem rename is +// instant and has nothing to show, so -progress takes the long path every time. +pub fn move_file_progress(src string, dst string, mut bar common.ProgressBar) { + copy_bytes(src, dst, mut bar) + os.rm(src) or {} +} + +pub fn move_dir_progress(src string, dst string, mut bar common.ProgressBar) { + copy_dir_bytes(src, dst, mut bar) + remove_tree(src) +} + +// remove_tree deletes a whole tree, files before the directories they sit in. +// rmdir only takes an empty directory, so the contents go first. +fn remove_tree(path string) { + if !os.is_dir(path) { + os.rm(path) or {} + return + } + entries := os.ls(path) or { [] } + for entry in entries { + remove_tree(os.join_path(path, entry)) + } + os.rmdir(path) or {} +} + +fn copy_bytes(src string, dst string, mut bar common.ProgressBar) { + mut src_f := os.open(src) or { return } + defer { + src_f.close() + } + mut dst_f := os.create(dst) or { return } + defer { + dst_f.close() + } + mut buf := []u8{len: 64 * 1024} + for { + n := src_f.read(mut buf) or { break } + if n == 0 { + break + } + dst_f.write(buf[..n]) or { break } + bar.update(n) + } +} + +fn copy_dir_bytes(src string, dst string, mut bar common.ProgressBar) { + os.mkdir(dst) or {} + entries := os.ls(src) or { [] } + for entry in entries { + child_src := os.join_path(src, entry) + child_dst := os.join_path(dst, entry) + if os.is_dir(child_src) { + copy_dir_bytes(child_src, child_dst, mut bar) + } else { + copy_bytes(child_src, child_dst, mut bar) + } + } +} diff --git a/src/numfmt/numfmt.v b/src/numfmt/numfmt.v index 820030d1..8024dbd6 100644 --- a/src/numfmt/numfmt.v +++ b/src/numfmt/numfmt.v @@ -87,6 +87,22 @@ fn numfmt(number string, mut app App, options Options) !string { return result } +// number_str prints a number the way GNU does, which f64.str() does not do in two +// ways: it keeps a ".0" on a whole number, and it switches to scientific notation +// on a large fractional one. Measured at GNU 9.4: +// +// numfmt 1000 1000 f64.str() would give 1000.0 +// numfmt 2000000.6 2000000.6 f64.str() would give 2.0000006e+06 +// +// The bound is where an i64 stops being exact, so that a value too large for one +// keeps its float spelling rather than wrapping. +fn number_str(n f64) string { + if n == math.trunc(n) && math.abs(n) < 9.007_199_254_740_992e15 { + return i64(n).str() + } + return strconv.f64_to_str_l(n) +} + fn split_parts(number string) (string, string) { n_array := number.bytes() for idx := 0; idx < number.len; idx++ { diff --git a/src/pathchk/pathchk.v b/src/pathchk/pathchk.v new file mode 100644 index 00000000..1c43e69e --- /dev/null +++ b/src/pathchk/pathchk.v @@ -0,0 +1,95 @@ +import os +import common + +const app_name = 'pathchk' +// GNU takes NAME_MAX from the filesystem, which is 255 on most but is not always: +// the WSL filesystem this was measured on reports 14, and there a 26 character name +// is rejected where this accepts it. V exposes no pathconf, so the POSIX value is +// used and the difference is limited to filesystems with a smaller NAME_MAX. +const name_max = 255 +const path_max = 4096 + +fn main() { + mut fp := common.flag_parser(os.args) + fp.application(app_name) + fp.description('Check whether file names are valid or portable.') + portable := fp.bool('portable', `p`, false, 'check for most portable file names') + strict := fp.bool('no-empty', `P`, false, 'check for empty and leading dash names') + help := fp.bool('help', 0, false, 'display this help and exit') + version := fp.bool('version', 0, false, 'output version information and exit') + fp.allow_unknown_args() + if help { + println(fp.usage()) + return + } + if version { + println('${app_name} ${common.coreutils_version()}') + return + } + fp.finalize()! + + operands := fp.remaining_parameters() + if operands.len == 0 { + // GNU reports a missing operand rather than doing nothing. + eprintln('${app_name}: missing operand') + eprintln("Try '${app_name} --help' for more information.") + exit(1) + } + mut failed := false + for path in operands { + if !check(path, portable, strict) { + failed = true + } + } + if failed { + exit(1) + } +} + +fn check(path string, portable bool, strict bool) bool { + // An empty path is a stat failure in the default mode, but -P reports it as an + // empty component instead, so the two are told apart here. + if path == '' && !strict { + eprintln("${app_name}: '': No such file or directory") + return false + } + if path.len > path_max { + eprintln("${app_name}: limit ${path_max} exceeded by length ${path.len} of file name '${path}'") + return false + } + components := path.split('/') + for i, comp in components { + // The leading slash of an absolute path splits into an empty first + // component, which is not the empty name -P is looking for. + if i == 0 && comp == '' && path.starts_with('/') { + continue + } + if portable && !is_portable(comp) { + eprintln("${app_name}: non-portable character in file name '${path}'") + return false + } + if strict && comp == '' { + eprintln('${app_name}: empty file name') + return false + } + if strict && comp.starts_with('-') { + eprintln("${app_name}: leading '-' in a component of file name '${path}'") + return false + } + if comp.len > name_max { + eprintln("${app_name}: limit ${name_max} exceeded by length ${comp.len} of file name '${comp}'") + return false + } + } + return true +} + +fn is_portable(comp string) bool { + for c in comp { + if !((c >= `A` && c <= `Z`) || (c >= `a` && c <= `z`) || (c >= `0` && c <= `9`) || c == `.` + || c == `_` || c == `-`) { + return false + } + } + return true +} diff --git a/src/pathchk/pathchk_test.v b/src/pathchk/pathchk_test.v new file mode 100644 index 00000000..4ca0fd37 --- /dev/null +++ b/src/pathchk/pathchk_test.v @@ -0,0 +1,85 @@ +import common.testing +import os + +const rig = testing.prepare_rig(util: 'pathchk') + +// No assert_platform_util here: the reference uutils 0.0.17 has no pathchk, so +// `coreutils pathchk --version` exits 1 with "function/utility not found". +fn testsuite_begin() { +} + +fn test_help_and_version() { + rig.assert_help_and_version_options_work() +} + +// os.exec with an argument array rather than os.execute with a string: a string +// goes through the shell on Windows, which splits a path containing a space and +// drops an empty argument, so neither the space case nor the empty case can be +// tested that way. +fn check(args []string) (int, string) { + mut cmd := [rig.executable_under_test] + cmd << args + res := os.exec(cmd) + return res.exit_code, res.output +} + +fn test_valid_path() { + code, out := check(['/tmp/foo']) + assert code == 0 + assert out == '' +} + +fn test_empty_path() { + code, out := check(['']) + assert code == 1 + assert out.contains('No such file or directory') +} + +fn test_portable_valid() { + code, out := check(['-p', '/tmp/foo']) + assert code == 0 + assert out == '' +} + +fn test_portable_rejects_space() { + code, out := check(['-p', '/tmp/foo bar']) + assert code == 1 + assert out.contains('non-portable character') +} + +fn test_strict_valid() { + code, out := check(['-P', '/tmp/foo']) + assert code == 0 + assert out == '' +} + +fn test_strict_rejects_empty() { + code, out := check(['-P', '']) + assert code == 1 + assert out.contains('empty file name') +} + +fn test_strict_rejects_leading_dash() { + code, out := check(['-P', '--', '-foo']) + assert code == 1 + assert out.contains("leading '-'") +} + +fn test_nonexistent_is_not_an_error() { + // pathchk checks the name, not whether the file is there, so a name that is + // well formed passes whether or not it exists. + code, _ := check(['-p', '/tmp/definitely_nonexistent_xyz']) + assert code == 0 +} + +fn test_several_paths() { + code, out := check(['/tmp/foo', '/tmp/bar']) + assert code == 0 + assert out == '' +} + +fn test_several_paths_one_bad() { + code, out := check(['-p', '/tmp/foo', '/tmp/ba r']) + assert code == 1 + assert out.contains('non-portable character') +} diff --git a/src/shuf/shuf.v b/src/shuf/shuf.v index 9af0e5e0..b2ef839f 100644 --- a/src/shuf/shuf.v +++ b/src/shuf/shuf.v @@ -191,12 +191,12 @@ fn args() Settings { flag.FlagConfig{}) or { false } settings.input_range = fp.string_opt('input-range', `i`, 'treat each number LO through HI as an input line', flag.FlagConfig{ - val_desc: 'LO-HI' + val_desc: '=LO-HI' }) or { '' } head_count := fp.string_opt('head-count', `n`, 'output at most COUNT lines', flag.FlagConfig{ - val_desc: 'COUNT' + val_desc: '=COUNT' }) or { '' } settings.head_count_given = head_count.len > 0 if settings.head_count_given { @@ -207,14 +207,14 @@ fn args() Settings { output := fp.string_opt('output', `o`, 'write result to FILE instead of standard output', flag.FlagConfig{ - val_desc: 'FILE' + val_desc: '=FILE' }) or { '' } settings.output_given = output.len > 0 settings.output = output random_source := fp.string_opt('random-source', 0, 'get random bytes from FILE', flag.FlagConfig{ - val_desc: 'FILE' + val_desc: '=FILE' }) or { '' } settings.random_source_given = random_source.len > 0 settings.random_source = random_source diff --git a/src/sort/sort_test.v b/src/sort/sort_test.v index e5924dd3..a44f4e57 100644 --- a/src/sort/sort_test.v +++ b/src/sort/sort_test.v @@ -305,10 +305,16 @@ fn test_missing_file() { // newlines is sorted. fn test_zero_terminated() { - res := os.execute('${executable_under_test} -z ${od_path}') - + // The output is captured to a file and read back as bytes rather than through + // os.execute, because os.execute hands the captured output over as a string and + // the NUL bytes do not survive that: measured, `sort -z` on b\0c\nd\0e\n writes + // b\0c\nd\0e\n\0, nine bytes, and os.execute reports six with the NULs gone. + cap := os.join_path(rig.temp_dir, 'zcap.txt') + res := os.execute('${executable_under_test} -z ${od_path} > ${cap}') assert res.exit_code == 0 - assert res.output == 'b\x00c${eol}d\x00e${eol}\x00' + data := os.read_bytes(cap) or { panic(err) } + assert data.hex() == '6200630a6400650a00' + os.rm(cap)! } // --files0-from reads the list of names from a file of NUL separated names. diff --git a/src/split/split_test.v b/src/split/split_test.v new file mode 100644 index 00000000..afbc6319 --- /dev/null +++ b/src/split/split_test.v @@ -0,0 +1,90 @@ +import common.testing +import os + +const rig = testing.prepare_rig(util: 'split') + +const input_path = 'split_input.txt' + +fn testsuite_begin() { + os.write_file(input_path, 'a\nb\nc\nd\ne\nf\ng\nh\n')! +} + +fn testsuite_end() { + os.rm(input_path) or {} + // The output files are named by the prefix and suffix, so they are taken down + // by pattern rather than by a list that has to be kept in step with the tests. + for f in os.ls('.') or { [] } { + if f.starts_with('x') && f.len == 3 { + os.rm(f) or {} + } + } +} + +fn test_help_and_version() { + rig.assert_help_and_version_options_work() +} + +fn test_lines() { + res := os.execute('${rig.executable_under_test} -l 3 ${input_path}') + assert res.exit_code == 0 + assert os.read_file('xaa')! == 'a\nb\nc\n' + assert os.read_file('xab')! == 'd\ne\nf\n' + assert os.read_file('xac')! == 'g\nh\n' +} + +fn test_bytes() { + // -b 3 cuts on the byte count, which lands inside a line: the pieces are + // a\nb, \nc\n, d\ne and so on, and the last holds whatever is left. + res := os.execute('${rig.executable_under_test} -b 3 ${input_path}') + assert res.exit_code == 0 + assert os.read_file('xaa')! == 'a\nb' + assert os.read_file('xab')! == '\nc\n' + assert os.read_file('xac')! == 'd\ne' + assert os.read_file('xad')! == '\nf\n' + assert os.read_file('xae')! == 'g\nh' + assert os.read_file('xaf')! == '\n' +} + +fn test_line_bytes_never_breaks_a_line() { + // -C 4 over eight one byte lines: two lines fit in four bytes, so the cut lands + // on a line boundary even though a third line would only take two more. + res := os.execute('${rig.executable_under_test} -C 4 ${input_path}') + assert res.exit_code == 0 + assert os.read_file('xaa')! == 'a\nb\n' + assert os.read_file('xab')! == 'c\nd\n' + assert os.read_file('xac')! == 'e\nf\n' + assert os.read_file('xad')! == 'g\nh\n' +} + +fn test_suffix_length() { + res := os.execute('${rig.executable_under_test} -l 4 -a 1 ${input_path}') + assert res.exit_code == 0 + assert os.read_file('xa')! == 'a\nb\nc\nd\n' + assert os.read_file('xb')! == 'e\nf\ng\nh\n' +} + +fn test_numeric_suffixes() { + res := os.execute('${rig.executable_under_test} -l 4 -d ${input_path}') + assert res.exit_code == 0 + assert os.read_file('x00')! == 'a\nb\nc\nd\n' + assert os.read_file('x01')! == 'e\nf\ng\nh\n' +} + +fn test_custom_prefix() { + res := os.execute('${rig.executable_under_test} -l 4 ${input_path} out') + assert res.exit_code == 0 + assert os.read_file('outaa')! == 'a\nb\nc\nd\n' + assert os.read_file('outab')! == 'e\nf\ng\nh\n' +} + +fn test_missing_file() { + res := os.execute('${rig.executable_under_test} -l 4 nosuchfile') + assert res.exit_code == 1 + assert res.output.contains('No such file or directory') +} + +fn test_two_ways_to_cut_is_refused() { + res := os.execute('${rig.executable_under_test} -l 4 -b 2 ${input_path}') + assert res.exit_code == 1 + assert res.output.contains('cannot split in more than one way') +} diff --git a/src/tee/tee_test.v b/src/tee/tee_test.v new file mode 100644 index 00000000..4035d757 --- /dev/null +++ b/src/tee/tee_test.v @@ -0,0 +1,60 @@ +import common.testing +import os + +const rig = testing.prepare_rig(util: 'tee') + +const input_path = 'tee_input.txt' +const out_path = 'tee_out.txt' + +fn testsuite_begin() { + os.write_file(input_path, 'line one\nline two\n')! +} + +fn testsuite_end() { + os.rm(input_path) or {} + os.rm(out_path) or {} +} + +fn test_help_and_version() { + rig.assert_help_and_version_options_work() +} + +// tee copies standard input to standard output and to every file named. The input +// is fed through a redirect, which is what a shell does for `tee < file`. +fn test_copies_to_stdout_and_file() { + res := os.execute('${rig.executable_under_test} ${out_path} < ${input_path}') + assert res.exit_code == 0 + assert res.output == 'line one\nline two\n' + assert os.read_file(out_path)! == 'line one\nline two\n' +} + +fn test_append_does_not_truncate() { + os.write_file(out_path, 'existing\n')! + res := os.execute('${rig.executable_under_test} -a ${out_path} < ${input_path}') + assert res.exit_code == 0 + assert os.read_file(out_path)! == 'existing\nline one\nline two\n' +} + +fn test_without_append_truncates() { + os.write_file(out_path, 'existing\n')! + res := os.execute('${rig.executable_under_test} ${out_path} < ${input_path}') + assert res.exit_code == 0 + assert os.read_file(out_path)! == 'line one\nline two\n' +} + +// A name in a directory that does not exist cannot be created, which is the case +// tee reports. A plain missing name is created by os.create and is not an error. +fn test_uncreatable_file_reports_it() { + res := os.execute('${rig.executable_under_test} nosuchdir/out.txt < ${input_path}') + assert res.exit_code == 1 + assert res.output.contains('nosuchdir') +} + +fn test_multiple_files() { + second := 'tee_second.txt' + res := os.execute('${rig.executable_under_test} ${out_path} ${second} < ${input_path}') + assert res.exit_code == 0 + assert os.read_file(out_path)! == 'line one\nline two\n' + assert os.read_file(second)! == 'line one\nline two\n' + os.rm(second) or {} +}