Skip to main content

repoctl_engine/
discovery.rs

1//! Repository discovery service.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    sync::Arc,
6};
7
8use repoctl_core::{
9    Diagnostic, DiscoverRequest, GraphBuildInput, GraphBuilder, ManifestParser, ManifestSource,
10    ProjectManifest, ProjectRelativePath, RepoFileSystem, RepoLocator, RepoRelativePath,
11    RepoSnapshot, RepoctlError, WalkRequest, YamlManifestParser, validate_project_convention,
12};
13
14use crate::{DefaultGraphBuilder, DefaultRepoLocator, LocalRepoFileSystem};
15
16/// Discovers repo manifests and builds snapshots.
17#[derive(Clone)]
18pub struct DiscoveryService {
19    locator: Arc<dyn RepoLocator>,
20    filesystem: Arc<dyn RepoFileSystem>,
21    parser: Arc<dyn ManifestParser>,
22    graph_builder: Arc<dyn GraphBuilder>,
23}
24
25impl std::fmt::Debug for DiscoveryService {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("DiscoveryService").finish_non_exhaustive()
28    }
29}
30
31impl Default for DiscoveryService {
32    fn default() -> Self {
33        Self {
34            locator: Arc::new(DefaultRepoLocator),
35            filesystem: Arc::new(LocalRepoFileSystem),
36            parser: Arc::new(YamlManifestParser),
37            graph_builder: Arc::new(DefaultGraphBuilder::default()),
38        }
39    }
40}
41
42impl DiscoveryService {
43    /// Creates a discovery service from explicit adapters.
44    pub fn new(
45        locator: Arc<dyn RepoLocator>,
46        filesystem: Arc<dyn RepoFileSystem>,
47        parser: Arc<dyn ManifestParser>,
48        graph_builder: Arc<dyn GraphBuilder>,
49    ) -> Self {
50        Self {
51            locator,
52            filesystem,
53            parser,
54            graph_builder,
55        }
56    }
57
58    /// Discovers a repository and returns a validated snapshot.
59    pub fn discover(&self, request: &DiscoverRequest) -> Result<RepoSnapshot, RepoctlError> {
60        let root = self.locator.locate(request.repo.as_deref())?;
61        let repo_path = RepoRelativePath::new("repo.yaml").map_err(RepoctlError::diagnostic)?;
62        let repo_bytes = self.filesystem.read_file(&root, &repo_path)?;
63        let repo_manifest = self
64            .parser
65            .parse_repo(ManifestSource::new("repo.yaml", repo_bytes))?;
66        let project_paths = self.discover_project_manifest_paths(&root)?;
67        let mut diagnostics = Vec::new();
68        let mut projects = Vec::new();
69        for path in project_paths {
70            match self.filesystem.read_file(&root, &path).and_then(|bytes| {
71                self.parser
72                    .parse_project(ManifestSource::new(path.as_str(), bytes))
73            }) {
74                Ok(project) => projects.push(project),
75                Err(error) => diagnostics.extend(error.diagnostics()),
76            }
77        }
78        Self::apply_repo_defaults(&repo_manifest, &mut projects, &mut diagnostics);
79        diagnostics.extend(validate_project_names(&projects));
80        diagnostics.extend(validate_manifest_locations(&projects));
81        diagnostics.extend(validate_project_dependencies(&projects));
82        for project in &projects {
83            diagnostics.extend(validate_project_convention(project));
84        }
85        if diagnostics
86            .iter()
87            .any(|diagnostic| diagnostic.severity == repoctl_core::Severity::Error)
88        {
89            return Err(RepoctlError::Diagnostics { diagnostics });
90        }
91        projects.sort_by(|left, right| left.name.cmp(&right.name));
92        let graph = self.graph_builder.build(GraphBuildInput {
93            root: root.clone(),
94            repo_manifest: repo_manifest.clone(),
95            projects: projects.clone(),
96        })?;
97        Ok(RepoSnapshot::new(root, repo_manifest, projects, graph))
98    }
99
100    fn discover_project_manifest_paths(
101        &self,
102        root: &repoctl_core::RepoRoot,
103    ) -> Result<Vec<RepoRelativePath>, RepoctlError> {
104        let files = self.filesystem.walk(root, &WalkRequest::default())?;
105        let mut project_paths = files
106            .into_iter()
107            .filter(|path| path.as_str().ends_with("project.yaml"))
108            .collect::<Vec<_>>();
109        project_paths.sort();
110        Ok(project_paths)
111    }
112
113    fn apply_repo_defaults(
114        repo_manifest: &repoctl_core::RepoManifest,
115        projects: &mut [ProjectManifest],
116        diagnostics: &mut Vec<Diagnostic>,
117    ) {
118        for project in projects {
119            if project.owners.is_empty() {
120                if let Some(owner) = &repo_manifest.default_owner {
121                    project.owners.push(owner.clone());
122                } else {
123                    diagnostics.push(
124                        Diagnostic::error(
125                            "manifest.project.owner_missing",
126                            format!("project `{}` must declare at least one owner", project.name),
127                        )
128                        .with_path(project.source.as_str())
129                        .with_project(project.name.as_str()),
130                    );
131                }
132            }
133        }
134    }
135}
136
137fn validate_project_names(projects: &[ProjectManifest]) -> Vec<Diagnostic> {
138    let mut seen = BTreeSet::new();
139    let mut diagnostics = Vec::new();
140    for project in projects {
141        if !seen.insert(project.name.clone()) {
142            diagnostics.push(
143                Diagnostic::error(
144                    "manifest.project.duplicate_name",
145                    format!("duplicate project name `{}`", project.name),
146                )
147                .with_path(project.source.as_str())
148                .with_project(project.name.as_str()),
149            );
150        }
151    }
152    diagnostics
153}
154
155fn validate_manifest_locations(projects: &[ProjectManifest]) -> Vec<Diagnostic> {
156    let mut diagnostics = Vec::new();
157    let project_manifest = ProjectRelativePath::new("project.yaml");
158    let Ok(project_manifest) = project_manifest else {
159        return diagnostics;
160    };
161    for project in projects {
162        let expected = project.path.join_project(&project_manifest);
163        match expected {
164            Ok(expected) if expected == project.source => {}
165            Ok(expected) => diagnostics.push(
166                Diagnostic::error(
167                    "manifest.project.path_mismatch",
168                    format!(
169                        "project `{}` declares path `{}` but manifest is at `{}`",
170                        project.name, project.path, project.source
171                    ),
172                )
173                .with_path(project.source.as_str())
174                .with_project(project.name.as_str())
175                .with_help(format!(
176                    "move the manifest to `{expected}` or update `path`"
177                )),
178            ),
179            Err(diagnostic) => diagnostics.push(diagnostic.with_path(project.source.as_str())),
180        }
181    }
182    diagnostics
183}
184
185fn validate_project_dependencies(projects: &[ProjectManifest]) -> Vec<Diagnostic> {
186    let by_name = projects
187        .iter()
188        .map(|project| (project.name.clone(), project))
189        .collect::<BTreeMap<_, _>>();
190    let mut diagnostics = Vec::new();
191    for project in projects {
192        for dependency in &project.depends_on {
193            let repoctl_core::DependencyTarget::Project(target) = &dependency.target else {
194                continue;
195            };
196            if !by_name.contains_key(target) {
197                diagnostics.push(
198                    Diagnostic::error(
199                        "manifest.dependency.unknown_project",
200                        format!(
201                            "project `{}` depends on unknown project `{target}`",
202                            project.name
203                        ),
204                    )
205                    .with_path(project.source.as_str())
206                    .with_project(project.name.as_str()),
207                );
208            }
209        }
210    }
211    diagnostics
212}