intent_engine/
logs.rs

1//! Log Query and Management Module
2//!
3//! Provides functionality to query, filter, and display application logs.
4
5use 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/// Log entry structure
12#[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/// Log query parameters
24#[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)), // Default: last 24 hours
39            until: None,
40            limit: Some(100),
41        }
42    }
43}
44
45/// Get log directory path
46pub fn log_dir() -> PathBuf {
47    dirs::home_dir()
48        .expect("Failed to get home directory")
49        .join(".intent-engine")
50        .join("logs")
51}
52
53/// Get log file path for a specific mode
54pub 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
64/// List all available log files
65pub 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        // Match both .log files and rotated files like .log.2025-11-23
76        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
89/// Parse a log line into a LogEntry
90pub fn parse_log_line(line: &str, mode: &str) -> Option<LogEntry> {
91    // Try JSON format first
92    if let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) {
93        // Try to get message from fields.message (MCP Server format) or top-level message
94        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    // Try text format: "2025-11-22T06:54:15.123456789+00:00  INFO target: message"
125    // Split by whitespace, skipping empty parts
126    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            // Find the position of the second whitespace to get the rest
132            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            // Try to extract target from "target: message"
138            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
159/// Query logs based on filter criteria
160pub 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        // Get all log files (including rotated ones) and filter by mode
169        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                // Filter by timestamp
200                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                // Filter by level
210                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    // Sort by timestamp
222    entries.sort_by_key(|e| e.timestamp);
223
224    // Apply limit
225    if let Some(limit) = query.limit {
226        entries.truncate(limit);
227    }
228
229    Ok(entries)
230}
231
232/// Parse duration string like "1h", "24h", "7d"
233pub 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
262/// Format log entry as text
263pub 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
284/// Format log entry as JSON
285pub fn format_entry_json(entry: &LogEntry) -> String {
286    serde_json::to_string(entry).unwrap_or_else(|_| "{}".to_string())
287}
288
289/// Follow logs in real-time (like tail -f)
290pub 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    // Get initial file sizes
307    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                // File was truncated or rotated
326                *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                        // Apply filters
343                        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    // ========== parse_duration tests ==========
366
367    #[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        // Empty and whitespace
380        assert_eq!(parse_duration(""), None);
381        assert_eq!(parse_duration("   "), None);
382
383        // Missing unit
384        assert_eq!(parse_duration("123"), None);
385        assert_eq!(parse_duration("42"), None);
386
387        // Invalid number
388        assert_eq!(parse_duration("abch"), None);
389        assert_eq!(parse_duration("12.5h"), None);
390
391        // Whitespace handling
392        assert_eq!(parse_duration("  1h  "), Some(Duration::hours(1)));
393        assert_eq!(parse_duration(" 7d "), Some(Duration::days(7)));
394
395        // Zero values
396        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        // Seconds
403        assert_eq!(parse_duration("1s"), Some(Duration::seconds(1)));
404        assert_eq!(parse_duration("3600s"), Some(Duration::seconds(3600)));
405
406        // Minutes
407        assert_eq!(parse_duration("1m"), Some(Duration::minutes(1)));
408        assert_eq!(parse_duration("60m"), Some(Duration::minutes(60)));
409
410        // Hours
411        assert_eq!(parse_duration("1h"), Some(Duration::hours(1)));
412        assert_eq!(parse_duration("168h"), Some(Duration::hours(168))); // 1 week
413
414        // Days
415        assert_eq!(parse_duration("1d"), Some(Duration::days(1)));
416        assert_eq!(parse_duration("30d"), Some(Duration::days(30)));
417    }
418
419    // ========== parse_log_line tests ==========
420
421    #[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"); // Should extract from fields.message
455        assert!(entry.fields.is_some());
456    }
457
458    #[test]
459    fn test_parse_log_line_json_missing_fields() {
460        // Minimal valid JSON - missing optional fields
461        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"); // Default
464        assert_eq!(entry.message, ""); // Default empty
465        assert_eq!(entry.target, None);
466    }
467
468    #[test]
469    fn test_parse_log_line_invalid() {
470        // Invalid JSON
471        assert_eq!(parse_log_line("{invalid json}", "test"), None);
472
473        // Malformed text (too few parts)
474        assert_eq!(parse_log_line("JUST_TEXT", "test"), None);
475        assert_eq!(parse_log_line("2025-11-22 INFO", "test"), None);
476
477        // Invalid timestamp
478        assert_eq!(parse_log_line("not-a-timestamp INFO message", "test"), None);
479
480        // Empty line
481        assert_eq!(parse_log_line("", "test"), None);
482    }
483
484    // ========== log_file_for_mode tests ==========
485
486    #[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        // Invalid mode should return None
501        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    // ========== LogQuery default tests ==========
507
508    #[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    // ========== format_entry tests ==========
519
520    #[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        // Should not have ": " separator when no target
552        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        // fields should be omitted when None
599        assert!(!json.contains("\"fields\""));
600    }
601}