Merge v3-t76: git show/log formats and decorations, find -prune and predicates, tail from a pipe (T76)

This commit is contained in:
2026-11-08 03:58:30 +00:00
28 changed files with 4359 additions and 92 deletions
+36
View File
@@ -138,6 +138,42 @@ 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"),
] {
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}");
}
}
let mut history = Vec::new();
assert_eq!(
exec.exec(
+42 -2
View File
@@ -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",
+1 -1
View File
@@ -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"
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+77
View File
@@ -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
+72
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+212
View File
@@ -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())
}
}
+72
View File
@@ -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};
+675
View File
@@ -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: &notify::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(())
}
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+30
View File
@@ -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;
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
});
}
+17 -1
View File
@@ -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' '{}']}
+3
View File
@@ -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
View File
@@ -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!(
"{} {}",
+4 -1
View File
@@ -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: {}",
+157
View File
@@ -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
View File
@@ -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