use std::path::PathBuf;
use crate::diagnostics::{DiagnosticResult, DiagnosticResults, DiagnosticStatus};
use super::action::DiagnosticKey;
use super::session::{suggestions_for_keys, FinalOutcome, Session};
fn downloads_dir() -> PathBuf {
#[cfg(windows)]
{
if let Ok(profile) = std::env::var("USERPROFILE") {
let dir = PathBuf::from(profile).join("Downloads");
if dir.is_dir() {
return dir;
}
}
}
#[cfg(not(windows))]
{
if let Ok(home) = std::env::var("HOME") {
let dir = PathBuf::from(home).join("Downloads");
if dir.is_dir() {
return dir;
}
}
}
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
pub fn save_session_report(session: &Session, outcome: &FinalOutcome) -> Option<PathBuf> {
save_session_report_with_recovery(session, outcome, &[])
}
pub fn save_session_report_with_recovery(
session: &Session,
outcome: &FinalOutcome,
recovery_needed: &[String],
) -> Option<PathBuf> {
let dir = downloads_dir();
let _ = std::fs::create_dir_all(&dir);
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
let path = dir.join(format!("nd300-fix-report-{}.md", stamp));
let markdown = render_markdown(session, outcome, recovery_needed);
match std::fs::write(&path, markdown) {
Ok(()) => Some(path),
Err(_) => None,
}
}
fn render_markdown(
session: &Session,
outcome: &FinalOutcome,
recovery_needed: &[String],
) -> String {
let mut md = String::with_capacity(8 * 1024);
let stamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
md.push_str("# ND-300 Fix Report\n\n");
md.push_str(&format!("**Generated:** {}\n", stamp));
md.push_str(&format!(
"**Platform:** {}\n",
crate::platform::platform_name()
));
md.push_str(&format!(
"**ND-300 version:** {}\n",
env!("CARGO_PKG_VERSION")
));
md.push_str(&format!(
"**Wall-clock duration:** {} seconds\n",
session.elapsed().as_secs()
));
md.push('\n');
md.push_str("## Verdict\n\n");
md.push_str(&render_verdict(outcome));
md.push('\n');
if !recovery_needed.is_empty() {
md.push_str("## ⚠️ Manual recovery needed\n\n");
md.push_str(
"nd300 tried to restore the network state it changed, but the following could not be \
completed automatically. Please address them manually:\n\n",
);
for item in recovery_needed {
md.push_str(&format!("- {}\n", item));
}
md.push('\n');
}
if let Some(baseline) = &session.baseline {
md.push_str("## Baseline diagnostics (before any actions)\n\n");
md.push_str(&render_diagnostic_table(baseline));
md.push('\n');
}
md.push_str("## Iteration timeline\n\n");
if session.action_log.is_empty() {
md.push_str("_No actions were applied — there was nothing to fix._\n\n");
} else {
let max_iter = session
.action_log
.iter()
.map(|r| r.iteration)
.max()
.unwrap_or(0);
for iter in 1..=max_iter {
md.push_str(&format!("### Iteration {}\n\n", iter));
let actions_in_iter: Vec<_> = session
.action_log
.iter()
.filter(|r| r.iteration == iter)
.collect();
if actions_in_iter.is_empty() {
md.push_str("_No actions applied this iteration._\n\n");
continue;
}
for record in actions_in_iter {
let status_icon = if record.outcome.ok {
"✅"
} else if record.skipped_no_interaction {
"⏭️"
} else if record.user_declined {
"🚫"
} else {
"❌"
};
md.push_str(&format!(
"**{}** `{:?}` — {}\n\n",
status_icon, record.action_id, record.label
));
md.push_str(&format!(
"- **Result:** {} ({:.1}s)\n",
record.outcome.message,
record.duration.as_secs_f64()
));
if !record.outcome.cmd_outcomes.is_empty() {
md.push_str("- **Commands run:**\n\n");
for cmd in &record.outcome.cmd_outcomes {
md.push_str(" ```\n");
md.push_str(&format!(" $ {}\n", cmd.cmdline()));
if !cmd.stdout.trim().is_empty() {
md.push_str(" --- stdout ---\n");
for line in cmd.stdout.lines().take(20) {
md.push_str(&format!(" {}\n", line));
}
}
if !cmd.stderr.trim().is_empty() {
md.push_str(" --- stderr ---\n");
for line in cmd.stderr.lines().take(20) {
md.push_str(&format!(" {}\n", line));
}
}
if let Some(code) = cmd.exit_code {
md.push_str(&format!(
" exit {} ({:.1}s)\n",
code,
cmd.duration.as_secs_f64()
));
}
md.push_str(" ```\n\n");
}
}
md.push('\n');
}
}
}
if let Some(last) = session.snapshots.last() {
md.push_str("## Final diagnostics (after all actions)\n\n");
md.push_str(&render_diagnostic_table(&last.results));
md.push('\n');
}
let remaining_keys = remaining_failures(outcome);
if !remaining_keys.is_empty() {
md.push_str("## What to try next\n\n");
for s in suggestions_for_keys(&remaining_keys) {
md.push_str(&format!("- {}\n", s));
}
md.push('\n');
}
md.push_str("## Environment\n\n");
md.push_str(&format!(
"- Platform: {}\n",
crate::platform::platform_name()
));
md.push_str(&format!("- Elevated: {}\n", crate::platform::is_elevated()));
md.push_str(&format!(
"- ND-300 version: {}\n",
env!("CARGO_PKG_VERSION")
));
if !session.vpn_names.is_empty() {
md.push_str(&format!(
"- VPNs detected: {}\n",
session.vpn_names.join(", ")
));
}
md.push('\n');
md.push_str("---\n");
md.push_str("_This report was generated automatically by `nd300 fix`._\n");
md
}
fn render_verdict(outcome: &FinalOutcome) -> String {
match outcome {
FinalOutcome::Fixed => {
"✅ **Fixed** — Connectivity is healthy. The actions above resolved the failures.\n"
.to_string()
}
FinalOutcome::Partial(remaining) => {
format!(
"⚠️ **Partially fixed** — Some failures cleared, but the following remain: {}\n",
describe_keys(remaining)
)
}
FinalOutcome::Exhausted(remaining) => {
format!(
"❌ **Couldn't fix** — Every applicable action was attempted. Still failing: {}\n",
describe_keys(remaining)
)
}
FinalOutcome::HardBlock(reason) => {
format!("⚠️ **Cannot fix from here.** {}\n", reason.user_message())
}
FinalOutcome::Timeout(remaining) => {
format!(
"❌ **Timed out** — Loop hit its safety cap. Still failing: {}\n",
describe_keys(remaining)
)
}
FinalOutcome::UserDeclined(remaining) => {
format!(
"⚠️ **Stopped at your request** — A high-risk action was declined. Still failing: {}\n",
describe_keys(remaining)
)
}
FinalOutcome::PreflightFailed(reason) => {
format!("❌ **Could not start.** {}\n", reason)
}
FinalOutcome::Interrupted(remaining) => {
if remaining.is_empty() {
"⚠️ **Interrupted** — The fix was stopped before it finished. nd300 attempted to restore any network state it had changed.\n".to_string()
} else {
format!(
"⚠️ **Interrupted** — The fix was stopped before it finished. nd300 attempted to restore any network state it had changed. Still outstanding when interrupted: {}\n",
describe_keys(remaining)
)
}
}
}
}
fn remaining_failures(outcome: &FinalOutcome) -> Vec<DiagnosticKey> {
match outcome {
FinalOutcome::Partial(rs)
| FinalOutcome::Exhausted(rs)
| FinalOutcome::Timeout(rs)
| FinalOutcome::UserDeclined(rs)
| FinalOutcome::Interrupted(rs) => rs.clone(),
_ => Vec::new(),
}
}
fn describe_keys(keys: &[DiagnosticKey]) -> String {
let names: Vec<&str> = keys.iter().map(|k| key_label(*k)).collect();
names.join(", ")
}
fn key_label(k: DiagnosticKey) -> &'static str {
match k {
DiagnosticKey::Adapters => "network adapter",
DiagnosticKey::Interfaces => "network interface",
DiagnosticKey::Gateway => "gateway / router",
DiagnosticKey::Dns => "DNS",
DiagnosticKey::PublicIp => "public IP",
DiagnosticKey::Latency => "latency",
DiagnosticKey::Ports => "outbound ports",
DiagnosticKey::Speed => "speed test",
}
}
fn render_diagnostic_table(r: &DiagnosticResults) -> String {
let rows: Vec<(&str, &DiagnosticResult)> = vec![
("Adapters", &r.adapters),
("Interfaces", &r.interfaces),
("Gateway", &r.gateway),
("DNS", &r.dns),
("Public IP", &r.public_ip),
("Latency", &r.latency),
("Ports", &r.ports),
("Speed", &r.speed),
];
let mut s = String::new();
s.push_str("| Diagnostic | Status | Summary |\n");
s.push_str("|------------|--------|---------|\n");
for (label, res) in rows {
let status = match res.status {
DiagnosticStatus::Ok => "✅ OK",
DiagnosticStatus::Warn => "⚠️ Warn",
DiagnosticStatus::Fail => "❌ Fail",
DiagnosticStatus::Skip => "— Skip",
};
let summary = res.summary.replace('|', "\\|");
s.push_str(&format!("| {} | {} | {} |\n", label, status, summary));
}
s
}