Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 113 additions & 12 deletions src/cortex-cli/src/logs_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//! - Clear old logs

use anyhow::Result;
use clap::Parser;
use clap::{Parser, ValueEnum};
use serde::Serialize;
use std::path::PathBuf;

Expand All @@ -23,8 +23,8 @@ pub struct LogsCli {
pub follow: bool,

/// Filter by log level (error, warn, info, debug, trace)
#[arg(long, short = 'l')]
pub level: Option<String>,
#[arg(long, short = 'l', value_enum)]
pub level: Option<LogFilterLevel>,

/// Show logs from a specific session
#[arg(long, short = 's')]
Expand Down Expand Up @@ -56,6 +56,18 @@ struct LogFileInfo {
modified: String,
}

/// Valid levels for log filtering.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lower")]
pub enum LogFilterLevel {
Error,
#[value(alias = "warning")]
Warn,
Info,
Debug,
Trace,
}

/// Get the logs directory.
fn get_logs_dir() -> PathBuf {
dirs::cache_dir()
Expand All @@ -81,6 +93,43 @@ fn format_size(bytes: u64) -> String {
}
}

fn level_from_token(token: &str) -> Option<LogFilterLevel> {
match token.to_ascii_uppercase().as_str() {
"ERROR" => Some(LogFilterLevel::Error),
"WARN" | "WARNING" => Some(LogFilterLevel::Warn),
"INFO" => Some(LogFilterLevel::Info),
"DEBUG" => Some(LogFilterLevel::Debug),
"TRACE" => Some(LogFilterLevel::Trace),
_ => None,
}
}

fn is_timestamp_token(token: &str) -> bool {
matches!(token, "T" | "Z" | "UTC")
}

fn line_log_level(line: &str) -> Option<LogFilterLevel> {
for token in line.split(|c: char| !c.is_ascii_alphabetic()) {
if token.is_empty() {
continue;
}

if let Some(level) = level_from_token(token) {
return Some(level);
}

if !is_timestamp_token(token) {
return None;
}
}

None
}

fn line_matches_level(line: &str, level: LogFilterLevel) -> bool {
line_log_level(line) == Some(level)
}

impl LogsCli {
/// Run the logs command.
pub async fn run(self) -> Result<()> {
Expand Down Expand Up @@ -170,11 +219,10 @@ impl LogsCli {
let total_lines = lines.len();

// Apply level filter if specified
let filtered_lines: Vec<&str> = if let Some(ref level) = self.level {
let level_upper = level.to_uppercase();
let filtered_lines: Vec<&str> = if let Some(level) = self.level {
lines
.iter()
.filter(|line| line.to_uppercase().contains(&level_upper))
.filter(|line| line_matches_level(line, level))
.copied()
.collect()
} else {
Expand Down Expand Up @@ -228,7 +276,7 @@ impl LogsCli {
// Seek to end
reader.seek(SeekFrom::End(0))?;

let level_filter = self.level.clone();
let level_filter = self.level;

loop {
let mut line = String::new();
Expand All @@ -239,8 +287,8 @@ impl LogsCli {
}
Ok(_) => {
// Apply level filter if specified
if let Some(ref level) = level_filter
&& !line.to_uppercase().contains(&level.to_uppercase())
if let Some(level) = level_filter
&& !line_matches_level(&line, level)
{
continue;
}
Expand Down Expand Up @@ -517,7 +565,7 @@ mod tests {
let cli = LogsCli::parse_from(["logs", "-l", "error"]);
assert_eq!(
cli.level,
Some("error".to_string()),
Some(LogFilterLevel::Error),
"Level should be 'error'"
);
}
Expand All @@ -529,11 +577,36 @@ mod tests {
let cli = LogsCli::parse_from(["logs", "--level", "debug"]);
assert_eq!(
cli.level,
Some("debug".to_string()),
Some(LogFilterLevel::Debug),
"Level should be 'debug'"
);
}

#[test]
fn test_logs_cli_level_filter_rejects_invalid_values() {
use clap::Parser;

let err = LogsCli::try_parse_from(["logs", "--level", "rr"])
.expect_err("invalid level should be rejected");
let message = err.to_string();
assert!(
message.contains("invalid value") && message.contains("error"),
"unexpected error: {message}"
);
}

#[test]
fn test_logs_cli_level_filter_accepts_warning_alias() {
use clap::Parser;

let cli = LogsCli::parse_from(["logs", "--level", "warning"]);
assert_eq!(
cli.level,
Some(LogFilterLevel::Warn),
"warning should map to warn"
);
}

#[test]
fn test_logs_cli_session_short() {
use clap::Parser;
Expand Down Expand Up @@ -598,12 +671,40 @@ mod tests {
assert_eq!(cli.lines, 25, "Lines should be 25");
assert_eq!(
cli.level,
Some("warn".to_string()),
Some(LogFilterLevel::Warn),
"Level should be 'warn'"
);
assert!(cli.json, "JSON should be true");
}

#[test]
fn test_line_matches_level_uses_structured_level_token() {
assert!(line_matches_level(
"2026-05-17T12:00:00.000000Z ERROR cortex_cli: failed",
LogFilterLevel::Error
));
assert!(line_matches_level(
"[WARN] retrying request",
LogFilterLevel::Warn
));
assert!(line_matches_level(
"INFO cortex_cli::logs_cmd: ready",
LogFilterLevel::Info
));
}

#[test]
fn test_line_matches_level_ignores_message_substrings() {
assert!(!line_matches_level(
"2026-05-17T12:00:00.000000Z INFO cortex_cli: no error occurred",
LogFilterLevel::Error
));
assert!(!line_matches_level(
"arbitrary user message mentions ERROR but has no leading level",
LogFilterLevel::Error
));
}

// =========================================================================
// Tests for LogFileInfo serialization
// =========================================================================
Expand Down