1use std::collections::HashSet;
3use std::{fmt::Debug, sync::Arc};
4
5use serde::{Deserialize, Serialize};
6use std::process::Stdio;
7use tokio::join;
8use tokio::process::Command;
9use tokio::time::{timeout, Duration};
10
11use crate::errors::GitMoverError;
12use crate::platform::{Platform, PlatformType};
13use crate::sync::{delete_repos, sync_repos};
14use crate::{
15 codeberg::config::CodebergConfig, config::GitMoverConfig, github::config::GithubConfig,
16 gitlab::config::GitlabConfig,
17};
18
19#[derive(Deserialize, Serialize, Debug, Default, PartialEq, Eq, Hash, Clone)]
21pub struct Repo {
22 pub name: String,
24
25 pub path: String,
27
28 pub description: String,
30
31 pub private: bool,
33
34 pub fork: bool,
36}
37
38impl Repo {
39 pub fn show_full_name(&self) -> String {
41 let fmt_path = if self.name != self.path {
42 format!(" at path '{}'", self.path)
43 } else {
44 "".into()
45 };
46 format!("{}{}", self.name, fmt_path)
47 }
48}
49
50pub enum Direction {
52 Source,
54 Destination,
56}
57
58pub fn input_number() -> Result<usize, GitMoverError> {
60 loop {
61 match input()?.parse::<usize>() {
62 Ok(i) => return Ok(i),
63 Err(_) => {
64 println!("Invalid input");
65 }
66 }
67 }
68}
69
70pub(crate) async fn check_ssh_access<S: AsRef<str>>(
72 ssh_url: S,
73) -> Result<(String, String), GitMoverError> {
74 let ssh_url = ssh_url.as_ref();
75 let result = timeout(Duration::from_secs(5), async {
76 Command::new("ssh")
77 .arg("-T")
78 .arg(ssh_url)
79 .stdin(Stdio::null())
80 .stdout(Stdio::piped())
81 .stderr(Stdio::piped())
82 .output()
83 .await
84 })
85 .await;
86
87 match result {
88 Ok(Ok(output)) => {
89 let stdout_str = str::from_utf8(&output.stdout)?.to_string();
90 let stderr_str = str::from_utf8(&output.stderr)?.to_string();
91 Ok((stdout_str, stderr_str))
92 }
93 Ok(Err(e)) => Err(e.into()),
94 Err(e) => Err(e.into()),
95 }
96}
97
98pub(crate) fn get_plateform(
100 config: &mut GitMoverConfig,
101 direction: Direction,
102) -> Result<Box<dyn Platform>, GitMoverError> {
103 let plateform_from_cli: Option<PlatformType> = match direction {
104 Direction::Source => config.cli_args.source.clone(),
105 Direction::Destination => config.cli_args.destination.clone(),
106 };
107 let chosen_platform = match plateform_from_cli {
108 Some(platform) => platform,
109 None => {
110 println!(
111 "Choose a platform {}",
112 match direction {
113 Direction::Source => "for source",
114 Direction::Destination => "for destination",
115 }
116 );
117 let platforms = [
118 PlatformType::Github,
119 PlatformType::Gitlab,
120 PlatformType::Codeberg,
121 ];
122 for (i, platform) in platforms.iter().enumerate() {
123 println!("{i}: {platform}");
124 }
125 let plateform = loop {
126 let plateform = input_number()?;
127 if platforms.get(plateform).is_none() {
128 println!("Wrong number");
129 continue;
130 } else {
131 break plateform;
132 }
133 };
134 platforms[plateform].clone()
135 }
136 };
137 let plateform: Box<dyn Platform> = match chosen_platform {
138 PlatformType::Gitlab => Box::new(GitlabConfig::get_plateform(config)?),
139 PlatformType::Github => Box::new(GithubConfig::get_plateform(config)?),
140 PlatformType::Codeberg => Box::new(CodebergConfig::get_plateform(config)?),
141 };
142 Ok(plateform)
143}
144
145pub async fn main_sync(config: GitMoverConfig) -> Result<(), GitMoverError> {
149 let mut config = config;
150 let source_platform = get_plateform(&mut config, Direction::Source)?;
151 println!("Chosen {} as source", source_platform.get_remote_url());
152
153 let destination_platform = get_plateform(&mut config, Direction::Destination)?;
154 println!(
155 "Chosen {} as destination",
156 destination_platform.get_remote_url()
157 );
158 if source_platform.get_remote_url() == destination_platform.get_remote_url() {
159 return Err("Source and destination can't be the same".into());
160 }
161 println!("Checking the git access for each plateform");
162 let (acc, acc2) = join!(
163 source_platform.check_git_access(),
164 destination_platform.check_git_access()
165 );
166 match acc {
167 Ok(_) => {
168 println!("Checked access to {}", source_platform.get_remote_url());
169 }
170 Err(e) => return Err(e),
171 }
172 match acc2 {
173 Ok(_) => {
174 println!(
175 "Checked access to {}",
176 destination_platform.get_remote_url()
177 );
178 }
179 Err(e) => return Err(e),
180 }
181 let source_platform = Arc::new(source_platform);
182 let destination_platform = Arc::new(destination_platform);
183 let (repos_source, repos_destination) = join!(
184 source_platform.get_all_repos(),
185 destination_platform.get_all_repos()
186 );
187
188 let repos_source = match repos_source {
189 Ok(repos) => repos,
190 Err(e) => {
191 return Err(format!("Error getting repositories for source: {e}").into());
192 }
193 };
194
195 let repos_destination = match repos_destination {
196 Ok(repos) => repos,
197 Err(e) => {
198 return Err(format!("Error getting repositories for destination: {e}").into());
199 }
200 };
201
202 let repos_source_without_fork = repos_source
203 .clone()
204 .into_iter()
205 .filter(|repo| !repo.fork)
206 .collect::<Vec<_>>();
207 let repos_source_forks = repos_source
208 .clone()
209 .into_iter()
210 .filter(|repo| repo.fork)
211 .collect::<Vec<_>>();
212 println!("Number of repos in source: {}", repos_source.len());
213 println!(
214 "- Number of forked repos in source: {}",
215 repos_source_forks.len()
216 );
217 println!(
218 "- Number of (non-forked) repos in source: {}",
219 repos_source_without_fork.len()
220 );
221 println!(
222 "Number of repos in destination: {}",
223 repos_destination.len()
224 );
225 let cloned_repos_source_without_fork = repos_source_without_fork.clone();
226 let cloned_repos_destination = repos_destination.clone();
227 let item_source_set: HashSet<_> = cloned_repos_source_without_fork.iter().collect();
228 let item_destination_set: HashSet<_> = repos_destination.iter().collect();
229 let missing_dest: Vec<Repo> = cloned_repos_destination
230 .into_iter()
231 .filter(|item| !item_source_set.contains(item))
232 .collect();
233 let resync = config.cli_args.resync;
234 let difference: Vec<Repo> = if resync {
235 cloned_repos_source_without_fork
236 } else {
237 cloned_repos_source_without_fork
238 .into_iter()
239 .filter(|item| !item_destination_set.contains(item))
240 .collect()
241 };
242 println!("Number of repos to sync: {}", difference.len());
243 println!("Number of repos to delete: {}", missing_dest.len());
244 if !difference.is_empty() && yes_no_input("Do you want to start syncing ? (y/n)")? {
245 match sync_repos(
246 &config,
247 source_platform.clone(),
248 destination_platform.clone(),
249 difference,
250 )
251 .await
252 {
253 Ok(_) => {
254 println!("All repos synced");
255 }
256 Err(e) => return Err(format!("Error syncing repos: {e}").into()),
257 }
258 }
259 if config.cli_args.no_forks {
260 println!("Not syncing forks");
261 } else if repos_source_forks.is_empty() {
262 println!("No forks found");
263 } else if yes_no_input(format!(
264 "Do you want to sync forks ({})? (y/n)",
265 repos_source_forks.len()
266 ))? {
267 match sync_repos(
268 &config,
269 source_platform,
270 destination_platform.clone(),
271 repos_source_forks,
272 )
273 .await
274 {
275 Ok(_) => {
276 println!("All forks synced");
277 }
278 Err(e) => {
279 return Err(format!("Error syncing forks: {e}").into());
280 }
281 }
282 }
283 if config.cli_args.no_delete {
284 println!("Not prompting for deletion");
285 } else if missing_dest.is_empty() {
286 println!("Nothing to delete");
287 } else if yes_no_input(format!(
288 "Do you want to delete the missing ({}) repos (manually)? (y/n)",
289 missing_dest.len()
290 ))? {
291 match delete_repos(destination_platform, missing_dest).await {
292 Ok(_) => {
293 println!("All repos deleted");
294 }
295 Err(e) => {
296 return Err(format!("Error deleting repos: {e}").into());
297 }
298 }
299 }
300 Ok(())
301}
302
303pub(crate) fn input() -> Result<String, GitMoverError> {
305 use std::io::{stdin, stdout, Write};
306 let mut s = String::new();
307 let _ = stdout().flush();
308 stdin()
309 .read_line(&mut s)
310 .map_err(|e| GitMoverError::new_with_source("Did not enter a correct string", e))?;
311 if let Some('\n') = s.chars().next_back() {
312 s.pop();
313 }
314 if let Some('\r') = s.chars().next_back() {
315 s.pop();
316 }
317 Ok(s)
318}
319
320pub(crate) fn yes_no_input<S: AsRef<str>>(msg: S) -> Result<bool, GitMoverError> {
322 let msg = msg.as_ref();
323 loop {
324 println!("{msg}");
325 let input = input()?;
326 match input.to_lowercase().as_str() {
327 "yes" | "y" | "Y" | "YES" | "Yes " => return Ok(true),
328 "no" | "n" | "N" | "NO" | "No" => return Ok(false),
329 _ => println!("Invalid input"),
330 }
331 }
332}
333
334pub(crate) fn get_password() -> Result<String, GitMoverError> {
336 rpassword::read_password()
337 .map_err(|e| GitMoverError::new_with_source("Error reading password", e))
338}
339
340#[cfg(test)]
341mod test {
342
343 use super::*;
344
345 #[test]
346 fn compare_repo() {
347 let repo1 = Repo {
348 name: "test".to_string(),
349 path: "test".to_string(),
350 description: "test".to_string(),
351 private: false,
352 fork: false,
353 };
354 let repo2 = Repo {
355 name: "test".to_string(),
356 path: "test".to_string(),
357 description: "test".to_string(),
358 private: false,
359 fork: false,
360 };
361 let repo3 = Repo {
362 name: "test".to_string(),
363 path: "test".to_string(),
364 description: "test".to_string(),
365 private: true,
366 fork: false,
367 };
368 assert!(repo1 == repo2);
369 assert!(repo1 != repo3);
370 assert_eq!(repo1, repo2);
371 }
372}