Skip to main content

hyperdb_api_core/client/
endpoint.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Connection endpoint types for Hyper database.
5//!
6//! This module provides [`ConnectionEndpoint`] for representing different
7//! transport mechanisms (TCP, Unix Domain Socket, Windows Named Pipe).
8
9use std::fmt;
10#[cfg(unix)]
11use std::path::PathBuf;
12
13use super::error::{Error, Result};
14
15/// Represents a connection endpoint for Hyper database.
16///
17/// Supports different transport mechanisms:
18/// - TCP: `tab.tcp://host:port`
19/// - Unix Domain Socket: `tab.domain://<directory>/domain/<name>` (Unix only)
20/// - Windows Named Pipe: `tab.pipe://<host>/pipe/<name>` (Windows only)
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum ConnectionEndpoint {
23    /// TCP endpoint: `tab.tcp://host:port`
24    Tcp {
25        /// Hostname or IP address
26        host: String,
27        /// Port number (0 for auto-assign)
28        port: u16,
29    },
30
31    /// Unix Domain Socket: `tab.domain://<directory>/domain/<name>`
32    #[cfg(unix)]
33    DomainSocket {
34        /// Directory containing the socket file
35        directory: PathBuf,
36        /// Socket name (e.g., `.s.PGSQL.12345`)
37        name: String,
38    },
39    /// Windows Named Pipe: `tab.pipe://<host>/pipe/<name>`
40    ///
41    /// Example format: `tab.pipe://./pipe/hyper-12345` for local pipe
42    #[cfg(windows)]
43    NamedPipe {
44        /// Server name ("." for local machine)
45        host: String,
46        /// Pipe name (e.g., "hyper-12345")
47        name: String,
48    },
49}
50
51impl ConnectionEndpoint {
52    /// Creates a new TCP endpoint.
53    pub fn tcp(host: impl Into<String>, port: u16) -> Self {
54        ConnectionEndpoint::Tcp {
55            host: host.into(),
56            port,
57        }
58    }
59
60    /// Creates a new Unix Domain Socket endpoint.
61    #[cfg(unix)]
62    pub fn domain_socket(directory: impl Into<PathBuf>, name: impl Into<String>) -> Self {
63        ConnectionEndpoint::DomainSocket {
64            directory: directory.into(),
65            name: name.into(),
66        }
67    }
68
69    /// Creates a new Windows Named Pipe endpoint.
70    ///
71    /// # Arguments
72    /// * `host` - Server name ("." for local machine)
73    /// * `name` - Pipe name (e.g., "hyper-12345")
74    #[cfg(windows)]
75    pub fn named_pipe(host: impl Into<String>, name: impl Into<String>) -> Self {
76        ConnectionEndpoint::NamedPipe {
77            host: host.into(),
78            name: name.into(),
79        }
80    }
81
82    /// Parses a connection descriptor string.
83    ///
84    /// Supported formats:
85    /// - `tab.tcp://host:port` or `host:port` → TCP
86    /// - `tab.domain://<dir>/domain/<name>` → Unix Domain Socket
87    /// - `tab.pipe://<host>/pipe/<name>` → Named Pipe
88    ///
89    /// # Errors
90    ///
91    /// Returns [`Error`] (connection) when:
92    /// - The descriptor has the `tab.domain://` prefix but is missing
93    ///   the `/domain/` separator, has an empty socket name, or is
94    ///   used on a non-Unix platform.
95    /// - The descriptor has the `tab.pipe://` prefix but malformed
96    ///   `/pipe/` segment, empty name, or is used on a non-Windows
97    ///   platform.
98    /// - The TCP descriptor cannot be parsed into a `host:port` pair,
99    ///   or the port is not a valid `u16`.
100    pub fn parse(descriptor: &str) -> Result<Self> {
101        // Unix Domain Socket: tab.domain://<directory>/domain/<name>
102        #[allow(unused_variables, reason = "`rest` is unused on non-unix platforms")]
103        if let Some(rest) = descriptor.strip_prefix("tab.domain://") {
104            #[cfg(unix)]
105            {
106                let idx = rest.find("/domain/").ok_or_else(|| {
107                    Error::connection(format!(
108                        "Invalid domain socket format: '{descriptor}'. Expected 'tab.domain://<dir>/domain/<name>'"
109                    ))
110                })?;
111                let directory = &rest[..idx];
112                let name = &rest[idx + 8..]; // "/domain/".len() == 8
113
114                if name.is_empty() {
115                    return Err(Error::connection("Domain socket name cannot be empty"));
116                }
117
118                return Ok(ConnectionEndpoint::DomainSocket {
119                    directory: PathBuf::from(directory),
120                    name: name.to_string(),
121                });
122            }
123            #[cfg(not(unix))]
124            {
125                return Err(Error::connection(
126                    "Unix domain sockets are not supported on this platform",
127                ));
128            }
129        }
130
131        // Named Pipe: tab.pipe://<host>/pipe/<name>
132        #[allow(unused_variables, reason = "`rest` is unused on non-windows platforms")]
133        if let Some(rest) = descriptor.strip_prefix("tab.pipe://") {
134            #[cfg(windows)]
135            {
136                // Format: tab.pipe://<host>/pipe/<name>
137                // Example: tab.pipe://./pipe/hyper-12345
138                let idx = rest.find("/pipe/").ok_or_else(|| {
139                    Error::connection(format!(
140                        "Invalid named pipe format: '{descriptor}'. Expected 'tab.pipe://<host>/pipe/<name>'"
141                    ))
142                })?;
143                let host = &rest[..idx];
144                let name = &rest[idx + 6..]; // "/pipe/".len() == 6
145
146                if name.is_empty() {
147                    return Err(Error::connection("Named pipe name cannot be empty"));
148                }
149
150                return Ok(ConnectionEndpoint::NamedPipe {
151                    host: host.to_string(),
152                    name: name.to_string(),
153                });
154            }
155            #[cfg(not(windows))]
156            {
157                return Err(Error::connection(
158                    "Named pipes are not supported on this platform",
159                ));
160            }
161        }
162
163        // TCP: tab.tcp://host:port or just host:port
164        let tcp_part = descriptor
165            .strip_prefix("tab.tcp://")
166            .or_else(|| descriptor.strip_prefix("tcp.libpq://"))
167            .unwrap_or(descriptor);
168
169        Self::parse_tcp(tcp_part)
170    }
171
172    /// Parses a TCP host:port string.
173    fn parse_tcp(s: &str) -> Result<Self> {
174        // Handle IPv6 addresses: [::1]:port
175        if s.starts_with('[') {
176            let end_bracket = s
177                .find(']')
178                .ok_or_else(|| Error::connection(format!("Invalid IPv6 address format: '{s}'")))?;
179            let host = &s[1..end_bracket];
180            let port_str = s[end_bracket + 1..]
181                .strip_prefix(':')
182                .ok_or_else(|| Error::connection(format!("Missing port in: '{s}'")))?;
183
184            let port = Self::parse_port(port_str)?;
185            return Ok(ConnectionEndpoint::Tcp {
186                host: host.to_string(),
187                port,
188            });
189        }
190
191        // Regular host:port
192        let colon_idx = s.rfind(':').ok_or_else(|| {
193            Error::connection(format!(
194                "Invalid endpoint format: '{s}'. Expected 'host:port'"
195            ))
196        })?;
197
198        let host = &s[..colon_idx];
199        let port_str = &s[colon_idx + 1..];
200
201        if host.is_empty() {
202            return Err(Error::connection("Host cannot be empty"));
203        }
204
205        let port = Self::parse_port(port_str)?;
206
207        Ok(ConnectionEndpoint::Tcp {
208            host: host.to_string(),
209            port,
210        })
211    }
212
213    /// Parses a port string, handling "auto" as 0.
214    fn parse_port(s: &str) -> Result<u16> {
215        if s == "auto" {
216            return Ok(0);
217        }
218        s.parse::<u16>()
219            .map_err(|_| Error::connection(format!("Invalid port number: '{s}'")))
220    }
221
222    /// Returns the connection descriptor string format.
223    ///
224    /// This is the format used by Hyper for `--listen-connection` and `--callback-connection`.
225    #[must_use]
226    pub fn to_descriptor(&self) -> String {
227        match self {
228            ConnectionEndpoint::Tcp { host, port } => {
229                let port_str = if *port == 0 {
230                    "auto".to_string()
231                } else {
232                    port.to_string()
233                };
234                // Handle IPv6 addresses
235                if host.contains(':') {
236                    format!("tab.tcp://[{host}]:{port_str}")
237                } else {
238                    format!("tab.tcp://{host}:{port_str}")
239                }
240            }
241            #[cfg(unix)]
242            ConnectionEndpoint::DomainSocket { directory, name } => {
243                format!("tab.domain://{}/domain/{}", directory.display(), name)
244            }
245            #[cfg(windows)]
246            ConnectionEndpoint::NamedPipe { host, name } => {
247                format!("tab.pipe://{host}/pipe/{name}")
248            }
249        }
250    }
251
252    /// Returns the socket file path for Unix Domain Sockets.
253    #[cfg(unix)]
254    #[must_use]
255    pub fn socket_path(&self) -> Option<PathBuf> {
256        match self {
257            ConnectionEndpoint::DomainSocket { directory, name } => Some(directory.join(name)),
258            ConnectionEndpoint::Tcp { .. } => None,
259        }
260    }
261
262    /// Returns the Windows pipe path (e.g., `\\.\pipe\hyper-12345`).
263    #[cfg(windows)]
264    pub fn pipe_path(&self) -> Option<String> {
265        match self {
266            ConnectionEndpoint::NamedPipe { host, name } => {
267                Some(format!("\\\\{host}\\pipe\\{name}"))
268            }
269            ConnectionEndpoint::Tcp { .. } => None,
270        }
271    }
272
273    /// Returns true if this is a TCP endpoint.
274    #[must_use]
275    pub fn is_tcp(&self) -> bool {
276        matches!(self, ConnectionEndpoint::Tcp { .. })
277    }
278
279    /// Returns true if this is a Unix Domain Socket endpoint.
280    #[cfg(unix)]
281    #[must_use]
282    pub fn is_domain_socket(&self) -> bool {
283        matches!(self, ConnectionEndpoint::DomainSocket { .. })
284    }
285
286    /// Returns true if this is a Windows Named Pipe endpoint.
287    #[cfg(windows)]
288    pub fn is_named_pipe(&self) -> bool {
289        matches!(self, ConnectionEndpoint::NamedPipe { .. })
290    }
291
292    /// Returns the host and port for TCP endpoints.
293    #[must_use]
294    pub fn tcp_addr(&self) -> Option<(&str, u16)> {
295        match self {
296            ConnectionEndpoint::Tcp { host, port } => Some((host, *port)),
297            #[cfg(unix)]
298            ConnectionEndpoint::DomainSocket { .. } => None,
299            #[cfg(windows)]
300            ConnectionEndpoint::NamedPipe { .. } => None,
301        }
302    }
303}
304
305impl fmt::Display for ConnectionEndpoint {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        match self {
308            ConnectionEndpoint::Tcp { host, port } => {
309                if host.contains(':') {
310                    write!(f, "[{host}]:{port}")
311                } else {
312                    write!(f, "{host}:{port}")
313                }
314            }
315            #[cfg(unix)]
316            ConnectionEndpoint::DomainSocket { directory, name } => {
317                write!(f, "{}/{}", directory.display(), name)
318            }
319            #[cfg(windows)]
320            ConnectionEndpoint::NamedPipe { host, name } => {
321                write!(f, "\\\\{host}\\pipe\\{name}")
322            }
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn test_parse_tcp_simple() {
333        let ep = ConnectionEndpoint::parse("localhost:7483").unwrap();
334        assert_eq!(
335            ep,
336            ConnectionEndpoint::Tcp {
337                host: "localhost".to_string(),
338                port: 7483
339            }
340        );
341    }
342
343    #[test]
344    fn test_parse_tcp_with_scheme() {
345        let ep = ConnectionEndpoint::parse("tab.tcp://127.0.0.1:7483").unwrap();
346        assert_eq!(
347            ep,
348            ConnectionEndpoint::Tcp {
349                host: "127.0.0.1".to_string(),
350                port: 7483
351            }
352        );
353    }
354
355    #[test]
356    fn test_parse_tcp_auto_port() {
357        let ep = ConnectionEndpoint::parse("tab.tcp://localhost:auto").unwrap();
358        assert_eq!(
359            ep,
360            ConnectionEndpoint::Tcp {
361                host: "localhost".to_string(),
362                port: 0
363            }
364        );
365    }
366
367    #[test]
368    fn test_parse_tcp_ipv6() {
369        let ep = ConnectionEndpoint::parse("tab.tcp://[::1]:7483").unwrap();
370        assert_eq!(
371            ep,
372            ConnectionEndpoint::Tcp {
373                host: "::1".to_string(),
374                port: 7483
375            }
376        );
377    }
378
379    #[cfg(unix)]
380    #[test]
381    fn test_parse_domain_socket() {
382        let ep =
383            ConnectionEndpoint::parse("tab.domain:///tmp/hyper/domain/.s.PGSQL.12345").unwrap();
384        assert_eq!(
385            ep,
386            ConnectionEndpoint::DomainSocket {
387                directory: PathBuf::from("/tmp/hyper"),
388                name: ".s.PGSQL.12345".to_string()
389            }
390        );
391    }
392
393    #[test]
394    fn test_to_descriptor_tcp() {
395        let ep = ConnectionEndpoint::tcp("localhost", 7483);
396        assert_eq!(ep.to_descriptor(), "tab.tcp://localhost:7483");
397    }
398
399    #[test]
400    fn test_to_descriptor_tcp_auto() {
401        let ep = ConnectionEndpoint::tcp("localhost", 0);
402        assert_eq!(ep.to_descriptor(), "tab.tcp://localhost:auto");
403    }
404
405    #[cfg(unix)]
406    #[test]
407    fn test_to_descriptor_domain_socket() {
408        let ep = ConnectionEndpoint::domain_socket("/tmp/hyper", ".s.PGSQL.12345");
409        assert_eq!(
410            ep.to_descriptor(),
411            "tab.domain:///tmp/hyper/domain/.s.PGSQL.12345"
412        );
413    }
414
415    #[cfg(unix)]
416    #[test]
417    fn test_socket_path() {
418        let ep = ConnectionEndpoint::domain_socket("/tmp/hyper", ".s.PGSQL.12345");
419        assert_eq!(
420            ep.socket_path(),
421            Some(PathBuf::from("/tmp/hyper/.s.PGSQL.12345"))
422        );
423    }
424
425    #[test]
426    fn test_display_tcp() {
427        let ep = ConnectionEndpoint::tcp("localhost", 7483);
428        assert_eq!(format!("{ep}"), "localhost:7483");
429    }
430
431    #[cfg(unix)]
432    #[test]
433    fn test_display_domain_socket() {
434        let ep = ConnectionEndpoint::domain_socket("/tmp/hyper", ".s.PGSQL.12345");
435        assert_eq!(format!("{ep}"), "/tmp/hyper/.s.PGSQL.12345");
436    }
437
438    #[cfg(windows)]
439    #[test]
440    fn test_parse_named_pipe() {
441        let ep = ConnectionEndpoint::parse("tab.pipe://./pipe/hyper-12345").unwrap();
442        assert_eq!(
443            ep,
444            ConnectionEndpoint::NamedPipe {
445                host: ".".to_string(),
446                name: "hyper-12345".to_string()
447            }
448        );
449    }
450
451    #[cfg(windows)]
452    #[test]
453    fn test_parse_named_pipe_remote() {
454        let ep = ConnectionEndpoint::parse("tab.pipe://server1/pipe/hyper-db").unwrap();
455        assert_eq!(
456            ep,
457            ConnectionEndpoint::NamedPipe {
458                host: "server1".to_string(),
459                name: "hyper-db".to_string()
460            }
461        );
462    }
463
464    #[cfg(windows)]
465    #[test]
466    fn test_to_descriptor_named_pipe() {
467        let ep = ConnectionEndpoint::named_pipe(".", "hyper-12345");
468        assert_eq!(ep.to_descriptor(), "tab.pipe://./pipe/hyper-12345");
469    }
470
471    #[cfg(windows)]
472    #[test]
473    fn test_pipe_path() {
474        let ep = ConnectionEndpoint::named_pipe(".", "hyper-12345");
475        assert_eq!(ep.pipe_path(), Some(r"\\.\pipe\hyper-12345".to_string()));
476    }
477
478    #[cfg(windows)]
479    #[test]
480    fn test_display_named_pipe() {
481        let ep = ConnectionEndpoint::named_pipe(".", "hyper-12345");
482        assert_eq!(format!("{ep}"), r"\\.\pipe\hyper-12345");
483    }
484
485    #[cfg(windows)]
486    #[test]
487    fn test_named_pipe_is_methods() {
488        let ep = ConnectionEndpoint::named_pipe(".", "test");
489        assert!(!ep.is_tcp());
490        assert!(ep.is_named_pipe());
491    }
492}