Skip to main content

nd_300/actions/fix/
session.rs

1//! Session state + plain-language reporter for the fix loop.
2
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use tokio::sync::Mutex;
7
8use crate::config::Config;
9use crate::diagnostics::DiagnosticResults;
10use crate::render::color;
11
12use super::action::{Action, ActionOutcome, DiagnosticKey, Risk};
13use super::triage::{Attempts, Effectiveness, HardBlock};
14use super::vpn::DisabledVpn;
15
16/// Hard wall-clock cap for a single `nd300 fix` run. Combined with the
17/// per-iteration count and per-action attempt caps, this guarantees the loop
18/// always exits in bounded time.
19pub const WALL_CLOCK_CAP: Duration = Duration::from_secs(240);
20
21/// Stabilization delay between iterations after non-disruptive actions. The
22/// loop additionally honors per-action `Action::stabilization` for actions
23/// that need longer (DHCP renew, interface bounce).
24pub const DEFAULT_ITERATION_DELAY: Duration = Duration::from_secs(2);
25
26/// Final outcome categories. Drives the verdict line and the "what to try
27/// next" suggestions.
28#[derive(Debug, Clone)]
29pub enum FinalOutcome {
30    /// Every actionable failure cleared.
31    Fixed,
32    /// Some failures cleared but not all.
33    Partial(Vec<DiagnosticKey>),
34    /// No more actions to try and failures remain.
35    Exhausted(Vec<DiagnosticKey>),
36    /// Loop exited cleanly because the failure cannot be auto-fixed.
37    HardBlock(HardBlock),
38    /// Iteration count or wall clock cap reached.
39    Timeout(Vec<DiagnosticKey>),
40    /// User declined a confirmation prompt; loop stopped with remaining failures.
41    UserDeclined(Vec<DiagnosticKey>),
42    /// Pre-flight check failed (e.g. not elevated). No diagnostics ran.
43    PreflightFailed(String),
44    /// The run was interrupted before it could finish — Ctrl-C, SIGTERM,
45    /// SIGHUP, a caught panic, or the outer drain timeout. The restore registry is drained on
46    /// this path so any half-applied network state is rolled back; the carried
47    /// keys are whatever failures were still outstanding when the interrupt
48    /// landed (best-effort, may be empty if the interrupt arrived early).
49    Interrupted(Vec<DiagnosticKey>),
50}
51
52impl FinalOutcome {
53    pub fn exit_code(&self) -> i32 {
54        match self {
55            FinalOutcome::Fixed => 0,
56            FinalOutcome::Partial(_) => 1,
57            FinalOutcome::Exhausted(_) => 2,
58            FinalOutcome::HardBlock(_) => 1,
59            FinalOutcome::Timeout(_) => 2,
60            FinalOutcome::UserDeclined(_) => 1,
61            FinalOutcome::PreflightFailed(_) => 2,
62            // 130 = process terminated by SIGINT, the conventional Ctrl-C code.
63            FinalOutcome::Interrupted(_) => 130,
64        }
65    }
66}
67
68// ── Restore registry ──────────────────────────────────────────────────────────
69
70/// Per-op timeout for a single restore operation during the drain. Restores
71/// run best-effort on a terminal path (normal end, Ctrl-C, panic, or the outer
72/// drain cap), so each one is individually bounded to keep the drain prompt.
73const RESTORE_OP_TIMEOUT: Duration = Duration::from_secs(30);
74
75/// Scheduling/reporting headroom added after reserving one complete per-op
76/// timeout for every pending restore. This replaces the old fixed 90-second
77/// aggregate cap, which could prevent the fourth and later LIFO entries from
78/// receiving any attempt at all.
79const RESTORE_DRAIN_MARGIN: Duration = Duration::from_secs(5);
80
81/// An inverse operation that undoes a destructive change applied during the fix.
82///
83/// Destructive actions register the matching `RestoreOp` *before* mutating
84/// state and `mark_resolved` it once they have restored the state themselves
85/// on a normal path. Anything still unresolved when the run ends — whether it
86/// ended normally, via a supported signal, via a caught panic, or via the wall-clock cap —
87/// is replayed by [`RestoreRegistry::drain`].
88#[derive(Debug, Clone)]
89pub enum RestoreOp {
90    /// Bring a network interface back up (idempotent — safe if already up).
91    ReEnableInterface { iface: String },
92    /// Re-connect a consumer VPN that the fix disabled.
93    ReEnableVpn(Arc<DisabledVpn>),
94    /// Restore exact resolver state if a macOS DNS command is interrupted.
95    #[cfg(target_os = "macos")]
96    RestoreMacosDns(Arc<super::dns::MacosDnsSnapshot>),
97    /// Re-enable a macOS network service and restore exact DNS/search-domain
98    /// state. The hardened deep-reset path never deletes/recreates services.
99    #[cfg(target_os = "macos")]
100    ReEnableMacosService {
101        iface: String,
102        service: String,
103        dns_snapshot: Arc<super::dns::MacosDnsSnapshot>,
104    },
105}
106
107impl RestoreOp {
108    /// Short human-readable label for reports / manual-recovery guidance.
109    fn label(&self) -> String {
110        match self {
111            RestoreOp::ReEnableInterface { iface } => {
112                format!("re-enable network adapter {}", iface)
113            }
114            RestoreOp::ReEnableVpn(vpn) => format!("re-enable VPN {}", vpn.name),
115            #[cfg(target_os = "macos")]
116            RestoreOp::RestoreMacosDns(snapshot) => {
117                format!("restore DNS/search domains on {}", snapshot.service())
118            }
119            #[cfg(target_os = "macos")]
120            RestoreOp::ReEnableMacosService { service, .. } => {
121                format!("re-enable macOS network service {}", service)
122            }
123        }
124    }
125}
126
127/// Run a single restore operation. Returns `Ok(())` on success, `Err(reason)`
128/// on failure — the caller turns failures into manual-recovery guidance.
129async fn restore_op(op: &RestoreOp) -> Result<(), String> {
130    match op {
131        RestoreOp::ReEnableInterface { iface } => super::stages::restore_interface(iface).await,
132        RestoreOp::ReEnableVpn(vpn) => {
133            if super::vpn::reenable_vpn(vpn).await {
134                Ok(())
135            } else {
136                Err(format!(
137                    "could not positively verify VPN {} reconnected",
138                    vpn.name
139                ))
140            }
141        }
142        #[cfg(target_os = "macos")]
143        RestoreOp::RestoreMacosDns(snapshot) => {
144            super::dns::restore_macos_dns_snapshot(snapshot).await
145        }
146        #[cfg(target_os = "macos")]
147        RestoreOp::ReEnableMacosService {
148            iface,
149            service,
150            dns_snapshot,
151        } => super::stages::restore_macos_service_state(iface, service, dns_snapshot).await,
152    }
153}
154
155/// One entry in the restore registry: an inverse op plus whether the owning
156/// action already resolved it on a normal path.
157#[derive(Debug, Clone)]
158struct RegisteredOp {
159    op: RestoreOp,
160    resolved: bool,
161}
162
163/// Token identifying a registered restore op so the owning action can mark it
164/// resolved once it has restored the state itself.
165pub type RestoreToken = usize;
166
167/// Tracks the inverse of every destructive change the fix has applied, so any
168/// terminal path can roll back half-applied state.
169///
170/// Cheaply cloneable (it is an `Arc` around a single mutex), so the same
171/// registry can be shared into the loop future and the Ctrl-C / drain arm of
172/// the outer `select!`. The mutex is `tokio::sync::Mutex`, which is
173/// **non-poisoning** — a panic inside the loop (caught by `catch_unwind`) does
174/// not wedge the registry, so the post-panic drain still works.
175#[derive(Clone, Default)]
176pub struct RestoreRegistry {
177    inner: Arc<Mutex<Vec<RegisteredOp>>>,
178}
179
180impl RestoreRegistry {
181    pub fn new() -> Self {
182        Self {
183            inner: Arc::new(Mutex::new(Vec::new())),
184        }
185    }
186
187    /// Register an inverse op and return a token to resolve it later. Call this
188    /// *before* applying the matching destructive change.
189    pub async fn register(&self, op: RestoreOp) -> RestoreToken {
190        let mut guard = self.inner.lock().await;
191        guard.push(RegisteredOp {
192            op,
193            resolved: false,
194        });
195        guard.len() - 1
196    }
197
198    /// Mark a previously-registered op resolved — the owning action restored the
199    /// state itself, so the drain should skip it.
200    pub async fn mark_resolved(&self, token: RestoreToken) {
201        let mut guard = self.inner.lock().await;
202        if let Some(entry) = guard.get_mut(token) {
203            entry.resolved = true;
204        }
205    }
206
207    /// Aggregate timeout for a drain snapshot. Reserve a full bounded attempt
208    /// for every currently-pending inverse operation, plus small bookkeeping
209    /// headroom. The fix loop has stopped mutating state before it asks for
210    /// this value, so the following drain sees the same or fewer entries.
211    pub(crate) async fn required_drain_timeout(&self) -> Duration {
212        let pending = self
213            .inner
214            .lock()
215            .await
216            .iter()
217            .filter(|entry| !entry.resolved)
218            .count();
219        let pending = u64::try_from(pending).unwrap_or(u64::MAX);
220        let operation_budget = RESTORE_OP_TIMEOUT.as_secs().saturating_mul(pending);
221        Duration::from_secs(operation_budget.saturating_add(RESTORE_DRAIN_MARGIN.as_secs()))
222    }
223
224    /// Run every still-unresolved restore op, best-effort. Snapshots the
225    /// pending ops under the lock, releases the lock, then runs each with its
226    /// own timeout so a wedged restore can't stall the others. Returns a
227    /// human-readable failure string per op that could not be restored
228    /// (empty `Vec` = everything restored cleanly / nothing to do).
229    ///
230    /// Resolved ops are cleared from the registry as they succeed, so a second
231    /// drain (should one ever happen) is a no-op for already-restored state.
232    pub async fn drain(&self) -> Vec<String> {
233        // Snapshot pending ops + their indices under the lock, then release it
234        // so the restore subprocess calls don't hold the mutex.
235        let pending: Vec<(usize, RestoreOp)> = {
236            let guard = self.inner.lock().await;
237            guard
238                .iter()
239                .enumerate()
240                .rev()
241                .filter(|(_, e)| !e.resolved)
242                .map(|(i, e)| (i, e.op.clone()))
243                .collect()
244        };
245
246        let mut failures = Vec::new();
247        for (idx, op) in pending {
248            let result = match tokio::time::timeout(RESTORE_OP_TIMEOUT, restore_op(&op)).await {
249                Ok(Ok(())) => Ok(()),
250                Ok(Err(e)) => Err(e),
251                Err(_) => Err(format!("timed out after {}s", RESTORE_OP_TIMEOUT.as_secs())),
252            };
253            match result {
254                Ok(()) => {
255                    // Mark resolved so a re-drain won't repeat it.
256                    let mut guard = self.inner.lock().await;
257                    if let Some(entry) = guard.get_mut(idx) {
258                        entry.resolved = true;
259                    }
260                }
261                Err(reason) => {
262                    failures.push(format!("Could not {}: {}", op.label(), reason));
263                }
264            }
265        }
266        failures
267    }
268
269    /// Number of still-unresolved restore ops. Test-only inspection helper.
270    #[cfg(test)]
271    pub(crate) async fn pending_count(&self) -> usize {
272        self.inner
273            .lock()
274            .await
275            .iter()
276            .filter(|e| !e.resolved)
277            .count()
278    }
279}
280
281/// One row in the iteration timeline — what happened during one pass through
282/// the loop.
283#[derive(Debug, Clone)]
284pub struct ActionRecord {
285    pub action_id: super::action::ActionId,
286    pub label: &'static str,
287    /// The failure categories this action declares it can address — used by
288    /// effectiveness attribution so an action is only credited for clearing
289    /// failures it actually targets.
290    pub targets: &'static [DiagnosticKey],
291    pub outcome: ActionOutcome,
292    pub duration: Duration,
293    pub iteration: u8,
294    /// Set when the user declined a confirmation prompt for this action.
295    pub user_declined: bool,
296    /// Set when the action was skipped because we couldn't render an
297    /// interactive prompt (e.g. JSON mode + confirmation-gated action).
298    pub skipped_no_interaction: bool,
299}
300
301#[derive(Debug)]
302pub struct IterationSnapshot {
303    pub iteration: u8,
304    pub results: DiagnosticResults,
305}
306
307#[derive(Debug)]
308pub struct Session {
309    pub started_at: Instant,
310    pub baseline: Option<DiagnosticResults>,
311    /// The second baseline pass used to confirm the failure set before the
312    /// first repair plan. Kept separate from `snapshots` so the report's
313    /// "final snapshot" and the JSON `iterations` count keep their meaning.
314    pub baseline_confirmation: Option<DiagnosticResults>,
315    /// Failures seen in exactly one of the two baseline passes — transient
316    /// flickers. Their later "recoveries" earn no effectiveness credit.
317    pub intermittent: std::collections::HashSet<DiagnosticKey>,
318    pub snapshots: Vec<IterationSnapshot>,
319    pub action_log: Vec<ActionRecord>,
320    pub attempts: Attempts,
321    pub effectiveness: Effectiveness,
322    pub vpn_names: Vec<String>,
323    pub final_outcome: Option<FinalOutcome>,
324}
325
326impl Session {
327    pub fn new() -> Self {
328        Self {
329            started_at: Instant::now(),
330            baseline: None,
331            baseline_confirmation: None,
332            intermittent: std::collections::HashSet::new(),
333            snapshots: Vec::new(),
334            action_log: Vec::new(),
335            attempts: Attempts::new(),
336            effectiveness: Effectiveness::new(),
337            vpn_names: Vec::new(),
338            final_outcome: None,
339        }
340    }
341
342    pub fn elapsed(&self) -> Duration {
343        self.started_at.elapsed()
344    }
345
346    pub fn wall_clock_exhausted(&self) -> bool {
347        self.elapsed() >= WALL_CLOCK_CAP
348    }
349
350    pub fn record_baseline(&mut self, results: DiagnosticResults) {
351        self.baseline = Some(results.clone());
352        self.snapshots.push(IterationSnapshot {
353            iteration: 0,
354            results,
355        });
356    }
357
358    pub fn record_iteration(&mut self, iteration: u8, results: DiagnosticResults) {
359        self.snapshots
360            .push(IterationSnapshot { iteration, results });
361    }
362
363    /// Record the second baseline pass and the failures that flickered
364    /// between the two passes.
365    pub fn record_confirmation(
366        &mut self,
367        results: DiagnosticResults,
368        intermittent: std::collections::HashSet<DiagnosticKey>,
369    ) {
370        self.baseline_confirmation = Some(results);
371        self.intermittent = intermittent;
372    }
373
374    pub fn record_action(
375        &mut self,
376        iteration: u8,
377        action: &Action,
378        outcome: ActionOutcome,
379        duration: Duration,
380        user_declined: bool,
381        skipped_no_interaction: bool,
382    ) {
383        let entry = self.attempts.entry(action.id).or_insert(0);
384        if !skipped_no_interaction && !user_declined {
385            *entry = entry.saturating_add(1);
386        }
387        self.action_log.push(ActionRecord {
388            action_id: action.id,
389            label: action.label,
390            targets: action.targets,
391            outcome,
392            duration,
393            iteration,
394            user_declined,
395            skipped_no_interaction,
396        });
397    }
398
399    /// After an iteration: compare prior failures to current ones; credit each
400    /// newly-cleared failure to the **most recent successful action this
401    /// iteration whose declared targets contain it**. Failures flagged
402    /// intermittent at baseline earn no credit — natural recoveries must not
403    /// bias future planning toward unrelated actions.
404    pub fn update_effectiveness(
405        &mut self,
406        iteration: u8,
407        prior_failures: &std::collections::HashSet<DiagnosticKey>,
408        current_failures: &std::collections::HashSet<DiagnosticKey>,
409    ) {
410        let cleared: std::collections::HashSet<DiagnosticKey> = prior_failures
411            .difference(current_failures)
412            .copied()
413            .collect();
414        for key in cleared {
415            if self.intermittent.contains(&key) {
416                continue;
417            }
418            if let Some(record) = self
419                .action_log
420                .iter()
421                .rev()
422                .take_while(|r| r.iteration == iteration)
423                .find(|r| r.outcome.ok && r.targets.contains(&key))
424            {
425                self.effectiveness.insert((record.action_id, key), true);
426            }
427        }
428    }
429}
430
431impl Default for Session {
432    fn default() -> Self {
433        Self::new()
434    }
435}
436
437// ── Reporter ────────────────────────────────────────────────────────────────
438
439/// Plain-language reporter for terminal output. Uses the same Config as the
440/// rest of nd300 so colors / ASCII / verbose are honored consistently.
441pub struct Reporter<'a> {
442    pub config: &'a Config,
443}
444
445impl<'a> Reporter<'a> {
446    pub fn new(config: &'a Config) -> Self {
447        Self { config }
448    }
449
450    pub fn header(&self) {
451        println!();
452        println!(
453            "  {} {}",
454            color::cyan("nd300 fix", self.config),
455            color::dim("— diagnostic-driven recovery", self.config),
456        );
457    }
458
459    pub fn iteration_header(&self, iteration: u8) {
460        println!();
461        println!(
462            "  {} {}",
463            color::cyan(&format!("Iteration {}", iteration), self.config),
464            color::dim(
465                "— running diagnostics, then applying targeted fixes",
466                self.config,
467            ),
468        );
469    }
470
471    pub fn baseline_summary(&self, failure_count: usize) {
472        if failure_count == 0 {
473            println!(
474                "  {} {}",
475                color::green("✓", self.config),
476                color::green("All diagnostics passing — nothing to fix.", self.config),
477            );
478        } else {
479            println!(
480                "  {} found {} failing area{}",
481                color::yellow("→", self.config),
482                failure_count,
483                if failure_count == 1 { "" } else { "s" },
484            );
485        }
486    }
487
488    /// Announce the second baseline pass that double-checks the failure set.
489    pub fn confirmation_pass(&self) {
490        println!(
491            "  {} {}",
492            color::dim("→", self.config),
493            color::dim(
494                "Double-checking the failures with a second diagnostic pass...",
495                self.config,
496            ),
497        );
498    }
499
500    /// Report how the confirmation pass shook out.
501    pub fn confirmation_result(&self, confirmed: usize, transient: usize) {
502        if confirmed == 0 && transient > 0 {
503            println!(
504                "  {} {}",
505                color::green("✓", self.config),
506                color::green(
507                    "The failures did not reproduce on the second pass — they were transient.",
508                    self.config,
509                ),
510            );
511        } else if transient > 0 {
512            println!(
513                "  {} {} confirmed, {} transient (will not be acted on)",
514                color::yellow("→", self.config),
515                confirmed,
516                transient,
517            );
518        } else {
519            println!(
520                "  {} {} failure{} confirmed on both passes",
521                color::yellow("→", self.config),
522                confirmed,
523                if confirmed == 1 { "" } else { "s" },
524            );
525        }
526    }
527
528    pub fn announce_action(&self, action: &Action) {
529        println!();
530        println!(
531            "  {} {}",
532            color::dim("•", self.config),
533            color::dim(action.one_line_why, self.config),
534        );
535        print!("  {} {} ", color::cyan("→", self.config), action.label,);
536        use std::io::Write;
537        let _ = std::io::stdout().flush();
538    }
539
540    pub fn finish_action(&self, outcome: &ActionOutcome, duration: Duration) {
541        if outcome.ok {
542            println!(
543                "{} {}",
544                color::green("✓", self.config),
545                color::dim(
546                    &format!("({:.1}s) {}", duration.as_secs_f64(), outcome.message),
547                    self.config,
548                ),
549            );
550        } else {
551            println!(
552                "{} {}",
553                color::red("✗", self.config),
554                color::red(&outcome.message, self.config),
555            );
556        }
557    }
558
559    pub fn finish_action_skipped(&self, reason: &str) {
560        println!(
561            "{} {}",
562            color::yellow("·", self.config),
563            color::yellow(reason, self.config),
564        );
565    }
566
567    /// Render a compact confirmation gate for mutating, non-high-risk actions.
568    /// Returns `true` only on explicit y/yes.
569    pub fn confirmation_prompt(&self, action: &Action) -> bool {
570        println!();
571        println!(
572            "  {} {}",
573            color::yellow("Confirm:", self.config),
574            color::yellow(action.label, self.config),
575        );
576        println!("    {}", color::dim(action.one_line_why, self.config));
577        println!(
578            "    {}",
579            color::dim("This changes live network settings. Use --yes to auto-confirm this class of action.", self.config),
580        );
581
582        use std::io::Write;
583        print!(
584            "  {} ",
585            color::yellow(
586                "Continue? Type 'y' to proceed, anything else to skip:",
587                self.config
588            ),
589        );
590        let _ = std::io::stdout().flush();
591
592        let mut input = String::new();
593        if std::io::stdin().read_line(&mut input).is_err() {
594            return false;
595        }
596        matches!(input.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
597    }
598
599    /// Render the structured high-risk action prompt and read Y/N from stdin.
600    /// Returns `true` only on an explicit `y` / `Y` / `yes`. Anything else,
601    /// including an empty press, is treated as N.
602    pub fn high_risk_prompt(&self, action: &Action) -> bool {
603        let exp = match &action.risk {
604            Risk::High(e) => e,
605            _ => return true, // non-High shouldn't reach here
606        };
607
608        let header = format!("Escalating: {}", exp.what);
609        let bar = if self.config.use_unicode { '─' } else { '-' };
610        let rule: String = std::iter::repeat_n(bar, 76).collect();
611
612        println!();
613        println!(
614            "  {}",
615            color::yellow(&format!("┌─ {} ", header), self.config)
616        );
617        println!("  {}", color::dim(&rule, self.config));
618        println!("  {}", color::bold("Why I want to do this:", self.config));
619        for line in wrap_text(exp.why, 70) {
620            println!("    {}", line);
621        }
622        println!();
623        println!("  {}", color::bold("What will happen:", self.config));
624        for bullet in exp.side_effects {
625            println!("    • {}", bullet);
626        }
627        println!();
628        println!(
629            "  {} {}",
630            color::bold("Reversible:", self.config),
631            exp.reversible.label(),
632        );
633        println!(
634            "  {} {}",
635            color::bold("Typical duration:", self.config),
636            exp.typical_duration,
637        );
638        println!("  {}", color::dim(&rule, self.config));
639
640        // Strict Y/N — empty input or any non-y answer is treated as No.
641        use std::io::Write;
642        print!(
643            "  {} ",
644            color::yellow(
645                "Continue? Type 'y' to proceed, anything else to skip:",
646                self.config
647            ),
648        );
649        let _ = std::io::stdout().flush();
650
651        let mut input = String::new();
652        if std::io::stdin().read_line(&mut input).is_err() {
653            return false;
654        }
655        matches!(input.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
656    }
657
658    pub fn high_risk_skipped_no_tty(&self, action: &Action) {
659        println!(
660            "  {} {}",
661            color::yellow("·", self.config),
662            color::yellow(
663                &format!(
664                    "Skipped {}: this action requires interactive confirmation. Re-run `nd300 fix` in a terminal to attempt it.",
665                    action.label,
666                ),
667                self.config,
668            ),
669        );
670    }
671
672    pub fn high_risk_declined(&self, action: &Action) {
673        self.confirmation_declined(action);
674    }
675
676    pub fn confirmation_declined(&self, action: &Action) {
677        println!(
678            "  {} {}",
679            color::yellow("·", self.config),
680            color::dim(
681                &format!("Skipped {} (you declined the prompt).", action.label),
682                self.config,
683            ),
684        );
685    }
686
687    pub fn final_verdict(&self, outcome: &FinalOutcome, report_path: Option<&std::path::Path>) {
688        let bar = if self.config.use_unicode { '─' } else { '-' };
689        let rule: String = std::iter::repeat_n(bar, 50).collect();
690
691        println!();
692        println!(
693            "  {} {}",
694            color::bold("Result", self.config),
695            color::dim(&rule, self.config),
696        );
697
698        match outcome {
699            FinalOutcome::Fixed => {
700                println!(
701                    "  {} {}",
702                    color::green("✓ Fixed", self.config),
703                    color::green(
704                        "Connectivity is healthy now. The actions above resolved the failures.",
705                        self.config,
706                    ),
707                );
708            }
709            FinalOutcome::Partial(remaining) => {
710                println!(
711                    "  {} {}",
712                    color::yellow("⚠ Partially fixed", self.config),
713                    color::yellow(
714                        &format!(
715                            "{} remain{}",
716                            describe_keys(remaining),
717                            if remaining.len() == 1 { "s" } else { "" }
718                        ),
719                        self.config,
720                    ),
721                );
722                self.suggestions_for(remaining);
723            }
724            FinalOutcome::Exhausted(remaining) => {
725                println!(
726                    "  {} {}",
727                    color::red("✗ Couldn't fix", self.config),
728                    color::red(
729                        &format!(
730                            "Tried every applicable action; {} still failing",
731                            describe_keys(remaining)
732                        ),
733                        self.config,
734                    ),
735                );
736                self.suggestions_for(remaining);
737            }
738            FinalOutcome::HardBlock(reason) => {
739                println!(
740                    "  {} {}",
741                    color::yellow("⚠ Cannot fix from here", self.config),
742                    color::yellow(&reason.user_message(), self.config),
743                );
744            }
745            FinalOutcome::Timeout(remaining) => {
746                println!(
747                    "  {} {}",
748                    color::red("✗ Timed out", self.config),
749                    color::red(
750                        &format!(
751                            "Loop hit its safety cap; {} still failing",
752                            describe_keys(remaining)
753                        ),
754                        self.config,
755                    ),
756                );
757                self.suggestions_for(remaining);
758            }
759            FinalOutcome::UserDeclined(remaining) => {
760                println!(
761                    "  {} {}",
762                    color::yellow("⚠ Stopped at your request", self.config),
763                    color::yellow(
764                        &format!(
765                            "You declined a confirmation prompt; {} still failing",
766                            describe_keys(remaining)
767                        ),
768                        self.config,
769                    ),
770                );
771                self.suggestions_for(remaining);
772            }
773            FinalOutcome::PreflightFailed(reason) => {
774                println!(
775                    "  {} {}",
776                    color::red("✗ Could not start", self.config),
777                    color::red(reason, self.config),
778                );
779            }
780            FinalOutcome::Interrupted(_) => {
781                println!(
782                    "  {} {}",
783                    color::yellow("⚠ Interrupted", self.config),
784                    color::yellow(
785                        "The fix was stopped before it finished. nd300 attempted to restore any network state it had changed — see below for anything that needs manual recovery.",
786                        self.config,
787                    ),
788                );
789            }
790        }
791
792        if let Some(path) = report_path {
793            println!(
794                "  {} {}",
795                color::dim("Full report:", self.config),
796                color::dim(&path.display().to_string(), self.config),
797            );
798        }
799    }
800
801    fn suggestions_for(&self, remaining: &[DiagnosticKey]) {
802        if remaining.is_empty() {
803            return;
804        }
805        println!();
806        println!("  {}", color::bold("What to try next:", self.config));
807        for s in suggestions_for_keys(remaining) {
808            println!("    • {}", s);
809        }
810    }
811}
812
813fn describe_keys(keys: &[DiagnosticKey]) -> String {
814    let names: Vec<&str> = keys.iter().map(|k| key_label(*k)).collect();
815    names.join(", ")
816}
817
818fn key_label(k: DiagnosticKey) -> &'static str {
819    match k {
820        DiagnosticKey::Adapters => "network adapter",
821        DiagnosticKey::Interfaces => "network interface",
822        DiagnosticKey::Gateway => "gateway / router",
823        DiagnosticKey::Dns => "DNS",
824        DiagnosticKey::PublicIp => "public IP",
825        DiagnosticKey::Latency => "latency",
826        DiagnosticKey::Ports => "outbound ports",
827        DiagnosticKey::Speed => "speed test",
828    }
829}
830
831/// Map remaining failure keys to concrete plain-language suggestions.
832pub fn suggestions_for_keys(remaining: &[DiagnosticKey]) -> Vec<String> {
833    use DiagnosticKey::*;
834    let mut out: Vec<String> = Vec::new();
835
836    let has = |k: DiagnosticKey| remaining.contains(&k);
837
838    if has(Adapters) || has(Interfaces) {
839        out.push(
840            "Reboot your computer — a deeper hardware/driver state may need a clean start."
841                .to_string(),
842        );
843        out.push(
844            "Check for network driver updates from your hardware vendor (Intel, Realtek, etc.)."
845                .to_string(),
846        );
847    }
848    if has(Gateway) {
849        out.push(
850            "Power-cycle your router / modem (unplug for 30 seconds, plug back in).".to_string(),
851        );
852        out.push("Try a different cable or Wi-Fi network if available.".to_string());
853    }
854    if has(Dns) {
855        out.push(
856            "Try `nd300 fix` again, then choose `Switch DNS to Cloudflare` if it offers it."
857                .to_string(),
858        );
859        out.push(
860            "Check your router admin page for a custom DNS setting and remove it.".to_string(),
861        );
862    }
863    if has(PublicIp) {
864        out.push("Check your ISP's status page — there may be an outage in your area.".to_string());
865        out.push(
866            "Disconnect any VPN you have running (including work VPNs) and re-test.".to_string(),
867        );
868    }
869    if has(Latency) {
870        out.push("If on Wi-Fi: move closer to your router or try a 5 GHz network.".to_string());
871        out.push(
872            "Run a speed test from another device on the same network to compare.".to_string(),
873        );
874    }
875    if has(Ports) {
876        out.push(
877            "Outbound ports may be blocked by a firewall (work network or AV software). Contact IT or check your firewall rules.".to_string(),
878        );
879    }
880
881    if out.is_empty() {
882        out.push("Reboot the machine and try again.".to_string());
883        out.push(
884            "Run `nd300 -t` for the full technician report and share it with support.".to_string(),
885        );
886    }
887    out
888}
889
890/// Wrap a text block to the given column width without splitting words.
891fn wrap_text(text: &str, width: usize) -> Vec<String> {
892    let mut lines = Vec::new();
893    let mut current = String::new();
894    for word in text.split_whitespace() {
895        if current.len() + word.len() + 1 > width && !current.is_empty() {
896            lines.push(std::mem::take(&mut current));
897        }
898        if !current.is_empty() {
899            current.push(' ');
900        }
901        current.push_str(word);
902    }
903    if !current.is_empty() {
904        lines.push(current);
905    }
906    lines
907}
908
909#[cfg(test)]
910mod restore_registry_tests {
911    use super::*;
912    use crate::actions::fix::vpn::{DisableMethod, DisabledVpn};
913
914    /// A restore op that fails fast and deterministically: a vendor CLI whose
915    /// binary does not exist, so `restore_op` returns a spawn error without
916    /// touching any real network state.
917    fn failing_vpn_op(name: &str) -> RestoreOp {
918        RestoreOp::ReEnableVpn(Arc::new(DisabledVpn {
919            name: name.to_string(),
920            method: DisableMethod::VendorCli(
921                "nd300-nonexistent-vpn-binary".to_string(),
922                vec!["connect".to_string()],
923            ),
924        }))
925    }
926
927    #[tokio::test]
928    async fn empty_registry_drains_to_no_failures() {
929        let reg = RestoreRegistry::new();
930        assert_eq!(reg.pending_count().await, 0);
931        assert!(reg.drain().await.is_empty());
932    }
933
934    #[tokio::test]
935    async fn register_then_resolve_clears_pending() {
936        let reg = RestoreRegistry::new();
937        let t1 = reg.register(failing_vpn_op("VpnA")).await;
938        let _t2 = reg.register(failing_vpn_op("VpnB")).await;
939        assert_eq!(reg.pending_count().await, 2);
940
941        reg.mark_resolved(t1).await;
942        assert_eq!(reg.pending_count().await, 1);
943    }
944
945    #[tokio::test]
946    async fn drain_skips_resolved_and_reports_unresolved_failures() {
947        let reg = RestoreRegistry::new();
948        let resolved = reg.register(failing_vpn_op("ResolvedVpn")).await;
949        let _pending = reg.register(failing_vpn_op("PendingVpn")).await;
950
951        // The first op is restored by its owning action — mark it resolved so
952        // the drain must skip it entirely.
953        reg.mark_resolved(resolved).await;
954
955        let failures = reg.drain().await;
956
957        // Exactly one failure, for the still-unresolved op, and it must name
958        // that VPN (proving the resolved one was skipped, not attempted).
959        assert_eq!(failures.len(), 1, "failures: {:?}", failures);
960        assert!(
961            failures[0].contains("PendingVpn"),
962            "unexpected failure text: {}",
963            failures[0]
964        );
965        assert!(
966            !failures[0].contains("ResolvedVpn"),
967            "resolved op should not have been drained: {}",
968            failures[0]
969        );
970
971        // After a drain, even failed ops are not retried as "pending success",
972        // but the unresolved op stays unresolved (drain only marks resolved on
973        // success), so a second drain re-attempts it and fails the same way.
974        let second = reg.drain().await;
975        assert_eq!(second.len(), 1);
976    }
977
978    #[tokio::test]
979    async fn drain_restores_in_lifo_order() {
980        let reg = RestoreRegistry::new();
981        reg.register(failing_vpn_op("FirstRegistered")).await;
982        reg.register(failing_vpn_op("LastRegistered")).await;
983
984        let failures = reg.drain().await;
985
986        assert_eq!(failures.len(), 2);
987        assert!(failures[0].contains("LastRegistered"), "{failures:?}");
988        assert!(failures[1].contains("FirstRegistered"), "{failures:?}");
989    }
990
991    #[tokio::test]
992    async fn aggregate_timeout_reserves_an_attempt_for_every_pending_restore() {
993        let reg = RestoreRegistry::new();
994        for index in 0..4 {
995            reg.register(failing_vpn_op(&format!("Vpn{index}"))).await;
996        }
997
998        assert_eq!(reg.required_drain_timeout().await, Duration::from_secs(125));
999    }
1000
1001    #[tokio::test]
1002    async fn drain_attempts_more_than_three_pending_ops_in_lifo_order() {
1003        let reg = RestoreRegistry::new();
1004        for index in 0..5 {
1005            reg.register(failing_vpn_op(&format!("Vpn{index}"))).await;
1006        }
1007
1008        let failures = reg.drain().await;
1009
1010        assert_eq!(failures.len(), 5, "failures: {failures:?}");
1011        for (position, expected_index) in (0..5).rev().enumerate() {
1012            assert!(
1013                failures[position].contains(&format!("Vpn{expected_index}")),
1014                "unexpected LIFO result: {failures:?}"
1015            );
1016        }
1017    }
1018
1019    #[test]
1020    fn interrupted_exit_code_is_130() {
1021        assert_eq!(FinalOutcome::Interrupted(Vec::new()).exit_code(), 130);
1022    }
1023}
1024
1025#[cfg(test)]
1026mod effectiveness_tests {
1027    use super::*;
1028    use crate::actions::fix::action::ActionId;
1029    use std::collections::HashSet;
1030
1031    fn record(
1032        action_id: ActionId,
1033        targets: &'static [DiagnosticKey],
1034        ok: bool,
1035        iteration: u8,
1036    ) -> ActionRecord {
1037        ActionRecord {
1038            action_id,
1039            label: "test action",
1040            targets,
1041            outcome: if ok {
1042                ActionOutcome::ok("ok")
1043            } else {
1044                ActionOutcome::fail("fail")
1045            },
1046            duration: Duration::from_millis(1),
1047            iteration,
1048            user_declined: false,
1049            skipped_no_interaction: false,
1050        }
1051    }
1052
1053    fn keys(ks: &[DiagnosticKey]) -> HashSet<DiagnosticKey> {
1054        ks.iter().copied().collect()
1055    }
1056
1057    /// Pins the fix for the original bug: a successful action that does NOT
1058    /// target the cleared key must earn no credit for it.
1059    #[test]
1060    fn credit_requires_matching_targets() {
1061        let mut s = Session::new();
1062        s.action_log.push(record(
1063            ActionId::FlushArp,
1064            &[DiagnosticKey::Gateway],
1065            true,
1066            1,
1067        ));
1068        s.action_log
1069            .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1070
1071        s.update_effectiveness(1, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1072
1073        assert_eq!(
1074            s.effectiveness
1075                .get(&(ActionId::FlushDns, DiagnosticKey::Dns)),
1076            Some(&true)
1077        );
1078        assert!(
1079            !s.effectiveness
1080                .contains_key(&(ActionId::FlushArp, DiagnosticKey::Dns)),
1081            "non-targeting action must not be credited"
1082        );
1083    }
1084
1085    #[test]
1086    fn most_recent_targeting_action_wins() {
1087        let mut s = Session::new();
1088        s.action_log
1089            .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1090        s.action_log.push(record(
1091            ActionId::SetDnsAutomatic,
1092            &[DiagnosticKey::Dns],
1093            true,
1094            1,
1095        ));
1096
1097        s.update_effectiveness(1, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1098
1099        assert!(s
1100            .effectiveness
1101            .contains_key(&(ActionId::SetDnsAutomatic, DiagnosticKey::Dns)));
1102        assert!(
1103            !s.effectiveness
1104                .contains_key(&(ActionId::FlushDns, DiagnosticKey::Dns)),
1105            "only the most recent targeting action is credited"
1106        );
1107    }
1108
1109    #[test]
1110    fn failed_action_earns_no_credit() {
1111        let mut s = Session::new();
1112        s.action_log
1113            .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], false, 1));
1114
1115        s.update_effectiveness(1, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1116
1117        assert!(s.effectiveness.is_empty());
1118    }
1119
1120    #[test]
1121    fn prior_iteration_records_never_credited() {
1122        let mut s = Session::new();
1123        s.action_log
1124            .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1125
1126        s.update_effectiveness(2, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1127
1128        assert!(s.effectiveness.is_empty());
1129    }
1130
1131    #[test]
1132    fn nothing_cleared_changes_nothing() {
1133        let mut s = Session::new();
1134        s.action_log
1135            .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1136
1137        s.update_effectiveness(
1138            1,
1139            &keys(&[DiagnosticKey::Dns]),
1140            &keys(&[DiagnosticKey::Dns]),
1141        );
1142
1143        assert!(s.effectiveness.is_empty());
1144    }
1145
1146    /// A failure flagged intermittent at baseline must not generate credit
1147    /// when it "clears" — that's the natural recovery the confirmation pass
1148    /// observed, not the action working.
1149    #[test]
1150    fn intermittent_key_earns_no_credit() {
1151        let mut s = Session::new();
1152        s.intermittent.insert(DiagnosticKey::Dns);
1153        s.action_log
1154            .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1155
1156        s.update_effectiveness(1, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1157
1158        assert!(s.effectiveness.is_empty());
1159    }
1160}