Skip to main content

dnslib/cli/
interactive.rs

1use inquire::validator::Validation;
2use inquire::{Confirm, InquireError, MultiSelect, Select, Text};
3
4use crate::control_plane::config::{
5    CLOUDFLARE_DEFAULT_BASE_URL, DnsServerConfig, DnsTransportConfig, DohTransportConfig,
6    DoqTransportConfig, DotTransportConfig, EndpointUpdate, McpPermissions,
7    PANGOLIN_DEFAULT_BASE_URL, PIHOLE_DEFAULT_BASE_URL, ServerLocation,
8    TECHNITIUM_DEFAULT_BASE_URL, UNIFI_DEFAULT_BASE_URL, ValidationEndpointConfig, VendorKind,
9};
10use crate::control_plane::policy::PolicyRule;
11use crate::core::error::{Error, Result};
12
13pub fn run_add_wizard(existing_ids: &[String]) -> Result<DnsServerConfig> {
14    let existing: Vec<String> = existing_ids.iter().map(|s| s.to_lowercase()).collect();
15    let id = Text::new("Server ID:")
16        .with_help_message("Unique identifier for this server entry")
17        .with_validator(move |input: &str| {
18            if existing.iter().any(|id| id == &input.to_lowercase()) {
19                Ok(Validation::Invalid(
20                    format!("a server with id '{input}' already exists").into(),
21                ))
22            } else {
23                Ok(Validation::Valid)
24            }
25        })
26        .prompt()
27        .map_err(wizard_err)?;
28
29    let vendor = {
30        let choices = vec![
31            VendorChoice {
32                kind: VendorKind::Technitium,
33                label: "technitium",
34            },
35            VendorChoice {
36                kind: VendorKind::Pangolin,
37                label: "pangolin",
38            },
39            VendorChoice {
40                kind: VendorKind::Cloudflare,
41                label: "cloudflare",
42            },
43            VendorChoice {
44                kind: VendorKind::Unifi,
45                label: "unifi",
46            },
47            VendorChoice {
48                kind: VendorKind::Pihole,
49                label: "pihole",
50            },
51        ];
52        Select::new("Vendor:", choices)
53            .prompt()
54            .map_err(wizard_err)?
55            .kind
56    };
57
58    let default_url = match vendor {
59        VendorKind::Technitium => TECHNITIUM_DEFAULT_BASE_URL,
60        VendorKind::Pangolin => PANGOLIN_DEFAULT_BASE_URL,
61        VendorKind::Cloudflare => CLOUDFLARE_DEFAULT_BASE_URL,
62        VendorKind::Unifi => UNIFI_DEFAULT_BASE_URL,
63        VendorKind::Pihole => PIHOLE_DEFAULT_BASE_URL,
64    };
65
66    let base_url = optional_text(
67        "Base URL:",
68        &format!("Press Enter for default ({default_url}), or type a custom URL"),
69        Some(default_url),
70    )?;
71
72    let token_env = optional_text(
73        "Token environment variable:",
74        "Name of the env var holding the API token (recommended). Leave empty to skip.",
75        None,
76    )?;
77
78    let token = if token_env.is_none() {
79        optional_text(
80            "API token (stored in plain text — prefer token env var above):",
81            "Leave empty to skip",
82            None,
83        )?
84    } else {
85        None
86    };
87
88    let org_id = match vendor {
89        VendorKind::Pangolin => {
90            optional_text("Organisation ID (Pangolin):", "Leave empty to skip", None)?
91        }
92        VendorKind::Unifi => Some(
93            Text::new("Site name (UniFi):")
94                .with_help_message(
95                    "Human-readable site name (e.g. \"Default\") or site UUID; stored in org_id. \
96                     Run `dns settings` after saving to list valid site names.",
97                )
98                .with_validator(|input: &str| {
99                    if input.trim().is_empty() {
100                        Ok(Validation::Invalid("site is required for UniFi".into()))
101                    } else {
102                        Ok(Validation::Valid)
103                    }
104                })
105                .prompt()
106                .map_err(wizard_err)?,
107        ),
108        _ => None,
109    };
110
111    let location = {
112        let choices = vec![
113            LocationChoice {
114                value: None,
115                label: "auto-detect",
116            },
117            LocationChoice {
118                value: Some(ServerLocation::Local),
119                label: "local",
120            },
121            LocationChoice {
122                value: Some(ServerLocation::External),
123                label: "external",
124            },
125        ];
126        Select::new("Location:", choices)
127            .with_help_message(
128                "auto-detect infers from the base URL (localhost/private IP → local)",
129            )
130            .prompt()
131            .map_err(wizard_err)?
132            .value
133    };
134
135    let access: Vec<PolicyRule> = {
136        let choices = vec![
137            AccessChoice {
138                rule: PolicyRule::Read,
139                label: "read   (list/export/stats/settings)",
140            },
141            AccessChoice {
142                rule: PolicyRule::Write,
143                label: "write  (create/update/import/flush)",
144            },
145            AccessChoice {
146                rule: PolicyRule::Delete,
147                label: "delete (delete zones/records/cache)",
148            },
149        ];
150        let defaults: Vec<usize> = (0..choices.len()).collect();
151        let chosen = MultiSelect::new("MCP allowed operations:", choices)
152            .with_default(&defaults)
153            .with_help_message("Select which operations are permitted for MCP tools on this server")
154            .prompt()
155            .map_err(wizard_err)?;
156        chosen.into_iter().map(|c| c.rule).collect()
157    };
158
159    let mut allowed_zones: Vec<String> = Vec::new();
160    loop {
161        let help = if allowed_zones.is_empty() {
162            "Restrict zone-targeting tools to specific zones; subdomains are also permitted. Leave empty to skip.".to_string()
163        } else {
164            format!(
165                "Added: {} — enter another, or leave empty to finish",
166                allowed_zones.join(", ")
167            )
168        };
169        let zone = match Text::new("Allowed zone:").with_help_message(&help).prompt() {
170            Ok(z) => z,
171            Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
172                return Err(Error::cancelled());
173            }
174            Err(e) => return Err(wizard_err(e)),
175        };
176        if zone.is_empty() {
177            break;
178        }
179        allowed_zones.push(zone);
180    }
181
182    let mut validation_endpoints: Vec<ValidationEndpointConfig> = Vec::new();
183    loop {
184        let help = if validation_endpoints.is_empty() {
185            "Optional DNS validation endpoints as name:transport:address (transport: dns, doh, dot). Leave empty to skip.".to_string()
186        } else {
187            format!(
188                "Added: {} — enter another, or leave empty to finish",
189                validation_endpoints
190                    .iter()
191                    .map(|endpoint| endpoint.name.as_str())
192                    .collect::<Vec<_>>()
193                    .join(", ")
194            )
195        };
196        let endpoint = match Text::new("Validation endpoint:")
197            .with_help_message(&help)
198            .prompt()
199        {
200            Ok(endpoint) => endpoint,
201            Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
202                return Err(Error::cancelled());
203            }
204            Err(e) => return Err(wizard_err(e)),
205        };
206        if endpoint.is_empty() {
207            break;
208        }
209        validation_endpoints.push(
210            endpoint
211                .parse::<ValidationEndpointConfig>()
212                .map_err(Error::parse)?,
213        );
214    }
215
216    let (dns, dot, doh, doq) = prompt_transport_endpoints_for_add()?;
217
218    Ok(DnsServerConfig {
219        id,
220        vendor,
221        location,
222        base_url,
223        base_url_env: None,
224        token,
225        token_env,
226        org_id,
227        cluster: None,
228        dns,
229        dot,
230        doh,
231        doq,
232        mcp: McpPermissions {
233            access,
234            allowed_zones,
235            show_settings_secrets: false,
236        },
237        validation_endpoints,
238    })
239}
240
241/// Interactive wizard for updating a single transport endpoint on an existing server.
242///
243/// Shows the current status of each endpoint and prompts the user to pick one to
244/// configure, update, or remove.
245pub fn run_server_wizard(server: &DnsServerConfig) -> Result<EndpointUpdate> {
246    let choices = vec![
247        EndpointChoice {
248            protocol: EndpointProtocol::Dns,
249            label: format_endpoint_label(
250                "dns",
251                "plain DNS, port 53",
252                endpoint_addr_status(server.dns.as_ref(), false),
253            ),
254        },
255        EndpointChoice {
256            protocol: EndpointProtocol::Dot,
257            label: format_endpoint_label(
258                "dot",
259                "DNS-over-TLS, port 853",
260                endpoint_addr_status(server.dot.as_ref(), false),
261            ),
262        },
263        EndpointChoice {
264            protocol: EndpointProtocol::Doh,
265            label: format_endpoint_label(
266                "doh",
267                "DNS-over-HTTPS",
268                endpoint_addr_status(server.doh.as_ref(), true),
269            ),
270        },
271        EndpointChoice {
272            protocol: EndpointProtocol::Doq,
273            label: format_endpoint_label(
274                "doq",
275                "DNS-over-QUIC",
276                endpoint_addr_status(server.doq.as_ref(), false),
277            ),
278        },
279    ];
280
281    let chosen = Select::new("Select endpoint to configure:", choices)
282        .with_help_message("Use arrow keys to select; current status shown on the right")
283        .prompt()
284        .map_err(wizard_err)?;
285
286    match chosen.protocol {
287        EndpointProtocol::Dns => {
288            let cfg = configure_or_remove("DNS", server.dns.as_ref(), |existing| {
289                prompt_dns_config(existing)
290            })?;
291            Ok(EndpointUpdate::Dns(cfg))
292        }
293        EndpointProtocol::Dot => {
294            let cfg = configure_or_remove("DoT", server.dot.as_ref(), |existing| {
295                prompt_dot_config(existing)
296            })?;
297            Ok(EndpointUpdate::Dot(cfg))
298        }
299        EndpointProtocol::Doh => {
300            let cfg = configure_or_remove("DoH", server.doh.as_ref(), |existing| {
301                prompt_doh_config(existing)
302            })?;
303            Ok(EndpointUpdate::Doh(cfg))
304        }
305        EndpointProtocol::Doq => {
306            let cfg = configure_or_remove("DoQ", server.doq.as_ref(), |existing| {
307                prompt_doq_config(existing)
308            })?;
309            Ok(EndpointUpdate::Doq(cfg))
310        }
311    }
312}
313
314/// Lets the user pick a server by ID from a list. Returns the chosen server ID.
315pub fn run_server_picker(servers: &[DnsServerConfig]) -> Result<String> {
316    let choices: Vec<ServerChoice> = servers
317        .iter()
318        .map(|s| ServerChoice {
319            id: s.id.clone(),
320            label: format_server_summary(s),
321        })
322        .collect();
323
324    let chosen = Select::new("Select server to update:", choices)
325        .prompt()
326        .map_err(wizard_err)?;
327
328    Ok(chosen.id)
329}
330
331// ─── Transport endpoint prompts ───────────────────────────────────────────────
332
333fn prompt_transport_endpoints_for_add() -> Result<(
334    Option<DnsTransportConfig>,
335    Option<DotTransportConfig>,
336    Option<DohTransportConfig>,
337    Option<DoqTransportConfig>,
338)> {
339    let configure = Confirm::new("Configure DNS transport endpoints (dns/dot/doh/doq)?")
340        .with_default(false)
341        .with_help_message(
342            "Set up direct DNS query endpoints for validation and resolution. \
343             You can always add these later with `dns config server <id> <protocol>`.",
344        )
345        .prompt()
346        .map_err(wizard_err)?;
347
348    if !configure {
349        return Ok((None, None, None, None));
350    }
351
352    let choices = vec![
353        ProtocolChoice {
354            id: 0,
355            label: "dns  (plain DNS, port 53)",
356        },
357        ProtocolChoice {
358            id: 1,
359            label: "dot  (DNS-over-TLS, port 853)",
360        },
361        ProtocolChoice {
362            id: 2,
363            label: "doh  (DNS-over-HTTPS)",
364        },
365        ProtocolChoice {
366            id: 3,
367            label: "doq  (DNS-over-QUIC)",
368        },
369    ];
370
371    let selected = MultiSelect::new("Select protocols to configure:", choices)
372        .with_help_message("Space to toggle, Enter to confirm")
373        .prompt()
374        .map_err(wizard_err)?;
375
376    let mut dns = None;
377    let mut dot = None;
378    let mut doh = None;
379    let mut doq = None;
380
381    for choice in selected {
382        match choice.id {
383            0 => dns = Some(prompt_dns_config(None)?),
384            1 => dot = Some(prompt_dot_config(None)?),
385            2 => doh = Some(prompt_doh_config(None)?),
386            3 => doq = Some(prompt_doq_config(None)?),
387            _ => unreachable!(),
388        }
389    }
390
391    Ok((dns, dot, doh, doq))
392}
393
394fn prompt_dns_config(existing: Option<&DnsTransportConfig>) -> Result<DnsTransportConfig> {
395    let addr = Text::new("Address (host:port):")
396        .with_help_message("e.g. 10.0.0.1:53 or dns.example.com:53")
397        .with_default(existing.and_then(|e| e.addr.as_deref()).unwrap_or(""))
398        .prompt()
399        .map_err(wizard_err)?;
400
401    let timeout_ms = optional_u64(
402        "Timeout (ms):",
403        "Query timeout in milliseconds, e.g. 2000. Leave empty to use the default.",
404        existing.and_then(|e| e.timeout_ms),
405    )?;
406
407    let enabled = Confirm::new("Enable this endpoint?")
408        .with_default(existing.map_or(true, |e| e.enabled))
409        .prompt()
410        .map_err(wizard_err)?;
411
412    let addr = addr.trim().to_string();
413    Ok(DnsTransportConfig {
414        enabled,
415        addr: Some(addr).filter(|a| !a.is_empty()),
416        timeout_ms,
417    })
418}
419
420fn prompt_dot_config(existing: Option<&DotTransportConfig>) -> Result<DotTransportConfig> {
421    let addr = Text::new("Address (host:port):")
422        .with_help_message("e.g. 10.0.0.1:853 or dns.example.com:853")
423        .with_default(existing.and_then(|e| e.addr.as_deref()).unwrap_or(""))
424        .prompt()
425        .map_err(wizard_err)?;
426
427    let server_name = optional_text(
428        "TLS server name (SNI):",
429        "Hostname for TLS certificate validation. Leave empty to use the hostname from address.",
430        existing.and_then(|e| e.server_name.as_deref()),
431    )?;
432
433    let timeout_ms = optional_u64(
434        "Timeout (ms):",
435        "Query timeout in milliseconds, e.g. 2000. Leave empty to use the default.",
436        existing.and_then(|e| e.timeout_ms),
437    )?;
438
439    let enabled = Confirm::new("Enable this endpoint?")
440        .with_default(existing.map_or(true, |e| e.enabled))
441        .prompt()
442        .map_err(wizard_err)?;
443
444    let addr = addr.trim().to_string();
445    Ok(DotTransportConfig {
446        enabled,
447        addr: Some(addr).filter(|a| !a.is_empty()),
448        server_name,
449        timeout_ms,
450    })
451}
452
453fn prompt_doh_config(existing: Option<&DohTransportConfig>) -> Result<DohTransportConfig> {
454    let url = optional_text(
455        "URL:",
456        "Full HTTPS URL, e.g. https://dns.example.com/dns-query",
457        existing.and_then(|e| e.url.as_deref()),
458    )?;
459
460    let addr = optional_text(
461        "Address override (host:port):",
462        "Override the TCP address resolved from the URL, e.g. 10.0.0.1:443. Leave empty to use DNS.",
463        existing.and_then(|e| e.addr.as_deref()),
464    )?;
465
466    let server_name = optional_text(
467        "TLS server name (SNI):",
468        "Hostname for TLS certificate validation. Leave empty to use the hostname from the URL.",
469        existing.and_then(|e| e.server_name.as_deref()),
470    )?;
471
472    let timeout_ms = optional_u64(
473        "Timeout (ms):",
474        "Query timeout in milliseconds, e.g. 2000. Leave empty to use the default.",
475        existing.and_then(|e| e.timeout_ms),
476    )?;
477
478    let enabled = Confirm::new("Enable this endpoint?")
479        .with_default(existing.map_or(true, |e| e.enabled))
480        .prompt()
481        .map_err(wizard_err)?;
482
483    Ok(DohTransportConfig {
484        enabled,
485        url,
486        addr,
487        server_name,
488        timeout_ms,
489    })
490}
491
492fn prompt_doq_config(existing: Option<&DoqTransportConfig>) -> Result<DoqTransportConfig> {
493    let addr = Text::new("Address (host:port):")
494        .with_help_message("e.g. 10.0.0.1:853 or dns.example.com:853")
495        .with_default(existing.and_then(|e| e.addr.as_deref()).unwrap_or(""))
496        .prompt()
497        .map_err(wizard_err)?;
498
499    let server_name = optional_text(
500        "TLS server name (SNI):",
501        "Hostname for TLS certificate validation. Leave empty to use the hostname from address.",
502        existing.and_then(|e| e.server_name.as_deref()),
503    )?;
504
505    let timeout_ms = optional_u64(
506        "Timeout (ms):",
507        "Query timeout in milliseconds, e.g. 2000. Leave empty to use the default.",
508        existing.and_then(|e| e.timeout_ms),
509    )?;
510
511    let enabled = Confirm::new("Enable this endpoint?")
512        .with_default(existing.map_or(true, |e| e.enabled))
513        .prompt()
514        .map_err(wizard_err)?;
515
516    let addr = addr.trim().to_string();
517    Ok(DoqTransportConfig {
518        enabled,
519        addr: Some(addr).filter(|a| !a.is_empty()),
520        server_name,
521        timeout_ms,
522    })
523}
524
525/// If an existing config is present, ask the user whether to update or remove it.
526/// If not present, go straight to the configure prompt.
527fn configure_or_remove<T, F>(
528    protocol: &str,
529    existing: Option<&T>,
530    configure: F,
531) -> Result<Option<T>>
532where
533    F: FnOnce(Option<&T>) -> Result<T>,
534{
535    if existing.is_some() {
536        let choices = vec![
537            ActionChoice {
538                action: EndpointAction::Configure,
539                label: "configure / update",
540            },
541            ActionChoice {
542                action: EndpointAction::Remove,
543                label: "remove endpoint",
544            },
545        ];
546        let chosen = Select::new(&format!("{protocol} endpoint:"), choices)
547            .prompt()
548            .map_err(wizard_err)?;
549
550        if matches!(chosen.action, EndpointAction::Remove) {
551            return Ok(None);
552        }
553    }
554
555    configure(existing).map(Some)
556}
557
558// ─── Formatting helpers ───────────────────────────────────────────────────────
559
560fn format_endpoint_label(protocol: &str, description: &str, status: String) -> String {
561    format!("{protocol:<4}  {description:<30}  {status}")
562}
563
564/// Returns a short status string for an addr-based endpoint (dns/dot/doq).
565/// When `is_url` is true, uses url instead of addr.
566fn endpoint_addr_status<T: EndpointInfo>(endpoint: Option<&T>, is_url: bool) -> String {
567    match endpoint {
568        None => "not configured".to_string(),
569        Some(ep) => {
570            let target = if is_url {
571                ep.url_str().unwrap_or_else(|| ep.addr_str().unwrap_or("?"))
572            } else {
573                ep.addr_str().unwrap_or("?")
574            };
575            let state = if ep.is_enabled() {
576                "enabled"
577            } else {
578                "disabled"
579            };
580            format!("{state} → {target}")
581        }
582    }
583}
584
585fn format_server_summary(server: &DnsServerConfig) -> String {
586    let vendor = match server.vendor {
587        crate::control_plane::config::VendorKind::Technitium => "technitium",
588        crate::control_plane::config::VendorKind::Pangolin => "pangolin",
589        crate::control_plane::config::VendorKind::Cloudflare => "cloudflare",
590        crate::control_plane::config::VendorKind::Unifi => "unifi",
591        crate::control_plane::config::VendorKind::Pihole => "pihole",
592    };
593    let url = server.base_url.as_deref().unwrap_or("(default)");
594    format!("{}  [{vendor}]  {url}", server.id)
595}
596
597// ─── Trait to unify transport config access for status formatting ─────────────
598
599trait EndpointInfo {
600    fn is_enabled(&self) -> bool;
601    fn addr_str(&self) -> Option<&str>;
602    fn url_str(&self) -> Option<&str> {
603        None
604    }
605}
606
607impl EndpointInfo for DnsTransportConfig {
608    fn is_enabled(&self) -> bool {
609        self.enabled
610    }
611    fn addr_str(&self) -> Option<&str> {
612        self.addr.as_deref()
613    }
614}
615
616impl EndpointInfo for DotTransportConfig {
617    fn is_enabled(&self) -> bool {
618        self.enabled
619    }
620    fn addr_str(&self) -> Option<&str> {
621        self.addr.as_deref()
622    }
623}
624
625impl EndpointInfo for DohTransportConfig {
626    fn is_enabled(&self) -> bool {
627        self.enabled
628    }
629    fn addr_str(&self) -> Option<&str> {
630        self.addr.as_deref()
631    }
632    fn url_str(&self) -> Option<&str> {
633        self.url.as_deref()
634    }
635}
636
637impl EndpointInfo for DoqTransportConfig {
638    fn is_enabled(&self) -> bool {
639        self.enabled
640    }
641    fn addr_str(&self) -> Option<&str> {
642        self.addr.as_deref()
643    }
644}
645
646// ─── Prompt utilities ─────────────────────────────────────────────────────────
647
648fn optional_text(label: &str, help: &str, default: Option<&str>) -> Result<Option<String>> {
649    let mut builder = Text::new(label).with_help_message(help);
650    if let Some(d) = default {
651        builder = builder.with_default(d);
652    }
653    let val = builder.prompt().map_err(wizard_err)?;
654    let val = val.trim();
655    Ok(if val.is_empty() {
656        None
657    } else {
658        Some(val.to_string())
659    })
660}
661
662fn optional_u64(label: &str, help: &str, current: Option<u64>) -> Result<Option<u64>> {
663    let default = current.map(|n| n.to_string());
664    let mut builder = Text::new(label)
665        .with_help_message(help)
666        .with_validator(|input: &str| {
667            if input.is_empty() {
668                return Ok(Validation::Valid);
669            }
670            if input.parse::<u64>().is_ok() {
671                Ok(Validation::Valid)
672            } else {
673                Ok(Validation::Invalid("must be a non-negative integer".into()))
674            }
675        });
676    if let Some(ref d) = default {
677        builder = builder.with_default(d.as_str());
678    }
679    let val = builder.prompt().map_err(wizard_err)?;
680    if val.is_empty() {
681        Ok(None)
682    } else {
683        val.parse::<u64>()
684            .map(Some)
685            .map_err(|_| Error::parse(format!("'{val}' is not a valid integer")))
686    }
687}
688
689fn wizard_err(e: inquire::InquireError) -> Error {
690    match e {
691        InquireError::OperationCanceled | InquireError::OperationInterrupted => Error::cancelled(),
692        other => Error::io(
693            format!("interactive prompt failed: {other}"),
694            std::io::Error::other(other.to_string()),
695        ),
696    }
697}
698
699// ─── Display wrappers so Select/MultiSelect can render enum variants ──────────
700
701struct VendorChoice {
702    kind: VendorKind,
703    label: &'static str,
704}
705
706impl std::fmt::Display for VendorChoice {
707    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
708        f.write_str(self.label)
709    }
710}
711
712struct LocationChoice {
713    value: Option<ServerLocation>,
714    label: &'static str,
715}
716
717impl std::fmt::Display for LocationChoice {
718    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719        f.write_str(self.label)
720    }
721}
722
723struct AccessChoice {
724    rule: PolicyRule,
725    label: &'static str,
726}
727
728impl std::fmt::Display for AccessChoice {
729    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
730        f.write_str(self.label)
731    }
732}
733
734struct ProtocolChoice {
735    id: u8,
736    label: &'static str,
737}
738
739impl std::fmt::Display for ProtocolChoice {
740    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
741        f.write_str(self.label)
742    }
743}
744
745enum EndpointProtocol {
746    Dns,
747    Dot,
748    Doh,
749    Doq,
750}
751
752struct EndpointChoice {
753    protocol: EndpointProtocol,
754    label: String,
755}
756
757impl std::fmt::Display for EndpointChoice {
758    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
759        f.write_str(&self.label)
760    }
761}
762
763enum EndpointAction {
764    Configure,
765    Remove,
766}
767
768struct ActionChoice {
769    action: EndpointAction,
770    label: &'static str,
771}
772
773impl std::fmt::Display for ActionChoice {
774    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
775        f.write_str(self.label)
776    }
777}
778
779struct ServerChoice {
780    id: String,
781    label: String,
782}
783
784impl std::fmt::Display for ServerChoice {
785    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
786        f.write_str(&self.label)
787    }
788}