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
13pub async fn generate_report_markdown() -> String {
14 let timestamp = now_timestamp_string();
15 let mut hostname = hostname_from_env();
16 let version = env!("CARGO_PKG_VERSION");
17 let mut sections: Vec<(&str, String)> = Vec::new();
18
19 for (topic, label) in REPORT_TOPICS {
20 let args = json!({"topic": topic});
21 let output = match crate::tools::host_inspect::inspect_host(&args).await {
22 Ok(s) => {
23 if *topic == "hardware" {
24 for line in s.lines() {
25 let ll = line.to_ascii_lowercase();
26 if ll.contains("hostname") || ll.contains("computer name") {
27 if let Some(val) = line.splitn(2, ':').nth(1) {
28 let h = val.trim().to_string();
29 if !h.is_empty() {
30 hostname = h;
31 }
32 }
33 }
34 }
35 }
36 s
37 }
38 Err(e) => format!("Error: {}", e),
39 };
40 sections.push((label, output));
41 }
42
43 let section_refs: Vec<(&str, &str)> = sections.iter().map(|(l, o)| (*l, o.as_str())).collect();
44 let score = crate::agent::fix_recipes::score_health(§ion_refs);
45 let action_plan = crate::agent::fix_recipes::format_action_plan(§ion_refs);
46
47 let mut md = String::new();
48 md.push_str("# Hematite Diagnostic Report\n\n");
49 md.push_str(&format!("**Generated:** {} \n", timestamp));
50 md.push_str(&format!("**Host:** {} \n", hostname));
51 md.push_str(&format!("**Hematite:** v{} \n", version));
52 md.push_str(&format!(
53 "**Health Score:** {} — {} \n\n",
54 score.grade, score.label
55 ));
56 md.push_str(&format!("> {}\n\n", score.summary_line()));
57 md.push_str("---\n\n");
58
59 md.push_str("## Action Plan\n\n");
60 md.push_str(&action_plan);
61 md.push_str("---\n\n");
62
63 for (label, output) in §ions {
64 md.push_str(&format!("## {}\n\n", label));
65 md.push_str("```\n");
66 md.push_str(output.trim_end());
67 md.push_str("\n```\n\n");
68 }
69
70 md
71}
72
73struct DiagnosisData {
74 timestamp: String,
75 hostname: String,
76 health_output: String,
77 follow_up_outputs: Vec<(&'static str, String)>,
78}
79
80async fn run_diagnosis_phases() -> DiagnosisData {
81 let timestamp = now_timestamp_string();
82 let hostname = hostname_from_env();
83
84 let health_args = json!({"topic": "health_report"});
85 let health_output = match crate::tools::host_inspect::inspect_host(&health_args).await {
86 Ok(s) => s,
87 Err(e) => format!("Error running health_report: {}", e),
88 };
89
90 let follow_up_topics = crate::agent::diagnose::triage_follow_up_topics(&health_output);
91
92 let mut follow_up_outputs: Vec<(&'static str, String)> = Vec::new();
93 for topic in &follow_up_topics {
94 let args = json!({"topic": topic});
95 let output = match crate::tools::host_inspect::inspect_host(&args).await {
96 Ok(s) => s,
97 Err(e) => format!("Error: {}", e),
98 };
99 follow_up_outputs.push((*topic, output));
100 }
101
102 DiagnosisData {
103 timestamp,
104 hostname,
105 health_output,
106 follow_up_outputs,
107 }
108}
109
110pub async fn generate_diagnosis_report() -> String {
113 let version = env!("CARGO_PKG_VERSION");
114 let data = run_diagnosis_phases().await;
115
116 let mut section_refs: Vec<(&str, &str)> = vec![("health_report", data.health_output.as_str())];
117 for (topic, output) in &data.follow_up_outputs {
118 section_refs.push((*topic, output.as_str()));
119 }
120 let score = crate::agent::fix_recipes::score_health(§ion_refs);
121 let action_plan = crate::agent::fix_recipes::format_action_plan(§ion_refs);
122
123 let mut md = String::new();
124 md.push_str("# Hematite Staged Diagnosis Report\n\n");
125 md.push_str(&format!("**Generated:** {} \n", data.timestamp));
126 md.push_str(&format!("**Host:** {} \n", data.hostname));
127 md.push_str(&format!("**Hematite:** v{} \n", version));
128 md.push_str(&format!(
129 "**Health Score:** {} — {} \n\n",
130 score.grade, score.label
131 ));
132 md.push_str(&format!("> {}\n\n", score.summary_line()));
133 md.push_str("---\n\n");
134 md.push_str("## Action Plan\n\n");
135 md.push_str(&action_plan);
136 md.push_str("---\n\n");
137 md.push_str("## System Health\n\n```\n");
138 md.push_str(data.health_output.trim_end());
139 md.push_str("\n```\n\n");
140
141 if !data.follow_up_outputs.is_empty() {
142 md.push_str("## Targeted Investigation\n\n");
143 for (topic, output) in &data.follow_up_outputs {
144 md.push_str(&format!("### {}\n\n```\n", topic));
145 md.push_str(output.trim_end());
146 md.push_str("\n```\n\n");
147 }
148 }
149
150 md
151}
152
153pub async fn generate_diagnosis_report_html() -> String {
155 let version = env!("CARGO_PKG_VERSION");
156 let data = run_diagnosis_phases().await;
157
158 let mut section_refs: Vec<(&str, &str)> = vec![("health_report", data.health_output.as_str())];
159 for (topic, output) in &data.follow_up_outputs {
160 section_refs.push((*topic, output.as_str()));
161 }
162 let score = crate::agent::fix_recipes::score_health(§ion_refs);
163 let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(§ion_refs);
164
165 let mut sections: Vec<(&str, String)> = vec![("System Health", data.health_output.clone())];
166 for (topic, output) in &data.follow_up_outputs {
167 sections.push((*topic, output.clone()));
168 }
169
170 build_html_document(
171 "Hematite Staged Diagnosis",
172 &data.timestamp,
173 &data.hostname,
174 version,
175 &score,
176 &action_plan_html,
177 §ions,
178 )
179}
180
181pub async fn generate_report_json() -> String {
182 let timestamp = now_timestamp_string();
183 let hostname = hostname_from_env();
184 let version = env!("CARGO_PKG_VERSION");
185 let mut obj = serde_json::Map::new();
186 obj.insert("generated".into(), json!(timestamp));
187 obj.insert("host".into(), json!(hostname));
188 obj.insert("hematite_version".into(), json!(version));
189
190 for (topic, label) in REPORT_TOPICS {
191 let args = json!({"topic": topic});
192 let value = match crate::tools::host_inspect::inspect_host(&args).await {
193 Ok(output) => json!({"label": label, "output": output}),
194 Err(e) => json!({"label": label, "error": e}),
195 };
196 obj.insert(topic.to_string(), value);
197 }
198
199 serde_json::to_string_pretty(&serde_json::Value::Object(obj))
200 .unwrap_or_else(|e| format!("{{\"error\": \"{}\"}}", e))
201}
202
203pub async fn save_report_markdown() -> (String, PathBuf) {
206 let md = generate_report_markdown().await;
207 let path = report_path("md");
208 ensure_parent(&path);
209 let _ = std::fs::write(&path, &md);
210 (md, path)
211}
212
213pub async fn save_report_json() -> (String, PathBuf) {
215 let json = generate_report_json().await;
216 let path = report_path("json");
217 ensure_parent(&path);
218 let _ = std::fs::write(&path, &json);
219 (json, path)
220}
221
222pub async fn generate_report_html() -> String {
224 let timestamp = now_timestamp_string();
225 let mut hostname = hostname_from_env();
226 let version = env!("CARGO_PKG_VERSION");
227 let mut sections: Vec<(&str, String)> = Vec::new();
228
229 for (topic, label) in REPORT_TOPICS {
230 let args = json!({"topic": topic});
231 let output = match crate::tools::host_inspect::inspect_host(&args).await {
232 Ok(s) => {
233 if *topic == "hardware" {
234 for line in s.lines() {
235 let ll = line.to_ascii_lowercase();
236 if ll.contains("hostname") || ll.contains("computer name") {
237 if let Some(val) = line.splitn(2, ':').nth(1) {
238 let h = val.trim().to_string();
239 if !h.is_empty() {
240 hostname = h;
241 }
242 }
243 }
244 }
245 }
246 s
247 }
248 Err(e) => format!("Error: {}", e),
249 };
250 sections.push((label, output));
251 }
252
253 let section_refs: Vec<(&str, &str)> = sections.iter().map(|(l, o)| (*l, o.as_str())).collect();
254 let score = crate::agent::fix_recipes::score_health(§ion_refs);
255 let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(§ion_refs);
256
257 build_html_document(
258 "Hematite Diagnostic Report",
259 ×tamp,
260 &hostname,
261 version,
262 &score,
263 &action_plan_html,
264 §ions,
265 )
266}
267
268pub async fn save_report_html() -> (String, PathBuf) {
269 let html = generate_report_html().await;
270 let path = report_path("html");
271 ensure_parent(&path);
272 let _ = std::fs::write(&path, &html);
273 (html, path)
274}
275
276pub async fn save_diagnosis_report() -> (String, PathBuf) {
277 let md = generate_diagnosis_report().await;
278 let path = crate::tools::file_ops::hematite_dir()
279 .join("reports")
280 .join(format!("diagnosis-{}.md", now_file_timestamp()));
281 ensure_parent(&path);
282 let _ = std::fs::write(&path, &md);
283 (md, path)
284}
285
286pub async fn save_diagnosis_report_html() -> (String, PathBuf) {
287 let html = generate_diagnosis_report_html().await;
288 let path = crate::tools::file_ops::hematite_dir()
289 .join("reports")
290 .join(format!("diagnosis-{}.html", now_file_timestamp()));
291 ensure_parent(&path);
292 let _ = std::fs::write(&path, &html);
293 (html, path)
294}
295
296fn build_html_document(
297 title: &str,
298 timestamp: &str,
299 hostname: &str,
300 version: &str,
301 score: &crate::agent::fix_recipes::HealthScore,
302 action_plan_html: &str,
303 sections: &[(&str, String)],
304) -> String {
305 use crate::agent::html_template::{build_html_shell, he, COPY_BUTTON_HTML};
306
307 let mut sections_html = String::new();
308 for (label, output) in sections {
309 sections_html.push_str(&format!(
310 "<details><summary>{}</summary><pre>{}</pre></details>\n",
311 he(label),
312 he(output.trim_end())
313 ));
314 }
315
316 let content = format!(
317 r#"<header>
318<h1>{title}</h1>
319<div class="meta">
320 <span>Generated: {timestamp}</span>
321 <span>Host: {hostname}</span>
322 <span>Hematite v{version}</span>
323</div>
324<div class="score-row">
325 <div class="grade g{grade}">{grade}</div>
326 <div class="score-info">
327 <h2>Health Score: {grade} — {label}</h2>
328 <p>{summary}</p>
329 </div>
330</div>
331<p class="grade-intro">{intro}</p>
332{copy_btn}
333</header>
334<section>
335<h2>Action Plan</h2>
336{action_plan_html}
337</section>
338<section>
339<h2>Diagnostic Data</h2>
340{sections_html}
341</section>"#,
342 title = he(title),
343 hostname = he(hostname),
344 timestamp = he(timestamp),
345 version = he(version),
346 grade = score.grade,
347 label = he(score.label),
348 summary = he(&score.summary_line()),
349 intro = he(score.grade_intro()),
350 copy_btn = COPY_BUTTON_HTML,
351 action_plan_html = action_plan_html,
352 sections_html = sections_html,
353 );
354
355 let page_title = format!("{} — {}", he(title), he(hostname));
356 build_html_shell(&page_title, version, &content)
357}
358
359pub fn save_research_html(title: &str, body_md: &str) -> (String, PathBuf) {
363 use crate::agent::html_template::{build_html_shell, he, markdown_to_html, COPY_BUTTON_HTML};
364 let version = env!("CARGO_PKG_VERSION");
365 let timestamp = now_timestamp_string();
366 let display_title = if title.trim().is_empty() {
367 format!("Research — {}", ×tamp[..10])
368 } else {
369 title.to_string()
370 };
371
372 let body_html = markdown_to_html(body_md);
373 let content = format!(
374 r#"<header>
375<h1>{title}</h1>
376<div class="meta">
377 <span>Saved: {timestamp}</span>
378 <span>Hematite v{version}</span>
379</div>
380{copy_btn}
381</header>
382<section>
383{body_html}
384</section>"#,
385 title = he(&display_title),
386 timestamp = he(×tamp),
387 version = he(version),
388 copy_btn = COPY_BUTTON_HTML,
389 body_html = body_html,
390 );
391
392 let html = build_html_shell(&display_title, version, &content);
393 let path = crate::tools::file_ops::hematite_dir()
394 .join("reports")
395 .join(format!("research-{}.html", now_file_timestamp()));
396 ensure_parent(&path);
397 let _ = std::fs::write(&path, &html);
398 (html, path)
399}
400
401fn report_path(ext: &str) -> PathBuf {
402 crate::tools::file_ops::hematite_dir()
403 .join("reports")
404 .join(format!("health-{}.{}", now_file_timestamp(), ext))
405}
406
407fn ensure_parent(path: &PathBuf) {
408 if let Some(parent) = path.parent() {
409 let _ = std::fs::create_dir_all(parent);
410 }
411}
412
413fn now_timestamp_string() -> String {
414 let now = unix_now();
415 let (y, mo, d, h, mi, s) = epoch_to_ymd_hms(now);
416 format!(
417 "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC",
418 y, mo, d, h, mi, s
419 )
420}
421
422fn now_file_timestamp() -> String {
423 let now = unix_now();
424 let (y, mo, d, h, mi, _s) = epoch_to_ymd_hms(now);
425 format!("{:04}-{:02}-{:02}_{:02}-{:02}", y, mo, d, h, mi)
426}
427
428fn unix_now() -> u64 {
429 std::time::SystemTime::now()
430 .duration_since(std::time::UNIX_EPOCH)
431 .unwrap_or_default()
432 .as_secs()
433}
434
435fn hostname_from_env() -> String {
436 std::env::var("COMPUTERNAME")
437 .or_else(|_| std::env::var("HOSTNAME"))
438 .unwrap_or_else(|_| "unknown".to_string())
439}
440
441fn epoch_to_ymd_hms(epoch: u64) -> (u32, u32, u32, u32, u32, u32) {
443 let s = (epoch % 60) as u32;
444 let mi = ((epoch / 60) % 60) as u32;
445 let h = ((epoch / 3600) % 24) as u32;
446 let days = epoch / 86400;
447
448 let years_400 = days / 146097;
449 let rem = days % 146097;
450 let years_100 = rem.min(146096) / 36524;
451 let rem = rem - years_100 * 36524;
452 let years_4 = rem / 1461;
453 let rem = rem % 1461;
454 let years_1 = rem.min(1460) / 365;
455 let rem = rem - years_1 * 365;
456
457 let year = (1970 + years_400 * 400 + years_100 * 100 + years_4 * 4 + years_1) as u32;
458 let leap = u32::from(year % 4 == 0 && (year % 100 != 0 || year % 400 == 0));
459 let month_days: [u32; 12] = [31, 28 + leap, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
460 let mut rem = rem as u32;
461 let mut month = 1u32;
462 for &md in &month_days {
463 if rem < md {
464 break;
465 }
466 rem -= md;
467 month += 1;
468 }
469 let day = rem + 1;
470 (year, month, day, h, mi, s)
471}