use super::Format;
use crate::request_log::{LogRecord, RecordKind, Source};
pub fn render(rec: &LogRecord, raw: &str, format: Format) -> String {
match format {
Format::Json => raw.to_string(),
Format::Oneline => oneline(rec),
Format::Full => full(rec),
}
}
fn short_time(ts: &str) -> &str {
let after_t = ts.split_once('T').map_or(ts, |(_, t)| t);
let end = after_t
.find(['Z', '+'])
.or_else(|| after_t.rfind('-'))
.unwrap_or(after_t.len());
&after_t[..end]
}
fn source_str(source: Option<Source>) -> &'static str {
match source {
Some(Source::Cli) => "cli",
Some(Source::Mcp) => "mcp",
Some(Source::Daemon) => "daemon",
Some(Source::Unknown) | None => "-",
}
}
fn oneline(rec: &LogRecord) -> String {
let time = short_time(&rec.timestamp);
match rec.kind {
RecordKind::Http => {
let service = rec.service.as_deref().unwrap_or("-");
let method = rec.method.as_deref().unwrap_or("-");
let status = rec
.status_code
.map_or_else(|| "ERR".to_string(), |c| c.to_string());
let elapsed = rec
.elapsed_ms
.map_or_else(String::new, |ms| format!("{ms}ms"));
let url = rec.url.as_deref().unwrap_or("");
let daemon = if rec.via_daemon { " [daemon]" } else { "" };
let err = rec
.error
.as_deref()
.map_or_else(String::new, |e| format!(" error={e}"));
format!(
"{time} http {service:<14} {method:<6} {status:<4} {elapsed:>7} {url}{daemon}{err}"
)
}
RecordKind::Invocation | RecordKind::Unknown => {
let source = source_str(rec.source);
let command = if rec.command.is_empty() {
rec.mcp_tool.clone().unwrap_or_else(|| "-".to_string())
} else {
rec.command.join(" ")
};
let exit = rec
.exit_code
.map_or_else(String::new, |c| format!(" exit={c}"));
let dur = rec
.duration_ms
.map_or_else(String::new, |ms| format!(" {ms}ms"));
format!("{time} inv {source:<14} {command}{exit}{dur}")
}
}
}
fn full(rec: &LogRecord) -> String {
match serde_json::to_string_pretty(rec) {
Ok(pretty) => format!("{pretty}\n"),
Err(_) => oneline(rec),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn short_time_extracts_clock() {
assert_eq!(short_time("2026-06-22T12:34:56.789Z"), "12:34:56.789");
assert_eq!(short_time("2026-06-22T01:02:03.004+10:00"), "01:02:03.004");
}
#[test]
fn json_format_is_verbatim() {
let rec = LogRecord::default();
let raw = r#"{"id":"x","kind":"http"}"#;
assert_eq!(render(&rec, raw, Format::Json), raw);
}
#[test]
fn oneline_http_includes_key_fields() {
let mut rec = LogRecord {
kind: RecordKind::Http,
timestamp: "2026-06-22T12:34:56.789Z".to_string(),
service: Some("jira".to_string()),
method: Some("GET".to_string()),
status_code: Some(200),
elapsed_ms: Some(42),
url: Some("https://x/rest/api/3/issue/A-1".to_string()),
..LogRecord::default()
};
let line = render(&rec, "", Format::Oneline);
assert!(line.contains("http"));
assert!(line.contains("jira"));
assert!(line.contains("GET"));
assert!(line.contains("200"));
assert!(line.contains("42ms"));
assert!(line.contains("issue/A-1"));
rec.status_code = None;
rec.error = Some("timeout".to_string());
let line = render(&rec, "", Format::Oneline);
assert!(line.contains("ERR"));
assert!(line.contains("error=timeout"));
}
#[test]
fn oneline_invocation_shows_command() {
let rec = LogRecord {
kind: RecordKind::Invocation,
timestamp: "2026-06-22T12:34:56.789Z".to_string(),
command: vec!["jira".to_string(), "read".to_string()],
source: Some(Source::Cli),
exit_code: Some(0),
duration_ms: Some(120),
..LogRecord::default()
};
let line = render(&rec, "", Format::Oneline);
assert!(line.contains("inv"));
assert!(line.contains("cli"));
assert!(line.contains("jira read"));
assert!(line.contains("exit=0"));
assert!(line.contains("120ms"));
}
#[test]
fn oneline_invocation_falls_back_to_mcp_tool() {
let rec = LogRecord {
kind: RecordKind::Invocation,
timestamp: "2026-06-22T12:00:00.000Z".to_string(),
source: Some(Source::Mcp),
mcp_tool: Some("jira_read".to_string()),
..LogRecord::default()
};
let line = render(&rec, "", Format::Oneline);
assert!(line.contains("mcp"));
assert!(line.contains("jira_read"));
}
#[test]
fn oneline_http_flags_daemon_and_handles_unknown_kind() {
let rec = LogRecord {
kind: RecordKind::Http,
timestamp: "2026-06-22T12:00:00.000Z".to_string(),
service: Some("snowflake".to_string()),
method: Some("POST".to_string()),
status_code: Some(200),
via_daemon: true,
..LogRecord::default()
};
assert!(render(&rec, "", Format::Oneline).contains("[daemon]"));
let unknown = LogRecord {
kind: RecordKind::Unknown,
..LogRecord::default()
};
let _ = render(&unknown, "", Format::Oneline);
}
#[test]
fn full_format_is_pretty_json() {
let rec = LogRecord {
kind: RecordKind::Http,
service: Some("jira".to_string()),
..LogRecord::default()
};
let out = render(&rec, "", Format::Full);
assert!(out.contains("\"service\": \"jira\""));
assert!(out.contains('\n'), "pretty output spans multiple lines");
}
#[test]
fn short_time_handles_negative_offset_and_no_t() {
assert_eq!(short_time("2026-06-22T01:02:03-05:00"), "01:02:03");
assert_eq!(short_time("12:00:00"), "12:00:00");
}
#[test]
fn oneline_renders_daemon_and_absent_source() {
let daemon = LogRecord {
kind: RecordKind::Invocation,
timestamp: "2026-06-22T12:00:00.000Z".to_string(),
source: Some(Source::Daemon),
command: vec!["daemon".to_string(), "run".to_string()],
..LogRecord::default()
};
assert!(render(&daemon, "", Format::Oneline).contains("daemon"));
let none = LogRecord {
kind: RecordKind::Invocation,
command: vec!["x".to_string()],
..LogRecord::default()
};
let line = render(&none, "", Format::Oneline);
assert!(line.contains(" - "));
}
}