axl_lib/project/
project_file.rs

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        // Arrange
307        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        // Arrange
363        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    // make into partial fixture when not drunk
387    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        // Arrange
405        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        // Act
422        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
428        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        // Arrange
445        let project_directory =
446            ResolvedProjectDirectory::new(&ConfigProjectDirectory::new(test_dir.1.path())?)?;
447
448        // Act
449        let projects = project_directory.get_projects_from_remotes()?;
450
451        // Assert
452        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        // Arrange
490        let project_directory = ResolvedProjectDirectory::new_filtered(
491            &ConfigProjectDirectory::new(test_dir.1.path())?,
492            &vec!["prod".to_string()],
493        )?;
494
495        // Act
496        let projects = project_directory.get_projects_from_remotes()?;
497
498        // Assert
499        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        // Act
519        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}