1use chrono::{DateTime, Duration, Utc};
6use serde::{Deserialize, Serialize};
7use std::fs::{self, File};
8use std::io::{self, BufRead, BufReader, Seek, SeekFrom};
9use std::path::PathBuf;
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct LogEntry {
14 pub timestamp: DateTime<Utc>,
15 pub level: String,
16 pub target: Option<String>,
17 pub message: String,
18 pub mode: String,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub fields: Option<serde_json::Value>,
21}
22
23#[derive(Debug, Clone)]
25pub struct LogQuery {
26 pub mode: Option<String>,
27 pub level: Option<String>,
28 pub since: Option<Duration>,
29 pub until: Option<DateTime<Utc>>,
30 pub limit: Option<usize>,
31}
32
33impl Default for LogQuery {
34 fn default() -> Self {
35 Self {
36 mode: None,
37 level: None,
38 since: Some(Duration::hours(24)), until: None,
40 limit: Some(100),
41 }
42 }
43}
44
45pub fn log_dir() -> PathBuf {
47 dirs::home_dir()
48 .expect("Failed to get home directory")
49 .join(".intent-engine")
50 .join("logs")
51}
52
53pub fn log_file_for_mode(mode: &str) -> Option<PathBuf> {
55 let dir = log_dir();
56 match mode {
57 "dashboard" => Some(dir.join("dashboard.log")),
58 "mcp-server" => Some(dir.join("mcp-server.log")),
59 "cli" => Some(dir.join("cli.log")),
60 _ => None,
61 }
62}
63
64pub fn list_log_files() -> io::Result<Vec<PathBuf>> {
66 let dir = log_dir();
67 if !dir.exists() {
68 return Ok(vec![]);
69 }
70
71 let mut files = vec![];
72 for entry in fs::read_dir(dir)? {
73 let entry = entry?;
74 let path = entry.path();
75 if path.is_file() {
77 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
78 if name.ends_with(".log") || name.contains(".log.") {
79 files.push(path);
80 }
81 }
82 }
83 }
84
85 files.sort();
86 Ok(files)
87}
88
89pub fn parse_log_line(line: &str, mode: &str) -> Option<LogEntry> {
91 if let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) {
93 let message = entry
95 .get("fields")
96 .and_then(|f| f.get("message"))
97 .and_then(|m| m.as_str())
98 .or_else(|| entry.get("message").and_then(|m| m.as_str()))
99 .unwrap_or("")
100 .to_string();
101
102 return Some(LogEntry {
103 timestamp: entry
104 .get("timestamp")
105 .and_then(|t| t.as_str())
106 .and_then(|t| DateTime::parse_from_rfc3339(t).ok())
107 .map(|dt| dt.with_timezone(&Utc))
108 .unwrap_or_else(Utc::now),
109 level: entry
110 .get("level")
111 .and_then(|l| l.as_str())
112 .unwrap_or("INFO")
113 .to_string(),
114 target: entry
115 .get("target")
116 .and_then(|t| t.as_str())
117 .map(String::from),
118 message,
119 mode: mode.to_string(),
120 fields: entry.get("fields").cloned(),
121 });
122 }
123
124 let parts: Vec<&str> = line.split_whitespace().collect();
127 if parts.len() >= 3 {
128 if let Ok(timestamp) = DateTime::parse_from_rfc3339(parts[0]) {
129 let level = parts[1].to_string();
130
131 let after_timestamp = line.find(parts[0]).unwrap() + parts[0].len();
133 let rest = &line[after_timestamp..].trim_start();
134 let after_level = rest.find(parts[1]).unwrap() + parts[1].len();
135 let rest = &rest[after_level..].trim_start();
136
137 let (target, message) = if let Some(idx) = rest.find(": ") {
139 let (t, m) = rest.split_at(idx);
140 (Some(t.to_string()), m[2..].to_string())
141 } else {
142 (None, rest.to_string())
143 };
144
145 return Some(LogEntry {
146 timestamp: timestamp.with_timezone(&Utc),
147 level,
148 target,
149 message,
150 mode: mode.to_string(),
151 fields: None,
152 });
153 }
154 }
155
156 None
157}
158
159pub fn query_logs(query: &LogQuery) -> io::Result<Vec<LogEntry>> {
161 let mut entries = Vec::new();
162 let cutoff_time = query
163 .since
164 .map(|d| Utc::now() - d)
165 .unwrap_or_else(|| Utc::now() - Duration::days(365));
166
167 let files = if let Some(mode) = &query.mode {
168 let all_files = list_log_files()?;
170 all_files
171 .into_iter()
172 .filter(|p| {
173 p.file_name()
174 .and_then(|n| n.to_str())
175 .map(|name| name.starts_with(&format!("{}.log", mode)))
176 .unwrap_or(false)
177 })
178 .collect()
179 } else {
180 list_log_files()?
181 };
182
183 for file_path in files {
184 if !file_path.exists() {
185 continue;
186 }
187
188 let mode = file_path
189 .file_stem()
190 .and_then(|s| s.to_str())
191 .unwrap_or("unknown");
192
193 let file = File::open(&file_path)?;
194 let reader = BufReader::new(file);
195
196 for line in reader.lines() {
197 let line = line?;
198 if let Some(entry) = parse_log_line(&line, mode) {
199 if entry.timestamp < cutoff_time {
201 continue;
202 }
203 if let Some(until) = query.until {
204 if entry.timestamp > until {
205 continue;
206 }
207 }
208
209 if let Some(ref level) = query.level {
211 if !entry.level.eq_ignore_ascii_case(level) {
212 continue;
213 }
214 }
215
216 entries.push(entry);
217 }
218 }
219 }
220
221 entries.sort_by_key(|e| e.timestamp);
223
224 if let Some(limit) = query.limit {
226 entries.truncate(limit);
227 }
228
229 Ok(entries)
230}
231
232pub fn parse_duration(s: &str) -> Option<Duration> {
234 let s = s.trim();
235 if s.is_empty() {
236 return None;
237 }
238
239 let (num_str, unit) = if let Some(stripped) = s.strip_suffix('s') {
240 (stripped, 's')
241 } else if let Some(stripped) = s.strip_suffix('m') {
242 (stripped, 'm')
243 } else if let Some(stripped) = s.strip_suffix('h') {
244 (stripped, 'h')
245 } else if let Some(stripped) = s.strip_suffix('d') {
246 (stripped, 'd')
247 } else {
248 return None;
249 };
250
251 let num: i64 = num_str.parse().ok()?;
252
253 match unit {
254 's' => Some(Duration::seconds(num)),
255 'm' => Some(Duration::minutes(num)),
256 'h' => Some(Duration::hours(num)),
257 'd' => Some(Duration::days(num)),
258 _ => None,
259 }
260}
261
262pub fn format_entry_text(entry: &LogEntry) -> String {
264 if let Some(ref target) = entry.target {
265 format!(
266 "{} {:5} {:10} {}: {}",
267 entry.timestamp.format("%Y-%m-%d %H:%M:%S"),
268 entry.level,
269 entry.mode,
270 target,
271 entry.message
272 )
273 } else {
274 format!(
275 "{} {:5} {:10} {}",
276 entry.timestamp.format("%Y-%m-%d %H:%M:%S"),
277 entry.level,
278 entry.mode,
279 entry.message
280 )
281 }
282}
283
284pub fn format_entry_json(entry: &LogEntry) -> String {
286 serde_json::to_string(entry).unwrap_or_else(|_| "{}".to_string())
287}
288
289pub fn follow_logs(query: &LogQuery) -> io::Result<()> {
291 use std::thread;
292 use std::time::Duration as StdDuration;
293
294 let files = if let Some(mode) = &query.mode {
295 if let Some(file) = log_file_for_mode(mode) {
296 vec![file]
297 } else {
298 vec![]
299 }
300 } else {
301 list_log_files()?
302 };
303
304 let mut positions: Vec<(PathBuf, u64)> = files.iter().map(|f| (f.clone(), 0)).collect();
305
306 for (path, pos) in &mut positions {
308 if let Ok(metadata) = fs::metadata(path) {
309 *pos = metadata.len();
310 }
311 }
312
313 println!("Following logs... (Ctrl+C to stop)");
314
315 loop {
316 for (path, last_pos) in &mut positions {
317 if !path.exists() {
318 continue;
319 }
320
321 let metadata = fs::metadata(&**path)?;
322 let current_size = metadata.len();
323
324 if current_size < *last_pos {
325 *last_pos = 0;
327 }
328
329 if current_size > *last_pos {
330 let mut file = File::open(&**path)?;
331 file.seek(SeekFrom::Start(*last_pos))?;
332 let reader = BufReader::new(file);
333
334 let mode = path
335 .file_stem()
336 .and_then(|s| s.to_str())
337 .unwrap_or("unknown");
338
339 for line in reader.lines() {
340 let line = line?;
341 if let Some(entry) = parse_log_line(&line, mode) {
342 if let Some(ref level) = query.level {
344 if !entry.level.eq_ignore_ascii_case(level) {
345 continue;
346 }
347 }
348
349 println!("{}", format_entry_text(&entry));
350 }
351 }
352
353 *last_pos = current_size;
354 }
355 }
356
357 thread::sleep(StdDuration::from_millis(500));
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
368 fn test_parse_duration() {
369 assert_eq!(parse_duration("1h"), Some(Duration::hours(1)));
370 assert_eq!(parse_duration("24h"), Some(Duration::hours(24)));
371 assert_eq!(parse_duration("7d"), Some(Duration::days(7)));
372 assert_eq!(parse_duration("30m"), Some(Duration::minutes(30)));
373 assert_eq!(parse_duration("60s"), Some(Duration::seconds(60)));
374 assert_eq!(parse_duration("invalid"), None);
375 }
376
377 #[test]
378 fn test_parse_duration_edge_cases() {
379 assert_eq!(parse_duration(""), None);
381 assert_eq!(parse_duration(" "), None);
382
383 assert_eq!(parse_duration("123"), None);
385 assert_eq!(parse_duration("42"), None);
386
387 assert_eq!(parse_duration("abch"), None);
389 assert_eq!(parse_duration("12.5h"), None);
390
391 assert_eq!(parse_duration(" 1h "), Some(Duration::hours(1)));
393 assert_eq!(parse_duration(" 7d "), Some(Duration::days(7)));
394
395 assert_eq!(parse_duration("0h"), Some(Duration::hours(0)));
397 assert_eq!(parse_duration("0d"), Some(Duration::days(0)));
398 }
399
400 #[test]
401 fn test_parse_duration_all_units() {
402 assert_eq!(parse_duration("1s"), Some(Duration::seconds(1)));
404 assert_eq!(parse_duration("3600s"), Some(Duration::seconds(3600)));
405
406 assert_eq!(parse_duration("1m"), Some(Duration::minutes(1)));
408 assert_eq!(parse_duration("60m"), Some(Duration::minutes(60)));
409
410 assert_eq!(parse_duration("1h"), Some(Duration::hours(1)));
412 assert_eq!(parse_duration("168h"), Some(Duration::hours(168))); assert_eq!(parse_duration("1d"), Some(Duration::days(1)));
416 assert_eq!(parse_duration("30d"), Some(Duration::days(30)));
417 }
418
419 #[test]
422 fn test_parse_log_line_text() {
423 let line =
424 "2025-11-22T06:54:15.123456789+00:00 INFO intent_engine::dashboard: Server started";
425 let entry = parse_log_line(line, "dashboard").unwrap();
426 assert_eq!(entry.level, "INFO");
427 assert_eq!(entry.target, Some("intent_engine::dashboard".to_string()));
428 assert_eq!(entry.message, "Server started");
429 }
430
431 #[test]
432 fn test_parse_log_line_json() {
433 let line = r#"{"timestamp":"2025-11-22T06:54:15.123456789+00:00","level":"INFO","target":"intent_engine","message":"Test message"}"#;
434 let entry = parse_log_line(line, "dashboard").unwrap();
435 assert_eq!(entry.level, "INFO");
436 assert_eq!(entry.message, "Test message");
437 }
438
439 #[test]
440 fn test_parse_log_line_text_no_target() {
441 let line = "2025-11-22T06:54:15.123456789+00:00 WARN Simple message without target";
442 let entry = parse_log_line(line, "cli").unwrap();
443 assert_eq!(entry.level, "WARN");
444 assert_eq!(entry.target, None);
445 assert_eq!(entry.message, "Simple message without target");
446 assert_eq!(entry.mode, "cli");
447 }
448
449 #[test]
450 fn test_parse_log_line_json_with_fields() {
451 let line = r#"{"timestamp":"2025-11-22T06:54:15.123456789+00:00","level":"DEBUG","target":"mcp","fields":{"message":"Field message","key":"value"}}"#;
452 let entry = parse_log_line(line, "mcp-server").unwrap();
453 assert_eq!(entry.level, "DEBUG");
454 assert_eq!(entry.message, "Field message"); assert!(entry.fields.is_some());
456 }
457
458 #[test]
459 fn test_parse_log_line_json_missing_fields() {
460 let line = r#"{"timestamp":"2025-11-22T06:54:15+00:00"}"#;
462 let entry = parse_log_line(line, "test").unwrap();
463 assert_eq!(entry.level, "INFO"); assert_eq!(entry.message, ""); assert_eq!(entry.target, None);
466 }
467
468 #[test]
469 fn test_parse_log_line_invalid() {
470 assert_eq!(parse_log_line("{invalid json}", "test"), None);
472
473 assert_eq!(parse_log_line("JUST_TEXT", "test"), None);
475 assert_eq!(parse_log_line("2025-11-22 INFO", "test"), None);
476
477 assert_eq!(parse_log_line("not-a-timestamp INFO message", "test"), None);
479
480 assert_eq!(parse_log_line("", "test"), None);
482 }
483
484 #[test]
487 fn test_log_file_for_mode_valid() {
488 let dashboard = log_file_for_mode("dashboard").unwrap();
489 assert!(dashboard.to_string_lossy().ends_with("dashboard.log"));
490
491 let mcp = log_file_for_mode("mcp-server").unwrap();
492 assert!(mcp.to_string_lossy().ends_with("mcp-server.log"));
493
494 let cli = log_file_for_mode("cli").unwrap();
495 assert!(cli.to_string_lossy().ends_with("cli.log"));
496 }
497
498 #[test]
499 fn test_log_file_for_mode_invalid() {
500 assert_eq!(log_file_for_mode("invalid"), None);
502 assert_eq!(log_file_for_mode("unknown"), None);
503 assert_eq!(log_file_for_mode(""), None);
504 }
505
506 #[test]
509 fn test_log_query_default() {
510 let query = LogQuery::default();
511 assert_eq!(query.mode, None);
512 assert_eq!(query.level, None);
513 assert_eq!(query.since, Some(Duration::hours(24)));
514 assert_eq!(query.until, None);
515 assert_eq!(query.limit, Some(100));
516 }
517
518 #[test]
521 fn test_format_entry_text_with_target() {
522 let entry = LogEntry {
523 timestamp: Utc::now(),
524 level: "INFO".to_string(),
525 target: Some("intent_engine::core".to_string()),
526 message: "Test message".to_string(),
527 mode: "dashboard".to_string(),
528 fields: None,
529 };
530 let formatted = format_entry_text(&entry);
531 assert!(formatted.contains("INFO"));
532 assert!(formatted.contains("dashboard"));
533 assert!(formatted.contains("intent_engine::core"));
534 assert!(formatted.contains("Test message"));
535 }
536
537 #[test]
538 fn test_format_entry_text_without_target() {
539 let entry = LogEntry {
540 timestamp: Utc::now(),
541 level: "ERROR".to_string(),
542 target: None,
543 message: "Error occurred".to_string(),
544 mode: "cli".to_string(),
545 fields: None,
546 };
547 let formatted = format_entry_text(&entry);
548 assert!(formatted.contains("ERROR"));
549 assert!(formatted.contains("cli"));
550 assert!(formatted.contains("Error occurred"));
551 assert!(!formatted.contains("::"));
553 }
554
555 #[test]
556 fn test_format_entry_json() {
557 let entry = LogEntry {
558 timestamp: Utc::now(),
559 level: "WARN".to_string(),
560 target: Some("test".to_string()),
561 message: "Warning message".to_string(),
562 mode: "mcp-server".to_string(),
563 fields: None,
564 };
565 let json = format_entry_json(&entry);
566 assert!(json.contains("\"level\":\"WARN\""));
567 assert!(json.contains("\"message\":\"Warning message\""));
568 assert!(json.contains("\"mode\":\"mcp-server\""));
569 }
570
571 #[test]
572 fn test_log_entry_fields_serialization() {
573 let fields = serde_json::json!({"key": "value", "count": 42});
574 let entry = LogEntry {
575 timestamp: Utc::now(),
576 level: "DEBUG".to_string(),
577 target: None,
578 message: "Test".to_string(),
579 mode: "test".to_string(),
580 fields: Some(fields),
581 };
582 let json = format_entry_json(&entry);
583 assert!(json.contains("\"fields\""));
584 assert!(json.contains("\"key\":\"value\""));
585 }
586
587 #[test]
588 fn test_log_entry_no_fields_serialization() {
589 let entry = LogEntry {
590 timestamp: Utc::now(),
591 level: "INFO".to_string(),
592 target: None,
593 message: "Test".to_string(),
594 mode: "test".to_string(),
595 fields: None,
596 };
597 let json = format_entry_json(&entry);
598 assert!(!json.contains("\"fields\""));
600 }
601}