waitup/
target.rs

1//! Target implementation and builders.
2//!
3//! This module provides implementations for creating and working with targets,
4//! which represent services to wait for. Targets can be either TCP connections
5//! or HTTP endpoints.
6//!
7//! # Features
8//!
9//! - **TCP Targets**: Direct socket connections to host:port
10//! - **HTTP Targets**: HTTP/HTTPS requests with status code validation
11//! - **Builder Pattern**: Fluent APIs for complex target configuration
12//! - **Input Validation**: Comprehensive validation for all inputs
13//! - **Parsing**: String-to-target parsing for CLI and config files
14//!
15//! # Examples
16//!
17//! ## Basic target creation
18//!
19//! ```rust
20//! use waitup::Target;
21//! use url::Url;
22//!
23//! // Create TCP targets
24//! let db = Target::tcp("database.example.com", 5432)?;
25//! let localhost_api = Target::localhost(8080)?;
26//!
27//! // Create HTTP targets
28//! let health_check = Target::http_url("https://api.example.com/health", 200)?;
29//! let status_page = Target::http(
30//!     Url::parse("https://status.example.com")?,
31//!     200
32//! )?;
33//! # Ok::<(), waitup::WaitForError>(())
34//! ```
35//!
36//! ## Using builders for complex configurations
37//!
38//! ```rust
39//! use waitup::Target;
40//! use url::Url;
41//!
42//! // HTTP target with custom headers
43//! let api_target = Target::http_builder(Url::parse("https://api.example.com/protected")?)
44//!     .status(201)
45//!     .auth_bearer("your-api-token")
46//!     .content_type("application/json")
47//!     .header("X-Custom-Header", "custom-value")
48//!     .build()?;
49//!
50//! // TCP target with specific port type validation
51//! let service = Target::tcp_builder("service.example.com")?
52//!     .registered_port(8080)
53//!     .build()?;
54//! # Ok::<(), waitup::WaitForError>(())
55//! ```
56//!
57//! ## Parsing targets from strings
58//!
59//! ```rust
60//! use waitup::Target;
61//!
62//! // Parse various target formats
63//! let tcp_target = Target::parse("localhost:8080", 200)?;
64//! let http_target = Target::parse("https://example.com/health", 200)?;
65//! let custom_port = Target::parse("api.example.com:3000", 200)?;
66//! # Ok::<(), waitup::WaitForError>(())
67//! ```
68
69use std::borrow::Cow;
70use url::Url;
71
72use crate::types::{Hostname, Port, Target};
73use crate::{Result, ResultExt, WaitForError};
74
75impl Target {
76    /// Create multiple TCP targets from a list of host:port pairs
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if any hostname or port is invalid
81    pub fn tcp_batch<I, S>(targets: I) -> crate::types::TargetVecResult
82    where
83        I: IntoIterator<Item = (S, u16)>,
84        S: AsRef<str>,
85    {
86        targets
87            .into_iter()
88            .map(|(host, port)| Self::tcp(host.as_ref(), port))
89            .collect()
90    }
91
92    /// Create multiple HTTP targets from a list of URLs
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if any URL is invalid or cannot be parsed
97    pub fn http_batch<I, S>(urls: I, default_status: u16) -> crate::types::TargetVecResult
98    where
99        I: IntoIterator<Item = S>,
100        S: AsRef<str>,
101    {
102        urls.into_iter()
103            .map(|url| Self::http_url(url.as_ref(), default_status))
104            .collect()
105    }
106    /// Create a new TCP target.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if the hostname is invalid or the port is out of range (1-65535)
111    ///
112    /// # Examples
113    ///
114    /// ```rust
115    /// use waitup::Target;
116    ///
117    /// let target = Target::tcp("localhost", 8080)?;
118    /// # Ok::<(), waitup::WaitForError>(())
119    /// ```
120    pub fn tcp(host: impl AsRef<str>, port: u16) -> Result<Self> {
121        let hostname = Hostname::new(host.as_ref())
122            .with_context(|| format!("Invalid hostname '{host}'", host = host.as_ref()))?;
123        let port = Port::try_from(port).with_context(|| format!("Invalid port {port}"))?;
124        Ok(Self::Tcp {
125            host: hostname,
126            port,
127        })
128    }
129
130    /// Create a TCP target for localhost.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the port is invalid (0 or > 65535)
135    ///
136    /// # Examples
137    ///
138    /// ```rust
139    /// use waitup::Target;
140    ///
141    /// let target = Target::localhost(8080)?;
142    /// # Ok::<(), waitup::WaitForError>(())
143    /// ```
144    pub fn localhost(port: u16) -> Result<Self> {
145        let port = Port::try_from(port).with_context(|| format!("Invalid port {port}"))?;
146        Ok(Self::Tcp {
147            host: Hostname::localhost(),
148            port,
149        })
150    }
151
152    /// Create a TCP target for IPv4 loopback (127.0.0.1).
153    ///
154    /// # Errors
155    ///
156    /// Returns an error if the port is invalid (0 or > 65535)
157    ///
158    /// # Examples
159    ///
160    /// ```rust
161    /// use waitup::Target;
162    ///
163    /// let target = Target::loopback(8080)?;
164    /// # Ok::<(), waitup::WaitForError>(())
165    /// ```
166    pub fn loopback(port: u16) -> Result<Self> {
167        let port = Port::try_from(port).with_context(|| format!("Invalid port {port}"))?;
168        Ok(Self::Tcp {
169            host: Hostname::loopback(),
170            port,
171        })
172    }
173
174    /// Create a TCP target for IPv6 loopback (`::1`).
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the port is invalid (0 or > 65535)
179    ///
180    /// # Examples
181    ///
182    /// ```rust
183    /// use waitup::Target;
184    ///
185    /// let target = Target::loopback_v6(8080)?;
186    /// # Ok::<(), waitup::WaitForError>(())
187    /// ```
188    pub fn loopback_v6(port: u16) -> Result<Self> {
189        let port = Port::try_from(port).with_context(|| format!("Invalid port {port}"))?;
190        Ok(Self::Tcp {
191            host: Hostname::loopback_v6(),
192            port,
193        })
194    }
195
196    /// Create a TCP target with validated types (no additional validation).
197    ///
198    /// # Examples
199    ///
200    /// ```rust
201    /// use waitup::{Target, Hostname, Port};
202    ///
203    /// let hostname = Hostname::localhost();
204    /// let port = Port::new(8080).unwrap();
205    /// let target = Target::from_parts(hostname, port);
206    /// ```
207    #[must_use]
208    pub const fn from_parts(host: Hostname, port: Port) -> Self {
209        Self::Tcp { host, port }
210    }
211
212    /// Create a new HTTP target with expected status code 200.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if the URL scheme is not HTTP/HTTPS or if the status code is invalid
217    ///
218    /// # Examples
219    ///
220    /// ```rust
221    /// use waitup::Target;
222    /// use url::Url;
223    ///
224    /// let url = Url::parse("https://api.example.com/health")?;
225    /// let target = Target::http(url, 200)?;
226    /// # Ok::<(), waitup::WaitForError>(())
227    /// ```
228    pub fn http(url: Url, expected_status: u16) -> Result<Self> {
229        Self::validate_http_config(&url, expected_status, None)?;
230        Ok(Self::Http {
231            url,
232            expected_status,
233            headers: None,
234        })
235    }
236
237    /// Create a new HTTP target from a URL string.
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if the URL cannot be parsed or if validation fails
242    ///
243    /// # Examples
244    ///
245    /// ```rust
246    /// use waitup::Target;
247    ///
248    /// let target = Target::http_url("https://api.example.com/health", 200)?;
249    /// # Ok::<(), waitup::WaitForError>(())
250    /// ```
251    pub fn http_url(url: impl AsRef<str>, expected_status: u16) -> Result<Self> {
252        let url = Url::parse(url.as_ref())
253            .with_context(|| format!("Invalid URL: {url}", url = url.as_ref()))?;
254        Self::http(url, expected_status)
255    }
256
257    /// Validate HTTP target configuration
258    pub(crate) fn validate_http_config(
259        url: &Url,
260        expected_status: u16,
261        headers: Option<&crate::types::HttpHeaders>,
262    ) -> Result<()> {
263        // Validate URL scheme
264        if !matches!(url.scheme(), "http" | "https") {
265            return Err(WaitForError::InvalidTarget(Cow::Owned(format!(
266                "Unsupported URL scheme: {scheme}",
267                scheme = url.scheme()
268            ))));
269        }
270
271        // Validate status code
272        if !(100..=599).contains(&expected_status) {
273            return Err(WaitForError::InvalidTarget(Cow::Owned(format!(
274                "Invalid HTTP status code: {expected_status}"
275            ))));
276        }
277
278        // Validate headers if present
279        if let Some(headers) = headers {
280            for (key, value) in headers {
281                if key.is_empty() {
282                    return Err(WaitForError::InvalidTarget(Cow::Borrowed(
283                        "HTTP header key cannot be empty",
284                    )));
285                }
286                if value.is_empty() {
287                    return Err(WaitForError::InvalidTarget(Cow::Borrowed(
288                        "HTTP header value cannot be empty",
289                    )));
290                }
291                // Basic header name validation (simplified)
292                if !key
293                    .chars()
294                    .all(|c| c.is_ascii_alphanumeric() || "-_".contains(c))
295                {
296                    return Err(WaitForError::InvalidTarget(Cow::Owned(format!(
297                        "Invalid HTTP header name: {key}"
298                    ))));
299                }
300            }
301        }
302
303        Ok(())
304    }
305
306    /// Create a new HTTP target with custom headers.
307    ///
308    /// # Errors
309    ///
310    /// Returns an error if URL validation fails or if headers are invalid
311    ///
312    /// # Examples
313    ///
314    /// ```rust
315    /// use waitup::Target;
316    /// use url::Url;
317    ///
318    /// let url = Url::parse("https://api.example.com/health")?;
319    /// let headers = vec![("Authorization".to_string(), "Bearer token".to_string())];
320    /// let target = Target::http_with_headers(url, 200, headers)?;
321    /// # Ok::<(), waitup::WaitForError>(())
322    /// ```
323    pub fn http_with_headers(
324        url: Url,
325        expected_status: u16,
326        headers: crate::types::HttpHeaders,
327    ) -> Result<Self> {
328        Self::validate_http_config(&url, expected_status, Some(&headers))?;
329        Ok(Self::Http {
330            url,
331            expected_status,
332            headers: Some(headers),
333        })
334    }
335
336    /// Parse a target from a string.
337    ///
338    /// Supports formats:
339    /// - `host:port` for TCP targets
340    /// - `http://host/path` or `https://host/path` for HTTP targets
341    ///
342    /// # Errors
343    ///
344    /// Returns an error if the string format is invalid or if parsing fails
345    ///
346    /// # Examples
347    ///
348    /// ```rust
349    /// use waitup::Target;
350    ///
351    /// let tcp_target = Target::parse("localhost:8080", 200)?;
352    /// let http_target = Target::parse("https://api.example.com/health", 200)?;
353    /// # Ok::<(), waitup::WaitForError>(())
354    /// ```
355    pub fn parse(target_str: &str, default_http_status: u16) -> Result<Self> {
356        if target_str.starts_with("http://") || target_str.starts_with("https://") {
357            let url = Url::parse(target_str)?;
358            Ok(Self::Http {
359                url,
360                expected_status: default_http_status,
361                headers: None,
362            })
363        } else {
364            let parts: Vec<&str> = target_str.split(':').collect();
365            if parts.len() != 2 {
366                return Err(WaitForError::InvalidTarget(Cow::Owned(
367                    target_str.to_string(),
368                )));
369            }
370            let hostname = Hostname::try_from(parts[0]).with_context(|| {
371                format!(
372                    "Invalid hostname '{hostname}' in target '{target_str}'",
373                    hostname = parts[0]
374                )
375            })?;
376            let port = parts[1]
377                .parse::<u16>()
378                .map_err(|_| WaitForError::InvalidTarget(Cow::Owned(target_str.to_string())))
379                .with_context(|| {
380                    format!(
381                        "Invalid port '{port}' in target '{target_str}'",
382                        port = parts[1]
383                    )
384                })?;
385            let port = Port::try_from(port)
386                .with_context(|| format!("Port {port} out of valid range (1-65535)"))?;
387            Ok(Self::Tcp {
388                host: hostname,
389                port,
390            })
391        }
392    }
393
394    /// Get a string representation of this target for display purposes.
395    #[must_use]
396    pub fn display(&self) -> String {
397        crate::zero_cost::TargetDisplay::new(self).to_string()
398    }
399
400    /// Get the hostname for this target (useful for logging and grouping)
401    #[must_use]
402    pub fn hostname(&self) -> &str {
403        match self {
404            Self::Tcp { host, .. } => host.as_str(),
405            Self::Http { url, .. } => url.host_str().unwrap_or("unknown"),
406        }
407    }
408
409    /// Get the port for this target
410    #[must_use]
411    pub fn port(&self) -> Option<u16> {
412        match self {
413            Self::Tcp { port, .. } => Some(port.get()),
414            Self::Http { url, .. } => url.port(),
415        }
416    }
417
418    /// Create a builder for HTTP targets
419    #[must_use]
420    pub const fn http_builder(url: Url) -> HttpTargetBuilder {
421        HttpTargetBuilder::new(url)
422    }
423
424    /// Create a builder for TCP targets
425    ///
426    /// # Errors
427    ///
428    /// Returns an error if the hostname is invalid
429    pub fn tcp_builder(host: impl AsRef<str>) -> Result<TcpTargetBuilder> {
430        let hostname = Hostname::new(host.as_ref())
431            .with_context(|| format!("Invalid hostname '{host}'", host = host.as_ref()))?;
432        Ok(TcpTargetBuilder::new(hostname))
433    }
434}
435
436/// Builder for HTTP targets
437#[derive(Debug)]
438pub struct HttpTargetBuilder {
439    url: Url,
440    expected_status: u16,
441    headers: crate::types::HttpHeaders,
442}
443
444impl HttpTargetBuilder {
445    pub(crate) const fn new(url: Url) -> Self {
446        Self {
447            url,
448            expected_status: 200,
449            headers: Vec::new(),
450        }
451    }
452
453    /// Set the expected HTTP status code
454    #[must_use]
455    pub const fn status(mut self, status: u16) -> Self {
456        self.expected_status = status;
457        self
458    }
459
460    /// Add a header
461    #[must_use]
462    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
463        self.headers.push((key.into(), value.into()));
464        self
465    }
466
467    /// Add multiple headers
468    #[must_use]
469    pub fn headers(mut self, headers: impl IntoIterator<Item = (String, String)>) -> Self {
470        self.headers.extend(headers);
471        self
472    }
473
474    /// Set authorization header with Bearer token
475    #[must_use]
476    pub fn auth_bearer(self, token: impl AsRef<str>) -> Self {
477        self.header(
478            "Authorization",
479            crate::lazy_format!("Bearer {}", token.as_ref()).to_string(),
480        )
481    }
482
483    /// Set content type header
484    #[must_use]
485    pub fn content_type(self, content_type: impl Into<String>) -> Self {
486        self.header("Content-Type", content_type)
487    }
488
489    /// Set user agent header
490    #[must_use]
491    pub fn user_agent(self, user_agent: impl Into<String>) -> Self {
492        self.header("User-Agent", user_agent)
493    }
494
495    /// Build the HTTP target
496    ///
497    /// # Errors
498    ///
499    /// Returns an error if validation fails
500    pub fn build(self) -> Result<Target> {
501        let headers = if self.headers.is_empty() {
502            None
503        } else {
504            Some(self.headers)
505        };
506        Target::validate_http_config(&self.url, self.expected_status, headers.as_ref())?;
507        Ok(Target::Http {
508            url: self.url,
509            expected_status: self.expected_status,
510            headers,
511        })
512    }
513}
514
515/// Builder for TCP targets
516#[derive(Debug)]
517pub struct TcpTargetBuilder {
518    host: Hostname,
519    port: Option<Port>,
520    port_validation_error: Option<WaitForError>,
521}
522
523impl TcpTargetBuilder {
524    pub(crate) const fn new(host: Hostname) -> Self {
525        Self {
526            host,
527            port: None,
528            port_validation_error: None,
529        }
530    }
531
532    /// Set the port
533    #[must_use]
534    pub fn port(mut self, port: u16) -> Self {
535        match Port::try_from(port) {
536            Ok(p) => {
537                self.port = Some(p);
538                self.port_validation_error = None;
539            }
540            Err(e) => {
541                self.port_validation_error = Some(e);
542            }
543        }
544        self
545    }
546
547    /// Set a well-known port (0-1023)
548    #[must_use]
549    pub fn well_known_port(mut self, port: u16) -> Self {
550        match Port::system_port(port) {
551            Some(p) => {
552                self.port = Some(p);
553                self.port_validation_error = None;
554            }
555            None => {
556                self.port_validation_error = Some(WaitForError::InvalidPort(port));
557            }
558        }
559        self
560    }
561
562    /// Set a registered port (1024-49151)
563    #[must_use]
564    pub fn registered_port(mut self, port: u16) -> Self {
565        match Port::user_port(port) {
566            Some(p) => {
567                self.port = Some(p);
568                self.port_validation_error = None;
569            }
570            None => {
571                self.port_validation_error = Some(WaitForError::InvalidPort(port));
572            }
573        }
574        self
575    }
576
577    /// Set a dynamic port (49152-65535)
578    #[must_use]
579    pub fn dynamic_port(mut self, port: u16) -> Self {
580        match Port::dynamic_port(port) {
581            Some(p) => {
582                self.port = Some(p);
583                self.port_validation_error = None;
584            }
585            None => {
586                self.port_validation_error = Some(WaitForError::InvalidPort(port));
587            }
588        }
589        self
590    }
591
592    /// Build the TCP target
593    ///
594    /// # Errors
595    ///
596    /// Returns an error if no port was specified or if validation fails
597    pub fn build(self) -> Result<Target> {
598        // Check for validation errors first
599        if let Some(error) = self.port_validation_error {
600            return Err(error);
601        }
602
603        let port = self
604            .port
605            .ok_or_else(|| WaitForError::InvalidTarget(Cow::Borrowed("Port must be specified")))?;
606        Ok(Target::Tcp {
607            host: self.host,
608            port,
609        })
610    }
611}