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