targrep 0.9.0

Search inside compressed log archives (.tgz, .tar.zst, .tar.xz, .tar.bz2, .tar.lz4) with ripgrep's flags and per-member line numbers
Documentation
mod helpers;

use std::io::Read;

fn collect(path: &std::path::Path) -> (Vec<String>, Vec<String>) {
    let rdr = trg::archive::open_decoded(path).unwrap();
    let mut names = Vec::new();
    let out = trg::archive::for_each_member(rdr, None, |name, r| {
        let mut s = String::new();
        r.read_to_string(&mut s).ok();
        names.push(format!("{name}={}", s.trim_end()));
        Ok(())
    });
    (names, out.errors)
}

#[test]
fn yields_regular_members_only() {
    let f = helpers::tgz("a.tgz", &[
        ("logs/a.log", b"alpha\n"),
        ("logs/b.log", b"beta\n"),
    ]);
    let (names, errs) = collect(&f.path);
    assert_eq!(names, vec!["logs/a.log=alpha", "logs/b.log=beta"]);
    assert!(errs.is_empty());
}

#[test]
fn skips_hardlink_members_entirely() {
    let f = helpers::tgz_with_hardlink("h.tgz");
    let (names, errs) = collect(&f.path);
    assert_eq!(names.len(), 1, "hardlink must not be yielded: {names:?}");
    assert!(names[0].starts_with("logs/real.log="));
    assert!(errs.is_empty(), "a skipped hardlink is not an error");
}

#[test]
fn long_member_names_survive_intact() {
    let f = helpers::tgz_with_long_name("l.tgz", 6);
    let (names, _) = collect(&f.path);
    assert_eq!(names.len(), 1);
    assert!(names[0].contains("averylongdirectorycomponent"));
    assert!(!names[0].contains("@LongLink"), "pseudo-member leaked into output");
    assert!(!names[0].contains("PaxHeader"), "pseudo-member leaked into output");
}

#[test]
fn nested_gz_member_is_decompressed_one_level() {
    let f = helpers::tgz_nested_gz("n.tgz", "logs/access.log.1.gz", b"NEEDLE nested\n");
    let (names, errs) = collect(&f.path);
    assert_eq!(names.len(), 1);
    assert!(names[0].contains("NEEDLE nested"), "got {names:?}");
    assert!(errs.is_empty());
}

#[test]
fn uncompressed_tar_also_works() {
    let f = helpers::tar_plain("a.tar", &[("logs/a.log", b"alpha\n")]);
    let (names, _) = collect(&f.path);
    assert_eq!(names, vec!["logs/a.log=alpha"]);
}

#[test]
fn plain_file_is_one_anonymous_member() {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path().join("access.log");
    std::fs::write(&p, b"plain line\n").unwrap();
    let rdr = trg::archive::open_decoded(&p).unwrap();
    let mut got = Vec::new();
    let out = trg::archive::for_each_member(rdr, None, |name, r| {
        let mut s = String::new();
        r.read_to_string(&mut s).ok();
        got.push((name.to_string(), s));
        Ok(())
    });
    assert_eq!(got.len(), 1);
    assert_eq!(got[0].0, "", "a plain file has no member name");
    assert!(out.errors.is_empty());
}

#[test]
fn truncated_archive_records_an_error_but_keeps_earlier_members() {
    let big = vec![b'x'; 300_000];
    let f = helpers::tgz_truncated("t.tgz", &[
        ("logs/first.log", b"NEEDLE early\n"),
        ("logs/second.log", &big),
    ], 0.5);
    let rdr = trg::archive::open_decoded(&f.path).unwrap();
    let mut names = Vec::new();
    let out = trg::archive::for_each_member(rdr, None, |name, r| {
        let mut sink = Vec::new();
        std::io::copy(&mut r.take(64), &mut sink)?;
        names.push(name.to_string());
        Ok(())
    });
    assert!(names.iter().any(|n| n == "logs/first.log"),
            "must keep members read before the truncation: {names:?}");
    assert!(!out.errors.is_empty(), "truncation must be recorded as an error");
}

#[test]
fn member_globs_filter_which_members_are_searched() {
    let f = helpers::tgz("g.tgz", &[
        ("logs/a.access.log", b"alpha\n"),
        ("logs/a.error.log", b"beta\n"),
    ]);
    let set = trg::source::build_globs(&["*.access.log".into()]).unwrap().unwrap();

    let rdr = trg::archive::open_decoded(&f.path).unwrap();
    let mut names = Vec::new();
    trg::archive::for_each_member(rdr, Some(&set), |name, _| {
        names.push(name.to_string());
        Ok(())
    });
    assert_eq!(names, vec!["logs/a.access.log"]);
}