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