use std::io::Write;
use anyhow::{bail, Context, Result};
use camino::Utf8PathBuf;
use doiget_core::provenance::{verify_all, VerifyIssueKind};
use super::fetch::CliExit;
#[allow(clippy::print_stderr)]
fn print_err(args: std::fmt::Arguments<'_>) {
eprintln!("{args}");
}
pub fn run(verify_flag: bool, mode: super::output::OutputMode) -> Result<()> {
if !verify_flag {
print_err(format_args!(
"error: doiget audit-log: --verify is required (Phase 1 ships only \
--verify; --since / --source / --session land later)"
));
return Err(anyhow::Error::new(CliExit(2)));
}
let log_path = resolve_log_path()?;
let segments = verify_all(&log_path)
.with_context(|| format!("failed to read provenance log at {log_path}"))?;
let total_rows: usize = segments.iter().map(|(_, r)| r.total_rows).sum();
let total_ok: usize = segments.iter().map(|(_, r)| r.ok_rows).sum();
let total_issues: usize = segments.iter().map(|(_, r)| r.errors.len()).sum();
let multi = segments.len() > 1;
if mode == super::output::OutputMode::Json {
#[derive(serde::Serialize)]
struct SegmentSummary<'a> {
name: &'a str,
rows: usize,
ok: usize,
issues: usize,
}
#[derive(serde::Serialize)]
struct IssueRecord<'a> {
segment: &'a str,
line: usize,
kind: &'static str,
message: &'a str,
}
#[derive(serde::Serialize)]
struct Report<'a> {
total_rows: usize,
total_ok: usize,
total_issues: usize,
segments: Vec<SegmentSummary<'a>>,
issues: Vec<IssueRecord<'a>>,
}
let mut segs: Vec<SegmentSummary> = Vec::with_capacity(segments.len());
let mut issues: Vec<IssueRecord> = Vec::new();
for (path, report) in &segments {
let seg = path.file_name().unwrap_or(path.as_str());
segs.push(SegmentSummary {
name: seg,
rows: report.total_rows,
ok: report.ok_rows,
issues: report.errors.len(),
});
for issue in &report.errors {
let kind = kind_label(issue.kind);
issues.push(IssueRecord {
segment: seg,
line: issue.line,
kind,
message: &issue.message,
});
}
}
let report = Report {
total_rows,
total_ok,
total_issues,
segments: segs,
issues,
};
let s =
serde_json::to_string_pretty(&report).context("serialize audit-log report to JSON")?;
let stdout = std::io::stdout();
let mut out = stdout.lock();
writeln!(out, "{s}").context("failed to write audit-log JSON to stdout")?;
} else if mode != super::output::OutputMode::Quiet {
let stdout = std::io::stdout();
let mut out = stdout.lock();
writeln!(out, "audit-log verify: {total_rows} rows")
.context("failed to write header to stdout")?;
writeln!(out, " ok: {total_ok}").context("failed to write ok-row count to stdout")?;
writeln!(out, " issues: {total_issues}")
.context("failed to write issue count to stdout")?;
for (path, report) in &segments {
let seg = path.file_name().unwrap_or(path.as_str());
if multi {
writeln!(
out,
" segment {}: {} rows, {} ok, {} issues",
seg,
report.total_rows,
report.ok_rows,
report.errors.len()
)
.context("failed to write segment summary to stdout")?;
}
for issue in &report.errors {
let kind = kind_label(issue.kind);
if multi {
writeln!(
out,
" [{}] line {}: {} — {}",
seg, issue.line, kind, issue.message
)
} else {
writeln!(out, " line {}: {} — {}", issue.line, kind, issue.message)
}
.context("failed to write issue line to stdout")?;
}
}
}
if total_issues == 0 {
Ok(())
} else {
bail!(
"audit-log: {} chain issue(s) detected across {} segment(s) — see stdout for details",
total_issues,
segments.len()
)
}
}
fn kind_label(k: VerifyIssueKind) -> &'static str {
match k {
VerifyIssueKind::ParseError => "parse",
VerifyIssueKind::PrevHashMismatch => "prev-hash",
VerifyIssueKind::ThisHashMismatch => "this-hash",
VerifyIssueKind::SequenceJump => "sequence",
_ => "other",
}
}
fn resolve_log_path() -> Result<Utf8PathBuf> {
if let Ok(s) = std::env::var("DOIGET_LOG_PATH") {
if !s.is_empty() {
return Ok(Utf8PathBuf::from(s));
}
}
let cfg = Utf8PathBuf::try_from(
dirs::config_dir().ok_or_else(|| anyhow::anyhow!("no config dir on this platform"))?,
)
.context("config directory path is not valid UTF-8")?;
Ok(cfg.join("doiget").join("access.jsonl"))
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
use super::*;
use camino::Utf8PathBuf;
use tempfile::TempDir;
use doiget_core::provenance::{Capability, LogEvent, LogResult, ProvenanceLog, RowInput};
#[test]
fn kind_label_covers_every_known_variant() {
let known: &[(VerifyIssueKind, &str)] = &[
(VerifyIssueKind::ParseError, "parse"),
(VerifyIssueKind::PrevHashMismatch, "prev-hash"),
(VerifyIssueKind::ThisHashMismatch, "this-hash"),
(VerifyIssueKind::SequenceJump, "sequence"),
];
for (kind, expected) in known {
let got = kind_label(*kind);
assert_eq!(
got, *expected,
"VerifyIssueKind variant fell through to wildcard: {kind:?}"
);
assert_ne!(got, "other", "known variant {kind:?} must not degrade");
}
}
struct EnvGuard {
var: &'static str,
prior: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn set(var: &'static str, value: &str) -> Self {
let prior = std::env::var_os(var);
std::env::set_var(var, value);
EnvGuard { var, prior }
}
fn unset(var: &'static str) -> Self {
let prior = std::env::var_os(var);
std::env::remove_var(var);
EnvGuard { var, prior }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.prior {
Some(v) => std::env::set_var(self.var, v),
None => std::env::remove_var(self.var),
}
}
}
fn tmp_dir_utf8(dir: &TempDir) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("temp dir path must be UTF-8")
}
#[test]
#[serial_test::serial]
fn run_without_verify_flag_errors() {
let _g = EnvGuard::unset("DOIGET_LOG_PATH");
let err = run(false, crate::commands::output::OutputMode::Human)
.expect_err("--verify must be required in Phase 1");
let cli_exit = err
.downcast_ref::<CliExit>()
.expect("missing --verify must carry a CliExit (issue #149)");
assert_eq!(
cli_exit.0, 2,
"missing required flag is misuse → exit 2, not the generic exit 1"
);
}
#[test]
#[serial_test::serial]
fn run_verifies_clean_log() {
let dir = TempDir::new().expect("tmp");
let path = tmp_dir_utf8(&dir).join("access.jsonl");
let log = ProvenanceLog::open(path.clone(), "01JCKZ7Q0000000000000000AB".to_string())
.expect("open log");
for _ in 0..3 {
log.append(RowInput {
event: LogEvent::Fetch,
result: LogResult::Ok,
capability: Capability::Oa,
ref_: None,
source: None,
error_code: None,
size_bytes: None,
license: None,
store_path: None,
canonical_digest: None,
})
.expect("append");
}
drop(log);
let _g = EnvGuard::set("DOIGET_LOG_PATH", path.as_str());
run(true, crate::commands::output::OutputMode::Human)
.expect("verify must pass on a clean log");
}
#[test]
#[serial_test::serial]
fn run_verifies_missing_log_as_clean() {
let dir = TempDir::new().expect("tmp");
let path = tmp_dir_utf8(&dir).join("never-created.jsonl");
assert!(!path.exists(), "precondition: log must not exist");
let _g = EnvGuard::set("DOIGET_LOG_PATH", path.as_str());
run(true, crate::commands::output::OutputMode::Human)
.expect("verify must succeed on missing log");
}
}