Skip to main content

repoctl_engine/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(rust_2024_compatibility, missing_docs, missing_debug_implementations)]
3#![allow(clippy::module_name_repetitions)]
4#![allow(clippy::must_use_candidate)]
5#![allow(clippy::missing_errors_doc)]
6
7//! Discovery, graph construction, and policy evaluation for repoctl.
8
9mod adapters;
10mod discovery;
11mod graph;
12mod policy;
13
14pub use adapters::{DefaultRepoLocator, LocalRepoFileSystem, RustWorkspaceInspector};
15pub use discovery::DiscoveryService;
16pub use graph::DefaultGraphBuilder;
17pub use policy::{
18    CrossAppDependencyRule, FoundationPublicClientOnlyRule, FrameworkFacadeOnlyRule,
19    GeneratedCodeReadonlyRule, HighRiskIacRule, PolicyEngine, ProjectKindDependencyRule,
20};
21
22/// Default engine wiring for phase 0-3 repoctl behavior.
23#[derive(Clone, Debug, Default)]
24pub struct RepoctlEngine {
25    discovery: DiscoveryService,
26    policies: PolicyEngine,
27}
28
29impl RepoctlEngine {
30    /// Creates an engine with local filesystem, YAML parsing, graph, and policy adapters.
31    pub fn with_default_adapters() -> Self {
32        Self::default()
33    }
34
35    /// Returns the discovery service.
36    pub fn discovery(&self) -> &DiscoveryService {
37        &self.discovery
38    }
39
40    /// Returns the policy engine.
41    pub fn policies(&self) -> &PolicyEngine {
42        &self.policies
43    }
44}
45
46#[cfg(test)]
47mod tests {
48    // Test fixtures build temporary repositories synchronously before invoking sync services.
49    #![allow(clippy::disallowed_methods)]
50
51    use std::{fs, path::Path};
52
53    use repoctl_core::{DiscoverRequest, RepoRelativePath};
54
55    use super::{DiscoveryService, PolicyEngine};
56
57    #[test]
58    fn test_should_discover_empty_repo() {
59        let temp = tempfile::tempdir().expect("tempdir");
60        write_repo_manifest(temp.path());
61        let snapshot = DiscoveryService::default()
62            .discover(&DiscoverRequest {
63                repo: Some(temp.path().to_path_buf()),
64            })
65            .expect("snapshot");
66        assert!(snapshot.projects.is_empty());
67        assert!(snapshot.graph.nodes.is_empty());
68    }
69
70    #[test]
71    fn test_should_build_graph_with_declared_edges() {
72        let temp = tempfile::tempdir().expect("tempdir");
73        write_repo_manifest(temp.path());
74        write_project(
75            temp.path(),
76            "apps/catalog",
77            r#"
78schema: company.project/v1
79name: apps.catalog
80kind: app
81path: apps/catalog
82owners:
83  - "@catalog"
84depends_on:
85  - frameworks.runtime
86  - foundations.identity.client
87"#,
88        );
89        write_project(
90            temp.path(),
91            "frameworks/runtime",
92            r#"
93schema: company.project/v1
94name: frameworks.runtime
95kind: framework
96path: frameworks/runtime
97owners:
98  - "@platform"
99"#,
100        );
101        write_project(
102            temp.path(),
103            "foundations/identity",
104            r#"
105schema: company.project/v1
106name: foundations.identity
107kind: foundation-service
108path: foundations/identity
109owners:
110  - "@identity"
111"#,
112        );
113        let snapshot = DiscoveryService::default()
114            .discover(&DiscoverRequest {
115                repo: Some(temp.path().to_path_buf()),
116            })
117            .expect("snapshot");
118        assert_eq!(snapshot.projects.len(), 3);
119        assert!(
120            snapshot
121                .graph
122                .edges
123                .iter()
124                .any(|edge| edge.kind == repoctl_core::EdgeKind::UsesFrameworkFacade)
125        );
126        assert!(
127            snapshot
128                .graph
129                .edges
130                .iter()
131                .any(|edge| edge.kind == repoctl_core::EdgeKind::UsesFoundationClient)
132        );
133    }
134
135    #[test]
136    fn test_should_reject_project_path_mismatch() {
137        let temp = tempfile::tempdir().expect("tempdir");
138        write_repo_manifest(temp.path());
139        write_project(
140            temp.path(),
141            "apps/catalog",
142            r#"
143schema: company.project/v1
144name: apps.catalog
145kind: app
146path: apps/wrong
147owners:
148  - "@catalog"
149"#,
150        );
151        let error = DiscoveryService::default()
152            .discover(&DiscoverRequest {
153                repo: Some(temp.path().to_path_buf()),
154            })
155            .expect_err("path mismatch");
156        assert!(
157            error
158                .diagnostics()
159                .iter()
160                .any(|diagnostic| diagnostic.code.as_ref() == "manifest.project.path_mismatch")
161        );
162    }
163
164    #[test]
165    fn test_should_report_policy_violations_and_risk() {
166        let temp = tempfile::tempdir().expect("tempdir");
167        write_repo_manifest(temp.path());
168        write_project(
169            temp.path(),
170            "apps/catalog",
171            r#"
172schema: company.project/v1
173name: apps.catalog
174kind: app
175path: apps/catalog
176owners:
177  - "@catalog"
178depends_on:
179  - apps.other
180  - frameworks.runtime.internal
181ai:
182  do_not_edit:
183    - "**/generated/**"
184"#,
185        );
186        write_project(
187            temp.path(),
188            "apps/other",
189            r#"
190schema: company.project/v1
191name: apps.other
192kind: app
193path: apps/other
194owners:
195  - "@other"
196"#,
197        );
198        write_project(
199            temp.path(),
200            "frameworks/runtime",
201            r#"
202schema: company.project/v1
203name: frameworks.runtime
204kind: framework
205path: frameworks/runtime
206owners:
207  - "@platform"
208"#,
209        );
210        let snapshot = DiscoveryService::default()
211            .discover(&DiscoverRequest {
212                repo: Some(temp.path().to_path_buf()),
213            })
214            .expect("snapshot");
215        let changed_files = vec![
216            RepoRelativePath::new("apps/catalog/api/generated/client.rs").expect("path"),
217            RepoRelativePath::new("apps/catalog/iac/stacks/prod.yaml").expect("path"),
218        ];
219        let diagnostics = PolicyEngine::default()
220            .evaluate(&snapshot, &changed_files)
221            .expect("policies");
222        assert!(
223            diagnostics
224                .iter()
225                .any(|diagnostic| diagnostic.code.as_ref() == "policy.cross_app_dependency")
226        );
227        assert!(
228            diagnostics.iter().any(
229                |diagnostic| diagnostic.code.as_ref() == "policy.framework_internal_dependency"
230            )
231        );
232        assert!(
233            diagnostics
234                .iter()
235                .any(|diagnostic| diagnostic.code.as_ref() == "policy.generated_code_readonly")
236        );
237        assert!(
238            diagnostics
239                .iter()
240                .any(|diagnostic| diagnostic.code.as_ref() == "policy.high_risk_iac")
241        );
242    }
243
244    fn write_repo_manifest(root: &Path) {
245        fs::write(
246            root.join("repo.yaml"),
247            r#"
248schema: company.repo/v1
249name: acme
250layout: functional
251defaults:
252  owner: "@platform"
253policies:
254  prod_change:
255    required_owners:
256      - "@platform"
257      - "@security"
258"#,
259        )
260        .expect("repo manifest");
261    }
262
263    fn write_project(root: &Path, relative: &str, contents: &str) {
264        let dir = root.join(relative);
265        fs::create_dir_all(&dir).expect("project dir");
266        fs::write(dir.join("project.yaml"), contents).expect("project manifest");
267    }
268}