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