175 lines
5.1 KiB
Rust
175 lines
5.1 KiB
Rust
//! log: a filtered view of the event journal — everything am did.
|
|
|
|
use super::*;
|
|
use crate::events::{self, Event};
|
|
use crate::output::print_json;
|
|
use serde::Serialize;
|
|
|
|
/// Filter journal events: agent, kind list, since date, and a max count.
|
|
/// Returns events in reverse chronological order (most recent first).
|
|
pub fn filter_events(
|
|
app: &App,
|
|
agent: Option<&str>,
|
|
kinds: Option<&str>,
|
|
since: Option<&str>,
|
|
limit: usize,
|
|
) -> Result<Vec<Event>> {
|
|
let mut events = events::read_events(&app.events_dir(), 0);
|
|
if let Some(a) = agent {
|
|
events.retain(|e| e.agent.as_deref() == Some(a));
|
|
}
|
|
if let Some(list) = kinds {
|
|
let wanted: Vec<&str> = list.split(',').map(|s| s.trim()).collect();
|
|
events.retain(|e| wanted.iter().any(|w| e.kind.as_str() == *w));
|
|
}
|
|
if let Some(s) = since {
|
|
let cut = parse_since(s)?;
|
|
events.retain(|e| {
|
|
chrono::DateTime::parse_from_rfc3339(&e.ts)
|
|
.map(|t| t >= cut)
|
|
.unwrap_or(false)
|
|
});
|
|
}
|
|
events.reverse(); // most recent first
|
|
events.truncate(limit);
|
|
Ok(events)
|
|
}
|
|
|
|
/// Live view: print matching events as they arrive (issue #38).
|
|
fn follow_loop(app: &App, agent: Option<&str>, kinds: Option<&str>, since: Option<&str>) -> Result<i32> {
|
|
app.log.info("following the journal (Ctrl-C to stop)");
|
|
let mut seen: usize = 0;
|
|
loop {
|
|
let events = filter_events(app, agent, kinds, since, 200)?;
|
|
let mut rendered = String::new();
|
|
for e in events.iter().rev() {
|
|
rendered.push_str(&e.ts);
|
|
rendered.push(' ');
|
|
rendered.push_str(e.kind.as_str());
|
|
if let Some(a) = &e.agent {
|
|
rendered.push(' ');
|
|
rendered.push_str(a);
|
|
}
|
|
rendered.push('\n');
|
|
}
|
|
let bytes = rendered.as_bytes();
|
|
if bytes.len() > seen {
|
|
print!("{}", text_after(&rendered, seen));
|
|
use std::io::Write;
|
|
let _ = std::io::stdout().flush();
|
|
}
|
|
seen = bytes.len();
|
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
}
|
|
}
|
|
|
|
pub fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::FixedOffset>> {
|
|
if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) {
|
|
return Ok(t);
|
|
}
|
|
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
|
.map_err(|_| anyhow!("invalid date '{s}' — expected YYYY-MM-DD or an RFC 3339 timestamp"))?
|
|
.and_hms_opt(0, 0, 0)
|
|
.map(|d| chrono::DateTime::from_naive_utc_and_offset(d, chrono::FixedOffset::east_opt(0).unwrap()))
|
|
.ok_or_else(|| anyhow!("invalid date '{s}'"))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct LogRow {
|
|
pub ts: String,
|
|
pub kind: String,
|
|
pub agent: Option<String>,
|
|
pub pid: Option<u32>,
|
|
pub exit_code: Option<i32>,
|
|
pub duration_s: Option<u64>,
|
|
pub session: Option<String>,
|
|
}
|
|
|
|
impl From<&Event> for LogRow {
|
|
fn from(e: &Event) -> Self {
|
|
LogRow {
|
|
ts: e.ts.clone(),
|
|
kind: e.kind.as_str().to_string(),
|
|
agent: e.agent.clone(),
|
|
pid: e.pid,
|
|
exit_code: e.exit_code,
|
|
duration_s: e.duration_s,
|
|
session: e.session.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rows for the web API (issue #54): same shape as `am log --json`.
|
|
pub fn rows(
|
|
app: &App,
|
|
agent: Option<&str>,
|
|
kinds: Option<&str>,
|
|
since: Option<&str>,
|
|
limit: usize,
|
|
) -> Result<Vec<LogRow>> {
|
|
Ok(filter_events(app, agent, kinds, since, limit)?
|
|
.iter()
|
|
.map(LogRow::from)
|
|
.collect())
|
|
}
|
|
|
|
/// Chars of text after byte offset seen (UTF-8 safe).
|
|
pub fn text_after(text: &str, seen: usize) -> String {
|
|
text.char_indices()
|
|
.filter(|(i, _)| *i >= seen)
|
|
.map(|(_, c)| c)
|
|
.collect()
|
|
}
|
|
|
|
pub fn run(
|
|
app: &App,
|
|
agent: Option<&str>,
|
|
kinds: Option<&str>,
|
|
since: Option<&str>,
|
|
limit: usize,
|
|
follow: bool,
|
|
) -> Result<i32> {
|
|
if follow {
|
|
return follow_loop(app, agent, kinds, since);
|
|
}
|
|
let events = filter_events(app, agent, kinds, since, limit)?;
|
|
if app.json() {
|
|
let rows: Vec<LogRow> = events.iter().map(LogRow::from).collect();
|
|
print_json(&rows);
|
|
return Ok(0);
|
|
}
|
|
for e in &events {
|
|
let mut parts = vec![e.ts.clone(), e.kind.as_str().to_string()];
|
|
if let Some(a) = &e.agent {
|
|
parts.push(a.clone());
|
|
}
|
|
if let Some(p) = e.pid {
|
|
parts.push(format!("pid={p}"));
|
|
}
|
|
if let Some(c) = e.exit_code {
|
|
parts.push(format!("exit={c}"));
|
|
}
|
|
if let Some(d) = e.duration_s {
|
|
parts.push(format!("dur={d}s"));
|
|
}
|
|
println!("{}", parts.join(" "));
|
|
}
|
|
if events.is_empty() {
|
|
app.log.info("no matching events in the journal");
|
|
}
|
|
Ok(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_since_accepts_date_and_timestamp() {
|
|
let d = parse_since("2026-08-01").unwrap();
|
|
assert_eq!(d.format("%Y-%m-%d").to_string(), "2026-08-01");
|
|
assert!(parse_since("2026-08-01T00:00:00Z").is_ok());
|
|
assert!(parse_since("hier").is_err());
|
|
}
|
|
}
|