1use anyhow::Result;
2use colored::Colorize;
3use console::Style;
4use git_lib::git::Git;
5use inquire::Confirm;
6use similar::{ChangeTag, TextDiff};
7use spinners::{Spinner, Spinners};
8use std::{
9 collections::BTreeSet,
10 fs::{self},
11 path::{Path, PathBuf},
12};
13use tracing::{debug, instrument, trace, warn};
14
15use serde_derive::{Deserialize, Serialize};
16
17use crate::{
18 error::AxlError, fzf::FzfCmd, helper::get_directories, project::group::ProjectGroupFile,
19};
20
21use super::{
22 group::GroupItem,
23 project_type::{ConfigProject, ResolvedProject},
24};
25
26#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
27pub struct ConfigProjectDirectory {
28 #[serde(skip)]
29 pub file_path: PathBuf,
30 pub projects_directory: PathBuf,
31 pub include: Vec<GroupItem>,
32}
33
34impl ConfigProjectDirectory {
35 #[instrument(err)]
36 pub fn new(path: &Path) -> Result<Self> {
37 trace!("reading projects directory file...");
38 let mut projects_directory_file: Self = serde_yaml::from_str(&fs::read_to_string(path)?)?;
39 projects_directory_file.file_path = path.to_path_buf();
40 trace!("finished reading projects directory file");
41 Ok(projects_directory_file)
42 }
43
44 #[instrument(err)]
45 pub fn resolve_projects(&self) -> Result<Vec<ConfigProject>> {
46 trace!("loading group files, and projects...");
47 let mut projects = vec![];
48 for item in self.include.clone() {
49 match item {
50 GroupItem::GroupFile(path) => {
51 let group_file = ProjectGroupFile::new(&path)?;
52 projects.extend(group_file.get_projects()?);
53 }
54 GroupItem::Project(p) => projects.push(p),
55 };
56 }
57 trace!("finished loading group files, and projects");
58 Ok(projects)
59 }
60
61 pub fn add_config_projects(&mut self, projects: Vec<ConfigProject>) -> Result<()> {
62 for project in projects {
63 self.include.push(GroupItem::Project(project));
64 }
65 Ok(())
66 }
67
68 #[instrument(err)]
69 pub fn save_file(&self) -> Result<()> {
70 fs::write(&self.file_path, serde_yaml::to_string::<Self>(self)?)?;
71 Ok(())
72 }
73}
74
75#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
76pub struct ResolvedProjectDirectory {
77 pub resolved_from_path: PathBuf,
78 pub projects_directory: PathBuf,
79 pub projects: Vec<ConfigProject>,
80}
81
82impl ResolvedProjectDirectory {
83 pub fn new(project_directory_file: &ConfigProjectDirectory) -> Result<Self> {
84 Ok(Self {
85 resolved_from_path: project_directory_file.file_path.clone(),
86 projects_directory: project_directory_file.projects_directory.clone(),
87 projects: project_directory_file.resolve_projects()?,
88 })
89 }
90
91 #[instrument(err)]
92 pub fn new_filtered(
93 project_directory_file: &ConfigProjectDirectory,
94 tags: &Vec<String>,
95 ) -> Result<Self> {
96 let mut project_directory: Self = Self::new(project_directory_file)?;
97
98 if !tags.is_empty() {
99 let filtered = project_directory
100 .projects
101 .clone()
102 .into_iter()
103 .filter(|project| project.tags.iter().any(|tag| tags.contains(tag)))
104 .collect::<Vec<_>>();
105
106 if filtered.len() < project_directory.projects.len() {
107 project_directory.projects = filtered;
108 }
109 }
110
111 Ok(project_directory)
112 }
113
114 #[instrument(err)]
115 pub fn get_project(&self) -> Result<ResolvedProject> {
116 Self::pick_project(self.get_projects_from_remotes()?)
117 }
118
119 #[instrument(err)]
120 pub fn get_projects_from_remotes(&self) -> Result<Vec<ResolvedProject>> {
121 self.projects
122 .iter()
123 .map(|project_config_type| {
124 Ok(ResolvedProject::new(
125 &self.projects_directory,
126 project_config_type
127 .name
128 .clone()
129 .unwrap_or(Git::parse_url(&project_config_type.remote)?.name),
130 project_config_type.remote.to_string(),
131 project_config_type.tags.clone(),
132 ))
133 })
134 .collect::<Result<Vec<ResolvedProject>>>()
135 }
136
137 #[instrument(err)]
138 pub fn get_projects_from_fs(path: &Path) -> Result<(Vec<ResolvedProject>, Vec<PathBuf>)> {
139 let mut ignored = vec![];
140 let projects: Vec<_> = get_directories(path)?
141 .into_iter()
142 .filter_map(|d| {
143 Git::get_remote_url("origin", &d)
144 .expect("git command to get remote should not fail")
145 .map_or_else(
146 || {
147 warn!("skipping [{d:?}]. Remote was not found.");
148 ignored.push(d.clone());
149 None
150 },
151 |remote| {
152 Some(ResolvedProject::new(
153 path,
154 d.file_name()
155 .expect("file_name should be representable as a String")
156 .to_string_lossy()
157 .to_string(),
158 remote,
159 BTreeSet::new(),
160 ))
161 },
162 )
163 })
164 .collect();
165 Ok((projects, ignored))
166 }
167
168 #[instrument(err)]
169 pub fn pick_project(projects: Vec<ResolvedProject>) -> Result<ResolvedProject> {
170 let project_names = projects.iter().map(|p| p.name.clone()).collect::<Vec<_>>();
171
172 let project_name = FzfCmd::new().find_vec(project_names)?;
173
174 projects
175 .iter()
176 .find(|p| p.name == project_name)
177 .map_or_else(
178 || {
179 eprintln!("{}", "No project was selected.".red().bold());
180 Err(AxlError::NoProjectSelected)?
181 },
182 |project| Ok(project.clone()),
183 )
184 }
185
186 #[instrument(err)]
187 pub fn pick_projects(pickable_projects: Vec<ResolvedProject>) -> Result<Vec<ResolvedProject>> {
188 let project_names = pickable_projects
189 .iter()
190 .map(|p| p.name.clone())
191 .collect::<Vec<_>>();
192
193 let project_names_picked = FzfCmd::new()
194 .args(vec!["--phony", "--multi"])
195 .find_vec(project_names)?
196 .trim_end()
197 .split('\n')
198 .map(|s| s.to_string())
199 .collect::<Vec<_>>();
200
201 debug!("picked_project_names: {project_names_picked:?}");
202
203 let projects = pickable_projects
204 .into_iter()
205 .filter(|p| project_names_picked.contains(&p.name))
206 .collect::<Vec<_>>();
207
208 if projects.is_empty() {
209 eprintln!("{}", "No projects were selected.".red().bold());
210 Err(AxlError::NoProjectSelected)?
211 }
212
213 Ok(projects)
214 }
215
216 #[instrument(err)]
217 pub fn pick_config_projects(
218 pickable_projects: Vec<ConfigProject>,
219 ) -> Result<Vec<ConfigProject>> {
220 let project_remotes = pickable_projects
221 .iter()
222 .map(|p| p.remote.clone())
223 .collect::<Vec<_>>();
224
225 let project_remotes_picked = FzfCmd::new()
226 .args(vec!["--phony", "--multi"])
227 .find_vec(project_remotes)?
228 .trim_end()
229 .split('\n')
230 .map(|s| s.to_string())
231 .collect::<Vec<_>>();
232
233 debug!("picked_project_remotes: {project_remotes_picked:?}");
234
235 let projects = pickable_projects
236 .into_iter()
237 .filter(|p| project_remotes_picked.contains(&p.remote))
238 .collect::<Vec<_>>();
239
240 if projects.is_empty() {
241 eprintln!("{}", "No projects were selected.".red().bold());
242 Err(AxlError::NoProjectSelected)?
243 }
244
245 Ok(projects)
246 }
247
248 pub fn add_config_projects(&mut self, projects: Vec<ConfigProject>) -> Result<()> {
249 let mut config_project_directory = ConfigProjectDirectory::new(&self.resolved_from_path)?;
250 let before = serde_yaml::to_string(&config_project_directory.resolve_projects()?)?;
251 config_project_directory.add_config_projects(projects)?;
252 let after = serde_yaml::to_string(&config_project_directory.resolve_projects()?)?;
253 let diff = TextDiff::from_lines(&before, &after);
254 println!("project file diff:\n----");
255 for change in diff.iter_all_changes() {
256 let (sign, style) = match change.tag() {
257 ChangeTag::Delete => ("-", Style::new().red()),
258 ChangeTag::Insert => ("+", Style::new().green()),
259 ChangeTag::Equal => (" ", Style::new()),
260 };
261 print!("{}{}", style.apply_to(sign).bold(), style.apply_to(change));
262 }
263
264 let file_string = self.resolved_from_path.to_string_lossy();
265 let ans = Confirm::new("Do you accept these changes?")
266 .with_default(false)
267 .with_help_message(&format!("These changes will be saved to [{file_string}]"))
268 .prompt()?;
269
270 if ans {
271 let mut sp = Spinner::new(Spinners::Dots9, format!("Saving to {file_string}..."));
272 config_project_directory.save_file()?;
273 sp.stop_and_persist(
274 &Style::new().green().apply_to("✓").bold().to_string(),
275 "Saved".into(),
276 );
277 } else {
278 println!("projects file will not be updated.")
279 }
280
281 Ok(())
282 }
283}
284
285#[cfg(test)]
286mod tests {
287
288 use std::{collections::BTreeSet, fs};
289
290 use anyhow::Result;
291
292 use assert_fs::{fixture::ChildPath, prelude::*, *};
293 use git_lib::git::Git;
294 use rstest::{fixture, rstest};
295 use similar_asserts::assert_eq;
296
297 use crate::project::{
298 project_file::ConfigProjectDirectory,
299 project_type::{ConfigProject, ResolvedProject},
300 };
301
302 use super::ResolvedProjectDirectory;
303
304 #[fixture]
305 fn projects_directory_file_1() -> (TempDir, ChildPath, ChildPath) {
306 let dir = TempDir::new().expect("temp dir can be created");
308 let file = dir.child("projects_test_1.yml");
309 let group_file = dir.child("projects_group_1.yml");
310
311 file.write_str(&format!(
312 "projects_directory: \"/test/projects/dir\"
313include:
314 - {}
315 - remote: git@github.com:user/test1.git
316 tags:
317 - tester_repo
318 - prod
319 - remote: git@github.com:user/test2.git
320 tags: [grouped]
321 name: test2_rename",
322 group_file.path().to_string_lossy()
323 ))
324 .expect("test fixture tmp file can be written to");
325
326 group_file
327 .write_str(
328 "tags: ['grouped']
329include:
330 - remote: git@github.com:user/test3.git
331 tags: ['test3']",
332 )
333 .expect("test fixture tmp file can be written to");
334
335 (dir, file, group_file)
336 }
337
338 #[fixture]
339 fn projects_vec_len_2() -> Vec<ResolvedProject> {
340 vec![
341 ResolvedProject {
342 name: "test1".to_string(),
343 safe_name: "test1".to_string(),
344 project_folder_path: "/test/projects/dir/".into(),
345 path: "/test/projects/dir/test1".into(),
346 remote: "git@github.com:user/test1.git".to_string(),
347 tags: BTreeSet::from_iter(vec!["test1".to_string()]),
348 },
349 ResolvedProject {
350 name: "test2".to_string(),
351 safe_name: "test2".to_string(),
352 project_folder_path: "/test/projects/dir/".into(),
353 path: "/test/projects/dir/test2".into(),
354 remote: "git@github.com:user/test2.git".to_string(),
355 tags: BTreeSet::new(),
356 },
357 ]
358 }
359
360 #[fixture]
361 fn projects_directory_fs() -> TempDir {
362 let projects = TempDir::new().expect("should be able to make temp dir");
364
365 let child_config = projects.child("project_config.yml");
366 child_config
367 .touch()
368 .expect("child_config should get created");
369 child_config
370 .write_str(&format!(
371 "path: \"{}\"\nprojects:\n- remote: git@github.com:test_user/test_repo1.git\n- remote: git@github.com:test_user/test_repo2.git",
372 &projects.path().join("projects").to_string_lossy()
373 ))
374 .expect("should be able to write to file");
375
376 make_test_repo(&projects, "test_repo1");
377 make_test_repo(&projects, "test_repo2");
378 let child_repo3 = projects.child("projects/test_repo3_not_tracked/file");
379 child_repo3
380 .touch()
381 .expect("should be able to create a file");
382
383 projects
384 }
385
386 fn make_test_repo(dir: &TempDir, name: &str) {
388 let child_repo = dir.child(format!("projects/{name}/file"));
389 let repo_dir = child_repo.parent().expect("should have parent");
390 child_repo.touch().expect("child_repo should get created");
391 Git::init(repo_dir).expect("child_repo can be initilized");
392 Git::add_remote(
393 "origin",
394 &format!("git@github.com:test_user/{name}.git"),
395 repo_dir,
396 )
397 .expect("child_repo can have remote added");
398 }
399
400 #[rstest]
401 fn should_read_projects_file_into_struct(
402 #[from(projects_directory_file_1)] test_dir: (TempDir, ChildPath, ChildPath),
403 ) -> Result<()> {
404 let project_1 = ConfigProject {
406 remote: "git@github.com:user/test1.git".to_string(),
407 name: None,
408 tags: BTreeSet::from_iter(vec!["tester_repo".to_string(), "prod".to_string()]),
409 };
410 let project_2 = ConfigProject {
411 remote: "git@github.com:user/test2.git".to_string(),
412 name: Some("test2_rename".to_string()),
413 tags: BTreeSet::from_iter(vec!["grouped".to_string()]),
414 };
415 let project_3 = ConfigProject {
416 remote: "git@github.com:user/test3.git".to_string(),
417 name: None,
418 tags: BTreeSet::from_iter(vec!["grouped".to_string(), "test3".to_string()]),
419 };
420
421 dbg!(test_dir.1.path());
423 dbg!(fs::read_to_string(test_dir.1.path())?);
424 let project_directory =
425 ResolvedProjectDirectory::new(&ConfigProjectDirectory::new(test_dir.1.path())?)?;
426
427 assert_eq!(
429 project_directory,
430 ResolvedProjectDirectory {
431 resolved_from_path: test_dir.1.path().to_path_buf(),
432 projects_directory: "/test/projects/dir".into(),
433 projects: vec![project_3, project_1, project_2]
434 }
435 );
436
437 Ok(())
438 }
439
440 #[rstest]
441 fn should_turn_remotes_into_project_structs(
442 #[from(projects_directory_file_1)] test_dir: (TempDir, ChildPath, ChildPath),
443 ) -> Result<()> {
444 let project_directory =
446 ResolvedProjectDirectory::new(&ConfigProjectDirectory::new(test_dir.1.path())?)?;
447
448 let projects = project_directory.get_projects_from_remotes()?;
450
451 assert_eq!(
453 projects,
454 vec![
455 ResolvedProject {
456 name: "test3".to_string(),
457 safe_name: "test3".to_string(),
458 project_folder_path: "/test/projects/dir".into(),
459 path: "/test/projects/dir/test3".into(),
460 remote: "git@github.com:user/test3.git".to_string(),
461 tags: BTreeSet::from_iter(vec!["grouped".to_string(), "test3".to_string()]),
462 },
463 ResolvedProject {
464 name: "test1".to_string(),
465 safe_name: "test1".to_string(),
466 project_folder_path: "/test/projects/dir".into(),
467 path: "/test/projects/dir/test1".into(),
468 remote: "git@github.com:user/test1.git".to_string(),
469 tags: BTreeSet::from_iter(vec!["tester_repo".to_string(), "prod".to_string()]),
470 },
471 ResolvedProject {
472 name: "test2_rename".to_string(),
473 safe_name: "test2_rename".to_string(),
474 project_folder_path: "/test/projects/dir".into(),
475 path: "/test/projects/dir/test2_rename".into(),
476 remote: "git@github.com:user/test2.git".to_string(),
477 tags: BTreeSet::from_iter(vec!["grouped".to_string()]),
478 },
479 ]
480 );
481
482 Ok(())
483 }
484
485 #[rstest]
486 fn should_turn_remotes_into_project_structs_and_filter_by_tags(
487 #[from(projects_directory_file_1)] test_dir: (TempDir, ChildPath, ChildPath),
488 ) -> Result<()> {
489 let project_directory = ResolvedProjectDirectory::new_filtered(
491 &ConfigProjectDirectory::new(test_dir.1.path())?,
492 &vec!["prod".to_string()],
493 )?;
494
495 let projects = project_directory.get_projects_from_remotes()?;
497
498 assert_eq!(
500 projects,
501 vec![ResolvedProject {
502 name: "test1".to_string(),
503 safe_name: "test1".to_string(),
504 project_folder_path: "/test/projects/dir".into(),
505 path: "/test/projects/dir/test1".into(),
506 remote: "git@github.com:user/test1.git".to_string(),
507 tags: BTreeSet::from_iter(vec!["tester_repo".to_string(), "prod".to_string()]),
508 },]
509 );
510
511 Ok(())
512 }
513
514 #[rstest]
515 fn should_read_projects_from_fs(
516 #[from(projects_directory_fs)] test_dir: TempDir,
517 ) -> Result<()> {
518 let projects_dir_path = test_dir.path().join("projects");
520 let projects = ResolvedProjectDirectory::get_projects_from_fs(&projects_dir_path)?;
521
522 assert_eq!(projects.0.len(), 2);
523 assert!(projects.0.contains(&ResolvedProject {
524 name: "test_repo1".to_string(),
525 safe_name: "test_repo1".to_string(),
526 project_folder_path: projects_dir_path.clone(),
527 path: projects_dir_path.join("test_repo1"),
528 remote: "git@github.com:test_user/test_repo1.git".to_string(),
529 tags: BTreeSet::new(),
530 },),);
531 assert!(projects.0.contains(&ResolvedProject {
532 name: "test_repo2".to_string(),
533 safe_name: "test_repo2".to_string(),
534 project_folder_path: projects_dir_path.clone(),
535 path: projects_dir_path.join("test_repo2"),
536 remote: "git@github.com:test_user/test_repo2.git".to_string(),
537 tags: BTreeSet::new(),
538 }));
539 assert_eq!(
540 projects.1,
541 vec![projects_dir_path.join("test_repo3_not_tracked"),]
542 );
543
544 test_dir.close().expect("temp dir can be closed");
545
546 Ok(())
547 }
548}