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