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::{Duration, timeout};
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 String::new()
43 } else {
44 format!(" at path '{}'", self.path)
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 = if let Some(platform) = plateform_from_cli {
108 platform
109 } else {
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 } else {
130 break plateform;
131 }
132 };
133 platforms[plateform].clone()
134 };
135 let plateform: Box<dyn Platform> = match chosen_platform {
136 PlatformType::Gitlab => Box::new(GitlabConfig::get_plateform(config)?),
137 PlatformType::Github => Box::new(GithubConfig::get_plateform(config)?),
138 PlatformType::Codeberg => Box::new(CodebergConfig::get_plateform(config)?),
139 };
140 Ok(plateform)
141}
142
143#[allow(clippy::too_many_lines)]
147pub async fn main_sync(config: GitMoverConfig) -> Result<(), GitMoverError> {
148 let mut config = config;
149 let source_platform = get_plateform(&mut config, &Direction::Source)?;
150 println!("Chosen {} as source", source_platform.get_remote_url());
151
152 let destination_platform = get_plateform(&mut config, &Direction::Destination)?;
153 println!(
154 "Chosen {} as destination",
155 destination_platform.get_remote_url()
156 );
157 if source_platform.get_remote_url() == destination_platform.get_remote_url() {
158 return Err("Source and destination can't be the same".into());
159 }
160 println!("Checking the git access for each plateform");
161 let (acc, acc2) = join!(
162 source_platform.check_git_access(),
163 destination_platform.check_git_access()
164 );
165 match acc {
166 Ok(()) => {
167 println!("Checked access to {}", source_platform.get_remote_url());
168 }
169 Err(e) => return Err(e),
170 }
171 match acc2 {
172 Ok(()) => {
173 println!(
174 "Checked access to {}",
175 destination_platform.get_remote_url()
176 );
177 }
178 Err(e) => return Err(e),
179 }
180 let source_platform = Arc::new(source_platform);
181 let destination_platform = Arc::new(destination_platform);
182 let (repos_source, repos_destination) = join!(
183 source_platform.get_all_repos(),
184 destination_platform.get_all_repos()
185 );
186
187 let repos_source = match repos_source {
188 Ok(repos) => repos,
189 Err(e) => {
190 return Err(format!("Error getting repositories for source: {e}").into());
191 }
192 };
193
194 let repos_destination = match repos_destination {
195 Ok(repos) => repos,
196 Err(e) => {
197 return Err(format!("Error getting repositories for destination: {e}").into());
198 }
199 };
200
201 println!("-----------");
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 let repos_dest_without_fork = repos_destination
213 .iter()
214 .filter(|repo| !repo.fork)
215 .collect::<Vec<_>>();
216 let repos_dest_forks = repos_destination
217 .iter()
218 .filter(|repo| repo.fork)
219 .collect::<Vec<_>>();
220 println!("Number of repos in source: {}", repos_source.len());
221 println!(
222 "- Number of forked repos in source: {}",
223 repos_source_forks.len()
224 );
225 println!(
226 "- Number of (non-forked) repos in source: {}",
227 repos_source_without_fork.len()
228 );
229 println!(
230 "Number of repos in destination: {}",
231 repos_destination.len()
232 );
233 println!(
234 "- Number of forked repos in source: {}",
235 repos_dest_forks.len()
236 );
237 println!(
238 "- Number of (non-forked) repos in source: {}",
239 repos_dest_without_fork.len()
240 );
241 println!("-----------");
242 let repos_source_set: HashSet<_> = repos_source_without_fork.iter().collect();
244 let repos_destination_set: HashSet<_> = repos_dest_without_fork.iter().collect();
245 let repos_to_delete: Vec<&Repo> = repos_dest_without_fork
246 .iter()
247 .filter_map(|item| {
248 if repos_source_set.contains(*item) {
249 None
250 } else {
251 Some(*item)
252 }
253 })
254 .collect();
255 let resync = config.cli_args.resync;
256 let difference: Vec<Repo> = if resync {
257 repos_source_without_fork
258 } else {
259 repos_source_without_fork
260 .into_iter()
261 .filter(|item| !repos_destination_set.contains(&item))
262 .collect()
263 };
264 println!("Number of repos to sync: {}", difference.len());
265 println!("Number of repos to delete: {}", repos_to_delete.len());
266 if !difference.is_empty() && yes_no_input("Do you want to start syncing ? (y/n)")? {
267 match sync_repos(
268 &config,
269 source_platform.clone(),
270 destination_platform.clone(),
271 difference,
272 )
273 .await
274 {
275 Ok(()) => {
276 println!("All repos synced");
277 }
278 Err(e) => return Err(format!("Error syncing repos: {e}").into()),
279 }
280 }
281 if config.cli_args.no_forks {
282 println!("Not syncing forks");
283 } else if repos_source_forks.is_empty() {
284 println!("No forks found");
285 } else if yes_no_input(format!(
286 "Do you want to sync forks ({})? (y/n)",
287 repos_source_forks.len()
288 ))? {
289 match sync_repos(
290 &config,
291 source_platform,
292 destination_platform.clone(),
293 repos_source_forks,
294 )
295 .await
296 {
297 Ok(()) => {
298 println!("All forks synced");
299 }
300 Err(e) => {
301 return Err(format!("Error syncing forks: {e}").into());
302 }
303 }
304 }
305 if config.cli_args.no_delete {
306 println!("Not prompting for deletion");
307 } else if repos_to_delete.is_empty() {
308 println!("Nothing to delete");
309 } else if yes_no_input(format!(
310 "Do you want to delete the missing ({}) repos (manually)? (y/n)",
311 repos_to_delete.len()
312 ))? {
313 match delete_repos(destination_platform, repos_to_delete).await {
314 Ok(()) => {
315 println!("All repos deleted");
316 }
317 Err(e) => {
318 return Err(format!("Error deleting repos: {e}").into());
319 }
320 }
321 }
322 Ok(())
323}
324
325pub(crate) fn input() -> Result<String, GitMoverError> {
327 use std::io::{Write, stdin, stdout};
328 let mut s = String::new();
329 let _ = stdout().flush();
330 stdin()
331 .read_line(&mut s)
332 .map_err(|e| GitMoverError::new_with_source("Did not enter a correct string", e))?;
333 if let Some('\n') = s.chars().next_back() {
334 s.pop();
335 }
336 if let Some('\r') = s.chars().next_back() {
337 s.pop();
338 }
339 Ok(s)
340}
341
342pub(crate) fn yes_no_input<S: AsRef<str>>(msg: S) -> Result<bool, GitMoverError> {
344 let msg = msg.as_ref();
345 loop {
346 println!("{msg}");
347 let input = input()?;
348 match input.to_lowercase().as_str() {
349 "yes" | "y" | "Y" | "YES" | "Yes " => return Ok(true),
350 "no" | "n" | "N" | "NO" | "No" => return Ok(false),
351 _ => println!("Invalid input"),
352 }
353 }
354}
355
356pub(crate) fn get_password() -> Result<String, GitMoverError> {
358 rpassword::read_password()
359 .map_err(|e| GitMoverError::new_with_source("Error reading password", e))
360}
361
362#[cfg(test)]
363mod test {
364
365 use super::*;
366
367 #[test]
368 fn compare_repo() {
369 let repo1 = Repo {
370 name: "test".to_string(),
371 path: "test".to_string(),
372 description: "test".to_string(),
373 private: false,
374 fork: false,
375 };
376 let repo2 = Repo {
377 name: "test".to_string(),
378 path: "test".to_string(),
379 description: "test".to_string(),
380 private: false,
381 fork: false,
382 };
383 let repo3 = Repo {
384 name: "test".to_string(),
385 path: "test".to_string(),
386 description: "test".to_string(),
387 private: true,
388 fork: false,
389 };
390 assert!(repo1 == repo2);
391 assert!(repo1 != repo3);
392 assert_eq!(repo1, repo2);
393 }
394}