Skip to main content

repoctl_engine/
adapters.rs

1//! Concrete local adapters.
2// Local discovery ports are synchronous by design in the phase 0-3 facade.
3// These calls are not used from async request handlers, so blocking std fs is explicit here.
4#![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/// Local repo locator using `repo.yaml` first and `.git` as a fallback root marker.
18#[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/// Local filesystem adapter with `.gitignore` support.
65#[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/// Rust workspace inspector backed by `cargo_metadata`.
123#[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}