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