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
423pub fn fix_plan_auto_commands(combined_output: &str) -> Vec<(&'static str, &'static str)> {
424    const SAFE: &[(&str, &str, &str)] = &[
425        ("dns: failed", "Flush DNS cache", "ipconfig /flushdns"),
426        (
427            "dns resolution: failed",
428            "Flush DNS cache",
429            "ipconfig /flushdns",
430        ),
431        (
432            "wsearch",
433            "Restart Windows Search",
434            "powershell -Command \"Restart-Service WSearch -ErrorAction SilentlyContinue\"",
435        ),
436        (
437            "windows search",
438            "Restart Windows Search",
439            "powershell -Command \"Restart-Service WSearch -ErrorAction SilentlyContinue\"",
440        ),
441        (
442            "spooler",
443            "Restart Print Spooler",
444            "powershell -Command \"Restart-Service Spooler -Force\"",
445        ),
446        (
447            "print spooler",
448            "Restart Print Spooler",
449            "powershell -Command \"Restart-Service Spooler -Force\"",
450        ),
451        (
452            "ntp source unreachable",
453            "Resync system clock",
454            "w32tm /resync /force",
455        ),
456        (
457            "time sync failed",
458            "Resync system clock",
459            "w32tm /resync /force",
460        ),
461        (
462            "bits",
463            "Restart BITS service",
464            "powershell -Command \"Restart-Service BITS -Force\"",
465        ),
466        (
467            "wuauserv",
468            "Restart Windows Update service",
469            "powershell -Command \"Restart-Service wuauserv -Force\"",
470        ),
471        (
472            "windows update service",
473            "Restart Windows Update service",
474            "powershell -Command \"Restart-Service wuauserv -Force\"",
475        ),
476        (
477            "audiosrv",
478            "Restart Audio service",
479            "powershell -Command \"Restart-Service Audiosrv -Force\"",
480        ),
481        (
482            "windows audio",
483            "Restart Audio service",
484            "powershell -Command \"Restart-Service Audiosrv -Force\"",
485        ),
486        (
487            "low disk",
488            "Empty Recycle Bin",
489            "powershell -Command \"Clear-RecycleBin -Force -ErrorAction SilentlyContinue\"",
490        ),
491        (
492            "free up space",
493            "Empty Recycle Bin",
494            "powershell -Command \"Clear-RecycleBin -Force -ErrorAction SilentlyContinue\"",
495        ),
496    ];
497
498    let lower = combined_output.to_ascii_lowercase();
499    let mut seen_labels = std::collections::HashSet::new();
500    let mut result: Vec<(&'static str, &'static str)> = Vec::new();
501    for &(trigger, label, cmd) in SAFE {
502        if lower.contains(trigger) && seen_labels.insert(label) {
503            result.push((label, cmd));
504        }
505    }
506    result
507}
508
509pub fn report_has_issues_in_content(content: &str) -> bool {
510    for line in content.lines() {
511        if line.contains("Health Score:") {
512            if let Some(pos) = line.find("Score:") {
513                let after = line[pos + 6..]
514                    .trim_start()
515                    .trim_start_matches('*')
516                    .trim_start();
517                return !after.starts_with('A');
518            }
519        }
520    }
521    false
522}
523
524pub fn fix_issue_categories() -> &'static [(&'static str, &'static str)] {
525    &[
526        (
527            "Performance",
528            "slow, lag, freeze, hang, high cpu, high ram, unresponsive",
529        ),
530        (
531            "Network",
532            "internet, wifi, offline, no connection, can't browse",
533        ),
534        ("DNS", "dns, name resolution, can't resolve"),
535        ("VPN", "vpn, tunnel, remote access"),
536        (
537            "Disk Space",
538            "disk full, out of space, low disk, drive full",
539        ),
540        (
541            "Disk Health",
542            "disk fail, smart error, bad sector, drive health",
543        ),
544        (
545            "Slow Boot",
546            "slow boot, startup slow, takes forever to boot",
547        ),
548        (
549            "Crash / BSOD",
550            "crash, bsod, blue screen, stop error, kernel panic",
551        ),
552        (
553            "App Crashes",
554            "app crash, not responding, application error",
555        ),
556        (
557            "Windows Update",
558            "update, windows update, patch, stuck on update",
559        ),
560        (
561            "Virus / Malware",
562            "virus, malware, hacked, threat, infected, ransomware",
563        ),
564        ("Firewall", "firewall, blocked port, blocked connection"),
565        ("Printer", "printer, printing, print queue, can't print"),
566        ("Audio", "sound, audio, no sound, speaker, mic, microphone"),
567        ("Bluetooth", "bluetooth, headphones, wireless headset"),
568        ("Camera", "camera, webcam, video call"),
569        ("Teams", "teams, microsoft teams"),
570        (
571            "Outlook / Email",
572            "outlook, email not working, calendar not",
573        ),
574        ("Browser", "browser, chrome, edge, firefox, slow browser"),
575        (
576            "Sign-In / PIN",
577            "sign in, can't log in, pin not working, fingerprint, locked out",
578        ),
579        (
580            "Remote Desktop",
581            "rdp, remote desktop, can't connect remotely",
582        ),
583        (
584            "Driver / Device",
585            "device not recognized, driver not, usb not working, yellow bang",
586        ),
587        ("Clock / Time", "time wrong, clock wrong, time sync"),
588        ("OneDrive", "onedrive, file sync, not syncing"),
589        ("WMI", "wmi error, powershell wmi"),
590    ]
591}
592
593pub async fn generate_report_markdown() -> String {
594    let timestamp = now_timestamp_string();
595    let mut hostname = hostname_from_env();
596    let version = env!("CARGO_PKG_VERSION");
597    let mut sections: Vec<(&str, String)> = Vec::new();
598
599    let total = REPORT_TOPICS.len();
600    for (i, (topic, label)) in REPORT_TOPICS.iter().enumerate() {
601        eprintln!("  [{}/{}] {}...", i + 1, total, label);
602        let args = json!({"topic": topic});
603        let output = match crate::tools::host_inspect::inspect_host(&args).await {
604            Ok(s) => {
605                if *topic == "hardware" {
606                    for line in s.lines() {
607                        let ll = line.to_ascii_lowercase();
608                        if ll.contains("hostname") || ll.contains("computer name") {
609                            if let Some(val) = line.splitn(2, ':').nth(1) {
610                                let h = val.trim().to_string();
611                                if !h.is_empty() {
612                                    hostname = h;
613                                }
614                            }
615                        }
616                    }
617                }
618                s
619            }
620            Err(e) => format!("Error: {}", e),
621        };
622        sections.push((label, output));
623    }
624
625    let section_refs: Vec<(&str, &str)> = sections.iter().map(|(l, o)| (*l, o.as_str())).collect();
626    let score = crate::agent::fix_recipes::score_health(&section_refs);
627    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
628
629    let mut md = String::new();
630    md.push_str("# Hematite Diagnostic Report\n\n");
631    md.push_str(&format!("**Generated:** {}  \n", timestamp));
632    md.push_str(&format!("**Host:** {}  \n", hostname));
633    md.push_str(&format!("**Hematite:** v{}  \n", version));
634    md.push_str(&format!(
635        "**Health Score:** {} — {}  \n\n",
636        score.grade, score.label
637    ));
638    md.push_str(&format!("> {}\n\n", score.summary_line()));
639    md.push_str("---\n\n");
640
641    md.push_str("## Action Plan\n\n");
642    md.push_str(&action_plan);
643    md.push_str("---\n\n");
644
645    for (label, output) in &sections {
646        md.push_str(&format!("## {}\n\n", label));
647        md.push_str("```\n");
648        md.push_str(output.trim_end());
649        md.push_str("\n```\n\n");
650    }
651
652    md
653}
654
655struct DiagnosisData {
656    timestamp: String,
657    hostname: String,
658    health_output: String,
659    follow_up_outputs: Vec<(&'static str, String)>,
660}
661
662async fn run_diagnosis_phases() -> DiagnosisData {
663    let timestamp = now_timestamp_string();
664    let hostname = hostname_from_env();
665
666    eprintln!("  → System Health (scanning for issues)...");
667    let health_args = json!({"topic": "health_report"});
668    let health_output = match crate::tools::host_inspect::inspect_host(&health_args).await {
669        Ok(s) => s,
670        Err(e) => format!("Error running health_report: {}", e),
671    };
672
673    let follow_up_topics = crate::agent::diagnose::triage_follow_up_topics(&health_output);
674
675    if follow_up_topics.is_empty() {
676        eprintln!("  → No follow-up checks needed.");
677    } else {
678        eprintln!(
679            "  → {} area(s) flagged — running targeted checks...",
680            follow_up_topics.len()
681        );
682    }
683
684    let mut follow_up_outputs: Vec<(&'static str, String)> = Vec::new();
685    for (i, topic) in follow_up_topics.iter().enumerate() {
686        eprintln!("  [{}/{}] {}...", i + 1, follow_up_topics.len(), topic);
687        let args = json!({"topic": topic});
688        let output = match crate::tools::host_inspect::inspect_host(&args).await {
689            Ok(s) => s,
690            Err(e) => format!("Error: {}", e),
691        };
692        follow_up_outputs.push((*topic, output));
693    }
694
695    DiagnosisData {
696        timestamp,
697        hostname,
698        health_output,
699        follow_up_outputs,
700    }
701}
702
703/// Run a full staged diagnosis — health_report → triage → targeted follow-ups → fix recipes.
704/// No TUI, no model required. Output is self-contained markdown for cloud model ingestion.
705pub async fn generate_diagnosis_report() -> String {
706    let version = env!("CARGO_PKG_VERSION");
707    let data = run_diagnosis_phases().await;
708
709    let mut section_refs: Vec<(&str, &str)> = vec![("health_report", data.health_output.as_str())];
710    for (topic, output) in &data.follow_up_outputs {
711        section_refs.push((*topic, output.as_str()));
712    }
713    let score = crate::agent::fix_recipes::score_health(&section_refs);
714    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
715
716    let mut md = String::new();
717    md.push_str("# Hematite Staged Diagnosis Report\n\n");
718    md.push_str(&format!("**Generated:** {}  \n", data.timestamp));
719    md.push_str(&format!("**Host:** {}  \n", data.hostname));
720    md.push_str(&format!("**Hematite:** v{}  \n", version));
721    md.push_str(&format!(
722        "**Health Score:** {} — {}  \n\n",
723        score.grade, score.label
724    ));
725    md.push_str(&format!("> {}\n\n", score.summary_line()));
726    md.push_str("---\n\n");
727    md.push_str("## Action Plan\n\n");
728    md.push_str(&action_plan);
729    md.push_str("---\n\n");
730    md.push_str("## System Health\n\n```\n");
731    md.push_str(data.health_output.trim_end());
732    md.push_str("\n```\n\n");
733
734    if !data.follow_up_outputs.is_empty() {
735        md.push_str("## Targeted Investigation\n\n");
736        for (topic, output) in &data.follow_up_outputs {
737            md.push_str(&format!("### {}\n\n```\n", topic));
738            md.push_str(output.trim_end());
739            md.push_str("\n```\n\n");
740        }
741    }
742
743    md
744}
745
746/// Same as generate_diagnosis_report but outputs a self-contained HTML file.
747pub async fn generate_diagnosis_report_html() -> String {
748    let version = env!("CARGO_PKG_VERSION");
749    let data = run_diagnosis_phases().await;
750
751    let mut section_refs: Vec<(&str, &str)> = vec![("health_report", data.health_output.as_str())];
752    for (topic, output) in &data.follow_up_outputs {
753        section_refs.push((*topic, output.as_str()));
754    }
755    let score = crate::agent::fix_recipes::score_health(&section_refs);
756    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
757
758    let mut sections: Vec<(&str, String)> = vec![("System Health", data.health_output.clone())];
759    for (topic, output) in &data.follow_up_outputs {
760        sections.push((*topic, output.clone()));
761    }
762
763    build_html_document(
764        "Hematite Staged Diagnosis",
765        &data.timestamp,
766        &data.hostname,
767        version,
768        &score,
769        &action_plan_html,
770        &sections,
771    )
772}
773
774pub async fn generate_report_json() -> String {
775    let timestamp = now_timestamp_string();
776    let hostname = hostname_from_env();
777    let version = env!("CARGO_PKG_VERSION");
778    let mut obj = serde_json::Map::new();
779    obj.insert("generated".into(), json!(timestamp));
780    obj.insert("host".into(), json!(hostname));
781    obj.insert("hematite_version".into(), json!(version));
782
783    let total = REPORT_TOPICS.len();
784    for (i, (topic, label)) in REPORT_TOPICS.iter().enumerate() {
785        eprintln!("  [{}/{}] {}...", i + 1, total, label);
786        let args = json!({"topic": topic});
787        let value = match crate::tools::host_inspect::inspect_host(&args).await {
788            Ok(output) => json!({"label": label, "output": output}),
789            Err(e) => json!({"label": label, "error": e}),
790        };
791        obj.insert(topic.to_string(), value);
792    }
793
794    serde_json::to_string_pretty(&serde_json::Value::Object(obj))
795        .unwrap_or_else(|e| format!("{{\"error\": \"{}\"}}", e))
796}
797
798/// Runs diagnostic topics, writes to `.hematite/reports/health-<timestamp>.md`,
799/// and returns `(markdown_content, saved_path)`.
800pub async fn save_report_markdown() -> (String, PathBuf) {
801    let md = generate_report_markdown().await;
802    let path = report_path("md");
803    ensure_parent(&path);
804    let _ = std::fs::write(&path, &md);
805    (md, path)
806}
807
808/// Same as `save_report_markdown` but JSON format.
809pub async fn save_report_json() -> (String, PathBuf) {
810    let json = generate_report_json().await;
811    let path = report_path("json");
812    ensure_parent(&path);
813    let _ = std::fs::write(&path, &json);
814    (json, path)
815}
816
817/// Self-contained HTML diagnostic report — double-clickable, no external deps.
818pub async fn generate_report_html() -> String {
819    let timestamp = now_timestamp_string();
820    let mut hostname = hostname_from_env();
821    let version = env!("CARGO_PKG_VERSION");
822    let mut sections: Vec<(&str, String)> = Vec::new();
823
824    let total = REPORT_TOPICS.len();
825    for (i, (topic, label)) in REPORT_TOPICS.iter().enumerate() {
826        eprintln!("  [{}/{}] {}...", i + 1, total, label);
827        let args = json!({"topic": topic});
828        let output = match crate::tools::host_inspect::inspect_host(&args).await {
829            Ok(s) => {
830                if *topic == "hardware" {
831                    for line in s.lines() {
832                        let ll = line.to_ascii_lowercase();
833                        if ll.contains("hostname") || ll.contains("computer name") {
834                            if let Some(val) = line.splitn(2, ':').nth(1) {
835                                let h = val.trim().to_string();
836                                if !h.is_empty() {
837                                    hostname = h;
838                                }
839                            }
840                        }
841                    }
842                }
843                s
844            }
845            Err(e) => format!("Error: {}", e),
846        };
847        sections.push((label, output));
848    }
849
850    let section_refs: Vec<(&str, &str)> = sections.iter().map(|(l, o)| (*l, o.as_str())).collect();
851    let score = crate::agent::fix_recipes::score_health(&section_refs);
852    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
853
854    build_html_document(
855        "Hematite Diagnostic Report",
856        &timestamp,
857        &hostname,
858        version,
859        &score,
860        &action_plan_html,
861        &sections,
862    )
863}
864
865pub async fn save_report_html() -> (String, PathBuf) {
866    let html = generate_report_html().await;
867    let path = report_path("html");
868    ensure_parent(&path);
869    let _ = std::fs::write(&path, &html);
870    (html, path)
871}
872
873pub async fn save_diagnosis_report() -> (String, PathBuf) {
874    let md = generate_diagnosis_report().await;
875    let path = crate::tools::file_ops::hematite_dir()
876        .join("reports")
877        .join(format!("diagnosis-{}.md", now_file_timestamp()));
878    ensure_parent(&path);
879    let _ = std::fs::write(&path, &md);
880    (md, path)
881}
882
883pub async fn save_diagnosis_report_html() -> (String, PathBuf) {
884    let html = generate_diagnosis_report_html().await;
885    let path = crate::tools::file_ops::hematite_dir()
886        .join("reports")
887        .join(format!("diagnosis-{}.html", now_file_timestamp()));
888    ensure_parent(&path);
889    let _ = std::fs::write(&path, &html);
890    (html, path)
891}
892
893fn build_html_document(
894    title: &str,
895    timestamp: &str,
896    hostname: &str,
897    version: &str,
898    score: &crate::agent::fix_recipes::HealthScore,
899    action_plan_html: &str,
900    sections: &[(&str, String)],
901) -> String {
902    use crate::agent::html_template::{build_html_shell, he, COPY_BUTTON_HTML};
903
904    let mut sections_html = String::new();
905    for (label, output) in sections {
906        sections_html.push_str(&format!(
907            "<details><summary>{}</summary><pre>{}</pre></details>\n",
908            he(label),
909            he(output.trim_end())
910        ));
911    }
912
913    let content = format!(
914        r#"<header>
915<h1>{title}</h1>
916<div class="meta">
917  <span>Generated: {timestamp}</span>
918  <span>Host: {hostname}</span>
919  <span>Hematite v{version}</span>
920</div>
921<div class="score-row">
922  <div class="grade g{grade}">{grade}</div>
923  <div class="score-info">
924    <h2>Health Score: {grade} — {label}</h2>
925    <p>{summary}</p>
926  </div>
927</div>
928<p class="grade-intro">{intro}</p>
929{copy_btn}
930</header>
931<section>
932<h2>Action Plan</h2>
933{action_plan_html}
934</section>
935<section>
936<h2>Diagnostic Data</h2>
937{sections_html}
938</section>"#,
939        title = he(title),
940        hostname = he(hostname),
941        timestamp = he(timestamp),
942        version = he(version),
943        grade = score.grade,
944        label = he(score.label),
945        summary = he(&score.summary_line()),
946        intro = he(score.grade_intro()),
947        copy_btn = COPY_BUTTON_HTML,
948        action_plan_html = action_plan_html,
949        sections_html = sections_html,
950    );
951
952    let page_title = format!("{} — {}", he(title), he(hostname));
953    build_html_shell(&page_title, version, &content)
954}
955
956// ── Triage report (IT-first-look, no model required) ─────────────────────────
957
958struct TriageData {
959    timestamp: String,
960    hostname: String,
961    sections: Vec<(&'static str, String)>,
962}
963
964async fn run_triage_phases(preset: &str) -> TriageData {
965    let topics = triage_topics_for_preset(preset);
966    let total = topics.len();
967    let timestamp = now_timestamp_string();
968    let mut hostname = hostname_from_env();
969    let mut sections: Vec<(&'static str, String)> = Vec::new();
970
971    for (i, &(topic, label)) in topics.iter().enumerate() {
972        eprintln!("  [{}/{}] {}...", i + 1, total, label);
973        let args = serde_json::json!({"topic": topic});
974        let output = match crate::tools::host_inspect::inspect_host(&args).await {
975            Ok(s) => {
976                if topic == "health_report" {
977                    for line in s.lines() {
978                        let ll = line.to_ascii_lowercase();
979                        if ll.contains("hostname") || ll.contains("computer name") {
980                            if let Some(val) = line.splitn(2, ':').nth(1) {
981                                let h = val.trim().to_string();
982                                if !h.is_empty() {
983                                    hostname = h;
984                                }
985                            }
986                        }
987                    }
988                }
989                s
990            }
991            Err(e) => format!("Error: {}", e),
992        };
993        sections.push((label, output));
994    }
995
996    TriageData {
997        timestamp,
998        hostname,
999        sections,
1000    }
1001}
1002
1003pub async fn generate_triage_report_markdown(preset: &str) -> String {
1004    let title = triage_preset_title(preset);
1005    let data = run_triage_phases(preset).await;
1006    let version = env!("CARGO_PKG_VERSION");
1007
1008    let section_refs: Vec<(&str, &str)> = data
1009        .sections
1010        .iter()
1011        .map(|(l, o)| (*l, o.as_str()))
1012        .collect();
1013    let score = crate::agent::fix_recipes::score_health(&section_refs);
1014    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
1015
1016    let mut md = String::new();
1017    md.push_str(&format!("# {}\n\n", title));
1018    md.push_str(&format!("**Generated:** {}  \n", data.timestamp));
1019    md.push_str(&format!("**Host:** {}  \n", data.hostname));
1020    md.push_str(&format!("**Hematite:** v{}  \n", version));
1021    md.push_str(&format!(
1022        "**Health Score:** {} — {}  \n\n",
1023        score.grade, score.label
1024    ));
1025    md.push_str(&format!("> {}\n\n", score.summary_line()));
1026    md.push_str("---\n\n## Action Plan\n\n");
1027    md.push_str(&action_plan);
1028    md.push_str("---\n\n");
1029    for (label, output) in &data.sections {
1030        md.push_str(&format!("## {}\n\n```\n", label));
1031        md.push_str(output.trim_end());
1032        md.push_str("\n```\n\n");
1033    }
1034    md
1035}
1036
1037pub async fn generate_triage_report_html(preset: &str) -> String {
1038    let title = triage_preset_title(preset);
1039    let data = run_triage_phases(preset).await;
1040    let version = env!("CARGO_PKG_VERSION");
1041
1042    let section_refs: Vec<(&str, &str)> = data
1043        .sections
1044        .iter()
1045        .map(|(l, o)| (*l, o.as_str()))
1046        .collect();
1047    let score = crate::agent::fix_recipes::score_health(&section_refs);
1048    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
1049
1050    build_html_document(
1051        title,
1052        &data.timestamp,
1053        &data.hostname,
1054        version,
1055        &score,
1056        &action_plan_html,
1057        &data.sections,
1058    )
1059}
1060
1061pub async fn save_triage_report(preset: &str) -> (String, PathBuf) {
1062    let md = generate_triage_report_markdown(preset).await;
1063    let path = crate::tools::file_ops::hematite_dir()
1064        .join("reports")
1065        .join(format!("triage-{}.md", now_file_timestamp()));
1066    ensure_parent(&path);
1067    let _ = std::fs::write(&path, &md);
1068    (md, path)
1069}
1070
1071pub async fn save_triage_report_html(preset: &str) -> (String, PathBuf) {
1072    let html = generate_triage_report_html(preset).await;
1073    let path = crate::tools::file_ops::hematite_dir()
1074        .join("reports")
1075        .join(format!("triage-{}.html", now_file_timestamp()));
1076    ensure_parent(&path);
1077    let _ = std::fs::write(&path, &html);
1078    (html, path)
1079}
1080
1081// ── Fix Plan (--fix "<issue>", no model required) ─────────────────────────────
1082
1083struct FixPlanData {
1084    timestamp: String,
1085    hostname: String,
1086    sections: Vec<(&'static str, String)>,
1087}
1088
1089async fn run_fix_plan_phases(issue: &str) -> FixPlanData {
1090    let initial_topics = topics_for_issue(issue);
1091    let total = initial_topics.len();
1092    let timestamp = now_timestamp_string();
1093    let mut hostname = hostname_from_env();
1094    let mut sections: Vec<(&'static str, String)> = Vec::new();
1095
1096    for (i, &(topic, label)) in initial_topics.iter().enumerate() {
1097        eprintln!("  [{}/{}] {}...", i + 1, total, label);
1098        let args = serde_json::json!({"topic": topic});
1099        let output = match crate::tools::host_inspect::inspect_host(&args).await {
1100            Ok(s) => {
1101                if topic == "health_report" {
1102                    for line in s.lines() {
1103                        let ll = line.to_ascii_lowercase();
1104                        if ll.contains("hostname") || ll.contains("computer name") {
1105                            if let Some(val) = line.splitn(2, ':').nth(1) {
1106                                let h = val.trim().to_string();
1107                                if !h.is_empty() {
1108                                    hostname = h;
1109                                }
1110                            }
1111                        }
1112                    }
1113                }
1114                s
1115            }
1116            Err(e) => format!("Error: {}", e),
1117        };
1118        sections.push((label, output));
1119    }
1120
1121    let combined: String = sections
1122        .iter()
1123        .map(|(_, o)| o.as_str())
1124        .collect::<Vec<_>>()
1125        .join("\n");
1126    let ran: Vec<&str> = initial_topics.iter().map(|&(t, _)| t).collect();
1127    let follow_ups = crate::agent::diagnose::fix_follow_up_topics(&combined, &ran);
1128
1129    if !follow_ups.is_empty() {
1130        eprintln!(
1131            "  → {} follow-up check(s) triggered by findings...",
1132            follow_ups.len()
1133        );
1134    }
1135
1136    for (i, &(topic, label)) in follow_ups.iter().enumerate() {
1137        eprintln!("  + [{}/{}] {}...", i + 1, follow_ups.len(), label);
1138        let args = serde_json::json!({"topic": topic});
1139        let output = match crate::tools::host_inspect::inspect_host(&args).await {
1140            Ok(s) => s,
1141            Err(e) => format!("Error: {}", e),
1142        };
1143        sections.push((label, output));
1144    }
1145
1146    FixPlanData {
1147        timestamp,
1148        hostname,
1149        sections,
1150    }
1151}
1152
1153pub async fn generate_fix_plan_markdown(issue: &str) -> String {
1154    let data = run_fix_plan_phases(issue).await;
1155    let version = env!("CARGO_PKG_VERSION");
1156
1157    let section_refs: Vec<(&str, &str)> = data
1158        .sections
1159        .iter()
1160        .map(|(l, o)| (*l, o.as_str()))
1161        .collect();
1162    let score = crate::agent::fix_recipes::score_health(&section_refs);
1163    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
1164
1165    let mut md = String::new();
1166    md.push_str("# Hematite Fix Plan\n\n");
1167    md.push_str(&format!("**Issue:** {}  \n", issue));
1168    md.push_str(&format!("**Generated:** {}  \n", data.timestamp));
1169    md.push_str(&format!("**Host:** {}  \n", data.hostname));
1170    md.push_str(&format!("**Hematite:** v{}  \n", version));
1171    md.push_str(&format!(
1172        "**Health Score:** {} — {}  \n\n",
1173        score.grade, score.label
1174    ));
1175    md.push_str(&format!("> {}\n\n", score.summary_line()));
1176    md.push_str("---\n\n## Fix Steps\n\n");
1177    md.push_str(&action_plan);
1178    md.push_str("---\n\n");
1179    for (label, output) in &data.sections {
1180        md.push_str(&format!("## {}\n\n```\n", label));
1181        md.push_str(output.trim_end());
1182        md.push_str("\n```\n\n");
1183    }
1184    md
1185}
1186
1187pub async fn generate_fix_plan_html(issue: &str) -> String {
1188    let data = run_fix_plan_phases(issue).await;
1189    let version = env!("CARGO_PKG_VERSION");
1190
1191    let section_refs: Vec<(&str, &str)> = data
1192        .sections
1193        .iter()
1194        .map(|(l, o)| (*l, o.as_str()))
1195        .collect();
1196    let score = crate::agent::fix_recipes::score_health(&section_refs);
1197    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
1198
1199    use crate::agent::html_template::{build_html_shell, he, COPY_BUTTON_HTML};
1200
1201    let mut sections_html = String::new();
1202    for (label, output) in &data.sections {
1203        sections_html.push_str(&format!(
1204            "<details><summary>{}</summary><pre>{}</pre></details>\n",
1205            he(label),
1206            he(output.trim_end())
1207        ));
1208    }
1209
1210    let content = format!(
1211        r#"<header>
1212<h1>Fix Plan</h1>
1213<p class="grade-intro" style="margin-bottom:.85rem">Issue: <strong>{issue}</strong></p>
1214<div class="meta">
1215  <span>Generated: {timestamp}</span>
1216  <span>Host: {hostname}</span>
1217  <span>Hematite v{version}</span>
1218</div>
1219<div class="score-row">
1220  <div class="grade g{grade}">{grade}</div>
1221  <div class="score-info">
1222    <h2>Health Score: {grade} — {label}</h2>
1223    <p>{summary}</p>
1224  </div>
1225</div>
1226{copy_btn}
1227</header>
1228<section>
1229<h2>Fix Steps</h2>
1230{action_plan_html}
1231</section>
1232<section>
1233<h2>Diagnostic Data</h2>
1234{sections_html}
1235</section>"#,
1236        issue = he(issue),
1237        hostname = he(&data.hostname),
1238        timestamp = he(&data.timestamp),
1239        version = he(version),
1240        grade = score.grade,
1241        label = he(score.label),
1242        summary = he(&score.summary_line()),
1243        copy_btn = COPY_BUTTON_HTML,
1244        action_plan_html = action_plan_html,
1245        sections_html = sections_html,
1246    );
1247
1248    let page_title = format!("Fix Plan: {} — {}", he(issue), he(&data.hostname));
1249    build_html_shell(&page_title, version, &content)
1250}
1251
1252pub async fn save_fix_plan(issue: &str) -> (String, PathBuf) {
1253    let md = generate_fix_plan_markdown(issue).await;
1254    let path = crate::tools::file_ops::hematite_dir()
1255        .join("reports")
1256        .join(format!("fix-{}.md", now_file_timestamp()));
1257    ensure_parent(&path);
1258    let _ = std::fs::write(&path, &md);
1259    (md, path)
1260}
1261
1262pub async fn save_fix_plan_html(issue: &str) -> (String, PathBuf) {
1263    let html = generate_fix_plan_html(issue).await;
1264    let path = crate::tools::file_ops::hematite_dir()
1265        .join("reports")
1266        .join(format!("fix-{}.html", now_file_timestamp()));
1267    ensure_parent(&path);
1268    let _ = std::fs::write(&path, &html);
1269    (html, path)
1270}
1271
1272/// Save arbitrary markdown content as a dark-theme HTML page.
1273/// Returns `(html_string, saved_path)`. Title defaults to a timestamp slug
1274/// if empty. Saves to `.hematite/reports/research-DATE.html`.
1275pub fn save_research_html(title: &str, body_md: &str) -> (String, PathBuf) {
1276    use crate::agent::html_template::{build_html_shell, he, markdown_to_html, COPY_BUTTON_HTML};
1277    let version = env!("CARGO_PKG_VERSION");
1278    let timestamp = now_timestamp_string();
1279    let display_title = if title.trim().is_empty() {
1280        format!("Research — {}", &timestamp[..10])
1281    } else {
1282        title.to_string()
1283    };
1284
1285    let body_html = markdown_to_html(body_md);
1286    let content = format!(
1287        r#"<header>
1288<h1>{title}</h1>
1289<div class="meta">
1290  <span>Saved: {timestamp}</span>
1291  <span>Hematite v{version}</span>
1292</div>
1293{copy_btn}
1294</header>
1295<section>
1296{body_html}
1297</section>"#,
1298        title = he(&display_title),
1299        timestamp = he(&timestamp),
1300        version = he(version),
1301        copy_btn = COPY_BUTTON_HTML,
1302        body_html = body_html,
1303    );
1304
1305    let html = build_html_shell(&display_title, version, &content);
1306    let path = crate::tools::file_ops::hematite_dir()
1307        .join("reports")
1308        .join(format!("research-{}.html", now_file_timestamp()));
1309    ensure_parent(&path);
1310    let _ = std::fs::write(&path, &html);
1311    (html, path)
1312}
1313
1314fn report_path(ext: &str) -> PathBuf {
1315    crate::tools::file_ops::hematite_dir()
1316        .join("reports")
1317        .join(format!("health-{}.{}", now_file_timestamp(), ext))
1318}
1319
1320fn ensure_parent(path: &PathBuf) {
1321    if let Some(parent) = path.parent() {
1322        let _ = std::fs::create_dir_all(parent);
1323    }
1324}
1325
1326fn now_timestamp_string() -> String {
1327    let now = unix_now();
1328    let (y, mo, d, h, mi, s) = epoch_to_ymd_hms(now);
1329    format!(
1330        "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC",
1331        y, mo, d, h, mi, s
1332    )
1333}
1334
1335fn now_file_timestamp() -> String {
1336    let now = unix_now();
1337    let (y, mo, d, h, mi, _s) = epoch_to_ymd_hms(now);
1338    format!("{:04}-{:02}-{:02}_{:02}-{:02}", y, mo, d, h, mi)
1339}
1340
1341fn unix_now() -> u64 {
1342    std::time::SystemTime::now()
1343        .duration_since(std::time::UNIX_EPOCH)
1344        .unwrap_or_default()
1345        .as_secs()
1346}
1347
1348fn hostname_from_env() -> String {
1349    std::env::var("COMPUTERNAME")
1350        .or_else(|_| std::env::var("HOSTNAME"))
1351        .unwrap_or_else(|_| "unknown".to_string())
1352}
1353
1354/// Gregorian calendar decomposition of a Unix timestamp (accurate 1970–2100).
1355fn epoch_to_ymd_hms(epoch: u64) -> (u32, u32, u32, u32, u32, u32) {
1356    let s = (epoch % 60) as u32;
1357    let mi = ((epoch / 60) % 60) as u32;
1358    let h = ((epoch / 3600) % 24) as u32;
1359    let days = epoch / 86400;
1360
1361    let years_400 = days / 146097;
1362    let rem = days % 146097;
1363    let years_100 = rem.min(146096) / 36524;
1364    let rem = rem - years_100 * 36524;
1365    let years_4 = rem / 1461;
1366    let rem = rem % 1461;
1367    let years_1 = rem.min(1460) / 365;
1368    let rem = rem - years_1 * 365;
1369
1370    let year = (1970 + years_400 * 400 + years_100 * 100 + years_4 * 4 + years_1) as u32;
1371    let leap = u32::from(year % 4 == 0 && (year % 100 != 0 || year % 400 == 0));
1372    let month_days: [u32; 12] = [31, 28 + leap, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
1373    let mut rem = rem as u32;
1374    let mut month = 1u32;
1375    for &md in &month_days {
1376        if rem < md {
1377            break;
1378        }
1379        rem -= md;
1380        month += 1;
1381    }
1382    let day = rem + 1;
1383    (year, month, day, h, mi, s)
1384}