src/main.rs

use std::path::PathBuf;
use std::time::Duration;

use anyhow::{Context, Result};
use clap::Parser;
use tokio::signal;
use tokio::sync::mpsc;

use ripgrab::{FilterSet, RenderMode, Renderer, TailEvent, TailHandle};

/// Tail logs with ripgrep-style filters and structured field extraction.
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
    /// Files to follow. At least one is required.
    #[arg(required = true)]
    paths: Vec<PathBuf>,

    /// Include lines matching this regex (repeatable).
    #[arg(short = 'm', long = "match")]
    matches: Vec<String>,

    /// Drop lines matching this regex (repeatable).
    #[arg(short = 'v', long = "exclude")]
    excludes: Vec<String>,

    /// Named-capture regex for structured extraction.
    #[arg(short = 'x', long = "extract")]
    extract: Option<String>,

    /// Skip lines older than this duration (e.g. "10m", "2h").
    #[arg(long)]
    since: Option<humantime::Duration>,

    /// Do not follow the file after reading existing contents.
    #[arg(long)]
    no_follow: bool,

    /// Disable ANSI color output.
    #[arg(long, env = "NO_COLOR")]
    no_color: bool,

    /// Start with the last N lines of each file before following.
    #[arg(short = 'n', long = "lines", default_value_t = 0)]
    lines: usize,
}

#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> {
    let cli = Cli::parse();

    let filters = FilterSet::compile(&cli.matches, &cli.excludes, cli.extract.as_deref())
        .context("failed to compile filter rules")?;

    let mode = if cli.extract.is_some() {
        RenderMode::Table
    } else {
        RenderMode::Stream
    };
    let renderer = Renderer::new(mode, !cli.no_color);

    let (tx, mut rx) = mpsc::channel::<TailEvent>(1024);
    let mut handles = Vec::with_capacity(cli.paths.len());
    for path in &cli.paths {
        let handle = TailHandle::spawn(
            path.clone(),
            tx.clone(),
            cli.lines,
            cli.no_follow,
            cli.since.map(Duration::from),
        )
        .with_context(|| format!("could not open {}", path.display()))?;
        handles.push(handle);
    }
    drop(tx);

    let mut stdout = tokio::io::stdout();
    let pump = async {
        while let Some(event) = rx.recv().await {
            match event {
                TailEvent::Line { source, line } => {
                    if let Some(outcome) = filters.apply(&line) {
                        renderer.write(&mut stdout, &source, &line, &outcome).await?;
                    }
                }
                TailEvent::Rotation { source } => {
                    renderer.notice(&mut stdout, &source, "file rotated").await?;
                }
                TailEvent::Error { source, err } => {
                    renderer.notice(&mut stdout, &source, &format!("{err}")).await?;
                }
            }
        }
        Ok::<_, anyhow::Error>(())
    };

    tokio::select! {
        res = pump => {
            for h in handles { h.shutdown().await; }
            res
        }
        _ = signal::ctrl_c() => {
            for h in handles { h.shutdown().await; }
            Ok(())
        }
    }
}