Skip to main content

hematite/agent/
report_export.rs

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(&section_refs);
45    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_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 &sections {
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
110/// Run a full staged diagnosis — health_report → triage → targeted follow-ups → fix recipes.
111/// No TUI, no model required. Output is self-contained markdown for cloud model ingestion.
112pub 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(&section_refs);
121    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_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
153/// Same as generate_diagnosis_report but outputs a self-contained HTML file.
154pub 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(&section_refs);
163    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_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        &sections,
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
203/// Runs diagnostic topics, writes to `.hematite/reports/health-<timestamp>.md`,
204/// and returns `(markdown_content, saved_path)`.
205pub 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
213/// Same as `save_report_markdown` but JSON format.
214pub 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
222/// Self-contained HTML diagnostic report — double-clickable, no external deps.
223pub 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(&section_refs);
255    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
256
257    build_html_document(
258        "Hematite Diagnostic Report",
259        &timestamp,
260        &hostname,
261        version,
262        &score,
263        &action_plan_html,
264        &sections,
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
359/// Save arbitrary markdown content as a dark-theme HTML page.
360/// Returns `(html_string, saved_path)`. Title defaults to a timestamp slug
361/// if empty. Saves to `.hematite/reports/research-DATE.html`.
362pub 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 — {}", &timestamp[..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(&timestamp),
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
441/// Gregorian calendar decomposition of a Unix timestamp (accurate 1970–2100).
442fn 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}