use anyhow::Result;
use std::path::PathBuf;
use inklog::support::query::{LogEntry, QueryOptions, query_exit_code, query_paths};
#[derive(Debug, Clone, Default)]
pub struct QueryArgs {
pub path: Vec<PathBuf>,
pub since: Option<String>,
pub until: Option<String>,
pub level: Option<String>,
pub grep: Option<String>,
pub limit: usize,
pub key_env: Option<String>,
pub json: bool,
}
fn parse_time(raw: &str, flag: &str) -> Result<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(raw)
.map(|t| t.with_timezone(&chrono::Utc))
.map_err(|e| anyhow::anyhow!("invalid --{flag} '{}': expected RFC3339 ({e})", raw))
}
pub fn run_query(args: &QueryArgs) -> Result<i32> {
if args.path.is_empty() {
anyhow::bail!("query requires at least one --path (file or directory)");
}
let opts = QueryOptions {
since: args
.since
.as_deref()
.map(|s| parse_time(s, "since"))
.transpose()?,
until: args
.until
.as_deref()
.map(|s| parse_time(s, "until"))
.transpose()?,
level: args.level.clone(),
keyword: args.grep.clone(),
limit: args.limit,
};
let entries = query_paths(&args.path, &opts, args.key_env.as_deref())?;
if args.json {
let payload: Vec<&LogEntry> = entries.iter().collect();
println!("{}", serde_json::to_string(&payload)?);
} else {
for e in &entries {
println!(
"{} [{}] {} - {} ({})",
e.timestamp
.map(|t| t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
.unwrap_or_else(|| "-".to_string()),
e.level,
e.target,
e.message,
e.source.display()
);
}
}
Ok(query_exit_code(&entries))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_file(dir: &std::path::Path) -> PathBuf {
let p = dir.join("app.log");
std::fs::write(
&p,
"2026-09-11T01:00:00.123Z [INFO] app::boot - started\n\
2026-09-11T02:00:00.000Z [ERROR] app::db - connection refused\n",
)
.unwrap();
p
}
#[test]
fn test_query_json_output_and_exit_codes() {
let dir = tempfile::tempdir().unwrap();
let path = sample_file(dir.path());
let args = QueryArgs {
path: vec![path.clone()],
json: true,
..Default::default()
};
let code = run_query(&args).unwrap();
assert_eq!(code, 0);
let args = QueryArgs {
path: vec![path.clone()],
grep: Some("no-such-keyword".to_string()),
..Default::default()
};
let code = run_query(&args).unwrap();
assert_eq!(code, 2);
}
#[test]
fn test_query_rejects_empty_paths_and_bad_time() {
let args = QueryArgs::default();
assert!(run_query(&args).is_err(), "no --path must be an error");
let args = QueryArgs {
path: vec![PathBuf::from("/tmp")],
since: Some("not-a-time".to_string()),
..Default::default()
};
let err = run_query(&args).unwrap_err().to_string();
assert!(
err.contains("--since"),
"error must name the offending flag"
);
}
#[test]
fn test_query_error_exit_on_missing_file_only() {
let args = QueryArgs {
path: vec![PathBuf::from("/nonexistent/inklog/query/dir")],
..Default::default()
};
let code = run_query(&args).unwrap();
assert_eq!(code, 2);
}
}