skootrs_lib/service/
project.rs

1//
2// Copyright 2024 The Skootrs Authors.
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16#![allow(clippy::module_name_repetitions)]
17
18use std::collections::HashMap;
19
20use crate::service::facet::{FacetSetParamsGenerator, RootFacetService};
21
22use skootrs_model::skootrs::{
23    facet::{CommonFacetCreateParams, InitializedFacet, SourceFile},
24    FacetGetParams, FacetMapKey, InitializedProject, InitializedSource, ProjectArchiveParams,
25    ProjectCreateParams, ProjectGetParams, ProjectOutput, ProjectOutputGetParams,
26    ProjectOutputReference, ProjectOutputsListParams, ProjectUpdateParams, SkootError,
27};
28
29use super::{
30    ecosystem::EcosystemService, output::OutputService, repo::RepoService, source::SourceService,
31};
32use tracing::{debug, error, info};
33
34/// The `ProjectService` trait provides an interface for initializing and managing a Skootrs project.
35pub trait ProjectService {
36    /// Initializes a Skootrs project.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the project can't be initialized for any reason.
41    fn initialize(
42        &self,
43        params: ProjectCreateParams,
44    ) -> impl std::future::Future<Output = Result<InitializedProject, SkootError>> + Send;
45
46    /// Gets an initialized project.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if the project can't be found or fetched.
51    fn get(
52        &self,
53        params: ProjectGetParams,
54    ) -> impl std::future::Future<Output = Result<InitializedProject, SkootError>> + Send;
55
56    /// Gets a facet along with its content from an initialized project.
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if the facet can't be found or fetched.
61    fn get_facet_with_content(
62        &self,
63        params: FacetGetParams,
64    ) -> impl std::future::Future<Output = Result<InitializedFacet, SkootError>> + Send;
65
66    /// Lists the facets of an initialized project.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if the list of facets can't be fetched.
71    fn list_facets(
72        &self,
73        params: ProjectGetParams,
74    ) -> impl std::future::Future<Output = Result<Vec<FacetMapKey>, SkootError>> + Send;
75
76    /// Lists the outputs of an initialized project.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if the list of outputs can't be fetched.
81    fn outputs_list(
82        &self,
83        params: ProjectOutputsListParams,
84    ) -> impl std::future::Future<Output = Result<Vec<ProjectOutputReference>, SkootError>> + Send;
85
86    fn output_get(
87        &self,
88        _params: ProjectOutputGetParams,
89    ) -> impl std::future::Future<Output = Result<ProjectOutput, SkootError>> + Send;
90
91    fn update(
92        &self,
93        params: ProjectUpdateParams,
94    ) -> impl std::future::Future<Output = Result<InitializedProject, SkootError>> + Send;
95
96    /// Archives an initialized project.
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if the project can't be archived.
101    fn archive(
102        &self,
103        _params: ProjectArchiveParams,
104    ) -> impl std::future::Future<Output = Result<String, SkootError>> + Send;
105}
106
107/// The `LocalProjectService` struct provides an implementation of the `ProjectService` trait for initializing
108/// and managing a Skootrs project on the local machine.
109#[derive(Debug)]
110pub struct LocalProjectService<
111    RS: RepoService,
112    ES: EcosystemService,
113    SS: SourceService,
114    FS: RootFacetService,
115    OS: OutputService,
116> {
117    pub repo_service: RS,
118    pub ecosystem_service: ES,
119    pub source_service: SS,
120    pub facet_service: FS,
121    pub output_service: OS,
122}
123
124impl<RS, ES, SS, FS, OS> ProjectService for LocalProjectService<RS, ES, SS, FS, OS>
125where
126    RS: RepoService + Send + Sync,
127    ES: EcosystemService + Send + Sync,
128    SS: SourceService + Send + Sync,
129    FS: RootFacetService + Send + Sync,
130    OS: OutputService + Send + Sync,
131{
132    async fn initialize(
133        &self,
134        params: ProjectCreateParams,
135    ) -> Result<InitializedProject, SkootError> {
136        debug!("Starting repo initialization");
137        let initialized_repo = self
138            .repo_service
139            .initialize(params.repo_params.clone())
140            .await?;
141        debug!("Starting source initialization");
142        let initialized_source: InitializedSource = self
143            .source_service
144            .initialize(params.source_params.clone(), initialized_repo.clone())?;
145        debug!("Starting ecosystem initialization");
146        let initialized_ecosystem = self
147            .ecosystem_service
148            .initialize(params.ecosystem_params.clone(), initialized_source.clone())?;
149        debug!("Starting facet initialization");
150        // TODO: This is ugly and this should probably be configured somewhere better, preferably outside of code.
151        let facet_set_params_generator = FacetSetParamsGenerator {};
152        let common_params = CommonFacetCreateParams {
153            project_name: params.name.clone(),
154            source: initialized_source.clone(),
155            repo: initialized_repo.clone(),
156            ecosystem: initialized_ecosystem.clone(),
157        };
158        let source_facet_set_params = facet_set_params_generator
159            .generate_default_source_bundle_facet_params(&common_params)?;
160        let api_facet_set_params =
161            facet_set_params_generator.generate_default_api_bundle(&common_params)?;
162        let initialized_source_facets = self
163            .facet_service
164            .initialize_all(source_facet_set_params)
165            .await?;
166        // TODO: Figure out how to better order commits and pushes
167        self.source_service.commit_and_push_changes(
168            initialized_source.clone(),
169            "Initialized project".to_string(),
170        )?;
171        let initialized_api_facets = self
172            .facet_service
173            .initialize_all(api_facet_set_params)
174            .await?;
175        // FIXME: Also add facet by name as well
176        let initialized_facets = [initialized_source_facets, initialized_api_facets]
177            .concat()
178            .into_iter()
179            .map(|f| (FacetMapKey::Type(f.facet_type()), f))
180            .collect::<HashMap<FacetMapKey, InitializedFacet>>();
181
182        info!("Completed project initialization");
183
184        Ok(InitializedProject {
185            repo: initialized_repo,
186            ecosystem: initialized_ecosystem,
187            source: initialized_source,
188            facets: initialized_facets,
189            name: params.name.clone(),
190        })
191    }
192
193    async fn get(&self, params: ProjectGetParams) -> Result<InitializedProject, SkootError> {
194        let get_repo_params = skootrs_model::skootrs::InitializedRepoGetParams {
195            repo_url: params.project_url.clone(),
196        };
197        debug!("Getting repo: {get_repo_params:?}");
198        let repo = self.repo_service.get(get_repo_params).await?;
199        // TODO: Skootrs file path should be kept as a global constant somewhere.
200        let skootrs_file = self
201            .repo_service
202            .fetch_file_content(&repo, ".skootrs")
203            .await?;
204        debug!("Skootrs file: {skootrs_file}");
205        let initialized_project: InitializedProject = serde_json::from_str(&skootrs_file)?;
206        Ok(initialized_project)
207    }
208
209    async fn get_facet_with_content(
210        &self,
211        params: FacetGetParams,
212    ) -> Result<InitializedFacet, SkootError> {
213        let initialized_project = self.get(params.project_get_params.clone()).await?;
214        let facet = initialized_project
215            .facets
216            .get(&params.facet_map_key)
217            .ok_or(SkootError::from("Facet not found"))?;
218
219        match facet {
220            InitializedFacet::SourceBundle(s) => {
221                if let Some(source_files) = s.source_files.clone() {
222                    let source_files_content_futures = source_files.into_iter().map(|sf| async {
223                        let path = std::path::Path::new(&sf.path).join(&sf.name);
224                        // FIXME: This stripped path should probably be handled by the fetch facet content function
225                        let stripped_path = path.strip_prefix("./").unwrap_or(&path);
226
227                        let content = self
228                            .repo_service
229                            .fetch_file_content(&initialized_project.repo, stripped_path)
230                            .await;
231                        match content {
232                            Ok(c) => Ok((sf, c)),
233                            Err(e) => {
234                                error!(
235                                    "Error fetching file content for path: {stripped_path:#?}, error: {e}"
236                                );
237                                Err(e)
238                            }
239                        }
240                    });
241                    let source_files_content_results =
242                        futures::future::join_all(source_files_content_futures)
243                            .await
244                            .into_iter()
245                            .collect::<Result<Vec<(SourceFile, String)>, SkootError>>()?;
246                    let source_files_content_map = source_files_content_results
247                        .into_iter()
248                        .collect::<HashMap<SourceFile, String>>();
249                    Ok(InitializedFacet::SourceBundle(
250                        skootrs_model::skootrs::facet::SourceBundleFacet {
251                            facet_type: s.facet_type.clone(),
252                            source_files: None,
253                            source_files_content: Some(source_files_content_map),
254                            labels: s.labels.clone(),
255                        },
256                    ))
257                } else {
258                    Err(SkootError::from("No source files found"))
259                }
260            }
261            InitializedFacet::APIBundle(a) => Ok(InitializedFacet::APIBundle(a.clone())),
262        }
263    }
264
265    // TODO: A lot of this code is copied from the initialize function. This should be refactored to avoid code duplication.
266    async fn update(&self, params: ProjectUpdateParams) -> Result<InitializedProject, SkootError> {
267        let initialized_project = params.initialized_project.clone();
268        let initialized_repo = initialized_project.repo;
269        let initialized_source = self.repo_service.clone_local_or_pull(
270            initialized_repo.clone(),
271            initialized_project.source.path.clone(),
272        )?;
273        let initialized_ecosystem = initialized_project.ecosystem;
274
275        let facet_set_params_generator = FacetSetParamsGenerator {};
276        let common_params = CommonFacetCreateParams {
277            project_name: initialized_project.name.clone(),
278            source: initialized_source.clone(),
279            repo: initialized_repo.clone(),
280            ecosystem: initialized_ecosystem.clone(),
281        };
282        let source_facet_set_params = facet_set_params_generator
283            .generate_default_source_bundle_facet_params(&common_params)?;
284        let api_facet_set_params =
285            facet_set_params_generator.generate_default_api_bundle(&common_params)?;
286        let initialized_source_facets = self
287            .facet_service
288            .initialize_all(source_facet_set_params)
289            .await?;
290        // TODO: Figure out how to better order commits and pushes
291        self.source_service.commit_and_push_changes(
292            initialized_source.clone(),
293            "Updated facets for project".to_string(),
294        )?;
295        let initialized_api_facets = self
296            .facet_service
297            .initialize_all(api_facet_set_params)
298            .await?;
299        // FIXME: Also add facet by name as well
300        let initialized_facets = [initialized_source_facets, initialized_api_facets]
301            .concat()
302            .into_iter()
303            .map(|f| (FacetMapKey::Type(f.facet_type()), f))
304            .collect::<HashMap<FacetMapKey, InitializedFacet>>();
305
306        Ok(InitializedProject {
307            repo: initialized_repo,
308            ecosystem: initialized_ecosystem,
309            source: initialized_source,
310            facets: initialized_facets,
311            name: initialized_project.name.clone(),
312        })
313    }
314
315    async fn outputs_list(
316        &self,
317        params: ProjectOutputsListParams,
318    ) -> Result<Vec<ProjectOutputReference>, SkootError> {
319        self.output_service.list(params).await
320    }
321
322    async fn list_facets(&self, params: ProjectGetParams) -> Result<Vec<FacetMapKey>, SkootError> {
323        Ok(self.get(params).await?.facets.keys().cloned().collect())
324    }
325
326    async fn output_get(
327        &self,
328        params: ProjectOutputGetParams,
329    ) -> Result<ProjectOutput, SkootError> {
330        self.output_service.get(params).await
331    }
332
333    async fn archive(&self, params: ProjectArchiveParams) -> Result<String, SkootError> {
334        self.repo_service
335            .archive(params.initialized_project.repo)
336            .await
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use std::path::Path;
343
344    use skootrs_model::skootrs::{
345        facet::{
346            APIBundleFacet, APIContent, FacetCreateParams, FacetSetCreateParams, SourceBundleFacet,
347            SupportedFacetType,
348        },
349        label::Label,
350        EcosystemInitializeParams, GithubRepoParams, GithubUser, GoParams, InitializedEcosystem,
351        InitializedGithubRepo, InitializedGo, InitializedMaven, InitializedRepo, ProjectOutputType,
352        RepoCreateParams, SourceInitializeParams,
353    };
354
355    use super::*;
356    struct MockRepoService;
357    struct MockEcosystemService;
358    struct MockSourceService;
359    struct MockFacetService;
360    struct MockOutputService;
361
362    impl RepoService for MockRepoService {
363        async fn initialize(
364            &self,
365            params: RepoCreateParams,
366        ) -> Result<InitializedRepo, SkootError> {
367            let RepoCreateParams::Github(inner_params) = params;
368
369            // Special case for testing error handling
370            if inner_params.name == "error" {
371                return Err("Error".into());
372            }
373
374            let initialized_repo = InitializedRepo::Github(InitializedGithubRepo {
375                name: inner_params.name,
376                organization: inner_params.organization,
377            });
378
379            Ok(initialized_repo)
380        }
381
382        fn clone_local(
383            &self,
384            initialized_repo: InitializedRepo,
385            path: String,
386        ) -> Result<InitializedSource, SkootError> {
387            let InitializedRepo::Github(inner_repo) = initialized_repo;
388
389            if inner_repo.name == "error" {
390                return Err("Error".into());
391            }
392
393            let initialized_source = InitializedSource {
394                path: format!("{}/{}", path, inner_repo.name),
395            };
396
397            Ok(initialized_source)
398        }
399
400        fn clone_local_or_pull(
401            &self,
402            initialized_repo: InitializedRepo,
403            path: String,
404        ) -> Result<InitializedSource, SkootError> {
405            self.clone_local(initialized_repo, path)
406        }
407
408        async fn get(
409            &self,
410            params: skootrs_model::skootrs::InitializedRepoGetParams,
411        ) -> Result<InitializedRepo, SkootError> {
412            let repo_url = params.repo_url.clone();
413            if repo_url == "error" {
414                return Err("Error".into());
415            }
416
417            let initialized_repo = InitializedRepo::Github(InitializedGithubRepo {
418                name: "test".to_string(),
419                organization: GithubUser::User("testuser".to_string()),
420            });
421
422            Ok(initialized_repo)
423        }
424
425        async fn fetch_file_content<P: AsRef<std::path::Path> + Send>(
426            &self,
427            _initialized_repo: &InitializedRepo,
428            path: P,
429        ) -> Result<String, SkootError> {
430            if path.as_ref().to_str().unwrap() == "error" {
431                return Err("Error".into());
432            }
433
434            Ok("Worked".to_string())
435        }
436
437        async fn archive(&self, initialized_repo: InitializedRepo) -> Result<String, SkootError> {
438            Ok(initialized_repo.full_url())
439        }
440    }
441
442    impl EcosystemService for MockEcosystemService {
443        fn initialize(
444            &self,
445            params: EcosystemInitializeParams,
446            _source: InitializedSource,
447        ) -> Result<InitializedEcosystem, SkootError> {
448            let initialized_ecosystem = match params {
449                EcosystemInitializeParams::Go(g) => {
450                    if g.host == "error" {
451                        return Err("Error".into());
452                    }
453                    InitializedEcosystem::Go(InitializedGo {
454                        name: g.name,
455                        host: g.host,
456                    })
457                }
458                EcosystemInitializeParams::Maven(m) => {
459                    if m.group_id == "error" {
460                        return Err("Error".into());
461                    }
462                    InitializedEcosystem::Maven(InitializedMaven {
463                        group_id: m.group_id,
464                        artifact_id: m.artifact_id,
465                    })
466                }
467            };
468
469            Ok(initialized_ecosystem)
470        }
471    }
472
473    impl SourceService for MockSourceService {
474        fn initialize(
475            &self,
476            params: skootrs_model::skootrs::SourceInitializeParams,
477            initialized_repo: InitializedRepo,
478        ) -> Result<InitializedSource, SkootError> {
479            if params.parent_path == "error" {
480                return Err("Error".into());
481            }
482
483            let repo_name = match initialized_repo {
484                InitializedRepo::Github(g) => g.name,
485            };
486
487            let initialized_source = InitializedSource {
488                path: format!("{}/{}", params.parent_path, repo_name),
489            };
490
491            Ok(initialized_source)
492        }
493
494        fn commit_and_push_changes(
495            &self,
496            _source: InitializedSource,
497            message: String,
498        ) -> Result<(), SkootError> {
499            if message == "error" {
500                return Err("Error".into());
501            }
502
503            Ok(())
504        }
505
506        fn write_file<P: AsRef<std::path::Path>, C: AsRef<[u8]>>(
507            &self,
508            _source: InitializedSource,
509            _path: P,
510            name: String,
511            _contents: C,
512        ) -> Result<(), SkootError> {
513            if name == "error" {
514                return Err("Error".into());
515            }
516
517            Ok(())
518        }
519
520        fn read_file<P: AsRef<std::path::Path>>(
521            &self,
522            _source: &InitializedSource,
523            _path: P,
524            name: String,
525        ) -> Result<String, SkootError> {
526            if name == "error" {
527                return Err("Error".into());
528            }
529
530            Ok("Worked".to_string())
531        }
532
533        fn hash_file<P: AsRef<Path>>(
534            &self,
535            _source: &InitializedSource,
536            path: P,
537            _name: String,
538        ) -> Result<String, SkootError> {
539            if path.as_ref().to_str().unwrap() == "error" {
540                return Err("Error".into());
541            }
542
543            Ok("fakehash".to_string())
544        }
545
546        fn pull_updates(&self, source: InitializedSource) -> Result<(), SkootError> {
547            if source.path == "error" {
548                return Err("Error".into());
549            }
550
551            Ok(())
552        }
553    }
554
555    impl RootFacetService for MockFacetService {
556        async fn initialize(
557            &self,
558            params: FacetCreateParams,
559        ) -> Result<InitializedFacet, SkootError> {
560            match params {
561                FacetCreateParams::SourceBundle(s) => {
562                    if s.common.project_name == "error" {
563                        return Err("Error".into());
564                    }
565                    let source_bundle_facet = SourceBundleFacet {
566                        source_files: Some(vec![SourceFile {
567                            name: "README.md".to_string(),
568                            path: "./".to_string(),
569                            hash: "fakehash".to_string(),
570                        }]),
571                        facet_type: SupportedFacetType::Readme,
572                        source_files_content: None,
573                        labels: vec![Label::Custom("test".to_string())],
574                    };
575
576                    Ok(InitializedFacet::SourceBundle(source_bundle_facet))
577                }
578                FacetCreateParams::APIBundle(a) => {
579                    if a.common.project_name == "error" {
580                        return Err("Error".into());
581                    }
582                    let api_bundle_facet = APIBundleFacet {
583                        apis: vec![APIContent {
584                            name: "test".to_string(),
585                            url: "https://foo.bar/test".to_string(),
586                            response: "worked".to_string(),
587                        }],
588                        facet_type: SupportedFacetType::BranchProtection,
589                        labels: vec![Label::Custom("test".to_string())],
590                    };
591
592                    Ok(InitializedFacet::APIBundle(api_bundle_facet))
593                }
594            }
595        }
596
597        async fn initialize_all(
598            &self,
599            params: FacetSetCreateParams,
600        ) -> Result<Vec<InitializedFacet>, SkootError> {
601            let mut initialized_facets = Vec::new();
602            for facet_params in params.facets_params {
603                let initialized_facet = self.initialize(facet_params).await?;
604                initialized_facets.push(initialized_facet);
605            }
606
607            Ok(initialized_facets)
608        }
609    }
610
611    impl OutputService for MockOutputService {
612        async fn list(
613            &self,
614            _params: ProjectOutputsListParams,
615        ) -> Result<Vec<ProjectOutputReference>, SkootError> {
616            Ok(vec![ProjectOutputReference {
617                name: "test".into(),
618                output_type: ProjectOutputType::SBOM,
619                labels: vec![Label::Custom("test".to_string())],
620            }])
621        }
622
623        async fn get(
624            &self,
625            _params: skootrs_model::skootrs::ProjectOutputGetParams,
626        ) -> Result<skootrs_model::skootrs::ProjectOutput, SkootError> {
627            Ok(skootrs_model::skootrs::ProjectOutput {
628                reference: ProjectOutputReference {
629                    name: "test".into(),
630                    output_type: ProjectOutputType::SBOM,
631                    labels: vec![Label::Custom("test".to_string())],
632                },
633                output: "test".into(),
634            })
635        }
636    }
637
638    #[tokio::test]
639    async fn test_initialize_project() {
640        let project_params = ProjectCreateParams {
641            name: "test".to_string(),
642            repo_params: RepoCreateParams::Github(GithubRepoParams {
643                name: "test".to_string(),
644                description: "foobar".to_string(),
645                organization: GithubUser::User("testuser".to_string()),
646            }),
647            ecosystem_params: EcosystemInitializeParams::Go(GoParams {
648                name: "test".to_string(),
649                host: "github.com".to_string(),
650            }),
651            source_params: SourceInitializeParams {
652                parent_path: "test".to_string(),
653            },
654        };
655
656        let local_project_service = LocalProjectService {
657            repo_service: MockRepoService,
658            ecosystem_service: MockEcosystemService,
659            source_service: MockSourceService,
660            facet_service: MockFacetService,
661            output_service: MockOutputService,
662        };
663
664        let result = local_project_service.initialize(project_params).await;
665
666        assert!(result.is_ok());
667        let initialized_project = result.unwrap();
668
669        assert!(initialized_project.repo.full_url() == "https://github.com/testuser/test");
670        let module = match initialized_project.ecosystem {
671            InitializedEcosystem::Go(g) => g,
672            _ => panic!("Wrong ecosystem type"),
673        };
674        assert!(module.name == "test");
675        assert!(initialized_project.source.path == "test/test");
676        println!("{:#?}", initialized_project.facets);
677
678        // TODO: This will always be equal to 2 because we are initializing two facets in the mock facet service
679        // and the `HashMap` for the facets will keep getting the same key. This is probably not a great way
680        // of handling that.
681        assert_eq!(initialized_project.facets.len(), 2);
682    }
683}