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