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("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
142/// Resolve a remote argument (name or URL) into a concrete target.
143pub fn resolve_remote(repo: &Repository, remote_arg: Option<&str>) -> Result<RemoteTarget> {
144    Ok(resolve_remote_with_key(repo, remote_arg)?.0)
145}
146
147/// Resolve a remote argument (name or URL) into a concrete target, also
148/// returning the raw URL string that can be used as a credential store key.
149pub 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
177/// Extract the hostname (credential store key) from a remote URL string.
178///
179/// Returns `None` for local paths (file:// or bare paths).
180pub fn credential_key_from_remote_url(url: &str) -> Option<String> {
181    credential_key_from_url(url)
182}
183
184/// Internal implementation of credential key extraction.
185fn credential_key_from_url(url: &str) -> Option<String> {
186    // Strip known scheme prefixes.
187    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    // Skip local paths.
194    if rest.starts_with('/') || url.starts_with("file://") {
195        return None;
196    }
197
198    // The credential key is the host part (before the first '/').
199    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        // Mutex serializes env-var access across this crate's tests so
229        // parallel runs don't observe each other's writes.
230        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}