Skip to main content

hematite/agent/
report_export.rs

1use serde_json::json;
2use std::path::PathBuf;
3
4const REPORT_TOPICS: &[(&str, &str)] = &[
5    ("health_report", "System Health"),
6    ("hardware", "Hardware"),
7    ("storage", "Storage"),
8    ("network", "Network"),
9    ("security", "Security"),
10    ("toolchains", "Developer Toolchains"),
11];
12
13/// IT-first-look triage topics (health, security, connectivity, identity, updates).
14const TRIAGE_TOPICS: &[(&str, &str)] = &[
15    ("health_report", "System Health"),
16    ("security", "Security Posture"),
17    ("connectivity", "Connectivity"),
18    ("identity_auth", "Identity & Auth (M365/AAD)"),
19    ("updates", "Windows Updates"),
20];
21
22fn triage_topics_for_preset(preset: &str) -> &'static [(&'static str, &'static str)] {
23    match preset {
24        "network" => &[
25            ("connectivity", "Connectivity"),
26            ("wifi", "Wi-Fi"),
27            ("latency", "Latency"),
28            ("dns_servers", "DNS Servers"),
29            ("vpn", "VPN"),
30            ("proxy", "Proxy"),
31            ("connections", "Active Connections"),
32        ],
33        "security" => &[
34            ("security", "Security Posture"),
35            ("bitlocker", "BitLocker"),
36            ("tpm", "TPM / Secure Boot"),
37            ("local_security_policy", "Local Security Policy"),
38            ("shares", "SMB Shares"),
39            ("print_spooler", "Print Spooler"),
40        ],
41        "performance" => &[
42            ("resource_load", "Resource Load"),
43            ("thermal", "Thermal"),
44            ("cpu_power", "CPU Power"),
45            ("processes", "Top Processes"),
46            ("pagefile", "Page File"),
47            ("startup_items", "Startup Items"),
48        ],
49        "storage" => &[
50            ("storage", "Storage"),
51            ("disk_health", "Disk Health"),
52            ("shadow_copies", "Shadow Copies"),
53            ("storage_spaces", "Storage Spaces"),
54            ("bitlocker", "BitLocker"),
55        ],
56        "apps" => &[
57            ("browser_health", "Browser Health"),
58            ("outlook", "Outlook"),
59            ("teams", "Teams"),
60            ("installer_health", "Installer Health"),
61            ("onedrive", "OneDrive"),
62        ],
63        _ => TRIAGE_TOPICS,
64    }
65}
66
67fn triage_preset_title(preset: &str) -> &'static str {
68    match preset {
69        "network" => "Hematite Network Triage Report",
70        "security" => "Hematite Security Triage Report",
71        "performance" => "Hematite Performance Triage Report",
72        "storage" => "Hematite Storage Triage Report",
73        "apps" => "Hematite App Health Triage Report",
74        _ => "Hematite IT Triage Report",
75    }
76}
77
78/// Map a plain-English issue description to the most relevant inspect_host topics.
79fn topics_for_issue(issue: &str) -> Vec<(&'static str, &'static str)> {
80    let lower = issue.to_ascii_lowercase();
81    let mut seen = std::collections::HashSet::new();
82    let mut topics: Vec<(&'static str, &'static str)> = Vec::new();
83
84    macro_rules! add_if {
85        ($keywords:expr, $pairs:expr) => {
86            if $keywords.iter().any(|k: &&str| lower.contains(k)) {
87                for &pair in $pairs {
88                    if seen.insert(pair.0) {
89                        topics.push(pair);
90                    }
91                }
92            }
93        };
94    }
95
96    add_if!(
97        &[
98            "slow",
99            "lag",
100            "freeze",
101            "hang",
102            "sluggish",
103            "unresponsive",
104            "performance",
105            "high cpu",
106            "high ram",
107            "high memory",
108            "locking up"
109        ],
110        &[
111            ("resource_load", "Resource Load"),
112            ("thermal", "Thermal"),
113            ("cpu_power", "CPU Power"),
114            ("pagefile", "Page File"),
115            ("startup_items", "Startup Items")
116        ]
117    );
118    add_if!(
119        &[
120            "internet",
121            "network",
122            "wifi",
123            "wi-fi",
124            "wireless",
125            "offline",
126            "no web",
127            "can't browse",
128            "ping fails",
129            "no connection",
130            "can't connect"
131        ],
132        &[
133            ("connectivity", "Connectivity"),
134            ("wifi", "Wi-Fi"),
135            ("latency", "Latency"),
136            ("dns_servers", "DNS Servers")
137        ]
138    );
139    add_if!(
140        &["dns ", "dns:", "name resolution", "can't resolve"],
141        &[
142            ("dns_servers", "DNS Servers"),
143            ("connectivity", "Connectivity")
144        ]
145    );
146    add_if!(
147        &["vpn ", "vpn:", "tunnel", "remote access"],
148        &[
149            ("vpn", "VPN"),
150            ("connectivity", "Connectivity"),
151            ("proxy", "Proxy")
152        ]
153    );
154    add_if!(
155        &[
156            "disk full",
157            "out of space",
158            "low disk",
159            "disk space",
160            "drive full",
161            "storage full",
162            "no space"
163        ],
164        &[
165            ("storage", "Storage"),
166            ("disk_health", "Disk Health"),
167            ("shadow_copies", "Shadow Copies")
168        ]
169    );
170    add_if!(
171        &[
172            "disk fail",
173            "drive fail",
174            "smart error",
175            "disk error",
176            "bad sector",
177            "drive health"
178        ],
179        &[("disk_health", "Disk Health"), ("storage", "Storage")]
180    );
181    add_if!(
182        &[
183            "slow boot",
184            "boot slow",
185            "slow startup",
186            "startup slow",
187            "takes forever to boot"
188        ],
189        &[
190            ("startup_items", "Startup Items"),
191            ("services", "Services"),
192            ("disk_health", "Disk Health")
193        ]
194    );
195    add_if!(
196        &[
197            "crash",
198            "bsod",
199            "blue screen",
200            "unexpected restart",
201            "unexpected shutdown",
202            "kernel panic",
203            "stop error"
204        ],
205        &[
206            ("recent_crashes", "Crash History"),
207            ("log_check", "Event Log"),
208            ("thermal", "Thermal"),
209            ("disk_health", "Disk Health")
210        ]
211    );
212    add_if!(
213        &[
214            "app crash",
215            "application crash",
216            "program crash",
217            "program not opening",
218            "app not starting",
219            "not responding",
220            "application error"
221        ],
222        &[
223            ("app_crashes", "Application Crashes"),
224            ("log_check", "Event Log")
225        ]
226    );
227    add_if!(
228        &[
229            "update",
230            "windows update",
231            "patch",
232            "stuck on update",
233            "update fail"
234        ],
235        &[
236            ("updates", "Windows Updates"),
237            ("pending_reboot", "Pending Reboot"),
238            ("services", "Services")
239        ]
240    );
241    add_if!(
242        &[
243            "virus",
244            "malware",
245            "hacked",
246            "suspicious",
247            "threat",
248            "infected",
249            "ransomware"
250        ],
251        &[
252            ("security", "Security Posture"),
253            ("defender_quarantine", "Defender Quarantine"),
254            ("log_check", "Event Log")
255        ]
256    );
257    add_if!(
258        &[
259            "firewall",
260            "blocked port",
261            "blocked connection",
262            "port block"
263        ],
264        &[
265            ("security", "Security Posture"),
266            ("firewall_rules", "Firewall Rules")
267        ]
268    );
269    add_if!(
270        &[
271            "printer",
272            "printing",
273            "print queue",
274            "can't print",
275            "print fail"
276        ],
277        &[
278            ("printers", "Printers"),
279            ("print_spooler", "Print Spooler"),
280            ("drivers", "Drivers")
281        ]
282    );
283    add_if!(
284        &[
285            "sound",
286            "audio",
287            "speaker",
288            "no sound",
289            "headset",
290            "mic",
291            "microphone",
292            "crackling",
293            "audio fail"
294        ],
295        &[("audio", "Audio")]
296    );
297    add_if!(
298        &[
299            "bluetooth",
300            "headphones",
301            "airpods",
302            "wireless headset",
303            "bt "
304        ],
305        &[("bluetooth", "Bluetooth"), ("audio", "Audio")]
306    );
307    add_if!(
308        &[
309            "camera",
310            "webcam",
311            "video call",
312            "camera not working",
313            "can't see camera"
314        ],
315        &[("camera", "Camera")]
316    );
317    add_if!(
318        &["teams", "microsoft teams"],
319        &[
320            ("teams", "Teams"),
321            ("identity_auth", "Identity & Auth"),
322            ("browser_health", "Browser Health")
323        ]
324    );
325    add_if!(
326        &["outlook", "email not working", "mail not", "calendar not"],
327        &[("outlook", "Outlook"), ("identity_auth", "Identity & Auth")]
328    );
329    add_if!(
330        &[
331            "browser",
332            "chrome",
333            "edge ",
334            "firefox",
335            "slow browser",
336            "browser crash",
337            "browser not"
338        ],
339        &[("browser_health", "Browser Health")]
340    );
341    add_if!(
342        &[
343            "sign in",
344            "can't log in",
345            "login fail",
346            "password",
347            "pin not working",
348            "fingerprint",
349            "hello not",
350            "locked out",
351            "authentication fail"
352        ],
353        &[
354            ("sign_in", "Sign-In / Windows Hello"),
355            ("identity_auth", "Identity & Auth"),
356            ("credentials", "Credentials")
357        ]
358    );
359    add_if!(
360        &[
361            "rdp",
362            "remote desktop",
363            "can't connect remotely",
364            "remote desktop not"
365        ],
366        &[
367            ("rdp", "Remote Desktop"),
368            ("connectivity", "Connectivity"),
369            ("firewall_rules", "Firewall Rules")
370        ]
371    );
372    add_if!(
373        &[
374            "device not recognized",
375            "driver not",
376            "usb not working",
377            "device problem",
378            "yellow bang",
379            "hardware not"
380        ],
381        &[
382            ("device_health", "Device Health"),
383            ("drivers", "Drivers"),
384            ("peripherals", "Peripherals")
385        ]
386    );
387    add_if!(
388        &[
389            "time wrong",
390            "clock wrong",
391            "wrong time",
392            "time sync",
393            "time off"
394        ],
395        &[("ntp", "NTP / Time Sync")]
396    );
397    add_if!(
398        &[
399            "onedrive",
400            "one drive",
401            "file sync",
402            "not syncing",
403            "sync fail"
404        ],
405        &[("onedrive", "OneDrive")]
406    );
407    add_if!(
408        &["wmi error", "powershell wmi", "get-wmiobject fail"],
409        &[("wmi_health", "WMI Health")]
410    );
411
412    if topics.is_empty() {
413        topics.push(("health_report", "System Health"));
414        topics.push(("log_check", "Event Log"));
415    }
416    topics
417}
418
419pub fn fix_plan_topics(issue: &str) -> Vec<(&'static str, &'static str)> {
420    topics_for_issue(issue)
421}
422
423struct AutoCmdAc {
424    ac: aho_corasick::AhoCorasick,
425    entries: Vec<(&'static str, &'static str)>,
426}
427
428static AUTO_CMD_AC: std::sync::OnceLock<AutoCmdAc> = std::sync::OnceLock::new();
429
430fn auto_cmd_ac() -> &'static AutoCmdAc {
431    AUTO_CMD_AC.get_or_init(|| {
432        const SAFE: &[(&'static str, &'static str, &'static str)] = &[
433            ("dns: failed", "Flush DNS cache", "ipconfig /flushdns"),
434            (
435                "dns resolution: failed",
436                "Flush DNS cache",
437                "ipconfig /flushdns",
438            ),
439            (
440                "wsearch",
441                "Restart Windows Search",
442                "powershell -Command \"Restart-Service WSearch -ErrorAction SilentlyContinue\"",
443            ),
444            (
445                "windows search",
446                "Restart Windows Search",
447                "powershell -Command \"Restart-Service WSearch -ErrorAction SilentlyContinue\"",
448            ),
449            (
450                "spooler",
451                "Restart Print Spooler",
452                "powershell -Command \"Restart-Service Spooler -Force\"",
453            ),
454            (
455                "print spooler",
456                "Restart Print Spooler",
457                "powershell -Command \"Restart-Service Spooler -Force\"",
458            ),
459            (
460                "ntp source unreachable",
461                "Resync system clock",
462                "w32tm /resync /force",
463            ),
464            (
465                "time sync failed",
466                "Resync system clock",
467                "w32tm /resync /force",
468            ),
469            (
470                "bits",
471                "Restart BITS service",
472                "powershell -Command \"Restart-Service BITS -Force\"",
473            ),
474            (
475                "wuauserv",
476                "Restart Windows Update service",
477                "powershell -Command \"Restart-Service wuauserv -Force\"",
478            ),
479            (
480                "windows update service",
481                "Restart Windows Update service",
482                "powershell -Command \"Restart-Service wuauserv -Force\"",
483            ),
484            (
485                "audiosrv",
486                "Restart Audio service",
487                "powershell -Command \"Restart-Service Audiosrv -Force\"",
488            ),
489            (
490                "windows audio",
491                "Restart Audio service",
492                "powershell -Command \"Restart-Service Audiosrv -Force\"",
493            ),
494            (
495                "low disk",
496                "Empty Recycle Bin",
497                "powershell -Command \"Clear-RecycleBin -Force -ErrorAction SilentlyContinue\"",
498            ),
499            (
500                "free up space",
501                "Empty Recycle Bin",
502                "powershell -Command \"Clear-RecycleBin -Force -ErrorAction SilentlyContinue\"",
503            ),
504        ];
505        let mut patterns: Vec<&str> = Vec::new();
506        let mut entries: Vec<(&'static str, &'static str)> = Vec::new();
507        for &(trigger, label, cmd) in SAFE {
508            patterns.push(trigger);
509            entries.push((label, cmd));
510        }
511        AutoCmdAc {
512            ac: aho_corasick::AhoCorasick::new(&patterns).expect("valid patterns"),
513            entries,
514        }
515    })
516}
517
518pub fn fix_plan_auto_commands(combined_output: &str) -> Vec<(&'static str, &'static str)> {
519    let lower = combined_output.to_ascii_lowercase();
520    let state = auto_cmd_ac();
521    let mut seen_labels = std::collections::HashSet::new();
522    let mut result: Vec<(&'static str, &'static str)> = Vec::new();
523    for mat in state.ac.find_iter(&lower) {
524        let (label, cmd) = state.entries[mat.pattern().as_usize()];
525        if seen_labels.insert(label) {
526            result.push((label, cmd));
527        }
528    }
529    result
530}
531
532pub fn report_has_issues_in_content(content: &str) -> bool {
533    for line in content.lines() {
534        if line.contains("Health Score:") {
535            if let Some(pos) = line.find("Score:") {
536                let after = line[pos + 6..]
537                    .trim_start()
538                    .trim_start_matches('*')
539                    .trim_start();
540                return !after.starts_with('A');
541            }
542        }
543    }
544    false
545}
546
547pub fn fix_issue_categories() -> &'static [(&'static str, &'static str)] {
548    &[
549        (
550            "Performance",
551            "slow, lag, freeze, hang, high cpu, high ram, unresponsive",
552        ),
553        (
554            "Network",
555            "internet, wifi, offline, no connection, can't browse",
556        ),
557        ("DNS", "dns, name resolution, can't resolve"),
558        ("VPN", "vpn, tunnel, remote access"),
559        (
560            "Disk Space",
561            "disk full, out of space, low disk, drive full",
562        ),
563        (
564            "Disk Health",
565            "disk fail, smart error, bad sector, drive health",
566        ),
567        (
568            "Slow Boot",
569            "slow boot, startup slow, takes forever to boot",
570        ),
571        (
572            "Crash / BSOD",
573            "crash, bsod, blue screen, stop error, kernel panic",
574        ),
575        (
576            "App Crashes",
577            "app crash, not responding, application error",
578        ),
579        (
580            "Windows Update",
581            "update, windows update, patch, stuck on update",
582        ),
583        (
584            "Virus / Malware",
585            "virus, malware, hacked, threat, infected, ransomware",
586        ),
587        ("Firewall", "firewall, blocked port, blocked connection"),
588        ("Printer", "printer, printing, print queue, can't print"),
589        ("Audio", "sound, audio, no sound, speaker, mic, microphone"),
590        ("Bluetooth", "bluetooth, headphones, wireless headset"),
591        ("Camera", "camera, webcam, video call"),
592        ("Teams", "teams, microsoft teams"),
593        (
594            "Outlook / Email",
595            "outlook, email not working, calendar not",
596        ),
597        ("Browser", "browser, chrome, edge, firefox, slow browser"),
598        (
599            "Sign-In / PIN",
600            "sign in, can't log in, pin not working, fingerprint, locked out",
601        ),
602        (
603            "Remote Desktop",
604            "rdp, remote desktop, can't connect remotely",
605        ),
606        (
607            "Driver / Device",
608            "device not recognized, driver not, usb not working, yellow bang",
609        ),
610        ("Clock / Time", "time wrong, clock wrong, time sync"),
611        ("OneDrive", "onedrive, file sync, not syncing"),
612        ("WMI", "wmi error, powershell wmi"),
613    ]
614}
615
616pub async fn generate_report_markdown() -> String {
617    let timestamp = now_timestamp_string();
618    let mut hostname = hostname_from_env();
619    let version = env!("CARGO_PKG_VERSION");
620    let mut sections: Vec<(&str, String)> = Vec::new();
621
622    let total = REPORT_TOPICS.len();
623    for (i, (topic, label)) in REPORT_TOPICS.iter().enumerate() {
624        eprintln!("  [{}/{}] {}...", i + 1, total, label);
625        let args = json!({"topic": topic});
626        let output = match crate::tools::host_inspect::inspect_host(&args).await {
627            Ok(s) => {
628                if *topic == "hardware" {
629                    for line in s.lines() {
630                        let ll = line.to_ascii_lowercase();
631                        if ll.contains("hostname") || ll.contains("computer name") {
632                            if let Some(val) = line.splitn(2, ':').nth(1) {
633                                let h = val.trim().to_string();
634                                if !h.is_empty() {
635                                    hostname = h;
636                                }
637                            }
638                        }
639                    }
640                }
641                s
642            }
643            Err(e) => format!("Error: {}", e),
644        };
645        sections.push((label, output));
646    }
647
648    let section_refs: Vec<(&str, &str)> = sections.iter().map(|(l, o)| (*l, o.as_str())).collect();
649    let score = crate::agent::fix_recipes::score_health(&section_refs);
650    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
651
652    let mut md = String::new();
653    md.push_str("# Hematite Diagnostic Report\n\n");
654    md.push_str(&format!("**Generated:** {}  \n", timestamp));
655    md.push_str(&format!("**Host:** {}  \n", hostname));
656    md.push_str(&format!("**Hematite:** v{}  \n", version));
657    md.push_str(&format!(
658        "**Health Score:** {} — {}  \n\n",
659        score.grade, score.label
660    ));
661    md.push_str(&format!("> {}\n\n", score.summary_line()));
662    md.push_str("---\n\n");
663
664    md.push_str("## Action Plan\n\n");
665    md.push_str(&action_plan);
666    md.push_str("---\n\n");
667
668    for (label, output) in &sections {
669        md.push_str(&format!("## {}\n\n", label));
670        md.push_str("```\n");
671        md.push_str(output.trim_end());
672        md.push_str("\n```\n\n");
673    }
674
675    md
676}
677
678struct DiagnosisData {
679    timestamp: String,
680    hostname: String,
681    health_output: String,
682    follow_up_outputs: Vec<(&'static str, String)>,
683}
684
685async fn run_diagnosis_phases() -> DiagnosisData {
686    let timestamp = now_timestamp_string();
687    let hostname = hostname_from_env();
688
689    eprintln!("  → System Health (scanning for issues)...");
690    let health_args = json!({"topic": "health_report"});
691    let health_output = match crate::tools::host_inspect::inspect_host(&health_args).await {
692        Ok(s) => s,
693        Err(e) => format!("Error running health_report: {}", e),
694    };
695
696    let follow_up_topics = crate::agent::diagnose::triage_follow_up_topics(&health_output);
697
698    if follow_up_topics.is_empty() {
699        eprintln!("  → No follow-up checks needed.");
700    } else {
701        eprintln!(
702            "  → {} area(s) flagged — running targeted checks...",
703            follow_up_topics.len()
704        );
705    }
706
707    let mut follow_up_outputs: Vec<(&'static str, String)> = Vec::new();
708    for (i, topic) in follow_up_topics.iter().enumerate() {
709        eprintln!("  [{}/{}] {}...", i + 1, follow_up_topics.len(), topic);
710        let args = json!({"topic": topic});
711        let output = match crate::tools::host_inspect::inspect_host(&args).await {
712            Ok(s) => s,
713            Err(e) => format!("Error: {}", e),
714        };
715        follow_up_outputs.push((*topic, output));
716    }
717
718    DiagnosisData {
719        timestamp,
720        hostname,
721        health_output,
722        follow_up_outputs,
723    }
724}
725
726/// Run a full staged diagnosis — health_report → triage → targeted follow-ups → fix recipes.
727/// No TUI, no model required. Output is self-contained markdown for cloud model ingestion.
728pub async fn generate_diagnosis_report() -> String {
729    let version = env!("CARGO_PKG_VERSION");
730    let data = run_diagnosis_phases().await;
731
732    let mut section_refs: Vec<(&str, &str)> = vec![("health_report", data.health_output.as_str())];
733    for (topic, output) in &data.follow_up_outputs {
734        section_refs.push((*topic, output.as_str()));
735    }
736    let score = crate::agent::fix_recipes::score_health(&section_refs);
737    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
738
739    let mut md = String::new();
740    md.push_str("# Hematite Staged Diagnosis Report\n\n");
741    md.push_str(&format!("**Generated:** {}  \n", data.timestamp));
742    md.push_str(&format!("**Host:** {}  \n", data.hostname));
743    md.push_str(&format!("**Hematite:** v{}  \n", version));
744    md.push_str(&format!(
745        "**Health Score:** {} — {}  \n\n",
746        score.grade, score.label
747    ));
748    md.push_str(&format!("> {}\n\n", score.summary_line()));
749    md.push_str("---\n\n");
750    md.push_str("## Action Plan\n\n");
751    md.push_str(&action_plan);
752    md.push_str("---\n\n");
753    md.push_str("## System Health\n\n```\n");
754    md.push_str(data.health_output.trim_end());
755    md.push_str("\n```\n\n");
756
757    if !data.follow_up_outputs.is_empty() {
758        md.push_str("## Targeted Investigation\n\n");
759        for (topic, output) in &data.follow_up_outputs {
760            md.push_str(&format!("### {}\n\n```\n", topic));
761            md.push_str(output.trim_end());
762            md.push_str("\n```\n\n");
763        }
764    }
765
766    md
767}
768
769/// Same as generate_diagnosis_report but outputs a self-contained HTML file.
770pub async fn generate_diagnosis_report_html() -> String {
771    let version = env!("CARGO_PKG_VERSION");
772    let data = run_diagnosis_phases().await;
773
774    let mut section_refs: Vec<(&str, &str)> = vec![("health_report", data.health_output.as_str())];
775    for (topic, output) in &data.follow_up_outputs {
776        section_refs.push((*topic, output.as_str()));
777    }
778    let score = crate::agent::fix_recipes::score_health(&section_refs);
779    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
780
781    let mut sections: Vec<(&str, String)> = vec![("System Health", data.health_output.clone())];
782    for (topic, output) in &data.follow_up_outputs {
783        sections.push((*topic, output.clone()));
784    }
785
786    build_html_document(
787        "Hematite Staged Diagnosis",
788        &data.timestamp,
789        &data.hostname,
790        version,
791        &score,
792        &action_plan_html,
793        &sections,
794    )
795}
796
797pub async fn generate_report_json() -> String {
798    let timestamp = now_timestamp_string();
799    let hostname = hostname_from_env();
800    let version = env!("CARGO_PKG_VERSION");
801    let mut obj = serde_json::Map::new();
802    obj.insert("generated".into(), json!(timestamp));
803    obj.insert("host".into(), json!(hostname));
804    obj.insert("hematite_version".into(), json!(version));
805
806    let total = REPORT_TOPICS.len();
807    for (i, (topic, label)) in REPORT_TOPICS.iter().enumerate() {
808        eprintln!("  [{}/{}] {}...", i + 1, total, label);
809        let args = json!({"topic": topic});
810        let value = match crate::tools::host_inspect::inspect_host(&args).await {
811            Ok(output) => json!({"label": label, "output": output}),
812            Err(e) => json!({"label": label, "error": e}),
813        };
814        obj.insert(topic.to_string(), value);
815    }
816
817    serde_json::to_string_pretty(&serde_json::Value::Object(obj))
818        .unwrap_or_else(|e| format!("{{\"error\": \"{}\"}}", e))
819}
820
821/// Runs diagnostic topics, writes to `.hematite/reports/health-<timestamp>.md`,
822/// and returns `(markdown_content, saved_path)`.
823pub async fn save_report_markdown() -> (String, PathBuf) {
824    let md = generate_report_markdown().await;
825    let path = report_path("md");
826    ensure_parent(&path);
827    let _ = std::fs::write(&path, &md);
828    (md, path)
829}
830
831/// Same as `save_report_markdown` but JSON format.
832pub async fn save_report_json() -> (String, PathBuf) {
833    let json = generate_report_json().await;
834    let path = report_path("json");
835    ensure_parent(&path);
836    let _ = std::fs::write(&path, &json);
837    (json, path)
838}
839
840/// Self-contained HTML diagnostic report — double-clickable, no external deps.
841pub async fn generate_report_html() -> String {
842    let timestamp = now_timestamp_string();
843    let mut hostname = hostname_from_env();
844    let version = env!("CARGO_PKG_VERSION");
845    let mut sections: Vec<(&str, String)> = Vec::new();
846
847    let total = REPORT_TOPICS.len();
848    for (i, (topic, label)) in REPORT_TOPICS.iter().enumerate() {
849        eprintln!("  [{}/{}] {}...", i + 1, total, label);
850        let args = json!({"topic": topic});
851        let output = match crate::tools::host_inspect::inspect_host(&args).await {
852            Ok(s) => {
853                if *topic == "hardware" {
854                    for line in s.lines() {
855                        let ll = line.to_ascii_lowercase();
856                        if ll.contains("hostname") || ll.contains("computer name") {
857                            if let Some(val) = line.splitn(2, ':').nth(1) {
858                                let h = val.trim().to_string();
859                                if !h.is_empty() {
860                                    hostname = h;
861                                }
862                            }
863                        }
864                    }
865                }
866                s
867            }
868            Err(e) => format!("Error: {}", e),
869        };
870        sections.push((label, output));
871    }
872
873    let section_refs: Vec<(&str, &str)> = sections.iter().map(|(l, o)| (*l, o.as_str())).collect();
874    let score = crate::agent::fix_recipes::score_health(&section_refs);
875    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
876
877    build_html_document(
878        "Hematite Diagnostic Report",
879        &timestamp,
880        &hostname,
881        version,
882        &score,
883        &action_plan_html,
884        &sections,
885    )
886}
887
888pub async fn save_report_html() -> (String, PathBuf) {
889    let html = generate_report_html().await;
890    let path = report_path("html");
891    ensure_parent(&path);
892    let _ = std::fs::write(&path, &html);
893    (html, path)
894}
895
896pub async fn save_diagnosis_report() -> (String, PathBuf) {
897    let md = generate_diagnosis_report().await;
898    let path = crate::tools::file_ops::hematite_dir()
899        .join("reports")
900        .join(format!("diagnosis-{}.md", now_file_timestamp()));
901    ensure_parent(&path);
902    let _ = std::fs::write(&path, &md);
903    (md, path)
904}
905
906pub async fn save_diagnosis_report_html() -> (String, PathBuf) {
907    let html = generate_diagnosis_report_html().await;
908    let path = crate::tools::file_ops::hematite_dir()
909        .join("reports")
910        .join(format!("diagnosis-{}.html", now_file_timestamp()));
911    ensure_parent(&path);
912    let _ = std::fs::write(&path, &html);
913    (html, path)
914}
915
916fn build_html_document(
917    title: &str,
918    timestamp: &str,
919    hostname: &str,
920    version: &str,
921    score: &crate::agent::fix_recipes::HealthScore,
922    action_plan_html: &str,
923    sections: &[(&str, String)],
924) -> String {
925    use crate::agent::html_template::{build_html_shell, he, COPY_BUTTON_HTML};
926
927    let mut sections_html = String::new();
928    for (label, output) in sections {
929        sections_html.push_str(&format!(
930            "<details><summary>{}</summary><pre>{}</pre></details>\n",
931            he(label),
932            he(output.trim_end())
933        ));
934    }
935
936    let content = format!(
937        r#"<header>
938<h1>{title}</h1>
939<div class="meta">
940  <span>Generated: {timestamp}</span>
941  <span>Host: {hostname}</span>
942  <span>Hematite v{version}</span>
943</div>
944<div class="score-row">
945  <div class="grade g{grade}">{grade}</div>
946  <div class="score-info">
947    <h2>Health Score: {grade} — {label}</h2>
948    <p>{summary}</p>
949  </div>
950</div>
951<p class="grade-intro">{intro}</p>
952{copy_btn}
953</header>
954<section>
955<h2>Action Plan</h2>
956{action_plan_html}
957</section>
958<section>
959<h2>Diagnostic Data</h2>
960{sections_html}
961</section>"#,
962        title = he(title),
963        hostname = he(hostname),
964        timestamp = he(timestamp),
965        version = he(version),
966        grade = score.grade,
967        label = he(score.label),
968        summary = he(&score.summary_line()),
969        intro = he(score.grade_intro()),
970        copy_btn = COPY_BUTTON_HTML,
971        action_plan_html = action_plan_html,
972        sections_html = sections_html,
973    );
974
975    let page_title = format!("{} — {}", he(title), he(hostname));
976    build_html_shell(&page_title, version, &content)
977}
978
979// ── Triage report (IT-first-look, no model required) ─────────────────────────
980
981struct TriageData {
982    timestamp: String,
983    hostname: String,
984    sections: Vec<(&'static str, String)>,
985}
986
987async fn run_triage_phases(preset: &str) -> TriageData {
988    let topics = triage_topics_for_preset(preset);
989    let total = topics.len();
990    let timestamp = now_timestamp_string();
991    let mut hostname = hostname_from_env();
992    let mut sections: Vec<(&'static str, String)> = Vec::new();
993
994    for (i, &(topic, label)) in topics.iter().enumerate() {
995        eprintln!("  [{}/{}] {}...", i + 1, total, label);
996        let args = serde_json::json!({"topic": topic});
997        let output = match crate::tools::host_inspect::inspect_host(&args).await {
998            Ok(s) => {
999                if topic == "health_report" {
1000                    for line in s.lines() {
1001                        let ll = line.to_ascii_lowercase();
1002                        if ll.contains("hostname") || ll.contains("computer name") {
1003                            if let Some(val) = line.splitn(2, ':').nth(1) {
1004                                let h = val.trim().to_string();
1005                                if !h.is_empty() {
1006                                    hostname = h;
1007                                }
1008                            }
1009                        }
1010                    }
1011                }
1012                s
1013            }
1014            Err(e) => format!("Error: {}", e),
1015        };
1016        sections.push((label, output));
1017    }
1018
1019    TriageData {
1020        timestamp,
1021        hostname,
1022        sections,
1023    }
1024}
1025
1026pub async fn generate_triage_report_markdown(preset: &str) -> String {
1027    let title = triage_preset_title(preset);
1028    let data = run_triage_phases(preset).await;
1029    let version = env!("CARGO_PKG_VERSION");
1030
1031    let section_refs: Vec<(&str, &str)> = data
1032        .sections
1033        .iter()
1034        .map(|(l, o)| (*l, o.as_str()))
1035        .collect();
1036    let score = crate::agent::fix_recipes::score_health(&section_refs);
1037    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
1038
1039    let mut md = String::new();
1040    md.push_str(&format!("# {}\n\n", title));
1041    md.push_str(&format!("**Generated:** {}  \n", data.timestamp));
1042    md.push_str(&format!("**Host:** {}  \n", data.hostname));
1043    md.push_str(&format!("**Hematite:** v{}  \n", version));
1044    md.push_str(&format!(
1045        "**Health Score:** {} — {}  \n\n",
1046        score.grade, score.label
1047    ));
1048    md.push_str(&format!("> {}\n\n", score.summary_line()));
1049    md.push_str("---\n\n## Action Plan\n\n");
1050    md.push_str(&action_plan);
1051    md.push_str("---\n\n");
1052    for (label, output) in &data.sections {
1053        md.push_str(&format!("## {}\n\n```\n", label));
1054        md.push_str(output.trim_end());
1055        md.push_str("\n```\n\n");
1056    }
1057    md
1058}
1059
1060pub async fn generate_triage_report_html(preset: &str) -> String {
1061    let title = triage_preset_title(preset);
1062    let data = run_triage_phases(preset).await;
1063    let version = env!("CARGO_PKG_VERSION");
1064
1065    let section_refs: Vec<(&str, &str)> = data
1066        .sections
1067        .iter()
1068        .map(|(l, o)| (*l, o.as_str()))
1069        .collect();
1070    let score = crate::agent::fix_recipes::score_health(&section_refs);
1071    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
1072
1073    build_html_document(
1074        title,
1075        &data.timestamp,
1076        &data.hostname,
1077        version,
1078        &score,
1079        &action_plan_html,
1080        &data.sections,
1081    )
1082}
1083
1084pub async fn save_triage_report(preset: &str) -> (String, PathBuf) {
1085    let md = generate_triage_report_markdown(preset).await;
1086    let path = crate::tools::file_ops::hematite_dir()
1087        .join("reports")
1088        .join(format!("triage-{}.md", now_file_timestamp()));
1089    ensure_parent(&path);
1090    let _ = std::fs::write(&path, &md);
1091    (md, path)
1092}
1093
1094pub async fn save_triage_report_html(preset: &str) -> (String, PathBuf) {
1095    let html = generate_triage_report_html(preset).await;
1096    let path = crate::tools::file_ops::hematite_dir()
1097        .join("reports")
1098        .join(format!("triage-{}.html", now_file_timestamp()));
1099    ensure_parent(&path);
1100    let _ = std::fs::write(&path, &html);
1101    (html, path)
1102}
1103
1104// ── Fix Plan (--fix "<issue>", no model required) ─────────────────────────────
1105
1106struct FixPlanData {
1107    timestamp: String,
1108    hostname: String,
1109    sections: Vec<(&'static str, String)>,
1110}
1111
1112async fn run_fix_plan_phases(issue: &str) -> FixPlanData {
1113    let initial_topics = topics_for_issue(issue);
1114    let total = initial_topics.len();
1115    let timestamp = now_timestamp_string();
1116    let mut hostname = hostname_from_env();
1117    let mut sections: Vec<(&'static str, String)> = Vec::new();
1118
1119    for (i, &(topic, label)) in initial_topics.iter().enumerate() {
1120        eprintln!("  [{}/{}] {}...", i + 1, total, label);
1121        let args = serde_json::json!({"topic": topic});
1122        let output = match crate::tools::host_inspect::inspect_host(&args).await {
1123            Ok(s) => {
1124                if topic == "health_report" {
1125                    for line in s.lines() {
1126                        let ll = line.to_ascii_lowercase();
1127                        if ll.contains("hostname") || ll.contains("computer name") {
1128                            if let Some(val) = line.splitn(2, ':').nth(1) {
1129                                let h = val.trim().to_string();
1130                                if !h.is_empty() {
1131                                    hostname = h;
1132                                }
1133                            }
1134                        }
1135                    }
1136                }
1137                s
1138            }
1139            Err(e) => format!("Error: {}", e),
1140        };
1141        sections.push((label, output));
1142    }
1143
1144    let combined: String = sections
1145        .iter()
1146        .map(|(_, o)| o.as_str())
1147        .collect::<Vec<_>>()
1148        .join("\n");
1149    let ran: Vec<&str> = initial_topics.iter().map(|&(t, _)| t).collect();
1150    let follow_ups = crate::agent::diagnose::fix_follow_up_topics(&combined, &ran);
1151
1152    if !follow_ups.is_empty() {
1153        eprintln!(
1154            "  → {} follow-up check(s) triggered by findings...",
1155            follow_ups.len()
1156        );
1157    }
1158
1159    for (i, &(topic, label)) in follow_ups.iter().enumerate() {
1160        eprintln!("  + [{}/{}] {}...", i + 1, follow_ups.len(), label);
1161        let args = serde_json::json!({"topic": topic});
1162        let output = match crate::tools::host_inspect::inspect_host(&args).await {
1163            Ok(s) => s,
1164            Err(e) => format!("Error: {}", e),
1165        };
1166        sections.push((label, output));
1167    }
1168
1169    FixPlanData {
1170        timestamp,
1171        hostname,
1172        sections,
1173    }
1174}
1175
1176pub async fn generate_fix_plan_markdown(issue: &str) -> String {
1177    let data = run_fix_plan_phases(issue).await;
1178    let version = env!("CARGO_PKG_VERSION");
1179
1180    let section_refs: Vec<(&str, &str)> = data
1181        .sections
1182        .iter()
1183        .map(|(l, o)| (*l, o.as_str()))
1184        .collect();
1185    let score = crate::agent::fix_recipes::score_health(&section_refs);
1186    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
1187
1188    let mut md = String::new();
1189    md.push_str("# Hematite Fix Plan\n\n");
1190    md.push_str(&format!("**Issue:** {}  \n", issue));
1191    md.push_str(&format!("**Generated:** {}  \n", data.timestamp));
1192    md.push_str(&format!("**Host:** {}  \n", data.hostname));
1193    md.push_str(&format!("**Hematite:** v{}  \n", version));
1194    md.push_str(&format!(
1195        "**Health Score:** {} — {}  \n\n",
1196        score.grade, score.label
1197    ));
1198    md.push_str(&format!("> {}\n\n", score.summary_line()));
1199    md.push_str("---\n\n## Fix Steps\n\n");
1200    md.push_str(&action_plan);
1201    md.push_str("---\n\n");
1202    for (label, output) in &data.sections {
1203        md.push_str(&format!("## {}\n\n```\n", label));
1204        md.push_str(output.trim_end());
1205        md.push_str("\n```\n\n");
1206    }
1207    md
1208}
1209
1210pub async fn generate_fix_plan_html(issue: &str) -> String {
1211    let data = run_fix_plan_phases(issue).await;
1212    let version = env!("CARGO_PKG_VERSION");
1213
1214    let section_refs: Vec<(&str, &str)> = data
1215        .sections
1216        .iter()
1217        .map(|(l, o)| (*l, o.as_str()))
1218        .collect();
1219    let score = crate::agent::fix_recipes::score_health(&section_refs);
1220    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
1221
1222    use crate::agent::html_template::{build_html_shell, he, COPY_BUTTON_HTML};
1223
1224    let mut sections_html = String::new();
1225    for (label, output) in &data.sections {
1226        sections_html.push_str(&format!(
1227            "<details><summary>{}</summary><pre>{}</pre></details>\n",
1228            he(label),
1229            he(output.trim_end())
1230        ));
1231    }
1232
1233    let content = format!(
1234        r#"<header>
1235<h1>Fix Plan</h1>
1236<p class="grade-intro" style="margin-bottom:.85rem">Issue: <strong>{issue}</strong></p>
1237<div class="meta">
1238  <span>Generated: {timestamp}</span>
1239  <span>Host: {hostname}</span>
1240  <span>Hematite v{version}</span>
1241</div>
1242<div class="score-row">
1243  <div class="grade g{grade}">{grade}</div>
1244  <div class="score-info">
1245    <h2>Health Score: {grade} — {label}</h2>
1246    <p>{summary}</p>
1247  </div>
1248</div>
1249{copy_btn}
1250</header>
1251<section>
1252<h2>Fix Steps</h2>
1253{action_plan_html}
1254</section>
1255<section>
1256<h2>Diagnostic Data</h2>
1257{sections_html}
1258</section>"#,
1259        issue = he(issue),
1260        hostname = he(&data.hostname),
1261        timestamp = he(&data.timestamp),
1262        version = he(version),
1263        grade = score.grade,
1264        label = he(score.label),
1265        summary = he(&score.summary_line()),
1266        copy_btn = COPY_BUTTON_HTML,
1267        action_plan_html = action_plan_html,
1268        sections_html = sections_html,
1269    );
1270
1271    let page_title = format!("Fix Plan: {} — {}", he(issue), he(&data.hostname));
1272    build_html_shell(&page_title, version, &content)
1273}
1274
1275pub async fn save_fix_plan(issue: &str) -> (String, PathBuf) {
1276    let md = generate_fix_plan_markdown(issue).await;
1277    let path = crate::tools::file_ops::hematite_dir()
1278        .join("reports")
1279        .join(format!("fix-{}.md", now_file_timestamp()));
1280    ensure_parent(&path);
1281    let _ = std::fs::write(&path, &md);
1282    (md, path)
1283}
1284
1285pub async fn save_fix_plan_html(issue: &str) -> (String, PathBuf) {
1286    let html = generate_fix_plan_html(issue).await;
1287    let path = crate::tools::file_ops::hematite_dir()
1288        .join("reports")
1289        .join(format!("fix-{}.html", now_file_timestamp()));
1290    ensure_parent(&path);
1291    let _ = std::fs::write(&path, &html);
1292    (html, path)
1293}
1294
1295/// Save arbitrary markdown content as a dark-theme HTML page.
1296/// Returns `(html_string, saved_path)`. Title defaults to a timestamp slug
1297/// if empty. Saves to `.hematite/reports/research-DATE.html`.
1298pub fn save_research_html(title: &str, body_md: &str) -> (String, PathBuf) {
1299    use crate::agent::html_template::{build_html_shell, he, markdown_to_html, COPY_BUTTON_HTML};
1300    let version = env!("CARGO_PKG_VERSION");
1301    let timestamp = now_timestamp_string();
1302    let display_title = if title.trim().is_empty() {
1303        format!("Research — {}", &timestamp[..10])
1304    } else {
1305        title.to_string()
1306    };
1307
1308    let body_html = markdown_to_html(body_md);
1309    let content = format!(
1310        r#"<header>
1311<h1>{title}</h1>
1312<div class="meta">
1313  <span>Saved: {timestamp}</span>
1314  <span>Hematite v{version}</span>
1315</div>
1316{copy_btn}
1317</header>
1318<section>
1319{body_html}
1320</section>"#,
1321        title = he(&display_title),
1322        timestamp = he(&timestamp),
1323        version = he(version),
1324        copy_btn = COPY_BUTTON_HTML,
1325        body_html = body_html,
1326    );
1327
1328    let html = build_html_shell(&display_title, version, &content);
1329    let path = crate::tools::file_ops::hematite_dir()
1330        .join("reports")
1331        .join(format!("research-{}.html", now_file_timestamp()));
1332    ensure_parent(&path);
1333    let _ = std::fs::write(&path, &html);
1334    (html, path)
1335}
1336
1337fn report_path(ext: &str) -> PathBuf {
1338    crate::tools::file_ops::hematite_dir()
1339        .join("reports")
1340        .join(format!("health-{}.{}", now_file_timestamp(), ext))
1341}
1342
1343fn ensure_parent(path: &PathBuf) {
1344    if let Some(parent) = path.parent() {
1345        let _ = std::fs::create_dir_all(parent);
1346    }
1347}
1348
1349fn now_timestamp_string() -> String {
1350    let now = unix_now();
1351    let (y, mo, d, h, mi, s) = epoch_to_ymd_hms(now);
1352    format!(
1353        "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC",
1354        y, mo, d, h, mi, s
1355    )
1356}
1357
1358fn now_file_timestamp() -> String {
1359    let now = unix_now();
1360    let (y, mo, d, h, mi, _s) = epoch_to_ymd_hms(now);
1361    format!("{:04}-{:02}-{:02}_{:02}-{:02}", y, mo, d, h, mi)
1362}
1363
1364fn unix_now() -> u64 {
1365    std::time::SystemTime::now()
1366        .duration_since(std::time::UNIX_EPOCH)
1367        .unwrap_or_default()
1368        .as_secs()
1369}
1370
1371fn hostname_from_env() -> String {
1372    std::env::var("COMPUTERNAME")
1373        .or_else(|_| std::env::var("HOSTNAME"))
1374        .unwrap_or_else(|_| "unknown".to_string())
1375}
1376
1377/// Gregorian calendar decomposition of a Unix timestamp (accurate 1970–2100).
1378fn epoch_to_ymd_hms(epoch: u64) -> (u32, u32, u32, u32, u32, u32) {
1379    let s = (epoch % 60) as u32;
1380    let mi = ((epoch / 60) % 60) as u32;
1381    let h = ((epoch / 3600) % 24) as u32;
1382    let days = epoch / 86400;
1383
1384    let years_400 = days / 146097;
1385    let rem = days % 146097;
1386    let years_100 = rem.min(146096) / 36524;
1387    let rem = rem - years_100 * 36524;
1388    let years_4 = rem / 1461;
1389    let rem = rem % 1461;
1390    let years_1 = rem.min(1460) / 365;
1391    let rem = rem - years_1 * 365;
1392
1393    let year = (1970 + years_400 * 400 + years_100 * 100 + years_4 * 4 + years_1) as u32;
1394    let leap = u32::from(year % 4 == 0 && (year % 100 != 0 || year % 400 == 0));
1395    let month_days: [u32; 12] = [31, 28 + leap, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
1396    let mut rem = rem as u32;
1397    let mut month = 1u32;
1398    for &md in &month_days {
1399        if rem < md {
1400            break;
1401        }
1402        rem -= md;
1403        month += 1;
1404    }
1405    let day = rem + 1;
1406    (year, month, day, h, mi, s)
1407}