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
14const 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
79fn 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(§ion_refs);
651 let action_plan = crate::agent::fix_recipes::format_action_plan(§ion_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 §ions {
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
729pub 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(§ion_refs);
740 let action_plan = crate::agent::fix_recipes::format_action_plan(§ion_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
774pub 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(§ion_refs);
784 let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(§ion_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 §ions,
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
826pub 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
836pub 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
845pub 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(§ion_refs);
880 let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(§ion_refs);
881
882 build_html_document(
883 "Hematite Diagnostic Report",
884 ×tamp,
885 &hostname,
886 version,
887 &score,
888 &action_plan_html,
889 §ions,
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
986struct 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(§ion_refs);
1044 let action_plan = crate::agent::fix_recipes::format_action_plan(§ion_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(§ion_refs);
1079 let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(§ion_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
1112struct 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(§ion_refs);
1200 let action_plan = crate::agent::fix_recipes::format_action_plan(§ion_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(§ion_refs);
1235 let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(§ion_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
1311pub 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 — {}", ×tamp[..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(×tamp),
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
1393fn 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}