use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::event::{EventKind, SessionRow, StoredEvent};
pub const MIN_WRAP_DURATION: chrono::Duration = chrono::Duration::hours(2);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WrapReport {
pub schema: u32,
pub session_ulid: String,
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
pub duration_secs: u64,
pub total_events: u64,
pub event_counts: EventCounts,
pub top_commands: Vec<CommandCount>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct EventCounts {
pub prompt: u64,
pub command: u64,
pub system: u64,
pub warn: u64,
pub alert: u64,
pub mode_change: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandCount {
pub name: String,
pub count: u64,
}
const SCHEMA: u32 = 1;
const TOP_COMMANDS_CAP: usize = 10;
#[must_use]
pub fn generate(
session: &SessionRow,
events: &[StoredEvent],
ended_at: DateTime<Utc>,
) -> WrapReport {
let duration_secs = (ended_at - session.started_at)
.num_seconds()
.max(0)
.cast_unsigned();
let mut counts = EventCounts::default();
for e in events {
match e.kind {
EventKind::Prompt => counts.prompt += 1,
EventKind::Command => counts.command += 1,
EventKind::System => counts.system += 1,
EventKind::Warn => counts.warn += 1,
EventKind::Alert => counts.alert += 1,
EventKind::ModeChange => counts.mode_change += 1,
}
}
WrapReport {
schema: SCHEMA,
session_ulid: session.ulid.clone(),
started_at: session.started_at,
ended_at,
duration_secs,
total_events: u64::try_from(events.len()).unwrap_or(u64::MAX),
event_counts: counts,
top_commands: compute_top_commands(events),
}
}
fn compute_top_commands(events: &[StoredEvent]) -> Vec<CommandCount> {
use std::collections::HashMap;
let mut tally: HashMap<String, u64> = HashMap::new();
for e in events {
if e.kind != EventKind::Prompt {
continue;
}
let trimmed = e.text.trim();
if !trimmed.starts_with('/') {
continue;
}
let first_token = trimmed.split_whitespace().next().unwrap_or(trimmed);
*tally.entry(first_token.to_string()).or_insert(0) += 1;
}
let mut rows: Vec<CommandCount> = tally
.into_iter()
.map(|(name, count)| CommandCount { name, count })
.collect();
rows.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
rows.truncate(TOP_COMMANDS_CAP);
rows
}
#[must_use]
pub fn should_wrap(session: &SessionRow, events: &[StoredEvent], ended_at: DateTime<Utc>) -> bool {
let duration = ended_at - session.started_at;
if duration < MIN_WRAP_DURATION {
return false;
}
events.iter().any(|e| e.kind == EventKind::Prompt)
}
pub fn write_wrap(dir: &Path, report: &WrapReport) -> Result<PathBuf, crate::SessionError> {
std::fs::create_dir_all(dir)?;
let final_path = dir.join(format!("{}.json", report.session_ulid));
let tmp_path = dir.join(format!("{}.json.tmp", report.session_ulid));
let json = serde_json::to_vec_pretty(report)?;
std::fs::write(&tmp_path, &json)?;
std::fs::rename(&tmp_path, &final_path)?;
Ok(final_path)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone};
fn row(started_at: DateTime<Utc>) -> SessionRow {
SessionRow {
id: 1,
ulid: "01HTEST".into(),
started_at,
ended_at: None,
engine_base_url: Some("https://example".into()),
cli_version: "0.3.0-test".into(),
parent_ulid: None,
}
}
fn ev(
session_id: i64,
seq: i64,
at: DateTime<Utc>,
kind: EventKind,
text: &str,
) -> StoredEvent {
StoredEvent {
id: seq,
session_id,
seq,
at,
kind,
text: text.into(),
}
}
#[test]
fn generate_counts_every_kind_and_computes_duration() {
let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
let end = start + Duration::hours(3);
let r = row(start);
let evs = vec![
ev(1, 1, start, EventKind::Prompt, "/status"),
ev(1, 2, start, EventKind::Command, "engine: OK"),
ev(1, 3, start, EventKind::Prompt, "/status BTC"),
ev(1, 4, start, EventKind::System, "poller started"),
ev(1, 5, start, EventKind::Warn, "slow response"),
ev(1, 6, start, EventKind::Alert, "engine unreachable"),
ev(1, 7, start, EventKind::ModeChange, "positions"),
ev(1, 8, start, EventKind::Prompt, "/risk"),
];
let w = generate(&r, &evs, end);
assert_eq!(w.schema, SCHEMA);
assert_eq!(w.session_ulid, "01HTEST");
assert_eq!(w.started_at, start);
assert_eq!(w.ended_at, end);
assert_eq!(w.duration_secs, 3 * 3600);
assert_eq!(w.total_events, 8);
assert_eq!(w.event_counts.prompt, 3);
assert_eq!(w.event_counts.command, 1);
assert_eq!(w.event_counts.system, 1);
assert_eq!(w.event_counts.warn, 1);
assert_eq!(w.event_counts.alert, 1);
assert_eq!(w.event_counts.mode_change, 1);
}
#[test]
fn top_commands_strips_args_and_sorts_stably() {
let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
let r = row(start);
let evs = vec![
ev(1, 1, start, EventKind::Prompt, "/status"),
ev(1, 2, start, EventKind::Prompt, "/status BTC"),
ev(1, 3, start, EventKind::Prompt, "/status ETH"),
ev(1, 4, start, EventKind::Prompt, "/risk"),
ev(1, 5, start, EventKind::Prompt, "/regime"),
ev(1, 6, start, EventKind::Prompt, "/regime BTC"),
ev(1, 7, start, EventKind::Prompt, "what is going on"),
ev(1, 8, start, EventKind::System, "/auto-line"),
];
let w = generate(&r, &evs, start + Duration::hours(3));
let top: Vec<(&str, u64)> = w
.top_commands
.iter()
.map(|c| (c.name.as_str(), c.count))
.collect();
assert_eq!(top, vec![("/status", 3), ("/regime", 2), ("/risk", 1)]);
}
#[test]
fn top_commands_tie_breaks_alphabetically() {
let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
let r = row(start);
let evs = vec![
ev(1, 1, start, EventKind::Prompt, "/zebra"),
ev(1, 2, start, EventKind::Prompt, "/alpha"),
ev(1, 3, start, EventKind::Prompt, "/mango"),
];
let w = generate(&r, &evs, start + Duration::hours(3));
let names: Vec<&str> = w.top_commands.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["/alpha", "/mango", "/zebra"]);
}
#[test]
fn top_commands_caps_at_n() {
let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
let r = row(start);
let mut evs = Vec::new();
let cap = i64::try_from(TOP_COMMANDS_CAP).expect("cap fits in i64");
for i in 0..(cap + 5) {
evs.push(ev(
1,
i + 1,
start,
EventKind::Prompt,
&format!("/cmd{i:02}"),
));
}
let w = generate(&r, &evs, start + Duration::hours(3));
assert_eq!(w.top_commands.len(), TOP_COMMANDS_CAP);
}
#[test]
fn should_wrap_respects_duration_floor() {
let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
let r = row(start);
let evs = vec![ev(1, 1, start, EventKind::Prompt, "/status")];
assert!(!should_wrap(&r, &evs, start + Duration::minutes(119)));
assert!(should_wrap(&r, &evs, start + Duration::hours(2)));
assert!(should_wrap(&r, &evs, start + Duration::hours(5)));
}
#[test]
fn should_wrap_requires_at_least_one_prompt() {
let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
let r = row(start);
let evs: Vec<_> = (0..10)
.map(|i| ev(1, i + 1, start, EventKind::System, "poll"))
.collect();
assert!(!should_wrap(&r, &evs, start + Duration::hours(3)));
}
#[test]
fn should_wrap_handles_clock_going_backwards() {
let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
let r = row(start);
let evs = vec![ev(1, 1, start, EventKind::Prompt, "/status")];
assert!(!should_wrap(&r, &evs, start - Duration::minutes(5)));
}
#[test]
fn write_wrap_round_trips_through_disk() {
use std::fs;
let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
let r = row(start);
let evs = vec![ev(1, 1, start, EventKind::Prompt, "/status")];
let report = generate(&r, &evs, start + Duration::hours(3));
let dir = std::env::temp_dir().join(format!("zero-wrap-test-{}", report.session_ulid));
let _ = fs::remove_dir_all(&dir);
let path = write_wrap(&dir, &report).expect("write");
assert!(path.ends_with("01HTEST.json"));
let bytes = fs::read(&path).expect("read");
let back: WrapReport = serde_json::from_slice(&bytes).expect("parse");
assert_eq!(back, report);
let _ = fs::remove_dir_all(&dir);
}
}