Skip to main content

cli_shared/remote/
target.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Remote target resolution.
3
4use std::{
5    net::{SocketAddr, ToSocketAddrs},
6    path::PathBuf,
7};
8
9/// A remote target - either a network address or a local path.
10#[derive(Debug, Clone)]
11pub enum RemoteTarget {
12    /// Network address (host:port).
13    Network {
14        addr: SocketAddr,
15        repo_path: Option<String>,
16    },
17    /// Local filesystem path (file:// URL).
18    Local(PathBuf),
19}
20
21impl RemoteTarget {
22    /// Parse from a string.
23    ///
24    /// Accepts:
25    /// - `file:///path/to/repo` or `file://path/to/repo`
26    /// - `/path/to/repo` (raw path, if it exists as a directory)
27    /// - `host:port` (network address)
28    pub fn parse(s: &str) -> Result<Self, String> {
29        // Check for file:// protocol
30        if let Some(path) = s.strip_prefix("file://") {
31            return Ok(RemoteTarget::Local(PathBuf::from(path)));
32        }
33
34        if let Some((addr, repo_path)) = parse_network_with_repo_path(s) {
35            return Ok(RemoteTarget::Network { addr, repo_path });
36        }
37
38        // Check if it's a raw path (exists as a directory)
39        let path = PathBuf::from(s);
40        if path.exists() && path.is_dir() {
41            return Ok(RemoteTarget::Local(path));
42        }
43
44        Err(format!(
45            "invalid remote url (expected file://path or host:port): {}",
46            s
47        ))
48    }
49
50    /// Check if this is a local target.
51    pub fn is_local(&self) -> bool {
52        matches!(self, RemoteTarget::Local(_))
53    }
54
55    /// Check if this is a network target.
56    pub fn is_network(&self) -> bool {
57        matches!(self, RemoteTarget::Network { .. })
58    }
59}
60
61impl std::fmt::Display for RemoteTarget {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            RemoteTarget::Network { addr, repo_path } => {
65                if let Some(repo_path) = repo_path {
66                    write!(f, "heddle://{}/{}", addr, repo_path)
67                } else {
68                    write!(f, "{}", addr)
69                }
70            }
71            RemoteTarget::Local(path) => write!(f, "file://{}", path.display()),
72        }
73    }
74}
75
76fn parse_network_with_repo_path(s: &str) -> Option<(SocketAddr, Option<String>)> {
77    if let Some(rest) = s.strip_prefix("heddle://") {
78        return parse_network_with_repo_path(rest);
79    }
80
81    if let Ok(addr) = s.parse::<SocketAddr>() {
82        return Some((addr, None));
83    }
84
85    if let Some(addr) = resolve_socket_addr(s) {
86        return Some((addr, None));
87    }
88
89    let slash = s.find('/')?;
90    let (addr_part, repo_part) = s.split_at(slash);
91    let addr = resolve_socket_addr(addr_part)?;
92    let repo_path = repo_part.trim_start_matches('/');
93    if repo_path.is_empty() {
94        return Some((addr, None));
95    }
96    Some((addr, Some(repo_path.to_string())))
97}
98
99fn resolve_socket_addr(addr: &str) -> Option<SocketAddr> {
100    if let Ok(parsed) = addr.parse::<SocketAddr>() {
101        return Some(parsed);
102    }
103
104    addr.to_socket_addrs().ok()?.next()
105}
106
107#[cfg(test)]
108mod tests {
109    use super::RemoteTarget;
110
111    #[test]
112    fn parses_hostname_without_repo_path() {
113        let target = RemoteTarget::parse("localhost:8421").expect("parse localhost");
114        match target {
115            RemoteTarget::Network { addr, repo_path } => {
116                assert_eq!(addr.port(), 8421);
117                assert!(addr.ip().is_loopback());
118                assert!(repo_path.is_none());
119            }
120            other => panic!("expected network target, got {other:?}"),
121        }
122    }
123
124    #[test]
125    fn parses_hostname_with_repo_path() {
126        let target =
127            RemoteTarget::parse("localhost:8421/acme/heddle").expect("parse localhost repo path");
128        match target {
129            RemoteTarget::Network { addr, repo_path } => {
130                assert_eq!(addr.port(), 8421);
131                assert!(addr.ip().is_loopback());
132                assert_eq!(repo_path.as_deref(), Some("acme/heddle"));
133            }
134            other => panic!("expected network target, got {other:?}"),
135        }
136    }
137}