1mod target;
7
8use std::{
9 collections::HashMap,
10 fs,
11 path::{Path, PathBuf},
12};
13
14use objects::fs_atomic::write_file_atomic;
15use repo::Repository;
16use serde::{Deserialize, Serialize};
17pub use target::RemoteTarget;
18
19#[derive(Debug, thiserror::Error)]
20pub enum RemoteError {
21 #[error("io error: {0}")]
22 Io(#[from] std::io::Error),
23
24 #[error("toml error: {0}")]
25 Toml(#[from] toml::de::Error),
26
27 #[error("toml serialize error: {0}")]
28 TomlSerialize(#[from] toml::ser::Error),
29
30 #[error("remote not found: {0}")]
31 NotFound(String),
32
33 #[error("no default remote configured")]
34 NoDefaultRemote,
35
36 #[error("invalid remote url: {0}")]
37 InvalidUrl(String),
38}
39
40pub type Result<T> = std::result::Result<T, RemoteError>;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Remote {
44 pub url: String,
45}
46
47#[derive(Debug, Default, Serialize, Deserialize)]
48pub struct RemotesFile {
49 #[serde(default)]
50 pub default: Option<String>,
51 #[serde(default)]
52 pub remotes: HashMap<String, Remote>,
53}
54
55impl RemotesFile {
56 pub fn load(path: &Path) -> Result<Self> {
57 match fs::read_to_string(path) {
58 Ok(contents) => Ok(toml::from_str(&contents)?),
59 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
60 Err(err) => Err(err.into()),
61 }
62 }
63
64 pub fn save(&self, path: &Path) -> Result<()> {
65 if let Some(parent) = path.parent() {
66 fs::create_dir_all(parent)?;
67 }
68 let contents = toml::to_string_pretty(self)?;
69 write_file_atomic(path, contents.as_bytes())?;
70 Ok(())
71 }
72}
73
74pub struct RemoteConfig {
75 path: PathBuf,
76 file: RemotesFile,
77}
78
79impl RemoteConfig {
80 pub fn open(repo: &Repository) -> Result<Self> {
81 let path = repo.heddle_dir().join("remotes.toml");
82 let file = RemotesFile::load(&path)?;
83 Ok(Self { path, file })
84 }
85
86 pub fn list(&self) -> Vec<(String, Remote)> {
87 let mut items: Vec<_> = self
88 .file
89 .remotes
90 .iter()
91 .map(|(name, remote)| (name.clone(), remote.clone()))
92 .collect();
93 items.sort_by(|a, b| a.0.cmp(&b.0));
94 items
95 }
96
97 pub fn get(&self, name: &str) -> Result<Remote> {
98 self.file
99 .remotes
100 .get(name)
101 .cloned()
102 .ok_or_else(|| RemoteError::NotFound(name.to_string()))
103 }
104
105 pub fn add(&mut self, name: &str, remote: Remote) -> Result<()> {
106 if self.file.default.is_none() {
107 self.file.default = Some(name.to_string());
108 }
109 self.file.remotes.insert(name.to_string(), remote);
110 self.file.save(&self.path)?;
111 Ok(())
112 }
113
114 pub fn remove(&mut self, name: &str) -> Result<()> {
115 if self.file.remotes.remove(name).is_none() {
116 return Err(RemoteError::NotFound(name.to_string()));
117 }
118 if self.file.default.as_deref() == Some(name) {
119 self.file.default = None;
120 }
121 self.file.save(&self.path)?;
122 Ok(())
123 }
124
125 pub fn clear_default(&mut self) -> Result<()> {
126 self.file.default = None;
127 self.file.save(&self.path)?;
128 Ok(())
129 }
130
131 pub fn set_default(&mut self, name: &str) -> Result<()> {
132 if !self.file.remotes.contains_key(name) {
133 return Err(RemoteError::NotFound(name.to_string()));
134 }
135 self.file.default = Some(name.to_string());
136 self.file.save(&self.path)?;
137 Ok(())
138 }
139
140 pub fn default_name(&self) -> Option<&str> {
141 self.file.default.as_deref()
142 }
143}
144
145pub fn resolve_remote(repo: &Repository, remote_arg: Option<&str>) -> Result<RemoteTarget> {
147 Ok(resolve_remote_with_key(repo, remote_arg)?.0)
148}
149
150pub fn resolve_remote_with_key(
153 repo: &Repository,
154 remote_arg: Option<&str>,
155) -> Result<(RemoteTarget, Option<String>)> {
156 let cfg = RemoteConfig::open(repo)?;
157
158 let spec = match remote_arg {
159 Some(spec) => spec.to_string(),
160 None => cfg
161 .default_name()
162 .ok_or(RemoteError::NoDefaultRemote)?
163 .to_string(),
164 };
165
166 if let Ok(target) = RemoteTarget::parse(&spec) {
167 let key = credential_key_from_url(&spec);
168 return Ok((target, key));
169 }
170
171 let remote = cfg.get(&spec)?;
172 if let Ok(target) = RemoteTarget::parse(&remote.url) {
173 let key = credential_key_from_url(&remote.url);
174 return Ok((target, key));
175 }
176
177 Err(RemoteError::InvalidUrl(remote.url))
178}
179
180pub fn credential_key_from_remote_url(url: &str) -> Option<String> {
184 credential_key_from_url(url)
185}
186
187fn credential_key_from_url(url: &str) -> Option<String> {
189 let rest = url
191 .strip_prefix("heddle://")
192 .or_else(|| url.strip_prefix("https://"))
193 .or_else(|| url.strip_prefix("http://"))
194 .unwrap_or(url);
195
196 if rest.starts_with('/') || url.starts_with("file://") {
198 return None;
199 }
200
201 let host_part = rest.split('/').next().unwrap_or(rest);
203 if host_part.is_empty() {
204 return None;
205 }
206 Some(host_part.to_string())
207}
208
209#[cfg(test)]
210mod tests {
211 use std::{
212 fs,
213 path::PathBuf,
214 time::{SystemTime, UNIX_EPOCH},
215 };
216
217 use repo::Repository;
218
219 use super::*;
220
221 fn unique_temp_dir(prefix: &str) -> PathBuf {
222 let unique = SystemTime::now()
223 .duration_since(UNIX_EPOCH)
224 .expect("system time before unix epoch")
225 .as_nanos();
226 std::env::temp_dir().join(format!("{prefix}-{unique}-{}", std::process::id()))
227 }
228
229 #[test]
230 fn remote_config_save_uses_atomic_write_and_persists() {
231 static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
234 let _guard = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
235 let temp = unique_temp_dir("heddle-remote-test");
236 fs::create_dir_all(&temp).expect("create temp dir");
237 let repo = Repository::init_default(&temp).expect("init repo");
238
239 {
240 let mut cfg = RemoteConfig::open(&repo).expect("open config");
241 cfg.add(
242 "origin",
243 Remote {
244 url: "http://heddle.example:8421/repo".to_string(),
245 },
246 )
247 .expect("add remote");
248 }
249
250 let path = repo.heddle_dir().join("remotes.toml");
251 assert!(path.exists(), "expected remotes file to exist");
252
253 let contents = fs::read_to_string(&path).expect("read remotes file");
254 assert!(contents.contains("origin"));
255 assert!(contents.contains("heddle.example:8421"));
256
257 let reopened = RemoteConfig::open(&repo).expect("reopen config");
258 let remote = reopened.get("origin").expect("load remote");
259 assert_eq!(remote.url, "http://heddle.example:8421/repo");
260
261 let _ = fs::remove_dir_all(temp);
262 }
263}