1#![allow(clippy::disallowed_methods)]
5
6use std::{fs, path::Path};
7
8use camino::{Utf8Path, Utf8PathBuf};
9use cargo_metadata::MetadataCommand;
10use ignore::{DirEntry, WalkBuilder};
11use repoctl_core::{
12 DependencySurface, DiscoveredEdge, EdgeKind, RepoFileSystem, RepoLocator, RepoRelativePath,
13 RepoRoot, RepoctlError, WalkRequest, WorkspaceInspectionInput, WorkspaceInspector,
14 WorkspaceLanguage, utf8_path_buf,
15};
16
17#[derive(Clone, Debug, Default)]
19pub struct DefaultRepoLocator;
20
21impl RepoLocator for DefaultRepoLocator {
22 fn locate(&self, start: Option<&Path>) -> Result<RepoRoot, RepoctlError> {
23 let start_path = match start {
24 Some(path) => path.to_path_buf(),
25 None => std::env::current_dir().map_err(|source| RepoctlError::io(".", source))?,
26 };
27 let canonical = fs::canonicalize(&start_path)
28 .map_err(|source| RepoctlError::io(pathbuf_to_utf8_lossy(&start_path), source))?;
29 let mut current = if canonical.is_file() {
30 canonical.parent().map(Path::to_path_buf).ok_or_else(|| {
31 RepoctlError::Internal("file path has no parent directory".to_string())
32 })?
33 } else {
34 canonical
35 };
36 let mut git_root = None;
37 loop {
38 if current.join("repo.yaml").is_file() {
39 return RepoRoot::new(utf8_path_buf(current).map_err(RepoctlError::diagnostic)?)
40 .map_err(RepoctlError::diagnostic);
41 }
42 if current.join(".git").exists() && git_root.is_none() {
43 git_root = Some(current.clone());
44 }
45 let Some(parent) = current.parent() else {
46 break;
47 };
48 current = parent.to_path_buf();
49 }
50 if let Some(root) = git_root {
51 return RepoRoot::new(utf8_path_buf(root).map_err(RepoctlError::diagnostic)?)
52 .map_err(RepoctlError::diagnostic);
53 }
54 Err(RepoctlError::diagnostic(
55 repoctl_core::Diagnostic::error(
56 "repo.root.not_found",
57 "could not find repo.yaml or a Git root",
58 )
59 .with_help("pass --repo with the repository root"),
60 ))
61 }
62}
63
64#[derive(Clone, Debug, Default)]
66pub struct LocalRepoFileSystem;
67
68impl RepoFileSystem for LocalRepoFileSystem {
69 fn read_file(&self, root: &RepoRoot, path: &RepoRelativePath) -> Result<Vec<u8>, RepoctlError> {
70 let absolute = root.join(path);
71 fs::read(absolute.as_std_path()).map_err(|source| RepoctlError::io(absolute, source))
72 }
73
74 fn walk(
75 &self,
76 root: &RepoRoot,
77 request: &WalkRequest,
78 ) -> Result<Vec<RepoRelativePath>, RepoctlError> {
79 let mut files = Vec::new();
80 for prefix in &request.roots {
81 let start = root.join(prefix);
82 if !start.exists() {
83 continue;
84 }
85 let mut builder = WalkBuilder::new(start.as_std_path());
86 builder
87 .hidden(false)
88 .parents(true)
89 .git_ignore(true)
90 .git_exclude(true)
91 .git_global(true)
92 .filter_entry(|entry| !is_excluded_entry(entry));
93 for entry in builder.build() {
94 let entry = entry.map_err(|error| {
95 RepoctlError::Environment(format!("failed to walk repository: {error}"))
96 })?;
97 if !entry
98 .file_type()
99 .is_some_and(|file_type| file_type.is_file())
100 {
101 continue;
102 }
103 let relative = strip_repo_prefix(&root.absolute, entry.path())?;
104 if is_excluded_path(&relative) {
105 continue;
106 }
107 files.push(relative);
108 if files.len() > request.max_files {
109 return Err(RepoctlError::diagnostic(repoctl_core::Diagnostic::error(
110 "repo.walk.too_many_files",
111 format!("repository walk exceeded {} files", request.max_files),
112 )));
113 }
114 }
115 }
116 files.sort();
117 files.dedup();
118 Ok(files)
119 }
120}
121
122#[derive(Clone, Debug, Default)]
124pub struct RustWorkspaceInspector;
125
126impl WorkspaceInspector for RustWorkspaceInspector {
127 fn language(&self) -> WorkspaceLanguage {
128 WorkspaceLanguage::Rust
129 }
130
131 fn inspect(
132 &self,
133 input: &WorkspaceInspectionInput<'_>,
134 ) -> Result<Vec<DiscoveredEdge>, RepoctlError> {
135 if input.workspace.language != WorkspaceLanguage::Rust {
136 return Ok(Vec::new());
137 }
138 let manifest_relative = input
139 .project
140 .path
141 .join_project(&input.workspace.manifest)
142 .map_err(RepoctlError::diagnostic)?;
143 let manifest_path = input.root.join(&manifest_relative);
144 if !manifest_path.is_file() {
145 return Ok(Vec::new());
146 }
147 let metadata = MetadataCommand::new()
148 .manifest_path(manifest_path.as_std_path())
149 .exec()
150 .map_err(|error| {
151 RepoctlError::Environment(format!(
152 "cargo metadata failed for {manifest_relative}: {error}"
153 ))
154 })?;
155 let mut edges = Vec::new();
156 for package in &metadata.packages {
157 for dependency in &package.dependencies {
158 let Some(path) = &dependency.path else {
159 continue;
160 };
161 let dependency_path = absolutize_dependency_path(&manifest_path, path);
162 let relative =
163 strip_repo_prefix(&input.root.absolute, dependency_path.as_std_path())?;
164 if let Some(edge) = classify_path_dependency(input, &relative) {
165 edges.push(edge);
166 }
167 }
168 }
169 edges.sort_by(|left, right| {
170 (
171 &left.from_project,
172 &left.to_project,
173 edge_rank(&left.kind),
174 &left.evidence,
175 )
176 .cmp(&(
177 &right.from_project,
178 &right.to_project,
179 edge_rank(&right.kind),
180 &right.evidence,
181 ))
182 });
183 edges.dedup();
184 Ok(edges)
185 }
186}
187
188fn classify_path_dependency(
189 input: &WorkspaceInspectionInput<'_>,
190 relative: &RepoRelativePath,
191) -> Option<DiscoveredEdge> {
192 for target in input.projects {
193 if target.name == input.project.name || !relative.starts_with(&target.path) {
194 continue;
195 }
196 let kind = match target.kind {
197 repoctl_core::ProjectKind::Framework => {
198 if path_matches_areas(relative, target, &target.areas.public_facades) {
199 EdgeKind::UsesFrameworkFacade
200 } else {
201 EdgeKind::UsesFrameworkInternal
202 }
203 }
204 repoctl_core::ProjectKind::FoundationService => {
205 if path_matches_areas(relative, target, &target.areas.public_clients) {
206 EdgeKind::UsesFoundationClient
207 } else {
208 EdgeKind::UsesFoundationInternal
209 }
210 }
211 repoctl_core::ProjectKind::CoreInfra
212 | repoctl_core::ProjectKind::CoreInfraComponent => {
213 let surface = classify_core_infra_module(relative, target);
214 match surface {
215 DependencySurface::CoreInfraPublicModule => EdgeKind::UsesCoreInfraModule,
216 DependencySurface::CoreInfraInternalModule => {
217 EdgeKind::UsesCoreInfraInternalModule
218 }
219 _ => EdgeKind::DependsOnProject,
220 }
221 }
222 repoctl_core::ProjectKind::App
223 | repoctl_core::ProjectKind::ProtoRoot
224 | repoctl_core::ProjectKind::Tool => EdgeKind::DependsOnProject,
225 };
226 return Some(DiscoveredEdge {
227 from_project: input.project.name.to_string(),
228 from_workspace: input.workspace.name.to_string(),
229 to_project: target.name.to_string(),
230 kind,
231 evidence: Some(relative.to_string()),
232 });
233 }
234 None
235}
236
237fn path_matches_areas(
238 relative: &RepoRelativePath,
239 target: &repoctl_core::ProjectManifest,
240 areas: &std::collections::BTreeMap<String, Vec<repoctl_core::ProjectRelativePath>>,
241) -> bool {
242 areas.values().flatten().any(|area| {
243 target
244 .path
245 .join_project(area)
246 .is_ok_and(|repo_path| relative.starts_with(&repo_path))
247 })
248}
249
250fn classify_core_infra_module(
251 relative: &RepoRelativePath,
252 target: &repoctl_core::ProjectManifest,
253) -> DependencySurface {
254 if target.areas.public_modules.iter().any(|area| {
255 target
256 .path
257 .join_project(area)
258 .is_ok_and(|repo_path| relative.starts_with(&repo_path))
259 }) {
260 DependencySurface::CoreInfraPublicModule
261 } else if target.areas.internal_modules.iter().any(|area| {
262 target
263 .path
264 .join_project(area)
265 .is_ok_and(|repo_path| relative.starts_with(&repo_path))
266 }) {
267 DependencySurface::CoreInfraInternalModule
268 } else {
269 DependencySurface::Unspecified
270 }
271}
272
273fn absolutize_dependency_path(manifest_path: &Utf8Path, dependency_path: &Utf8Path) -> Utf8PathBuf {
274 if dependency_path.is_absolute() {
275 dependency_path.to_path_buf()
276 } else {
277 manifest_path.parent().map_or_else(
278 || dependency_path.to_path_buf(),
279 |parent| parent.join(dependency_path),
280 )
281 }
282}
283
284fn strip_repo_prefix(root: &Utf8Path, path: &Path) -> Result<RepoRelativePath, RepoctlError> {
285 let relative = path.strip_prefix(root.as_std_path()).map_err(|error| {
286 RepoctlError::Environment(format!("walked path escaped repository root: {error}"))
287 })?;
288 let relative = utf8_path_buf(relative.to_path_buf()).map_err(RepoctlError::diagnostic)?;
289 RepoRelativePath::new(relative.as_str().to_string()).map_err(RepoctlError::diagnostic)
290}
291
292fn is_excluded_entry(entry: &DirEntry) -> bool {
293 entry
294 .path()
295 .file_name()
296 .and_then(|name| name.to_str())
297 .is_some_and(|name| matches!(name, ".git" | "target"))
298}
299
300fn is_excluded_path(path: &RepoRelativePath) -> bool {
301 let value = path.as_str();
302 value.starts_with("target/")
303 || value == "target"
304 || value.starts_with(".git/")
305 || value == ".git"
306 || value.starts_with(".repoctl/cache/")
307}
308
309fn pathbuf_to_utf8_lossy(path: &Path) -> Utf8PathBuf {
310 Utf8PathBuf::from(path.to_string_lossy().to_string())
311}
312
313fn edge_rank(kind: &EdgeKind) -> u8 {
314 match kind {
315 EdgeKind::DependsOnProject => 0,
316 EdgeKind::ContainsWorkspace => 1,
317 EdgeKind::ConsumesProto => 2,
318 EdgeKind::OwnsProto => 3,
319 EdgeKind::UsesFrameworkFacade => 4,
320 EdgeKind::UsesFrameworkInternal => 5,
321 EdgeKind::UsesFoundationClient => 6,
322 EdgeKind::UsesFoundationInternal => 7,
323 EdgeKind::UsesCoreInfraModule => 8,
324 EdgeKind::UsesCoreInfraInternalModule => 9,
325 EdgeKind::OwnsIac => 10,
326 EdgeKind::RunsTask => 11,
327 }
328}