repoctl_engine/
discovery.rs1use 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#[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 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 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}