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, 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    #[test]
366    fn test_parse_duration() {
367        assert_eq!(parse_duration("1h"), Some(Duration::hours(1)));
368        assert_eq!(parse_duration("24h"), Some(Duration::hours(24)));
369        assert_eq!(parse_duration("7d"), Some(Duration::days(7)));
370        assert_eq!(parse_duration("30m"), Some(Duration::minutes(30)));
371        assert_eq!(parse_duration("60s"), Some(Duration::seconds(60)));
372        assert_eq!(parse_duration("invalid"), None);
373    }
374
375    #[test]
376    fn test_parse_log_line_text() {
377        let line =
378            "2025-11-22T06:54:15.123456789+00:00  INFO intent_engine::dashboard: Server started";
379        let entry = parse_log_line(line, "dashboard").unwrap();
380        assert_eq!(entry.level, "INFO");
381        assert_eq!(entry.target, Some("intent_engine::dashboard".to_string()));
382        assert_eq!(entry.message, "Server started");
383    }
384
385    #[test]
386    fn test_parse_log_line_json() {
387        let line = r#"{"timestamp":"2025-11-22T06:54:15.123456789+00:00","level":"INFO","target":"intent_engine","message":"Test message"}"#;
388        let entry = parse_log_line(line, "dashboard").unwrap();
389        assert_eq!(entry.level, "INFO");
390        assert_eq!(entry.message, "Test message");
391    }
392}