use std::time::{Duration, Instant};
use crate::config::Config;
use crate::diagnostics::DiagnosticResults;
use crate::render::color;
use super::action::{Action, ActionOutcome, DiagnosticKey, Risk};
use super::triage::{Attempts, Effectiveness, HardBlock};
pub const WALL_CLOCK_CAP: Duration = Duration::from_secs(240);
pub const DEFAULT_ITERATION_DELAY: Duration = Duration::from_secs(2);
#[derive(Debug, Clone)]
pub enum FinalOutcome {
Fixed,
Partial(Vec<DiagnosticKey>),
Exhausted(Vec<DiagnosticKey>),
HardBlock(HardBlock),
Timeout(Vec<DiagnosticKey>),
UserDeclined(Vec<DiagnosticKey>),
PreflightFailed(String),
}
impl FinalOutcome {
pub fn exit_code(&self) -> i32 {
match self {
FinalOutcome::Fixed => 0,
FinalOutcome::Partial(_) => 1,
FinalOutcome::Exhausted(_) => 2,
FinalOutcome::HardBlock(_) => 1,
FinalOutcome::Timeout(_) => 2,
FinalOutcome::UserDeclined(_) => 1,
FinalOutcome::PreflightFailed(_) => 2,
}
}
}
#[derive(Debug, Clone)]
pub struct ActionRecord {
pub action_id: super::action::ActionId,
pub label: &'static str,
pub outcome: ActionOutcome,
pub duration: Duration,
pub iteration: u8,
pub user_declined: bool,
pub skipped_no_interaction: bool,
}
#[derive(Debug)]
pub struct IterationSnapshot {
pub iteration: u8,
pub results: DiagnosticResults,
}
#[derive(Debug)]
pub struct Session {
pub started_at: Instant,
pub baseline: Option<DiagnosticResults>,
pub snapshots: Vec<IterationSnapshot>,
pub action_log: Vec<ActionRecord>,
pub attempts: Attempts,
pub effectiveness: Effectiveness,
pub vpn_names: Vec<String>,
pub final_outcome: Option<FinalOutcome>,
}
impl Session {
pub fn new() -> Self {
Self {
started_at: Instant::now(),
baseline: None,
snapshots: Vec::new(),
action_log: Vec::new(),
attempts: Attempts::new(),
effectiveness: Effectiveness::new(),
vpn_names: Vec::new(),
final_outcome: None,
}
}
pub fn elapsed(&self) -> Duration {
self.started_at.elapsed()
}
pub fn wall_clock_exhausted(&self) -> bool {
self.elapsed() >= WALL_CLOCK_CAP
}
pub fn record_baseline(&mut self, results: DiagnosticResults) {
self.baseline = Some(results.clone());
self.snapshots.push(IterationSnapshot {
iteration: 0,
results,
});
}
pub fn record_iteration(&mut self, iteration: u8, results: DiagnosticResults) {
self.snapshots
.push(IterationSnapshot { iteration, results });
}
pub fn record_action(
&mut self,
iteration: u8,
action: &Action,
outcome: ActionOutcome,
duration: Duration,
user_declined: bool,
skipped_no_interaction: bool,
) {
let entry = self.attempts.entry(action.id).or_insert(0);
if !skipped_no_interaction && !user_declined {
*entry = entry.saturating_add(1);
}
self.action_log.push(ActionRecord {
action_id: action.id,
label: action.label,
outcome,
duration,
iteration,
user_declined,
skipped_no_interaction,
});
}
pub fn update_effectiveness(
&mut self,
iteration: u8,
prior_failures: &std::collections::HashSet<DiagnosticKey>,
current_failures: &std::collections::HashSet<DiagnosticKey>,
) {
let cleared: std::collections::HashSet<DiagnosticKey> = prior_failures
.difference(current_failures)
.copied()
.collect();
if cleared.is_empty() {
return;
}
for record in self.action_log.iter().rev() {
if record.iteration != iteration {
break;
}
if !record.outcome.ok {
continue;
}
for k in cleared.iter() {
self.effectiveness.insert((record.action_id, *k), true);
}
}
}
}
impl Default for Session {
fn default() -> Self {
Self::new()
}
}
pub struct Reporter<'a> {
pub config: &'a Config,
}
impl<'a> Reporter<'a> {
pub fn new(config: &'a Config) -> Self {
Self { config }
}
pub fn header(&self) {
println!();
println!(
" {} {}",
color::cyan("nd300 fix", self.config),
color::dim("— diagnostic-driven recovery", self.config),
);
}
pub fn iteration_header(&self, iteration: u8) {
println!();
println!(
" {} {}",
color::cyan(&format!("Iteration {}", iteration), self.config),
color::dim(
"— running diagnostics, then applying targeted fixes",
self.config,
),
);
}
pub fn baseline_summary(&self, failure_count: usize) {
if failure_count == 0 {
println!(
" {} {}",
color::green("✓", self.config),
color::green("All diagnostics passing — nothing to fix.", self.config),
);
} else {
println!(
" {} found {} failing area{}",
color::yellow("→", self.config),
failure_count,
if failure_count == 1 { "" } else { "s" },
);
}
}
pub fn announce_action(&self, action: &Action) {
println!();
println!(
" {} {}",
color::dim("•", self.config),
color::dim(action.one_line_why, self.config),
);
print!(" {} {} ", color::cyan("→", self.config), action.label,);
use std::io::Write;
let _ = std::io::stdout().flush();
}
pub fn finish_action(&self, outcome: &ActionOutcome, duration: Duration) {
if outcome.ok {
println!(
"{} {}",
color::green("✓", self.config),
color::dim(
&format!("({:.1}s) {}", duration.as_secs_f64(), outcome.message),
self.config,
),
);
} else {
println!(
"{} {}",
color::red("✗", self.config),
color::red(&outcome.message, self.config),
);
}
}
pub fn finish_action_skipped(&self, reason: &str) {
println!(
"{} {}",
color::yellow("·", self.config),
color::yellow(reason, self.config),
);
}
pub fn confirmation_prompt(&self, action: &Action) -> bool {
println!();
println!(
" {} {}",
color::yellow("Confirm:", self.config),
color::yellow(action.label, self.config),
);
println!(" {}", color::dim(action.one_line_why, self.config));
println!(
" {}",
color::dim("This changes live network settings. Use --yes to auto-confirm this class of action.", self.config),
);
use std::io::Write;
print!(
" {} ",
color::yellow(
"Continue? Type 'y' to proceed, anything else to skip:",
self.config
),
);
let _ = std::io::stdout().flush();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
}
pub fn high_risk_prompt(&self, action: &Action) -> bool {
let exp = match &action.risk {
Risk::High(e) => e,
_ => return true, };
let header = format!("Escalating: {}", exp.what);
let bar = if self.config.use_unicode { '─' } else { '-' };
let rule: String = std::iter::repeat_n(bar, 76).collect();
println!();
println!(
" {}",
color::yellow(&format!("┌─ {} ", header), self.config)
);
println!(" {}", color::dim(&rule, self.config));
println!(" {}", color::bold("Why I want to do this:", self.config));
for line in wrap_text(exp.why, 70) {
println!(" {}", line);
}
println!();
println!(" {}", color::bold("What will happen:", self.config));
for bullet in exp.side_effects {
println!(" • {}", bullet);
}
println!();
println!(
" {} {}",
color::bold("Reversible:", self.config),
exp.reversible.label(),
);
println!(
" {} {}",
color::bold("Typical duration:", self.config),
exp.typical_duration,
);
println!(" {}", color::dim(&rule, self.config));
use std::io::Write;
print!(
" {} ",
color::yellow(
"Continue? Type 'y' to proceed, anything else to skip:",
self.config
),
);
let _ = std::io::stdout().flush();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
}
pub fn high_risk_skipped_no_tty(&self, action: &Action) {
println!(
" {} {}",
color::yellow("·", self.config),
color::yellow(
&format!(
"Skipped {}: this action requires interactive confirmation. Re-run `nd300 fix` in a terminal to attempt it.",
action.label,
),
self.config,
),
);
}
pub fn high_risk_declined(&self, action: &Action) {
self.confirmation_declined(action);
}
pub fn confirmation_declined(&self, action: &Action) {
println!(
" {} {}",
color::yellow("·", self.config),
color::dim(
&format!("Skipped {} (you declined the prompt).", action.label),
self.config,
),
);
}
pub fn final_verdict(&self, outcome: &FinalOutcome, report_path: Option<&std::path::Path>) {
let bar = if self.config.use_unicode { '─' } else { '-' };
let rule: String = std::iter::repeat_n(bar, 50).collect();
println!();
println!(
" {} {}",
color::bold("Result", self.config),
color::dim(&rule, self.config),
);
match outcome {
FinalOutcome::Fixed => {
println!(
" {} {}",
color::green("✓ Fixed", self.config),
color::green(
"Connectivity is healthy now. The actions above resolved the failures.",
self.config,
),
);
}
FinalOutcome::Partial(remaining) => {
println!(
" {} {}",
color::yellow("⚠ Partially fixed", self.config),
color::yellow(
&format!(
"{} remain{}",
describe_keys(remaining),
if remaining.len() == 1 { "s" } else { "" }
),
self.config,
),
);
self.suggestions_for(remaining);
}
FinalOutcome::Exhausted(remaining) => {
println!(
" {} {}",
color::red("✗ Couldn't fix", self.config),
color::red(
&format!(
"Tried every applicable action; {} still failing",
describe_keys(remaining)
),
self.config,
),
);
self.suggestions_for(remaining);
}
FinalOutcome::HardBlock(reason) => {
println!(
" {} {}",
color::yellow("⚠ Cannot fix from here", self.config),
color::yellow(&reason.user_message(), self.config),
);
}
FinalOutcome::Timeout(remaining) => {
println!(
" {} {}",
color::red("✗ Timed out", self.config),
color::red(
&format!(
"Loop hit its safety cap; {} still failing",
describe_keys(remaining)
),
self.config,
),
);
self.suggestions_for(remaining);
}
FinalOutcome::UserDeclined(remaining) => {
println!(
" {} {}",
color::yellow("⚠ Stopped at your request", self.config),
color::yellow(
&format!(
"You declined a confirmation prompt; {} still failing",
describe_keys(remaining)
),
self.config,
),
);
self.suggestions_for(remaining);
}
FinalOutcome::PreflightFailed(reason) => {
println!(
" {} {}",
color::red("✗ Could not start", self.config),
color::red(reason, self.config),
);
}
}
if let Some(path) = report_path {
println!(
" {} {}",
color::dim("Full report:", self.config),
color::dim(&path.display().to_string(), self.config),
);
}
}
fn suggestions_for(&self, remaining: &[DiagnosticKey]) {
if remaining.is_empty() {
return;
}
println!();
println!(" {}", color::bold("What to try next:", self.config));
for s in suggestions_for_keys(remaining) {
println!(" • {}", s);
}
}
}
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",
}
}
pub fn suggestions_for_keys(remaining: &[DiagnosticKey]) -> Vec<String> {
use DiagnosticKey::*;
let mut out: Vec<String> = Vec::new();
let has = |k: DiagnosticKey| remaining.contains(&k);
if has(Adapters) || has(Interfaces) {
out.push(
"Reboot your computer — a deeper hardware/driver state may need a clean start."
.to_string(),
);
out.push(
"Check for network driver updates from your hardware vendor (Intel, Realtek, etc.)."
.to_string(),
);
}
if has(Gateway) {
out.push(
"Power-cycle your router / modem (unplug for 30 seconds, plug back in).".to_string(),
);
out.push("Try a different cable or Wi-Fi network if available.".to_string());
}
if has(Dns) {
out.push(
"Try `nd300 fix` again, then choose `Switch DNS to Cloudflare` if it offers it."
.to_string(),
);
out.push(
"Check your router admin page for a custom DNS setting and remove it.".to_string(),
);
}
if has(PublicIp) {
out.push("Check your ISP's status page — there may be an outage in your area.".to_string());
out.push(
"Disconnect any VPN you have running (including work VPNs) and re-test.".to_string(),
);
}
if has(Latency) {
out.push("If on Wi-Fi: move closer to your router or try a 5 GHz network.".to_string());
out.push(
"Run a speed test from another device on the same network to compare.".to_string(),
);
}
if has(Ports) {
out.push(
"Outbound ports may be blocked by a firewall (work network or AV software). Contact IT or check your firewall rules.".to_string(),
);
}
if out.is_empty() {
out.push("Reboot the machine and try again.".to_string());
out.push(
"Run `nd300 -t` for the full technician report and share it with support.".to_string(),
);
}
out
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if current.len() + word.len() + 1 > width && !current.is_empty() {
lines.push(std::mem::take(&mut current));
}
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
if !current.is_empty() {
lines.push(current);
}
lines
}