1use 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
16pub const WALL_CLOCK_CAP: Duration = Duration::from_secs(240);
20
21pub const DEFAULT_ITERATION_DELAY: Duration = Duration::from_secs(2);
25
26#[derive(Debug, Clone)]
29pub enum FinalOutcome {
30 Fixed,
32 Partial(Vec<DiagnosticKey>),
34 Exhausted(Vec<DiagnosticKey>),
36 HardBlock(HardBlock),
38 Timeout(Vec<DiagnosticKey>),
40 UserDeclined(Vec<DiagnosticKey>),
42 PreflightFailed(String),
44 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 FinalOutcome::Interrupted(_) => 130,
64 }
65 }
66}
67
68const RESTORE_OP_TIMEOUT: Duration = Duration::from_secs(30);
74
75#[derive(Debug, Clone)]
83pub enum RestoreOp {
84 ReEnableInterface { iface: String },
86 ReEnableVpn(Arc<DisabledVpn>),
88 #[cfg(target_os = "macos")]
92 RecreateMacosService {
93 iface: String,
94 service: String,
95 snapshot: super::stages::MacosNetworkSnapshot,
96 },
97}
98
99impl RestoreOp {
100 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
115async 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#[derive(Debug, Clone)]
139struct RegisteredOp {
140 op: RestoreOp,
141 resolved: bool,
142}
143
144pub type RestoreToken = usize;
147
148#[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 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 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 pub async fn drain(&self) -> Vec<String> {
197 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 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 #[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#[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 pub user_declined: bool,
255 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 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 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 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
375pub 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 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 pub fn high_risk_prompt(&self, action: &Action) -> bool {
501 let exp = match &action.risk {
502 Risk::High(e) => e,
503 _ => return true, };
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 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
729pub 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
788fn 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 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 reg.mark_resolved(resolved).await;
852
853 let failures = reg.drain().await;
854
855 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 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}