Skip to main content

indra_db/remote/
mod.rs

1//! Remote repository management
2//!
3//! Handles configuration and synchronization with remote IndraNet repositories.
4
5mod credentials;
6mod sync;
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::Path;
11
12#[cfg(feature = "sync")]
13pub use credentials::refresh_access_token;
14pub use credentials::{CredentialStore, Credentials, UserInfo};
15pub use sync::{
16    Auth, PullResult, PushResponse, RemoteStatus, SyncClient, SyncConfig, SyncState,
17    DEFAULT_API_URL,
18};
19
20/// A remote repository configuration
21#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct Remote {
23    /// Name of the remote (e.g., "origin")
24    pub name: String,
25    /// URL of the remote (e.g., "username/repo" or "https://indradb.net/username/repo")
26    pub url: String,
27    /// Last known HEAD commit hash on the remote
28    #[serde(default)]
29    pub last_known_head: Option<String>,
30    /// When we last synced with this remote
31    #[serde(default)]
32    pub last_sync: Option<u64>,
33}
34
35impl Remote {
36    pub fn new(name: impl Into<String>, url: impl Into<String>) -> Self {
37        Remote {
38            name: name.into(),
39            url: url.into(),
40            last_known_head: None,
41            last_sync: None,
42        }
43    }
44
45    /// Parse the URL to extract owner and repo name
46    pub fn parse_url(&self) -> Option<(String, String)> {
47        let url = self.url.trim();
48
49        // Handle full URLs: https://indradb.net/username/repo
50        if url.starts_with("https://") || url.starts_with("http://") {
51            let path = url
52                .trim_start_matches("https://")
53                .trim_start_matches("http://")
54                .trim_start_matches("indradb.net/")
55                .trim_start_matches("api.indradb.net/")
56                // Legacy domain support
57                .trim_start_matches("indra.net/")
58                .trim_start_matches("api.indra.net/")
59                .trim_start_matches("indra.dev/")
60                .trim_start_matches("api.indra.dev/");
61            return Self::parse_path(path);
62        }
63
64        // Handle short form: username/repo
65        Self::parse_path(url)
66    }
67
68    fn parse_path(path: &str) -> Option<(String, String)> {
69        let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
70        if parts.len() >= 2 {
71            Some((parts[0].to_string(), parts[1].to_string()))
72        } else {
73            None
74        }
75    }
76
77    /// Get the full API URL for this remote
78    pub fn api_url(&self, base_url: &str) -> String {
79        if let Some((owner, repo)) = self.parse_url() {
80            format!(
81                "{}/bases/{}/{}",
82                base_url.trim_end_matches('/'),
83                owner,
84                repo
85            )
86        } else {
87            // Assume it's already a full URL
88            self.url.clone()
89        }
90    }
91}
92
93/// Configuration for all remotes, stored alongside the database
94#[derive(Clone, Debug, Default, Serialize, Deserialize)]
95pub struct RemoteConfig {
96    /// Map of remote name to remote configuration
97    pub remotes: HashMap<String, Remote>,
98    /// Default remote for push/pull operations
99    #[serde(default)]
100    pub default_remote: Option<String>,
101}
102
103impl RemoteConfig {
104    /// Load remote config from a file path
105    pub fn load(db_path: &Path) -> crate::Result<Self> {
106        let config_path = Self::config_path(db_path);
107        if config_path.exists() {
108            let content = std::fs::read_to_string(&config_path)?;
109            Ok(serde_json::from_str(&content)?)
110        } else {
111            Ok(Self::default())
112        }
113    }
114
115    /// Save remote config to a file path
116    pub fn save(&self, db_path: &Path) -> crate::Result<()> {
117        let config_path = Self::config_path(db_path);
118        let content = serde_json::to_string_pretty(self)?;
119        std::fs::write(config_path, content)?;
120        Ok(())
121    }
122
123    /// Get the config file path for a database
124    fn config_path(db_path: &Path) -> std::path::PathBuf {
125        let mut config_path = db_path.to_path_buf();
126        let file_name = db_path
127            .file_name()
128            .map(|s| s.to_string_lossy().to_string())
129            .unwrap_or_else(|| ".indra".to_string());
130        config_path.set_file_name(format!("{}.remotes", file_name));
131        config_path
132    }
133
134    /// Add a new remote
135    pub fn add(&mut self, name: impl Into<String>, url: impl Into<String>) -> crate::Result<()> {
136        let name = name.into();
137        if self.remotes.contains_key(&name) {
138            return Err(crate::Error::Remote(format!(
139                "Remote '{}' already exists",
140                name
141            )));
142        }
143        let remote = Remote::new(name.clone(), url);
144        self.remotes.insert(name.clone(), remote);
145
146        // Set as default if it's the first remote
147        if self.default_remote.is_none() {
148            self.default_remote = Some(name);
149        }
150
151        Ok(())
152    }
153
154    /// Remove a remote
155    pub fn remove(&mut self, name: &str) -> crate::Result<()> {
156        if self.remotes.remove(name).is_none() {
157            return Err(crate::Error::Remote(format!("Remote '{}' not found", name)));
158        }
159
160        // Clear default if we removed it
161        if self.default_remote.as_deref() == Some(name) {
162            self.default_remote = self.remotes.keys().next().cloned();
163        }
164
165        Ok(())
166    }
167
168    /// Get a remote by name
169    pub fn get(&self, name: &str) -> Option<&Remote> {
170        self.remotes.get(name)
171    }
172
173    /// Get a mutable remote by name
174    pub fn get_mut(&mut self, name: &str) -> Option<&mut Remote> {
175        self.remotes.get_mut(name)
176    }
177
178    /// Set the URL for an existing remote
179    pub fn set_url(&mut self, name: &str, url: impl Into<String>) -> crate::Result<()> {
180        let remote = self
181            .remotes
182            .get_mut(name)
183            .ok_or_else(|| crate::Error::Remote(format!("Remote '{}' not found", name)))?;
184        remote.url = url.into();
185        Ok(())
186    }
187
188    /// List all remotes
189    pub fn list(&self) -> Vec<&Remote> {
190        self.remotes.values().collect()
191    }
192
193    /// Set the default remote
194    pub fn set_default(&mut self, name: &str) {
195        if self.remotes.contains_key(name) {
196            self.default_remote = Some(name.to_string());
197        }
198    }
199
200    /// Update the last_sync timestamp for a remote
201    pub fn update_last_sync(&mut self, name: &str) -> crate::Result<()> {
202        let remote = self
203            .remotes
204            .get_mut(name)
205            .ok_or_else(|| crate::Error::Remote(format!("Remote '{}' not found", name)))?;
206
207        let now = std::time::SystemTime::now()
208            .duration_since(std::time::UNIX_EPOCH)
209            .map(|d| d.as_secs())
210            .unwrap_or(0);
211
212        remote.last_sync = Some(now);
213        Ok(())
214    }
215
216    /// Update the last known HEAD hash for a remote
217    pub fn update_last_known_head(&mut self, name: &str, hash: &str) -> crate::Result<()> {
218        let remote = self
219            .remotes
220            .get_mut(name)
221            .ok_or_else(|| crate::Error::Remote(format!("Remote '{}' not found", name)))?;
222
223        remote.last_known_head = Some(hash.to_string());
224        Ok(())
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_remote_url_parsing() {
234        let remote = Remote::new("origin", "username/repo");
235        assert_eq!(
236            remote.parse_url(),
237            Some(("username".to_string(), "repo".to_string()))
238        );
239
240        let remote = Remote::new("origin", "https://indradb.net/username/repo");
241        assert_eq!(
242            remote.parse_url(),
243            Some(("username".to_string(), "repo".to_string()))
244        );
245
246        let remote = Remote::new("origin", "https://api.indradb.net/username/repo");
247        assert_eq!(
248            remote.parse_url(),
249            Some(("username".to_string(), "repo".to_string()))
250        );
251
252        // Legacy domains should still work
253        let remote = Remote::new("origin", "https://indra.dev/username/repo");
254        assert_eq!(
255            remote.parse_url(),
256            Some(("username".to_string(), "repo".to_string()))
257        );
258    }
259
260    #[test]
261    fn test_remote_config() {
262        let mut config = RemoteConfig::default();
263
264        config.add("origin", "user/repo").unwrap();
265        assert_eq!(config.default_remote, Some("origin".to_string()));
266
267        config.add("upstream", "other/repo").unwrap();
268        assert!(config.get("upstream").is_some());
269
270        config.set_url("origin", "newuser/newrepo").unwrap();
271        assert_eq!(config.get("origin").unwrap().url, "newuser/newrepo");
272
273        config.remove("origin").unwrap();
274        assert!(config.get("origin").is_none());
275        assert_eq!(config.default_remote, Some("upstream".to_string()));
276    }
277}