pub(crate) mod search;
use std::io::Write;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
#[derive(Parser)]
pub struct LogsCommand {
#[command(subcommand)]
pub command: LogsSubcommands,
}
#[derive(Subcommand)]
pub enum LogsSubcommands {
Search(search::SearchCommand),
}
impl LogsCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
LogsSubcommands::Search(cmd) => cmd.execute().await,
}
}
}
pub(crate) struct LogRow<'a> {
pub timestamp: &'a str,
pub service: &'a str,
pub status: &'a str,
pub message: &'a str,
}
pub(crate) fn render_log_table(rows: &[LogRow<'_>], out: &mut dyn Write) -> Result<()> {
if rows.is_empty() {
writeln!(out, "No logs returned.").context("Failed to write empty-table message")?;
return Ok(());
}
let ts_width = "TIMESTAMP"
.len()
.max(rows.iter().map(|r| r.timestamp.len()).max().unwrap_or(0));
let service_width = "SERVICE"
.len()
.max(rows.iter().map(|r| r.service.len()).max().unwrap_or(0));
let status_width = "STATUS"
.len()
.max(rows.iter().map(|r| r.status.len()).max().unwrap_or(0));
let message_width = "MESSAGE"
.len()
.max(rows.iter().map(|r| r.message.len()).max().unwrap_or(0));
write_row(
out,
"TIMESTAMP",
"SERVICE",
"STATUS",
"MESSAGE",
ts_width,
service_width,
status_width,
message_width,
)?;
write_row(
out,
&"-".repeat(ts_width),
&"-".repeat(service_width),
&"-".repeat(status_width),
&"-".repeat(message_width),
ts_width,
service_width,
status_width,
message_width,
)?;
for row in rows {
write_row(
out,
row.timestamp,
row.service,
row.status,
row.message,
ts_width,
service_width,
status_width,
message_width,
)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn write_row(
out: &mut dyn Write,
timestamp: &str,
service: &str,
status: &str,
message: &str,
ts_w: usize,
service_w: usize,
status_w: usize,
message_w: usize,
) -> Result<()> {
writeln!(
out,
"{timestamp:<ts_w$} {service:<service_w$} {status:<status_w$} {message:<message_w$}"
)
.context("Failed to write log row")?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::cli::datadog::format::OutputFormat;
use crate::datadog::types::SortOrder;
struct FailAfter {
successes_remaining: usize,
sink: Vec<u8>,
}
impl FailAfter {
fn new(successes_remaining: usize) -> Self {
Self {
successes_remaining,
sink: Vec::new(),
}
}
}
impl Write for FailAfter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.successes_remaining == 0 {
return Err(std::io::Error::other("test forced write failure"));
}
self.sink.extend_from_slice(buf);
if buf.contains(&b'\n') {
self.successes_remaining -= 1;
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn render_table_writes_header_and_rows_aligned() {
let rows = [
LogRow {
timestamp: "2026-04-22T10:00:00.000Z",
service: "api",
status: "info",
message: "hello",
},
LogRow {
timestamp: "2026-04-22T10:00:01.000Z",
service: "worker",
status: "error",
message: "boom",
},
];
let mut buf = Vec::new();
render_log_table(&rows, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("TIMESTAMP"));
assert!(out.contains("SERVICE"));
assert!(out.contains("STATUS"));
assert!(out.contains("MESSAGE"));
assert!(out.contains("2026-04-22T10:00:00.000Z"));
assert!(out.contains("api"));
assert!(out.contains("worker"));
assert!(out.contains("error"));
assert!(out.contains("boom"));
assert_eq!(out.lines().count(), 4);
}
#[test]
fn render_table_empty_prints_message() {
let mut buf = Vec::new();
render_log_table(&[], &mut buf).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "No logs returned.\n");
}
#[test]
fn render_table_propagates_header_write_errors() {
let rows = [LogRow {
timestamp: "t",
service: "s",
status: "i",
message: "m",
}];
let err = render_log_table(&rows, &mut FailAfter::new(0)).unwrap_err();
assert!(err.to_string().contains("Failed to write"));
}
#[test]
fn render_table_propagates_separator_write_errors() {
let rows = [LogRow {
timestamp: "t",
service: "s",
status: "i",
message: "m",
}];
let err = render_log_table(&rows, &mut FailAfter::new(1)).unwrap_err();
assert!(err.to_string().contains("Failed to write"));
}
#[test]
fn render_table_propagates_data_row_write_errors() {
let rows = [LogRow {
timestamp: "t",
service: "s",
status: "i",
message: "m",
}];
let err = render_log_table(&rows, &mut FailAfter::new(2)).unwrap_err();
assert!(err.to_string().contains("Failed to write"));
}
#[test]
fn render_table_empty_propagates_write_errors() {
let err = render_log_table(&[], &mut FailAfter::new(0)).unwrap_err();
assert!(err.to_string().contains("empty-table message"));
}
#[test]
fn fail_after_flush_is_a_noop() {
let mut w = FailAfter::new(0);
w.flush().unwrap();
}
#[test]
fn logs_subcommands_search_variant() {
let cmd = LogsCommand {
command: LogsSubcommands::Search(search::SearchCommand {
filter: "*".into(),
from: "15m".into(),
to: "now".into(),
limit: 100,
sort: search::SortArg::TimestampDesc,
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, LogsSubcommands::Search(_)));
}
#[test]
fn sort_arg_maps_to_sort_order() {
assert_eq!(
search::SortArg::TimestampAsc.to_sort_order(),
SortOrder::TimestampAsc
);
assert_eq!(
search::SortArg::TimestampDesc.to_sort_order(),
SortOrder::TimestampDesc
);
}
}