use anyhow::{Context, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::io;
use std::path::PathBuf;
use std::time::Duration;
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader as TokioBufReader};
use tokio::time::sleep;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
pub timestamp: String,
pub status: u16,
pub method: String,
pub url: String,
pub response_time: Option<u64>,
pub size: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct ApiResponse<T> {
success: bool,
data: Option<T>,
error: Option<String>,
}
#[allow(clippy::too_many_arguments)]
pub async fn execute_logs_command(
admin_url: Option<String>,
file: Option<PathBuf>,
follow: bool,
method: Option<String>,
path: Option<String>,
status: Option<u16>,
limit: Option<usize>,
json: bool,
config: Option<PathBuf>,
) -> Result<()> {
if let Some(file_path) = file {
return read_logs_from_file(file_path, follow, json).await;
}
if let Some(config_path) = config {
if let Ok(log_file) = get_log_file_from_config(&config_path).await {
if log_file.exists() {
return read_logs_from_file(log_file, follow, json).await;
}
}
}
let admin_url = admin_url.unwrap_or_else(|| "http://localhost:9080".to_string());
if follow {
stream_logs_from_api(&admin_url, method, path, status, json).await
} else {
fetch_logs_from_api(&admin_url, method, path, status, limit, json).await
}
}
async fn fetch_logs_from_api(
admin_url: &str,
method: Option<String>,
path: Option<String>,
status: Option<u16>,
limit: Option<usize>,
json: bool,
) -> Result<()> {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.context("Failed to create HTTP client")?;
let mut url = format!("{}/__mockforge/logs", admin_url);
let mut query_params = Vec::new();
if let Some(m) = method {
query_params.push(("method", m));
}
if let Some(p) = path {
query_params.push(("path", p));
}
if let Some(s) = status {
query_params.push(("status", s.to_string()));
}
if let Some(l) = limit {
query_params.push(("limit", l.to_string()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
url = format!("{}?{}", url, query_string);
}
let response = client
.get(&url)
.send()
.await
.context("Failed to connect to Admin API. Is the server running with --admin flag?")?;
if !response.status().is_success() {
anyhow::bail!(
"Admin API returned error: {} - {}. Make sure the server is running with --admin flag.",
response.status(),
response.text().await.unwrap_or_default()
);
}
let api_response: ApiResponse<Vec<LogEntry>> =
response.json().await.context("Failed to parse API response")?;
if !api_response.success {
anyhow::bail!(
"API error: {}",
api_response.error.unwrap_or_else(|| "Unknown error".to_string())
);
}
let logs = api_response.data.unwrap_or_default();
if json {
println!("{}", serde_json::to_string_pretty(&logs)?);
} else {
print_logs_table(&logs);
}
Ok(())
}
async fn stream_logs_from_api(
admin_url: &str,
method: Option<String>,
path: Option<String>,
status: Option<u16>,
json: bool,
) -> Result<()> {
let client = Client::builder()
.timeout(Duration::from_secs(0)) .build()
.context("Failed to create HTTP client")?;
let mut url = format!("{}/__mockforge/logs", admin_url);
let mut query_params = Vec::new();
if let Some(m) = method {
query_params.push(("method", m));
}
if let Some(p) = path {
query_params.push(("path", p));
}
if let Some(s) = status {
query_params.push(("status", s.to_string()));
}
query_params.push(("limit", "50".to_string()));
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
url = format!("{}?{}", url, query_string);
}
match client.get(&url).send().await {
Ok(response) if response.status().is_success() => {
if let Ok(api_response) = response.json::<ApiResponse<Vec<LogEntry>>>().await {
if let Some(logs) = api_response.data {
if json {
println!("{}", serde_json::to_string_pretty(&logs)?);
} else {
print_logs_table(&logs);
}
}
}
}
_ => {
eprintln!("⚠️ Could not fetch initial logs. Starting to follow...");
}
}
eprintln!("📡 Following logs (press Ctrl+C to stop)...");
let mut last_seen_timestamp = chrono::Utc::now().to_rfc3339();
loop {
sleep(Duration::from_millis(500)).await;
match client.get(&url).send().await {
Ok(response) if response.status().is_success() => {
if let Ok(api_response) = response.json::<ApiResponse<Vec<LogEntry>>>().await {
if let Some(logs) = api_response.data {
let new_logs: Vec<_> =
logs.iter().filter(|log| log.timestamp > last_seen_timestamp).collect();
if !new_logs.is_empty() {
if let Some(last_log) = new_logs.last() {
last_seen_timestamp = last_log.timestamp.clone();
}
if json {
for log in new_logs {
println!("{}", serde_json::to_string(log)?);
}
} else {
for log in new_logs {
print_log_entry(log);
}
}
}
}
}
}
Err(e) => {
eprintln!("⚠️ Error fetching logs: {}. Retrying...", e);
sleep(Duration::from_secs(2)).await;
}
_ => {}
}
}
}
async fn read_logs_from_file(file_path: PathBuf, follow: bool, json: bool) -> Result<()> {
if !file_path.exists() {
anyhow::bail!("Log file does not exist: {}", file_path.display());
}
if follow {
follow_log_file(file_path, json).await
} else {
read_log_file_tail(file_path, json).await
}
}
async fn read_log_file_tail(file_path: PathBuf, json: bool) -> Result<()> {
let file = File::open(&file_path)
.await
.with_context(|| format!("Failed to open log file: {}", file_path.display()))?;
let reader = TokioBufReader::new(file);
let mut lines = reader.lines();
let mut all_lines = Vec::new();
while let Some(line) = lines.next_line().await? {
all_lines.push(line);
}
let start = all_lines.len().saturating_sub(100);
for line in &all_lines[start..] {
if json {
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(line) {
println!("{}", serde_json::to_string_pretty(&json_value)?);
} else {
println!("{}", line);
}
} else {
println!("{}", line);
}
}
Ok(())
}
async fn follow_log_file(file_path: PathBuf, json: bool) -> Result<()> {
use tokio::fs::OpenOptions;
eprintln!("📡 Following log file: {} (press Ctrl+C to stop)...", file_path.display());
let mut last_size = std::fs::metadata(&file_path)
.with_context(|| format!("Failed to get file metadata: {}", file_path.display()))?
.len();
loop {
let current_size = match std::fs::metadata(&file_path) {
Ok(meta) => meta.len(),
Err(_) => {
sleep(Duration::from_millis(500)).await;
continue;
}
};
if current_size > last_size {
let file = OpenOptions::new()
.read(true)
.open(&file_path)
.await
.with_context(|| format!("Failed to open log file: {}", file_path.display()))?;
let mut reader = TokioBufReader::new(file);
reader.seek(io::SeekFrom::Start(last_size)).await?;
let mut buffer = String::new();
while reader.read_line(&mut buffer).await? > 0 {
let line = buffer.trim_end();
if !line.is_empty() {
if json {
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(line) {
println!("{}", serde_json::to_string_pretty(&json_value)?);
} else {
println!("{}", line);
}
} else {
println!("{}", line);
}
}
buffer.clear();
}
last_size = current_size;
} else {
sleep(Duration::from_millis(100)).await;
}
}
}
async fn get_log_file_from_config(config_path: &PathBuf) -> Result<PathBuf> {
use mockforge_core::config::load_config_auto;
let config = load_config_auto(config_path).await?;
if let Some(file_path) = config.logging.file_path {
return Ok(PathBuf::from(file_path));
}
anyhow::bail!("No log file path configured")
}
fn print_logs_table(logs: &[LogEntry]) {
if logs.is_empty() {
println!("No logs found.");
return;
}
println!(
"{:<20} {:<8} {:<8} {:<50} {:<8} {:<10}",
"Timestamp", "Status", "Method", "Path", "Time(ms)", "Size(bytes)"
);
println!("{}", "-".repeat(110));
for log in logs {
print_log_entry(log);
}
}
fn print_log_entry(log: &LogEntry) {
let timestamp = if log.timestamp.len() > 19 {
&log.timestamp[..19] } else {
&log.timestamp
};
let response_time = log.response_time.map(|t| t.to_string()).unwrap_or_else(|| "-".to_string());
let size = log.size.map(|s| s.to_string()).unwrap_or_else(|| "-".to_string());
let status_str = if log.status >= 500 {
format!("\x1b[31m{}\x1b[0m", log.status) } else if log.status >= 400 {
format!("\x1b[33m{}\x1b[0m", log.status) } else {
format!("\x1b[32m{}\x1b[0m", log.status) };
let method_str = match log.method.as_str() {
"GET" => format!("\x1b[34m{}\x1b[0m", log.method), "POST" => format!("\x1b[32m{}\x1b[0m", log.method), "PUT" => format!("\x1b[33m{}\x1b[0m", log.method), "DELETE" => format!("\x1b[31m{}\x1b[0m", log.method), "PATCH" => format!("\x1b[35m{}\x1b[0m", log.method), _ => log.method.clone(),
};
let path = if log.url.len() > 48 {
format!("{}...", &log.url[..45])
} else {
log.url.clone()
};
println!(
"{:<20} {:<8} {:<8} {:<50} {:<8} {:<10}",
timestamp, status_str, method_str, path, response_time, size
);
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_log_entry() -> LogEntry {
LogEntry {
timestamp: "2025-01-15T10:30:45.123Z".to_string(),
status: 200,
method: "GET".to_string(),
url: "/api/users".to_string(),
response_time: Some(42),
size: Some(1024),
}
}
#[test]
fn test_log_entry_serialization() {
let log = create_test_log_entry();
let json = serde_json::to_string(&log).unwrap();
assert!(json.contains("\"timestamp\":\"2025-01-15T10:30:45.123Z\""));
assert!(json.contains("\"status\":200"));
assert!(json.contains("\"method\":\"GET\""));
assert!(json.contains("\"url\":\"/api/users\""));
assert!(json.contains("\"response_time\":42"));
assert!(json.contains("\"size\":1024"));
}
#[test]
fn test_log_entry_deserialization() {
let json = r#"{
"timestamp": "2025-01-15T10:30:45.123Z",
"status": 201,
"method": "POST",
"url": "/api/items",
"response_time": 100,
"size": 2048
}"#;
let log: LogEntry = serde_json::from_str(json).unwrap();
assert_eq!(log.timestamp, "2025-01-15T10:30:45.123Z");
assert_eq!(log.status, 201);
assert_eq!(log.method, "POST");
assert_eq!(log.url, "/api/items");
assert_eq!(log.response_time, Some(100));
assert_eq!(log.size, Some(2048));
}
#[test]
fn test_log_entry_deserialization_without_optional_fields() {
let json = r#"{
"timestamp": "2025-01-15T10:30:45Z",
"status": 404,
"method": "DELETE",
"url": "/api/items/123",
"response_time": null,
"size": null
}"#;
let log: LogEntry = serde_json::from_str(json).unwrap();
assert_eq!(log.status, 404);
assert_eq!(log.method, "DELETE");
assert!(log.response_time.is_none());
assert!(log.size.is_none());
}
#[test]
fn test_log_entry_clone() {
let log = create_test_log_entry();
let cloned = log.clone();
assert_eq!(log.timestamp, cloned.timestamp);
assert_eq!(log.status, cloned.status);
assert_eq!(log.method, cloned.method);
assert_eq!(log.url, cloned.url);
}
#[test]
fn test_log_entry_debug() {
let log = create_test_log_entry();
let debug_str = format!("{:?}", log);
assert!(debug_str.contains("LogEntry"));
assert!(debug_str.contains("GET"));
assert!(debug_str.contains("200"));
}
#[test]
fn test_api_response_success() {
let json = r#"{
"success": true,
"data": [{"timestamp": "2025-01-15T10:30:45Z", "status": 200, "method": "GET", "url": "/api/test", "response_time": 50, "size": 100}],
"error": null
}"#;
let response: ApiResponse<Vec<LogEntry>> = serde_json::from_str(json).unwrap();
assert!(response.success);
assert!(response.data.is_some());
assert!(response.error.is_none());
assert_eq!(response.data.unwrap().len(), 1);
}
#[test]
fn test_api_response_error() {
let json = r#"{
"success": false,
"data": null,
"error": "Server unavailable"
}"#;
let response: ApiResponse<Vec<LogEntry>> = serde_json::from_str(json).unwrap();
assert!(!response.success);
assert!(response.data.is_none());
assert_eq!(response.error, Some("Server unavailable".to_string()));
}
#[test]
fn test_log_entry_all_http_methods() {
let methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"];
for method in methods {
let log = LogEntry {
timestamp: "2025-01-15T10:30:45Z".to_string(),
status: 200,
method: method.to_string(),
url: "/api/test".to_string(),
response_time: None,
size: None,
};
let json = serde_json::to_string(&log).unwrap();
assert!(json.contains(method));
}
}
#[test]
fn test_log_entry_various_status_codes() {
let status_codes = [200, 201, 204, 301, 302, 400, 401, 403, 404, 500, 502, 503];
for status in status_codes {
let log = LogEntry {
timestamp: "2025-01-15T10:30:45Z".to_string(),
status,
method: "GET".to_string(),
url: "/api/test".to_string(),
response_time: None,
size: None,
};
let json = serde_json::to_string(&log).unwrap();
assert!(json.contains(&format!("\"status\":{}", status)));
}
}
#[test]
fn test_log_entry_long_url_serialization() {
let long_url =
"/api/v1/organizations/12345/projects/67890/resources/abcdef/items/ghijkl/details"
.to_string();
let log = LogEntry {
timestamp: "2025-01-15T10:30:45Z".to_string(),
status: 200,
method: "GET".to_string(),
url: long_url.clone(),
response_time: Some(150),
size: Some(5000),
};
let json = serde_json::to_string(&log).unwrap();
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.url, long_url);
}
#[test]
fn test_log_entry_special_characters_in_url() {
let url = "/api/search?q=hello%20world&page=1".to_string();
let log = LogEntry {
timestamp: "2025-01-15T10:30:45Z".to_string(),
status: 200,
method: "GET".to_string(),
url: url.clone(),
response_time: None,
size: None,
};
let json = serde_json::to_string(&log).unwrap();
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.url, url);
}
#[test]
fn test_log_entry_zero_values() {
let log = LogEntry {
timestamp: "2025-01-15T10:30:45Z".to_string(),
status: 200,
method: "GET".to_string(),
url: "/".to_string(),
response_time: Some(0),
size: Some(0),
};
assert_eq!(log.response_time, Some(0));
assert_eq!(log.size, Some(0));
let json = serde_json::to_string(&log).unwrap();
assert!(json.contains("\"response_time\":0"));
assert!(json.contains("\"size\":0"));
}
#[test]
fn test_log_entry_large_values() {
let log = LogEntry {
timestamp: "2025-01-15T10:30:45Z".to_string(),
status: 200,
method: "GET".to_string(),
url: "/api/large".to_string(),
response_time: Some(u64::MAX),
size: Some(u64::MAX),
};
let json = serde_json::to_string(&log).unwrap();
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.response_time, Some(u64::MAX));
assert_eq!(parsed.size, Some(u64::MAX));
}
#[test]
fn test_print_logs_table_empty() {
let logs: Vec<LogEntry> = vec![];
print_logs_table(&logs);
}
#[test]
fn test_print_log_entry_does_not_panic() {
let test_cases = vec![
LogEntry {
timestamp: "2025-01-15".to_string(),
status: 200,
method: "GET".to_string(),
url: "/short".to_string(),
response_time: None,
size: None,
},
LogEntry {
timestamp: "2025-01-15T10:30:45.123456789Z".to_string(),
status: 500,
method: "POST".to_string(),
url: "/error".to_string(),
response_time: Some(1000),
size: Some(100),
},
LogEntry {
timestamp: "2025-01-15T10:30:45Z".to_string(),
status: 404,
method: "DELETE".to_string(),
url: "/api/v1/very/long/path/that/should/be/truncated/by/the/print/function"
.to_string(),
response_time: None,
size: None,
},
LogEntry {
timestamp: "2025-01-15T10:30:45Z".to_string(),
status: 403,
method: "PUT".to_string(),
url: "/forbidden".to_string(),
response_time: Some(5),
size: Some(0),
},
LogEntry {
timestamp: "2025-01-15T10:30:45Z".to_string(),
status: 200,
method: "PATCH".to_string(),
url: "/update".to_string(),
response_time: Some(25),
size: Some(512),
},
LogEntry {
timestamp: "2025-01-15T10:30:45Z".to_string(),
status: 200,
method: "CUSTOM".to_string(),
url: "/custom".to_string(),
response_time: None,
size: None,
},
];
for log in &test_cases {
print_log_entry(log);
}
}
#[test]
fn test_print_logs_table_with_entries() {
let logs = vec![
create_test_log_entry(),
LogEntry {
timestamp: "2025-01-15T10:31:00Z".to_string(),
status: 201,
method: "POST".to_string(),
url: "/api/items".to_string(),
response_time: Some(100),
size: Some(2048),
},
];
print_logs_table(&logs);
}
#[test]
fn test_log_entry_round_trip() {
let original = create_test_log_entry();
let json = serde_json::to_string(&original).unwrap();
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
assert_eq!(original.timestamp, parsed.timestamp);
assert_eq!(original.status, parsed.status);
assert_eq!(original.method, parsed.method);
assert_eq!(original.url, parsed.url);
assert_eq!(original.response_time, parsed.response_time);
assert_eq!(original.size, parsed.size);
}
}