Skip to main content

cli_shared/remote/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Remote configuration management.
3//!
4//! Remote aliases remain repository-scoped and live in `.heddle/remotes.toml`.
5
6mod 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
145/// Resolve a remote argument (name or URL) into a concrete target.
146pub fn resolve_remote(repo: &Repository, remote_arg: Option<&str>) -> Result<RemoteTarget> {
147    Ok(resolve_remote_with_key(repo, remote_arg)?.0)
148}
149
150/// Resolve a remote argument (name or URL) into a concrete target, also
151/// returning the raw URL string that can be used as a credential store key.
152pub 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
180/// Extract the hostname (credential store key) from a remote URL string.
181///
182/// Returns `None` for local paths (file:// or bare paths).
183pub fn credential_key_from_remote_url(url: &str) -> Option<String> {
184    credential_key_from_url(url)
185}
186
187/// Internal implementation of credential key extraction.
188fn credential_key_from_url(url: &str) -> Option<String> {
189    // Strip known scheme prefixes.
190    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    // Skip local paths.
197    if rest.starts_with('/') || url.starts_with("file://") {
198        return None;
199    }
200
201    // The credential key is the host part (before the first '/').
202    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        // Mutex serializes env-var access across this crate's tests so
232        // parallel runs don't observe each other's writes.
233        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}