Skip to main content

dnslib/cli/
query.rs

1//! `dns query` — direct DNS lookups (dig-style).
2//!
3//! Resolves a name via the system resolver by default, or via a
4//! configured `[[servers]]` entry (`--server <ID>` + one or more of
5//! `--dns`/`--dot`/`--doh`/`--doq` or `--all`), or via an ad-hoc
6//! resolver (`--at <ADDR>` or dig-style `@ADDR` positional).
7//!
8//! Output is dig-flavoured: a header line starting with `@`, a blank
9//! line, then a column-aligned table of answers (one block per
10//! transport when fanning out). `--short` emits answers only;
11//! `--json` emits a stable JSON shape.
12
13use std::{fmt::Write, time::Duration, time::Instant};
14
15use clap::Args;
16use hickory_resolver::{
17    Resolver, config::ResolverOpts, net::runtime::TokioRuntimeProvider, proto::rr::Record,
18    proto::rr::RecordType,
19};
20use serde::Serialize;
21use serde_json::json;
22
23use crate::{
24    control_plane::config::{AppConfig, DnsServerConfig, ValidationTransport},
25    core::{
26        dns::{
27            resolver::{ResolverKind, ResolverTarget, build_resolver, classify_hickory_error},
28            validation::{ObservedRecord, ValidationFailureKind},
29        },
30        error::{Error, Result},
31    },
32};
33
34/// Default per-attempt timeout when no `--timeout` and no per-block
35/// `timeout_ms` is configured.
36const DEFAULT_TIMEOUT_MS: u64 = 5_000;
37
38/// Order in which transports render and run when fanning out. Matches
39/// the precedence used to pick a single transport when none is
40/// requested (DoH first, DoQ last because it's an opt-in build).
41pub const TRANSPORT_PRECEDENCE: [ValidationTransport; 4] = [
42    ValidationTransport::Doh,
43    ValidationTransport::Dot,
44    ValidationTransport::Dns,
45    ValidationTransport::Doq,
46];
47
48const DEFAULT_RECORD_TYPES: [&str; 10] = [
49    "A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "PTR", "SOA",
50];
51
52#[derive(Args, Debug, Clone, Default)]
53pub struct QueryArgs {
54    /// Domain to resolve, plus an optional dig-style `@ADDR` positional
55    /// (alias for `--at`). The non-`@` positional is the domain; the
56    /// `@`-prefixed one, if any, is the ad-hoc resolver target.
57    pub targets: Vec<String>,
58
59    /// Record type, repeatable (default: query all supported standard
60    /// types). Standard mnemonics:
61    /// `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `NS`, `SRV`, `CAA`, `PTR`,
62    /// `SOA`, `ANY`.
63    #[arg(short = 't', long = "type", value_name = "RR")]
64    pub r#type: Vec<String>,
65
66    /// A configured `[[servers]]` entry to query. Matched case-
67    /// insensitively against `server.id`. Mutually exclusive with
68    /// `--at`/`@ADDR`.
69    #[arg(long)]
70    pub server: Option<String>,
71
72    /// Ad-hoc resolver. `host[:port]` or `scheme://host[:port][/path]`.
73    /// Schemes recognised: `udp://`, `tcp://`, `dns://`, `tls://`,
74    /// `dot://`, `https://`, `doh://`, `quic://`, `doq://`.
75    #[arg(long)]
76    pub at: Option<String>,
77
78    /// Use the `[servers.dns]` block (plain DNS). With `--at`, forces
79    /// plain DNS.
80    #[arg(long)]
81    pub dns: bool,
82
83    /// Use the `[servers.dot]` block (DoT). With `--at`, forces DoT.
84    #[arg(long)]
85    pub dot: bool,
86
87    /// Use the `[servers.doh]` block (DoH). With `--at`, forces DoH.
88    #[arg(long)]
89    pub doh: bool,
90
91    /// Use the `[servers.doq]` block (DoQ). With `--at`, forces DoQ.
92    /// Requires the `doq` Cargo feature.
93    #[arg(long)]
94    pub doq: bool,
95
96    /// Equivalent to passing every transport flag. Only blocks
97    /// present and `enabled = true` on the target are actually
98    /// queried. Requires `--server`.
99    #[arg(long)]
100    pub all: bool,
101
102    /// Override the port. Defaults: DNS 53, DoT 853, DoH 443, DoQ 853.
103    /// Only valid with an ad-hoc target.
104    #[arg(long)]
105    pub port: Option<u16>,
106
107    /// SNI / certificate name override for DoT, DoH, DoQ. Only valid
108    /// with an ad-hoc target.
109    #[arg(long = "tls-server-name")]
110    pub tls_server_name: Option<String>,
111
112    /// Per-attempt timeout in milliseconds (default 5000).
113    #[arg(long)]
114    pub timeout: Option<u64>,
115
116    /// With `--dns`, force TCP only for the plain-DNS query (skip
117    /// UDP). Ignored for other transports.
118    #[arg(long)]
119    pub tcp: bool,
120
121    /// Print only the data column. Mirrors `dig +short`.
122    #[arg(long)]
123    pub short: bool,
124
125    /// Emit structured JSON output.
126    #[arg(long)]
127    pub json: bool,
128}
129
130/// Per-transport outcome for one block within a single `dns query`
131/// invocation. The renderer turns these into header+rows / short
132/// lines / JSON entries.
133#[derive(Debug, Clone)]
134pub struct QueryResultBlock {
135    pub target_label: String,
136    pub transport: ValidationTransport,
137    pub extras: Vec<(String, String)>,
138    pub url: Option<String>,
139    pub host_for_json: Option<String>,
140    pub port_for_json: Option<u16>,
141    pub elapsed: Duration,
142    pub status: QueryStatus,
143    pub records: Vec<ObservedRecord>,
144    pub asked_types: Vec<String>,
145    /// The domain that was queried, kept so status rows (NXDOMAIN,
146    /// TIMEOUT, …) can show the name on the left even when no answer
147    /// records came back.
148    pub queried_name: String,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum QueryStatus {
153    NoError,
154    NxDomain,
155    Servfail,
156    Refused,
157    Timeout,
158    TlsFailure,
159    DohHttpFailure,
160    MalformedResponse,
161    UnsupportedTransport,
162    Skipped { reason: String },
163}
164
165impl QueryStatus {
166    fn header_word(&self) -> Option<&str> {
167        Some(match self {
168            QueryStatus::NoError => return None,
169            QueryStatus::NxDomain => "NXDOMAIN",
170            QueryStatus::Servfail => "SERVFAIL",
171            QueryStatus::Refused => "REFUSED",
172            QueryStatus::Timeout => "TIMEOUT",
173            QueryStatus::TlsFailure => "TLS_FAILURE",
174            QueryStatus::DohHttpFailure => "HTTP_FAILURE",
175            QueryStatus::MalformedResponse => "MALFORMED",
176            QueryStatus::UnsupportedTransport => "UNSUPPORTED",
177            QueryStatus::Skipped { .. } => "SKIPPED",
178        })
179    }
180
181    fn json_tag(&self) -> &'static str {
182        match self {
183            QueryStatus::NoError => "noerror",
184            QueryStatus::NxDomain => "nxdomain",
185            QueryStatus::Servfail => "servfail",
186            QueryStatus::Refused => "refused",
187            QueryStatus::Timeout => "timeout",
188            QueryStatus::TlsFailure => "tls_failure",
189            QueryStatus::DohHttpFailure => "doh_http_failure",
190            QueryStatus::MalformedResponse => "malformed_response",
191            QueryStatus::UnsupportedTransport => "unsupported_transport",
192            QueryStatus::Skipped { .. } => "skipped",
193        }
194    }
195
196    /// Severity rank — `noerror` is best (0), failure modes worst.
197    /// Used for the "worst across blocks" exit-code rule.
198    fn severity(&self) -> u8 {
199        match self {
200            QueryStatus::NoError => 0,
201            QueryStatus::Skipped { .. } => 1,
202            QueryStatus::NxDomain => 2,
203            QueryStatus::Servfail
204            | QueryStatus::Refused
205            | QueryStatus::Timeout
206            | QueryStatus::TlsFailure
207            | QueryStatus::DohHttpFailure
208            | QueryStatus::MalformedResponse
209            | QueryStatus::UnsupportedTransport => 3,
210        }
211    }
212}
213
214impl From<ValidationFailureKind> for QueryStatus {
215    fn from(kind: ValidationFailureKind) -> Self {
216        match kind {
217            ValidationFailureKind::Timeout => QueryStatus::Timeout,
218            ValidationFailureKind::Nxdomain => QueryStatus::NxDomain,
219            ValidationFailureKind::Servfail => QueryStatus::Servfail,
220            ValidationFailureKind::Refused => QueryStatus::Refused,
221            ValidationFailureKind::TlsFailure => QueryStatus::TlsFailure,
222            ValidationFailureKind::DohHttpFailure => QueryStatus::DohHttpFailure,
223            ValidationFailureKind::MalformedResponse => QueryStatus::MalformedResponse,
224            ValidationFailureKind::UnsupportedTransport => QueryStatus::UnsupportedTransport,
225        }
226    }
227}
228
229/// Entry point for the `dns query` subcommand.
230///
231/// Returns an exit code (0 on success; non-zero per-status mapping).
232/// Output goes to stdout; errors that prevent any query from running
233/// (parse-time invariants, unknown `--server`) return `Err`.
234pub async fn run_query(config: Option<AppConfig>, args: QueryArgs) -> Result<i32> {
235    let outcome = execute_query(config, args.clone()).await?;
236
237    if args.json {
238        print_json(
239            &outcome.domain,
240            &outcome.record_types,
241            &outcome.target_kind,
242            &outcome.blocks,
243        );
244    } else if args.short {
245        print_short(&outcome.blocks);
246    } else {
247        print_table(&outcome.blocks, &outcome.record_types);
248    }
249
250    Ok(exit_code_for(&outcome.blocks))
251}
252
253/// Programmatic entry point — runs a query and returns the per-
254/// transport results without printing anything. Shared between the
255/// CLI runner and the MCP `dns_resolve` tool so behaviour stays in
256/// parity by construction.
257pub async fn execute_query(config: Option<AppConfig>, args: QueryArgs) -> Result<QueryOutcome> {
258    let (domain, ad_hoc_from_positional) = split_targets(&args.targets)?;
259    let mut effective = args;
260    if let Some(at) = ad_hoc_from_positional {
261        if effective.at.is_some() {
262            return Err(Error::parse(
263                "ambiguous resolver target: pass either `@ADDR` or `--at <ADDR>`, not both",
264            ));
265        }
266        effective.at = Some(at);
267    }
268
269    validate_cli_rules(&effective)?;
270
271    let record_types = parse_record_types(&effective.r#type)?;
272    let default_timeout = Duration::from_millis(effective.timeout.unwrap_or(DEFAULT_TIMEOUT_MS));
273
274    let plan = build_query_plan(config.as_ref(), &effective, default_timeout)?;
275
276    let mut blocks = Vec::with_capacity(plan.targets.len());
277    for plan_target in plan.targets {
278        blocks.push(run_block(plan_target, &record_types, &domain).await);
279    }
280
281    Ok(QueryOutcome {
282        domain,
283        record_types,
284        target_kind: plan.kind,
285        blocks,
286    })
287}
288
289/// Result of `execute_query` — everything needed to render output or
290/// shape a JSON response for the MCP tool.
291#[derive(Debug, Clone)]
292pub struct QueryOutcome {
293    pub domain: String,
294    pub record_types: Vec<String>,
295    pub target_kind: TargetKind,
296    pub blocks: Vec<QueryResultBlock>,
297}
298
299impl QueryOutcome {
300    /// Render the same JSON shape `dns query --json` emits. Used by
301    /// the MCP `dns_resolve` tool to keep CLI/MCP parity.
302    pub fn to_json(&self) -> serde_json::Value {
303        build_json_value(
304            &self.domain,
305            &self.record_types,
306            &self.target_kind,
307            &self.blocks,
308        )
309    }
310}
311
312/// Split the positionals into a single domain plus an optional `@addr`.
313fn split_targets(positionals: &[String]) -> Result<(String, Option<String>)> {
314    let mut domain: Option<&str> = None;
315    let mut at: Option<String> = None;
316    for raw in positionals {
317        if let Some(rest) = raw.strip_prefix('@') {
318            if at.is_some() {
319                return Err(Error::parse("only one `@ADDR` positional is accepted"));
320            }
321            if rest.is_empty() {
322                return Err(Error::parse("`@ADDR` is missing an address after `@`"));
323            }
324            at = Some(rest.to_string());
325        } else if domain.is_none() {
326            domain = Some(raw);
327        } else {
328            return Err(Error::parse(format!(
329                "unexpected positional argument '{raw}': pass a single domain plus an optional `@ADDR`",
330            )));
331        }
332    }
333    let Some(domain) = domain else {
334        return Err(Error::parse(
335            "missing required positional `<DOMAIN>` (the name to resolve)",
336        ));
337    };
338    Ok((domain.to_string(), at))
339}
340
341fn validate_cli_rules(args: &QueryArgs) -> Result<()> {
342    if args.server.is_some() && args.at.is_some() {
343        return Err(Error::parse(
344            "`--server` and `--at`/`@ADDR` are mutually exclusive",
345        ));
346    }
347
348    let any_transport = args.dns || args.dot || args.doh || args.doq;
349    let has_target = args.server.is_some() || args.at.is_some();
350
351    if args.all && (args.dns || args.dot || args.doh || args.doq) {
352        return Err(Error::parse(
353            "`--all` is mutually exclusive with `--dns` / `--dot` / `--doh` / `--doq`",
354        ));
355    }
356
357    if args.all && args.server.is_none() {
358        return Err(Error::parse(
359            "`--all` requires `--server <ID>` — there's no way to enumerate transports for an ad-hoc target or the system resolver",
360        ));
361    }
362
363    if !has_target && (any_transport || args.all) {
364        return Err(Error::parse(
365            "transport flags (--dns/--dot/--doh/--doq/--all) require a resolver target — pass --server <ID> or --at <ADDR>",
366        ));
367    }
368
369    if args.at.is_some() && (args.dns as u8 + args.dot as u8 + args.doh as u8 + args.doq as u8) > 1
370    {
371        return Err(Error::parse(
372            "with `--at`/`@ADDR`, at most one of --dns/--dot/--doh/--doq is accepted",
373        ));
374    }
375
376    if args.server.is_some() && (args.port.is_some() || args.tls_server_name.is_some() || args.tcp)
377    {
378        return Err(Error::parse(
379            "`--port` / `--tls-server-name` / `--tcp` only apply to ad-hoc resolvers (`--at` / `@ADDR`); for `--server`, the transport block owns those values",
380        ));
381    }
382
383    Ok(())
384}
385
386fn parse_record_types(input: &[String]) -> Result<Vec<String>> {
387    if input.is_empty() {
388        return Ok(DEFAULT_RECORD_TYPES
389            .iter()
390            .map(|rr_type| (*rr_type).to_string())
391            .collect());
392    }
393    let mut out = Vec::with_capacity(input.len());
394    for raw in input {
395        let upper = raw.trim().to_ascii_uppercase();
396        if upper.is_empty() {
397            return Err(Error::parse("--type cannot be empty"));
398        }
399        upper
400            .parse::<RecordType>()
401            .map_err(|_| Error::parse(format!("unknown record type '{raw}'")))?;
402        if !out.contains(&upper) {
403            out.push(upper);
404        }
405    }
406    Ok(out)
407}
408
409/// Internal: per-target plan entry, plus the overall `target.kind` for
410/// JSON output.
411struct QueryPlan {
412    kind: TargetKind,
413    targets: Vec<PlanTarget>,
414}
415
416struct PlanTarget {
417    transport: ValidationTransport,
418    /// `Some(target)` runs the lookup; `None` records a `skipped` row
419    /// without a network call (explicit transport flag on a missing
420    /// or disabled block).
421    target: Option<ResolverTarget>,
422    target_label: String,
423    extras: Vec<(String, String)>,
424    url: Option<String>,
425    host_for_json: Option<String>,
426    port_for_json: Option<u16>,
427    timeout: Duration,
428    skip_reason: Option<String>,
429}
430
431#[derive(Debug, Clone)]
432pub enum TargetKind {
433    System {
434        display: String,
435    },
436    Named {
437        server_id: String,
438        cluster: Option<String>,
439    },
440    AdHoc,
441}
442
443fn build_query_plan(
444    config: Option<&AppConfig>,
445    args: &QueryArgs,
446    timeout: Duration,
447) -> Result<QueryPlan> {
448    if let Some(server_id) = args.server.as_deref() {
449        return build_named_plan(config, server_id, args, timeout);
450    }
451    if let Some(at) = args.at.as_deref() {
452        return build_ad_hoc_plan(at, args, timeout);
453    }
454    build_system_plan(args, timeout)
455}
456
457fn build_system_plan(_args: &QueryArgs, timeout: Duration) -> Result<QueryPlan> {
458    let display = system_resolver_display();
459    // System path uses Resolver::builder_tokio() directly; we don't
460    // construct a ResolverTarget. Encode that with a synthetic
461    // PlanTarget that the runner recognises.
462    let mut extras = Vec::new();
463    extras.push(("system".to_string(), String::new()));
464    Ok(QueryPlan {
465        kind: TargetKind::System {
466            display: display.clone(),
467        },
468        targets: vec![PlanTarget {
469            transport: ValidationTransport::Dns,
470            target: None,
471            target_label: display,
472            extras,
473            url: None,
474            host_for_json: None,
475            port_for_json: None,
476            timeout,
477            skip_reason: Some("__system__".to_string()),
478        }],
479    })
480}
481
482/// Render the system resolver's nameserver(s) for the header line.
483/// Best-effort: reads the platform config; falls back to `system` on
484/// error or no entries.
485fn system_resolver_display() -> String {
486    match hickory_resolver::system_conf::read_system_conf() {
487        Ok((config, _)) => {
488            let mut servers = config
489                .name_servers()
490                .iter()
491                .map(|ns| ns.ip.to_string())
492                .collect::<Vec<_>>();
493            servers.sort();
494            servers.dedup();
495            if servers.is_empty() {
496                "system".to_string()
497            } else if servers.len() == 1 {
498                servers.into_iter().next().unwrap()
499            } else {
500                servers.join(",")
501            }
502        }
503        Err(_) => "system".to_string(),
504    }
505}
506
507fn build_named_plan(
508    config: Option<&AppConfig>,
509    server_id: &str,
510    args: &QueryArgs,
511    timeout: Duration,
512) -> Result<QueryPlan> {
513    let cfg = config.ok_or_else(|| {
514        Error::parse(format!(
515            "--server {server_id} requires a config file; none was loaded",
516        ))
517    })?;
518
519    if cfg.clusters.contains_key(server_id) {
520        let members = cfg
521            .clusters
522            .get(server_id)
523            .map(|c| c.members.join(", "))
524            .unwrap_or_default();
525        return Err(Error::parse(format!(
526            "'{server_id}' is a cluster id, not a server. Pick one of its members ({members}) with --server",
527        )));
528    }
529
530    let server = cfg.selected_server(Some(server_id))?;
531    let mut transports = chosen_transports(args);
532    transports.sort_by_key(|t| precedence_index(*t));
533    if !args.all
534        && !has_explicit_transport(args)
535        && let Some(best) = transports
536            .iter()
537            .copied()
538            .find(|transport| ResolverTarget::is_enabled_on(server, *transport))
539    {
540        transports = vec![best];
541    }
542
543    let mut plan_targets = Vec::new();
544    for transport in transports {
545        let block_enabled = ResolverTarget::is_enabled_on(server, transport);
546        if !block_enabled {
547            if args.all {
548                continue;
549            }
550            plan_targets.push(skipped_plan_target(
551                transport,
552                server,
553                "block not configured or disabled",
554                timeout,
555            ));
556            continue;
557        }
558        let Some(mut target) = ResolverTarget::from_server_block(server, transport) else {
559            if args.all {
560                continue;
561            }
562            plan_targets.push(skipped_plan_target(
563                transport,
564                server,
565                "block not configured",
566                timeout,
567            ));
568            continue;
569        };
570        if let Some(override_ms) = args.timeout {
571            target.timeout = Duration::from_millis(override_ms);
572        } else {
573            // Timeout-override is the only thing applied here; everything
574            // else (port, server_name, etc.) lives in the block.
575            if target.timeout == Duration::ZERO {
576                target.timeout = timeout;
577            }
578        }
579        let (label, extras, url, host_for_json, port_for_json) = describe_target(&target);
580        let target_timeout = target.timeout;
581        plan_targets.push(PlanTarget {
582            transport,
583            target: Some(target),
584            target_label: label,
585            extras,
586            url,
587            host_for_json,
588            port_for_json,
589            timeout: target_timeout,
590            skip_reason: None,
591        });
592    }
593
594    Ok(QueryPlan {
595        kind: TargetKind::Named {
596            server_id: server.id.clone(),
597            cluster: server.cluster.clone(),
598        },
599        targets: plan_targets,
600    })
601}
602
603fn skipped_plan_target(
604    transport: ValidationTransport,
605    server: &DnsServerConfig,
606    reason: &str,
607    timeout: Duration,
608) -> PlanTarget {
609    PlanTarget {
610        transport,
611        target: None,
612        target_label: format!(
613            "—  (no [servers.{}] on {})",
614            transport_word(transport),
615            server.id
616        ),
617        extras: Vec::new(),
618        url: None,
619        host_for_json: None,
620        port_for_json: None,
621        timeout,
622        skip_reason: Some(reason.to_string()),
623    }
624}
625
626fn has_explicit_transport(args: &QueryArgs) -> bool {
627    args.dns || args.dot || args.doh || args.doq
628}
629
630fn chosen_transports(args: &QueryArgs) -> Vec<ValidationTransport> {
631    let any_explicit = has_explicit_transport(args);
632    if args.all {
633        return TRANSPORT_PRECEDENCE.to_vec();
634    }
635    if !any_explicit {
636        // Single-best: caller will use precedence to pick the first
637        // enabled block.
638        return TRANSPORT_PRECEDENCE.to_vec();
639    }
640    let mut out = Vec::new();
641    if args.doh {
642        out.push(ValidationTransport::Doh);
643    }
644    if args.dot {
645        out.push(ValidationTransport::Dot);
646    }
647    if args.dns {
648        out.push(ValidationTransport::Dns);
649    }
650    if args.doq {
651        out.push(ValidationTransport::Doq);
652    }
653    out
654}
655
656fn build_ad_hoc_plan(at: &str, args: &QueryArgs, timeout: Duration) -> Result<QueryPlan> {
657    let parsed = parse_ad_hoc(at)?;
658    let forced = forced_transport_from_flags(args);
659    let transport = match (parsed.transport, forced) {
660        (Some(parsed_t), Some(forced_t)) if parsed_t != forced_t => {
661            return Err(Error::parse(format!(
662                "ad-hoc target scheme implies {parsed_t:?} but a different transport flag was supplied",
663            )));
664        }
665        (_, Some(t)) | (Some(t), None) => t,
666        (None, None) => ValidationTransport::Dns,
667    };
668
669    let mut target = ResolverTarget {
670        kind: ResolverKind::AdHoc,
671        transport,
672        host: parsed.host.clone(),
673        port: args.port.or(parsed.port),
674        url: parsed.url.clone(),
675        server_name: args.tls_server_name.clone(),
676        tcp_only: transport == ValidationTransport::Dns && args.tcp,
677        timeout,
678    };
679    if let Some(override_ms) = args.timeout {
680        target.timeout = Duration::from_millis(override_ms);
681    }
682
683    let (label, extras, url, host_for_json, port_for_json) = describe_target(&target);
684    let target_timeout = target.timeout;
685    Ok(QueryPlan {
686        kind: TargetKind::AdHoc,
687        targets: vec![PlanTarget {
688            transport,
689            target: Some(target),
690            target_label: label,
691            extras,
692            url,
693            host_for_json,
694            port_for_json,
695            timeout: target_timeout,
696            skip_reason: None,
697        }],
698    })
699}
700
701fn forced_transport_from_flags(args: &QueryArgs) -> Option<ValidationTransport> {
702    if args.doh {
703        Some(ValidationTransport::Doh)
704    } else if args.dot {
705        Some(ValidationTransport::Dot)
706    } else if args.dns {
707        Some(ValidationTransport::Dns)
708    } else if args.doq {
709        Some(ValidationTransport::Doq)
710    } else {
711        None
712    }
713}
714
715#[derive(Debug, Default)]
716struct ParsedAdHoc {
717    transport: Option<ValidationTransport>,
718    host: Option<String>,
719    port: Option<u16>,
720    url: Option<String>,
721}
722
723fn parse_ad_hoc(raw: &str) -> Result<ParsedAdHoc> {
724    let trimmed = raw.trim();
725    if trimmed.is_empty() {
726        return Err(Error::parse("--at value is empty"));
727    }
728
729    if let Some((scheme, rest)) = trimmed.split_once("://") {
730        let scheme = scheme.to_ascii_lowercase();
731        let (transport, is_url_transport) = match scheme.as_str() {
732            "udp" | "tcp" | "dns" => (Some(ValidationTransport::Dns), false),
733            "tls" | "dot" => (Some(ValidationTransport::Dot), false),
734            "https" | "doh" => (Some(ValidationTransport::Doh), true),
735            "quic" | "doq" => (Some(ValidationTransport::Doq), false),
736            other => {
737                return Err(Error::parse(format!(
738                    "unknown ad-hoc scheme '{other}'; expected one of udp/tcp/dns/tls/dot/https/doh/quic/doq",
739                )));
740            }
741        };
742        if is_url_transport {
743            let url = if scheme == "doh" {
744                format!("https://{rest}")
745            } else {
746                trimmed.to_string()
747            };
748            return Ok(ParsedAdHoc {
749                transport,
750                host: None,
751                port: None,
752                url: Some(url),
753            });
754        }
755        let (host, port) = split_addr(rest)?;
756        return Ok(ParsedAdHoc {
757            transport,
758            host: Some(host),
759            port,
760            url: None,
761        });
762    }
763
764    let (host, port) = split_addr(trimmed)?;
765    Ok(ParsedAdHoc {
766        transport: None,
767        host: Some(host),
768        port,
769        url: None,
770    })
771}
772
773fn split_addr(raw: &str) -> Result<(String, Option<u16>)> {
774    let raw = raw.trim();
775    if raw.is_empty() {
776        return Err(Error::parse("ad-hoc target is empty"));
777    }
778    if let Some(stripped) = raw.strip_prefix('[') {
779        let (host, rest) = stripped
780            .split_once(']')
781            .ok_or_else(|| Error::parse("unmatched `[` in IPv6 literal"))?;
782        let port = rest
783            .strip_prefix(':')
784            .map(|p| {
785                p.parse::<u16>()
786                    .map_err(|_| Error::parse(format!("invalid port '{p}'")))
787            })
788            .transpose()?;
789        return Ok((host.to_string(), port));
790    }
791    if let Some((host, port_s)) = raw.rsplit_once(':')
792        && !host.is_empty()
793        && !host.contains(':')
794    {
795        let port = port_s
796            .parse::<u16>()
797            .map_err(|_| Error::parse(format!("invalid port '{port_s}'")))?;
798        return Ok((host.to_string(), Some(port)));
799    }
800    Ok((raw.to_string(), None))
801}
802
803fn describe_target(
804    target: &ResolverTarget,
805) -> (
806    String,
807    Vec<(String, String)>,
808    Option<String>,
809    Option<String>,
810    Option<u16>,
811) {
812    let mut extras: Vec<(String, String)> = Vec::new();
813    let (label, url_for_json, host_for_json, port_for_json) = match target.transport {
814        ValidationTransport::Doh => {
815            let url = target.url.clone();
816            let label = url
817                .as_deref()
818                .map(strip_https_scheme_for_display)
819                .unwrap_or_else(|| target.host.clone().unwrap_or_default());
820            if let Some(name) = target.server_name.as_deref()
821                && !name.is_empty()
822                && !label.starts_with(name)
823            {
824                extras.push(("sni".to_string(), name.to_string()));
825            }
826            (label, url, target.host.clone(), target.port)
827        }
828        ValidationTransport::Dot | ValidationTransport::Doq => {
829            let port = target.port.unwrap_or(853);
830            let label = format!("{}:{}", target.host.clone().unwrap_or_default(), port);
831            if let Some(name) = target.server_name.as_deref()
832                && !name.is_empty()
833            {
834                extras.push(("sni".to_string(), name.to_string()));
835            }
836            (label, None, target.host.clone(), Some(port))
837        }
838        ValidationTransport::Dns => {
839            let port = target.port.unwrap_or(53);
840            let host = target.host.clone().unwrap_or_default();
841            let label = if port == 53 {
842                host.clone()
843            } else {
844                format!("{host}:{port}")
845            };
846            (label, None, target.host.clone(), Some(port))
847        }
848    };
849    (label, extras, url_for_json, host_for_json, port_for_json)
850}
851
852fn strip_https_scheme_for_display(url: &str) -> String {
853    url.strip_prefix("https://")
854        .map(str::to_string)
855        .unwrap_or_else(|| url.to_string())
856}
857
858fn precedence_index(t: ValidationTransport) -> u8 {
859    TRANSPORT_PRECEDENCE
860        .iter()
861        .position(|p| *p == t)
862        .map(|i| i as u8)
863        .unwrap_or(255)
864}
865
866fn transport_word(t: ValidationTransport) -> &'static str {
867    match t {
868        ValidationTransport::Dns => "dns",
869        ValidationTransport::Dot => "dot",
870        ValidationTransport::Doh => "doh",
871        ValidationTransport::Doq => "doq",
872    }
873}
874
875async fn run_block(plan: PlanTarget, record_types: &[String], domain: &str) -> QueryResultBlock {
876    let started = Instant::now();
877    let asked_types = record_types.to_vec();
878    let queried_name = domain.to_string();
879    let status_for_skip = plan.skip_reason.clone();
880
881    let finish = |status: QueryStatus, records: Vec<ObservedRecord>| QueryResultBlock {
882        target_label: plan.target_label.clone(),
883        transport: plan.transport,
884        extras: plan.extras.clone(),
885        url: plan.url.clone(),
886        host_for_json: plan.host_for_json.clone(),
887        port_for_json: plan.port_for_json,
888        elapsed: started.elapsed(),
889        status,
890        records,
891        asked_types: asked_types.clone(),
892        queried_name: queried_name.clone(),
893    };
894
895    // System path: special-case.
896    if plan.skip_reason.as_deref() == Some("__system__") {
897        let resolver = match build_system_resolver(plan.timeout) {
898            Ok(r) => r,
899            Err(status) => return finish(status, Vec::new()),
900        };
901        let (status, records) = lookup_all(&resolver, domain, record_types, plan.transport).await;
902        return finish(status, records);
903    }
904
905    let Some(mut target) = plan.target.clone() else {
906        return finish(
907            QueryStatus::Skipped {
908                reason: status_for_skip.unwrap_or_else(|| "skipped".to_string()),
909            },
910            Vec::new(),
911        );
912    };
913
914    // DoH bootstrap: hickory's HTTPS NameServerConfig needs an IP, but
915    // a user-supplied URL like `https://cloudflare-dns.com/dns-query`
916    // gives only a hostname. Resolve it via the system resolver before
917    // building the DoH resolver.
918    if target.transport == ValidationTransport::Doh
919        && target
920            .host
921            .as_deref()
922            .is_none_or(|h| h.parse::<std::net::IpAddr>().is_err())
923        && let Some(ref url) = target.url
924    {
925        match bootstrap_doh_host(url, target.timeout).await {
926            Ok(ip) => target.host = Some(ip),
927            Err(status) => return finish(status, Vec::new()),
928        }
929    }
930
931    let resolver = match build_resolver(&target) {
932        Ok(r) => r,
933        Err(kind) => return finish(QueryStatus::from(kind), Vec::new()),
934    };
935    let (status, records) = lookup_all(&resolver, domain, record_types, plan.transport).await;
936    finish(status, records)
937}
938
939/// Resolve the host portion of a DoH URL via the system resolver and
940/// return the first IPv4-or-IPv6 address. Hickory's `https()`
941/// NameServerConfig needs an `IpAddr`; the bootstrap removes the need
942/// for users to know the IP in advance.
943async fn bootstrap_doh_host(
944    url: &str,
945    timeout: Duration,
946) -> std::result::Result<String, QueryStatus> {
947    let host = extract_doh_host(url).ok_or(QueryStatus::MalformedResponse)?;
948    if let Ok(ip) = host.parse::<std::net::IpAddr>() {
949        return Ok(ip.to_string());
950    }
951    let resolver = build_system_resolver(timeout)?;
952    let lookup = resolver.lookup_ip(host).await.map_err(|e| {
953        QueryStatus::from(classify_hickory_error(
954            ValidationTransport::Doh,
955            &e.to_string(),
956        ))
957    })?;
958    // Prefer IPv4: many container/CI environments have no IPv6
959    // outbound. Fall back to whatever the system returned first if no
960    // IPv4 is present.
961    let ips: Vec<std::net::IpAddr> = lookup.iter().collect();
962    ips.iter()
963        .find(|ip| ip.is_ipv4())
964        .or_else(|| ips.first())
965        .map(|ip| ip.to_string())
966        .ok_or(QueryStatus::NxDomain)
967}
968
969fn extract_doh_host(url: &str) -> Option<&str> {
970    let after_scheme = url.strip_prefix("https://").unwrap_or(url);
971    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
972    let authority = authority
973        .rsplit_once('@')
974        .map_or(authority, |(_, host_port)| host_port);
975    let host = if let Some(stripped) = authority.strip_prefix('[') {
976        stripped.split_once(']').map_or(authority, |(host, _)| host)
977    } else {
978        authority
979            .split_once(':')
980            .map_or(authority, |(host, _)| host)
981    };
982    if host.is_empty() { None } else { Some(host) }
983}
984
985fn build_system_resolver(
986    timeout: Duration,
987) -> std::result::Result<Resolver<TokioRuntimeProvider>, QueryStatus> {
988    let mut opts = ResolverOpts::default();
989    opts.timeout = timeout;
990    opts.attempts = 1;
991    let builder = Resolver::builder_tokio().map_err(|e| {
992        tracing::debug!(%e, "could not load system resolver");
993        QueryStatus::MalformedResponse
994    })?;
995    builder.with_options(opts).build().map_err(|e| {
996        tracing::debug!(%e, "system resolver build failed");
997        QueryStatus::MalformedResponse
998    })
999}
1000
1001async fn lookup_all(
1002    resolver: &Resolver<TokioRuntimeProvider>,
1003    domain: &str,
1004    record_types: &[String],
1005    transport: ValidationTransport,
1006) -> (QueryStatus, Vec<ObservedRecord>) {
1007    let mut all_records = Vec::new();
1008    let mut worst_status = QueryStatus::NoError;
1009
1010    for rr_name in record_types {
1011        let Ok(rr_type) = rr_name.parse::<RecordType>() else {
1012            worst_status = worst(worst_status, QueryStatus::MalformedResponse);
1013            continue;
1014        };
1015        match resolver.lookup(domain, rr_type).await {
1016            Ok(lookup) => {
1017                if lookup.answers().is_empty() {
1018                    // Empty answer set for that type — treat as no data
1019                    // (NoError but no records emitted for this type).
1020                } else {
1021                    for record in observed_records_from_answers(lookup.answers()) {
1022                        push_observed_record_once(&mut all_records, record);
1023                    }
1024                }
1025            }
1026            Err(err) => {
1027                let kind = classify_hickory_error(transport, &err.to_string());
1028                worst_status = worst(worst_status, QueryStatus::from(kind));
1029            }
1030        }
1031    }
1032
1033    if all_records.is_empty() {
1034        (worst_status, all_records)
1035    } else {
1036        // If we have any successful records, return NoError status so
1037        // expand_rows will display them instead of showing status-only rows.
1038        // Mixed success/failure means we show the successful answers.
1039        (QueryStatus::NoError, all_records)
1040    }
1041}
1042
1043fn push_observed_record_once(records: &mut Vec<ObservedRecord>, record: ObservedRecord) {
1044    if !records.iter().any(|existing| {
1045        existing.name == record.name
1046            && existing.record_type == record.record_type
1047            && existing.ttl == record.ttl
1048            && existing.values == record.values
1049    }) {
1050        records.push(record);
1051    }
1052}
1053
1054fn observed_records_from_answers(answers: &[Record]) -> Vec<ObservedRecord> {
1055    answers
1056        .iter()
1057        .map(|record| ObservedRecord {
1058            name: record.name.to_string(),
1059            record_type: record.record_type().to_string(),
1060            ttl: Some(record.ttl),
1061            values: vec![record.data.to_string()],
1062        })
1063        .collect()
1064}
1065
1066fn worst(a: QueryStatus, b: QueryStatus) -> QueryStatus {
1067    if a.severity() >= b.severity() { a } else { b }
1068}
1069
1070fn exit_code_for(blocks: &[QueryResultBlock]) -> i32 {
1071    let mut worst = 0u8;
1072    for b in blocks {
1073        worst = worst.max(b.status.severity());
1074    }
1075    match worst {
1076        0 => 0,
1077        1 => 0, // implicit skip doesn't affect exit
1078        2 => 1, // NXDOMAIN
1079        _ => 2,
1080    }
1081}
1082
1083// ───── Rendering ─────────────────────────────────────────────────────────────
1084
1085fn print_table(blocks: &[QueryResultBlock], asked_types: &[String]) {
1086    let multi_type = asked_types.len() > 1;
1087    let mut first = true;
1088    for block in blocks {
1089        if !first {
1090            println!();
1091        }
1092        first = false;
1093        print_header(block);
1094        println!();
1095        let rows = expand_rows(block, multi_type);
1096        print_rows(&rows, multi_type);
1097    }
1098}
1099
1100fn print_header(block: &QueryResultBlock) {
1101    let mut line = format!(
1102        "@ {}  {}",
1103        block.target_label,
1104        transport_word(block.transport)
1105    );
1106    for (k, v) in &block.extras {
1107        if v.is_empty() {
1108            line.push_str("  ");
1109            line.push_str(k);
1110        } else {
1111            let _ = write!(&mut line, "  {k}={v}");
1112        }
1113    }
1114    let _ = write!(&mut line, "  {}ms", block.elapsed.as_millis());
1115    println!("{line}");
1116}
1117
1118#[derive(Debug)]
1119struct Row {
1120    name: String,
1121    rr_type: String,
1122    ttl: Option<String>,
1123    data: String,
1124}
1125
1126fn expand_rows(block: &QueryResultBlock, _multi_type: bool) -> Vec<Row> {
1127    // For noerror, one row per record value; for non-noerror, one row
1128    // per asked type with the status word as the data field. Status
1129    // rows fall back to `queried_name` so NXDOMAIN/TIMEOUT/etc still
1130    // show what was asked.
1131    let mut rows = Vec::new();
1132    if let Some(status_word) = block.status.header_word() {
1133        let name = trim_trailing_dot(&block.queried_name).to_string();
1134        for rr_type in &block.asked_types {
1135            rows.push(Row {
1136                name: name.clone(),
1137                rr_type: rr_type.clone(),
1138                ttl: None,
1139                data: status_word.to_string(),
1140            });
1141        }
1142        return rows;
1143    }
1144    for record in &block.records {
1145        for value in &record.values {
1146            rows.push(Row {
1147                name: trim_trailing_dot(&record.name).to_string(),
1148                rr_type: record.record_type.clone(),
1149                ttl: record.ttl.map(|ttl| ttl.to_string()),
1150                data: value.clone(),
1151            });
1152        }
1153    }
1154    rows
1155}
1156
1157fn trim_trailing_dot(name: &str) -> &str {
1158    name.strip_suffix('.').unwrap_or(name)
1159}
1160
1161fn print_rows(rows: &[Row], multi_type: bool) {
1162    if rows.is_empty() {
1163        return;
1164    }
1165    let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(0);
1166    let type_w = rows.iter().map(|r| r.rr_type.len()).max().unwrap_or(0);
1167    let ttl_w = rows
1168        .iter()
1169        .map(|r| r.ttl.as_deref().unwrap_or("").len())
1170        .max()
1171        .unwrap_or(0);
1172
1173    for row in rows {
1174        let mut line = String::new();
1175        let _ = write!(&mut line, "{:<name_w$}", row.name);
1176        if multi_type
1177            || ttl_w > 0
1178            || rows.iter().any(|r| r.ttl.is_some())
1179            || !row.rr_type.is_empty()
1180        {
1181            let _ = write!(&mut line, "  {:<type_w$}", row.rr_type);
1182        }
1183        if let Some(ttl) = &row.ttl {
1184            let _ = write!(&mut line, "  {:<ttl_w$}", ttl);
1185        }
1186        let _ = write!(&mut line, "  {}", row.data);
1187        println!("{line}");
1188    }
1189}
1190
1191fn print_short(blocks: &[QueryResultBlock]) {
1192    for block in blocks {
1193        for record in &block.records {
1194            for value in &record.values {
1195                println!("{value}");
1196            }
1197        }
1198    }
1199}
1200
1201#[derive(Serialize)]
1202struct JsonOutput<'a> {
1203    query: JsonQuery<'a>,
1204    target: JsonTarget<'a>,
1205    results: Vec<JsonResult<'a>>,
1206}
1207
1208#[derive(Serialize)]
1209struct JsonQuery<'a> {
1210    name: &'a str,
1211    types: &'a [String],
1212}
1213
1214#[derive(Serialize)]
1215struct JsonTarget<'a> {
1216    kind: &'a str,
1217    #[serde(skip_serializing_if = "Option::is_none")]
1218    server: Option<&'a str>,
1219    #[serde(skip_serializing_if = "Option::is_none")]
1220    cluster: Option<&'a str>,
1221    #[serde(skip_serializing_if = "Option::is_none")]
1222    system_resolver: Option<&'a str>,
1223}
1224
1225#[derive(Serialize)]
1226struct JsonResult<'a> {
1227    resolver: JsonResolver<'a>,
1228    elapsed_ms: u128,
1229    status: &'a str,
1230    #[serde(skip_serializing_if = "Option::is_none")]
1231    skip_reason: Option<&'a str>,
1232    answers: Vec<JsonAnswer>,
1233}
1234
1235#[derive(Serialize)]
1236struct JsonResolver<'a> {
1237    transport: &'a str,
1238    #[serde(skip_serializing_if = "Option::is_none")]
1239    address: Option<&'a str>,
1240    #[serde(skip_serializing_if = "Option::is_none")]
1241    port: Option<u16>,
1242    #[serde(skip_serializing_if = "Option::is_none")]
1243    url: Option<&'a str>,
1244    #[serde(skip_serializing_if = "Option::is_none")]
1245    server_name: Option<&'a str>,
1246}
1247
1248#[derive(Serialize)]
1249struct JsonAnswer {
1250    name: String,
1251    #[serde(rename = "type")]
1252    rr_type: String,
1253    data: String,
1254    #[serde(skip_serializing_if = "Option::is_none")]
1255    ttl: Option<u32>,
1256}
1257
1258fn print_json(
1259    domain: &str,
1260    record_types: &[String],
1261    kind: &TargetKind,
1262    blocks: &[QueryResultBlock],
1263) {
1264    let value = build_json_value(domain, record_types, kind, blocks);
1265    println!(
1266        "{}",
1267        serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
1268    );
1269}
1270
1271/// Produce the stable JSON shape `dns query --json` emits, without
1272/// printing. Reused by the MCP `dns_resolve` tool so CLI and MCP
1273/// return identical structured payloads.
1274fn build_json_value(
1275    domain: &str,
1276    record_types: &[String],
1277    kind: &TargetKind,
1278    blocks: &[QueryResultBlock],
1279) -> serde_json::Value {
1280    let target = match kind {
1281        TargetKind::System { display } => JsonTarget {
1282            kind: "system",
1283            server: None,
1284            cluster: None,
1285            system_resolver: Some(display.as_str()),
1286        },
1287        TargetKind::Named { server_id, cluster } => JsonTarget {
1288            kind: "named",
1289            server: Some(server_id.as_str()),
1290            cluster: cluster.as_deref(),
1291            system_resolver: None,
1292        },
1293        TargetKind::AdHoc => JsonTarget {
1294            kind: "ad_hoc",
1295            server: None,
1296            cluster: None,
1297            system_resolver: None,
1298        },
1299    };
1300
1301    let results: Vec<JsonResult> = blocks
1302        .iter()
1303        .map(|b| JsonResult {
1304            resolver: JsonResolver {
1305                transport: transport_word(b.transport),
1306                address: b.host_for_json.as_deref(),
1307                port: b.port_for_json,
1308                url: b.url.as_deref(),
1309                server_name: b
1310                    .extras
1311                    .iter()
1312                    .find(|(k, _)| k == "sni")
1313                    .map(|(_, v)| v.as_str()),
1314            },
1315            elapsed_ms: b.elapsed.as_millis(),
1316            status: b.status.json_tag(),
1317            skip_reason: match &b.status {
1318                QueryStatus::Skipped { reason } => Some(reason.as_str()),
1319                _ => None,
1320            },
1321            answers: b
1322                .records
1323                .iter()
1324                .flat_map(|r| {
1325                    r.values.iter().map(move |v| JsonAnswer {
1326                        name: trim_trailing_dot(&r.name).to_string(),
1327                        rr_type: r.record_type.clone(),
1328                        data: v.clone(),
1329                        ttl: r.ttl,
1330                    })
1331                })
1332                .collect(),
1333        })
1334        .collect();
1335
1336    let out = JsonOutput {
1337        query: JsonQuery {
1338            name: domain,
1339            types: record_types,
1340        },
1341        target,
1342        results,
1343    };
1344    json!(out)
1345}
1346
1347// ───── Tests ─────────────────────────────────────────────────────────────────
1348
1349#[cfg(test)]
1350mod tests {
1351    use super::*;
1352    use crate::cli::{Cli, Command};
1353    use clap::Parser;
1354    use hickory_resolver::proto::rr::{Name, RData, Record};
1355    use rstest::rstest;
1356    use std::str::FromStr;
1357
1358    fn parse(args: &[&str]) -> Result<QueryArgs> {
1359        let mut argv = vec!["dns", "query"];
1360        argv.extend_from_slice(args);
1361        let cli = Cli::try_parse_from(argv).map_err(|e| Error::parse(e.to_string()))?;
1362        match cli.command {
1363            Command::Query(q) => Ok(q),
1364            _ => Err(Error::parse("expected Command::Query")),
1365        }
1366    }
1367
1368    #[test]
1369    fn split_targets_domain_only() {
1370        let (domain, at) = split_targets(&["huly.hankin.io".to_string()]).unwrap();
1371        assert_eq!(domain, "huly.hankin.io");
1372        assert_eq!(at, None);
1373    }
1374
1375    #[test]
1376    fn split_targets_with_at_sugar() {
1377        let (domain, at) =
1378            split_targets(&["huly.hankin.io".to_string(), "@1.1.1.1".to_string()]).unwrap();
1379        assert_eq!(domain, "huly.hankin.io");
1380        assert_eq!(at.as_deref(), Some("1.1.1.1"));
1381    }
1382
1383    #[test]
1384    fn split_targets_at_before_domain() {
1385        let (domain, at) =
1386            split_targets(&["@1.1.1.1".to_string(), "huly.hankin.io".to_string()]).unwrap();
1387        assert_eq!(domain, "huly.hankin.io");
1388        assert_eq!(at.as_deref(), Some("1.1.1.1"));
1389    }
1390
1391    #[test]
1392    fn split_targets_rejects_multiple_at() {
1393        assert!(
1394            split_targets(&[
1395                "huly.hankin.io".to_string(),
1396                "@1.1.1.1".to_string(),
1397                "@8.8.8.8".to_string(),
1398            ])
1399            .is_err()
1400        );
1401    }
1402
1403    #[test]
1404    fn split_targets_rejects_extra_positional() {
1405        assert!(
1406            split_targets(&["huly.hankin.io".to_string(), "extra.example".to_string(),]).is_err()
1407        );
1408    }
1409
1410    #[test]
1411    fn split_targets_requires_domain() {
1412        assert!(split_targets(&[]).is_err());
1413        assert!(split_targets(&["@1.1.1.1".to_string()]).is_err());
1414    }
1415
1416    #[test]
1417    fn parse_record_types_default_to_supported_standard_types() {
1418        let types = parse_record_types(&[]).unwrap();
1419        assert_eq!(
1420            types,
1421            DEFAULT_RECORD_TYPES
1422                .iter()
1423                .map(|rr_type| (*rr_type).to_string())
1424                .collect::<Vec<_>>()
1425        );
1426    }
1427
1428    #[test]
1429    fn parse_record_types_uppercases_and_dedups() {
1430        let types =
1431            parse_record_types(&["a".to_string(), "AAAA".to_string(), "A".to_string()]).unwrap();
1432        assert_eq!(types, vec!["A".to_string(), "AAAA".to_string()]);
1433    }
1434
1435    #[test]
1436    fn parse_record_types_rejects_unknown() {
1437        assert!(parse_record_types(&["BOGUS".to_string()]).is_err());
1438    }
1439
1440    #[test]
1441    fn validate_rejects_server_and_at() {
1442        let mut args = QueryArgs::default();
1443        args.server = Some("dns1".to_string());
1444        args.at = Some("1.1.1.1".to_string());
1445        assert!(validate_cli_rules(&args).is_err());
1446    }
1447
1448    #[test]
1449    fn validate_rejects_all_with_explicit_transport() {
1450        let mut args = QueryArgs::default();
1451        args.server = Some("dns1".to_string());
1452        args.all = true;
1453        args.dot = true;
1454        assert!(validate_cli_rules(&args).is_err());
1455    }
1456
1457    #[test]
1458    fn validate_rejects_all_without_server() {
1459        let mut args = QueryArgs::default();
1460        args.all = true;
1461        args.at = Some("1.1.1.1".to_string());
1462        assert!(validate_cli_rules(&args).is_err());
1463    }
1464
1465    #[test]
1466    fn validate_rejects_transport_flags_with_no_target() {
1467        let mut args = QueryArgs::default();
1468        args.dot = true;
1469        assert!(validate_cli_rules(&args).is_err());
1470    }
1471
1472    #[test]
1473    fn validate_rejects_multiple_transport_flags_with_at() {
1474        let mut args = QueryArgs::default();
1475        args.at = Some("1.1.1.1".to_string());
1476        args.dns = true;
1477        args.dot = true;
1478        assert!(validate_cli_rules(&args).is_err());
1479    }
1480
1481    #[test]
1482    fn validate_rejects_port_with_named_server() {
1483        let mut args = QueryArgs::default();
1484        args.server = Some("dns1".to_string());
1485        args.port = Some(53);
1486        assert!(validate_cli_rules(&args).is_err());
1487    }
1488
1489    #[test]
1490    fn validate_accepts_single_target_with_no_transport_flags() {
1491        let mut args = QueryArgs::default();
1492        args.server = Some("dns1".to_string());
1493        validate_cli_rules(&args).unwrap();
1494
1495        let mut args = QueryArgs::default();
1496        args.at = Some("1.1.1.1".to_string());
1497        validate_cli_rules(&args).unwrap();
1498    }
1499
1500    #[test]
1501    fn parse_ad_hoc_plain_ip_no_scheme() {
1502        let p = parse_ad_hoc("1.1.1.1").unwrap();
1503        assert_eq!(p.transport, None);
1504        assert_eq!(p.host.as_deref(), Some("1.1.1.1"));
1505        assert_eq!(p.port, None);
1506    }
1507
1508    #[test]
1509    fn parse_ad_hoc_ip_with_port() {
1510        let p = parse_ad_hoc("9.9.9.9:53").unwrap();
1511        assert_eq!(p.host.as_deref(), Some("9.9.9.9"));
1512        assert_eq!(p.port, Some(53));
1513    }
1514
1515    #[test]
1516    fn parse_ad_hoc_tls_scheme_maps_to_dot() {
1517        let p = parse_ad_hoc("tls://9.9.9.9").unwrap();
1518        assert_eq!(p.transport, Some(ValidationTransport::Dot));
1519        assert_eq!(p.host.as_deref(), Some("9.9.9.9"));
1520    }
1521
1522    #[test]
1523    fn parse_ad_hoc_https_scheme_carries_url() {
1524        let p = parse_ad_hoc("https://cloudflare-dns.com/dns-query").unwrap();
1525        assert_eq!(p.transport, Some(ValidationTransport::Doh));
1526        assert_eq!(
1527            p.url.as_deref(),
1528            Some("https://cloudflare-dns.com/dns-query")
1529        );
1530    }
1531
1532    #[test]
1533    fn parse_ad_hoc_doq_scheme() {
1534        let p = parse_ad_hoc("doq://dns.adguard.com:853").unwrap();
1535        assert_eq!(p.transport, Some(ValidationTransport::Doq));
1536        assert_eq!(p.host.as_deref(), Some("dns.adguard.com"));
1537        assert_eq!(p.port, Some(853));
1538    }
1539
1540    #[test]
1541    fn parse_ad_hoc_rejects_unknown_scheme() {
1542        assert!(parse_ad_hoc("ftp://1.1.1.1").is_err());
1543    }
1544
1545    #[test]
1546    fn parse_ad_hoc_ipv6_literal_no_port() {
1547        let p = parse_ad_hoc("[2001:db8::1]").unwrap();
1548        assert_eq!(p.host.as_deref(), Some("2001:db8::1"));
1549        assert_eq!(p.port, None);
1550    }
1551
1552    #[test]
1553    fn parse_ad_hoc_ipv6_literal_with_port() {
1554        let p = parse_ad_hoc("[2001:db8::1]:53").unwrap();
1555        assert_eq!(p.host.as_deref(), Some("2001:db8::1"));
1556        assert_eq!(p.port, Some(53));
1557    }
1558
1559    #[test]
1560    fn clap_parses_query_alias_q() {
1561        let args = parse(&["huly.hankin.io"]).unwrap();
1562        assert_eq!(args.targets, vec!["huly.hankin.io".to_string()]);
1563    }
1564
1565    #[test]
1566    fn clap_parses_at_sugar_as_positional() {
1567        let args = parse(&["huly.hankin.io", "@1.1.1.1"]).unwrap();
1568        assert_eq!(args.targets.len(), 2);
1569        assert!(args.targets.contains(&"@1.1.1.1".to_string()));
1570    }
1571
1572    #[test]
1573    fn clap_parses_multiple_transport_flags() {
1574        let args = parse(&["huly.hankin.io", "--server", "dns1", "--dot", "--doh"]).unwrap();
1575        assert!(args.dot);
1576        assert!(args.doh);
1577        assert!(!args.dns);
1578        assert!(!args.all);
1579        assert_eq!(args.server.as_deref(), Some("dns1"));
1580    }
1581
1582    #[test]
1583    fn clap_q_alias_works() {
1584        let cli = Cli::try_parse_from(["dns", "q", "huly.hankin.io"]).unwrap();
1585        match cli.command {
1586            Command::Query(q) => assert_eq!(q.targets, vec!["huly.hankin.io".to_string()]),
1587            _ => panic!("expected Command::Query"),
1588        }
1589    }
1590
1591    #[test]
1592    fn forced_transport_picks_in_precedence_order() {
1593        let mut args = QueryArgs::default();
1594        args.doh = true;
1595        assert_eq!(
1596            forced_transport_from_flags(&args),
1597            Some(ValidationTransport::Doh)
1598        );
1599        let mut args = QueryArgs::default();
1600        args.doq = true;
1601        assert_eq!(
1602            forced_transport_from_flags(&args),
1603            Some(ValidationTransport::Doq)
1604        );
1605        let args = QueryArgs::default();
1606        assert_eq!(forced_transport_from_flags(&args), None);
1607    }
1608
1609    #[test]
1610    fn worst_status_picks_higher_severity() {
1611        assert_eq!(
1612            worst(QueryStatus::NoError, QueryStatus::NxDomain),
1613            QueryStatus::NxDomain
1614        );
1615        assert_eq!(
1616            worst(QueryStatus::NxDomain, QueryStatus::NoError),
1617            QueryStatus::NxDomain
1618        );
1619        assert_eq!(
1620            worst(QueryStatus::Timeout, QueryStatus::NxDomain),
1621            QueryStatus::Timeout
1622        );
1623    }
1624
1625    #[test]
1626    fn exit_code_worst_across_blocks() {
1627        fn block(status: QueryStatus) -> QueryResultBlock {
1628            QueryResultBlock {
1629                target_label: String::new(),
1630                transport: ValidationTransport::Dns,
1631                extras: Vec::new(),
1632                url: None,
1633                host_for_json: None,
1634                port_for_json: None,
1635                elapsed: Duration::ZERO,
1636                status,
1637                records: Vec::new(),
1638                asked_types: vec!["A".to_string()],
1639                queried_name: "example.com".to_string(),
1640            }
1641        }
1642        assert_eq!(exit_code_for(&[block(QueryStatus::NoError)]), 0);
1643        assert_eq!(
1644            exit_code_for(&[block(QueryStatus::NoError), block(QueryStatus::NxDomain)]),
1645            1
1646        );
1647        assert_eq!(
1648            exit_code_for(&[block(QueryStatus::NoError), block(QueryStatus::Timeout)]),
1649            2
1650        );
1651        // Implicit skip doesn't change the exit code
1652        assert_eq!(
1653            exit_code_for(&[
1654                block(QueryStatus::NoError),
1655                block(QueryStatus::Skipped {
1656                    reason: "block not configured or disabled".to_string()
1657                })
1658            ]),
1659            0
1660        );
1661    }
1662
1663    #[rstest]
1664    #[case("A", "192.0.2.10", "192.0.2.10")]
1665    #[case("AAAA", "2001:db8::10", "2001:db8::10")]
1666    #[case("CNAME", "target.example.com.", "target.example.com.")]
1667    #[case("MX", "10 mail.example.com.", "10 mail.example.com.")]
1668    #[case("TXT", "\"v=spf1 -all\"", "v=spf1 -all")]
1669    #[case("NS", "ns1.example.com.", "ns1.example.com.")]
1670    #[case("SRV", "10 20 5060 sip.example.com.", "10 20 5060 sip.example.com.")]
1671    #[case("CAA", "0 issue \"letsencrypt.org\"", "0 issue \"letsencrypt.org\"")]
1672    #[case("PTR", "host.example.com.", "host.example.com.")]
1673    #[case(
1674        "SOA",
1675        "ns1.example.com. hostmaster.example.com. 2026052901 3600 900 604800 300",
1676        "ns1.example.com. hostmaster.example.com. 2026052901 3600 900 604800 300"
1677    )]
1678    fn observed_records_preserve_actual_type_name_ttl_and_value(
1679        #[case] rr_type: &str,
1680        #[case] rdata_text: &str,
1681        #[case] expected_value: &str,
1682    ) {
1683        let rr_type = rr_type.parse::<RecordType>().unwrap();
1684        let record = test_record("owner.example.com.", 600, rr_type, rdata_text);
1685
1686        let observed = observed_records_from_answers(&[record]);
1687
1688        assert_eq!(observed.len(), 1);
1689        assert_eq!(observed[0].name, "owner.example.com.");
1690        assert_eq!(observed[0].record_type, rr_type.to_string());
1691        assert_eq!(observed[0].ttl, Some(600));
1692        assert_eq!(observed[0].values, vec![expected_value.to_string()]);
1693    }
1694
1695    #[test]
1696    fn observed_records_keep_cname_type_returned_during_aaaa_lookup() {
1697        let records = vec![
1698            test_record(
1699                "alias.example.com.",
1700                300,
1701                RecordType::CNAME,
1702                "target.example.com.",
1703            ),
1704            test_record("target.example.com.", 300, RecordType::AAAA, "2001:db8::10"),
1705        ];
1706
1707        let observed = observed_records_from_answers(&records);
1708
1709        assert_eq!(observed[0].name, "alias.example.com.");
1710        assert_eq!(observed[0].record_type, "CNAME");
1711        assert_eq!(observed[0].values, vec!["target.example.com.".to_string()]);
1712        assert_eq!(observed[1].name, "target.example.com.");
1713        assert_eq!(observed[1].record_type, "AAAA");
1714        assert_eq!(observed[1].values, vec!["2001:db8::10".to_string()]);
1715    }
1716
1717    #[test]
1718    fn observed_records_keep_cname_type_returned_during_a_lookup() {
1719        let records = vec![
1720            test_record(
1721                "alias.example.com.",
1722                300,
1723                RecordType::CNAME,
1724                "target.example.com.",
1725            ),
1726            test_record("target.example.com.", 300, RecordType::A, "192.0.2.10"),
1727        ];
1728
1729        let observed = observed_records_from_answers(&records);
1730
1731        assert_eq!(observed[0].name, "alias.example.com.");
1732        assert_eq!(observed[0].record_type, "CNAME");
1733        assert_eq!(observed[0].values, vec!["target.example.com.".to_string()]);
1734        assert_eq!(observed[1].name, "target.example.com.");
1735        assert_eq!(observed[1].record_type, "A");
1736        assert_eq!(observed[1].values, vec!["192.0.2.10".to_string()]);
1737    }
1738    #[test]
1739    fn push_observed_record_once_deduplicates_cname_seen_from_multiple_type_lookups() {
1740        let mut records = Vec::new();
1741        let cname = ObservedRecord {
1742            name: "alias.example.com.".to_string(),
1743            record_type: "CNAME".to_string(),
1744            ttl: Some(300),
1745            values: vec!["target.example.com.".to_string()],
1746        };
1747
1748        push_observed_record_once(&mut records, cname.clone());
1749        push_observed_record_once(&mut records, cname);
1750
1751        assert_eq!(records.len(), 1);
1752        assert_eq!(records[0].record_type, "CNAME");
1753    }
1754
1755    fn test_record(name: &str, ttl: u32, rr_type: RecordType, rdata_text: &str) -> Record {
1756        Record::from_rdata(
1757            Name::from_str(name).unwrap(),
1758            ttl,
1759            RData::try_from_str(rr_type, rdata_text).unwrap(),
1760        )
1761    }
1762}