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