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
7mod 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#[derive(Clone, Debug, Default)]
24pub struct RepoctlEngine {
25 discovery: DiscoveryService,
26 policies: PolicyEngine,
27}
28
29impl RepoctlEngine {
30 pub fn with_default_adapters() -> Self {
32 Self::default()
33 }
34
35 pub fn discovery(&self) -> &DiscoveryService {
37 &self.discovery
38 }
39
40 pub fn policies(&self) -> &PolicyEngine {
42 &self.policies
43 }
44}
45
46#[cfg(test)]
47mod tests {
48 #![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}