Add Git inspection forms, find pruning, and WASI tail stdin (T76)
This commit is contained in:
@@ -59,6 +59,7 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
|
||||
("printf 'a\\n' | xargs echo", "a\n"),
|
||||
("env FOO=1 printenv FOO", "1\n"),
|
||||
] {
|
||||
eprintln!("P1 command: {script}");
|
||||
let mut out = Vec::new();
|
||||
let code = exec
|
||||
.exec(
|
||||
@@ -84,6 +85,7 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
|
||||
("head -c 4 /dev/urandom | wc -c", "4\n"),
|
||||
("echo x > /dev/null 2>&1; cat < /dev/null", ""),
|
||||
] {
|
||||
eprintln!("P1 command: {script}");
|
||||
let mut out = Vec::new();
|
||||
let code = exec
|
||||
.exec(
|
||||
@@ -99,6 +101,44 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
|
||||
assert_eq!(code, 0, "{script}");
|
||||
assert_eq!(out, expected.as_bytes(), "{script}");
|
||||
}
|
||||
for utility in [
|
||||
"tail -n 5",
|
||||
"head -n 2",
|
||||
"wc -l",
|
||||
"sort",
|
||||
"uniq",
|
||||
"cut -c 1",
|
||||
] {
|
||||
for script in [
|
||||
format!("printf 'g\\nb\\nb\\na\\nf\\ne\\nd\\n' | {utility}"),
|
||||
format!("printf '' | {utility}"),
|
||||
format!("printf 'g\\nb\\n' | {utility} | head -n 0"),
|
||||
] {
|
||||
eprintln!("P1 pipe: {script}");
|
||||
let native = std::process::Command::new("bash")
|
||||
.args(["-c", &script])
|
||||
.output()?;
|
||||
assert!(native.status.success(), "{script}: {:?}", native.stderr);
|
||||
let mut out = Vec::new();
|
||||
let mut err = Vec::new();
|
||||
let code = exec
|
||||
.exec(
|
||||
Command::Shell(script.clone()),
|
||||
Options {
|
||||
timeout: Some(std::time::Duration::from_secs(15)),
|
||||
..Options::default()
|
||||
},
|
||||
&mut |stream, bytes| match stream {
|
||||
Stream::Stdout => out.extend_from_slice(bytes),
|
||||
Stream::Stderr => err.extend_from_slice(bytes),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(code, 0, "{script}: {}", String::from_utf8_lossy(&err));
|
||||
assert_eq!(out, native.stdout, "{script}");
|
||||
}
|
||||
}
|
||||
eprintln!("P1 Git history");
|
||||
let mut history = Vec::new();
|
||||
assert_eq!(
|
||||
exec.exec(
|
||||
|
||||
Generated
+42
-2
@@ -1448,6 +1448,12 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
@@ -4221,6 +4227,12 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "relative-path"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
@@ -4277,6 +4289,35 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rstest"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "948203e6d13b83e51a90d7cf236dd58a0065f23f4b902f00f306e7eb2bd09b9c"
|
||||
dependencies = [
|
||||
"futures-timer",
|
||||
"futures-util",
|
||||
"rstest_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rstest_macros"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d92eaf7b6e51e12471d71ddc72608e7151573de44099aacfbb1128177c0da4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"glob",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"relative-path",
|
||||
"rustc_version",
|
||||
"syn 2.0.119",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -6072,14 +6113,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "uu_tail"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04ff141dd3fab47b991b2063fb8c1c9ff5ca4f9f23f5bcba75c691a247e1aca4"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fluent",
|
||||
"libc",
|
||||
"memchr",
|
||||
"notify",
|
||||
"rstest",
|
||||
"rustix",
|
||||
"uucore",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -67,7 +67,7 @@ uu_sleep = "=0.12.0"
|
||||
uu_sort = "=0.12.0"
|
||||
uu_split = "=0.12.0"
|
||||
uu_sum = "=0.12.0"
|
||||
uu_tail = "=0.12.0"
|
||||
uu_tail = { path = "vendor/uu_tail" }
|
||||
uu_tee = "=0.12.0"
|
||||
uu_test = "=0.12.0"
|
||||
uu_touch = "=0.12.0"
|
||||
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
# uu_tail 0.12.0
|
||||
|
||||
This directory contains the upstream crate's manifest, source, locales, and MIT license.
|
||||
The dependency uses a local path so the patch stays inside the coreutils package.
|
||||
The manifest omits the standalone binary because the coreutils wrapper owns that binary.
|
||||
|
||||
`src/paths.rs`: treat WASI stdin as a stream, like macOS stdin.
|
||||
WASI has no filesystem path for standard input.
|
||||
The upstream resolution path otherwise attempts to open `standard input` as a file.
|
||||
The patch selects the existing streaming implementation without temporary files or another tail implementation.
|
||||
|
||||
The real-world P1 test compares pipes through tail, head, wc, sort, uniq, and cut with native tools.
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2024"
|
||||
rust-version = "1.88.0"
|
||||
name = "uu_tail"
|
||||
version = "0.12.0"
|
||||
build = false
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "tail ~ (uutils) display the last lines of input"
|
||||
homepage = "https://github.com/uutils/coreutils"
|
||||
readme = "README.package.md"
|
||||
keywords = [
|
||||
"coreutils",
|
||||
"uutils",
|
||||
"cross-platform",
|
||||
"cli",
|
||||
"utility",
|
||||
]
|
||||
categories = ["command-line-utilities"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/uutils/coreutils/tree/main/src/uu/tail"
|
||||
|
||||
[features]
|
||||
diagnostics = ["uucore/diagnostics"]
|
||||
|
||||
[lib]
|
||||
name = "uu_tail"
|
||||
path = "src/tail.rs"
|
||||
doctest = false
|
||||
|
||||
# The coreutils wrapper owns the tail binary.
|
||||
|
||||
[dependencies.clap]
|
||||
version = "4.5"
|
||||
features = [
|
||||
"wrap_help",
|
||||
"cargo",
|
||||
"color",
|
||||
]
|
||||
|
||||
[dependencies.fluent]
|
||||
version = "0.17.0"
|
||||
|
||||
[dependencies.memchr]
|
||||
version = "2.7.2"
|
||||
|
||||
[dependencies.uucore]
|
||||
version = "0.12.0"
|
||||
features = [
|
||||
"fs",
|
||||
"parser-size",
|
||||
"signals",
|
||||
]
|
||||
|
||||
[dev-dependencies.rstest]
|
||||
version = "0.27.0"
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies.uucore]
|
||||
version = "0.12.0"
|
||||
features = ["pipes"]
|
||||
|
||||
[target.'cfg(not(target_os = "wasi"))'.dependencies.libc]
|
||||
version = "0.2.186"
|
||||
|
||||
[target.'cfg(not(target_os = "wasi"))'.dependencies.notify]
|
||||
version = "8.2.0"
|
||||
features = ["macos_kqueue"]
|
||||
|
||||
[target."cfg(unix)".dependencies.rustix]
|
||||
version = "1.1.4"
|
||||
features = [
|
||||
"fs",
|
||||
"process",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[target."cfg(windows)".dependencies.windows-sys]
|
||||
version = "0.61.0"
|
||||
features = [
|
||||
"Win32_System_Threading",
|
||||
"Win32_Foundation",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[lints.clippy]
|
||||
cargo_common_metadata = "allow"
|
||||
cast_lossless = "allow"
|
||||
cast_possible_truncation = "allow"
|
||||
cast_possible_wrap = "allow"
|
||||
cast_precision_loss = "allow"
|
||||
cast_sign_loss = "allow"
|
||||
doc_link_with_quotes = "allow"
|
||||
doc_markdown = "allow"
|
||||
enum_glob_use = "allow"
|
||||
flat_map_option = "allow"
|
||||
float_cmp = "allow"
|
||||
fn_params_excessive_bools = "allow"
|
||||
format_push_string = "allow"
|
||||
from_iter_instead_of_collect = "allow"
|
||||
ignored_unit_patterns = "allow"
|
||||
implicit_hasher = "allow"
|
||||
inline_always = "allow"
|
||||
large_types_passed_by_value = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
multiple_crate_versions = "allow"
|
||||
must_use_candidate = "allow"
|
||||
needless_pass_by_value = "allow"
|
||||
return_self_not_must_use = "allow"
|
||||
should_panic_without_expect = "allow"
|
||||
similar_names = "allow"
|
||||
struct_excessive_bools = "allow"
|
||||
too_many_lines = "allow"
|
||||
unnested_or_patterns = "allow"
|
||||
unused_self = "allow"
|
||||
use_self = "warn"
|
||||
|
||||
[lints.clippy.all]
|
||||
level = "warn"
|
||||
priority = -1
|
||||
|
||||
[lints.clippy.cargo]
|
||||
level = "warn"
|
||||
priority = -1
|
||||
|
||||
[lints.clippy.pedantic]
|
||||
level = "warn"
|
||||
priority = -1
|
||||
|
||||
[lints.rust]
|
||||
unused_qualifications = "warn"
|
||||
|
||||
[lints.rust.renamed_and_removed_lints]
|
||||
level = "allow"
|
||||
priority = -1
|
||||
|
||||
[lints.rust.unexpected_cfgs]
|
||||
level = "warn"
|
||||
priority = 0
|
||||
check-cfg = [
|
||||
"cfg(fuzzing)",
|
||||
"cfg(pgo_training)",
|
||||
'cfg(target_os, values("cygwin"))',
|
||||
"cfg(wasi_runner)",
|
||||
]
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
Copyright (c) uutils developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,77 @@
|
||||
tail-about = Print the last 10 lines of each FILE to standard output.
|
||||
With more than one FILE, precede each with a header giving the file name.
|
||||
With no FILE, or when FILE is -, read standard input.
|
||||
|
||||
Mandatory arguments to long options are mandatory for short options too.
|
||||
tail-usage = tail [OPTION]... [FILE]...
|
||||
|
||||
# Help messages
|
||||
tail-help-bytes = Number of bytes to print
|
||||
tail-help-debug = indicate which --follow implementation is used
|
||||
tail-help-follow = Print the file as it grows
|
||||
tail-help-lines = Number of lines to print
|
||||
tail-help-pid = With -f, terminate after process ID, PID dies
|
||||
tail-help-quiet = Never output headers giving file names
|
||||
tail-help-sleep-interval = Number of seconds to sleep between polling the file when running with -f
|
||||
tail-help-max-unchanged-stats = Reopen a FILE which has not changed size after N (default 5) iterations to see if it has been unlinked or renamed (this is the usual case of rotated log files); This option is meaningful only when polling (i.e., with --use-polling) and when --follow=name
|
||||
tail-help-verbose = Always output headers giving file names
|
||||
tail-help-zero-terminated = Line delimiter is NUL, not newline
|
||||
tail-help-retry = Keep trying to open a file if it is inaccessible
|
||||
tail-help-follow-retry = Same as --follow=name --retry
|
||||
tail-help-polling-linux = Disable 'inotify' support and use polling instead
|
||||
tail-help-polling-unix = Disable 'kqueue' support and use polling instead
|
||||
tail-help-polling-windows = Disable 'ReadDirectoryChanges' support and use polling instead
|
||||
|
||||
# Error messages
|
||||
tail-error-cannot-follow-stdin-by-name = cannot follow { $stdin } by name
|
||||
tail-error-cannot-open-no-such-file = cannot open '{ $file }' for reading: { $error }
|
||||
tail-error-reading-file = error reading '{ $file }': { $error }
|
||||
tail-error-cannot-follow-file-type = { $file }: cannot follow end of this type of file{ $msg }
|
||||
tail-error-cannot-open-for-reading = cannot open '{ $file }' for reading
|
||||
tail-error-cannot-fstat = cannot fstat { $file }: { $error }
|
||||
tail-error-invalid-number-of-bytes = invalid number of bytes: { $arg }
|
||||
tail-error-invalid-number-of-lines = invalid number of lines: { $arg }
|
||||
tail-error-invalid-number-of-seconds = invalid number of seconds: '{ $source }'
|
||||
tail-error-invalid-max-unchanged-stats = invalid maximum number of unchanged stats between opens: { $value }
|
||||
tail-error-invalid-pid = invalid PID: { $pid }
|
||||
tail-error-invalid-pid-with-error = invalid PID: { $pid }: { $error }
|
||||
tail-error-invalid-number-out-of-range = invalid number: { $arg }: Numerical result out of range
|
||||
tail-error-invalid-number-overflow = invalid number: { $arg }
|
||||
tail-error-option-used-in-invalid-context = option used in invalid context -- { $option }
|
||||
tail-error-bad-argument-encoding = bad argument encoding: { $arg }
|
||||
tail-error-cannot-watch-parent-directory = cannot watch parent directory of { $path }
|
||||
tail-error-backend-cannot-be-used-too-many-files = { $backend } cannot be used, reverting to polling: Too many open files
|
||||
tail-error-backend-resources-exhausted = { $backend } resources exhausted
|
||||
tail-error-notify-error = NotifyError: { $error }
|
||||
tail-error-recv-timeout-error = RecvTimeoutError: { $error }
|
||||
|
||||
# Warning messages
|
||||
tail-warning-retry-ignored = --retry ignored; --retry is useful only when following
|
||||
tail-warning-retry-only-effective = --retry only effective for the initial open
|
||||
tail-warning-pid-ignored = PID ignored; --pid=PID is useful only when following
|
||||
tail-warning-pid-not-supported = --pid=PID is not supported on this system
|
||||
tail-warning-following-stdin-ineffective = following standard input indefinitely is ineffective
|
||||
|
||||
# Status messages
|
||||
tail-status-has-become-accessible = { $file } has become accessible
|
||||
tail-status-has-appeared-following-new-file = { $file } has appeared; following new file
|
||||
tail-status-has-been-replaced-following-new-file = { $file } has been replaced; following new file
|
||||
tail-status-file-truncated = { $file }: file truncated
|
||||
tail-status-replaced-with-untailable-file = { $file } has been replaced with an untailable file
|
||||
tail-status-replaced-with-untailable-symlink = { $file } has been replaced with an untailable symbolic link
|
||||
tail-status-replaced-with-untailable-file-giving-up = { $file } has been replaced with an untailable file; giving up on this name
|
||||
tail-status-directory-containing-watched-file-removed = directory containing watched file was removed
|
||||
tail-status-backend-cannot-be-used-reverting-to-polling = { $backend } cannot be used, reverting to polling
|
||||
|
||||
# Text constants
|
||||
tail-bad-fd = Bad file descriptor
|
||||
tail-no-such-file-or-directory = No such file or directory
|
||||
tail-is-a-directory = Is a directory
|
||||
tail-giving-up-on-this-name = ; giving up on this name
|
||||
tail-stdin-header = standard input
|
||||
tail-no-files-remaining = no files remaining
|
||||
tail-become-inaccessible = has become inaccessible
|
||||
|
||||
# Debug messages
|
||||
tail-debug-using-notification-mode = using notification mode
|
||||
tail-debug-using-polling-mode = using polling mode
|
||||
@@ -0,0 +1,72 @@
|
||||
tail-about = Afficher les 10 dernières lignes de chaque FICHIER sur la sortie standard.
|
||||
Avec plus d'un FICHIER, précéder chacun d'un en-tête donnant le nom du fichier.
|
||||
Sans FICHIER, ou quand FICHIER est -, lire l'entrée standard.
|
||||
|
||||
Les arguments obligatoires pour les options longues sont également obligatoires pour les options courtes.
|
||||
tail-usage = tail [OPTION]... [FICHIER]...
|
||||
|
||||
# Messages d'aide
|
||||
tail-help-bytes = Nombre d'octets à afficher
|
||||
tail-help-follow = Afficher le fichier au fur et à mesure de sa croissance
|
||||
tail-help-lines = Nombre de lignes à afficher
|
||||
tail-help-pid = Avec -f, terminer après que l'ID de processus, PID meure
|
||||
tail-help-quiet = Ne jamais afficher d'en-têtes donnant les noms de fichiers
|
||||
tail-help-sleep-interval = Nombre de secondes à attendre entre les sondages du fichier lors de l'exécution avec -f
|
||||
tail-help-max-unchanged-stats = Rouvrir un FICHIER qui n'a pas changé de taille après N (par défaut 5) itérations pour voir s'il a été supprimé ou renommé (c'est le cas habituel des fichiers journaux pivotés) ; Cette option n'a de sens que lors du sondage (c'est-à-dire avec --use-polling) et quand --follow=name
|
||||
tail-help-verbose = Toujours afficher des en-têtes donnant les noms de fichiers
|
||||
tail-help-zero-terminated = Le délimiteur de ligne est NUL, pas newline
|
||||
tail-help-retry = Continuer d'essayer d'ouvrir un fichier s'il est inaccessible
|
||||
tail-help-follow-retry = Identique à --follow=name --retry
|
||||
tail-help-polling-linux = Désactiver le support 'inotify' et utiliser le sondage à la place
|
||||
tail-help-polling-unix = Désactiver le support 'kqueue' et utiliser le sondage à la place
|
||||
tail-help-polling-windows = Désactiver le support 'ReadDirectoryChanges' et utiliser le sondage à la place
|
||||
|
||||
# Messages d'erreur
|
||||
tail-error-cannot-follow-stdin-by-name = impossible de suivre { $stdin } par nom
|
||||
tail-error-cannot-open-no-such-file = impossible d'ouvrir '{ $file }' en lecture : { $error }
|
||||
tail-error-reading-file = erreur de lecture de '{ $file }' : { $error }
|
||||
tail-error-cannot-follow-file-type = { $file } : impossible de suivre la fin de ce type de fichier{ $msg }
|
||||
tail-error-cannot-open-for-reading = impossible d'ouvrir '{ $file }' en lecture
|
||||
tail-error-cannot-fstat = impossible de faire fstat { $file } : { $error }
|
||||
tail-error-invalid-number-of-bytes = nombre d'octets invalide : { $arg }
|
||||
tail-error-invalid-number-of-lines = nombre de lignes invalide : { $arg }
|
||||
tail-error-invalid-number-of-seconds = nombre de secondes invalide : '{ $source }'
|
||||
tail-error-invalid-max-unchanged-stats = nombre maximum invalide de statistiques inchangées entre les ouvertures : { $value }
|
||||
tail-error-invalid-pid = PID invalide : { $pid }
|
||||
tail-error-invalid-pid-with-error = PID invalide : { $pid } : { $error }
|
||||
tail-error-invalid-number-out-of-range = nombre invalide : { $arg } : Résultat numérique hors limites
|
||||
tail-error-invalid-number-overflow = nombre invalide : { $arg }
|
||||
tail-error-option-used-in-invalid-context = option utilisée dans un contexte invalide -- { $option }
|
||||
tail-error-bad-argument-encoding = encodage d'argument incorrect : { $arg }
|
||||
tail-error-cannot-watch-parent-directory = impossible de surveiller le répertoire parent de { $path }
|
||||
tail-error-backend-cannot-be-used-too-many-files = { $backend } ne peut pas être utilisé, retour au sondage : Trop de fichiers ouverts
|
||||
tail-error-backend-resources-exhausted = ressources { $backend } épuisées
|
||||
tail-error-notify-error = Erreur de notification : { $error }
|
||||
tail-error-recv-timeout-error = Erreur de délai de réception : { $error }
|
||||
|
||||
# Messages d'avertissement
|
||||
tail-warning-retry-ignored = --retry ignoré ; --retry n'est utile que lors du suivi
|
||||
tail-warning-retry-only-effective = --retry n'est effectif que pour l'ouverture initiale
|
||||
tail-warning-pid-ignored = PID ignoré ; --pid=PID n'est utile que lors du suivi
|
||||
tail-warning-pid-not-supported = --pid=PID n'est pas pris en charge sur ce système
|
||||
tail-warning-following-stdin-ineffective = suivre l'entrée standard indéfiniment est inefficace
|
||||
|
||||
# Messages de statut
|
||||
tail-status-has-become-accessible = { $file } est devenu accessible
|
||||
tail-status-has-appeared-following-new-file = { $file } est apparu ; suivi du nouveau fichier
|
||||
tail-status-has-been-replaced-following-new-file = { $file } a été remplacé ; suivi du nouveau fichier
|
||||
tail-status-file-truncated = { $file } : fichier tronqué
|
||||
tail-status-replaced-with-untailable-file = { $file } a été remplacé par un fichier non suivable
|
||||
tail-status-replaced-with-untailable-symlink = { $file } a été remplacé par un lien symbolique non suivable
|
||||
tail-status-replaced-with-untailable-file-giving-up = { $file } a été remplacé par un fichier non suivable ; abandon de ce nom
|
||||
tail-status-directory-containing-watched-file-removed = le répertoire contenant le fichier surveillé a été supprimé
|
||||
tail-status-backend-cannot-be-used-reverting-to-polling = { $backend } ne peut pas être utilisé, retour au sondage
|
||||
|
||||
# Constantes de texte
|
||||
tail-bad-fd = Descripteur de fichier incorrect
|
||||
tail-no-such-file-or-directory = Aucun fichier ou répertoire de ce type
|
||||
tail-is-a-directory = Est un répertoire
|
||||
tail-giving-up-on-this-name = ; abandon de ce nom
|
||||
tail-stdin-header = entrée standard
|
||||
tail-no-files-remaining = aucun fichier restant
|
||||
tail-become-inaccessible = est devenu inaccessible
|
||||
+688
@@ -0,0 +1,688 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
// spell-checker:ignore (ToDO) kqueue Signum
|
||||
|
||||
use crate::paths::Input;
|
||||
use crate::{Quotable, parse, platform};
|
||||
use clap::{Arg, ArgAction, ArgMatches, Command, value_parser};
|
||||
use std::ffi::OsString;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::time::Duration;
|
||||
use uucore::diagnostics::OptionValue;
|
||||
use uucore::error::{UResult, USimpleError, UUsageError};
|
||||
use uucore::parser::parse_signed_num::{SignPrefix, number_offset, parse_signed_num_max};
|
||||
use uucore::parser::parse_size::ParseSizeError;
|
||||
use uucore::parser::parse_time;
|
||||
use uucore::parser::shortcut_value_parser::ShortcutValueParser;
|
||||
use uucore::translate;
|
||||
use uucore::{format_usage, show_warning};
|
||||
|
||||
pub mod options {
|
||||
pub mod verbosity {
|
||||
pub const QUIET: &str = "quiet";
|
||||
pub const VERBOSE: &str = "verbose";
|
||||
}
|
||||
pub const BYTES: &str = "bytes";
|
||||
pub const FOLLOW: &str = "follow";
|
||||
pub const LINES: &str = "lines";
|
||||
pub const PID: &str = "pid";
|
||||
pub const SLEEP_INT: &str = "sleep-interval";
|
||||
pub const ZERO_TERM: &str = "zero-terminated";
|
||||
pub const DISABLE_INOTIFY_TERM: &str = "-disable-inotify"; // NOTE: three hyphens is correct
|
||||
pub const USE_POLLING: &str = "use-polling";
|
||||
pub const RETRY: &str = "retry";
|
||||
pub const FOLLOW_RETRY: &str = "F";
|
||||
pub const MAX_UNCHANGED_STATS: &str = "max-unchanged-stats";
|
||||
pub const ARG_FILES: &str = "files";
|
||||
pub const PRESUME_INPUT_PIPE: &str = "-presume-input-pipe"; // NOTE: three hyphens is correct
|
||||
pub const DEBUG: &str = "debug";
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Signum {
|
||||
Negative(u64),
|
||||
Positive(u64),
|
||||
PlusZero,
|
||||
MinusZero,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum FilterMode {
|
||||
Bytes(Signum),
|
||||
|
||||
/// Mode for lines delimited by delimiter as u8
|
||||
Lines(Signum, u8),
|
||||
}
|
||||
|
||||
impl FilterMode {
|
||||
fn from_obsolete_args(args: &parse::ObsoleteArgs) -> Self {
|
||||
let signum = if args.plus {
|
||||
Signum::Positive(args.num)
|
||||
} else {
|
||||
Signum::Negative(args.num)
|
||||
};
|
||||
if args.lines {
|
||||
Self::Lines(signum, b'\n')
|
||||
} else {
|
||||
Self::Bytes(signum)
|
||||
}
|
||||
}
|
||||
|
||||
fn from(matches: &ArgMatches, diag_args: Option<&[OsString]>) -> UResult<Self> {
|
||||
// The plain message, or a caret under the part of the size at fault.
|
||||
let raise = |message: String, arg: &str, short, long, error: &ParseSizeError| {
|
||||
error.size_value_error(
|
||||
diag_args,
|
||||
&OptionValue::new(arg, short, long),
|
||||
// The parser never saw the sign; the caret has to count it back in.
|
||||
number_offset(arg),
|
||||
&message,
|
||||
USimpleError::new(1, message.clone()),
|
||||
)
|
||||
};
|
||||
|
||||
let zero_term = matches.get_flag(options::ZERO_TERM);
|
||||
let mode = if let Some(arg) = matches.get_one::<String>(options::BYTES) {
|
||||
match parse_num(arg) {
|
||||
Ok(signum) => Self::Bytes(signum),
|
||||
Err(e) => {
|
||||
let message =
|
||||
translate!("tail-error-invalid-number-of-bytes", "arg" => e.to_string());
|
||||
return Err(raise(message, arg, 'c', "bytes", &e));
|
||||
}
|
||||
}
|
||||
} else if let Some(arg) = matches.get_one::<String>(options::LINES) {
|
||||
match parse_num(arg) {
|
||||
Ok(signum) => {
|
||||
let delimiter = if zero_term { 0 } else { b'\n' };
|
||||
Self::Lines(signum, delimiter)
|
||||
}
|
||||
Err(e) => {
|
||||
let message =
|
||||
translate!("tail-error-invalid-number-of-lines", "arg" => arg.quote());
|
||||
return Err(raise(message, arg, 'n', "lines", &e));
|
||||
}
|
||||
}
|
||||
} else if zero_term {
|
||||
Self::default_zero()
|
||||
} else {
|
||||
Self::default()
|
||||
};
|
||||
|
||||
Ok(mode)
|
||||
}
|
||||
|
||||
fn default_zero() -> Self {
|
||||
Self::Lines(Signum::Negative(10), 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FilterMode {
|
||||
fn default() -> Self {
|
||||
Self::Lines(Signum::Negative(10), b'\n')
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum FollowMode {
|
||||
Descriptor,
|
||||
Name,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum VerificationResult {
|
||||
Ok,
|
||||
CannotFollowStdinByName,
|
||||
NoOutput,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Settings {
|
||||
pub follow: Option<FollowMode>,
|
||||
pub max_unchanged_stats: u32,
|
||||
pub mode: FilterMode,
|
||||
pub pid: Option<platform::Pid>,
|
||||
pub retry: bool,
|
||||
pub sleep_sec: Duration,
|
||||
pub use_polling: bool,
|
||||
pub verbose: bool,
|
||||
pub presume_input_pipe: bool,
|
||||
pub debug: bool,
|
||||
/// `FILE(s)` positional arguments
|
||||
pub inputs: Vec<Input>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_unchanged_stats: 5,
|
||||
sleep_sec: Duration::from_secs_f32(1.0),
|
||||
follow: Option::default(),
|
||||
mode: FilterMode::default(),
|
||||
pid: Option::default(),
|
||||
retry: Default::default(),
|
||||
use_polling: Default::default(),
|
||||
verbose: Default::default(),
|
||||
presume_input_pipe: Default::default(),
|
||||
debug: Default::default(),
|
||||
inputs: Vec::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn from_obsolete_args(args: &parse::ObsoleteArgs, name: Option<&OsString>) -> Self {
|
||||
let mut settings = Self::default();
|
||||
if args.follow {
|
||||
settings.follow = if name.is_some() {
|
||||
Some(FollowMode::Name)
|
||||
} else {
|
||||
Some(FollowMode::Descriptor)
|
||||
};
|
||||
}
|
||||
settings.mode = FilterMode::from_obsolete_args(args);
|
||||
let input = if let Some(name) = name {
|
||||
Input::from(name)
|
||||
} else {
|
||||
Input::default()
|
||||
};
|
||||
settings.inputs.push(input);
|
||||
settings
|
||||
}
|
||||
|
||||
pub fn from(matches: &ArgMatches) -> UResult<Self> {
|
||||
Self::from_with_diagnostics(matches, None)
|
||||
}
|
||||
|
||||
fn from_with_diagnostics(
|
||||
matches: &ArgMatches,
|
||||
diag_args: Option<&[OsString]>,
|
||||
) -> UResult<Self> {
|
||||
// We're parsing --follow, -F and --retry under the following conditions:
|
||||
// * -F sets --retry and --follow=name
|
||||
// * plain --follow or short -f is the same like specifying --follow=descriptor
|
||||
// * All these options and flags can occur multiple times as command line arguments
|
||||
let follow_retry = matches.get_flag(options::FOLLOW_RETRY);
|
||||
// We don't need to check for occurrences of --retry if -F was specified which already sets
|
||||
// retry
|
||||
let retry = follow_retry || matches.get_flag(options::RETRY);
|
||||
let follow = match (
|
||||
follow_retry,
|
||||
matches
|
||||
.get_one::<String>(options::FOLLOW)
|
||||
.map(String::as_str),
|
||||
) {
|
||||
// -F and --follow if -F is specified after --follow. We don't need to care about the
|
||||
// value of --follow.
|
||||
(true, Some(_))
|
||||
// It's ok to use `index_of` instead of `indices_of` since -F and --follow
|
||||
// overwrite themselves (not only the value but also the index).
|
||||
if matches.index_of(options::FOLLOW_RETRY) > matches.index_of(options::FOLLOW) =>
|
||||
{
|
||||
Some(FollowMode::Name)
|
||||
}
|
||||
// * -F and --follow=name if --follow=name is specified after -F
|
||||
// * No occurrences of -F but --follow=name
|
||||
// * -F and no occurrences of --follow
|
||||
(_, Some("name")) | (true, None) => Some(FollowMode::Name),
|
||||
// * -F and --follow=descriptor (or plain --follow, -f) if --follow=descriptor is
|
||||
// specified after -F
|
||||
// * No occurrences of -F but --follow=descriptor, --follow, -f
|
||||
(_, Some(_)) => Some(FollowMode::Descriptor),
|
||||
// The default for no occurrences of -F or --follow
|
||||
(false, None) => None,
|
||||
};
|
||||
|
||||
let mut settings: Self = Self {
|
||||
follow,
|
||||
retry,
|
||||
use_polling: matches.get_flag(options::USE_POLLING),
|
||||
mode: FilterMode::from(matches, diag_args)?,
|
||||
verbose: matches.get_flag(options::verbosity::VERBOSE),
|
||||
presume_input_pipe: matches.get_flag(options::PRESUME_INPUT_PIPE),
|
||||
debug: matches.get_flag(options::DEBUG),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Some(source) = matches.get_one::<String>(options::SLEEP_INT) {
|
||||
settings.sleep_sec = parse_time::from_str(source, false).map_err(|_| {
|
||||
UUsageError::new(
|
||||
1,
|
||||
translate!("tail-error-invalid-number-of-seconds", "source" => source),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(s) = matches.get_one::<String>(options::MAX_UNCHANGED_STATS) {
|
||||
settings.max_unchanged_stats = match s.parse::<u32>() {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return Err(UUsageError::new(
|
||||
1,
|
||||
translate!("tail-error-invalid-max-unchanged-stats", "value" => s.quote()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pid_str) = matches.get_one::<String>(options::PID) {
|
||||
match pid_str.parse() {
|
||||
Ok(pid) => {
|
||||
// NOTE: on unix platform::Pid is i32, on windows platform::Pid is u32
|
||||
#[cfg(unix)]
|
||||
if pid < 0 {
|
||||
// NOTE: tail only accepts an unsigned pid
|
||||
return Err(USimpleError::new(
|
||||
1,
|
||||
translate!("tail-error-invalid-pid", "pid" => pid_str.quote()),
|
||||
));
|
||||
}
|
||||
|
||||
settings.pid = Some(pid);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(USimpleError::new(
|
||||
1,
|
||||
translate!("tail-error-invalid-pid-with-error", "pid" => pid_str.quote(), "error" => e),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settings.inputs = matches
|
||||
.get_many::<OsString>(options::ARG_FILES)
|
||||
.map_or_else(|| vec![Input::default()], |v| v.map(Input::from).collect());
|
||||
|
||||
settings.verbose = (matches.get_flag(options::verbosity::VERBOSE)
|
||||
|| settings.inputs.len() > 1)
|
||||
&& !matches.get_flag(options::verbosity::QUIET);
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn has_only_stdin(&self) -> bool {
|
||||
self.inputs.iter().all(Input::is_stdin)
|
||||
}
|
||||
|
||||
pub fn has_stdin(&self) -> bool {
|
||||
self.inputs.iter().any(Input::is_stdin)
|
||||
}
|
||||
|
||||
pub fn num_inputs(&self) -> usize {
|
||||
self.inputs.len()
|
||||
}
|
||||
|
||||
/// Check [`Settings`] for problematic configurations of tail originating from user provided
|
||||
/// command line arguments and print appropriate warnings.
|
||||
pub fn check_warnings(&self) -> std::io::Result<()> {
|
||||
if self.retry {
|
||||
if self.follow.is_none() {
|
||||
show_warning!("{}", translate!("tail-warning-retry-ignored"));
|
||||
} else if self.follow == Some(FollowMode::Descriptor) {
|
||||
show_warning!("{}", translate!("tail-warning-retry-only-effective"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pid) = self.pid {
|
||||
if self.follow.is_none() {
|
||||
writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"{}: warning: {}",
|
||||
uucore::util_name(),
|
||||
translate!("tail-warning-pid-ignored")
|
||||
)?;
|
||||
} else if pid != 0 && !platform::supports_pid_checks(pid) {
|
||||
show_warning!("{}", translate!("tail-warning-pid-not-supported"));
|
||||
}
|
||||
}
|
||||
|
||||
// This warning originates from gnu's tail implementation of the equivalent warning. If the
|
||||
// user wants to follow stdin, but tail is blocking indefinitely anyways, because of stdin
|
||||
// as `tty` (but no otherwise blocking stdin), then we print a warning that `--follow`
|
||||
// cannot be applied under these circumstances and is therefore ineffective.
|
||||
if self.follow.is_some() && self.has_stdin() {
|
||||
#[cfg(unix)]
|
||||
let stdin_is_regular = rustix::fs::fstat(std::io::stdin())
|
||||
.is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFREG);
|
||||
#[cfg(not(unix))]
|
||||
let stdin_is_regular = true;
|
||||
let blocking_stdin = self.pid.unwrap_or_default() == 0
|
||||
&& self.follow == Some(FollowMode::Descriptor)
|
||||
&& self.num_inputs() == 1
|
||||
&& !stdin_is_regular;
|
||||
|
||||
if !blocking_stdin && std::io::stdin().is_terminal() {
|
||||
show_warning!("{}", translate!("tail-warning-following-stdin-ineffective"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify [`Settings`] and try to find unsolvable misconfigurations of tail originating from
|
||||
/// user provided command line arguments. In contrast to [`Settings::check_warnings`] these
|
||||
/// misconfigurations usually lead to the immediate exit or abortion of the running `tail`
|
||||
/// process.
|
||||
pub fn verify(&self) -> VerificationResult {
|
||||
// Mimic GNU's tail for `tail -F`
|
||||
if self.inputs.iter().any(Input::is_stdin) && self.follow == Some(FollowMode::Name) {
|
||||
return VerificationResult::CannotFollowStdinByName;
|
||||
}
|
||||
|
||||
// Mimic GNU's tail for -[nc]0 without -f and exit immediately
|
||||
if self.follow.is_none()
|
||||
&& matches!(
|
||||
self.mode,
|
||||
FilterMode::Lines(Signum::MinusZero, _) | FilterMode::Bytes(Signum::MinusZero)
|
||||
)
|
||||
{
|
||||
return VerificationResult::NoOutput;
|
||||
}
|
||||
|
||||
VerificationResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_obsolete(arg: &OsString, input: Option<&OsString>) -> UResult<Option<Settings>> {
|
||||
match parse::parse_obsolete(arg) {
|
||||
Some(Ok(args)) => Ok(Some(Settings::from_obsolete_args(&args, input))),
|
||||
None => Ok(None),
|
||||
Some(Err(e)) => {
|
||||
Err(USimpleError::new(
|
||||
1,
|
||||
match e {
|
||||
// this ensures compatibility to GNU's error message (as tested in misc/tail)
|
||||
parse::ParseError::Context => {
|
||||
translate!(
|
||||
"tail-error-option-used-in-invalid-context",
|
||||
"option" => arg.to_string_lossy().chars().nth(1).unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
parse::ParseError::InvalidEncoding => {
|
||||
translate!("tail-error-bad-argument-encoding", "arg" => arg.quote())
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_num(src: &str) -> Result<Signum, ParseSizeError> {
|
||||
let result = parse_signed_num_max(src)?;
|
||||
// tail: '+' means "starting from line/byte N", default/'-' means "last N"
|
||||
let is_plus = result.sign == Some(SignPrefix::Plus);
|
||||
|
||||
match (result.value, is_plus) {
|
||||
(0, true) => Ok(Signum::PlusZero),
|
||||
(0, false) => Ok(Signum::MinusZero),
|
||||
(n, true) => Ok(Signum::Positive(n)),
|
||||
(n, false) => Ok(Signum::Negative(n)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_args(args: impl uucore::Args) -> UResult<Settings> {
|
||||
let args_vec: Vec<OsString> = args.collect();
|
||||
let clap_args = uu_app().try_get_matches_from(args_vec.clone());
|
||||
let clap_result = match clap_args {
|
||||
// Kept for the caret in size diagnostics, which needs the value as
|
||||
// typed.
|
||||
Ok(matches) => Ok(Settings::from_with_diagnostics(
|
||||
&matches,
|
||||
uucore::diagnostics::capture(&args_vec).as_deref(),
|
||||
)?),
|
||||
Err(err) => Err(err.into()),
|
||||
};
|
||||
|
||||
// clap isn't able to handle obsolete syntax.
|
||||
// therefore, we want to check further for obsolete arguments.
|
||||
// argv[0] is always present, argv[1] might be obsolete arguments
|
||||
// argv[2] might contain an input file, argv[3] isn't allowed in obsolete mode
|
||||
if args_vec.len() != 2 && args_vec.len() != 3 {
|
||||
return clap_result;
|
||||
}
|
||||
|
||||
// At this point, there are a few possible cases:
|
||||
//
|
||||
// 1. clap has succeeded and the arguments would be invalid for the obsolete syntax.
|
||||
// 2. The case of `tail -c 5` is ambiguous. clap parses this as `tail -c5`,
|
||||
// but it could also be interpreted as valid obsolete syntax (tail -c on file '5').
|
||||
// GNU chooses to interpret this as `tail -c5`, like clap.
|
||||
// 3. `tail -f foo` is also ambiguous, but has the same effect in both cases. We can safely
|
||||
// use the clap result here.
|
||||
// 4. clap succeeded for obsolete arguments starting with '+', but misinterprets them as
|
||||
// input files (e.g. 'tail +f').
|
||||
// 5. clap failed because of unknown flags, but possibly valid obsolete arguments
|
||||
// (e.g. tail -l; tail -10c).
|
||||
//
|
||||
// In cases 4 & 5, we want to try parsing the obsolete arguments, which corresponds to
|
||||
// checking whether clap succeeded or the first argument starts with '+'.
|
||||
let possible_obsolete_args = &args_vec[1];
|
||||
if clap_result.is_ok() && possible_obsolete_args.as_encoded_bytes().first() != Some(&b'+') {
|
||||
return clap_result;
|
||||
}
|
||||
match parse_obsolete(possible_obsolete_args, args_vec.get(2))? {
|
||||
Some(settings) => Ok(settings),
|
||||
None => clap_result,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uu_app() -> Command {
|
||||
#[cfg(target_os = "linux")]
|
||||
let polling_help = translate!("tail-help-polling-linux");
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
let polling_help = translate!("tail-help-polling-unix");
|
||||
#[cfg(windows)]
|
||||
let polling_help = translate!("tail-help-polling-windows");
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let polling_help = translate!("tail-help-polling-unix");
|
||||
|
||||
Command::new("tail")
|
||||
.version(uucore::crate_version!())
|
||||
.help_template(uucore::localized_help_template("tail"))
|
||||
.about(translate!("tail-about"))
|
||||
.override_usage(format_usage(&translate!("tail-usage")))
|
||||
.infer_long_args(true)
|
||||
.arg(
|
||||
Arg::new(options::BYTES)
|
||||
.short('c')
|
||||
.long(options::BYTES)
|
||||
.allow_hyphen_values(true)
|
||||
.overrides_with_all([options::BYTES, options::LINES])
|
||||
.help(translate!("tail-help-bytes")),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::FOLLOW)
|
||||
.short('f')
|
||||
.long(options::FOLLOW)
|
||||
.default_missing_value("descriptor")
|
||||
.num_args(0..=1)
|
||||
.require_equals(true)
|
||||
.value_parser(ShortcutValueParser::new(["descriptor", "name"]))
|
||||
.overrides_with(options::FOLLOW)
|
||||
.help(translate!("tail-help-follow")),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::LINES)
|
||||
.short('n')
|
||||
.long(options::LINES)
|
||||
.allow_hyphen_values(true)
|
||||
.overrides_with_all([options::BYTES, options::LINES])
|
||||
.help(translate!("tail-help-lines")),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::PID)
|
||||
.long(options::PID)
|
||||
.value_name("PID")
|
||||
.help(translate!("tail-help-pid"))
|
||||
.overrides_with(options::PID),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::verbosity::QUIET)
|
||||
.short('q')
|
||||
.long(options::verbosity::QUIET)
|
||||
.visible_alias("silent")
|
||||
.overrides_with_all([options::verbosity::QUIET, options::verbosity::VERBOSE])
|
||||
.help(translate!("tail-help-quiet"))
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::SLEEP_INT)
|
||||
.short('s')
|
||||
.value_name("N")
|
||||
.long(options::SLEEP_INT)
|
||||
.help(translate!("tail-help-sleep-interval")),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::MAX_UNCHANGED_STATS)
|
||||
.value_name("N")
|
||||
.long(options::MAX_UNCHANGED_STATS)
|
||||
.help(translate!("tail-help-max-unchanged-stats")),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::verbosity::VERBOSE)
|
||||
.short('v')
|
||||
.long(options::verbosity::VERBOSE)
|
||||
.overrides_with_all([options::verbosity::QUIET, options::verbosity::VERBOSE])
|
||||
.help(translate!("tail-help-verbose"))
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::ZERO_TERM)
|
||||
.short('z')
|
||||
.long(options::ZERO_TERM)
|
||||
.help(translate!("tail-help-zero-terminated"))
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::USE_POLLING)
|
||||
.alias(options::DISABLE_INOTIFY_TERM) // NOTE: Used by GNU's test suite
|
||||
.alias("dis") // NOTE: Used by GNU's test suite
|
||||
.long(options::USE_POLLING)
|
||||
.help(polling_help)
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::RETRY)
|
||||
.long(options::RETRY)
|
||||
.help(translate!("tail-help-retry"))
|
||||
.overrides_with(options::RETRY)
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::FOLLOW_RETRY)
|
||||
.short('F')
|
||||
.help(translate!("tail-help-follow-retry"))
|
||||
.overrides_with(options::FOLLOW_RETRY)
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::DEBUG)
|
||||
.long(options::DEBUG)
|
||||
.help(translate!("tail-help-debug"))
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::PRESUME_INPUT_PIPE)
|
||||
.long("presume-input-pipe")
|
||||
.alias(options::PRESUME_INPUT_PIPE)
|
||||
.hide(true)
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new(options::ARG_FILES)
|
||||
.action(ArgAction::Append)
|
||||
.num_args(1..)
|
||||
.value_parser(value_parser!(OsString))
|
||||
.value_hint(clap::ValueHint::FilePath),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
use crate::parse::ObsoleteArgs;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_num_when_sign_is_given() {
|
||||
let result = parse_num("+0");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), Signum::PlusZero);
|
||||
|
||||
let result = parse_num("+1");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), Signum::Positive(1));
|
||||
|
||||
let result = parse_num("-0");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), Signum::MinusZero);
|
||||
|
||||
let result = parse_num("-1");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), Signum::Negative(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_num_when_no_sign_is_given() {
|
||||
let result = parse_num("0");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), Signum::MinusZero);
|
||||
|
||||
let result = parse_num("1");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), Signum::Negative(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_obsolete_settings_f() {
|
||||
let args = ObsoleteArgs {
|
||||
follow: true,
|
||||
..Default::default()
|
||||
};
|
||||
let result = Settings::from_obsolete_args(&args, None);
|
||||
assert_eq!(result.follow, Some(FollowMode::Descriptor));
|
||||
|
||||
let result = Settings::from_obsolete_args(&args, Some(&"file".into()));
|
||||
assert_eq!(result.follow, Some(FollowMode::Name));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::default(vec![], None, false)]
|
||||
#[case::retry(vec!["--retry"], None, true)]
|
||||
#[case::multiple_retry(vec!["--retry", "--retry"], None, true)]
|
||||
#[case::follow_long(vec!["--follow"], Some(FollowMode::Descriptor), false)]
|
||||
#[case::follow_short(vec!["-f"], Some(FollowMode::Descriptor), false)]
|
||||
#[case::follow_long_with_retry(vec!["--follow", "--retry"], Some(FollowMode::Descriptor), true)]
|
||||
#[case::follow_short_with_retry(vec!["-f", "--retry"], Some(FollowMode::Descriptor), true)]
|
||||
#[case::follow_overwrites_previous_selection_1(vec!["--follow=name", "--follow=descriptor"], Some(FollowMode::Descriptor), false)]
|
||||
#[case::follow_overwrites_previous_selection_2(vec!["--follow=descriptor", "--follow=name"], Some(FollowMode::Name), false)]
|
||||
#[case::big_f(vec!["-F"], Some(FollowMode::Name), true)]
|
||||
#[case::multiple_big_f(vec!["-F", "-F"], Some(FollowMode::Name), true)]
|
||||
#[case::big_f_with_retry_then_does_not_change(vec!["-F", "--retry"], Some(FollowMode::Name), true)]
|
||||
#[case::big_f_with_follow_descriptor_then_change(vec!["-F", "--follow=descriptor"], Some(FollowMode::Descriptor), true)]
|
||||
#[case::multiple_big_f_with_follow_descriptor_then_no_change(vec!["-F", "--follow=descriptor", "-F"], Some(FollowMode::Name), true)]
|
||||
#[case::big_f_with_follow_short_then_change(vec!["-F", "-f"], Some(FollowMode::Descriptor), true)]
|
||||
#[case::follow_descriptor_with_big_f_then_change(vec!["--follow=descriptor", "-F"], Some(FollowMode::Name), true)]
|
||||
#[case::follow_short_with_big_f_then_change(vec!["-f", "-F"], Some(FollowMode::Name), true)]
|
||||
#[case::big_f_with_follow_name_then_not_change(vec!["-F", "--follow=name"], Some(FollowMode::Name), true)]
|
||||
#[case::follow_name_with_big_f_then_not_change(vec!["--follow=name", "-F"], Some(FollowMode::Name), true)]
|
||||
#[case::big_f_with_multiple_long_follow(vec!["--follow=name", "-F", "--follow=descriptor"], Some(FollowMode::Descriptor), true)]
|
||||
#[case::big_f_with_multiple_long_follow_name(vec!["--follow=name", "-F", "--follow=name"], Some(FollowMode::Name), true)]
|
||||
#[case::big_f_with_multiple_short_follow(vec!["-f", "-F", "-f"], Some(FollowMode::Descriptor), true)]
|
||||
#[case::multiple_big_f_with_multiple_short_follow(vec!["-f", "-F", "-f", "-F"], Some(FollowMode::Name), true)]
|
||||
fn test_parse_settings_follow_mode_and_retry(
|
||||
#[case] args: Vec<&str>,
|
||||
#[case] expected_follow_mode: Option<FollowMode>,
|
||||
#[case] expected_retry: bool,
|
||||
) {
|
||||
let settings =
|
||||
Settings::from(&uu_app().no_binary_name(true).get_matches_from(args)).unwrap();
|
||||
assert_eq!(settings.follow, expected_follow_mode);
|
||||
assert_eq!(settings.retry, expected_retry);
|
||||
}
|
||||
}
|
||||
+700
@@ -0,0 +1,700 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
//! Iterating over a file by chunks, either starting at the end of the file with [`ReverseChunks`]
|
||||
//! or at the end of piped stdin with [`LinesChunk`] or [`BytesChunk`].
|
||||
//!
|
||||
//! Use [`ReverseChunks::new`] to create a new iterator over chunks of bytes from the file.
|
||||
|
||||
// spell-checker:ignore (ToDO) filehandle BUFSIZ
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, Read, Seek, SeekFrom, Write};
|
||||
use uucore::error::UResult;
|
||||
|
||||
/// When reading files in reverse in `bounded_tail`, this is the size of each
|
||||
/// block read at a time.
|
||||
pub const BLOCK_SIZE: u64 = 1 << 16;
|
||||
|
||||
/// The size of the backing buffer of a [`LinesChunk`] or [`BytesChunk`] in bytes. The value of `BUFFER_SIZE`
|
||||
/// originates from the BUFSIZ constant in stdio.h and the libc crate to make stream IO efficient.
|
||||
/// In the latter the value is constantly set to 8192 on all platforms, where the value in stdio.h
|
||||
/// is determined on each platform differently. Since libc chose 8192 as a reasonable default the
|
||||
/// value here is set to this value, too.
|
||||
pub const BUFFER_SIZE: usize = 8192;
|
||||
|
||||
/// An iterator over a file in non-overlapping chunks from the end of the file.
|
||||
///
|
||||
/// Each chunk is a [`Vec`]<[`u8`]> of size [`BLOCK_SIZE`] (except
|
||||
/// possibly the last chunk, which might be smaller). Each call to
|
||||
/// [`ReverseChunks::next`] will seek backwards through the given file.
|
||||
pub struct ReverseChunks<'a> {
|
||||
/// The file to iterate over, by blocks, from the end to the beginning.
|
||||
file: &'a File,
|
||||
|
||||
/// The total number of bytes in the file.
|
||||
size: u64,
|
||||
|
||||
/// The total number of blocks to read.
|
||||
max_blocks_to_read: usize,
|
||||
|
||||
/// The index of the next block to read.
|
||||
block_idx: usize,
|
||||
}
|
||||
|
||||
impl<'a> ReverseChunks<'a> {
|
||||
pub fn new(file: &'a mut File) -> Self {
|
||||
let current = if cfg!(unix) {
|
||||
file.stream_position().unwrap()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let size = file.seek(SeekFrom::End(0)).unwrap() - current;
|
||||
let max_blocks_to_read = (size as f64 / BLOCK_SIZE as f64).ceil() as usize;
|
||||
let block_idx = 0;
|
||||
ReverseChunks {
|
||||
file,
|
||||
size,
|
||||
max_blocks_to_read,
|
||||
block_idx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for ReverseChunks<'_> {
|
||||
type Item = Vec<u8>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// If there are no more chunks to read, terminate the iterator.
|
||||
if self.block_idx >= self.max_blocks_to_read {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The chunk size is `BLOCK_SIZE` for all but the last chunk
|
||||
// (that is, the chunk closest to the beginning of the file),
|
||||
// which contains the remainder of the bytes.
|
||||
let block_size = if self.block_idx == self.max_blocks_to_read - 1 {
|
||||
self.size % BLOCK_SIZE
|
||||
} else {
|
||||
BLOCK_SIZE
|
||||
};
|
||||
|
||||
// Seek backwards by the next chunk, read the full chunk into
|
||||
// `buf`, and then seek back to the start of the chunk again.
|
||||
let mut buf = vec![0; block_size as usize];
|
||||
let pos = self
|
||||
.file
|
||||
.seek(SeekFrom::Current(-(block_size as i64)))
|
||||
.unwrap();
|
||||
self.file.read_exact(&mut buf).unwrap();
|
||||
let pos2 = self
|
||||
.file
|
||||
.seek(SeekFrom::Current(-(block_size as i64)))
|
||||
.unwrap();
|
||||
assert_eq!(pos, pos2);
|
||||
|
||||
self.block_idx += 1;
|
||||
|
||||
Some(buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of the backing buffer of [`BytesChunk`] and [`LinesChunk`] which can hold
|
||||
/// [`BUFFER_SIZE`] elements at max.
|
||||
type ChunkBuffer = [u8; BUFFER_SIZE];
|
||||
|
||||
/// A [`BytesChunk`] storing a fixed size number of bytes in a buffer.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct BytesChunk {
|
||||
/// The [`ChunkBuffer`], an array storing the bytes, for example filled by
|
||||
/// [`BytesChunk::fill`]
|
||||
buffer: ChunkBuffer,
|
||||
|
||||
/// Stores the number of bytes, this buffer holds. This is not equal to `buffer.len()`, since the
|
||||
/// [`BytesChunk`] may store less bytes than the internal buffer can hold. In addition,
|
||||
/// [`BytesChunk`] may be reused, what makes it necessary to track the number of stored bytes.
|
||||
/// The choice of usize is sufficient here, since the number of bytes max value is
|
||||
/// [`BUFFER_SIZE`], which is a usize.
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl BytesChunk {
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buffer: [0; BUFFER_SIZE],
|
||||
bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new chunk from an existing chunk. The new chunk's buffer will be copied from the
|
||||
/// old chunk's buffer, copying the slice `[offset..old_chunk.bytes]` into the new chunk's
|
||||
/// buffer but starting at 0 instead of offset. If the offset is larger or equal to
|
||||
/// `chunk.lines` then a new empty `BytesChunk` is returned.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chunk`: The chunk to create a new `BytesChunk` chunk from
|
||||
/// * `offset`: Start to copy the old chunk's buffer from this position. May not be larger
|
||||
/// than `chunk.bytes`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let mut chunk = BytesChunk::new();
|
||||
/// chunk.buffer[1] = 1;
|
||||
/// chunk.bytes = 2;
|
||||
/// let new_chunk = BytesChunk::from_chunk(&chunk, 0);
|
||||
/// assert_eq!(2, new_chunk.get_buffer().len());
|
||||
/// assert_eq!(&[0, 1], new_chunk.get_buffer());
|
||||
///
|
||||
/// let new_chunk = BytesChunk::from_chunk(&chunk, 1);
|
||||
/// assert_eq!(1, new_chunk.get_buffer().len());
|
||||
/// assert_eq!(&[1], new_chunk.get_buffer());
|
||||
/// ```
|
||||
fn from_chunk(chunk: &Self, offset: usize) -> Self {
|
||||
if offset >= chunk.bytes {
|
||||
return Self::new();
|
||||
}
|
||||
|
||||
let mut buffer: ChunkBuffer = [0; BUFFER_SIZE];
|
||||
let slice = chunk.get_buffer_with(offset);
|
||||
buffer[..slice.len()].copy_from_slice(slice);
|
||||
Self {
|
||||
buffer,
|
||||
bytes: chunk.bytes - offset,
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive the internal buffer safely, so it returns a slice only containing as many bytes as
|
||||
/// large the `self.bytes` value is.
|
||||
///
|
||||
/// returns: a slice containing the bytes of the internal buffer from `[0..self.bytes]`
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let mut chunk = BytesChunk::new();
|
||||
/// chunk.bytes = 1;
|
||||
/// assert_eq!(&[0], chunk.get_buffer());
|
||||
/// ```
|
||||
pub fn get_buffer(&self) -> &[u8] {
|
||||
&self.buffer[..self.bytes]
|
||||
}
|
||||
|
||||
/// Like [`BytesChunk::get_buffer`], but returning a slice from `[offset.self.bytes]`.
|
||||
///
|
||||
/// returns: a slice containing the bytes of the internal buffer from `[offset..self.bytes]`
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let mut chunk = BytesChunk::new();
|
||||
/// chunk.bytes = 2;
|
||||
/// assert_eq!(&[0], chunk.get_buffer_with(1));
|
||||
/// ```
|
||||
pub fn get_buffer_with(&self, offset: usize) -> &[u8] {
|
||||
&self.buffer[offset..self.bytes]
|
||||
}
|
||||
|
||||
pub fn has_data(&self) -> bool {
|
||||
self.bytes > 0
|
||||
}
|
||||
|
||||
/// Fills `self.buffer` with maximal [`BUFFER_SIZE`] number of bytes, draining the reader by
|
||||
/// that number of bytes. If EOF is reached (so 0 bytes are read), it returns
|
||||
/// [`UResult<None>`]; otherwise, it returns [`UResult<Some(bytes)>`], where bytes is the
|
||||
/// number of bytes read from the source.
|
||||
pub fn fill(&mut self, filehandle: &mut impl BufRead) -> UResult<Option<usize>> {
|
||||
let num_bytes = filehandle.read(&mut self.buffer)?;
|
||||
self.bytes = num_bytes;
|
||||
if num_bytes == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(self.bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// An abstraction layer on top of [`BytesChunk`] mainly to simplify filling only the needed amount
|
||||
/// of chunks. See also [`Self::fill`].
|
||||
pub struct BytesChunkBuffer {
|
||||
/// The number of bytes to print
|
||||
num_print: u64,
|
||||
/// The current number of bytes summed over all stored chunks in [`Self::chunks`]. Use u64 here
|
||||
/// to support files > 4GB on 32-bit systems. Note, this differs from `BytesChunk::bytes` which
|
||||
/// is a usize. The choice of u64 is based on `tail::FilterMode::Bytes`.
|
||||
bytes: u64,
|
||||
/// The buffer to store [`BytesChunk`] in
|
||||
chunks: VecDeque<Box<BytesChunk>>,
|
||||
}
|
||||
|
||||
impl BytesChunkBuffer {
|
||||
/// Creates a new [`BytesChunkBuffer`].
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `num_print`: The number of bytes to print
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let mut chunk = BytesChunk::new();
|
||||
/// chunk.buffer[1] = 1;
|
||||
/// chunk.bytes = 2;
|
||||
/// let new_chunk = BytesChunk::from_chunk(&chunk, 0);
|
||||
/// assert_eq!(2, new_chunk.get_buffer().len());
|
||||
/// assert_eq!(&[0, 1], new_chunk.get_buffer());
|
||||
///
|
||||
/// let new_chunk = BytesChunk::from_chunk(&chunk, 1);
|
||||
/// assert_eq!(1, new_chunk.get_buffer().len());
|
||||
/// assert_eq!(&[1], new_chunk.get_buffer());
|
||||
/// ```
|
||||
pub fn new(num_print: u64) -> Self {
|
||||
Self {
|
||||
bytes: 0,
|
||||
num_print,
|
||||
chunks: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills this buffer with chunks and consumes the reader completely. This method ensures that
|
||||
/// there are exactly as many chunks as needed to match `self.num_print` bytes, so there are
|
||||
/// in sum exactly `self.num_print` bytes stored in all chunks. The method returns an iterator
|
||||
/// over these chunks. If there are no chunks, for example because the piped stdin contained no
|
||||
/// bytes, or `num_print = 0` then `iterator.next` returns None.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use crate::chunks::BytesChunkBuffer;
|
||||
/// use std::io::{BufReader, Cursor};
|
||||
///
|
||||
/// let mut reader = BufReader::new(Cursor::new(""));
|
||||
/// let num_print = 0;
|
||||
/// let mut chunks = BytesChunkBuffer::new(num_print);
|
||||
/// chunks.fill(&mut reader).unwrap();
|
||||
///
|
||||
/// let mut reader = BufReader::new(Cursor::new("a"));
|
||||
/// let num_print = 1;
|
||||
/// let mut chunks = BytesChunkBuffer::new(num_print);
|
||||
/// chunks.fill(&mut reader).unwrap();
|
||||
/// ```
|
||||
pub fn fill(&mut self, reader: &mut impl BufRead) -> UResult<()> {
|
||||
let mut chunk = Box::new(BytesChunk::new());
|
||||
|
||||
// fill chunks with all bytes from reader and reuse already instantiated chunks if possible
|
||||
while chunk.fill(reader)?.is_some() {
|
||||
self.bytes += chunk.bytes as u64;
|
||||
self.chunks.push_back(chunk.clone());
|
||||
|
||||
let first = &self.chunks[0];
|
||||
if self.bytes - first.bytes as u64 > self.num_print {
|
||||
chunk = self.chunks.pop_front().unwrap();
|
||||
self.bytes -= chunk.bytes as u64;
|
||||
} else {
|
||||
*chunk = BytesChunk::new();
|
||||
}
|
||||
}
|
||||
|
||||
let Some(chunk) = self.chunks.pop_front() else {
|
||||
// quit if there are no chunks for example in case the pipe was empty
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// calculate the offset in the first chunk and put the calculated chunk as first element in
|
||||
// the self.chunks collection. The calculated offset must be in the range 0 to BUFFER_SIZE
|
||||
// and is therefore safely convertible to a usize without losses.
|
||||
let offset = self.bytes.saturating_sub(self.num_print) as usize;
|
||||
self.chunks
|
||||
.push_front(Box::new(BytesChunk::from_chunk(&chunk, offset)));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write(&self, writer: &mut impl Write) -> UResult<()> {
|
||||
for chunk in &self.chunks {
|
||||
writer.write_all(chunk.get_buffer())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn has_data(&self) -> bool {
|
||||
!self.chunks.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Works similar to a [`BytesChunk`] but also stores the number of lines encountered in the current
|
||||
/// buffer. The size of the buffer is limited to a fixed size number of bytes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LinesChunk {
|
||||
/// Work on top of a [`BytesChunk`]
|
||||
chunk: BytesChunk,
|
||||
/// The number of lines delimited by `delimiter`. The choice of usize is sufficient here,
|
||||
/// because lines max value is the number of bytes contained in this chunk's buffer, and the
|
||||
/// number of bytes max value is [`BUFFER_SIZE`], which is a usize.
|
||||
lines: usize,
|
||||
/// The delimiter to use, to count the lines
|
||||
delimiter: u8,
|
||||
}
|
||||
|
||||
impl LinesChunk {
|
||||
pub fn new(delimiter: u8) -> Self {
|
||||
Self {
|
||||
chunk: BytesChunk::new(),
|
||||
lines: 0,
|
||||
delimiter,
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the number of lines delimited with [`Self::delimiter`] contained in the buffer.
|
||||
/// Currently [`memchr`] is used because performance is better than using an iterator or for
|
||||
/// loop.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let mut chunk = LinesChunk::new(b'\n');
|
||||
/// chunk.buffer[0..12].copy_from_slice("hello\nworld\n".as_bytes());
|
||||
/// chunk.bytes = 12;
|
||||
/// assert_eq!(2, chunk.count_lines());
|
||||
///
|
||||
/// chunk.buffer[0..14].copy_from_slice("hello\r\nworld\r\n".as_bytes());
|
||||
/// chunk.bytes = 14;
|
||||
/// assert_eq!(2, chunk.count_lines());
|
||||
/// ```
|
||||
fn count_lines(&self) -> usize {
|
||||
memchr::memchr_iter(self.delimiter, self.get_buffer()).count()
|
||||
}
|
||||
|
||||
/// Creates a new [`LinesChunk`] from an existing one with an offset in lines. The new chunk
|
||||
/// contains exactly `chunk.lines - offset` lines. The offset in bytes is calculated and applied
|
||||
/// to the new chunk, so the new chunk contains only the bytes encountered after the offset in
|
||||
/// number of lines and the `delimiter`. If the offset is larger than `chunk.lines` then a new
|
||||
/// empty `LinesChunk` is returned.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chunk`: The chunk to create the new chunk from
|
||||
/// * `offset`: The offset in number of lines (not bytes)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let mut chunk = LinesChunk::new(b'\n');
|
||||
/// // manually filling the buffer and setting the correct values for bytes and lines
|
||||
/// chunk.buffer[0..12].copy_from_slice("hello\nworld\n".as_bytes());
|
||||
/// chunk.bytes = 12;
|
||||
/// chunk.lines = 2;
|
||||
///
|
||||
/// let offset = 1; // offset in number of lines
|
||||
/// let new_chunk = LinesChunk::from(&chunk, offset);
|
||||
/// assert_eq!("world\n".as_bytes(), new_chunk.get_buffer());
|
||||
/// assert_eq!(6, new_chunk.bytes);
|
||||
/// assert_eq!(1, new_chunk.lines);
|
||||
/// ```
|
||||
fn from_chunk(chunk: &Self, offset: usize) -> Self {
|
||||
if offset > chunk.lines {
|
||||
return Self::new(chunk.delimiter);
|
||||
}
|
||||
|
||||
let bytes_offset = chunk.calculate_bytes_offset_from(offset);
|
||||
let new_chunk = BytesChunk::from_chunk(&chunk.chunk, bytes_offset);
|
||||
|
||||
Self {
|
||||
chunk: new_chunk,
|
||||
lines: chunk.lines - offset,
|
||||
delimiter: chunk.delimiter,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this buffer has stored any bytes.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let mut chunk = LinesChunk::new(b'\n');
|
||||
/// assert!(!chunk.has_data());
|
||||
///
|
||||
/// chunk.buffer[0] = 1;
|
||||
/// assert!(!chunk.has_data());
|
||||
///
|
||||
/// chunk.bytes = 1;
|
||||
/// assert!(chunk.has_data());
|
||||
/// ```
|
||||
pub fn has_data(&self) -> bool {
|
||||
self.chunk.has_data()
|
||||
}
|
||||
|
||||
/// Returns this buffer safely. See [`BytesChunk::get_buffer`]
|
||||
///
|
||||
/// returns: &[u8] with length `self.bytes`
|
||||
pub fn get_buffer(&self) -> &[u8] {
|
||||
self.chunk.get_buffer()
|
||||
}
|
||||
|
||||
/// Returns this buffer safely with an offset applied. See [`BytesChunk::get_buffer_with`].
|
||||
///
|
||||
/// returns: &[u8] with length `self.bytes - offset`
|
||||
pub fn get_buffer_with(&self, offset: usize) -> &[u8] {
|
||||
self.chunk.get_buffer_with(offset)
|
||||
}
|
||||
|
||||
/// Return the number of lines the buffer contains. `self.lines` needs to be set before the call
|
||||
/// to this function returns the correct value. If the calculation of lines is needed then
|
||||
/// use `self.count_lines`.
|
||||
pub fn get_lines(&self) -> usize {
|
||||
self.lines
|
||||
}
|
||||
|
||||
/// Fills `self.buffer` with maximal [`BUFFER_SIZE`] number of bytes, draining the reader by
|
||||
/// that number of bytes. This function works like the [`BytesChunk::fill`] function besides
|
||||
/// that this function also counts and stores the number of lines encountered while reading from
|
||||
/// the `filehandle`.
|
||||
pub fn fill(&mut self, filehandle: &mut impl BufRead) -> UResult<Option<usize>> {
|
||||
match self.chunk.fill(filehandle)? {
|
||||
None => {
|
||||
self.lines = 0;
|
||||
Ok(None)
|
||||
}
|
||||
Some(bytes) => {
|
||||
self.lines = self.count_lines();
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the offset in bytes within this buffer from the offset in number of lines. The
|
||||
/// resulting offset is 0-based and points to the byte after the delimiter.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `offset`: the offset in number of lines. If offset is 0 then 0 is returned, if larger than
|
||||
/// the contained lines then self.bytes is returned.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let mut chunk = LinesChunk::new(b'\n');
|
||||
/// chunk.buffer[0..12].copy_from_slice("hello\nworld\n".as_bytes());
|
||||
/// chunk.bytes = 12;
|
||||
/// chunk.lines = 2; // note that if not setting lines the result might not be what is expected
|
||||
/// let bytes_offset = chunk.calculate_bytes_offset_from(1);
|
||||
/// assert_eq!(6, bytes_offset);
|
||||
/// assert_eq!(
|
||||
/// "world\n",
|
||||
/// String::from_utf8_lossy(chunk.get_buffer_with(bytes_offset)));
|
||||
/// ```
|
||||
fn calculate_bytes_offset_from(&self, offset: usize) -> usize {
|
||||
let mut lines_offset = offset;
|
||||
let mut bytes_offset = 0;
|
||||
for byte in self.get_buffer() {
|
||||
if lines_offset == 0 {
|
||||
break;
|
||||
}
|
||||
if byte == &self.delimiter {
|
||||
lines_offset -= 1;
|
||||
}
|
||||
bytes_offset += 1;
|
||||
}
|
||||
bytes_offset
|
||||
}
|
||||
|
||||
/// Write the bytes contained in this buffer calculated with the given offset in number of
|
||||
/// lines.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `writer`: must implement [`Write`]
|
||||
/// * `offset`: An offset in number of lines.
|
||||
pub fn write_lines(&self, writer: &mut impl Write, offset: usize) -> UResult<()> {
|
||||
self.write_bytes(writer, self.calculate_bytes_offset_from(offset))
|
||||
}
|
||||
|
||||
/// Write the bytes contained in this buffer beginning from the given offset in number of bytes.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `writer`: must implement [`Write`]
|
||||
/// * `offset`: An offset in number of bytes.
|
||||
pub fn write_bytes(&self, writer: &mut impl Write, offset: usize) -> UResult<()> {
|
||||
writer.write_all(self.get_buffer_with(offset))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// An abstraction layer on top of [`LinesChunk`] mainly to simplify filling only the needed amount
|
||||
/// of chunks. See also [`Self::fill`]. Works similar like [`BytesChunkBuffer`], but works on top
|
||||
/// of lines delimited by `self.delimiter` instead of bytes.
|
||||
pub struct LinesChunkBuffer {
|
||||
/// The delimiter to recognize a line. Any [`u8`] is allowed.
|
||||
delimiter: u8,
|
||||
/// The amount of lines occurring in all currently stored [`LinesChunk`]s. Use u64 here to
|
||||
/// support files > 4GB on 32-bit systems. Note, this differs from [`LinesChunk::lines`] which
|
||||
/// is a usize. The choice of u64 is based on `tail::FilterMode::Lines`.
|
||||
lines: u64,
|
||||
/// The amount of lines to print.
|
||||
num_print: u64,
|
||||
/// Stores the [`LinesChunk`]
|
||||
chunks: VecDeque<Box<LinesChunk>>,
|
||||
}
|
||||
|
||||
impl LinesChunkBuffer {
|
||||
/// Create a new [`LinesChunkBuffer`]
|
||||
pub fn new(delimiter: u8, num_print: u64) -> Self {
|
||||
Self {
|
||||
delimiter,
|
||||
num_print,
|
||||
lines: 0,
|
||||
chunks: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills this buffer with chunks and consumes the reader completely. This method ensures that
|
||||
/// there are exactly as many chunks as needed to match `self.num_print` lines, so there are
|
||||
/// in sum exactly `self.num_print` lines stored in all chunks. The method returns an iterator
|
||||
/// over these chunks. If there are no chunks, for example because the piped stdin contained no
|
||||
/// lines, or `num_print = 0` then `iterator.next` will return None.
|
||||
pub fn fill(&mut self, reader: &mut impl BufRead) -> UResult<()> {
|
||||
let mut chunk = Box::new(LinesChunk::new(self.delimiter));
|
||||
|
||||
while chunk.fill(reader)?.is_some() {
|
||||
self.lines += chunk.lines as u64;
|
||||
self.chunks.push_back(chunk.clone());
|
||||
|
||||
let first = &self.chunks[0];
|
||||
if self.lines - first.lines as u64 > self.num_print {
|
||||
chunk = self.chunks.pop_front().unwrap();
|
||||
|
||||
self.lines -= chunk.lines as u64;
|
||||
} else {
|
||||
*chunk = LinesChunk::new(self.delimiter);
|
||||
}
|
||||
}
|
||||
|
||||
if self.chunks.is_empty() {
|
||||
// chunks is empty when a file is empty so quitting early here
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let length = &self.chunks.len();
|
||||
let last = &mut self.chunks[length - 1];
|
||||
if !last.get_buffer().ends_with(&[self.delimiter]) {
|
||||
last.lines += 1;
|
||||
self.lines += 1;
|
||||
}
|
||||
|
||||
// skip unnecessary chunks and save the first chunk which may hold some lines we have to
|
||||
// print
|
||||
let chunk = loop {
|
||||
// it's safe to call unwrap here because there is at least one chunk and sorting out
|
||||
// more chunks than exist shouldn't be possible.
|
||||
let chunk = self.chunks.pop_front().unwrap();
|
||||
|
||||
// skip is true as long there are enough lines left in the other stored chunks.
|
||||
let skip = self.lines - chunk.lines as u64 > self.num_print;
|
||||
if skip {
|
||||
self.lines -= chunk.lines as u64;
|
||||
} else {
|
||||
break chunk;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate the number of lines to skip in the current chunk. The calculated value must be
|
||||
// in the range 0 to BUFFER_SIZE and is therefore safely convertible to a usize without
|
||||
// losses.
|
||||
let skip_lines = self.lines.saturating_sub(self.num_print) as usize;
|
||||
let chunk = LinesChunk::from_chunk(&chunk, skip_lines);
|
||||
self.chunks.push_front(Box::new(chunk));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write(&self, mut writer: impl Write) -> UResult<()> {
|
||||
for chunk in &self.chunks {
|
||||
chunk.write_bytes(&mut writer, 0)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::chunks::{BUFFER_SIZE, BytesChunk};
|
||||
|
||||
#[test]
|
||||
fn test_bytes_chunk_from_when_offset_is_zero() {
|
||||
let mut chunk = BytesChunk::new();
|
||||
chunk.bytes = BUFFER_SIZE;
|
||||
chunk.buffer[1] = 1;
|
||||
let other = BytesChunk::from_chunk(&chunk, 0);
|
||||
assert_eq!(other, chunk);
|
||||
|
||||
chunk.bytes = 2;
|
||||
let other = BytesChunk::from_chunk(&chunk, 0);
|
||||
assert_eq!(other, chunk);
|
||||
|
||||
chunk.bytes = 1;
|
||||
let other = BytesChunk::from_chunk(&chunk, 0);
|
||||
assert_eq!(other.buffer, [0; BUFFER_SIZE]);
|
||||
assert_eq!(other.bytes, chunk.bytes);
|
||||
|
||||
chunk.bytes = BUFFER_SIZE;
|
||||
let other = BytesChunk::from_chunk(&chunk, 2);
|
||||
assert_eq!(other.buffer, [0; BUFFER_SIZE]);
|
||||
assert_eq!(other.bytes, BUFFER_SIZE - 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_chunk_from_when_offset_is_not_zero() {
|
||||
let mut chunk = BytesChunk::new();
|
||||
chunk.bytes = BUFFER_SIZE;
|
||||
chunk.buffer[1] = 1;
|
||||
|
||||
let other = BytesChunk::from_chunk(&chunk, 1);
|
||||
let mut expected_buffer = [0; BUFFER_SIZE];
|
||||
expected_buffer[0] = 1;
|
||||
assert_eq!(other.buffer, expected_buffer);
|
||||
assert_eq!(other.bytes, BUFFER_SIZE - 1);
|
||||
|
||||
let other = BytesChunk::from_chunk(&chunk, 2);
|
||||
assert_eq!(other.buffer, [0; BUFFER_SIZE]);
|
||||
assert_eq!(other.bytes, BUFFER_SIZE - 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_chunk_from_when_offset_is_larger_than_chunk_size_1() {
|
||||
let mut chunk = BytesChunk::new();
|
||||
chunk.bytes = BUFFER_SIZE;
|
||||
let new_chunk = BytesChunk::from_chunk(&chunk, BUFFER_SIZE + 1);
|
||||
assert_eq!(0, new_chunk.bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_chunk_from_when_offset_is_larger_than_chunk_size_2() {
|
||||
let mut chunk = BytesChunk::new();
|
||||
chunk.bytes = 0;
|
||||
let new_chunk = BytesChunk::from_chunk(&chunk, 1);
|
||||
assert_eq!(0, new_chunk.bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_chunk_from_when_offset_is_larger_than_chunk_size_3() {
|
||||
let mut chunk = BytesChunk::new();
|
||||
chunk.bytes = 1;
|
||||
let new_chunk = BytesChunk::from_chunk(&chunk, 2);
|
||||
assert_eq!(0, new_chunk.bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_chunk_from_when_offset_is_equal_to_chunk_size() {
|
||||
let mut chunk = BytesChunk::new();
|
||||
chunk.buffer[0] = 1;
|
||||
chunk.bytes = 1;
|
||||
let new_chunk = BytesChunk::from_chunk(&chunk, 1);
|
||||
assert_eq!(0, new_chunk.bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
// spell-checker:ignore tailable stdlib (stdlib)
|
||||
|
||||
use crate::args::Settings;
|
||||
use crate::chunks::BytesChunkBuffer;
|
||||
use crate::paths::{HeaderPrinter, PathExtTail};
|
||||
use crate::text;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Keys;
|
||||
use std::fs::{File, Metadata};
|
||||
use std::io::{BufRead, BufReader, BufWriter, Write, stdout};
|
||||
use std::path::{Path, PathBuf};
|
||||
use uucore::error::UResult;
|
||||
|
||||
/// Data structure to keep a handle on files to follow.
|
||||
/// `last` always holds the path/key of the last file that was printed from.
|
||||
/// The keys of the [`HashMap`] can point to an existing file path (normal case),
|
||||
/// or stdin ("-"), or to a non-existing path (--retry).
|
||||
/// For existing files, all keys in the [`HashMap`] are absolute Paths.
|
||||
pub struct FileHandling {
|
||||
map: HashMap<PathBuf, PathData>,
|
||||
last: Option<PathBuf>,
|
||||
header_printer: HeaderPrinter,
|
||||
}
|
||||
|
||||
impl FileHandling {
|
||||
pub fn from(settings: &Settings) -> Self {
|
||||
Self {
|
||||
map: HashMap::with_capacity(settings.inputs.len()),
|
||||
last: None,
|
||||
header_printer: HeaderPrinter::new(settings.verbose, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper for [`HashMap::insert`] using [`Path::canonicalize`]
|
||||
pub fn insert(&mut self, k: &Path, v: PathData, update_last: bool) {
|
||||
let k = Self::canonicalize_path(k);
|
||||
if update_last {
|
||||
self.last = Some(k.clone());
|
||||
}
|
||||
let _ = self.map.insert(k, v);
|
||||
}
|
||||
|
||||
/// Wrapper for [`HashMap::remove`] using [`Path::canonicalize`]
|
||||
pub fn remove(&mut self, k: &Path) -> PathData {
|
||||
self.map.remove(&Self::canonicalize_path(k)).unwrap()
|
||||
}
|
||||
|
||||
/// Wrapper for [`HashMap::get`] using [`Path::canonicalize`]
|
||||
pub fn get(&self, k: &Path) -> &PathData {
|
||||
self.map.get(&Self::canonicalize_path(k)).unwrap()
|
||||
}
|
||||
|
||||
/// Wrapper for [`HashMap::get_mut`] using [`Path::canonicalize`]
|
||||
pub fn get_mut(&mut self, k: &Path) -> &mut PathData {
|
||||
self.map.get_mut(&Self::canonicalize_path(k)).unwrap()
|
||||
}
|
||||
|
||||
/// Canonicalize `path` if it is not already an absolute path
|
||||
fn canonicalize_path(path: &Path) -> PathBuf {
|
||||
if path.is_relative()
|
||||
&& !path.is_stdin()
|
||||
&& let Ok(p) = path.canonicalize()
|
||||
{
|
||||
return p;
|
||||
}
|
||||
path.to_owned()
|
||||
}
|
||||
|
||||
pub fn get_mut_metadata(&mut self, path: &Path) -> Option<&Metadata> {
|
||||
self.get_mut(path).metadata.as_ref()
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> Keys<'_, PathBuf, PathData> {
|
||||
self.map.keys()
|
||||
}
|
||||
|
||||
pub fn contains_key(&self, k: &Path) -> bool {
|
||||
self.map.contains_key(k)
|
||||
}
|
||||
|
||||
pub fn get_last(&self) -> Option<&PathBuf> {
|
||||
self.last.as_ref()
|
||||
}
|
||||
|
||||
/// Return true if there is only stdin remaining
|
||||
pub fn only_stdin_remaining(&self) -> bool {
|
||||
self.map.len() == 1 && (self.map.contains_key(Path::new(text::DASH)))
|
||||
}
|
||||
|
||||
/// Return true if there is at least one "tailable" path (or stdin) remaining
|
||||
pub fn files_remaining(&self) -> bool {
|
||||
for path in self.map.keys() {
|
||||
if path.is_tailable() || path.is_stdin() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns true if there are no files remaining
|
||||
pub fn no_files_remaining(&self, settings: &Settings) -> bool {
|
||||
self.map.is_empty() || !self.files_remaining() && !settings.retry
|
||||
}
|
||||
|
||||
/// Set `reader` to None to indicate that `path` is not an existing file anymore.
|
||||
pub fn reset_reader(&mut self, path: &Path) {
|
||||
self.get_mut(path).reader = None;
|
||||
}
|
||||
|
||||
/// Reopen the file at the monitored `path`
|
||||
pub fn update_reader(&mut self, path: &Path) -> UResult<()> {
|
||||
/*
|
||||
BUG: If it's not necessary to reopen a file, GNU's tail calls seek to offset 0.
|
||||
However, we can't call seek here because `BufRead` does not implement `Seek`.
|
||||
As a workaround, we always reopen the file even though this might not always
|
||||
be necessary.
|
||||
*/
|
||||
self.get_mut(path)
|
||||
.reader
|
||||
.replace(Box::new(BufReader::new(File::open(path)?)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reload metadata from `path`, or `metadata`
|
||||
pub fn update_metadata(&mut self, path: &Path, metadata: Option<Metadata>) {
|
||||
self.get_mut(path).metadata = if metadata.is_some() {
|
||||
metadata
|
||||
} else {
|
||||
path.metadata().ok()
|
||||
};
|
||||
}
|
||||
|
||||
/// Read new data from `path` and print it to stdout
|
||||
pub fn tail_file(&mut self, path: &Path, verbose: bool) -> UResult<bool> {
|
||||
let mut chunks = BytesChunkBuffer::new(u64::MAX);
|
||||
if let Some(reader) = self.get_mut(path).reader.as_mut() {
|
||||
chunks.fill(reader)?;
|
||||
}
|
||||
if chunks.has_data() {
|
||||
if self.needs_header(path, verbose) {
|
||||
let display_name = self.get(path).display_name.clone();
|
||||
self.header_printer.print(display_name.as_str());
|
||||
}
|
||||
|
||||
let mut writer = BufWriter::new(stdout().lock());
|
||||
chunks.write(&mut writer)?;
|
||||
writer.flush()?;
|
||||
|
||||
self.last.replace(path.to_owned());
|
||||
self.update_metadata(path, None);
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide if printing `path` needs a header based on when it was last printed
|
||||
pub fn needs_header(&self, path: &Path, verbose: bool) -> bool {
|
||||
if verbose {
|
||||
if let Some(ref last) = self.last {
|
||||
!last.eq(&path)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data structure to keep a handle on the [`BufReader`], [`Metadata`]
|
||||
/// and the `display_name` (`header_name`) of files that are being followed.
|
||||
pub struct PathData {
|
||||
pub reader: Option<Box<dyn BufRead>>,
|
||||
pub metadata: Option<Metadata>,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
impl PathData {
|
||||
pub fn new(
|
||||
reader: Option<Box<dyn BufRead>>,
|
||||
metadata: Option<Metadata>,
|
||||
display_name: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
reader,
|
||||
metadata,
|
||||
display_name: display_name.to_owned(),
|
||||
}
|
||||
}
|
||||
pub fn from_other_with_path(data: Self, path: &Path) -> Self {
|
||||
// Remove old reader
|
||||
let old_reader = data.reader;
|
||||
let reader = if old_reader.is_some() {
|
||||
// Use old reader with the same file descriptor if there is one
|
||||
old_reader
|
||||
} else if let Ok(file) = File::open(path) {
|
||||
// Open new file tail from start
|
||||
Some(Box::new(BufReader::new(file)) as Box<dyn BufRead>)
|
||||
} else {
|
||||
// Probably file was renamed/moved or removed again
|
||||
None
|
||||
};
|
||||
|
||||
Self::new(reader, path.metadata().ok(), data.display_name.as_str())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
mod files;
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
mod watch;
|
||||
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
pub use watch::{Observer, follow};
|
||||
|
||||
// WASI: notify/inotify are unavailable, so `tail -f` cannot work.
|
||||
// Provide minimal stubs matching the real Observer API so tail compiles.
|
||||
#[cfg(target_os = "wasi")]
|
||||
mod wasi_stubs {
|
||||
use crate::args::Settings;
|
||||
use std::io::BufRead;
|
||||
use std::path::Path;
|
||||
use uucore::error::{UResult, USimpleError};
|
||||
|
||||
pub struct Observer {
|
||||
pub use_polling: bool,
|
||||
}
|
||||
|
||||
impl Observer {
|
||||
pub fn from(_settings: &Settings) -> Self {
|
||||
Self { use_polling: false }
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
pub fn start(&mut self, _settings: &Settings) -> UResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
pub fn add_path(
|
||||
&mut self,
|
||||
_path: &Path,
|
||||
_display_name: &str,
|
||||
_reader: Option<Box<dyn BufRead>>,
|
||||
_update_last: bool,
|
||||
) -> UResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
pub fn add_bad_path(
|
||||
&mut self,
|
||||
_path: &Path,
|
||||
_display_name: &str,
|
||||
_update_last: bool,
|
||||
) -> UResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn follow_name_retry(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn follow(_observer: Observer, _settings: &Settings) -> UResult<()> {
|
||||
Err(USimpleError::new(
|
||||
1,
|
||||
"follow mode is not supported on this platform",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "wasi")]
|
||||
pub use wasi_stubs::{Observer, follow};
|
||||
@@ -0,0 +1,675 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
// spell-checker:ignore (ToDO) tailable untailable stdlib kqueue Uncategorized unwatch
|
||||
|
||||
use crate::args::{FollowMode, Settings};
|
||||
use crate::follow::files::{FileHandling, PathData};
|
||||
use crate::paths::{Input, InputKind, MetadataExtTail, PathExtTail};
|
||||
use crate::{platform, text};
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher, WatcherKind};
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{self, Receiver, channel};
|
||||
use uucore::display::Quotable;
|
||||
use uucore::error::{UResult, USimpleError, set_exit_code};
|
||||
#[cfg(target_os = "linux")]
|
||||
use uucore::signals::ensure_stdout_not_broken;
|
||||
use uucore::translate;
|
||||
|
||||
use uucore::show_error;
|
||||
|
||||
pub struct WatcherRx {
|
||||
watcher: Box<dyn Watcher>,
|
||||
receiver: Receiver<Result<notify::Event, notify::Error>>,
|
||||
}
|
||||
|
||||
impl WatcherRx {
|
||||
fn new(
|
||||
watcher: Box<dyn Watcher>,
|
||||
receiver: Receiver<Result<notify::Event, notify::Error>>,
|
||||
) -> Self {
|
||||
Self { watcher, receiver }
|
||||
}
|
||||
|
||||
/// Wrapper for `notify::Watcher::watch` to also add the parent directory of `path` if necessary.
|
||||
fn watch_with_parent(&mut self, path: &Path) -> UResult<()> {
|
||||
let mut path = path.to_owned();
|
||||
#[cfg(target_os = "linux")]
|
||||
if path.is_file() {
|
||||
/*
|
||||
NOTE: Using the parent directory instead of the file is a workaround.
|
||||
This workaround follows the recommendation of the notify crate authors:
|
||||
> On some platforms, if the `path` is renamed or removed while being watched, behavior may
|
||||
> be unexpected. See discussions in [#165] and [#166]. If less surprising behavior is wanted
|
||||
> one may non-recursively watch the _parent_ directory as well and manage related events.
|
||||
NOTE: Adding both: file and parent results in duplicate/wrong events.
|
||||
Tested for notify::InotifyWatcher and for notify::PollWatcher.
|
||||
*/
|
||||
if let Some(parent) = path.parent() {
|
||||
if parent.is_dir() {
|
||||
path = parent.to_owned();
|
||||
} else {
|
||||
path = PathBuf::from(".");
|
||||
}
|
||||
} else {
|
||||
return Err(USimpleError::new(
|
||||
1,
|
||||
translate!("tail-error-cannot-watch-parent-directory", "path" => path.quote()),
|
||||
));
|
||||
}
|
||||
}
|
||||
if path.is_relative() {
|
||||
path = path.canonicalize()?;
|
||||
}
|
||||
|
||||
// for syscalls: 2x "inotify_add_watch" ("filename" and ".") and 1x "inotify_rm_watch"
|
||||
self.watch(&path, RecursiveMode::NonRecursive)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn watch(&mut self, path: &Path, mode: RecursiveMode) -> UResult<()> {
|
||||
self.watcher
|
||||
.watch(path, mode)
|
||||
.map_err(|err| USimpleError::new(1, err.to_string()))
|
||||
}
|
||||
|
||||
fn unwatch(&mut self, path: &Path) -> UResult<()> {
|
||||
self.watcher
|
||||
.unwatch(path)
|
||||
.map_err(|err| USimpleError::new(1, err.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Observer {
|
||||
/// Whether --retry was given on the command line
|
||||
pub retry: bool,
|
||||
|
||||
/// The [`FollowMode`]
|
||||
pub follow: Option<FollowMode>,
|
||||
|
||||
/// Indicates whether to use the fallback `polling` method instead of the
|
||||
/// platform specific event driven method. Since `use_polling` is subject to
|
||||
/// change during runtime it is moved out of [`Settings`].
|
||||
pub use_polling: bool,
|
||||
|
||||
pub watcher_rx: Option<WatcherRx>,
|
||||
pub orphans: Vec<PathBuf>,
|
||||
pub files: FileHandling,
|
||||
|
||||
pub pid: platform::Pid,
|
||||
}
|
||||
|
||||
impl Observer {
|
||||
pub fn new(
|
||||
retry: bool,
|
||||
follow: Option<FollowMode>,
|
||||
use_polling: bool,
|
||||
files: FileHandling,
|
||||
pid: platform::Pid,
|
||||
) -> Self {
|
||||
let pid = if platform::supports_pid_checks(pid) {
|
||||
pid
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Self {
|
||||
retry,
|
||||
follow,
|
||||
use_polling,
|
||||
watcher_rx: None,
|
||||
orphans: Vec::new(),
|
||||
files,
|
||||
pid,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from(settings: &Settings) -> Self {
|
||||
Self::new(
|
||||
settings.retry,
|
||||
settings.follow,
|
||||
settings.use_polling,
|
||||
FileHandling::from(settings),
|
||||
settings.pid.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn add_path(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
display_name: &str,
|
||||
reader: Option<Box<dyn BufRead>>,
|
||||
update_last: bool,
|
||||
) -> UResult<()> {
|
||||
if self.follow.is_some() {
|
||||
let path = if path.is_relative() {
|
||||
std::env::current_dir()?.join(path)
|
||||
} else {
|
||||
path.to_owned()
|
||||
};
|
||||
let metadata = path.metadata().ok();
|
||||
self.files.insert(
|
||||
&path,
|
||||
PathData::new(reader, metadata, display_name),
|
||||
update_last,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_bad_path(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
display_name: &str,
|
||||
update_last: bool,
|
||||
) -> UResult<()> {
|
||||
if self.retry && self.follow.is_some() {
|
||||
return self.add_path(path, display_name, None, update_last);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start(&mut self, settings: &Settings) -> UResult<()> {
|
||||
if settings.follow.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (tx, rx) = channel();
|
||||
|
||||
/*
|
||||
Watcher is implemented per platform using the best implementation available on that
|
||||
platform. In addition to such event driven implementations, a polling implementation
|
||||
is also provided that should work on any platform.
|
||||
Linux / Android: inotify
|
||||
macOS: FSEvents / kqueue
|
||||
Windows: ReadDirectoryChangesWatcher
|
||||
FreeBSD / NetBSD / OpenBSD / DragonflyBSD: kqueue
|
||||
Fallback: polling every n seconds
|
||||
|
||||
NOTE:
|
||||
We force the use of kqueue with: features=["macos_kqueue"].
|
||||
On macOS only `kqueue` is suitable for our use case because `FSEvents`
|
||||
waits for file close util it delivers a modify event. See:
|
||||
https://github.com/notify-rs/notify/issues/240
|
||||
*/
|
||||
|
||||
let watcher: Box<dyn Watcher>;
|
||||
let watcher_config = notify::Config::default()
|
||||
.with_poll_interval(settings.sleep_sec)
|
||||
/*
|
||||
NOTE: By enabling compare_contents, performance will be significantly impacted
|
||||
as all files will need to be read and hashed at each `poll_interval`.
|
||||
However, this is necessary to pass: "gnu/tests/tail-2/F-vs-rename.sh"
|
||||
*/
|
||||
.with_compare_contents(true);
|
||||
if self.use_polling || RecommendedWatcher::kind() == WatcherKind::PollWatcher {
|
||||
self.use_polling = true; // We have to use polling because there's no supported backend
|
||||
watcher = Box::new(notify::PollWatcher::new(tx, watcher_config).unwrap());
|
||||
} else {
|
||||
let tx_clone = tx.clone();
|
||||
match RecommendedWatcher::new(tx, notify::Config::default()) {
|
||||
Ok(w) => watcher = Box::new(w),
|
||||
Err(e) if e.to_string().starts_with("Too many open files") => {
|
||||
/*
|
||||
NOTE: This ErrorKind is `Uncategorized`, but it is not recommended
|
||||
to match an error against `Uncategorized`
|
||||
NOTE: Could be tested with decreasing `max_user_instances`, e.g.:
|
||||
`sudo sysctl fs.inotify.max_user_instances=64`
|
||||
*/
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-error-backend-cannot-be-used-too-many-files", "backend" => text::BACKEND)
|
||||
);
|
||||
set_exit_code(1);
|
||||
self.use_polling = true;
|
||||
watcher = Box::new(notify::PollWatcher::new(tx_clone, watcher_config).unwrap());
|
||||
}
|
||||
Err(e) => return Err(USimpleError::new(1, e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
self.watcher_rx = Some(WatcherRx::new(watcher, rx));
|
||||
self.init_files(&settings.inputs)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn follow_descriptor(&self) -> bool {
|
||||
self.follow == Some(FollowMode::Descriptor)
|
||||
}
|
||||
|
||||
pub fn follow_name(&self) -> bool {
|
||||
self.follow == Some(FollowMode::Name)
|
||||
}
|
||||
|
||||
pub fn follow_descriptor_retry(&self) -> bool {
|
||||
self.follow_descriptor() && self.retry
|
||||
}
|
||||
|
||||
pub fn follow_name_retry(&self) -> bool {
|
||||
self.follow_name() && self.retry
|
||||
}
|
||||
|
||||
fn init_files(&mut self, inputs: &Vec<Input>) -> UResult<()> {
|
||||
if let Some(watcher_rx) = &mut self.watcher_rx {
|
||||
for input in inputs {
|
||||
match input.kind() {
|
||||
InputKind::Stdin => (),
|
||||
InputKind::File(path) => {
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let mut path = path.clone();
|
||||
if path.is_relative() {
|
||||
path = std::env::current_dir()?.join(path);
|
||||
}
|
||||
|
||||
if path.is_tailable() {
|
||||
// Add existing regular files to `Watcher` (InotifyWatcher).
|
||||
watcher_rx.watch_with_parent(&path)?;
|
||||
} else if let Some(active_parent) = path.parent().filter(|p| p.is_dir()) {
|
||||
// If `path` is not a tailable file, add its parent to `Watcher`.
|
||||
watcher_rx.watch(active_parent, RecursiveMode::NonRecursive)?;
|
||||
// Add symlinks to orphans for retry polling (target may not exist)
|
||||
if path.is_symlink() {
|
||||
self.orphans.push(path);
|
||||
}
|
||||
} else {
|
||||
// If there is no parent, add `path` to `orphans`.
|
||||
self.orphans.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
fn handle_event(
|
||||
&mut self,
|
||||
event: ¬ify::Event,
|
||||
settings: &Settings,
|
||||
) -> UResult<Vec<PathBuf>> {
|
||||
use notify::event::{
|
||||
CreateKind, DataChange, EventKind, MetadataKind, ModifyKind, RemoveKind, RenameMode,
|
||||
};
|
||||
|
||||
let event_path = event.paths.first().unwrap();
|
||||
let mut paths: Vec<PathBuf> = vec![];
|
||||
let display_name = self.files.get(event_path).display_name.clone();
|
||||
|
||||
match event.kind {
|
||||
// NOTE: `ModifyKind::Any` is emitted by the Windows `ReadDirectoryChangesW`
|
||||
// backend (for `FILE_ACTION_MODIFIED`) and by the macOS/BSD `kqueue` backend
|
||||
// (for `NOTE_WRITE`) whenever a watched file's contents change. Without it,
|
||||
// appends are never followed on those platforms (see GH issue #4827).
|
||||
// Linux inotify and the polling fallback report the more specific
|
||||
// `Modify(Data(..))`/`Modify(Metadata(..))` instead, so this is a no-op there.
|
||||
EventKind::Modify(ModifyKind::Any | ModifyKind::Metadata(MetadataKind::Any | MetadataKind::WriteTime) | ModifyKind::Data(DataChange::Any) | ModifyKind::Name(RenameMode::To)) |
|
||||
EventKind::Create(CreateKind::File | CreateKind::Folder | CreateKind::Any) => {
|
||||
if let Ok(new_md) = event_path.metadata() {
|
||||
// `metadata()` follows symlinks, so under --follow=name a
|
||||
// watched file swapped for a symlink would otherwise be
|
||||
// silently followed to its target. GNU treats such a
|
||||
// replacement as untailable.
|
||||
let replaced_by_symlink = self.follow_name()
|
||||
&& event_path
|
||||
.symlink_metadata()
|
||||
.is_ok_and(|m| m.file_type().is_symlink());
|
||||
let is_tailable = !replaced_by_symlink && new_md.is_tailable();
|
||||
let pd = self.files.get(event_path);
|
||||
if let Some(old_md) = &pd.metadata {
|
||||
if is_tailable {
|
||||
// The path was untailable (missing, or not a
|
||||
// regular file) and has become tailable again:
|
||||
// open the new file and read it from the start.
|
||||
if !old_md.is_tailable() {
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-status-has-become-accessible", "file" => display_name.quote())
|
||||
);
|
||||
self.files.update_reader(event_path)?;
|
||||
} else if pd.reader.is_none() {
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-status-has-appeared-following-new-file", "file" => display_name.quote())
|
||||
);
|
||||
self.files.update_reader(event_path)?;
|
||||
} else if event.kind == EventKind::Modify(ModifyKind::Name(RenameMode::To))
|
||||
|| (self.use_polling && !old_md.file_id_eq(&new_md)) {
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-status-has-been-replaced-following-new-file", "file" => display_name.quote())
|
||||
);
|
||||
self.files.update_reader(event_path)?;
|
||||
} else if old_md.got_truncated(&new_md)? {
|
||||
// A file that has lost content is read again
|
||||
// from offset zero, since shortening a log
|
||||
// almost always means it was emptied outright.
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-status-file-truncated", "file" => display_name)
|
||||
);
|
||||
self.files.update_reader(event_path)?;
|
||||
}
|
||||
paths.push(event_path.clone());
|
||||
} else if !is_tailable && old_md.is_tailable() {
|
||||
if replaced_by_symlink {
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-status-replaced-with-untailable-symlink", "file" => display_name.quote())
|
||||
);
|
||||
self.files.reset_reader(event_path);
|
||||
} else if pd.reader.is_some() {
|
||||
self.files.reset_reader(event_path);
|
||||
} else {
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-status-replaced-with-untailable-file", "file" => display_name.quote())
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if is_tailable {
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-status-has-appeared-following-new-file", "file" => display_name.quote())
|
||||
);
|
||||
self.files.update_reader(event_path)?;
|
||||
paths.push(event_path.clone());
|
||||
} else if settings.retry {
|
||||
if self.follow_descriptor() {
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-status-replaced-with-untailable-file-giving-up", "file" => display_name.quote())
|
||||
);
|
||||
let _ = self.watcher_rx.as_mut().unwrap().watcher.unwatch(event_path);
|
||||
self.files.remove(event_path);
|
||||
if self.files.no_files_remaining(settings) {
|
||||
return Err(USimpleError::new(1, translate!("tail-no-files-remaining")));
|
||||
}
|
||||
} else {
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-status-replaced-with-untailable-file", "file" => display_name.quote())
|
||||
);
|
||||
}
|
||||
}
|
||||
self.files.update_metadata(event_path, Some(new_md));
|
||||
} else if event_path.is_symlink() && settings.retry {
|
||||
self.files.reset_reader(event_path);
|
||||
self.orphans.push(event_path.clone());
|
||||
}
|
||||
}
|
||||
EventKind::Remove(RemoveKind::File | RemoveKind::Any)
|
||||
| EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
|
||||
if self.follow_name() {
|
||||
if settings.retry {
|
||||
if self.files.get_mut_metadata(event_path).is_some_and(MetadataExtTail::is_tailable)
|
||||
&& self.files.get(event_path).reader.is_some() {
|
||||
show_error!(
|
||||
"{} {}: {}",
|
||||
display_name.quote(),
|
||||
translate!("tail-become-inaccessible"),
|
||||
translate!("tail-no-such-file-or-directory")
|
||||
);
|
||||
}
|
||||
if !event_path.has_active_parent() && !self.orphans.contains(event_path) {
|
||||
show_error!("{}", translate!("tail-status-directory-containing-watched-file-removed"));
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-status-backend-cannot-be-used-reverting-to-polling", "backend" => text::BACKEND)
|
||||
);
|
||||
self.orphans.push(event_path.clone());
|
||||
let _ = self.watcher_rx.as_mut().unwrap().unwatch(event_path);
|
||||
}
|
||||
} else {
|
||||
show_error!(
|
||||
"{}: {}",
|
||||
display_name,
|
||||
translate!("tail-no-such-file-or-directory")
|
||||
);
|
||||
if !self.files.files_remaining() && self.use_polling {
|
||||
// NOTE: GNU's tail exits here for `---disable-inotify`
|
||||
return Err(USimpleError::new(1, translate!("tail-no-files-remaining")));
|
||||
}
|
||||
}
|
||||
self.files.reset_reader(event_path);
|
||||
} else if self.follow_descriptor_retry() {
|
||||
// --retry only effective for the initial open
|
||||
let _ = self.watcher_rx.as_mut().unwrap().unwatch(event_path);
|
||||
self.files.remove(event_path);
|
||||
} else if self.use_polling && event.kind == EventKind::Remove(RemoveKind::Any) {
|
||||
/*
|
||||
BUG: The watched file was removed. Since we're using Polling, this
|
||||
could be a rename. We can't tell because `notify::PollWatcher` doesn't
|
||||
recognize renames properly.
|
||||
Ideally we want to call seek to offset 0 on the file handle.
|
||||
But because we only have access to `PathData::reader` as `BufRead`,
|
||||
we cannot seek to 0 with `BufReader::seek_relative`.
|
||||
Also because we don't have the new name, we cannot work around this
|
||||
by simply reopening the file.
|
||||
*/
|
||||
}
|
||||
}
|
||||
EventKind::Modify(ModifyKind::Name(RenameMode::Both))
|
||||
/*
|
||||
NOTE: For `tail -f a`, keep tracking additions to b after `mv a b`
|
||||
(gnu/tests/tail-2/descriptor-vs-rename.sh)
|
||||
NOTE: The File/BufReader doesn't need to be updated.
|
||||
However, we need to update our `files.map`.
|
||||
This can only be done for inotify, because this EventKind does not
|
||||
trigger for the PollWatcher.
|
||||
BUG: As a result, there's a bug if polling is used:
|
||||
$ tail -f file_a ---disable-inotify
|
||||
$ mv file_a file_b
|
||||
$ echo A >> file_b
|
||||
$ echo A >> file_a
|
||||
The last append to file_a is printed, however this shouldn't be because
|
||||
after the "mv" tail should only follow "file_b".
|
||||
TODO: [2022-05; jhscheer] add test for this bug
|
||||
*/
|
||||
|
||||
if self.follow_descriptor() => {
|
||||
let new_path = event.paths.last().unwrap();
|
||||
paths.push(new_path.clone());
|
||||
|
||||
let new_data = PathData::from_other_with_path(self.files.remove(event_path), new_path);
|
||||
self.files.insert(
|
||||
new_path,
|
||||
new_data,
|
||||
self.files.get_last().unwrap() == event_path
|
||||
);
|
||||
|
||||
// Unwatch old path and watch new path
|
||||
let _ = self.watcher_rx.as_mut().unwrap().unwatch(event_path);
|
||||
self.watcher_rx.as_mut().unwrap().watch_with_parent(new_path)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
pub fn follow(mut observer: Observer, settings: &Settings) -> UResult<()> {
|
||||
if observer.files.no_files_remaining(settings) && !observer.files.only_stdin_remaining() {
|
||||
return Err(USimpleError::new(1, translate!("tail-no-files-remaining")));
|
||||
}
|
||||
|
||||
let process = platform::ProcessChecker::new(observer.pid);
|
||||
|
||||
let mut timeout_counter = 0;
|
||||
|
||||
// main follow loop
|
||||
loop {
|
||||
let mut _read_some = false;
|
||||
|
||||
// If `--pid=p`, tail checks whether process p
|
||||
// is alive at least every `--sleep-interval=N` seconds
|
||||
if settings.follow.is_some() && observer.pid != 0 && process.is_dead() {
|
||||
// p is dead, tail will also terminate
|
||||
break;
|
||||
}
|
||||
|
||||
// For `-F` we need to poll if an orphan path becomes available during runtime.
|
||||
// If a path becomes an orphan during runtime, it will be added to orphans.
|
||||
// To be able to differentiate between the cases of test_retry8 and test_retry9,
|
||||
// here paths will not be removed from orphans if the path becomes available.
|
||||
if observer.follow_name_retry() {
|
||||
for new_path in &observer.orphans {
|
||||
// Use metadata() directly instead of exists() + metadata().unwrap()
|
||||
// to avoid a TOCTOU race where the file is removed between the two calls.
|
||||
if let Ok(md) = new_path.metadata() {
|
||||
let pd = observer.files.get(new_path);
|
||||
if md.is_tailable() && pd.reader.is_none() {
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-status-has-appeared-following-new-file", "file" => pd.display_name.quote())
|
||||
);
|
||||
observer.files.update_metadata(new_path, Some(md));
|
||||
observer.files.update_reader(new_path)?;
|
||||
_read_some = observer.files.tail_file(new_path, settings.verbose)?;
|
||||
observer
|
||||
.watcher_rx
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.watch_with_parent(new_path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With -f, sleep for approximately N seconds (default 1.0) between iterations;
|
||||
// We wake up if Notify sends an Event or if we wait more than `sleep_sec`.
|
||||
let rx_result = observer
|
||||
.watcher_rx
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.receiver
|
||||
.recv_timeout(settings.sleep_sec);
|
||||
|
||||
if rx_result.is_ok() {
|
||||
timeout_counter = 0;
|
||||
}
|
||||
|
||||
let mut paths = vec![]; // Paths worth checking for new content to print
|
||||
|
||||
// Helper closure to process a single event
|
||||
let process_event = |observer: &mut Observer,
|
||||
event: notify::Event,
|
||||
settings: &Settings,
|
||||
paths: &mut Vec<PathBuf>|
|
||||
-> UResult<()> {
|
||||
if event
|
||||
.paths
|
||||
.first()
|
||||
.is_some_and(|path| observer.files.contains_key(path))
|
||||
{
|
||||
// Handle Event if it is about a path that we are monitoring
|
||||
let new_paths = observer.handle_event(&event, settings)?;
|
||||
for p in new_paths {
|
||||
if !paths.contains(&p) {
|
||||
paths.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
match rx_result {
|
||||
Ok(Ok(event)) => {
|
||||
process_event(&mut observer, event, settings, &mut paths)?;
|
||||
|
||||
// Drain any additional pending events to batch them together.
|
||||
// This prevents redundant headers when multiple inotify events
|
||||
// are queued (e.g., after resuming from SIGSTOP).
|
||||
// Multiple iterations with spin_loop hints give the notify
|
||||
// background thread chances to deliver pending events.
|
||||
for _ in 0..100 {
|
||||
while let Ok(Ok(event)) =
|
||||
observer.watcher_rx.as_mut().unwrap().receiver.try_recv()
|
||||
{
|
||||
process_event(&mut observer, event, settings, &mut paths)?;
|
||||
}
|
||||
// Use both yield and spin hint for broader CPU support
|
||||
std::thread::yield_now();
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
Ok(Err(notify::Error {
|
||||
kind: notify::ErrorKind::Io(ref e),
|
||||
paths,
|
||||
})) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
if let Some(event_path) = paths.first().filter(|p| observer.files.contains_key(p)) {
|
||||
let _ = observer
|
||||
.watcher_rx
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.watcher
|
||||
.unwatch(event_path);
|
||||
}
|
||||
}
|
||||
Ok(Err(notify::Error {
|
||||
kind: notify::ErrorKind::MaxFilesWatch,
|
||||
..
|
||||
})) => {
|
||||
return Err(USimpleError::new(
|
||||
1,
|
||||
translate!("tail-error-backend-resources-exhausted", "backend" => text::BACKEND),
|
||||
));
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
return Err(USimpleError::new(
|
||||
1,
|
||||
translate!("tail-error-notify-error", "error" => e),
|
||||
));
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
timeout_counter += 1;
|
||||
// Check if stdout pipe is still open
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(false) = ensure_stdout_not_broken() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(USimpleError::new(
|
||||
1,
|
||||
translate!("tail-error-recv-timeout-error", "error" => e),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if observer.use_polling && settings.follow.is_some() {
|
||||
// Consider all files to potentially have new content.
|
||||
// This is a workaround because `Notify::PollWatcher`
|
||||
// does not recognize the "renaming" of files.
|
||||
paths = observer.files.keys().cloned().collect::<Vec<_>>();
|
||||
}
|
||||
|
||||
// main print loop
|
||||
for path in &paths {
|
||||
_read_some = observer.files.tail_file(path, settings.verbose)?;
|
||||
}
|
||||
|
||||
if timeout_counter == settings.max_unchanged_stats {
|
||||
/*
|
||||
TODO: [2021-10; jhscheer] implement timeout_counter for each file.
|
||||
`--max-unchanged-stats=n` counts per file, not once for the whole run:
|
||||
a name that goes n polls (5 by default) without changing should be
|
||||
re-opened and its device and inode compared against the handle still
|
||||
held for it, which is how the swap behind a rotated log is noticed.
|
||||
Only the polling path needs this, and only when following by name.
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
uucore::bin!(uu_tail);
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
use std::ffi::OsString;
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
|
||||
pub struct ObsoleteArgs {
|
||||
pub num: u64,
|
||||
pub plus: bool,
|
||||
pub lines: bool,
|
||||
pub follow: bool,
|
||||
}
|
||||
|
||||
impl Default for ObsoleteArgs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
num: 10,
|
||||
plus: false,
|
||||
lines: true,
|
||||
follow: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug)]
|
||||
pub enum ParseError {
|
||||
Context,
|
||||
InvalidEncoding,
|
||||
}
|
||||
/// Parses obsolete syntax
|
||||
/// tail -\[NUM\]\[bcl\]\[f\] and tail +\[NUM\]\[bcl\]\[f\]
|
||||
pub fn parse_obsolete(src: &OsString) -> Option<Result<ObsoleteArgs, ParseError>> {
|
||||
let Some(mut rest) = src.to_str() else {
|
||||
return Some(Err(ParseError::InvalidEncoding));
|
||||
};
|
||||
let sign = if let Some(r) = rest.strip_prefix('-') {
|
||||
rest = r;
|
||||
'-'
|
||||
} else {
|
||||
let r = rest.strip_prefix('+')?;
|
||||
rest = r;
|
||||
'+'
|
||||
};
|
||||
|
||||
let end_num = rest
|
||||
.find(|c: char| !c.is_ascii_digit())
|
||||
.unwrap_or(rest.len());
|
||||
let has_num = !rest[..end_num].is_empty();
|
||||
let num: u64 = if has_num {
|
||||
rest[..end_num].parse().unwrap_or(u64::MAX)
|
||||
} else {
|
||||
10
|
||||
};
|
||||
rest = &rest[end_num..];
|
||||
|
||||
let mode = if let Some(r) = rest.strip_prefix('l') {
|
||||
rest = r;
|
||||
'l'
|
||||
} else if let Some(r) = rest.strip_prefix('c') {
|
||||
rest = r;
|
||||
'c'
|
||||
} else if let Some(r) = rest.strip_prefix('b') {
|
||||
rest = r;
|
||||
'b'
|
||||
} else {
|
||||
'l'
|
||||
};
|
||||
|
||||
let follow = rest.contains('f');
|
||||
if !rest.chars().all(|f| f == 'f') {
|
||||
// GNU allows an arbitrary amount of following fs, but nothing else
|
||||
if sign == '-' && has_num {
|
||||
return Some(Err(ParseError::Context));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
let multiplier = if mode == 'b' { 512 } else { 1 };
|
||||
let num = num.saturating_mul(multiplier);
|
||||
|
||||
Some(Ok(ObsoleteArgs {
|
||||
num,
|
||||
plus: sign == '+',
|
||||
lines: mode == 'l',
|
||||
follow,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_parse_numbers_obsolete() {
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("+2c")),
|
||||
Some(Ok(ObsoleteArgs {
|
||||
num: 2,
|
||||
plus: true,
|
||||
lines: false,
|
||||
follow: false,
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("-5")),
|
||||
Some(Ok(ObsoleteArgs {
|
||||
num: 5,
|
||||
plus: false,
|
||||
lines: true,
|
||||
follow: false,
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("+100f")),
|
||||
Some(Ok(ObsoleteArgs {
|
||||
num: 100,
|
||||
plus: true,
|
||||
lines: true,
|
||||
follow: true,
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("-2b")),
|
||||
Some(Ok(ObsoleteArgs {
|
||||
num: 1024,
|
||||
plus: false,
|
||||
lines: false,
|
||||
follow: false,
|
||||
}))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn test_parse_errors_obsolete() {
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("-5n")),
|
||||
Some(Err(ParseError::Context))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("-5c5")),
|
||||
Some(Err(ParseError::Context))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("-1vzc")),
|
||||
Some(Err(ParseError::Context))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("-5m")),
|
||||
Some(Err(ParseError::Context))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("-1k")),
|
||||
Some(Err(ParseError::Context))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("-1mmk")),
|
||||
Some(Err(ParseError::Context))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("-105kzm")),
|
||||
Some(Err(ParseError::Context))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("-1vz")),
|
||||
Some(Err(ParseError::Context))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_obsolete(&OsString::from("-1vzqvq")), // spell-checker:disable-line
|
||||
Some(Err(ParseError::Context))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn test_parse_obsolete_no_match() {
|
||||
assert_eq!(parse_obsolete(&OsString::from("-k")), None);
|
||||
assert_eq!(parse_obsolete(&OsString::from("asd")), None);
|
||||
assert_eq!(parse_obsolete(&OsString::from("-cc")), None);
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
// spell-checker:ignore tailable stdlib (stdlib)
|
||||
|
||||
use crate::text;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::{File, Metadata};
|
||||
use std::io::{Seek, SeekFrom};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{FileTypeExt, MetadataExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
use uucore::error::UResult;
|
||||
use uucore::translate;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InputKind {
|
||||
File(PathBuf),
|
||||
Stdin,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl From<&OsStr> for InputKind {
|
||||
fn from(value: &OsStr) -> Self {
|
||||
if value == OsStr::new("-") {
|
||||
Self::Stdin
|
||||
} else {
|
||||
Self::File(PathBuf::from(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
impl From<&OsStr> for InputKind {
|
||||
fn from(value: &OsStr) -> Self {
|
||||
if value == OsStr::new(text::DASH) {
|
||||
Self::Stdin
|
||||
} else {
|
||||
Self::File(PathBuf::from(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Input {
|
||||
kind: InputKind,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
pub fn from<T: AsRef<OsStr>>(string: T) -> Self {
|
||||
let string = string.as_ref();
|
||||
|
||||
let kind = string.into();
|
||||
let display_name = match kind {
|
||||
InputKind::File(_) => string.to_string_lossy().to_string(),
|
||||
InputKind::Stdin => translate!("tail-stdin-header"),
|
||||
};
|
||||
|
||||
Self { kind, display_name }
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> &InputKind {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
pub fn is_stdin(&self) -> bool {
|
||||
match self.kind {
|
||||
InputKind::File(_) => false,
|
||||
InputKind::Stdin => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(&self) -> Option<PathBuf> {
|
||||
match &self.kind {
|
||||
InputKind::File(path) if path != &PathBuf::from(text::DEV_STDIN) => {
|
||||
path.canonicalize().ok()
|
||||
}
|
||||
InputKind::File(_) | InputKind::Stdin => {
|
||||
// on macOS, /dev/fd isn't backed by /proc and canonicalize()
|
||||
// on dev/fd/0 (or /dev/stdin) will fail (NotFound),
|
||||
// so we treat stdin as a pipe here
|
||||
// https://github.com/rust-lang/rust/issues/95239
|
||||
#[cfg(any(target_vendor = "apple", target_os = "wasi"))]
|
||||
{
|
||||
// WASI streams have no filesystem path. Read stdin directly.
|
||||
None
|
||||
}
|
||||
#[cfg(not(any(target_vendor = "apple", target_os = "wasi")))]
|
||||
{
|
||||
PathBuf::from(text::FD0).canonicalize().ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_tailable(&self) -> bool {
|
||||
match &self.kind {
|
||||
InputKind::File(path) => path_is_tailable(path),
|
||||
InputKind::Stdin => self.resolve().is_some_and(|path| path_is_tailable(&path)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Input {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: InputKind::Stdin,
|
||||
display_name: translate!("tail-stdin-header"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct HeaderPrinter {
|
||||
verbose: bool,
|
||||
first_header: bool,
|
||||
}
|
||||
|
||||
impl HeaderPrinter {
|
||||
pub fn new(verbose: bool, first_header: bool) -> Self {
|
||||
Self {
|
||||
verbose,
|
||||
first_header,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_input(&mut self, input: &Input) {
|
||||
self.print(input.display_name.as_str());
|
||||
}
|
||||
|
||||
pub fn print(&mut self, string: &str) {
|
||||
if self.verbose {
|
||||
println!(
|
||||
"{}==> {string} <==",
|
||||
if self.first_header { "" } else { "\n" },
|
||||
);
|
||||
self.first_header = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub trait FileExtTail {
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn is_seekable(&mut self, current_offset: u64) -> bool;
|
||||
}
|
||||
|
||||
impl FileExtTail for File {
|
||||
/// Test if File is seekable.
|
||||
/// Set the current position offset to `current_offset`.
|
||||
fn is_seekable(&mut self, current_offset: u64) -> bool {
|
||||
self.stream_position().is_ok()
|
||||
&& self.seek(SeekFrom::End(0)).is_ok()
|
||||
&& self.seek(SeekFrom::Start(current_offset)).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait MetadataExtTail {
|
||||
fn is_tailable(&self) -> bool;
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
fn got_truncated(&self, other: &Metadata) -> UResult<bool>;
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
fn file_id_eq(&self, other: &Metadata) -> bool;
|
||||
}
|
||||
|
||||
impl MetadataExtTail for Metadata {
|
||||
fn is_tailable(&self) -> bool {
|
||||
let ft = self.file_type();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
ft.is_file() || ft.is_char_device() || ft.is_fifo()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
ft.is_file()
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true if the file was modified and is now shorter
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
fn got_truncated(&self, other: &Metadata) -> UResult<bool> {
|
||||
Ok(other.len() < self.len() && other.modified()? != self.modified()?)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
fn file_id_eq(&self, #[cfg(unix)] other: &Metadata, #[cfg(not(unix))] _: &Metadata) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
self.ino().eq(&other.ino())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// TODO: `file_index` requires unstable library feature `windows_by_handle`
|
||||
// use std::os::windows::prelude::*;
|
||||
// if let Some(self_id) = self.file_index() {
|
||||
// if let Some(other_id) = other.file_index() {
|
||||
// // TODO: not sure this is the equivalent of comparing inode numbers
|
||||
//
|
||||
// return self_id.eq(&other_id);
|
||||
// }
|
||||
// }
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
pub trait PathExtTail {
|
||||
fn is_stdin(&self) -> bool;
|
||||
fn has_active_parent(&self) -> bool;
|
||||
fn is_tailable(&self) -> bool;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
impl PathExtTail for Path {
|
||||
fn is_stdin(&self) -> bool {
|
||||
self.eq(Self::new(text::DASH))
|
||||
|| self.eq(Self::new(text::DEV_STDIN))
|
||||
|| self.eq(Self::new(&translate!("tail-stdin-header")))
|
||||
}
|
||||
|
||||
/// Return true if `path` has an existing parent directory
|
||||
fn has_active_parent(&self) -> bool {
|
||||
self.parent().is_some_and(Self::is_dir)
|
||||
}
|
||||
|
||||
/// Return true if `path` is a file type that can be tailed
|
||||
fn is_tailable(&self) -> bool {
|
||||
path_is_tailable(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path_is_tailable(path: &Path) -> bool {
|
||||
path.is_file() || path.exists() && path.metadata().is_ok_and(|meta| meta.is_tailable())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(all(unix, not(target_os = "fuchsia")))]
|
||||
pub fn stdin_is_bad_fd() -> bool {
|
||||
uucore::signals::stdin_was_closed()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(not(all(unix, not(target_os = "fuchsia"))))]
|
||||
pub fn stdin_is_bad_fd() -> bool {
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use self::unix::{
|
||||
Pid,
|
||||
ProcessChecker,
|
||||
//stdin_is_bad_fd, stdin_is_pipe_or_fifo, supports_pid_checks, Pid, ProcessChecker,
|
||||
supports_pid_checks,
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
pub use self::windows::{Pid, ProcessChecker, supports_pid_checks};
|
||||
|
||||
// WASI has no process management; provide stubs so tail compiles.
|
||||
#[cfg(target_os = "wasi")]
|
||||
pub type Pid = u64;
|
||||
|
||||
#[cfg(target_os = "wasi")]
|
||||
pub fn supports_pid_checks(_pid: Pid) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
@@ -0,0 +1,34 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
// spell-checker:ignore (ToDO) stdlib, ISCHR, GETFD
|
||||
// spell-checker:ignore (options) EPERM, ENOSYS, NOSYS
|
||||
|
||||
use rustix::process::{Pid as RustixPid, test_kill_process};
|
||||
|
||||
pub type Pid = i32;
|
||||
|
||||
pub struct ProcessChecker {
|
||||
pid: Pid,
|
||||
}
|
||||
|
||||
impl ProcessChecker {
|
||||
pub fn new(process_id: Pid) -> Self {
|
||||
Self { pid: process_id }
|
||||
}
|
||||
|
||||
pub fn is_dead(&self) -> bool {
|
||||
RustixPid::from_raw(self.pid)
|
||||
.is_none_or(|pid| test_kill_process(pid).is_err_and(|e| e != rustix::io::Errno::PERM))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessChecker {
|
||||
fn drop(&mut self) {}
|
||||
}
|
||||
|
||||
pub fn supports_pid_checks(pid: Pid) -> bool {
|
||||
RustixPid::from_raw(pid).is_some_and(|p| test_kill_process(p) != Err(rustix::io::Errno::NOSYS))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
use std::cell::Cell;
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
};
|
||||
use windows_sys::core::BOOL;
|
||||
|
||||
pub type Pid = u32;
|
||||
|
||||
pub struct ProcessChecker {
|
||||
dead: Cell<bool>,
|
||||
handle: HANDLE,
|
||||
}
|
||||
|
||||
impl ProcessChecker {
|
||||
pub fn new(process_id: Pid) -> Self {
|
||||
#[allow(non_snake_case)]
|
||||
let FALSE: BOOL = 0;
|
||||
let h = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, FALSE, process_id) };
|
||||
Self {
|
||||
dead: Cell::new(h.is_null()),
|
||||
handle: h,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_dead(&self) -> bool {
|
||||
if !self.dead.get() {
|
||||
self.dead.set(unsafe {
|
||||
let status = WaitForSingleObject(self.handle, 0);
|
||||
status == WAIT_OBJECT_0 || status == WAIT_FAILED
|
||||
});
|
||||
}
|
||||
|
||||
self.dead.get()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessChecker {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
CloseHandle(self.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supports_pid_checks(_pid: Pid) -> bool {
|
||||
true
|
||||
}
|
||||
+626
@@ -0,0 +1,626 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
// spell-checker:ignore (ToDO) seek'd tail'ing ringbuffer ringbuf unwatch
|
||||
// spell-checker:ignore (ToDO) Uncategorized filehandle Signum memrchr
|
||||
// spell-checker:ignore (libs) kqueue
|
||||
// spell-checker:ignore (acronyms)
|
||||
// spell-checker:ignore (env/flags)
|
||||
// spell-checker:ignore (jargon) tailable untailable stdlib
|
||||
// spell-checker:ignore (names)
|
||||
// spell-checker:ignore (shell/tools)
|
||||
// spell-checker:ignore (misc)
|
||||
|
||||
pub mod args;
|
||||
pub mod chunks;
|
||||
mod follow;
|
||||
mod parse;
|
||||
mod paths;
|
||||
mod platform;
|
||||
pub mod text;
|
||||
|
||||
pub use args::uu_app;
|
||||
use args::{FilterMode, Settings, Signum, parse_args};
|
||||
use chunks::ReverseChunks;
|
||||
use follow::Observer;
|
||||
use memchr::{memchr_iter, memrchr_iter};
|
||||
use paths::{FileExtTail, HeaderPrinter, Input, InputKind};
|
||||
use std::cmp::Ordering;
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufReader, BufWriter, ErrorKind, Read, Seek, SeekFrom, Write, stdin, stdout};
|
||||
use std::path::{Path, PathBuf};
|
||||
use uucore::display::Quotable;
|
||||
use uucore::error::{FromIo, UResult, USimpleError, set_exit_code};
|
||||
use uucore::translate;
|
||||
|
||||
use uucore::{show, show_error};
|
||||
|
||||
#[uucore::main]
|
||||
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
|
||||
let settings = parse_args(args)?;
|
||||
|
||||
settings.check_warnings()?;
|
||||
|
||||
match settings.verify() {
|
||||
args::VerificationResult::CannotFollowStdinByName => {
|
||||
return Err(USimpleError::new(
|
||||
1,
|
||||
translate!("tail-error-cannot-follow-stdin-by-name", "stdin" => text::DASH.quote()),
|
||||
));
|
||||
}
|
||||
// Exit early if we do not output anything. Note, that this may break a pipe
|
||||
// when tail is on the receiving side.
|
||||
args::VerificationResult::NoOutput => return Ok(()),
|
||||
args::VerificationResult::Ok => {}
|
||||
}
|
||||
|
||||
uu_tail(&settings)
|
||||
}
|
||||
|
||||
fn uu_tail(settings: &Settings) -> UResult<()> {
|
||||
let mut printer = HeaderPrinter::new(settings.verbose, true);
|
||||
let mut observer = Observer::from(settings);
|
||||
|
||||
observer.start(settings)?;
|
||||
|
||||
// Print debug info about the follow implementation being used
|
||||
if settings.debug && settings.follow.is_some() {
|
||||
if observer.use_polling {
|
||||
show_error!("{}", translate!("tail-debug-using-polling-mode"));
|
||||
} else {
|
||||
show_error!("{}", translate!("tail-debug-using-notification-mode"));
|
||||
}
|
||||
}
|
||||
|
||||
// Do an initial tail print of each path's content.
|
||||
// Add `path` and `reader` to `files` map if `--follow` is selected.
|
||||
for input in &settings.inputs {
|
||||
match input.kind() {
|
||||
InputKind::Stdin => {
|
||||
tail_stdin(settings, &mut printer, input, &mut observer)?;
|
||||
}
|
||||
InputKind::File(path) if cfg!(unix) && path == &PathBuf::from(text::DEV_STDIN) => {
|
||||
tail_stdin(settings, &mut printer, input, &mut observer)?;
|
||||
}
|
||||
InputKind::File(path) => {
|
||||
if let Err(err) = tail_file(settings, &mut printer, input, path, &mut observer, 0) {
|
||||
show!(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if settings.follow.is_some() {
|
||||
/*
|
||||
POSIX specification regarding tail -f
|
||||
If the input file is a regular file or if the file operand specifies a FIFO, do not
|
||||
terminate after the last line of the input file has been copied, but read and copy
|
||||
further bytes from the input file when they become available. If no file operand is
|
||||
specified and standard input is a pipe or FIFO, the -f option shall be ignored. If
|
||||
the input file is not a FIFO, pipe, or regular file, it is unspecified whether or
|
||||
not the -f option shall be ignored.
|
||||
*/
|
||||
if !settings.has_only_stdin() || settings.pid.is_some_and(|pid| pid != 0) {
|
||||
follow::follow(observer, settings)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tail_file(
|
||||
settings: &Settings,
|
||||
header_printer: &mut HeaderPrinter,
|
||||
input: &Input,
|
||||
path: &Path,
|
||||
observer: &mut Observer,
|
||||
offset: u64,
|
||||
) -> UResult<()> {
|
||||
// some platform has different read error message
|
||||
#[cfg(not(unix))]
|
||||
if path
|
||||
.metadata()
|
||||
.is_err_and(|e| e.kind() == ErrorKind::NotFound)
|
||||
{
|
||||
set_exit_code(1);
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!(
|
||||
"tail-error-cannot-open-no-such-file",
|
||||
"file" => input.display_name,
|
||||
"error" => translate!("tail-no-such-file-or-directory")
|
||||
)
|
||||
);
|
||||
observer.add_bad_path(path, input.display_name.as_str(), false)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
set_exit_code(1);
|
||||
|
||||
header_printer.print_input(input);
|
||||
let err_msg = translate!("tail-is-a-directory");
|
||||
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-error-reading-file", "file" => input.display_name, "error" => err_msg)
|
||||
);
|
||||
if settings.follow.is_some() {
|
||||
let msg = if settings.retry {
|
||||
""
|
||||
} else {
|
||||
&translate!("tail-giving-up-on-this-name")
|
||||
};
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-error-cannot-follow-file-type", "file" => input.display_name, "msg" => msg)
|
||||
);
|
||||
}
|
||||
if !observer.follow_name_retry() {
|
||||
return Ok(());
|
||||
}
|
||||
observer.add_bad_path(path, input.display_name.as_str(), false)?;
|
||||
} else {
|
||||
#[cfg(unix)]
|
||||
let open_result = open_file(path, settings.pid.is_some_and(|pid| pid != 0));
|
||||
#[cfg(not(unix))]
|
||||
let open_result = File::open(path);
|
||||
|
||||
match open_result {
|
||||
Ok(mut file) => {
|
||||
let st = file.metadata()?;
|
||||
let blksize_limit = uucore::fs::sane_blksize::sane_blksize_from_metadata(&st);
|
||||
header_printer.print_input(input);
|
||||
let mut reader;
|
||||
if !settings.presume_input_pipe
|
||||
&& file.is_seekable(if input.is_stdin() { offset } else { 0 })
|
||||
&& (!st.is_file() || st.len() > blksize_limit)
|
||||
{
|
||||
bounded_tail(&mut file, settings)?;
|
||||
reader = BufReader::new(file);
|
||||
} else {
|
||||
reader = BufReader::new(file);
|
||||
unbounded_tail(&mut reader, settings)?;
|
||||
}
|
||||
if input.is_tailable() {
|
||||
observer.add_path(
|
||||
path,
|
||||
input.display_name.as_str(),
|
||||
Some(Box::new(reader)),
|
||||
true,
|
||||
)?;
|
||||
} else {
|
||||
observer.add_bad_path(path, input.display_name.as_str(), false)?;
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => {
|
||||
observer.add_bad_path(path, input.display_name.as_str(), false)?;
|
||||
show!(e.map_err_context(|| {
|
||||
translate!("tail-error-cannot-open-for-reading", "file" => input.display_name)
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
observer.add_bad_path(path, input.display_name.as_str(), false)?;
|
||||
return Err(e.map_err_context(|| {
|
||||
translate!("tail-error-cannot-open-for-reading", "file" => input.display_name)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens a file, using non-blocking mode for FIFOs when `use_nonblock_for_fifo` is true.
|
||||
///
|
||||
/// When opening a FIFO with `--pid`, we need to use O_NONBLOCK so that:
|
||||
/// 1. The open() call doesn't block waiting for a writer
|
||||
/// 2. We can periodically check if the monitored process is still alive
|
||||
///
|
||||
/// After opening, we clear O_NONBLOCK so subsequent reads block normally.
|
||||
/// Without `--pid`, FIFOs block on open() until a writer connects (GNU behavior).
|
||||
#[cfg(unix)]
|
||||
fn open_file(path: &Path, use_nonblock_for_fifo: bool) -> io::Result<File> {
|
||||
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::unix::fs::{FileTypeExt, OpenOptionsExt};
|
||||
|
||||
let is_fifo = path.metadata().is_ok_and(|m| m.file_type().is_fifo());
|
||||
|
||||
if is_fifo && use_nonblock_for_fifo {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(path)?;
|
||||
|
||||
// Clear O_NONBLOCK so reads block normally
|
||||
let flags = fcntl_getfl(&file)?;
|
||||
let new_flags = flags & !OFlags::NONBLOCK;
|
||||
fcntl_setfl(&file, new_flags)?;
|
||||
|
||||
Ok(file)
|
||||
} else {
|
||||
File::open(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_stdin(
|
||||
settings: &Settings,
|
||||
header_printer: &mut HeaderPrinter,
|
||||
input: &Input,
|
||||
observer: &mut Observer,
|
||||
) -> UResult<()> {
|
||||
// on macOS, resolve() will always return None for stdin,
|
||||
// we need to detect if stdin is a directory ourselves.
|
||||
// fstat-ing certain descriptors under /dev/fd fails with
|
||||
// bad file descriptor or might not catch directory cases
|
||||
// e.g. see the differences between running ls -l /dev/stdin /dev/fd/0
|
||||
// on macOS and Linux.
|
||||
#[cfg(target_vendor = "apple")]
|
||||
if uucore::fs::is_stdin_directory(&stdin()) {
|
||||
set_exit_code(1);
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-error-cannot-open-no-such-file", "file" => input.display_name, "error" => translate!("tail-no-such-file-or-directory"))
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if stdin was closed before Rust reopened it as /dev/null
|
||||
if paths::stdin_is_bad_fd() {
|
||||
set_exit_code(1);
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-error-cannot-fstat", "file" => translate!("tail-stdin-header").quote(), "error" => translate!("tail-bad-fd"))
|
||||
);
|
||||
show_error!("{}", translate!("tail-no-files-remaining"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(path) = input.resolve() {
|
||||
#[cfg(not(unix))]
|
||||
let stdin_offset = 0;
|
||||
// Save the current seek position/offset of a stdin redirected file.
|
||||
// This is needed to pass "gnu/tests/tail-2/start-middle.sh"
|
||||
#[cfg(unix)]
|
||||
let stdin_offset = rustix::fs::tell(stdin()).unwrap_or(0); // fifo
|
||||
tail_file(
|
||||
settings,
|
||||
header_printer,
|
||||
input,
|
||||
&path,
|
||||
observer,
|
||||
stdin_offset,
|
||||
)?;
|
||||
} else {
|
||||
// pipe
|
||||
header_printer.print_input(input);
|
||||
if paths::stdin_is_bad_fd() {
|
||||
set_exit_code(1);
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-error-cannot-fstat", "file" => translate!("tail-stdin-header"), "error" => translate!("tail-bad-fd"))
|
||||
);
|
||||
if settings.follow.is_some() {
|
||||
show_error!(
|
||||
"{}",
|
||||
translate!("tail-error-reading-file", "file" => translate!("tail-stdin-header"), "error" => translate!("tail-bad-fd"))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let mut reader = BufReader::new(stdin());
|
||||
unbounded_tail(&mut reader, settings)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find the index after the given number of instances of a given byte.
|
||||
///
|
||||
/// This function reads through a given reader until `num_delimiters`
|
||||
/// instances of `delimiter` have been seen, returning the index of
|
||||
/// the byte immediately following that delimiter. If there are fewer
|
||||
/// than `num_delimiters` instances of `delimiter`, this returns the
|
||||
/// total number of bytes read from the `reader` until EOF.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function returns an error if there is an error during reading
|
||||
/// from `reader`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// Basic usage:
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use std::io::Cursor;
|
||||
///
|
||||
/// let mut reader = Cursor::new("a\nb\nc\nd\ne\n");
|
||||
/// let i = forwards_thru_file(&mut reader, 2, b'\n').unwrap();
|
||||
/// assert_eq!(i, 4);
|
||||
/// ```
|
||||
///
|
||||
/// If `num_delimiters` is zero, then this function always returns
|
||||
/// zero:
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use std::io::Cursor;
|
||||
///
|
||||
/// let mut reader = Cursor::new("a\n");
|
||||
/// let i = forwards_thru_file(&mut reader, 0, b'\n').unwrap();
|
||||
/// assert_eq!(i, 0);
|
||||
/// ```
|
||||
///
|
||||
/// If there are fewer than `num_delimiters` instances of `delimiter`
|
||||
/// in the reader, then this function returns the total number of
|
||||
/// bytes read:
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use std::io::Cursor;
|
||||
///
|
||||
/// let mut reader = Cursor::new("a\n");
|
||||
/// let i = forwards_thru_file(&mut reader, 2, b'\n').unwrap();
|
||||
/// assert_eq!(i, 2);
|
||||
/// ```
|
||||
fn forwards_thru_file(
|
||||
reader: &mut impl Read,
|
||||
num_delimiters: u64,
|
||||
delimiter: u8,
|
||||
) -> io::Result<usize> {
|
||||
// If num_delimiters == 0, always return 0.
|
||||
if num_delimiters == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
// Use a 32K buffer.
|
||||
let mut buf = [0; 32 * 1024];
|
||||
let mut total = 0;
|
||||
let mut count = 0;
|
||||
// Iterate through the input, using `count` to record the number of times `delimiter`
|
||||
// is seen. Once we find `num_delimiters` instances, return the offset of the byte
|
||||
// immediately following that delimiter.
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
// Ok(0) => EoF before we found `num_delimiters` instance of `delimiter`.
|
||||
// Return the total number of bytes read in that case.
|
||||
Ok(0) => return Ok(total),
|
||||
Ok(n) => {
|
||||
// Use memchr_iter since it greatly improves search performance.
|
||||
for offset in memchr_iter(delimiter, &buf[..n]) {
|
||||
count += 1;
|
||||
if count == num_delimiters {
|
||||
// Return offset of the byte after the `delimiter` instance.
|
||||
return Ok(total + offset + 1);
|
||||
}
|
||||
}
|
||||
total += n;
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::Interrupted => (),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate over bytes in the file, in reverse, until we find the
|
||||
/// `num_delimiters` instance of `delimiter`. The `file` is left seek'd to the
|
||||
/// position just after that delimiter.
|
||||
fn backwards_thru_file(file: &mut File, num_delimiters: u64, delimiter: u8) {
|
||||
if num_delimiters == 0 {
|
||||
file.seek(SeekFrom::End(0)).unwrap();
|
||||
return;
|
||||
}
|
||||
// This variable counts the number of delimiters found in the file
|
||||
// so far (reading from the end of the file toward the beginning).
|
||||
let mut counter = 0;
|
||||
let mut first_slice = true;
|
||||
for slice in ReverseChunks::new(file) {
|
||||
// Iterate over each byte in the slice in reverse order.
|
||||
let mut iter = memrchr_iter(delimiter, &slice);
|
||||
|
||||
// Ignore a trailing newline in the last block, if there is one.
|
||||
if first_slice {
|
||||
if slice.last().is_some_and(|&c| c == delimiter) {
|
||||
iter.next();
|
||||
}
|
||||
first_slice = false;
|
||||
}
|
||||
|
||||
// For each byte, increment the count of the number of
|
||||
// delimiters found. If we have found more than the specified
|
||||
// number of delimiters, terminate the search and seek to the
|
||||
// appropriate location in the file.
|
||||
for i in iter {
|
||||
counter += 1;
|
||||
if counter >= num_delimiters {
|
||||
// We should never over-count - assert that.
|
||||
assert_eq!(counter, num_delimiters);
|
||||
// After each iteration of the outer loop, the
|
||||
// cursor in the file is at the *beginning* of the
|
||||
// block, so seeking forward by `i + 1` bytes puts
|
||||
// us right after the found delimiter.
|
||||
file.seek(SeekFrom::Current((i + 1) as i64)).unwrap();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When tail'ing a file, we do not need to read the whole file from start to
|
||||
/// finish just to find the last n lines or bytes. Instead, we can seek to the
|
||||
/// end of the file, and then read the file "backwards" in blocks of size
|
||||
/// `BLOCK_SIZE` until we find the location of the first line/byte. This ends up
|
||||
/// being a nice performance win for very large files.
|
||||
fn bounded_tail(file: &mut File, settings: &Settings) -> UResult<()> {
|
||||
debug_assert!(!settings.presume_input_pipe);
|
||||
let mut limit = None;
|
||||
|
||||
// Find the position in the file to start printing from.
|
||||
match &settings.mode {
|
||||
FilterMode::Lines(Signum::Negative(count), delimiter) => {
|
||||
backwards_thru_file(file, *count, *delimiter);
|
||||
}
|
||||
FilterMode::Lines(Signum::Positive(count), delimiter) if count > &1 => {
|
||||
let i = forwards_thru_file(file, *count - 1, *delimiter).unwrap();
|
||||
file.seek(SeekFrom::Start(i as u64)).unwrap();
|
||||
}
|
||||
FilterMode::Lines(Signum::MinusZero, _) | FilterMode::Bytes(Signum::MinusZero) => {
|
||||
file.seek(SeekFrom::End(0)).unwrap();
|
||||
}
|
||||
FilterMode::Bytes(Signum::Negative(count)) => {
|
||||
if file.seek(SeekFrom::End(-(*count as i64))).is_err() {
|
||||
file.seek(SeekFrom::Start(0)).unwrap();
|
||||
}
|
||||
limit = Some(*count);
|
||||
}
|
||||
FilterMode::Bytes(Signum::Positive(count)) if count > &1 => {
|
||||
// GNU `tail` seems to index bytes and lines starting at 1, not
|
||||
// at 0. It seems to treat `+0` and `+1` as the same thing.
|
||||
// A start offset past the largest seekable position makes the
|
||||
// underlying `lseek` fail with `EINVAL`; treat that like a start
|
||||
// beyond the end of the file and produce no output.
|
||||
file.seek(SeekFrom::Start(*count - 1))
|
||||
.or_else(|_| file.seek(SeekFrom::End(0)))
|
||||
.unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
print_target_section(file, limit)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unbounded_tail<T: Read>(reader: &mut BufReader<T>, settings: &Settings) -> UResult<()> {
|
||||
let mut writer = BufWriter::new(stdout().lock());
|
||||
match &settings.mode {
|
||||
FilterMode::Lines(Signum::Negative(count), sep) => {
|
||||
let mut chunks = chunks::LinesChunkBuffer::new(*sep, *count);
|
||||
chunks.fill(reader)?;
|
||||
chunks.write(&mut writer)?;
|
||||
}
|
||||
FilterMode::Lines(Signum::PlusZero | Signum::Positive(1), _)
|
||||
| FilterMode::Bytes(Signum::PlusZero | Signum::Positive(1)) => {
|
||||
io::copy(reader, &mut writer)?;
|
||||
}
|
||||
FilterMode::Lines(Signum::Positive(count), sep) => {
|
||||
let mut num_skip = *count - 1;
|
||||
let mut chunk = chunks::LinesChunk::new(*sep);
|
||||
while chunk.fill(reader)?.is_some() {
|
||||
let lines = chunk.get_lines() as u64;
|
||||
if lines < num_skip {
|
||||
num_skip -= lines;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if chunk.has_data() {
|
||||
chunk.write_lines(&mut writer, num_skip as usize)?;
|
||||
io::copy(reader, &mut writer)?;
|
||||
}
|
||||
}
|
||||
FilterMode::Bytes(Signum::Negative(count)) => {
|
||||
let mut chunks = chunks::BytesChunkBuffer::new(*count);
|
||||
chunks.fill(reader)?;
|
||||
chunks.write(&mut writer)?;
|
||||
}
|
||||
FilterMode::Lines(Signum::MinusZero, sep) => {
|
||||
let mut chunks = chunks::LinesChunkBuffer::new(*sep, 0);
|
||||
chunks.fill(reader)?;
|
||||
chunks.write(&mut writer)?;
|
||||
}
|
||||
FilterMode::Bytes(Signum::Positive(count)) => {
|
||||
let mut num_skip = *count - 1;
|
||||
let mut chunk = chunks::BytesChunk::new();
|
||||
loop {
|
||||
let Some(bytes) = chunk.fill(reader)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let bytes: u64 = bytes as u64;
|
||||
match bytes.cmp(&num_skip) {
|
||||
Ordering::Less => num_skip -= bytes,
|
||||
Ordering::Equal => {
|
||||
break;
|
||||
}
|
||||
Ordering::Greater => {
|
||||
writer.write_all(chunk.get_buffer_with(num_skip as usize))?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
io::copy(reader, &mut writer)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
writer.flush()?;
|
||||
|
||||
// SIGPIPE is not available on Windows.
|
||||
#[cfg(windows)]
|
||||
writer.flush().inspect_err(|err| {
|
||||
if err.kind() == ErrorKind::BrokenPipe {
|
||||
std::process::exit(13);
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Print the target section of the file
|
||||
// use zero-copy on Linux
|
||||
fn print_target_section<
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))] R: Read + rustix::fd::AsFd,
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))] R: Read,
|
||||
>(
|
||||
file: &mut R,
|
||||
limit: Option<u64>,
|
||||
) -> UResult<()> {
|
||||
let stdout = stdout();
|
||||
let mut stdout = stdout.lock();
|
||||
if let Some(limit) = limit {
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
uucore::pipes::send_n_bytes(file, &mut stdout, limit)?;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
{
|
||||
let mut reader = file.take(limit);
|
||||
io::copy(&mut reader, &mut stdout)?;
|
||||
}
|
||||
} else {
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
if uucore::pipes::splice_unbounded_auto(file, &mut stdout)?.is_err() {
|
||||
io::copy(file, &mut stdout)?;
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
io::copy(file, &mut stdout)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use crate::forwards_thru_file;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn test_forwards_thru_file_zero() {
|
||||
let mut reader = Cursor::new("a\n");
|
||||
let i = forwards_thru_file(&mut reader, 0, b'\n').unwrap();
|
||||
assert_eq!(i, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forwards_thru_file_basic() {
|
||||
// 01 23 45 67 89
|
||||
let mut reader = Cursor::new("a\nb\nc\nd\ne\n");
|
||||
let i = forwards_thru_file(&mut reader, 2, b'\n').unwrap();
|
||||
assert_eq!(i, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forwards_thru_file_past_end() {
|
||||
let mut reader = Cursor::new("x\n");
|
||||
let i = forwards_thru_file(&mut reader, 2, b'\n').unwrap();
|
||||
assert_eq!(i, 2);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// This file is part of the uutils coreutils package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
// spell-checker:ignore (ToDO) kqueue
|
||||
|
||||
// Non-localized constants (system paths and technical identifiers)
|
||||
pub const DASH: &str = "-";
|
||||
pub const DEV_STDIN: &str = "/dev/stdin";
|
||||
pub const FD0: &str = "/dev/fd/0";
|
||||
pub const DEV_TTY: &str = "/dev/tty";
|
||||
pub const DEV_PTMX: &str = "/dev/ptmx";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const BACKEND: &str = "inotify";
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
pub const BACKEND: &str = "kqueue";
|
||||
#[cfg(windows)]
|
||||
pub const BACKEND: &str = "ReadDirectoryChanges";
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub const BACKEND: &str = "polling";
|
||||
+148
-40
@@ -1,67 +1,175 @@
|
||||
//! Find world paths by name, type, path, depth, or modification time.
|
||||
use std::{io::{self, Write}, path::PathBuf};
|
||||
//! Find world paths with short-circuit AND/OR predicates and directory pruning.
|
||||
use sausage_spawn::Command;
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
enum Predicate { Name(globset::GlobMatcher), Path(globset::GlobMatcher), Type(String), Newer(std::time::SystemTime) }
|
||||
enum Predicate {
|
||||
Name(globset::GlobMatcher),
|
||||
Path(globset::GlobMatcher),
|
||||
Type(String),
|
||||
Newer(std::time::SystemTime),
|
||||
Empty,
|
||||
Prune,
|
||||
Print(bool),
|
||||
Exec(Vec<String>),
|
||||
}
|
||||
fn run() -> Result<i32, Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let mut i = 0;
|
||||
let mut roots = Vec::new();
|
||||
while i < args.len() && !args[i].starts_with('-') { roots.push(PathBuf::from(&args[i])); i += 1; }
|
||||
if roots.is_empty() { roots.push(".".into()); }
|
||||
let mut predicates = Vec::new();
|
||||
while i < args.len() && !args[i].starts_with('-') {
|
||||
roots.push(PathBuf::from(&args[i]));
|
||||
i += 1;
|
||||
}
|
||||
if roots.is_empty() {
|
||||
roots.push(".".into());
|
||||
}
|
||||
let mut groups = vec![Vec::new()];
|
||||
let mut maxdepth = usize::MAX;
|
||||
let mut null = false;
|
||||
let mut exec = None;
|
||||
let mut mindepth = 0;
|
||||
let mut explicit_action = false;
|
||||
while i < args.len() {
|
||||
let flag = &args[i]; i += 1;
|
||||
match flag.as_str() {
|
||||
"-print" => (), "-print0" => null = true,
|
||||
"-name" | "-path" | "-type" | "-maxdepth" | "-newer" => {
|
||||
let value = args.get(i).ok_or("missing predicate argument")?; i += 1;
|
||||
let flag = &args[i];
|
||||
i += 1;
|
||||
let predicate = match flag.as_str() {
|
||||
"-o" | "-or" => {
|
||||
groups.push(Vec::new());
|
||||
continue;
|
||||
}
|
||||
"-a" | "-and" => continue,
|
||||
"-prune" => Predicate::Prune,
|
||||
"-empty" => Predicate::Empty,
|
||||
"-print" | "-print0" => {
|
||||
explicit_action = true;
|
||||
Predicate::Print(flag == "-print0")
|
||||
}
|
||||
"-name" | "-path" | "-type" | "-maxdepth" | "-mindepth" | "-newer" => {
|
||||
let value = args.get(i).ok_or("missing predicate argument")?;
|
||||
i += 1;
|
||||
match flag.as_str() {
|
||||
"-name" => predicates.push(Predicate::Name(globset::Glob::new(value)?.compile_matcher())),
|
||||
"-path" => predicates.push(Predicate::Path(globset::Glob::new(value)?.compile_matcher())),
|
||||
"-type" => predicates.push(Predicate::Type(value.clone())),
|
||||
"-newer" => predicates.push(Predicate::Newer(std::fs::metadata(value)?.modified()?)),
|
||||
_ => maxdepth = value.parse()?,
|
||||
"-name" => Predicate::Name(globset::Glob::new(value)?.compile_matcher()),
|
||||
"-path" => Predicate::Path(globset::Glob::new(value)?.compile_matcher()),
|
||||
"-type" => Predicate::Type(value.clone()),
|
||||
"-newer" => Predicate::Newer(std::fs::metadata(value)?.modified()?),
|
||||
"-maxdepth" => {
|
||||
maxdepth = value.parse()?;
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
mindepth = value.parse()?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
"-exec" => {
|
||||
let start = i;
|
||||
while i < args.len() && args[i] != ";" { i += 1; }
|
||||
if i == args.len() || i == start { return Err("-exec requires a command and ;".into()); }
|
||||
exec = Some(args[start..i].to_vec()); i += 1;
|
||||
while i < args.len() && args[i] != ";" {
|
||||
i += 1;
|
||||
}
|
||||
if i == args.len() || i == start {
|
||||
return Err("-exec requires a command and ;".into());
|
||||
}
|
||||
explicit_action = true;
|
||||
let argv = args[start..i].to_vec();
|
||||
i += 1;
|
||||
Predicate::Exec(argv)
|
||||
}
|
||||
_ => return Err(format!("unsupported predicate: {flag}").into()),
|
||||
}
|
||||
};
|
||||
groups.last_mut().unwrap().push(predicate);
|
||||
}
|
||||
let mut status = 0;
|
||||
let mut out = io::BufWriter::new(io::stdout().lock());
|
||||
for root in roots {
|
||||
for entry in walkdir::WalkDir::new(root).max_depth(maxdepth) {
|
||||
let entry = match entry { Ok(e) => e, Err(e) => { eprintln!("find: {e}"); status = 1; continue; } };
|
||||
let mut matches = true;
|
||||
for predicate in &predicates {
|
||||
matches &= match predicate {
|
||||
Predicate::Name(g) => g.is_match(entry.file_name()),
|
||||
Predicate::Path(g) => g.is_match(entry.path()),
|
||||
Predicate::Type(t) => match t.as_str() { "f" => entry.file_type().is_file(), "d" => entry.file_type().is_dir(), "l" => entry.file_type().is_symlink(), _ => return Err("unsupported file type".into()) },
|
||||
Predicate::Newer(time) => entry.metadata()?.modified()? > *time,
|
||||
};
|
||||
let mut walk = walkdir::WalkDir::new(root).max_depth(maxdepth).into_iter();
|
||||
while let Some(entry) = walk.next() {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("find: {e}");
|
||||
status = 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if entry.depth() < mindepth {
|
||||
continue;
|
||||
}
|
||||
if !matches { continue; }
|
||||
if let Some(argv) = &exec {
|
||||
out.flush()?;
|
||||
let argv: Vec<_> = argv.iter().map(|arg| arg.replace("{}", &entry.path().to_string_lossy())).collect();
|
||||
if let Err(e) = Command::new(&argv[0]).args(&argv[1..]).status() { eprintln!("find: {e}"); status = 1; }
|
||||
} else {
|
||||
let mut prune = false;
|
||||
let mut matches = false;
|
||||
for group in &groups {
|
||||
let mut matched = true;
|
||||
for predicate in group {
|
||||
matched = match predicate {
|
||||
Predicate::Name(g) => g.is_match(entry.file_name()),
|
||||
Predicate::Path(g) => g.is_match(entry.path()),
|
||||
Predicate::Type(t) => match t.as_str() {
|
||||
"f" => entry.file_type().is_file(),
|
||||
"d" => entry.file_type().is_dir(),
|
||||
"l" => entry.file_type().is_symlink(),
|
||||
_ => return Err("unsupported file type".into()),
|
||||
},
|
||||
Predicate::Newer(time) => entry.metadata()?.modified()? > *time,
|
||||
Predicate::Empty => {
|
||||
if entry.file_type().is_dir() {
|
||||
std::fs::read_dir(entry.path())?.next().is_none()
|
||||
} else {
|
||||
entry.file_type().is_file() && entry.metadata()?.len() == 0
|
||||
}
|
||||
}
|
||||
Predicate::Prune => {
|
||||
prune = true;
|
||||
true
|
||||
}
|
||||
Predicate::Print(null) => {
|
||||
out.write_all(entry.path().as_os_str().as_encoded_bytes())?;
|
||||
out.write_all(if *null { b"\0" } else { b"\n" })?;
|
||||
true
|
||||
}
|
||||
Predicate::Exec(argv) => {
|
||||
out.flush()?;
|
||||
let argv: Vec<_> = argv
|
||||
.iter()
|
||||
.map(|arg| arg.replace("{}", &entry.path().to_string_lossy()))
|
||||
.collect();
|
||||
match Command::new(&argv[0]).args(&argv[1..]).status() {
|
||||
Ok(code) => code == 0,
|
||||
Err(e) => {
|
||||
eprintln!("find: {e}");
|
||||
status = 1;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if !matched {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
matches = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if prune && entry.file_type().is_dir() {
|
||||
walk.skip_current_dir();
|
||||
}
|
||||
if matches && !explicit_action {
|
||||
out.write_all(entry.path().as_os_str().as_encoded_bytes())?;
|
||||
out.write_all(if null { b"\0" } else { b"\n" })?;
|
||||
out.write_all(b"\n")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(status)
|
||||
}
|
||||
fn main() { std::process::exit(match run() { Ok(code) => code, Err(e) => { eprintln!("find: {e}"); 1 } }); }
|
||||
fn main() {
|
||||
std::process::exit(match run() {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
eprintln!("find: {e}");
|
||||
1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
#!/usr/bin/env nu
|
||||
# Compare xargs stdout and exit codes with GNU xargs in a real world.
|
||||
# Compare find and xargs with native tools in a real world.
|
||||
def main [host: path, bundle: path] {
|
||||
let world = (mktemp -d)
|
||||
mkdir ($world | path join world)
|
||||
cp -r ($bundle | path join bin) ($world | path join world bin)
|
||||
let fixture = ($world | path join world fixture)
|
||||
mkdir ($fixture | path join skip/deep) ($fixture | path join keep/deep) ($fixture | path join empty)
|
||||
'hidden' | save ($fixture | path join skip/deep/file)
|
||||
'shown' | save ($fixture | path join keep/deep/file)
|
||||
'' | save ($fixture | path join zero)
|
||||
'' | save ($fixture | path join reference)
|
||||
^touch -d '2001-01-01 UTC' ($fixture | path join reference)
|
||||
for args in [[-name skip -prune -o -type f -print] [-path './skip' -prune -o -print] [-name skip -prune -o -type f] [-maxdepth 1] [-mindepth 1 -maxdepth 2] [-empty] [-newer reference] [-path './keep/*']] {
|
||||
let native = (do { cd $fixture; ^find . ...$args | complete })
|
||||
let command = ($args | each {|arg| $arg | into string | to json -r} | str join ' ')
|
||||
let guest = (^$host sh $world -c $"cd /fixture; find . ($command)" | complete)
|
||||
if $native.exit_code != $guest.exit_code or ($native.stdout | lines | sort) != ($guest.stdout | lines | sort) {
|
||||
error make {msg: $"find mismatch ($args): native=($native) guest=($guest). World: ($world)"}
|
||||
}
|
||||
print $"PASS find ($args)"
|
||||
}
|
||||
let nul = (char nul)
|
||||
let cases = [
|
||||
{input: "a\nb\n", args: [-I{} printf '<%s>\n' '{}']}
|
||||
|
||||
@@ -70,6 +70,9 @@ It also checks these forms:
|
||||
- `status -sb` and `status --porcelain=v1`.
|
||||
- `log -N`, `log -n N`, `--max-count=N`, `--oneline`, `-p`, `--stat`, and one literal path after `--`.
|
||||
- `log --format` and `--pretty=format:` with `%H %h %s %an %ae %ad %d`.
|
||||
- `log --decorate` and `--decorate=short`, including `--oneline`.
|
||||
- `show -s --format=...` and `show -s --pretty=format:...`.
|
||||
- `%P %p %T %t %cn %ce %cd %ar %cr %b %B %n` in log and show formats.
|
||||
- `log A..B`, `--name-only`, `--no-renames`, and `--diff-merges=first-parent`.
|
||||
- `%x00`, `%x1e`, and `%(trailers:key=Sausage-Conversation,valueonly)` in log formats.
|
||||
- `show --stat`, `--oneline`, `--name-only`, `show HEAD:path`, and merge commits without a combined diff.
|
||||
|
||||
+47
-46
@@ -61,6 +61,7 @@ mod history;
|
||||
mod local;
|
||||
mod inspect;
|
||||
mod plumbing;
|
||||
mod pretty;
|
||||
mod stash;
|
||||
use clap::{Parser, Subcommand};
|
||||
#[derive(Parser)]
|
||||
@@ -78,6 +79,12 @@ pub struct Cli {
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
Show {
|
||||
#[arg(short = 's', long = "no-patch")]
|
||||
no_patch: bool,
|
||||
#[arg(long)]
|
||||
format: Option<String>,
|
||||
#[arg(long)]
|
||||
pretty: Option<String>,
|
||||
#[arg(long)]
|
||||
stat: bool,
|
||||
#[arg(long)]
|
||||
@@ -224,6 +231,8 @@ enum Command {
|
||||
no_gpg_sign: bool,
|
||||
},
|
||||
Log {
|
||||
#[arg(long, num_args = 0..=1, default_missing_value = "short", require_equals = true, value_parser = ["short"])]
|
||||
decorate: Option<String>,
|
||||
#[arg(long)]
|
||||
name_only: bool,
|
||||
#[arg(long)]
|
||||
@@ -988,6 +997,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
Command::Log {
|
||||
decorate,
|
||||
name_only,
|
||||
no_renames,
|
||||
diff_merges,
|
||||
@@ -1063,49 +1073,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
.as_deref()
|
||||
.map(|s| s.strip_prefix("format:").unwrap_or(s)))
|
||||
{
|
||||
let author = c.author().map_err(|e| error("log author", e))?;
|
||||
let date = author
|
||||
.time()
|
||||
.map_err(|e| error("log date", e))?
|
||||
.format_or_unix(gix::date::time::format::DEFAULT);
|
||||
let mut labels = Vec::new();
|
||||
for reference in repository
|
||||
.references()
|
||||
.map_err(|e| error("log refs", e))?
|
||||
.all()
|
||||
.map_err(|e| error("log refs", e))?
|
||||
{
|
||||
let reference = reference.map_err(|e| error("log refs", e))?;
|
||||
let name = reference.name().as_bstr().to_str_lossy();
|
||||
if repository
|
||||
.rev_parse_single(name.as_ref())
|
||||
.map(|id| id.to_string() == c.id.to_string())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let short = reference.name().shorten().to_string();
|
||||
labels.push(
|
||||
if head_info(&repository)?.branch.as_deref() == Some(name.as_ref())
|
||||
{
|
||||
format!("HEAD -> {short}")
|
||||
} else {
|
||||
short
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let decorations = if labels.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", labels.join(", "))
|
||||
};
|
||||
let text = plumbing::format(template, &message)
|
||||
.replace("%H", &c.id.to_string())
|
||||
.replace("%h", &c.id.to_string()[..7])
|
||||
.replace("%s", message.lines().next().unwrap_or(""))
|
||||
.replace("%an", &author.name.to_string())
|
||||
.replace("%ae", &author.email.to_string())
|
||||
.replace("%ad", &date)
|
||||
.replace("%d", &decorations);
|
||||
let text = pretty::format(&c, template)?;
|
||||
if pretty.is_some() {
|
||||
if !first {
|
||||
println!();
|
||||
@@ -1117,15 +1085,24 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
first = false;
|
||||
} else if oneline {
|
||||
println!(
|
||||
"{} {}",
|
||||
"{}{} {}",
|
||||
&c.id.to_string()[..7],
|
||||
if decorate.is_some() {
|
||||
pretty::decorations(&c)?
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
message.lines().next().unwrap_or("")
|
||||
);
|
||||
} else {
|
||||
if !first {
|
||||
println!();
|
||||
}
|
||||
output::commit(&c)?;
|
||||
if decorate.is_some() {
|
||||
output::decorated_commit(&c, &pretty::decorations(&c)?)?;
|
||||
} else {
|
||||
output::commit(&c)?;
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
if name_only && !diffs.is_empty() {
|
||||
@@ -1548,6 +1525,9 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
)?;
|
||||
}
|
||||
Command::Show {
|
||||
no_patch,
|
||||
format,
|
||||
pretty,
|
||||
revision,
|
||||
stat,
|
||||
oneline,
|
||||
@@ -1565,6 +1545,22 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
.map_err(|e| error("show", e))?;
|
||||
} else {
|
||||
let commit = object.peel_to_commit().map_err(|e| error("show", e))?;
|
||||
if no_patch {
|
||||
if let Some(template) = format.as_deref().or(pretty
|
||||
.as_deref()
|
||||
.map(|s| s.strip_prefix("format:").unwrap_or(s)))
|
||||
{
|
||||
let text = pretty::format(&commit, template)?;
|
||||
if pretty.is_some() && format.is_none() {
|
||||
print!("{text}");
|
||||
} else {
|
||||
println!("{text}");
|
||||
}
|
||||
} else {
|
||||
output::commit(&commit)?;
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
let merge_commit = commit.parent_ids().count() > 1;
|
||||
if merge_commit && !stat {
|
||||
let mut common: Option<std::collections::BTreeSet<String>> = None;
|
||||
@@ -1592,7 +1588,12 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
return Ok(129);
|
||||
}
|
||||
}
|
||||
if oneline {
|
||||
if let Some(template) = format.as_deref().or(pretty
|
||||
.as_deref()
|
||||
.map(|s| s.strip_prefix("format:").unwrap_or(s)))
|
||||
{
|
||||
println!("{}\n", pretty::format(&commit, template)?);
|
||||
} else if oneline {
|
||||
let decoded = commit.decode().map_err(|e| error("show", e))?;
|
||||
println!(
|
||||
"{} {}",
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
use crate::changes::{FileDiff, Kind, Status};
|
||||
use gix::bstr::ByteSlice;
|
||||
pub(crate) fn commit(commit: &gix::Commit<'_>) -> crate::Result<()> {
|
||||
decorated_commit(commit, "")
|
||||
}
|
||||
pub(crate) fn decorated_commit(commit: &gix::Commit<'_>, decorations: &str) -> crate::Result<()> {
|
||||
let decoded = commit
|
||||
.decode()
|
||||
.map_err(|e| crate::repo::error("commit output", e))?;
|
||||
println!("commit {}", commit.id);
|
||||
println!("commit {}{}", commit.id, decorations);
|
||||
if decoded.parents.len() > 1 {
|
||||
println!(
|
||||
"Merge: {}",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Shared commit formats and short reference decorations for log and show.
|
||||
use crate::{Result, error};
|
||||
use gix::bstr::ByteSlice;
|
||||
|
||||
pub(crate) fn decorations(commit: &gix::Commit<'_>) -> Result<String> {
|
||||
let repo = commit.repo;
|
||||
let head = crate::head_info(repo)?;
|
||||
let mut labels = Vec::new();
|
||||
for reference in repo
|
||||
.references()
|
||||
.map_err(|e| error("refs", e))?
|
||||
.all()
|
||||
.map_err(|e| error("refs", e))?
|
||||
{
|
||||
let mut reference = reference.map_err(|e| error("refs", e))?;
|
||||
let name = reference.name().as_bstr().to_str_lossy().into_owned();
|
||||
if reference
|
||||
.peel_to_id()
|
||||
.is_ok_and(|id| id.detach() == commit.id)
|
||||
{
|
||||
let short = reference.name().shorten().to_string();
|
||||
labels.push(if head.branch.as_deref() == Some(name.as_ref()) {
|
||||
format!("HEAD -> {short}")
|
||||
} else if name.starts_with("refs/tags/") {
|
||||
format!("tag: {short}")
|
||||
} else {
|
||||
short
|
||||
});
|
||||
}
|
||||
}
|
||||
if head.branch.is_none()
|
||||
&& repo
|
||||
.rev_parse_single("HEAD")
|
||||
.is_ok_and(|id| id.detach() == commit.id)
|
||||
{
|
||||
labels.push("HEAD".into());
|
||||
}
|
||||
labels.sort_by(|a, b| {
|
||||
b.starts_with("HEAD")
|
||||
.cmp(&a.starts_with("HEAD"))
|
||||
.then_with(|| b.starts_with("tag: ").cmp(&a.starts_with("tag: ")))
|
||||
.then_with(|| b.cmp(a))
|
||||
});
|
||||
Ok(if labels.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", labels.join(", "))
|
||||
})
|
||||
}
|
||||
fn relative(seconds: i64) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let mut diff = now - seconds;
|
||||
if diff < 0 {
|
||||
return "in the future".into();
|
||||
}
|
||||
let (count, unit) = if diff < 90 {
|
||||
(diff, "second")
|
||||
} else {
|
||||
diff = (diff + 30) / 60;
|
||||
if diff < 90 {
|
||||
(diff, "minute")
|
||||
} else {
|
||||
diff = (diff + 30) / 60;
|
||||
if diff < 36 {
|
||||
(diff, "hour")
|
||||
} else {
|
||||
diff = (diff + 12) / 24;
|
||||
if diff < 14 {
|
||||
(diff, "day")
|
||||
} else if diff < 70 {
|
||||
((diff + 3) / 7, "week")
|
||||
} else if diff < 365 {
|
||||
((diff + 15) / 30, "month")
|
||||
} else if diff < 1825 {
|
||||
let total_months = (diff * 12 + 183) / 365;
|
||||
let years = total_months / 12;
|
||||
let months = total_months % 12;
|
||||
if months != 0 {
|
||||
return format!(
|
||||
"{years} year{}, {months} month{} ago",
|
||||
if years == 1 { "" } else { "s" },
|
||||
if months == 1 { "" } else { "s" }
|
||||
);
|
||||
}
|
||||
(years, "year")
|
||||
} else {
|
||||
((diff + 183) / 365, "year")
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
format!("{count} {unit}{} ago", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
pub(crate) fn format(commit: &gix::Commit<'_>, template: &str) -> Result<String> {
|
||||
let decoded = commit.decode().map_err(|e| error("format", e))?;
|
||||
let message = decoded.message.to_str_lossy();
|
||||
let author = commit.author().map_err(|e| error("author", e))?;
|
||||
let committer = commit.committer().map_err(|e| error("committer", e))?;
|
||||
let at = author.time().map_err(|e| error("date", e))?;
|
||||
let ct = committer.time().map_err(|e| error("date", e))?;
|
||||
let parents = decoded
|
||||
.parents
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let tree = decoded.tree.to_string();
|
||||
let id = commit.id.to_string();
|
||||
let fields = [
|
||||
("%H", id.clone()),
|
||||
("%h", id[..7].into()),
|
||||
("%P", parents.join(" ")),
|
||||
(
|
||||
"%p",
|
||||
parents
|
||||
.iter()
|
||||
.map(|p| &p[..7])
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
),
|
||||
("%T", tree.clone()),
|
||||
("%t", tree[..7].into()),
|
||||
("%an", author.name.to_string()),
|
||||
("%ae", author.email.to_string()),
|
||||
("%ad", at.format_or_unix(gix::date::time::format::DEFAULT)),
|
||||
("%ar", relative(at.seconds)),
|
||||
("%cn", committer.name.to_string()),
|
||||
("%ce", committer.email.to_string()),
|
||||
("%cd", ct.format_or_unix(gix::date::time::format::DEFAULT)),
|
||||
("%cr", relative(ct.seconds)),
|
||||
("%s", message.lines().next().unwrap_or("").into()),
|
||||
(
|
||||
"%b",
|
||||
message.split_once("\n\n").map_or("", |(_, b)| b).into(),
|
||||
),
|
||||
("%B", message.to_string()),
|
||||
("%d", decorations(commit)?),
|
||||
("%n", "\n".into()),
|
||||
("%%", "%".into()),
|
||||
];
|
||||
let expanded = crate::plumbing::format(template, &message);
|
||||
let mut rest = expanded.as_str();
|
||||
let mut text = String::new();
|
||||
while !rest.is_empty() {
|
||||
if let Some((key, value)) = fields.iter().find(|(key, _)| rest.starts_with(key)) {
|
||||
text.push_str(value);
|
||||
rest = &rest[key.len()..];
|
||||
} else {
|
||||
let ch = rest.chars().next().unwrap();
|
||||
text.push(ch);
|
||||
rest = &rest[ch.len_utf8()..];
|
||||
}
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
+21
-1
@@ -64,6 +64,11 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t
|
||||
{args: [log -n 1 --stat], compare: true}
|
||||
{args: [log --oneline -- file.txt], compare: true}
|
||||
{args: [show --stat], compare: true}
|
||||
{args: [show -s '--format=%P|%p|%T|%t|%cn|%ce|%cd|%ar|%cr|%b|%B|%n'], compare: true}
|
||||
{args: [show -s '--pretty=format:%H%n%B'], compare: true}
|
||||
{args: [log -1 '--format=%P|%p|%T|%t|%cn|%ce|%cd|%ar|%cr|%b|%B|%n'], compare: true}
|
||||
{args: [log --decorate], compare: true}
|
||||
{args: [log --decorate=short --oneline], compare: true}
|
||||
{args: [show --oneline], compare: true}
|
||||
{args: [show --name-only], compare: true}
|
||||
{args: [show --oneline --stat], compare: true}
|
||||
@@ -88,6 +93,8 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t
|
||||
{write: 'topic\n', file: 'topic.txt'}
|
||||
{args: [add .], compare: true}
|
||||
{args: [commit -m topic], compare: true}
|
||||
{args: [log -1 '--format=%P|%p|%T|%t|%cn|%ce|%cd|%ar|%cr|%b|%B|%n'], compare: true}
|
||||
{args: [show -s '--pretty=format:%P%n%p%n%T%n%t'], compare: true}
|
||||
{args: [rev-parse HEAD HEAD~1], compare: true}
|
||||
{args: [log -1], compare: true}
|
||||
{args: [log -2], compare: true}
|
||||
@@ -199,6 +206,8 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t
|
||||
{args: [remote -v], compare: true}
|
||||
{args: [tag lightweight], compare: true, state: true}
|
||||
{args: [tag -a annotated -m annotation], compare: true, state: true}
|
||||
{args: [log --decorate -1], compare: true}
|
||||
{args: [log --decorate=short --oneline -1], compare: true}
|
||||
{args: [tag], compare: true}
|
||||
{args: [tag --list], compare: true}
|
||||
{args: [tag -d annotated], compare: true, state: true}
|
||||
@@ -332,7 +341,9 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t
|
||||
{args: [switch rework], compare: true}
|
||||
{args: [commit --allow-empty -m trailer], native_args: [commit --allow-empty -m 'trailer\n\nSausage-Conversation: conversation-34'], conversation: conversation-34, compare: true, state: true}
|
||||
{args: [log -n 1 --name-only --no-renames --diff-merges=first-parent '--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00'], compare: true}
|
||||
{args: [commit --allow-empty -m no-trailer], compare: true, state: true}
|
||||
{args: [commit --allow-empty -m "no-trailer\n\nBody with literal %P and %cn."], compare: true, state: true}
|
||||
{args: [show -s '--format=%P|%p|%T|%t|%cn|%ce|%cd|%ar|%cr|%b|%B|%n'], compare: true}
|
||||
{args: [log -1 '--pretty=format:%b|%B|%n'], compare: true}
|
||||
{args: [log --name-only --no-renames --diff-merges=first-parent '--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00' HEAD~3..HEAD], compare: true}
|
||||
{args: [diff --check], compare: true}
|
||||
{args: [diff --cached --check], compare: true}
|
||||
@@ -486,6 +497,15 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t
|
||||
if not $equal { $failures += 1; print ($expected | to json); print ($actual | to json) }
|
||||
}
|
||||
^git -C $no_identity reset --hard -q HEAD
|
||||
for age in [2min 1hr 1day 15day 100day 1459day 2190day] {
|
||||
let stamp = ((date now) - $age | format date '%s +0000')
|
||||
with-env {GIT_AUTHOR_DATE: $stamp, GIT_COMMITTER_DATE: $stamp} { ^git -C $no_identity commit --no-gpg-sign --allow-empty -q -m relative }
|
||||
let expected = (^git -C $no_identity show -s '--format=%ar|%cr' | complete)
|
||||
let actual = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($no_identity)::/repo' $component -C /repo show -s '--format=%ar|%cr' | complete)
|
||||
let equal = ($expected.exit_code == 0 and $actual.exit_code == 0 and $expected.stdout == $actual.stdout)
|
||||
print $'relative date ($age): (if $equal {"PASS"} else {"FAIL"})'
|
||||
if not $equal { $failures += 1; print ($expected | to json); print ($actual | to json) }
|
||||
}
|
||||
^git -C $no_identity config user.useConfigOnly true
|
||||
let identity_failures = (do {
|
||||
mut failed = 0
|
||||
|
||||
Reference in New Issue
Block a user