cli_shared/remote/
target.rs1use std::{
5 net::{SocketAddr, ToSocketAddrs},
6 path::PathBuf,
7};
8
9#[derive(Debug, Clone)]
11pub enum RemoteTarget {
12 Network {
14 addr: SocketAddr,
15 repo_path: Option<String>,
16 },
17 Local(PathBuf),
19}
20
21impl RemoteTarget {
22 pub fn parse(s: &str) -> Result<Self, String> {
29 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 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 pub fn parse_native(s: &str) -> Result<Self, String> {
57 if let Some(rest) = s.strip_prefix("https://") {
58 let (addr, repo_path) = parse_https_network_with_repo_path(rest)
59 .ok_or_else(|| format!("invalid native HTTPS remote url: {s}"))?;
60 return Ok(RemoteTarget::Network { addr, repo_path });
61 }
62 Self::parse(s)
63 }
64
65 pub fn is_local(&self) -> bool {
67 matches!(self, RemoteTarget::Local(_))
68 }
69
70 pub fn is_network(&self) -> bool {
72 matches!(self, RemoteTarget::Network { .. })
73 }
74}
75
76impl std::fmt::Display for RemoteTarget {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match self {
79 RemoteTarget::Network { addr, repo_path } => {
80 if let Some(repo_path) = repo_path {
81 write!(f, "heddle://{}/{}", addr, repo_path)
82 } else {
83 write!(f, "{}", addr)
84 }
85 }
86 RemoteTarget::Local(path) => write!(f, "file://{}", path.display()),
87 }
88 }
89}
90
91fn parse_network_with_repo_path(s: &str) -> Option<(SocketAddr, Option<String>)> {
92 if let Some(rest) = s.strip_prefix("heddle://") {
93 return parse_network_with_repo_path(rest);
94 }
95
96 if let Ok(addr) = s.parse::<SocketAddr>() {
97 return Some((addr, None));
98 }
99
100 if let Some(addr) = resolve_socket_addr(s) {
101 return Some((addr, None));
102 }
103
104 let slash = s.find('/')?;
105 let (addr_part, repo_part) = s.split_at(slash);
106 let addr = resolve_socket_addr(addr_part)?;
107 let repo_path = repo_part.trim_start_matches('/');
108 if repo_path.is_empty() {
109 return Some((addr, None));
110 }
111 Some((addr, Some(repo_path.to_string())))
112}
113
114fn parse_https_network_with_repo_path(s: &str) -> Option<(SocketAddr, Option<String>)> {
115 if s.is_empty() || s.contains(['?', '#', '@']) {
116 return None;
117 }
118 let (authority, repo_path) = match s.split_once('/') {
119 Some((authority, path)) => (authority, Some(path.trim_matches('/'))),
120 None => (s, None),
121 };
122 if authority.is_empty() {
123 return None;
124 }
125 let addr = resolve_socket_addr(authority).or_else(|| {
126 let host = authority
127 .strip_prefix('[')
128 .and_then(|host| host.strip_suffix(']'))
129 .unwrap_or(authority);
130 (host, 443).to_socket_addrs().ok()?.next()
131 })?;
132 let repo_path = repo_path
133 .filter(|path| !path.is_empty())
134 .map(str::to_string);
135 Some((addr, repo_path))
136}
137
138fn resolve_socket_addr(addr: &str) -> Option<SocketAddr> {
139 if let Ok(parsed) = addr.parse::<SocketAddr>() {
140 return Some(parsed);
141 }
142
143 addr.to_socket_addrs().ok()?.next()
144}
145
146#[cfg(test)]
147mod tests {
148 use super::RemoteTarget;
149
150 #[test]
151 fn parses_hostname_without_repo_path() {
152 let target = RemoteTarget::parse("localhost:8421").expect("parse localhost");
153 match target {
154 RemoteTarget::Network { addr, repo_path } => {
155 assert_eq!(addr.port(), 8421);
156 assert!(addr.ip().is_loopback());
157 assert!(repo_path.is_none());
158 }
159 other => panic!("expected network target, got {other:?}"),
160 }
161 }
162
163 #[test]
164 fn parses_hostname_with_repo_path() {
165 let target =
166 RemoteTarget::parse("localhost:8421/acme/heddle").expect("parse localhost repo path");
167 match target {
168 RemoteTarget::Network { addr, repo_path } => {
169 assert_eq!(addr.port(), 8421);
170 assert!(addr.ip().is_loopback());
171 assert_eq!(repo_path.as_deref(), Some("acme/heddle"));
172 }
173 other => panic!("expected network target, got {other:?}"),
174 }
175 }
176
177 #[test]
178 fn native_parser_accepts_https_without_changing_generic_classification() {
179 assert!(RemoteTarget::parse("https://127.0.0.1:8431/acme/heddle").is_err());
180
181 let target = RemoteTarget::parse_native("https://127.0.0.1:8431/acme/heddle")
182 .expect("parse native HTTPS URL");
183 match target {
184 RemoteTarget::Network { addr, repo_path } => {
185 assert_eq!(addr, "127.0.0.1:8431".parse().unwrap());
186 assert_eq!(repo_path.as_deref(), Some("acme/heddle"));
187 }
188 other => panic!("expected network target, got {other:?}"),
189 }
190 }
191
192 #[test]
193 fn native_https_parser_defaults_to_port_443() {
194 let target =
195 RemoteTarget::parse_native("https://127.0.0.1/acme/heddle").expect("parse HTTPS URL");
196 match target {
197 RemoteTarget::Network { addr, .. } => assert_eq!(addr.port(), 443),
198 other => panic!("expected network target, got {other:?}"),
199 }
200 }
201}