quanttide_devops/contract/
core.rs1use super::{platform::*, scope::*, source::*, stage::*};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub struct Contract {
13 #[serde(default)]
14 pub stages: Stage,
15 #[serde(default)]
16 pub platform: Platform,
17 #[serde(default)]
18 pub sources: Source,
19 #[serde(default, deserialize_with = "deserialize_scopes")]
20 pub scopes: Vec<Scope>,
21}
22
23impl Contract {
26 pub fn scope_release<'a>(&'a self, scope: &'a Scope) -> &'a StageRelease {
28 let has_custom =
29 !scope.release.pre_publish.is_empty() || scope.release.changelog != "CHANGELOG.md";
30 if has_custom {
31 &scope.release
32 } else {
33 &self.stages.release
34 }
35 }
36
37 pub fn scope_test_threshold(&self, scope: &Scope) -> f64 {
39 scope.test_threshold.unwrap_or(self.stages.test.threshold)
40 }
41
42 pub fn find_scope_by_path(&self, current_dir: &Path) -> Option<&Scope> {
47 let current_str = current_dir.to_string_lossy();
48 self.scopes
49 .iter()
50 .filter(|s| current_str.starts_with(&s.dir) || s.dir == ".")
51 .max_by_key(|s| s.dir.len())
52 }
53
54 pub fn resolve_language(&self, scope: &Scope, scope_dir: &Path) -> Language {
56 match &scope.language {
57 Language::Unknown(_) => detect_language_by_files(scope_dir),
58 lang => lang.clone(),
59 }
60 }
61
62 pub fn validate(&self, repo_path: &Path) -> Vec<String> {
75 let mut errors = Vec::new();
76 for scope in &self.scopes {
77 let dir = repo_path.join(&scope.dir);
78 if !dir.exists() {
79 errors.push(format!("scope '{}' 目录不存在: {}", scope.name, scope.dir));
80 }
81 }
82 errors
83 }
84}
85
86pub fn detect_language_by_files(dir: &Path) -> Language {
88 if dir.join("Cargo.toml").exists() {
89 Language::Rust
90 } else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
91 Language::Python
92 } else if dir.join("go.mod").exists() {
93 Language::Go
94 } else if dir.join("pubspec.yaml").exists() {
95 Language::Dart
96 } else if dir.join("package.json").exists() {
97 Language::TypeScript
98 } else {
99 Language::Unknown("无法识别".into())
100 }
101}
102
103#[cfg(test)]
108mod tests {
109 use super::*;
110 use serde_yaml;
111
112 fn parse_yaml(s: &str) -> Contract {
113 serde_yaml::from_str(s).expect("YAML 应能解析")
114 }
115
116 #[test]
119 fn test_full_contract() {
120 let yaml = r#"
121stages:
122 build:
123 command: cargo build
124 test:
125 command: cargo test
126 threshold: 80.0
127 release:
128 changelog: CHANGELOG.md
129 pre_publish:
130 - cargo publish
131
132platform:
133 source_control: github
134 pipeline: github_actions
135 artifact_registry: crates
136
137sources:
138 version:
139 type: cargo
140
141scopes:
142 cli:
143 dir: src/cli
144 language: rust
145 build_tool: cargo
146 registry: crates
147 test_threshold: 90.0
148 web:
149 dir: src/web
150 language: typescript
151 build_tool: npm
152"#;
153 let c: Contract = parse_yaml(yaml);
154 assert_eq!(c.stages.build.command.as_deref(), Some("cargo build"));
155 assert_eq!(c.stages.test.threshold, 80.0);
156 assert_eq!(c.stages.test.command.as_deref(), Some("cargo test"));
157 assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
158 assert_eq!(
159 c.stages.release.pre_publish,
160 vec!["cargo publish".to_string()]
161 );
162
163 assert_eq!(c.platform.source_control, SourceControl::Github);
164 assert_eq!(c.platform.pipeline, Pipeline::GithubActions);
165 assert_eq!(c.platform.artifact_registry, Registry::Crates);
166
167 assert_eq!(c.sources.version.source_type, SourceType::Cargo);
168
169 assert_eq!(c.scopes.len(), 2);
170
171 let cli = &c.scopes[0];
172 assert_eq!(cli.name, "cli");
173 assert_eq!(cli.dir, "src/cli");
174 assert_eq!(cli.language, Language::Rust);
175 assert_eq!(cli.build_tool, BuildTool::Cargo);
176 assert_eq!(cli.registry, Registry::Crates);
177 assert_eq!(cli.test_threshold, Some(90.0));
178
179 let web = &c.scopes[1];
180 assert_eq!(web.name, "web");
181 assert_eq!(web.language, Language::TypeScript);
182 assert_eq!(web.build_tool, BuildTool::Npm);
183 }
184
185 #[test]
188 fn test_empty_contract() {
189 let yaml = r#"
190stages:
191scopes:
192"#;
193 let c: Contract = parse_yaml(yaml);
194 assert_eq!(c.stages.build.command, None);
195 assert_eq!(c.stages.test.threshold, 70.0);
196 assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
197 assert_eq!(c.platform.source_control, SourceControl::Github);
198 assert_eq!(c.sources.version.source_type, SourceType::Auto);
199 assert!(c.scopes.is_empty());
200 }
201
202 #[test]
203 fn test_fully_empty_yaml() {
204 let c: Contract = serde_yaml::from_str("").unwrap_or_default();
205 assert_eq!(c.stages.test.threshold, 70.0);
206 assert!(c.scopes.is_empty());
207 }
208
209 #[test]
212 fn test_language_parse() {
213 let c: Contract = parse_yaml(
214 r#"
215scopes:
216 a:
217 dir: .
218 language: rust
219 b:
220 dir: .
221 language: typescript
222 c:
223 dir: .
224 language: ts
225 d:
226 dir: .
227 language: node
228 e:
229 dir: .
230 language: unknown_lang
231"#,
232 );
233 assert_eq!(c.scopes[0].language, Language::Rust);
234 assert_eq!(c.scopes[1].language, Language::TypeScript);
235 assert_eq!(c.scopes[2].language, Language::TypeScript);
236 assert_eq!(c.scopes[3].language, Language::TypeScript);
237 assert_eq!(
238 c.scopes[4].language,
239 Language::Unknown("unknown_lang".into())
240 );
241 }
242
243 #[test]
246 fn test_registry_parse() {
247 let c: Contract = parse_yaml(
248 r#"
249platform:
250 artifact_registry: pypi
251scopes:
252 s:
253 dir: .
254 registry: github_releases
255"#,
256 );
257 assert_eq!(c.platform.artifact_registry, Registry::PyPI);
258 assert_eq!(c.scopes[0].registry, Registry::GitHubReleases);
259 }
260
261 #[test]
264 fn test_source_type() {
265 let c: Contract = parse_yaml(
266 r#"
267sources:
268 version:
269 type: package.json
270"#,
271 );
272 assert_eq!(c.sources.version.source_type, SourceType::PackageJson);
273 }
274
275 #[test]
278 fn test_scope_release_fallback() {
279 let c: Contract = parse_yaml(
280 r#"
281stages:
282 release:
283 changelog: CHANGELOG.md
284 pre_publish:
285 - cargo publish
286scopes:
287 cli:
288 dir: src/cli
289 language: rust
290"#,
291 );
292 let cli = &c.scopes[0];
293 let rel = c.scope_release(cli);
294 assert_eq!(rel.pre_publish, vec!["cargo publish".to_string()]);
295 }
296
297 #[test]
298 fn test_scope_release_override() {
299 let c: Contract = parse_yaml(
300 r#"
301stages:
302 release:
303 changelog: CHANGELOG.md
304scopes:
305 cli:
306 dir: src/cli
307 language: rust
308 release:
309 changelog: docs/CHANGELOG.md
310"#,
311 );
312 let cli = &c.scopes[0];
313 let rel = c.scope_release(cli);
314 assert_eq!(rel.changelog, "docs/CHANGELOG.md");
315 }
316
317 #[test]
318 fn test_scope_test_threshold() {
319 let c: Contract = parse_yaml(
320 r#"
321stages:
322 test:
323 threshold: 70.0
324scopes:
325 a:
326 dir: .
327 b:
328 dir: .
329 test_threshold: 90.0
330"#,
331 );
332 assert_eq!(c.scope_test_threshold(&c.scopes[0]), 70.0);
333 assert_eq!(c.scope_test_threshold(&c.scopes[1]), 90.0);
334 }
335
336 #[test]
339 fn test_find_scope_by_path() {
340 let c: Contract = parse_yaml(
341 r#"
342scopes:
343 root:
344 dir: .
345 cli:
346 dir: src/cli
347 web:
348 dir: src/web
349"#,
350 );
351 assert_eq!(
352 c.find_scope_by_path(std::path::Path::new("src/cli/sub"))
353 .map(|s| s.name.as_str()),
354 Some("cli")
355 );
356 assert_eq!(
357 c.find_scope_by_path(std::path::Path::new("src/web"))
358 .map(|s| s.name.as_str()),
359 Some("web")
360 );
361 assert_eq!(
362 c.find_scope_by_path(std::path::Path::new("unknown"))
363 .map(|s| s.name.as_str()),
364 Some("root")
365 );
366 }
367
368 #[test]
371 fn test_resolve_language_declared() {
372 let c: Contract = parse_yaml(
373 r#"
374scopes:
375 cli:
376 dir: .
377 language: rust
378"#,
379 );
380 let lang = c.resolve_language(&c.scopes[0], std::path::Path::new("/tmp"));
381 assert_eq!(lang, Language::Rust);
382 }
383
384 #[test]
385 fn test_resolve_language_auto() {
386 let d = tempfile::tempdir().unwrap();
387 std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
388 let c: Contract = parse_yaml(
389 r#"
390scopes:
391 cli:
392 dir: .
393"#,
394 );
395 let lang = c.resolve_language(&c.scopes[0], d.path());
396 assert_eq!(lang, Language::Rust);
397 }
398
399 #[test]
402 fn test_detect_by_files() {
403 let d = tempfile::tempdir().unwrap();
404 assert_eq!(
405 detect_language_by_files(d.path()),
406 Language::Unknown("无法识别".into())
407 );
408 std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
409 assert_eq!(detect_language_by_files(d.path()), Language::Rust);
410 std::fs::write(d.path().join("go.mod"), "").unwrap();
411 assert_eq!(detect_language_by_files(d.path()), Language::Rust);
413 }
414}