Skip to main content

hematite/agent/
report_export.rs

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