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
75const RESTORE_DRAIN_MARGIN: Duration = Duration::from_secs(5);
80
81#[derive(Debug, Clone)]
89pub enum RestoreOp {
90 ReEnableInterface { iface: String },
92 ReEnableVpn(Arc<DisabledVpn>),
94 #[cfg(target_os = "macos")]
96 RestoreMacosDns(Arc<super::dns::MacosDnsSnapshot>),
97 #[cfg(target_os = "macos")]
100 ReEnableMacosService {
101 iface: String,
102 service: String,
103 dns_snapshot: Arc<super::dns::MacosDnsSnapshot>,
104 },
105}
106
107impl RestoreOp {
108 fn label(&self) -> String {
110 match self {
111 RestoreOp::ReEnableInterface { iface } => {
112 format!("re-enable network adapter {}", iface)
113 }
114 RestoreOp::ReEnableVpn(vpn) => format!("re-enable VPN {}", vpn.name),
115 #[cfg(target_os = "macos")]
116 RestoreOp::RestoreMacosDns(snapshot) => {
117 format!("restore DNS/search domains on {}", snapshot.service())
118 }
119 #[cfg(target_os = "macos")]
120 RestoreOp::ReEnableMacosService { service, .. } => {
121 format!("re-enable macOS network service {}", service)
122 }
123 }
124 }
125}
126
127async fn restore_op(op: &RestoreOp) -> Result<(), String> {
130 match op {
131 RestoreOp::ReEnableInterface { iface } => super::stages::restore_interface(iface).await,
132 RestoreOp::ReEnableVpn(vpn) => {
133 if super::vpn::reenable_vpn(vpn).await {
134 Ok(())
135 } else {
136 Err(format!(
137 "could not positively verify VPN {} reconnected",
138 vpn.name
139 ))
140 }
141 }
142 #[cfg(target_os = "macos")]
143 RestoreOp::RestoreMacosDns(snapshot) => {
144 super::dns::restore_macos_dns_snapshot(snapshot).await
145 }
146 #[cfg(target_os = "macos")]
147 RestoreOp::ReEnableMacosService {
148 iface,
149 service,
150 dns_snapshot,
151 } => super::stages::restore_macos_service_state(iface, service, dns_snapshot).await,
152 }
153}
154
155#[derive(Debug, Clone)]
158struct RegisteredOp {
159 op: RestoreOp,
160 resolved: bool,
161}
162
163pub type RestoreToken = usize;
166
167#[derive(Clone, Default)]
176pub struct RestoreRegistry {
177 inner: Arc<Mutex<Vec<RegisteredOp>>>,
178}
179
180impl RestoreRegistry {
181 pub fn new() -> Self {
182 Self {
183 inner: Arc::new(Mutex::new(Vec::new())),
184 }
185 }
186
187 pub async fn register(&self, op: RestoreOp) -> RestoreToken {
190 let mut guard = self.inner.lock().await;
191 guard.push(RegisteredOp {
192 op,
193 resolved: false,
194 });
195 guard.len() - 1
196 }
197
198 pub async fn mark_resolved(&self, token: RestoreToken) {
201 let mut guard = self.inner.lock().await;
202 if let Some(entry) = guard.get_mut(token) {
203 entry.resolved = true;
204 }
205 }
206
207 pub(crate) async fn required_drain_timeout(&self) -> Duration {
212 let pending = self
213 .inner
214 .lock()
215 .await
216 .iter()
217 .filter(|entry| !entry.resolved)
218 .count();
219 let pending = u64::try_from(pending).unwrap_or(u64::MAX);
220 let operation_budget = RESTORE_OP_TIMEOUT.as_secs().saturating_mul(pending);
221 Duration::from_secs(operation_budget.saturating_add(RESTORE_DRAIN_MARGIN.as_secs()))
222 }
223
224 pub async fn drain(&self) -> Vec<String> {
233 let pending: Vec<(usize, RestoreOp)> = {
236 let guard = self.inner.lock().await;
237 guard
238 .iter()
239 .enumerate()
240 .rev()
241 .filter(|(_, e)| !e.resolved)
242 .map(|(i, e)| (i, e.op.clone()))
243 .collect()
244 };
245
246 let mut failures = Vec::new();
247 for (idx, op) in pending {
248 let result = match tokio::time::timeout(RESTORE_OP_TIMEOUT, restore_op(&op)).await {
249 Ok(Ok(())) => Ok(()),
250 Ok(Err(e)) => Err(e),
251 Err(_) => Err(format!("timed out after {}s", RESTORE_OP_TIMEOUT.as_secs())),
252 };
253 match result {
254 Ok(()) => {
255 let mut guard = self.inner.lock().await;
257 if let Some(entry) = guard.get_mut(idx) {
258 entry.resolved = true;
259 }
260 }
261 Err(reason) => {
262 failures.push(format!("Could not {}: {}", op.label(), reason));
263 }
264 }
265 }
266 failures
267 }
268
269 #[cfg(test)]
271 pub(crate) async fn pending_count(&self) -> usize {
272 self.inner
273 .lock()
274 .await
275 .iter()
276 .filter(|e| !e.resolved)
277 .count()
278 }
279}
280
281#[derive(Debug, Clone)]
284pub struct ActionRecord {
285 pub action_id: super::action::ActionId,
286 pub label: &'static str,
287 pub targets: &'static [DiagnosticKey],
291 pub outcome: ActionOutcome,
292 pub duration: Duration,
293 pub iteration: u8,
294 pub user_declined: bool,
296 pub skipped_no_interaction: bool,
299}
300
301#[derive(Debug)]
302pub struct IterationSnapshot {
303 pub iteration: u8,
304 pub results: DiagnosticResults,
305}
306
307#[derive(Debug)]
308pub struct Session {
309 pub started_at: Instant,
310 pub baseline: Option<DiagnosticResults>,
311 pub baseline_confirmation: Option<DiagnosticResults>,
315 pub intermittent: std::collections::HashSet<DiagnosticKey>,
318 pub snapshots: Vec<IterationSnapshot>,
319 pub action_log: Vec<ActionRecord>,
320 pub attempts: Attempts,
321 pub effectiveness: Effectiveness,
322 pub vpn_names: Vec<String>,
323 pub final_outcome: Option<FinalOutcome>,
324}
325
326impl Session {
327 pub fn new() -> Self {
328 Self {
329 started_at: Instant::now(),
330 baseline: None,
331 baseline_confirmation: None,
332 intermittent: std::collections::HashSet::new(),
333 snapshots: Vec::new(),
334 action_log: Vec::new(),
335 attempts: Attempts::new(),
336 effectiveness: Effectiveness::new(),
337 vpn_names: Vec::new(),
338 final_outcome: None,
339 }
340 }
341
342 pub fn elapsed(&self) -> Duration {
343 self.started_at.elapsed()
344 }
345
346 pub fn wall_clock_exhausted(&self) -> bool {
347 self.elapsed() >= WALL_CLOCK_CAP
348 }
349
350 pub fn record_baseline(&mut self, results: DiagnosticResults) {
351 self.baseline = Some(results.clone());
352 self.snapshots.push(IterationSnapshot {
353 iteration: 0,
354 results,
355 });
356 }
357
358 pub fn record_iteration(&mut self, iteration: u8, results: DiagnosticResults) {
359 self.snapshots
360 .push(IterationSnapshot { iteration, results });
361 }
362
363 pub fn record_confirmation(
366 &mut self,
367 results: DiagnosticResults,
368 intermittent: std::collections::HashSet<DiagnosticKey>,
369 ) {
370 self.baseline_confirmation = Some(results);
371 self.intermittent = intermittent;
372 }
373
374 pub fn record_action(
375 &mut self,
376 iteration: u8,
377 action: &Action,
378 outcome: ActionOutcome,
379 duration: Duration,
380 user_declined: bool,
381 skipped_no_interaction: bool,
382 ) {
383 let entry = self.attempts.entry(action.id).or_insert(0);
384 if !skipped_no_interaction && !user_declined {
385 *entry = entry.saturating_add(1);
386 }
387 self.action_log.push(ActionRecord {
388 action_id: action.id,
389 label: action.label,
390 targets: action.targets,
391 outcome,
392 duration,
393 iteration,
394 user_declined,
395 skipped_no_interaction,
396 });
397 }
398
399 pub fn update_effectiveness(
405 &mut self,
406 iteration: u8,
407 prior_failures: &std::collections::HashSet<DiagnosticKey>,
408 current_failures: &std::collections::HashSet<DiagnosticKey>,
409 ) {
410 let cleared: std::collections::HashSet<DiagnosticKey> = prior_failures
411 .difference(current_failures)
412 .copied()
413 .collect();
414 for key in cleared {
415 if self.intermittent.contains(&key) {
416 continue;
417 }
418 if let Some(record) = self
419 .action_log
420 .iter()
421 .rev()
422 .take_while(|r| r.iteration == iteration)
423 .find(|r| r.outcome.ok && r.targets.contains(&key))
424 {
425 self.effectiveness.insert((record.action_id, key), true);
426 }
427 }
428 }
429}
430
431impl Default for Session {
432 fn default() -> Self {
433 Self::new()
434 }
435}
436
437pub struct Reporter<'a> {
442 pub config: &'a Config,
443}
444
445impl<'a> Reporter<'a> {
446 pub fn new(config: &'a Config) -> Self {
447 Self { config }
448 }
449
450 pub fn header(&self) {
451 println!();
452 println!(
453 " {} {}",
454 color::cyan("nd300 fix", self.config),
455 color::dim("— diagnostic-driven recovery", self.config),
456 );
457 }
458
459 pub fn iteration_header(&self, iteration: u8) {
460 println!();
461 println!(
462 " {} {}",
463 color::cyan(&format!("Iteration {}", iteration), self.config),
464 color::dim(
465 "— running diagnostics, then applying targeted fixes",
466 self.config,
467 ),
468 );
469 }
470
471 pub fn baseline_summary(&self, failure_count: usize) {
472 if failure_count == 0 {
473 println!(
474 " {} {}",
475 color::green("✓", self.config),
476 color::green("All diagnostics passing — nothing to fix.", self.config),
477 );
478 } else {
479 println!(
480 " {} found {} failing area{}",
481 color::yellow("→", self.config),
482 failure_count,
483 if failure_count == 1 { "" } else { "s" },
484 );
485 }
486 }
487
488 pub fn confirmation_pass(&self) {
490 println!(
491 " {} {}",
492 color::dim("→", self.config),
493 color::dim(
494 "Double-checking the failures with a second diagnostic pass...",
495 self.config,
496 ),
497 );
498 }
499
500 pub fn confirmation_result(&self, confirmed: usize, transient: usize) {
502 if confirmed == 0 && transient > 0 {
503 println!(
504 " {} {}",
505 color::green("✓", self.config),
506 color::green(
507 "The failures did not reproduce on the second pass — they were transient.",
508 self.config,
509 ),
510 );
511 } else if transient > 0 {
512 println!(
513 " {} {} confirmed, {} transient (will not be acted on)",
514 color::yellow("→", self.config),
515 confirmed,
516 transient,
517 );
518 } else {
519 println!(
520 " {} {} failure{} confirmed on both passes",
521 color::yellow("→", self.config),
522 confirmed,
523 if confirmed == 1 { "" } else { "s" },
524 );
525 }
526 }
527
528 pub fn announce_action(&self, action: &Action) {
529 println!();
530 println!(
531 " {} {}",
532 color::dim("•", self.config),
533 color::dim(action.one_line_why, self.config),
534 );
535 print!(" {} {} ", color::cyan("→", self.config), action.label,);
536 use std::io::Write;
537 let _ = std::io::stdout().flush();
538 }
539
540 pub fn finish_action(&self, outcome: &ActionOutcome, duration: Duration) {
541 if outcome.ok {
542 println!(
543 "{} {}",
544 color::green("✓", self.config),
545 color::dim(
546 &format!("({:.1}s) {}", duration.as_secs_f64(), outcome.message),
547 self.config,
548 ),
549 );
550 } else {
551 println!(
552 "{} {}",
553 color::red("✗", self.config),
554 color::red(&outcome.message, self.config),
555 );
556 }
557 }
558
559 pub fn finish_action_skipped(&self, reason: &str) {
560 println!(
561 "{} {}",
562 color::yellow("·", self.config),
563 color::yellow(reason, self.config),
564 );
565 }
566
567 pub fn confirmation_prompt(&self, action: &Action) -> bool {
570 println!();
571 println!(
572 " {} {}",
573 color::yellow("Confirm:", self.config),
574 color::yellow(action.label, self.config),
575 );
576 println!(" {}", color::dim(action.one_line_why, self.config));
577 println!(
578 " {}",
579 color::dim("This changes live network settings. Use --yes to auto-confirm this class of action.", self.config),
580 );
581
582 use std::io::Write;
583 print!(
584 " {} ",
585 color::yellow(
586 "Continue? Type 'y' to proceed, anything else to skip:",
587 self.config
588 ),
589 );
590 let _ = std::io::stdout().flush();
591
592 let mut input = String::new();
593 if std::io::stdin().read_line(&mut input).is_err() {
594 return false;
595 }
596 matches!(input.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
597 }
598
599 pub fn high_risk_prompt(&self, action: &Action) -> bool {
603 let exp = match &action.risk {
604 Risk::High(e) => e,
605 _ => return true, };
607
608 let header = format!("Escalating: {}", exp.what);
609 let bar = if self.config.use_unicode { '─' } else { '-' };
610 let rule: String = std::iter::repeat_n(bar, 76).collect();
611
612 println!();
613 println!(
614 " {}",
615 color::yellow(&format!("┌─ {} ", header), self.config)
616 );
617 println!(" {}", color::dim(&rule, self.config));
618 println!(" {}", color::bold("Why I want to do this:", self.config));
619 for line in wrap_text(exp.why, 70) {
620 println!(" {}", line);
621 }
622 println!();
623 println!(" {}", color::bold("What will happen:", self.config));
624 for bullet in exp.side_effects {
625 println!(" • {}", bullet);
626 }
627 println!();
628 println!(
629 " {} {}",
630 color::bold("Reversible:", self.config),
631 exp.reversible.label(),
632 );
633 println!(
634 " {} {}",
635 color::bold("Typical duration:", self.config),
636 exp.typical_duration,
637 );
638 println!(" {}", color::dim(&rule, self.config));
639
640 use std::io::Write;
642 print!(
643 " {} ",
644 color::yellow(
645 "Continue? Type 'y' to proceed, anything else to skip:",
646 self.config
647 ),
648 );
649 let _ = std::io::stdout().flush();
650
651 let mut input = String::new();
652 if std::io::stdin().read_line(&mut input).is_err() {
653 return false;
654 }
655 matches!(input.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
656 }
657
658 pub fn high_risk_skipped_no_tty(&self, action: &Action) {
659 println!(
660 " {} {}",
661 color::yellow("·", self.config),
662 color::yellow(
663 &format!(
664 "Skipped {}: this action requires interactive confirmation. Re-run `nd300 fix` in a terminal to attempt it.",
665 action.label,
666 ),
667 self.config,
668 ),
669 );
670 }
671
672 pub fn high_risk_declined(&self, action: &Action) {
673 self.confirmation_declined(action);
674 }
675
676 pub fn confirmation_declined(&self, action: &Action) {
677 println!(
678 " {} {}",
679 color::yellow("·", self.config),
680 color::dim(
681 &format!("Skipped {} (you declined the prompt).", action.label),
682 self.config,
683 ),
684 );
685 }
686
687 pub fn final_verdict(&self, outcome: &FinalOutcome, report_path: Option<&std::path::Path>) {
688 let bar = if self.config.use_unicode { '─' } else { '-' };
689 let rule: String = std::iter::repeat_n(bar, 50).collect();
690
691 println!();
692 println!(
693 " {} {}",
694 color::bold("Result", self.config),
695 color::dim(&rule, self.config),
696 );
697
698 match outcome {
699 FinalOutcome::Fixed => {
700 println!(
701 " {} {}",
702 color::green("✓ Fixed", self.config),
703 color::green(
704 "Connectivity is healthy now. The actions above resolved the failures.",
705 self.config,
706 ),
707 );
708 }
709 FinalOutcome::Partial(remaining) => {
710 println!(
711 " {} {}",
712 color::yellow("⚠ Partially fixed", self.config),
713 color::yellow(
714 &format!(
715 "{} remain{}",
716 describe_keys(remaining),
717 if remaining.len() == 1 { "s" } else { "" }
718 ),
719 self.config,
720 ),
721 );
722 self.suggestions_for(remaining);
723 }
724 FinalOutcome::Exhausted(remaining) => {
725 println!(
726 " {} {}",
727 color::red("✗ Couldn't fix", self.config),
728 color::red(
729 &format!(
730 "Tried every applicable action; {} still failing",
731 describe_keys(remaining)
732 ),
733 self.config,
734 ),
735 );
736 self.suggestions_for(remaining);
737 }
738 FinalOutcome::HardBlock(reason) => {
739 println!(
740 " {} {}",
741 color::yellow("⚠ Cannot fix from here", self.config),
742 color::yellow(&reason.user_message(), self.config),
743 );
744 }
745 FinalOutcome::Timeout(remaining) => {
746 println!(
747 " {} {}",
748 color::red("✗ Timed out", self.config),
749 color::red(
750 &format!(
751 "Loop hit its safety cap; {} still failing",
752 describe_keys(remaining)
753 ),
754 self.config,
755 ),
756 );
757 self.suggestions_for(remaining);
758 }
759 FinalOutcome::UserDeclined(remaining) => {
760 println!(
761 " {} {}",
762 color::yellow("⚠ Stopped at your request", self.config),
763 color::yellow(
764 &format!(
765 "You declined a confirmation prompt; {} still failing",
766 describe_keys(remaining)
767 ),
768 self.config,
769 ),
770 );
771 self.suggestions_for(remaining);
772 }
773 FinalOutcome::PreflightFailed(reason) => {
774 println!(
775 " {} {}",
776 color::red("✗ Could not start", self.config),
777 color::red(reason, self.config),
778 );
779 }
780 FinalOutcome::Interrupted(_) => {
781 println!(
782 " {} {}",
783 color::yellow("⚠ Interrupted", self.config),
784 color::yellow(
785 "The fix was stopped before it finished. nd300 attempted to restore any network state it had changed — see below for anything that needs manual recovery.",
786 self.config,
787 ),
788 );
789 }
790 }
791
792 if let Some(path) = report_path {
793 println!(
794 " {} {}",
795 color::dim("Full report:", self.config),
796 color::dim(&path.display().to_string(), self.config),
797 );
798 }
799 }
800
801 fn suggestions_for(&self, remaining: &[DiagnosticKey]) {
802 if remaining.is_empty() {
803 return;
804 }
805 println!();
806 println!(" {}", color::bold("What to try next:", self.config));
807 for s in suggestions_for_keys(remaining) {
808 println!(" • {}", s);
809 }
810 }
811}
812
813fn describe_keys(keys: &[DiagnosticKey]) -> String {
814 let names: Vec<&str> = keys.iter().map(|k| key_label(*k)).collect();
815 names.join(", ")
816}
817
818fn key_label(k: DiagnosticKey) -> &'static str {
819 match k {
820 DiagnosticKey::Adapters => "network adapter",
821 DiagnosticKey::Interfaces => "network interface",
822 DiagnosticKey::Gateway => "gateway / router",
823 DiagnosticKey::Dns => "DNS",
824 DiagnosticKey::PublicIp => "public IP",
825 DiagnosticKey::Latency => "latency",
826 DiagnosticKey::Ports => "outbound ports",
827 DiagnosticKey::Speed => "speed test",
828 }
829}
830
831pub fn suggestions_for_keys(remaining: &[DiagnosticKey]) -> Vec<String> {
833 use DiagnosticKey::*;
834 let mut out: Vec<String> = Vec::new();
835
836 let has = |k: DiagnosticKey| remaining.contains(&k);
837
838 if has(Adapters) || has(Interfaces) {
839 out.push(
840 "Reboot your computer — a deeper hardware/driver state may need a clean start."
841 .to_string(),
842 );
843 out.push(
844 "Check for network driver updates from your hardware vendor (Intel, Realtek, etc.)."
845 .to_string(),
846 );
847 }
848 if has(Gateway) {
849 out.push(
850 "Power-cycle your router / modem (unplug for 30 seconds, plug back in).".to_string(),
851 );
852 out.push("Try a different cable or Wi-Fi network if available.".to_string());
853 }
854 if has(Dns) {
855 out.push(
856 "Try `nd300 fix` again, then choose `Switch DNS to Cloudflare` if it offers it."
857 .to_string(),
858 );
859 out.push(
860 "Check your router admin page for a custom DNS setting and remove it.".to_string(),
861 );
862 }
863 if has(PublicIp) {
864 out.push("Check your ISP's status page — there may be an outage in your area.".to_string());
865 out.push(
866 "Disconnect any VPN you have running (including work VPNs) and re-test.".to_string(),
867 );
868 }
869 if has(Latency) {
870 out.push("If on Wi-Fi: move closer to your router or try a 5 GHz network.".to_string());
871 out.push(
872 "Run a speed test from another device on the same network to compare.".to_string(),
873 );
874 }
875 if has(Ports) {
876 out.push(
877 "Outbound ports may be blocked by a firewall (work network or AV software). Contact IT or check your firewall rules.".to_string(),
878 );
879 }
880
881 if out.is_empty() {
882 out.push("Reboot the machine and try again.".to_string());
883 out.push(
884 "Run `nd300 -t` for the full technician report and share it with support.".to_string(),
885 );
886 }
887 out
888}
889
890fn wrap_text(text: &str, width: usize) -> Vec<String> {
892 let mut lines = Vec::new();
893 let mut current = String::new();
894 for word in text.split_whitespace() {
895 if current.len() + word.len() + 1 > width && !current.is_empty() {
896 lines.push(std::mem::take(&mut current));
897 }
898 if !current.is_empty() {
899 current.push(' ');
900 }
901 current.push_str(word);
902 }
903 if !current.is_empty() {
904 lines.push(current);
905 }
906 lines
907}
908
909#[cfg(test)]
910mod restore_registry_tests {
911 use super::*;
912 use crate::actions::fix::vpn::{DisableMethod, DisabledVpn};
913
914 fn failing_vpn_op(name: &str) -> RestoreOp {
918 RestoreOp::ReEnableVpn(Arc::new(DisabledVpn {
919 name: name.to_string(),
920 method: DisableMethod::VendorCli(
921 "nd300-nonexistent-vpn-binary".to_string(),
922 vec!["connect".to_string()],
923 ),
924 }))
925 }
926
927 #[tokio::test]
928 async fn empty_registry_drains_to_no_failures() {
929 let reg = RestoreRegistry::new();
930 assert_eq!(reg.pending_count().await, 0);
931 assert!(reg.drain().await.is_empty());
932 }
933
934 #[tokio::test]
935 async fn register_then_resolve_clears_pending() {
936 let reg = RestoreRegistry::new();
937 let t1 = reg.register(failing_vpn_op("VpnA")).await;
938 let _t2 = reg.register(failing_vpn_op("VpnB")).await;
939 assert_eq!(reg.pending_count().await, 2);
940
941 reg.mark_resolved(t1).await;
942 assert_eq!(reg.pending_count().await, 1);
943 }
944
945 #[tokio::test]
946 async fn drain_skips_resolved_and_reports_unresolved_failures() {
947 let reg = RestoreRegistry::new();
948 let resolved = reg.register(failing_vpn_op("ResolvedVpn")).await;
949 let _pending = reg.register(failing_vpn_op("PendingVpn")).await;
950
951 reg.mark_resolved(resolved).await;
954
955 let failures = reg.drain().await;
956
957 assert_eq!(failures.len(), 1, "failures: {:?}", failures);
960 assert!(
961 failures[0].contains("PendingVpn"),
962 "unexpected failure text: {}",
963 failures[0]
964 );
965 assert!(
966 !failures[0].contains("ResolvedVpn"),
967 "resolved op should not have been drained: {}",
968 failures[0]
969 );
970
971 let second = reg.drain().await;
975 assert_eq!(second.len(), 1);
976 }
977
978 #[tokio::test]
979 async fn drain_restores_in_lifo_order() {
980 let reg = RestoreRegistry::new();
981 reg.register(failing_vpn_op("FirstRegistered")).await;
982 reg.register(failing_vpn_op("LastRegistered")).await;
983
984 let failures = reg.drain().await;
985
986 assert_eq!(failures.len(), 2);
987 assert!(failures[0].contains("LastRegistered"), "{failures:?}");
988 assert!(failures[1].contains("FirstRegistered"), "{failures:?}");
989 }
990
991 #[tokio::test]
992 async fn aggregate_timeout_reserves_an_attempt_for_every_pending_restore() {
993 let reg = RestoreRegistry::new();
994 for index in 0..4 {
995 reg.register(failing_vpn_op(&format!("Vpn{index}"))).await;
996 }
997
998 assert_eq!(reg.required_drain_timeout().await, Duration::from_secs(125));
999 }
1000
1001 #[tokio::test]
1002 async fn drain_attempts_more_than_three_pending_ops_in_lifo_order() {
1003 let reg = RestoreRegistry::new();
1004 for index in 0..5 {
1005 reg.register(failing_vpn_op(&format!("Vpn{index}"))).await;
1006 }
1007
1008 let failures = reg.drain().await;
1009
1010 assert_eq!(failures.len(), 5, "failures: {failures:?}");
1011 for (position, expected_index) in (0..5).rev().enumerate() {
1012 assert!(
1013 failures[position].contains(&format!("Vpn{expected_index}")),
1014 "unexpected LIFO result: {failures:?}"
1015 );
1016 }
1017 }
1018
1019 #[test]
1020 fn interrupted_exit_code_is_130() {
1021 assert_eq!(FinalOutcome::Interrupted(Vec::new()).exit_code(), 130);
1022 }
1023}
1024
1025#[cfg(test)]
1026mod effectiveness_tests {
1027 use super::*;
1028 use crate::actions::fix::action::ActionId;
1029 use std::collections::HashSet;
1030
1031 fn record(
1032 action_id: ActionId,
1033 targets: &'static [DiagnosticKey],
1034 ok: bool,
1035 iteration: u8,
1036 ) -> ActionRecord {
1037 ActionRecord {
1038 action_id,
1039 label: "test action",
1040 targets,
1041 outcome: if ok {
1042 ActionOutcome::ok("ok")
1043 } else {
1044 ActionOutcome::fail("fail")
1045 },
1046 duration: Duration::from_millis(1),
1047 iteration,
1048 user_declined: false,
1049 skipped_no_interaction: false,
1050 }
1051 }
1052
1053 fn keys(ks: &[DiagnosticKey]) -> HashSet<DiagnosticKey> {
1054 ks.iter().copied().collect()
1055 }
1056
1057 #[test]
1060 fn credit_requires_matching_targets() {
1061 let mut s = Session::new();
1062 s.action_log.push(record(
1063 ActionId::FlushArp,
1064 &[DiagnosticKey::Gateway],
1065 true,
1066 1,
1067 ));
1068 s.action_log
1069 .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1070
1071 s.update_effectiveness(1, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1072
1073 assert_eq!(
1074 s.effectiveness
1075 .get(&(ActionId::FlushDns, DiagnosticKey::Dns)),
1076 Some(&true)
1077 );
1078 assert!(
1079 !s.effectiveness
1080 .contains_key(&(ActionId::FlushArp, DiagnosticKey::Dns)),
1081 "non-targeting action must not be credited"
1082 );
1083 }
1084
1085 #[test]
1086 fn most_recent_targeting_action_wins() {
1087 let mut s = Session::new();
1088 s.action_log
1089 .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1090 s.action_log.push(record(
1091 ActionId::SetDnsAutomatic,
1092 &[DiagnosticKey::Dns],
1093 true,
1094 1,
1095 ));
1096
1097 s.update_effectiveness(1, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1098
1099 assert!(s
1100 .effectiveness
1101 .contains_key(&(ActionId::SetDnsAutomatic, DiagnosticKey::Dns)));
1102 assert!(
1103 !s.effectiveness
1104 .contains_key(&(ActionId::FlushDns, DiagnosticKey::Dns)),
1105 "only the most recent targeting action is credited"
1106 );
1107 }
1108
1109 #[test]
1110 fn failed_action_earns_no_credit() {
1111 let mut s = Session::new();
1112 s.action_log
1113 .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], false, 1));
1114
1115 s.update_effectiveness(1, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1116
1117 assert!(s.effectiveness.is_empty());
1118 }
1119
1120 #[test]
1121 fn prior_iteration_records_never_credited() {
1122 let mut s = Session::new();
1123 s.action_log
1124 .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1125
1126 s.update_effectiveness(2, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1127
1128 assert!(s.effectiveness.is_empty());
1129 }
1130
1131 #[test]
1132 fn nothing_cleared_changes_nothing() {
1133 let mut s = Session::new();
1134 s.action_log
1135 .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1136
1137 s.update_effectiveness(
1138 1,
1139 &keys(&[DiagnosticKey::Dns]),
1140 &keys(&[DiagnosticKey::Dns]),
1141 );
1142
1143 assert!(s.effectiveness.is_empty());
1144 }
1145
1146 #[test]
1150 fn intermittent_key_earns_no_credit() {
1151 let mut s = Session::new();
1152 s.intermittent.insert(DiagnosticKey::Dns);
1153 s.action_log
1154 .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1155
1156 s.update_effectiveness(1, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1157
1158 assert!(s.effectiveness.is_empty());
1159 }
1160}