├── src ├── main.rs ├── errors.rs ├── opts.rs └── lib.rs ├── tests ├── files │ └── input.txt └── integration.rs ├── .gitignore ├── RELEASE.md ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── license ├── APACHE └── MIT ├── release.toml ├── Cargo.toml ├── benches └── basic.rs ├── README.md └── CHANGELOG.md /src/main.rs: -------------------------------------------------------------------------------- 1 | use headtail::{errors::HeadTailError, headtail, opts::Opts}; 2 | 3 | fn main() -> Result<(), HeadTailError> { 4 | env_logger::init(); 5 | let opts = Opts::parse_args(); 6 | //println!("{opts:#?}"); 7 | headtail(&opts)?; 8 | Ok(()) 9 | } 10 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum HeadTailError { 5 | #[error("IO error: {0}")] 6 | IOError(#[from] std::io::Error), 7 | 8 | #[error("File watcher error: {0}")] 9 | FileWatcherError(#[from] notify::Error), 10 | } 11 | -------------------------------------------------------------------------------- /tests/files/input.txt: -------------------------------------------------------------------------------- 1 | one 2 | two 3 | three 4 | four 5 | five 6 | six 7 | seven 8 | eight 9 | nine 10 | ten 11 | eleven 12 | twelve 13 | thirteen 14 | fourteen 15 | fifteen 16 | sixteen 17 | seventeen 18 | eighteen 19 | nineteen 20 | twenty 21 | twenty-one 22 | twenty-two 23 | twenty-three 24 | twenty-four 25 | twenty-five 26 | twenty-six 27 | twenty-seven 28 | twenty-eight 29 | twenty-nine 30 | thirty 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | 13 | # Added by cargo 14 | 15 | /target 16 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Setting up 2 | 3 | Install `cargo-release` with: 4 | 5 | ```shell 6 | cargo install cargo-release 7 | ``` 8 | 9 | # Configuration 10 | 11 | Configuration goes in the [`release.toml`](./release.toml) 12 | 13 | # Releasing 14 | 15 | ```shell 16 | # First, choose `major`, `minor`, or `patch` for the level to release 17 | 18 | # Next, run the command in dry-run mode 19 | $ cargo release -vv LEVEL 20 | 21 | # Then do it for real with the same level 22 | $ cargo release --execute LEVEL 23 | ``` 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | commit-message: 11 | prefix: "build:" 12 | schedule: 13 | interval: "weekly" 14 | -------------------------------------------------------------------------------- /license/APACHE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Nathan Stocks 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /release.toml: -------------------------------------------------------------------------------- 1 | pre-release-commit-message = "{{crate_name}} {{version}}" 2 | tag-message = "{{tag_name}}" 3 | tag-name = "{{prefix}}v{{version}}" 4 | pre-release-replacements = [ 5 | { file = "CHANGELOG.md", search = "Unreleased", replace = "{{version}}", min = 1 }, 6 | { file = "CHANGELOG.md", search = "\\.\\.\\.HEAD", replace = "...{{tag_name}}", exactly = 1 }, 7 | { file = "CHANGELOG.md", search = "ReleaseDate", replace = "{{date}}", min = 1 }, 8 | { file = "CHANGELOG.md", search = "", replace = "\n## [Unreleased] - ReleaseDate\n", exactly = 1 }, 9 | { file = "CHANGELOG.md", search = "", replace = "\n[Unreleased]: https://github.com/CleanCut/headtail/compare/{{tag_name}}...HEAD", exactly = 1 }, 10 | ] 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "headtail" 3 | version = "0.4.0" 4 | edition = "2021" 5 | description = "head and tail simultaneously" 6 | homepage = "https://github.com/CleanCut/headtail" 7 | repository = "https://github.com/CleanCut/headtail" 8 | readme = "README.md" 9 | keywords = [ "head", "tail", "trim", "stdout", "file" ] 10 | categories = [ "command-line-utilities" ] 11 | license = "MIT OR Apache-2.0" 12 | 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | 15 | [dependencies] 16 | clap = { version = "4", features = ["derive"] } 17 | crossbeam-channel = "0.5.6" 18 | env_logger = "0.11.1" 19 | log = "0.4.17" 20 | notify = "8.0.0" 21 | thiserror = "2.0.3" 22 | 23 | [dev-dependencies] 24 | criterion = "0.8.0" 25 | tempfile = "3.3.0" 26 | 27 | [[bench]] 28 | name = "basic" 29 | harness = false 30 | -------------------------------------------------------------------------------- /license/MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Nathan Stocks 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /benches/basic.rs: -------------------------------------------------------------------------------- 1 | use std::hint::black_box; 2 | 3 | use criterion::{criterion_group, criterion_main, Criterion}; 4 | use headtail::{headtail, opts::Opts}; 5 | 6 | pub fn c1(c: &mut Criterion) { 7 | let o = Opts { 8 | filename: Some(String::from("tests/files/input.txt")), 9 | head: 10, 10 | tail: 10, 11 | follow: false, 12 | sleep_interval: 0.025, 13 | outfile: None, 14 | separator: false, 15 | }; 16 | c.bench_function("no args", |b| b.iter(|| headtail(black_box(&o)))); 17 | } 18 | criterion_group!(no_args, c1); 19 | 20 | pub fn c2(c: &mut Criterion) { 21 | let o = Opts { 22 | filename: Some(String::from("tests/files/input.txt")), 23 | head: 10, 24 | tail: 0, 25 | follow: false, 26 | sleep_interval: 0.025, 27 | outfile: None, 28 | separator: false, 29 | }; 30 | c.bench_function("head only", |b| b.iter(|| headtail(black_box(&o)))); 31 | } 32 | criterion_group!(head_only, c2); 33 | 34 | pub fn c3(c: &mut Criterion) { 35 | let o = Opts { 36 | filename: Some(String::from("tests/files/input.txt")), 37 | head: 0, 38 | tail: 10, 39 | follow: false, 40 | sleep_interval: 0.025, 41 | outfile: None, 42 | separator: false, 43 | }; 44 | c.bench_function("tail only", |b| b.iter(|| headtail(black_box(&o)))); 45 | } 46 | criterion_group!(tail_only, c3); 47 | 48 | criterion_main! { 49 | no_args, 50 | head_only, 51 | tail_only, 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | workflow_dispatch: 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | 13 | jobs: 14 | test: 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [macos-latest, windows-latest, ubuntu-latest] 20 | 21 | steps: 22 | - name: Set git to use LF on Windows 23 | run: | 24 | git config --global core.autocrlf false 25 | git config --global core.eol lf 26 | if: runner.os == 'Windows' 27 | 28 | - uses: actions/checkout@v3 29 | 30 | - name: Fetch cargo registry cache 31 | uses: actions/cache@v3 32 | continue-on-error: false 33 | with: 34 | path: | 35 | ~/.cargo/bin/ 36 | ~/.cargo/registry/index/ 37 | ~/.cargo/registry/cache/ 38 | ~/.cargo/git/db/ 39 | target/ 40 | Cargo.lock 41 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 42 | 43 | - name: Build 44 | run: cargo build 45 | 46 | - name: rustfmt & clippy 47 | run: | 48 | rustup component add clippy rustfmt 49 | cargo clippy --workspace 50 | cargo fmt --all -- --check 51 | 52 | - name: Run tests 53 | run: cargo test -- --show-output 54 | env: 55 | RUST_LOG: trace 56 | 57 | - name: Run benchmarks 58 | run: cargo bench 59 | if: runner.os == 'Linux' 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # headtail 2 | 3 | `head` and `tail` simultaneously! 4 | 5 | ## Backstory 6 | 7 | Not finding an existing utility to head _and_ tail the output, nor a good way to combine existing utilities even with the help of a [couple](https://github.com/jorendorff) [friends](https://github.com/bensherman) of [mine](https://github.com/CleanCut), we decided to make one. We ended up doing a "day of learning" session with about 50 other engineers from GitHub and collaboritively came up with a minimum viable solution that worked well! 8 | 9 | Now it's a real utility that we keep improving. Go try it out! 10 | 11 | ## Contributing 12 | 13 | Contributions are welcome! Here are some [good first issues](https://github.com/CleanCut/headtail/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) you could look into. 14 | 15 | ## Quick Start 16 | 17 | You need to [have Rust installed](https://www.rust-lang.org/tools/install). 18 | 19 | ```shell 20 | # Install latest *release* version of headtail 21 | $ cargo install headtail 22 | ``` 23 | 24 | ``` 25 | # Use it on a file - prints the first 10 and last 10 lines 26 | $ headtail somebigfile.txt 27 | 28 | # Pipe stdout to it - prints the first 10 and last 10 lines 29 | $ somecommand | headtail 30 | 31 | # Print the first 25 and last 5 lines of a file 32 | $ headtail somebigfile.txt -H 25 -T 5 33 | 34 | # Print the default amount of first lines, but only 3 last lines 35 | $ headtail somebigfile.txt -T 3 36 | 37 | # Do the default thing...but then keep tailing the file and print 38 | # out anything new that is appended to it. 39 | $ headtail somebigfile.txt -f 40 | ``` 41 | 42 | See `headtail -h` for a full list of command-line options. 43 | 44 | ## Development 45 | 46 | ``` 47 | # Run locally with arguments 48 | $ cargo run -- YOUR_ARGS_GO_HERE 49 | 50 | # Enable debug logging 51 | $ RUST_LOG=trace cargo run -- YOUR_ARGS_GO_HERE 52 | 53 | # Install local development version of headtail into your ~/.cargo/bin 54 | $ cargo install --path . 55 | ``` 56 | 57 | ## Software License 58 | 59 | Distributed under the terms of both the MIT license and the Apache License (Version 2.0). 60 | 61 | See [license/APACHE](license/APACHE) and [license/MIT](license/MIT). 62 | -------------------------------------------------------------------------------- /src/opts.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::OpenOptions, 3 | io::{BufRead, BufReader, BufWriter, Write}, 4 | }; 5 | 6 | use clap::Parser; 7 | 8 | #[derive(Clone, Debug, clap::Parser)] 9 | pub struct Opts { 10 | /// Read from a file instead of stdin 11 | pub filename: Option, 12 | /// Number of first lines of a file to display 13 | #[arg(short = 'H', long, default_value_t = 10)] 14 | pub head: usize, 15 | /// Number of last lines of a file to display 16 | #[arg(short = 'T', long, default_value_t = 10)] 17 | pub tail: usize, 18 | /// Wait for additional data to be appended to a file. Ignored if standard 19 | /// input is a pipe. If a `notify`-compatible filesystem watcher is 20 | /// available, that will be used. If not, we will fall back to a polling 21 | /// watcher. 22 | #[arg(short, long)] 23 | pub follow: bool, 24 | /// When following a file, sleep this amount in seconds between polling for changes. Ignored if 25 | /// a `notify`-compatible watcher is available. 26 | #[arg(short, long, default_value_t = 0.025)] 27 | pub sleep_interval: f64, 28 | 29 | /// Write output to file 30 | #[arg(short, long)] 31 | pub outfile: Option, 32 | 33 | /// Show separator between head and tail 34 | #[arg(short = 'S', long, default_value_t = false)] 35 | pub separator: bool, 36 | } 37 | 38 | impl Opts { 39 | pub fn parse_args() -> Self { 40 | Self::parse() 41 | } 42 | 43 | /// Stream to receive input from. Either the file passed, or stdin otherwise. 44 | pub fn input_stream(&self) -> std::io::Result> { 45 | let stream: Box = match self.filename { 46 | Some(ref filename) => { 47 | let file = OpenOptions::new().read(true).open(filename)?; 48 | Box::new(BufReader::new(file)) 49 | } 50 | None => Box::new(BufReader::new(std::io::stdin())), 51 | }; 52 | Ok(stream) 53 | } 54 | 55 | /// Stream to write output to. Either the file passed, or stdout otherwise. 56 | pub fn output_stream(&self) -> std::io::Result> { 57 | let stream: Box = match self.outfile { 58 | Some(ref filename) => { 59 | let file = OpenOptions::new().write(true).create(true).open(filename)?; 60 | Box::new(BufWriter::new(file)) 61 | } 62 | None => Box::new(BufWriter::new(std::io::stdout())), 63 | }; 64 | Ok(stream) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [Unreleased] - ReleaseDate 3 | 4 | ## [0.4.0] - 2025-04-23 5 | 6 | ### Improved 7 | 8 | - We now run CI on Windows. `headtail` _appears_ to work on Windows. If you run into problems, please report them! 9 | - Dependency version bumps: 10 | - clap 3.2 to clap 4.0 internally (no user-facing change) 11 | - env_logger 0.9.1 to 0.11.1 12 | - notify 5.0.0 to 8.0.0 13 | - thiserror 1.0.35 to 2.0.3 14 | 15 | ### Added 16 | 17 | - We now have some benchmarks, and they are run in CI 18 | - We set up dependabot to automatically open pull requests to update dependencies 19 | - New option `-S` / `--separator` will add a message about the number of lines omitted between the head and tail output. It looks something like this: `[... 10 line(s) omitted ...]` 20 | 21 | ## [0.3.2] - 2022-09-22 22 | 23 | ### Improved 24 | 25 | - Internal improvements to how we use clap 26 | 27 | ## [0.3.1] - 2022-09-21 28 | 29 | ### Added 30 | 31 | - CI now runs on macOS in addition to Linux. Now we just need someone to help us [support Windows](https://github.com/CleanCut/headtail/issues/21)! 32 | 33 | ### Improved 34 | 35 | - We now use a notify-based watcher (inotify on Linux, etc.) when available to avoid polling. 36 | - Internal improvements. 37 | 38 | ## [0.3.0] - 2022-09-15 39 | 40 | ### Added 41 | 42 | - CI builds are now available on pull requests (including caching, rustfmt, and clippy) 43 | 44 | ### Fixed 45 | 46 | - Don't crash when writing to a broken pipe 47 | - Reduce follow from a busy loop (100% CPU) to a user-configurable sleep interval that defaults to 25ms (0.2% CPU in local testing) 48 | 49 | ### Other 50 | 51 | - Refactored where the input streams are created 52 | 53 | 54 | ## [0.2.0] - 2022-09-15 55 | 56 | ### New! 57 | 58 | - Made the project! It supports some flags. Here's the help output: 59 | 60 | ``` 61 | headtail 62 | 63 | USAGE: 64 | headtail [OPTIONS] [FILENAME] 65 | 66 | ARGS: 67 | Read from a file instead of stdin 68 | 69 | OPTIONS: 70 | -f, --follow Wait for additional data to be appended to a file. Ignored if standard 71 | input is a pipe. 72 | -h, --help Print help information 73 | -H, --head Number of first lines of a file to display [default: 10] 74 | -T, --tail Number of last lines of a file to display [default: 10] 75 | ``` 76 | 77 | ## [0.1.0] - 2022-08-24 78 | 79 | Placeholder release to reserve the name. 80 | 81 | 82 | [Unreleased]: https://github.com/CleanCut/headtail/compare/v0.4.0...HEAD 83 | [0.4.0]: https://github.com/CleanCut/headtail/compare/v0.3.2...v0.4.0 84 | [0.3.2]: https://github.com/CleanCut/headtail/compare/v0.3.1...v0.3.2 85 | [0.3.1]: https://github.com/CleanCut/headtail/compare/v0.3.0...v0.3.1 86 | [0.3.0]: https://github.com/CleanCut/headtail/compare/v0.2.0...v0.3.0 87 | [0.2.0]: https://github.com/CleanCut/headtail/compare/v0.1.0...v0.2.0 88 | [0.1.0]: https://github.com/CleanCut/headtail/compare/v0.0.0...v0.1.0 89 | -------------------------------------------------------------------------------- /tests/integration.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::{BufRead, BufReader, Result, Write}; 3 | use std::process::{Command, Stdio}; 4 | use std::time::Duration; 5 | 6 | use tempfile::NamedTempFile; 7 | 8 | fn number_of_input_lines() -> usize { 9 | let f = BufReader::new(File::open("tests/files/input.txt").unwrap()); 10 | f.lines().count() 11 | } 12 | 13 | #[test] 14 | fn help() { 15 | match Command::new(env!("CARGO_BIN_EXE_headtail")) 16 | .arg("-h") 17 | .output() 18 | { 19 | Ok(output) => { 20 | // debug_output(&output); 21 | let stdout = String::from_utf8_lossy(&output.stdout); 22 | assert!(stdout.contains("Usage:")); 23 | assert!(stdout.contains("headtail")); 24 | assert!(stdout.contains("FILENAME")); 25 | assert!(stdout.contains("-H")); 26 | assert!(stdout.contains("-T")); 27 | } 28 | Err(e) => println!("Error: {}", e), 29 | } 30 | } 31 | 32 | #[test] 33 | fn argless() { 34 | match Command::new(env!("CARGO_BIN_EXE_headtail")) 35 | .arg("tests/files/input.txt") 36 | .output() 37 | { 38 | Ok(output) => { 39 | // debug_output(&output); 40 | let stdout = String::from_utf8_lossy(&output.stdout); 41 | assert!(stdout.contains("one\n")); 42 | assert!(stdout.contains("ten\n")); 43 | assert!(!stdout.contains("eleven\n")); 44 | assert!(!stdout.contains("twenty\n")); 45 | assert!(stdout.contains("thirty\n")); 46 | } 47 | Err(e) => println!("Error: {}", e), 48 | } 49 | } 50 | 51 | #[test] 52 | fn head() { 53 | match Command::new(env!("CARGO_BIN_EXE_headtail")) 54 | .arg("tests/files/input.txt") 55 | .arg("-H") 56 | .arg("3") 57 | .arg("-T") 58 | .arg("0") 59 | .output() 60 | { 61 | Ok(output) => { 62 | // debug_output(&output); 63 | let stdout = String::from_utf8_lossy(&output.stdout); 64 | assert!(stdout.contains("one\n")); 65 | assert!(stdout.contains("two\n")); 66 | assert!(stdout.contains("three\n")); 67 | assert!(!stdout.contains("four\n")); 68 | assert!(!stdout.contains("twenty\n")); 69 | assert!(!stdout.contains("thirty\n")); 70 | } 71 | Err(e) => println!("Error: {}", e), 72 | } 73 | } 74 | 75 | #[test] 76 | fn tail() { 77 | match Command::new(env!("CARGO_BIN_EXE_headtail")) 78 | .arg("tests/files/input.txt") 79 | .arg("-H") 80 | .arg("0") 81 | .arg("-T") 82 | .arg("3") 83 | .output() 84 | { 85 | Ok(output) => { 86 | // debug_output(&output); 87 | let stdout = String::from_utf8_lossy(&output.stdout); 88 | assert!(stdout.contains("twenty-eight\n")); 89 | assert!(stdout.contains("twenty-nine\n")); 90 | assert!(stdout.contains("thirty\n")); 91 | assert!(!stdout.contains("twenty-seven\n")); 92 | assert!(!stdout.contains("one\n")); 93 | assert!(!stdout.contains("ten\n")); 94 | } 95 | Err(e) => println!("Error: {}", e), 96 | } 97 | } 98 | 99 | #[test] 100 | fn head_length_exceeds_file_length() { 101 | let expected_lines = number_of_input_lines(); 102 | let head_lines = 64; 103 | assert!(expected_lines < head_lines, "Expected input file to have fewer than {} lines (otherwise this test doesn't test anything)", head_lines); 104 | 105 | match Command::new(env!("CARGO_BIN_EXE_headtail")) 106 | .arg("tests/files/input.txt") 107 | .arg("-H") 108 | .arg(head_lines.to_string()) 109 | .arg("-T") 110 | .arg("0") 111 | .output() 112 | { 113 | Ok(output) => { 114 | let stdout = String::from_utf8_lossy(&output.stdout); 115 | let num_lines = stdout.lines().count(); 116 | assert_eq!(num_lines, expected_lines); 117 | } 118 | Err(e) => println!("Error: {}", e), 119 | } 120 | } 121 | 122 | #[test] 123 | fn tail_length_exceeds_file_length() { 124 | let expected_lines = number_of_input_lines(); 125 | let tail_lines = 64; 126 | assert!(expected_lines < tail_lines, "Expected input file to have fewer than {} lines (otherwise this test doesn't test anything)", tail_lines); 127 | 128 | match Command::new(env!("CARGO_BIN_EXE_headtail")) 129 | .arg("tests/files/input.txt") 130 | .arg("-H") 131 | .arg("0") 132 | .arg("-T") 133 | .arg(tail_lines.to_string()) 134 | .output() 135 | { 136 | Ok(output) => { 137 | let stdout = String::from_utf8_lossy(&output.stdout); 138 | let num_lines = stdout.lines().count(); 139 | assert_eq!(num_lines, expected_lines); 140 | } 141 | Err(e) => println!("Error: {}", e), 142 | } 143 | } 144 | 145 | #[test] 146 | fn overlapping_head_and_tail() { 147 | let expected_lines = number_of_input_lines(); 148 | let head_lines = 20; 149 | let tail_lines = 20; 150 | assert!(expected_lines < head_lines + tail_lines, "Expected input file to have fewer than {} lines (otherwise this test doesn't test anything)", head_lines + tail_lines); 151 | 152 | match Command::new(env!("CARGO_BIN_EXE_headtail")) 153 | .arg("tests/files/input.txt") 154 | .arg("-H") 155 | .arg(head_lines.to_string()) 156 | .arg("-T") 157 | .arg(tail_lines.to_string()) 158 | .output() 159 | { 160 | Ok(output) => { 161 | let stdout = String::from_utf8_lossy(&output.stdout); 162 | let num_lines = stdout.lines().count(); 163 | assert_eq!(num_lines, expected_lines); 164 | } 165 | Err(e) => println!("Error: {}", e), 166 | } 167 | } 168 | 169 | #[test] 170 | fn show_separator() { 171 | match Command::new(env!("CARGO_BIN_EXE_headtail")) 172 | .arg("tests/files/input.txt") 173 | .arg("-S") 174 | .output() 175 | { 176 | Ok(output) => { 177 | let stdout = String::from_utf8_lossy(&output.stdout); 178 | let num_lines = stdout.lines().count(); 179 | assert_eq!(num_lines, 10 + 10 + 1); 180 | assert!(stdout.contains(&format!( 181 | "[... {} line(s) omitted ...]", 182 | number_of_input_lines() - 20 183 | ))); 184 | } 185 | Err(e) => println!("Error: {}", e), 186 | } 187 | } 188 | 189 | // TODO: Add test for -f/--follow 190 | 191 | #[test] 192 | fn follow_detects_recreation() -> Result<()> { 193 | if let Ok(ci_var) = std::env::var("CI") { 194 | if !ci_var.is_empty() && cfg!(target_os = "linux") { 195 | eprintln!("WARNING: Ignoring follow_detects_recreation test on Linux CI since this feature doesn't work with the fallback polling strategy."); 196 | return Ok(()); 197 | } 198 | } 199 | let wait_duration = Duration::from_millis(125); // 5 times higher than minimum required for my machine - cleancut 200 | let first_file_contents = "first file\n"; 201 | let second_file_contents = "second file\n"; 202 | 203 | // create a temporary file 204 | let just_for_name_file = NamedTempFile::new()?; 205 | let tmpfilename = just_for_name_file.path().to_owned(); 206 | drop(just_for_name_file); 207 | // give filesystem time to really delete the file 208 | std::thread::sleep(wait_duration); 209 | 210 | let mut tmpfile = File::create(&tmpfilename)?; 211 | write!(tmpfile, "{}", first_file_contents)?; 212 | let _ = tmpfile.flush(); 213 | drop(tmpfile); 214 | 215 | // give filesystem time to write file contents and close file 216 | std::thread::sleep(wait_duration); 217 | 218 | let mut cmd = Command::new(env!("CARGO_BIN_EXE_headtail")) 219 | .arg(&tmpfilename) 220 | .arg("--follow") 221 | .stdout(Stdio::piped()) 222 | .stderr(Stdio::piped()) 223 | .spawn()?; 224 | 225 | // Give headtail sufficient time to open the file and read it 226 | std::thread::sleep(wait_duration); 227 | 228 | // give filesystem time to really delete the file 229 | std::fs::remove_file(&tmpfilename)?; 230 | 231 | std::thread::sleep(wait_duration); 232 | 233 | let mut newfile = File::create(&tmpfilename)?; 234 | write!(newfile, "{}", second_file_contents)?; 235 | let _ = newfile.flush(); 236 | drop(newfile); 237 | 238 | // give filesystem time to write file contents and close file 239 | std::thread::sleep(wait_duration); 240 | 241 | cmd.kill()?; 242 | 243 | match cmd.wait_with_output() { 244 | Ok(output) => { 245 | println!( 246 | "stderr was: `{}`", 247 | String::from_utf8_lossy(&output.stderr).to_string() 248 | ); 249 | let stdout = String::from_utf8_lossy(&output.stdout).to_string(); 250 | let mut combined = first_file_contents.to_owned(); 251 | combined.push_str(second_file_contents); 252 | assert_eq!(combined, stdout); 253 | } 254 | Err(e) => println!("Error: {}", e), 255 | } 256 | Ok(()) 257 | } 258 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod errors; 2 | pub mod opts; 3 | 4 | use std::{ 5 | collections::VecDeque, 6 | io::{BufRead, ErrorKind, Write}, 7 | path::Path, 8 | time::Duration, 9 | }; 10 | 11 | use errors::HeadTailError; 12 | use log::trace; 13 | use notify::{event::EventKind, Watcher}; 14 | 15 | use opts::Opts; 16 | 17 | fn careful_write(writer: &mut dyn Write, line: &str) -> Result<(), HeadTailError> { 18 | if let Err(e) = writer.write(line.as_bytes()) { 19 | if e.kind() == ErrorKind::BrokenPipe { 20 | return Ok(()); 21 | } else { 22 | return Err(e.into()); 23 | } 24 | } 25 | Ok(()) 26 | } 27 | 28 | pub fn headtail(opts: &Opts) -> Result<(), HeadTailError> { 29 | let mut reader = opts.input_stream()?; 30 | let mut writer = opts.output_stream()?; 31 | 32 | // Do our head/tail thing 33 | let mut tail_buffer: VecDeque = VecDeque::with_capacity(opts.tail + 1); 34 | let mut line_num = 0; 35 | let mut omitted = 0; 36 | loop { 37 | let mut line = String::new(); 38 | match reader.read_line(&mut line)? { 39 | 0 => { 40 | if opts.separator && !tail_buffer.is_empty() && omitted > 0 { 41 | careful_write( 42 | &mut writer, 43 | &format!("[... {} line(s) omitted ...]\n", omitted), 44 | )?; 45 | } 46 | for tail_line in &tail_buffer { 47 | careful_write(&mut writer, tail_line)?; 48 | } 49 | let _ = writer.flush(); 50 | break; 51 | } 52 | _ => { 53 | if opts.head > line_num { 54 | line_num += 1; 55 | trace!(target: "head line", "read line: {}", line.trim_end()); 56 | careful_write(&mut writer, &line)?; 57 | let _ = writer.flush(); 58 | } else { 59 | tail_buffer.push_back(line); 60 | if tail_buffer.len() > opts.tail { 61 | tail_buffer.pop_front(); 62 | omitted += 1; 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | // Keep following 70 | // 71 | // To avoid wasted CPU cycles, we can use a file system watcher (e.g. 72 | // `inotify(7)` on Linux). 73 | // 74 | // The `notify` crate provides several optimized file system watchers using 75 | // functionality built into operating systems. Should an optimized watcher 76 | // not be available, `notify` will default to a polling watcher. 77 | if opts.follow && opts.filename.is_some() { 78 | // Use a channel to send lines read back to the main thread 79 | // TODO: 1024 is an arbitrary number. Let's benchmark different values. 80 | let (tx, rx) = crossbeam_channel::bounded::>(1024); 81 | 82 | // If using a polling watcher, respect the `--sleep-interval` argument. 83 | let sleep_interval = Duration::from_secs_f64(opts.sleep_interval); 84 | let config = notify::Config::default().with_poll_interval(sleep_interval); 85 | 86 | // Setup the file watcher 87 | let opts2 = opts.clone(); // TODO: Refactor so we don't need to clone opts 88 | let mut watcher = notify::RecommendedWatcher::new( 89 | move |res: notify::Result| { 90 | match res { 91 | Ok(event) => { 92 | match event.kind { 93 | EventKind::Any => trace!("EventKind::Any encountered"), 94 | EventKind::Modify(m) => { 95 | // TODO: Should(can?) we handle the truncation of a file? On macOS 96 | // file truncation shows up as an EventKind::Modify(Metadata(Any)), 97 | // which seems like could apply to events other than truncation. 98 | trace!(target: "following file", "modified: {:?}", m); 99 | let mut line = String::new(); 100 | match reader.read_line(&mut line) { 101 | Ok(0) => {} 102 | Ok(_) => { 103 | // If the main thread has closed the channel, it will soon cause 104 | // us to exit cleanly, so we can ignore the error. 105 | let _ = tx.send(Ok(line)); 106 | } 107 | Err(e) => { 108 | // Can ignore channel send error for the same reason as above... 109 | trace!(target: "following file", "normal read error"); 110 | let _ = tx.send(Err(e.into())); 111 | } 112 | } 113 | } 114 | EventKind::Create(_) => { 115 | trace!(target: "following file", "detected possible file (re)creation"); 116 | // The file has been recreated, so we need to re-open the input stream, 117 | // read *everything* that is in the new file, and resume tailing. 118 | let result = opts2.input_stream(); 119 | match result { 120 | Ok(new_reader) => { 121 | trace!(target: "following file", "succeeded reopening file"); 122 | reader = new_reader; 123 | } 124 | Err(e) => { 125 | if let ErrorKind::NotFound = e.kind() { 126 | trace!(target: "following file", "cannot find file...aborting reopen"); 127 | return; 128 | } 129 | // Can ignore channel send error for the same reason as above... 130 | let _ = tx.send(Err(e.into())); 131 | } 132 | } 133 | loop { 134 | let mut line = String::new(); 135 | match reader.read_line(&mut line) { 136 | Ok(0) => { 137 | trace!(target: "following file", "catchup done"); 138 | break; 139 | } 140 | Ok(_) => { 141 | trace!(target: "following file", "catchup read line: {}", line.trim_end()); 142 | // If the main thread has closed the channel, it will soon cause us to 143 | // exit cleanly, so we can ignore the error. 144 | let _ = tx.send(Ok(line)); 145 | } 146 | Err(e) => { 147 | // Can ignore sending error for same reason as 👆🏻 148 | let _ = tx.send(Err(e.into())); 149 | break; 150 | } 151 | } 152 | } 153 | } 154 | EventKind::Remove(r) => { 155 | trace!(target: "following file", "file removed: {:?}", r) 156 | } 157 | // We are being explicit about the variants we are ignoring just in case we 158 | // need to research them. 159 | EventKind::Access(_) => {} 160 | EventKind::Other => { 161 | trace!(target: "following file", "EventKind::Other encountered") 162 | } 163 | }; 164 | } 165 | Err(e) => { 166 | let _ = tx.send(Err(e.into())); 167 | } 168 | } 169 | }, 170 | config, 171 | )?; 172 | 173 | // TODO: Figure out what to do about the possibility of a race condition between the initial 174 | // headtail and the following. See https://github.com/CleanCut/headtail/pull/17/files#r973220630 175 | watcher.watch( 176 | Path::new(opts.filename.as_ref().unwrap()), 177 | notify::RecursiveMode::NonRecursive, 178 | )?; 179 | 180 | // Loop over the lines sent from the `notify` watcher over a channel. This will block the 181 | // main thread without sleeping. 182 | for result in rx { 183 | let line = result?; 184 | careful_write(&mut writer, &line)?; 185 | let _ = writer.flush(); 186 | } 187 | } 188 | 189 | Ok(()) 190 | } 191 | --------------------------------------------------------------------------------