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    pub outcome: ActionOutcome,
251    pub duration: Duration,
252    pub iteration: u8,
253    /// Set when the user declined a confirmation prompt for this action.
254    pub user_declined: bool,
255    /// Set when the action was skipped because we couldn't render an
256    /// interactive prompt (e.g. JSON mode + confirmation-gated action).
257    pub skipped_no_interaction: bool,
258}
259
260#[derive(Debug)]
261pub struct IterationSnapshot {
262    pub iteration: u8,
263    pub results: DiagnosticResults,
264}
265
266#[derive(Debug)]
267pub struct Session {
268    pub started_at: Instant,
269    pub baseline: Option<DiagnosticResults>,
270    pub snapshots: Vec<IterationSnapshot>,
271    pub action_log: Vec<ActionRecord>,
272    pub attempts: Attempts,
273    pub effectiveness: Effectiveness,
274    pub vpn_names: Vec<String>,
275    pub final_outcome: Option<FinalOutcome>,
276}
277
278impl Session {
279    pub fn new() -> Self {
280        Self {
281            started_at: Instant::now(),
282            baseline: None,
283            snapshots: Vec::new(),
284            action_log: Vec::new(),
285            attempts: Attempts::new(),
286            effectiveness: Effectiveness::new(),
287            vpn_names: Vec::new(),
288            final_outcome: None,
289        }
290    }
291
292    pub fn elapsed(&self) -> Duration {
293        self.started_at.elapsed()
294    }
295
296    pub fn wall_clock_exhausted(&self) -> bool {
297        self.elapsed() >= WALL_CLOCK_CAP
298    }
299
300    pub fn record_baseline(&mut self, results: DiagnosticResults) {
301        self.baseline = Some(results.clone());
302        self.snapshots.push(IterationSnapshot {
303            iteration: 0,
304            results,
305        });
306    }
307
308    pub fn record_iteration(&mut self, iteration: u8, results: DiagnosticResults) {
309        self.snapshots
310            .push(IterationSnapshot { iteration, results });
311    }
312
313    pub fn record_action(
314        &mut self,
315        iteration: u8,
316        action: &Action,
317        outcome: ActionOutcome,
318        duration: Duration,
319        user_declined: bool,
320        skipped_no_interaction: bool,
321    ) {
322        let entry = self.attempts.entry(action.id).or_insert(0);
323        if !skipped_no_interaction && !user_declined {
324            *entry = entry.saturating_add(1);
325        }
326        self.action_log.push(ActionRecord {
327            action_id: action.id,
328            label: action.label,
329            outcome,
330            duration,
331            iteration,
332            user_declined,
333            skipped_no_interaction,
334        });
335    }
336
337    /// After an iteration: compare prior failures to current ones; for each
338    /// action applied this iteration, mark which targets newly cleared.
339    pub fn update_effectiveness(
340        &mut self,
341        iteration: u8,
342        prior_failures: &std::collections::HashSet<DiagnosticKey>,
343        current_failures: &std::collections::HashSet<DiagnosticKey>,
344    ) {
345        let cleared: std::collections::HashSet<DiagnosticKey> = prior_failures
346            .difference(current_failures)
347            .copied()
348            .collect();
349        if cleared.is_empty() {
350            return;
351        }
352        // Attribute clears to the last action in this iteration that targeted them.
353        for record in self.action_log.iter().rev() {
354            if record.iteration != iteration {
355                break;
356            }
357            if !record.outcome.ok {
358                continue;
359            }
360            // Find the registry action so we can read its targets.
361            // (The label match is sufficient — ActionIds are unique.)
362            for k in cleared.iter() {
363                self.effectiveness.insert((record.action_id, *k), true);
364            }
365        }
366    }
367}
368
369impl Default for Session {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375// ── Reporter ────────────────────────────────────────────────────────────────
376
377/// Plain-language reporter for terminal output. Uses the same Config as the
378/// rest of nd300 so colors / ASCII / verbose are honored consistently.
379pub struct Reporter<'a> {
380    pub config: &'a Config,
381}
382
383impl<'a> Reporter<'a> {
384    pub fn new(config: &'a Config) -> Self {
385        Self { config }
386    }
387
388    pub fn header(&self) {
389        println!();
390        println!(
391            "  {} {}",
392            color::cyan("nd300 fix", self.config),
393            color::dim("— diagnostic-driven recovery", self.config),
394        );
395    }
396
397    pub fn iteration_header(&self, iteration: u8) {
398        println!();
399        println!(
400            "  {} {}",
401            color::cyan(&format!("Iteration {}", iteration), self.config),
402            color::dim(
403                "— running diagnostics, then applying targeted fixes",
404                self.config,
405            ),
406        );
407    }
408
409    pub fn baseline_summary(&self, failure_count: usize) {
410        if failure_count == 0 {
411            println!(
412                "  {} {}",
413                color::green("✓", self.config),
414                color::green("All diagnostics passing — nothing to fix.", self.config),
415            );
416        } else {
417            println!(
418                "  {} found {} failing area{}",
419                color::yellow("→", self.config),
420                failure_count,
421                if failure_count == 1 { "" } else { "s" },
422            );
423        }
424    }
425
426    pub fn announce_action(&self, action: &Action) {
427        println!();
428        println!(
429            "  {} {}",
430            color::dim("•", self.config),
431            color::dim(action.one_line_why, self.config),
432        );
433        print!("  {} {} ", color::cyan("→", self.config), action.label,);
434        use std::io::Write;
435        let _ = std::io::stdout().flush();
436    }
437
438    pub fn finish_action(&self, outcome: &ActionOutcome, duration: Duration) {
439        if outcome.ok {
440            println!(
441                "{} {}",
442                color::green("✓", self.config),
443                color::dim(
444                    &format!("({:.1}s) {}", duration.as_secs_f64(), outcome.message),
445                    self.config,
446                ),
447            );
448        } else {
449            println!(
450                "{} {}",
451                color::red("✗", self.config),
452                color::red(&outcome.message, self.config),
453            );
454        }
455    }
456
457    pub fn finish_action_skipped(&self, reason: &str) {
458        println!(
459            "{} {}",
460            color::yellow("·", self.config),
461            color::yellow(reason, self.config),
462        );
463    }
464
465    /// Render a compact confirmation gate for mutating, non-high-risk actions.
466    /// Returns `true` only on explicit y/yes.
467    pub fn confirmation_prompt(&self, action: &Action) -> bool {
468        println!();
469        println!(
470            "  {} {}",
471            color::yellow("Confirm:", self.config),
472            color::yellow(action.label, self.config),
473        );
474        println!("    {}", color::dim(action.one_line_why, self.config));
475        println!(
476            "    {}",
477            color::dim("This changes live network settings. Use --yes to auto-confirm this class of action.", self.config),
478        );
479
480        use std::io::Write;
481        print!(
482            "  {} ",
483            color::yellow(
484                "Continue? Type 'y' to proceed, anything else to skip:",
485                self.config
486            ),
487        );
488        let _ = std::io::stdout().flush();
489
490        let mut input = String::new();
491        if std::io::stdin().read_line(&mut input).is_err() {
492            return false;
493        }
494        matches!(input.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
495    }
496
497    /// Render the structured high-risk action prompt and read Y/N from stdin.
498    /// Returns `true` only on an explicit `y` / `Y` / `yes`. Anything else,
499    /// including an empty press, is treated as N.
500    pub fn high_risk_prompt(&self, action: &Action) -> bool {
501        let exp = match &action.risk {
502            Risk::High(e) => e,
503            _ => return true, // non-High shouldn't reach here
504        };
505
506        let header = format!("Escalating: {}", exp.what);
507        let bar = if self.config.use_unicode { '─' } else { '-' };
508        let rule: String = std::iter::repeat_n(bar, 76).collect();
509
510        println!();
511        println!(
512            "  {}",
513            color::yellow(&format!("┌─ {} ", header), self.config)
514        );
515        println!("  {}", color::dim(&rule, self.config));
516        println!("  {}", color::bold("Why I want to do this:", self.config));
517        for line in wrap_text(exp.why, 70) {
518            println!("    {}", line);
519        }
520        println!();
521        println!("  {}", color::bold("What will happen:", self.config));
522        for bullet in exp.side_effects {
523            println!("    • {}", bullet);
524        }
525        println!();
526        println!(
527            "  {} {}",
528            color::bold("Reversible:", self.config),
529            exp.reversible.label(),
530        );
531        println!(
532            "  {} {}",
533            color::bold("Typical duration:", self.config),
534            exp.typical_duration,
535        );
536        println!("  {}", color::dim(&rule, self.config));
537
538        // Strict Y/N — empty input or any non-y answer is treated as No.
539        use std::io::Write;
540        print!(
541            "  {} ",
542            color::yellow(
543                "Continue? Type 'y' to proceed, anything else to skip:",
544                self.config
545            ),
546        );
547        let _ = std::io::stdout().flush();
548
549        let mut input = String::new();
550        if std::io::stdin().read_line(&mut input).is_err() {
551            return false;
552        }
553        matches!(input.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
554    }
555
556    pub fn high_risk_skipped_no_tty(&self, action: &Action) {
557        println!(
558            "  {} {}",
559            color::yellow("·", self.config),
560            color::yellow(
561                &format!(
562                    "Skipped {}: this action requires interactive confirmation. Re-run `nd300 fix` in a terminal to attempt it.",
563                    action.label,
564                ),
565                self.config,
566            ),
567        );
568    }
569
570    pub fn high_risk_declined(&self, action: &Action) {
571        self.confirmation_declined(action);
572    }
573
574    pub fn confirmation_declined(&self, action: &Action) {
575        println!(
576            "  {} {}",
577            color::yellow("·", self.config),
578            color::dim(
579                &format!("Skipped {} (you declined the prompt).", action.label),
580                self.config,
581            ),
582        );
583    }
584
585    pub fn final_verdict(&self, outcome: &FinalOutcome, report_path: Option<&std::path::Path>) {
586        let bar = if self.config.use_unicode { '─' } else { '-' };
587        let rule: String = std::iter::repeat_n(bar, 50).collect();
588
589        println!();
590        println!(
591            "  {} {}",
592            color::bold("Result", self.config),
593            color::dim(&rule, self.config),
594        );
595
596        match outcome {
597            FinalOutcome::Fixed => {
598                println!(
599                    "  {} {}",
600                    color::green("✓ Fixed", self.config),
601                    color::green(
602                        "Connectivity is healthy now. The actions above resolved the failures.",
603                        self.config,
604                    ),
605                );
606            }
607            FinalOutcome::Partial(remaining) => {
608                println!(
609                    "  {} {}",
610                    color::yellow("⚠ Partially fixed", self.config),
611                    color::yellow(
612                        &format!(
613                            "{} remain{}",
614                            describe_keys(remaining),
615                            if remaining.len() == 1 { "s" } else { "" }
616                        ),
617                        self.config,
618                    ),
619                );
620                self.suggestions_for(remaining);
621            }
622            FinalOutcome::Exhausted(remaining) => {
623                println!(
624                    "  {} {}",
625                    color::red("✗ Couldn't fix", self.config),
626                    color::red(
627                        &format!(
628                            "Tried every applicable action; {} still failing",
629                            describe_keys(remaining)
630                        ),
631                        self.config,
632                    ),
633                );
634                self.suggestions_for(remaining);
635            }
636            FinalOutcome::HardBlock(reason) => {
637                println!(
638                    "  {} {}",
639                    color::yellow("⚠ Cannot fix from here", self.config),
640                    color::yellow(&reason.user_message(), self.config),
641                );
642            }
643            FinalOutcome::Timeout(remaining) => {
644                println!(
645                    "  {} {}",
646                    color::red("✗ Timed out", self.config),
647                    color::red(
648                        &format!(
649                            "Loop hit its safety cap; {} still failing",
650                            describe_keys(remaining)
651                        ),
652                        self.config,
653                    ),
654                );
655                self.suggestions_for(remaining);
656            }
657            FinalOutcome::UserDeclined(remaining) => {
658                println!(
659                    "  {} {}",
660                    color::yellow("⚠ Stopped at your request", self.config),
661                    color::yellow(
662                        &format!(
663                            "You declined a confirmation prompt; {} still failing",
664                            describe_keys(remaining)
665                        ),
666                        self.config,
667                    ),
668                );
669                self.suggestions_for(remaining);
670            }
671            FinalOutcome::PreflightFailed(reason) => {
672                println!(
673                    "  {} {}",
674                    color::red("✗ Could not start", self.config),
675                    color::red(reason, self.config),
676                );
677            }
678            FinalOutcome::Interrupted(_) => {
679                println!(
680                    "  {} {}",
681                    color::yellow("⚠ Interrupted", self.config),
682                    color::yellow(
683                        "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.",
684                        self.config,
685                    ),
686                );
687            }
688        }
689
690        if let Some(path) = report_path {
691            println!(
692                "  {} {}",
693                color::dim("Full report:", self.config),
694                color::dim(&path.display().to_string(), self.config),
695            );
696        }
697    }
698
699    fn suggestions_for(&self, remaining: &[DiagnosticKey]) {
700        if remaining.is_empty() {
701            return;
702        }
703        println!();
704        println!("  {}", color::bold("What to try next:", self.config));
705        for s in suggestions_for_keys(remaining) {
706            println!("    • {}", s);
707        }
708    }
709}
710
711fn describe_keys(keys: &[DiagnosticKey]) -> String {
712    let names: Vec<&str> = keys.iter().map(|k| key_label(*k)).collect();
713    names.join(", ")
714}
715
716fn key_label(k: DiagnosticKey) -> &'static str {
717    match k {
718        DiagnosticKey::Adapters => "network adapter",
719        DiagnosticKey::Interfaces => "network interface",
720        DiagnosticKey::Gateway => "gateway / router",
721        DiagnosticKey::Dns => "DNS",
722        DiagnosticKey::PublicIp => "public IP",
723        DiagnosticKey::Latency => "latency",
724        DiagnosticKey::Ports => "outbound ports",
725        DiagnosticKey::Speed => "speed test",
726    }
727}
728
729/// Map remaining failure keys to concrete plain-language suggestions.
730pub fn suggestions_for_keys(remaining: &[DiagnosticKey]) -> Vec<String> {
731    use DiagnosticKey::*;
732    let mut out: Vec<String> = Vec::new();
733
734    let has = |k: DiagnosticKey| remaining.contains(&k);
735
736    if has(Adapters) || has(Interfaces) {
737        out.push(
738            "Reboot your computer — a deeper hardware/driver state may need a clean start."
739                .to_string(),
740        );
741        out.push(
742            "Check for network driver updates from your hardware vendor (Intel, Realtek, etc.)."
743                .to_string(),
744        );
745    }
746    if has(Gateway) {
747        out.push(
748            "Power-cycle your router / modem (unplug for 30 seconds, plug back in).".to_string(),
749        );
750        out.push("Try a different cable or Wi-Fi network if available.".to_string());
751    }
752    if has(Dns) {
753        out.push(
754            "Try `nd300 fix` again, then choose `Switch DNS to Cloudflare` if it offers it."
755                .to_string(),
756        );
757        out.push(
758            "Check your router admin page for a custom DNS setting and remove it.".to_string(),
759        );
760    }
761    if has(PublicIp) {
762        out.push("Check your ISP's status page — there may be an outage in your area.".to_string());
763        out.push(
764            "Disconnect any VPN you have running (including work VPNs) and re-test.".to_string(),
765        );
766    }
767    if has(Latency) {
768        out.push("If on Wi-Fi: move closer to your router or try a 5 GHz network.".to_string());
769        out.push(
770            "Run a speed test from another device on the same network to compare.".to_string(),
771        );
772    }
773    if has(Ports) {
774        out.push(
775            "Outbound ports may be blocked by a firewall (work network or AV software). Contact IT or check your firewall rules.".to_string(),
776        );
777    }
778
779    if out.is_empty() {
780        out.push("Reboot the machine and try again.".to_string());
781        out.push(
782            "Run `nd300 -t` for the full technician report and share it with support.".to_string(),
783        );
784    }
785    out
786}
787
788/// Wrap a text block to the given column width without splitting words.
789fn wrap_text(text: &str, width: usize) -> Vec<String> {
790    let mut lines = Vec::new();
791    let mut current = String::new();
792    for word in text.split_whitespace() {
793        if current.len() + word.len() + 1 > width && !current.is_empty() {
794            lines.push(std::mem::take(&mut current));
795        }
796        if !current.is_empty() {
797            current.push(' ');
798        }
799        current.push_str(word);
800    }
801    if !current.is_empty() {
802        lines.push(current);
803    }
804    lines
805}
806
807#[cfg(test)]
808mod restore_registry_tests {
809    use super::*;
810    use crate::actions::fix::vpn::{DisableMethod, DisabledVpn};
811
812    /// A restore op that fails fast and deterministically: a vendor CLI whose
813    /// binary does not exist, so `restore_op` returns a spawn error without
814    /// touching any real network state.
815    fn failing_vpn_op(name: &str) -> RestoreOp {
816        RestoreOp::ReEnableVpn(Arc::new(DisabledVpn {
817            name: name.to_string(),
818            method: DisableMethod::VendorCli(
819                "nd300-nonexistent-vpn-binary".to_string(),
820                vec!["connect".to_string()],
821            ),
822        }))
823    }
824
825    #[tokio::test]
826    async fn empty_registry_drains_to_no_failures() {
827        let reg = RestoreRegistry::new();
828        assert_eq!(reg.pending_count().await, 0);
829        assert!(reg.drain().await.is_empty());
830    }
831
832    #[tokio::test]
833    async fn register_then_resolve_clears_pending() {
834        let reg = RestoreRegistry::new();
835        let t1 = reg.register(failing_vpn_op("VpnA")).await;
836        let _t2 = reg.register(failing_vpn_op("VpnB")).await;
837        assert_eq!(reg.pending_count().await, 2);
838
839        reg.mark_resolved(t1).await;
840        assert_eq!(reg.pending_count().await, 1);
841    }
842
843    #[tokio::test]
844    async fn drain_skips_resolved_and_reports_unresolved_failures() {
845        let reg = RestoreRegistry::new();
846        let resolved = reg.register(failing_vpn_op("ResolvedVpn")).await;
847        let _pending = reg.register(failing_vpn_op("PendingVpn")).await;
848
849        // The first op is restored by its owning action — mark it resolved so
850        // the drain must skip it entirely.
851        reg.mark_resolved(resolved).await;
852
853        let failures = reg.drain().await;
854
855        // Exactly one failure, for the still-unresolved op, and it must name
856        // that VPN (proving the resolved one was skipped, not attempted).
857        assert_eq!(failures.len(), 1, "failures: {:?}", failures);
858        assert!(
859            failures[0].contains("PendingVpn"),
860            "unexpected failure text: {}",
861            failures[0]
862        );
863        assert!(
864            !failures[0].contains("ResolvedVpn"),
865            "resolved op should not have been drained: {}",
866            failures[0]
867        );
868
869        // After a drain, even failed ops are not retried as "pending success",
870        // but the unresolved op stays unresolved (drain only marks resolved on
871        // success), so a second drain re-attempts it and fails the same way.
872        let second = reg.drain().await;
873        assert_eq!(second.len(), 1);
874    }
875
876    #[test]
877    fn interrupted_exit_code_is_130() {
878        assert_eq!(FinalOutcome::Interrupted(Vec::new()).exit_code(), 130);
879    }
880}