1mod 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#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct Remote {
23 pub name: String,
25 pub url: String,
27 #[serde(default)]
29 pub last_known_head: Option<String>,
30 #[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 pub fn parse_url(&self) -> Option<(String, String)> {
47 let url = self.url.trim();
48
49 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 .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 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 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 self.url.clone()
89 }
90 }
91}
92
93#[derive(Clone, Debug, Default, Serialize, Deserialize)]
95pub struct RemoteConfig {
96 pub remotes: HashMap<String, Remote>,
98 #[serde(default)]
100 pub default_remote: Option<String>,
101}
102
103impl RemoteConfig {
104 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 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 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 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 if self.default_remote.is_none() {
148 self.default_remote = Some(name);
149 }
150
151 Ok(())
152 }
153
154 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 if self.default_remote.as_deref() == Some(name) {
162 self.default_remote = self.remotes.keys().next().cloned();
163 }
164
165 Ok(())
166 }
167
168 pub fn get(&self, name: &str) -> Option<&Remote> {
170 self.remotes.get(name)
171 }
172
173 pub fn get_mut(&mut self, name: &str) -> Option<&mut Remote> {
175 self.remotes.get_mut(name)
176 }
177
178 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 pub fn list(&self) -> Vec<&Remote> {
190 self.remotes.values().collect()
191 }
192
193 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 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 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 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}