1use std::path::{Path, PathBuf};
2use std::fs;
3use serde::{Deserialize, Serialize};
4use crate::error::{Result, ToriiError};
5use crate::core::GitRepo;
6use crate::duration::format_duration;
7use dirs;
8
9#[derive(Debug, Serialize, Clone, PartialEq)]
10pub enum MirrorType {
11 Primary,
12 Replica,
13}
14
15impl<'de> serde::Deserialize<'de> for MirrorType {
16 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
17 let s = String::deserialize(d)?;
18 match s.as_str() {
19 "Primary" | "Master" => Ok(MirrorType::Primary),
20 "Replica" | "Slave" => Ok(MirrorType::Replica),
21 other => Err(serde::de::Error::unknown_variant(other, &["Primary", "Replica"])),
22 }
23 }
24}
25
26#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
27pub enum AccountType {
28 User,
29 Organization,
30}
31
32#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
33pub enum Protocol {
34 SSH,
35 HTTPS,
36}
37
38#[derive(Debug, Serialize, Deserialize, Clone)]
39pub struct Mirror {
40 pub name: String,
41 pub platform: String,
42 pub account_type: AccountType,
43 pub account_name: String,
44 pub repo_name: String,
45 pub url: String,
46 pub protocol: Protocol,
47 pub mirror_type: MirrorType,
48 pub enabled: bool,
49}
50
51#[derive(Debug, Serialize, Deserialize)]
52struct MirrorConfig {
53 mirrors: Vec<Mirror>,
54 #[serde(default)]
55 autofetch_enabled: bool,
56 #[serde(default = "default_autofetch_interval")]
57 autofetch_interval_minutes: u32,
58}
59
60fn default_autofetch_interval() -> u32 {
61 30 }
63
64impl Mirror {
65 pub fn generate_url(
67 platform: &str,
68 _account_type: &AccountType,
69 account_name: &str,
70 repo_name: &str,
71 protocol: &Protocol,
72 ) -> String {
73 match protocol {
74 Protocol::SSH => {
75 match platform.to_lowercase().as_str() {
76 "github" => format!("git@github.com:{}/{}.git", account_name, repo_name),
77 "gitlab" => format!("git@gitlab.com:{}/{}.git", account_name, repo_name),
78 "bitbucket" => format!("git@bitbucket.org:{}/{}.git", account_name, repo_name),
79 "codeberg" => format!("git@codeberg.org:{}/{}.git", account_name, repo_name),
80 "gitea" => format!("git@gitea.com:{}/{}.git", account_name, repo_name),
81 "forgejo" => format!("git@codeberg.org:{}/{}.git", account_name, repo_name),
82 "sourcehut" | "srht" => format!("git@git.sr.ht:~{}/{}", account_name, repo_name),
83 "sourceforge" => format!("git@git.code.sf.net:p/{}/{}", account_name, repo_name),
84 _ => format!("git@{}:{}/{}.git", platform, account_name, repo_name),
85 }
86 }
87 Protocol::HTTPS => {
88 match platform.to_lowercase().as_str() {
89 "github" => format!("https://github.com/{}/{}.git", account_name, repo_name),
90 "gitlab" => format!("https://gitlab.com/{}/{}.git", account_name, repo_name),
91 "bitbucket" => format!("https://bitbucket.org/{}/{}.git", account_name, repo_name),
92 "codeberg" => format!("https://codeberg.org/{}/{}.git", account_name, repo_name),
93 "gitea" => format!("https://gitea.com/{}/{}.git", account_name, repo_name),
94 "forgejo" => format!("https://codeberg.org/{}/{}.git", account_name, repo_name),
95 "sourcehut" | "srht" => format!("https://git.sr.ht/~{}/{}", account_name, repo_name),
96 "sourceforge" => format!("https://git.code.sf.net/p/{}/{}", account_name, repo_name),
97 _ => format!("https://{}/{}/{}.git", platform, account_name, repo_name),
98 }
99 }
100 }
101 }
102
103 #[allow(dead_code)]
105 pub fn display_name(&self) -> String {
106 format!("{}/{}", self.account_name, self.repo_name)
107 }
108}
109
110pub struct MirrorManager {
111 repo_path: PathBuf,
112 config_path: PathBuf,
113}
114
115impl MirrorManager {
116 pub fn new<P: AsRef<Path>>(repo_path: P) -> Result<Self> {
117 let repo_path = repo_path.as_ref().to_path_buf();
118 let torii_dir = repo_path.join(".torii");
119 fs::create_dir_all(&torii_dir)?;
120
121 let config_path = torii_dir.join("mirrors.json");
122
123 Ok(Self {
124 repo_path,
125 config_path,
126 })
127 }
128
129 fn load_config(&self) -> Result<MirrorConfig> {
131 if !self.config_path.exists() {
132 return Ok(MirrorConfig {
133 mirrors: vec![],
134 autofetch_enabled: false,
135 autofetch_interval_minutes: 30,
136 });
137 }
138
139 let config_str = fs::read_to_string(&self.config_path)?;
140 let config: MirrorConfig = serde_json::from_str(&config_str)?;
141
142 Ok(config)
143 }
144
145 fn save_config(&self, config: &MirrorConfig) -> Result<()> {
147 let config_str = serde_json::to_string_pretty(config)?;
148 fs::write(&self.config_path, config_str)?;
149 Ok(())
150 }
151
152 pub fn add_mirror(
154 &self,
155 platform: &str,
156 account_type: AccountType,
157 account_name: &str,
158 repo_name: &str,
159 protocol: Protocol,
160 is_primary: bool,
161 ) -> Result<()> {
162 let mut config = self.load_config()?;
163
164 if is_primary && config.mirrors.iter().any(|m| m.mirror_type == MirrorType::Primary) {
166 return Err(ToriiError::Mirror(
167 "A primary mirror already exists. Use 'torii mirror promote' to change it.".to_string()
168 ));
169 }
170
171 let url = Mirror::generate_url(platform, &account_type, account_name, repo_name, &protocol);
173
174 let remote_name = if is_primary {
176 "origin".to_string()
177 } else {
178 format!("{}-{}", platform, account_name)
179 };
180
181 let mirror = Mirror {
182 name: remote_name.clone(),
183 platform: platform.to_string(),
184 account_type,
185 account_name: account_name.to_string(),
186 repo_name: repo_name.to_string(),
187 url: url.clone(),
188 protocol,
189 mirror_type: if is_primary { MirrorType::Primary } else { MirrorType::Replica },
190 enabled: true,
191 };
192
193 let repo = GitRepo::open(&self.repo_path)?;
198 self.add_git_remote(&repo, &remote_name, &url)?;
199
200 config.mirrors.push(mirror);
201 if let Err(e) = self.save_config(&config) {
202 let _ = repo.repository().remote_delete(&remote_name);
203 return Err(e);
204 }
205
206 Ok(())
207 }
208
209 pub fn set_primary(&self, platform: &str, account_name: &str) -> Result<()> {
211 let mut config = self.load_config()?;
212
213 let mirror_index = config.mirrors.iter().position(|m| {
215 m.platform == platform && m.account_name == account_name
216 }).ok_or_else(|| ToriiError::Mirror("Mirror not found".to_string()))?;
217
218 for mirror in &mut config.mirrors {
220 mirror.mirror_type = MirrorType::Replica;
221 }
222
223 config.mirrors[mirror_index].mirror_type = MirrorType::Primary;
225
226 self.save_config(&config)?;
227 Ok(())
228 }
229
230 fn add_git_remote(&self, repo: &GitRepo, name: &str, url: &str) -> Result<()> {
232 repo.repository().remote(name, url)?;
233 Ok(())
234 }
235
236 pub fn list_mirrors(&self) -> Result<()> {
238 let config = self.load_config()?;
239
240 if config.mirrors.is_empty() {
241 println!("No mirrors configured");
242 println!();
243 println!("๐ก Add a primary mirror first:");
244 println!(" torii mirror add <platform> <user|org> <account> <repo> --primary");
245 return Ok(());
246 }
247
248 println!("๐ช Configured Mirrors:");
249 println!();
250
251 for mirror in config.mirrors.iter().filter(|m| m.mirror_type == MirrorType::Primary) {
253 let status = if mirror.enabled { "โ
" } else { "โ" };
254 let account_type = match mirror.account_type {
255 AccountType::User => "๐ค",
256 AccountType::Organization => "๐ข",
257 };
258 let protocol_icon = match mirror.protocol {
259 Protocol::SSH => "๐",
260 Protocol::HTTPS => "๐",
261 };
262 println!(" {} ๐ PRIMARY - {} {} {} {}/{}",
263 status,
264 protocol_icon,
265 account_type,
266 mirror.platform,
267 mirror.account_name,
268 mirror.repo_name
269 );
270 println!(" {}", mirror.url);
271 println!();
272 }
273
274 let replicas: Vec<_> = config.mirrors.iter()
276 .filter(|m| m.mirror_type == MirrorType::Replica)
277 .collect();
278
279 if !replicas.is_empty() {
280 println!(" Replica Mirrors:");
281 for mirror in replicas {
282 let status = if mirror.enabled { "โ
" } else { "โ" };
283 let account_type = match mirror.account_type {
284 AccountType::User => "๐ค",
285 AccountType::Organization => "๐ข",
286 };
287 let protocol_icon = match mirror.protocol {
288 Protocol::SSH => "๐",
289 Protocol::HTTPS => "๐",
290 };
291 println!(" {} {} {} {} {}/{}",
292 status,
293 protocol_icon,
294 account_type,
295 mirror.platform,
296 mirror.account_name,
297 mirror.repo_name
298 );
299 println!(" {}", mirror.url);
300 }
301 }
302
303 Ok(())
304 }
305
306 pub fn sync_replicas_if_any(&self, force: bool) -> Result<()> {
308 let config = self.load_config()?;
309 let replicas: Vec<_> = config.mirrors.iter()
310 .filter(|m| m.mirror_type == MirrorType::Replica && m.enabled)
311 .collect();
312 if replicas.is_empty() {
313 return Ok(());
314 }
315 let repo = GitRepo::open(&self.repo_path)?;
316 let mut failed = vec![];
317 for mirror in &replicas {
318 if let Err(e) = self.sync_to_mirror(&repo, mirror, force) {
319 failed.push(format!("{}/{}: {}", mirror.platform, mirror.account_name, e));
320 }
321 }
322 let ok = replicas.len() - failed.len();
323 if ok > 0 {
324 println!("๐ช Mirrors synced: {}/{}", ok, replicas.len());
325 }
326 for f in &failed {
327 eprintln!("โ ๏ธ Mirror sync failed: {}", f);
328 }
329 Ok(())
330 }
331
332 pub fn sync_all(&self, force: bool) -> Result<()> {
334 let config = self.load_config()?;
335 let repo = GitRepo::open(&self.repo_path)?;
336
337 let primary = config.mirrors.iter()
339 .find(|m| m.mirror_type == MirrorType::Primary);
340
341 if primary.is_none() {
342 println!("โ ๏ธ No primary mirror configured. Add one with:");
343 println!(" torii mirror add-primary <platform> <user|org> <account> <repo>");
344 return Ok(());
345 }
346
347 let replicas: Vec<_> = config.mirrors.iter()
349 .filter(|m| m.mirror_type == MirrorType::Replica && m.enabled)
350 .collect();
351
352 if replicas.is_empty() {
353 println!("โน๏ธ No replica mirrors configured. Add one with:");
354 println!(" torii mirror add-replica <platform> <user|org> <account> <repo>");
355 return Ok(());
356 }
357
358 println!("๐ค Syncing from primary to {} replica mirror(s)...\n", replicas.len());
359
360 let mut success_count = 0;
361 let mut fail_count = 0;
362
363 for mirror in replicas {
364 println!("๐ Syncing to {} {}/{} ...",
365 mirror.platform,
366 mirror.account_name,
367 mirror.repo_name
368 );
369
370 match self.sync_to_mirror(&repo, mirror, force) {
371 Ok(_) => {
372 println!(" โ
Synced successfully\n");
373 success_count += 1;
374 }
375 Err(e) => {
376 eprintln!(" โ Failed: {}\n", e);
377 fail_count += 1;
378 if !force {
379 return Err(e);
380 }
381 }
382 }
383 }
384
385 println!("๐ Summary: {} succeeded, {} failed", success_count, fail_count);
386 Ok(())
387 }
388
389 fn sync_to_mirror(&self, repo: &GitRepo, mirror: &Mirror, force: bool) -> Result<()> {
391 let mut remote = repo.repository().find_remote(&mirror.name)?;
392 let branch = repo.get_current_branch()?;
393
394 let refspec = if force {
395 format!("+refs/heads/{}:refs/heads/{}", branch, branch)
396 } else {
397 format!("refs/heads/{}:refs/heads/{}", branch, branch)
398 };
399
400 let mut callbacks = git2::RemoteCallbacks::new();
402 callbacks.credentials(|_url, username_from_url, _allowed_types| {
403 let username = username_from_url.unwrap_or("git");
404 let home = dirs::home_dir().unwrap_or_default();
405 let ed25519 = home.join(".ssh").join("id_ed25519");
406 let rsa = home.join(".ssh").join("id_rsa");
407 if ed25519.exists() {
408 git2::Cred::ssh_key(username, None, &ed25519, None)
409 } else if rsa.exists() {
410 git2::Cred::ssh_key(username, None, &rsa, None)
411 } else {
412 git2::Cred::ssh_key_from_agent(username)
413 }
414 });
415
416 let mut push_options = git2::PushOptions::new();
417 push_options.remote_callbacks(callbacks);
418
419 remote.push(&[&refspec], Some(&mut push_options))?;
420
421 let tags = repo.repository().tag_names(None)?;
428 if !tags.is_empty() {
429 let local: std::collections::HashMap<String, git2::Oid> = tags.iter()
430 .flatten()
431 .filter_map(|t| {
432 let refname = format!("refs/tags/{}", t);
433 repo.repository().refname_to_id(&refname).ok().map(|oid| (t.to_string(), oid))
434 })
435 .collect();
436
437 let mut tag_remote = repo.repository().find_remote(&mirror.name)?;
438
439 let make_ssh_callbacks = || {
440 let mut cb = git2::RemoteCallbacks::new();
441 cb.credentials(|_url, username_from_url, _allowed_types| {
442 let username = username_from_url.unwrap_or("git");
443 let home = dirs::home_dir().unwrap_or_default();
444 let ed25519 = home.join(".ssh").join("id_ed25519");
445 let rsa = home.join(".ssh").join("id_rsa");
446 if ed25519.exists() {
447 git2::Cred::ssh_key(username, None, &ed25519, None)
448 } else if rsa.exists() {
449 git2::Cred::ssh_key(username, None, &rsa, None)
450 } else {
451 git2::Cred::ssh_key_from_agent(username)
452 }
453 });
454 cb
455 };
456
457 let remote_tags: std::collections::HashMap<String, git2::Oid> = {
459 let cb = make_ssh_callbacks();
460 tag_remote.connect_auth(git2::Direction::Fetch, Some(cb), None)?;
461 let list = tag_remote.list()?;
462 let map = list.iter()
463 .filter_map(|h| {
464 h.name().strip_prefix("refs/tags/")
465 .filter(|n| !n.ends_with("^{}"))
466 .map(|n| (n.to_string(), h.oid()))
467 })
468 .collect::<std::collections::HashMap<_, _>>();
469 let _ = tag_remote.disconnect();
470 map
471 };
472
473 let refspecs: Vec<String> = local.iter()
474 .filter(|(name, oid)| remote_tags.get(*name) != Some(oid))
475 .map(|(t, _)| {
476 let r = format!("refs/tags/{}:refs/tags/{}", t, t);
477 if force { format!("+{}", r) } else { r }
478 })
479 .collect();
480
481 if !refspecs.is_empty() {
482 let refspec_refs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect();
483 let mut tag_push_opts = git2::PushOptions::new();
484 tag_push_opts.remote_callbacks(make_ssh_callbacks());
485 let _ = tag_remote.push(&refspec_refs, Some(&mut tag_push_opts));
486 }
487 }
488
489 Ok(())
490 }
491
492 pub fn remove_mirror_by_account(&self, platform: &str, account: &str) -> Result<()> {
494 let mut config = self.load_config()?;
495
496 let mirror = config.mirrors.iter()
497 .find(|m| m.platform == platform && m.account_name == account)
498 .ok_or_else(|| ToriiError::Mirror("Mirror not found".to_string()))?;
499
500 let remote_name = mirror.name.clone();
501
502 config.mirrors.retain(|m| !(m.platform == platform && m.account_name == account));
503 self.save_config(&config)?;
504
505 let repo = GitRepo::open(&self.repo_path)?;
506 repo.repository().remote_delete(&remote_name)?;
507
508 Ok(())
509 }
510
511 #[allow(dead_code)]
513 pub fn remove_mirror(&self, name: &str) -> Result<()> {
514 let mut config = self.load_config()?;
515
516 config.mirrors.retain(|m| m.name != name);
517 self.save_config(&config)?;
518
519 let repo = GitRepo::open(&self.repo_path)?;
520 repo.repository().remote_delete(name)?;
521
522 Ok(())
523 }
524
525 pub fn configure_autofetch(&self, enable: bool, interval: Option<u32>) -> Result<()> {
527 let mut config = self.load_config()?;
528
529 config.autofetch_enabled = enable;
530 if let Some(interval_minutes) = interval {
531 config.autofetch_interval_minutes = interval_minutes;
532 }
533
534 self.save_config(&config)?;
535
536 if enable {
537 let duration_str = format_duration(config.autofetch_interval_minutes);
538 println!("โ
Autofetch enabled: every {}", duration_str);
539 println!("๐ก Torii will automatically fetch updates from all mirrors");
540 } else {
541 println!("โ Autofetch disabled");
542 }
543
544 Ok(())
545 }
546
547 pub fn show_autofetch_status(&self) -> Result<()> {
549 let config = self.load_config()?;
550
551 println!("๐ Autofetch Configuration:");
552 println!();
553
554 if config.autofetch_enabled {
555 let duration_str = format_duration(config.autofetch_interval_minutes);
556 println!(" Status: โ
Enabled");
557 println!(" Interval: {}", duration_str);
558 println!();
559 println!("๐ก Torii will automatically fetch from all mirrors every {}", duration_str);
560 } else {
561 println!(" Status: โ Disabled");
562 println!();
563 println!("๐ก Enable with:");
564 println!(" torii mirror autofetch --enable --interval <duration>");
565 println!(" Examples: 10m, 30s, 2h, 1d");
566 }
567
568 Ok(())
569 }
570}