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, repo_path: &Path, subpath: &Path) -> Option<&Scope> {
50 let relative = subpath.strip_prefix(repo_path).unwrap_or(subpath);
51 let relative_str = relative.to_string_lossy();
52 self.scopes
53 .iter()
54 .filter(|s| relative_str.starts_with(&s.dir) || s.dir == ".")
55 .max_by_key(|s| s.dir.len())
56 }
57
58 pub fn resolve_language(&self, scope: &Scope, scope_dir: &Path) -> Language {
60 match &scope.language {
61 Language::Unknown(_) => crate::source::config_file::detect_languages(scope_dir)
62 .into_iter()
63 .next()
64 .unwrap_or(Language::Unknown("无法识别".into())),
65 lang => lang.clone(),
66 }
67 }
68
69 pub fn validate(&self, repo_path: &Path) -> Vec<String> {
82 let mut errors = Vec::new();
83 for scope in &self.scopes {
84 let dir = repo_path.join(&scope.dir);
85 if !dir.exists() {
86 errors.push(format!("scope '{}' 目录不存在: {}", scope.name, scope.dir));
87 }
88 }
89 errors
90 }
91
92 pub fn auto_detect(repo_path: &Path) -> Self {
105 let root_langs = crate::source::config_file::detect_languages(repo_path);
106
107 let mut scopes: Vec<Scope> = Vec::new();
108 for base in &["src", "packages", "apps"] {
109 let base_dir = repo_path.join(base);
110 if !base_dir.is_dir() {
111 continue;
112 }
113 if let Ok(entries) = std::fs::read_dir(&base_dir) {
114 for entry in entries.flatten() {
115 let sub = entry.path();
116 if !sub.is_dir() {
117 continue;
118 }
119 let name = match sub.file_name() {
120 Some(n) => n.to_string_lossy().to_string(),
121 None => continue,
122 };
123 let sub_langs = crate::source::config_file::detect_languages(&sub);
124 if sub_langs.is_empty() {
125 continue;
126 }
127 let sub_lang = sub_langs.into_iter().next().unwrap();
129 scopes.push(Scope {
130 name,
131 dir: format!("{}/{}", base, sub.file_name().unwrap().to_string_lossy()),
132 language: sub_lang.clone(),
133 build_tool: sub_lang.default_build_tool(),
134 framework: String::new(),
135 registry: sub_lang.default_registry(),
136 release: StageRelease::default(),
137 test_threshold: None,
138 ci_workflow: None,
139 });
140 }
141 }
142 }
143
144 if let Some(root_lang) = root_langs.into_iter().next() {
145 scopes.insert(
146 0,
147 Scope {
148 name: "(root)".into(),
149 dir: ".".into(),
150 language: root_lang.clone(),
151 build_tool: root_lang.default_build_tool(),
152 framework: String::new(),
153 registry: root_lang.default_registry(),
154 release: StageRelease::default(),
155 test_threshold: None,
156 ci_workflow: None,
157 },
158 );
159 }
160
161 Self {
162 stages: Stage {
163 build: StageBuild {
164 command: Some("cargo build".into()),
165 },
166 test: StageTest {
167 command: Some("cargo test".into()),
168 ..StageTest::default()
169 },
170 release: StageRelease::default(),
171 },
172 scopes,
173 ..Self::default()
174 }
175 }
176}
177
178#[cfg(test)]
184mod tests {
185 use super::*;
186 use serde_yaml;
187
188 fn parse_yaml(s: &str) -> Contract {
189 serde_yaml::from_str(s).expect("YAML 应能解析")
190 }
191
192 #[test]
195 fn test_full_contract() {
196 let yaml = r#"
197stages:
198 build:
199 command: cargo build
200 test:
201 command: cargo test
202 threshold: 80.0
203 release:
204 changelog: CHANGELOG.md
205 pre_publish:
206 - cargo publish
207
208platform:
209 source_control: github
210 pipeline: github_actions
211 artifact_registry: crates
212
213sources:
214 version:
215 type: cargo
216
217scopes:
218 cli:
219 dir: src/cli
220 language: rust
221 build_tool: cargo
222 registry: crates
223 test_threshold: 90.0
224 web:
225 dir: src/web
226 language: typescript
227 build_tool: npm
228"#;
229 let c: Contract = parse_yaml(yaml);
230 assert_eq!(c.stages.build.command.as_deref(), Some("cargo build"));
231 assert_eq!(c.stages.test.threshold, 80.0);
232 assert_eq!(c.stages.test.command.as_deref(), Some("cargo test"));
233 assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
234 assert_eq!(
235 c.stages.release.pre_publish,
236 vec!["cargo publish".to_string()]
237 );
238
239 assert_eq!(c.platform.source_control, SourceControl::Github);
240 assert_eq!(c.platform.pipeline, Pipeline::GithubActions);
241 assert_eq!(c.platform.artifact_registry, Registry::Crates);
242
243 assert_eq!(c.sources.version.source_type, SourceType::Cargo);
244
245 assert_eq!(c.scopes.len(), 2);
246
247 let cli = &c.scopes[0];
248 assert_eq!(cli.name, "cli");
249 assert_eq!(cli.dir, "src/cli");
250 assert_eq!(cli.language, Language::Rust);
251 assert_eq!(cli.build_tool, BuildTool::Cargo);
252 assert_eq!(cli.registry, Registry::Crates);
253 assert_eq!(cli.test_threshold, Some(90.0));
254
255 let web = &c.scopes[1];
256 assert_eq!(web.name, "web");
257 assert_eq!(web.language, Language::TypeScript);
258 assert_eq!(web.build_tool, BuildTool::Npm);
259 }
260
261 #[test]
264 fn test_empty_contract() {
265 let yaml = r#"
266stages:
267scopes:
268"#;
269 let c: Contract = parse_yaml(yaml);
270 assert_eq!(c.stages.build.command, None);
271 assert_eq!(c.stages.test.threshold, 80.0);
272 assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
273 assert_eq!(c.platform.source_control, SourceControl::Github);
274 assert_eq!(c.sources.version.source_type, SourceType::Auto);
275 assert!(c.scopes.is_empty());
276 }
277
278 #[test]
279 fn test_fully_empty_yaml() {
280 let c: Contract = serde_yaml::from_str("").unwrap_or_default();
281 assert_eq!(c.stages.test.threshold, 80.0);
282 assert!(c.scopes.is_empty());
283 }
284
285 #[test]
288 fn test_language_parse() {
289 let c: Contract = parse_yaml(
290 r#"
291scopes:
292 a:
293 dir: .
294 language: rust
295 b:
296 dir: .
297 language: typescript
298 c:
299 dir: .
300 language: ts
301 d:
302 dir: .
303 language: node
304 e:
305 dir: .
306 language: unknown_lang
307"#,
308 );
309 assert_eq!(c.scopes[0].language, Language::Rust);
310 assert_eq!(c.scopes[1].language, Language::TypeScript);
311 assert_eq!(c.scopes[2].language, Language::TypeScript);
312 assert_eq!(c.scopes[3].language, Language::TypeScript);
313 assert_eq!(
314 c.scopes[4].language,
315 Language::Unknown("unknown_lang".into())
316 );
317 }
318
319 #[test]
322 fn test_registry_parse() {
323 let c: Contract = parse_yaml(
324 r#"
325platform:
326 artifact_registry: pypi
327scopes:
328 s:
329 dir: .
330 registry: github_releases
331"#,
332 );
333 assert_eq!(c.platform.artifact_registry, Registry::PyPI);
334 assert_eq!(c.scopes[0].registry, Registry::GitHubReleases);
335 }
336
337 #[test]
340 fn test_source_type() {
341 let c: Contract = parse_yaml(
342 r#"
343sources:
344 version:
345 type: package.json
346"#,
347 );
348 assert_eq!(c.sources.version.source_type, SourceType::PackageJson);
349 }
350
351 #[test]
354 fn test_scope_release_fallback() {
355 let c: Contract = parse_yaml(
356 r#"
357stages:
358 release:
359 changelog: CHANGELOG.md
360 pre_publish:
361 - cargo publish
362scopes:
363 cli:
364 dir: src/cli
365 language: rust
366"#,
367 );
368 let cli = &c.scopes[0];
369 let rel = c.scope_release(cli);
370 assert_eq!(rel.pre_publish, vec!["cargo publish".to_string()]);
371 }
372
373 #[test]
374 fn test_scope_release_override() {
375 let c: Contract = parse_yaml(
376 r#"
377stages:
378 release:
379 changelog: CHANGELOG.md
380scopes:
381 cli:
382 dir: src/cli
383 language: rust
384 release:
385 changelog: docs/CHANGELOG.md
386"#,
387 );
388 let cli = &c.scopes[0];
389 let rel = c.scope_release(cli);
390 assert_eq!(rel.changelog, "docs/CHANGELOG.md");
391 }
392
393 #[test]
394 fn test_scope_test_threshold() {
395 let c: Contract = parse_yaml(
396 r#"
397stages:
398 test:
399 threshold: 70.0
400scopes:
401 a:
402 dir: .
403 b:
404 dir: .
405 test_threshold: 90.0
406"#,
407 );
408 assert_eq!(c.scope_test_threshold(&c.scopes[0]), 70.0);
409 assert_eq!(c.scope_test_threshold(&c.scopes[1]), 90.0);
410 }
411
412 #[test]
415 fn test_find_scope_by_path() {
416 let c: Contract = parse_yaml(
417 r#"
418scopes:
419 root:
420 dir: .
421 cli:
422 dir: src/cli
423 web:
424 dir: src/web
425"#,
426 );
427 let repo = std::path::Path::new("/repo");
428 assert_eq!(
429 c.find_scope_by_path(repo, std::path::Path::new("src/cli/sub"))
430 .map(|s| s.name.as_str()),
431 Some("cli")
432 );
433 assert_eq!(
434 c.find_scope_by_path(repo, std::path::Path::new("src/web"))
435 .map(|s| s.name.as_str()),
436 Some("web")
437 );
438 assert_eq!(
439 c.find_scope_by_path(repo, std::path::Path::new("unknown"))
440 .map(|s| s.name.as_str()),
441 Some("root")
442 );
443 }
444
445 #[test]
448 fn test_resolve_language_declared() {
449 let c: Contract = parse_yaml(
450 r#"
451scopes:
452 cli:
453 dir: .
454 language: rust
455"#,
456 );
457 let lang = c.resolve_language(&c.scopes[0], std::path::Path::new("/tmp"));
458 assert_eq!(lang, Language::Rust);
459 }
460
461 #[test]
462 fn test_resolve_language_auto() {
463 let d = tempfile::tempdir().unwrap();
464 std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
465 let c: Contract = parse_yaml(
466 r#"
467scopes:
468 cli:
469 dir: .
470"#,
471 );
472 let lang = c.resolve_language(&c.scopes[0], d.path());
473 assert_eq!(lang, Language::Rust);
474 }
475}