using System.IO; using System.Text; using System.Text.RegularExpressions; using System.Windows; using AxCopilot.SDK; using AxCopilot.Services; using AxCopilot.Themes; namespace AxCopilot.Handlers; /// /// L21-2: 로그 파일 분석기 핸들러. "log" 프리픽스로 사용합니다. /// /// 예: log → 클립보드 로그 통계 /// log <경로> → 파일 경로 직접 분석 /// log error → ERROR 줄만 표시 /// log warn → WARN 줄만 표시 /// log last 20 → 마지막 20줄 (tail) /// log head 20 → 처음 20줄 /// log grep <키워드> → 키워드 필터 /// log stats → 레벨별 통계 + 시간대 분포 /// log exceptions → 예외·스택트레이스 블록 추출 /// log today → 오늘 날짜 포함 줄만 표시 /// Enter → 값 복사. /// public partial class LogHandler : IActionHandler { public string? Prefix => "log"; public PluginMetadata Metadata => new( "Log", "로그 파일 분석기 — ERROR/WARN 필터·tail·grep·통계·예외 추출", "1.0", "AX"); private record LogLine(int No, string Level, string Raw); // 로그 레벨 감지 패턴 private static readonly (string Level, string[] Keywords)[] LevelPatterns = [ ("ERROR", ["[ERROR]", "ERROR:", "ERRO ", "error", "Error", "FATAL", "[FATAL]", "Exception", "EXCEPTION"]), ("WARN", ["[WARN]", "WARN:", "WARNING", "warning", "Warning", "[WARNING]"]), ("INFO", ["[INFO]", "INFO:", "information", "Information"]), ("DEBUG", ["[DEBUG]", "DEBUG:", "debug", "Debug", "TRACE", "[TRACE]"]), ]; public Task> GetItemsAsync(string query, CancellationToken ct) { var q = query.Trim(); var items = new List(); // 클립보드 or 파일 string? src = null; string? srcLabel = null; try { System.Windows.Application.Current.Dispatcher.Invoke(() => { if (Clipboard.ContainsText()) src = Clipboard.GetText(); }); } catch { } if (string.IsNullOrWhiteSpace(q)) { if (string.IsNullOrWhiteSpace(src)) { items.Add(new LauncherItem("로그 파일 분석기", "클립보드에 로그를 복사하거나 log <파일경로> / log error / log grep <키워드>", null, null, Symbol: "\uE9D9")); return Task.FromResult>(items); } srcLabel = "클립보드"; BuildSummary(items, src!, srcLabel); return Task.FromResult>(items); } var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); var sub = parts[0].ToLowerInvariant(); // 파일 경로인지 판단 if ((sub.Contains('\\') || sub.Contains('/') || sub.Contains(':') || sub.EndsWith(".log") || sub.EndsWith(".txt")) && File.Exists(parts[0])) { string? fileErr = null; string? content = null; try { content = File.ReadAllText(parts[0]); } catch (Exception ex) { fileErr = ex.Message; } if (fileErr != null) { items.Add(ErrorItem($"파일 읽기 오류: {fileErr}")); return Task.FromResult>(items); } src = content; srcLabel = Path.GetFileName(parts[0]); if (parts.Length == 1) { BuildSummary(items, src!, srcLabel); return Task.FromResult>(items); } // 파일 경로 + 서브커맨드 sub = parts[1].ToLowerInvariant(); parts = parts[1..]; } else { srcLabel = "클립보드"; } var logSrc = src ?? ""; if (string.IsNullOrWhiteSpace(logSrc)) { items.Add(ErrorItem("분석할 로그가 없습니다. 클립보드에 로그를 복사하세요.")); return Task.FromResult>(items); } var allLines = ParseLogLines(logSrc); switch (sub) { case "error" or "err": BuildFilteredLines(items, allLines, "ERROR", "ERROR / FATAL / Exception", srcLabel!); break; case "warn" or "warning": BuildFilteredLines(items, allLines, "WARN", "WARN / WARNING", srcLabel!); break; case "info": BuildFilteredLines(items, allLines, "INFO", "INFO / information", srcLabel!); break; case "debug" or "trace": BuildFilteredLines(items, allLines, "DEBUG", "DEBUG / TRACE", srcLabel!); break; case "last" or "tail": { int n = 20; if (parts.Length >= 2 && int.TryParse(parts[1], out var pn)) n = Math.Clamp(pn, 1, 500); var tail = allLines.TakeLast(n).ToList(); var joined = string.Join("\n", tail.Select(l => l.Raw)); items.Add(new LauncherItem($"마지막 {tail.Count}줄 ({srcLabel})", "Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9")); foreach (var l in tail) items.Add(BuildLineItem(l)); break; } case "head" or "first": { int n = 20; if (parts.Length >= 2 && int.TryParse(parts[1], out var pn)) n = Math.Clamp(pn, 1, 500); var head = allLines.Take(n).ToList(); var joined = string.Join("\n", head.Select(l => l.Raw)); items.Add(new LauncherItem($"처음 {head.Count}줄 ({srcLabel})", "Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9")); foreach (var l in head) items.Add(BuildLineItem(l)); break; } case "grep" or "find" or "search": { if (parts.Length < 2) { items.Add(ErrorItem("예: log grep Exception")); break; } var kw = string.Join(" ", parts[1..]); var matched = allLines.Where(l => l.Raw.Contains(kw, StringComparison.OrdinalIgnoreCase)).ToList(); var joined = string.Join("\n", matched.Select(l => l.Raw)); items.Add(new LauncherItem($"'{kw}' 검색 결과: {matched.Count}줄", "Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9")); foreach (var l in matched.Take(15)) items.Add(BuildLineItem(l)); if (matched.Count > 15) items.Add(new LauncherItem($"... ({matched.Count - 15}줄 더)", "Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9")); break; } case "stats": BuildStats(items, allLines, logSrc, srcLabel!); break; case "exceptions" or "exception" or "ex": { var blocks = ExtractExceptions(logSrc); items.Add(new LauncherItem($"예외 블록 {blocks.Count}개 발견", srcLabel!, null, null, Symbol: "\uE9D9")); for (int i = 0; i < blocks.Count; i++) { var b = blocks[i]; var preview = b.Split('\n')[0]; items.Add(new LauncherItem($"#{i + 1} {TruncateStr(preview, 60)}", $"{b.Split('\n').Length}줄", null, ("copy", b), Symbol: "\uE9D9")); } if (blocks.Count == 0) items.Add(new LauncherItem("예외·스택트레이스 패턴 없음", "", null, null, Symbol: "\uE9D9")); break; } case "today": { var today = DateTime.Today.ToString("yyyy-MM-dd"); var today2 = DateTime.Today.ToString("yyyy/MM/dd"); var matched = allLines.Where(l => l.Raw.Contains(today) || l.Raw.Contains(today2)).ToList(); var joined = string.Join("\n", matched.Select(l => l.Raw)); items.Add(new LauncherItem($"오늘({today}) {matched.Count}줄", "Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9")); foreach (var l in matched.Take(15)) items.Add(BuildLineItem(l)); break; } default: // 기본 요약으로 폴백 BuildSummary(items, logSrc, srcLabel!); break; } return Task.FromResult>(items); } public Task ExecuteAsync(LauncherItem item, CancellationToken ct) { if (item.Data is ("copy", string text)) { try { System.Windows.Application.Current.Dispatcher.Invoke( () => Clipboard.SetText(text)); NotificationService.Notify("Log", "클립보드에 복사했습니다."); } catch { } } return Task.CompletedTask; } // ── 빌더 ──────────────────────────────────────────────────────────────── private static void BuildSummary(List items, string src, string label) { var lines = ParseLogLines(src); var errors = lines.Count(l => l.Level == "ERROR"); var warns = lines.Count(l => l.Level == "WARN"); var infos = lines.Count(l => l.Level == "INFO"); var debugs = lines.Count(l => l.Level == "DEBUG"); var unknowns = lines.Count(l => l.Level == "OTHER"); items.Add(new LauncherItem($"{label} — {lines.Count}줄", $"ERROR:{errors} WARN:{warns} INFO:{infos} DEBUG:{debugs}", null, null, Symbol: "\uE9D9")); if (errors > 0) { items.Add(new LauncherItem($"🔴 ERROR {errors}줄", "log error 서브커맨드로 상세 보기", null, null, Symbol: "\uE9D9")); } if (warns > 0) { items.Add(new LauncherItem($"🟡 WARN {warns}줄", "log warn 서브커맨드로 상세 보기", null, null, Symbol: "\uE9D9")); } items.Add(CopyItem("전체 줄 수", lines.Count.ToString())); items.Add(CopyItem("ERROR 줄", errors.ToString())); items.Add(CopyItem("WARN 줄", warns.ToString())); // 마지막 5줄 미리보기 items.Add(new LauncherItem("── 마지막 5줄 ──", "", null, null, Symbol: "\uE9D9")); foreach (var l in lines.TakeLast(5)) items.Add(BuildLineItem(l)); } private static void BuildFilteredLines(List items, List lines, string level, string label, string srcLabel) { var filtered = lines.Where(l => l.Level == level).ToList(); var joined = string.Join("\n", filtered.Select(l => l.Raw)); items.Add(new LauncherItem($"{label} {filtered.Count}줄 ({srcLabel})", "Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9")); if (filtered.Count == 0) { items.Add(new LauncherItem($"{label} 줄이 없습니다", "", null, null, Symbol: "\uE9D9")); return; } foreach (var l in filtered.Take(15)) items.Add(BuildLineItem(l)); if (filtered.Count > 15) items.Add(new LauncherItem($"... ({filtered.Count - 15}줄 더)", "Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9")); } private static void BuildStats(List items, List lines, string src, string label) { var counts = new Dictionary { ["ERROR"] = lines.Count(l => l.Level == "ERROR"), ["WARN"] = lines.Count(l => l.Level == "WARN"), ["INFO"] = lines.Count(l => l.Level == "INFO"), ["DEBUG"] = lines.Count(l => l.Level == "DEBUG"), ["OTHER"] = lines.Count(l => l.Level == "OTHER"), }; items.Add(new LauncherItem($"로그 통계 ({label})", $"총 {lines.Count}줄", null, null, Symbol: "\uE9D9")); foreach (var (lvl, cnt) in counts.Where(kv => kv.Value > 0)) items.Add(CopyItem(lvl, $"{cnt}줄 ({cnt * 100.0 / Math.Max(1, lines.Count):F1}%)")); // 날짜별 분포 (yyyy-MM-dd 패턴 추출) var dateCounts = new Dictionary(); foreach (var l in lines) { var m = DatePattern().Match(l.Raw); if (m.Success) { var date = m.Value[..10]; dateCounts[date] = dateCounts.GetValueOrDefault(date) + 1; } } if (dateCounts.Count > 0) { items.Add(new LauncherItem("── 날짜별 분포 ──", "", null, null, Symbol: "\uE9D9")); foreach (var (date, cnt) in dateCounts.OrderByDescending(kv => kv.Key).Take(5)) items.Add(CopyItem(date, $"{cnt}줄")); } } private static List ExtractExceptions(string src) { var blocks = new List(); var lines = src.Split('\n'); var inBlock = false; var current = new StringBuilder(); foreach (var line in lines) { var isEx = line.Contains("Exception") || line.Contains("Error:") || line.Contains("at ") || line.TrimStart().StartsWith("Caused by"); if (!inBlock && isEx) { inBlock = true; current.Clear(); current.AppendLine(line); } else if (inBlock) { if (isEx || line.TrimStart().StartsWith("at ") || line.TrimStart().StartsWith("...")) current.AppendLine(line); else { if (current.Length > 0) blocks.Add(current.ToString().Trim()); inBlock = false; current.Clear(); } } } if (inBlock && current.Length > 0) blocks.Add(current.ToString().Trim()); return blocks; } // ── 파서·헬퍼 ─────────────────────────────────────────────────────────── private static List ParseLogLines(string src) { var lines = src.Split('\n'); return lines.Select((raw, i) => { var level = DetectLevel(raw); return new LogLine(i + 1, level, raw.TrimEnd('\r')); }).ToList(); } private static string DetectLevel(string line) { foreach (var (level, keywords) in LevelPatterns) if (keywords.Any(kw => line.Contains(kw, StringComparison.Ordinal))) return level; return "OTHER"; } private static LauncherItem BuildLineItem(LogLine l) { var icon = l.Level switch { "ERROR" => "🔴", "WARN" => "🟡", "INFO" => "🔵", "DEBUG" => "⚪", _ => " " }; var display = TruncateStr(l.Raw, 80); return new LauncherItem($"{icon} {display}", $"줄 {l.No} ({l.Level})", null, ("copy", l.Raw), Symbol: "\uE9D9"); } private static string TruncateStr(string s, int max) => s.Length <= max ? s : s[..max] + "…"; private static LauncherItem CopyItem(string label, string value) => new(label, value, null, ("copy", value), Symbol: "\uE9D9"); private static LauncherItem ErrorItem(string msg) => new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783"); [GeneratedRegex(@"\d{4}[-/]\d{2}[-/]\d{2}")] private static partial Regex DatePattern(); }