1use serde::de::{Deserializer, MapAccess, Visitor};
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::path::Path;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub struct Contract {
14 #[serde(default)]
15 pub stages: Stage,
16 #[serde(default)]
17 pub platform: Platform,
18 #[serde(default)]
19 pub sources: Source,
20 #[serde(default, deserialize_with = "deserialize_scopes")]
21 pub scopes: Vec<Scope>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub struct Stage {
32 #[serde(default)]
33 pub build: StageBuild,
34 #[serde(default)]
35 pub test: StageTest,
36 #[serde(default)]
37 pub release: StageRelease,
38}
39
40impl Default for Stage {
41 fn default() -> Self {
42 Self {
43 build: StageBuild { command: None },
44 test: StageTest {
45 command: None,
46 threshold: 70.0,
47 },
48 release: StageRelease {
49 changelog: "CHANGELOG.md".into(),
50 pre_publish: Vec::new(),
51 },
52 }
53 }
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub struct StageBuild {
60 #[serde(default)]
61 pub command: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub struct StageTest {
68 #[serde(default)]
69 pub command: Option<String>,
70 #[serde(default = "default_threshold")]
71 pub threshold: f64,
72}
73
74impl Default for StageTest {
75 fn default() -> Self {
76 Self {
77 command: None,
78 threshold: 70.0,
79 }
80 }
81}
82
83const fn default_threshold() -> f64 {
84 70.0
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub struct StageRelease {
91 #[serde(default = "default_changelog")]
92 pub changelog: String,
93 #[serde(default)]
94 pub pre_publish: Vec<String>,
95}
96
97fn default_changelog() -> String {
98 "CHANGELOG.md".into()
99}
100
101impl Default for StageRelease {
102 fn default() -> Self {
103 Self {
104 changelog: "CHANGELOG.md".into(),
105 pre_publish: Vec::new(),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub struct Platform {
116 #[serde(default)]
117 pub source_control: SourceControl,
118 #[serde(default)]
119 pub pipeline: Pipeline,
120 #[serde(default)]
121 pub artifact_registry: Registry,
122}
123
124impl Default for Platform {
125 fn default() -> Self {
126 Self {
127 source_control: SourceControl::Github,
128 pipeline: Pipeline::GithubActions,
129 artifact_registry: Registry::None,
130 }
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum SourceControl {
138 #[default]
139 Github,
140 Gitlab,
141 Gitee,
142}
143
144#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum Pipeline {
148 #[default]
149 #[serde(rename = "github_actions")]
150 GithubActions,
151 #[serde(rename = "gitlab_ci")]
152 GitlabCi,
153 Jenkins,
154}
155
156#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum Registry {
162 Crates,
163 #[serde(rename = "pypi")]
164 PyPI,
165 #[serde(rename = "pubdev")]
166 PubDev,
167 Npm,
168 #[serde(rename = "github_releases")]
169 GitHubReleases,
170 Docker,
171 #[default]
172 #[serde(other)]
173 None,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub struct Source {
182 #[serde(default)]
183 pub version: VersionSource,
184}
185
186impl Default for Source {
187 fn default() -> Self {
188 Self {
189 version: VersionSource {
190 source_type: SourceType::Auto,
191 path: None,
192 },
193 }
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub struct VersionSource {
201 #[serde(default, rename = "type")]
203 pub source_type: SourceType,
204 #[serde(default)]
205 pub path: Option<String>,
206}
207
208impl Default for VersionSource {
209 fn default() -> Self {
210 Self {
211 source_type: SourceType::Auto,
212 path: None,
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum SourceType {
221 Cargo,
222 Pyproject,
223 TagOnly,
225 Pubspec,
226 #[serde(rename = "package.json")]
227 PackageJson,
228 #[default]
230 Auto,
231}
232
233#[derive(Debug, Clone, Serialize)]
239#[serde(rename_all = "snake_case")]
240pub struct Scope {
241 pub name: String,
242 pub dir: String,
243 #[serde(default)]
244 pub language: Language,
245 #[serde(default)]
246 pub framework: String,
247 #[serde(default)]
248 pub build_tool: BuildTool,
249 #[serde(default)]
250 pub registry: Registry,
251 #[serde(default)]
252 pub release: StageRelease,
253 #[serde(default)]
254 pub test_threshold: Option<f64>,
255 #[serde(default)]
256 pub ci_workflow: Option<String>,
257}
258
259#[derive(Debug, Clone, PartialEq, Serialize)]
261#[serde(rename_all = "snake_case")]
262pub enum Language {
263 Rust,
264 Python,
265 Go,
266 Dart,
267 #[serde(rename = "typescript")]
268 TypeScript,
269 Unknown(String),
270}
271
272impl Default for Language {
273 fn default() -> Self {
274 Self::Unknown("auto".into())
275 }
276}
277
278#[derive(Debug, Clone, PartialEq, Serialize)]
280#[serde(rename_all = "snake_case")]
281pub enum BuildTool {
282 Cargo,
283 Uv,
284 Go,
285 Flutter,
286 Npm,
287 Unknown(String),
288}
289
290impl Default for BuildTool {
291 fn default() -> Self {
292 Self::Unknown("auto".into())
293 }
294}
295
296impl<'de> Deserialize<'de> for Language {
299 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
300 where
301 D: Deserializer<'de>,
302 {
303 let s = String::deserialize(deserializer)?;
304 Ok(match s.as_str() {
305 "rust" => Language::Rust,
306 "python" => Language::Python,
307 "go" => Language::Go,
308 "dart" => Language::Dart,
309 "typescript" | "ts" | "node" => Language::TypeScript,
310 other => Language::Unknown(other.to_string()),
311 })
312 }
313}
314
315impl<'de> Deserialize<'de> for BuildTool {
316 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
317 where
318 D: Deserializer<'de>,
319 {
320 let s = String::deserialize(deserializer)?;
321 Ok(match s.as_str() {
322 "cargo" => BuildTool::Cargo,
323 "uv" | "poetry" | "pdm" => BuildTool::Uv,
324 "go" => BuildTool::Go,
325 "flutter" => BuildTool::Flutter,
326 "npm" | "pnpm" | "yarn" | "bun" => BuildTool::Npm,
327 other => BuildTool::Unknown(other.to_string()),
328 })
329 }
330}
331
332#[derive(Debug, Deserialize)]
336#[serde(rename_all = "snake_case")]
337struct ScopeConfig {
338 dir: String,
339 #[serde(default)]
340 language: Option<Language>,
341 #[serde(default)]
342 framework: Option<String>,
343 #[serde(default)]
344 build_tool: Option<BuildTool>,
345 #[serde(default)]
346 registry: Option<Registry>,
347 #[serde(default)]
348 release: Option<StageRelease>,
349 #[serde(default)]
350 test_threshold: Option<f64>,
351 #[serde(default)]
352 ci_workflow: Option<String>,
353}
354
355fn deserialize_scopes<'de, D>(deserializer: D) -> Result<Vec<Scope>, D::Error>
356where
357 D: Deserializer<'de>,
358{
359 struct ScopesVisitor;
361
362 impl<'de> Visitor<'de> for ScopesVisitor {
363 type Value = Vec<Scope>;
364
365 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
366 f.write_str("作用域映射")
367 }
368
369 fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
370 where
371 M: MapAccess<'de>,
372 {
373 let mut scopes = Vec::new();
374 while let Some((name, config)) = access.next_entry::<String, ScopeConfig>()? {
375 scopes.push(Scope {
376 name,
377 dir: config.dir,
378 language: config.language.unwrap_or(Language::Unknown("auto".into())),
379 framework: config.framework.unwrap_or_default(),
380 build_tool: config
381 .build_tool
382 .unwrap_or(BuildTool::Unknown("auto".into())),
383 registry: config.registry.unwrap_or(Registry::None),
384 release: config.release.unwrap_or_default(),
385 test_threshold: config.test_threshold,
386 ci_workflow: config.ci_workflow,
387 });
388 }
389 Ok(scopes)
390 }
391 }
392
393 deserializer.deserialize_map(ScopesVisitor)
394}
395
396impl Contract {
399 pub fn scope_release<'a>(&'a self, scope: &'a Scope) -> &'a StageRelease {
401 let has_custom =
402 !scope.release.pre_publish.is_empty() || scope.release.changelog != "CHANGELOG.md";
403 if has_custom {
404 &scope.release
405 } else {
406 &self.stages.release
407 }
408 }
409
410 pub fn scope_test_threshold(&self, scope: &Scope) -> f64 {
412 scope.test_threshold.unwrap_or(self.stages.test.threshold)
413 }
414
415 pub fn find_scope_by_path(&self, current_dir: &Path) -> Option<&Scope> {
420 let current_str = current_dir.to_string_lossy();
421 self.scopes
422 .iter()
423 .filter(|s| current_str.starts_with(&s.dir) || s.dir == ".")
424 .max_by_key(|s| s.dir.len())
425 }
426
427 pub fn resolve_language(&self, scope: &Scope, scope_dir: &Path) -> Language {
429 match &scope.language {
430 Language::Unknown(_) => detect_language_by_files(scope_dir),
431 lang => lang.clone(),
432 }
433 }
434}
435
436pub fn detect_language_by_files(dir: &Path) -> Language {
438 if dir.join("Cargo.toml").exists() {
439 Language::Rust
440 } else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
441 Language::Python
442 } else if dir.join("go.mod").exists() {
443 Language::Go
444 } else if dir.join("pubspec.yaml").exists() {
445 Language::Dart
446 } else if dir.join("package.json").exists() {
447 Language::TypeScript
448 } else {
449 Language::Unknown("无法识别".into())
450 }
451}
452
453#[cfg(test)]
458mod tests {
459 use super::*;
460 use serde_yaml;
461
462 fn parse_yaml(s: &str) -> Contract {
463 serde_yaml::from_str(s).expect("YAML 应能解析")
464 }
465
466 #[test]
469 fn test_full_contract() {
470 let yaml = r#"
471stages:
472 build:
473 command: cargo build
474 test:
475 command: cargo test
476 threshold: 80.0
477 release:
478 changelog: CHANGELOG.md
479 pre_publish:
480 - cargo publish
481
482platform:
483 source_control: github
484 pipeline: github_actions
485 artifact_registry: crates
486
487sources:
488 version:
489 type: cargo
490
491scopes:
492 cli:
493 dir: src/cli
494 language: rust
495 build_tool: cargo
496 registry: crates
497 test_threshold: 90.0
498 web:
499 dir: src/web
500 language: typescript
501 build_tool: npm
502"#;
503 let c: Contract = parse_yaml(yaml);
504 assert_eq!(c.stages.build.command.as_deref(), Some("cargo build"));
505 assert_eq!(c.stages.test.threshold, 80.0);
506 assert_eq!(c.stages.test.command.as_deref(), Some("cargo test"));
507 assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
508 assert_eq!(
509 c.stages.release.pre_publish,
510 vec!["cargo publish".to_string()]
511 );
512
513 assert_eq!(c.platform.source_control, SourceControl::Github);
514 assert_eq!(c.platform.pipeline, Pipeline::GithubActions);
515 assert_eq!(c.platform.artifact_registry, Registry::Crates);
516
517 assert_eq!(c.sources.version.source_type, SourceType::Cargo);
518
519 assert_eq!(c.scopes.len(), 2);
520
521 let cli = &c.scopes[0];
522 assert_eq!(cli.name, "cli");
523 assert_eq!(cli.dir, "src/cli");
524 assert_eq!(cli.language, Language::Rust);
525 assert_eq!(cli.build_tool, BuildTool::Cargo);
526 assert_eq!(cli.registry, Registry::Crates);
527 assert_eq!(cli.test_threshold, Some(90.0));
528
529 let web = &c.scopes[1];
530 assert_eq!(web.name, "web");
531 assert_eq!(web.language, Language::TypeScript);
532 assert_eq!(web.build_tool, BuildTool::Npm);
533 }
534
535 #[test]
538 fn test_empty_contract() {
539 let yaml = r#"
540stages:
541scopes:
542"#;
543 let c: Contract = parse_yaml(yaml);
544 assert_eq!(c.stages.build.command, None);
545 assert_eq!(c.stages.test.threshold, 70.0);
546 assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
547 assert_eq!(c.platform.source_control, SourceControl::Github);
548 assert_eq!(c.sources.version.source_type, SourceType::Auto);
549 assert!(c.scopes.is_empty());
550 }
551
552 #[test]
553 fn test_fully_empty_yaml() {
554 let c: Contract = serde_yaml::from_str("").unwrap_or_default();
555 assert_eq!(c.stages.test.threshold, 70.0);
556 assert!(c.scopes.is_empty());
557 }
558
559 #[test]
562 fn test_language_parse() {
563 let c: Contract = parse_yaml(
564 r#"
565scopes:
566 a:
567 dir: .
568 language: rust
569 b:
570 dir: .
571 language: typescript
572 c:
573 dir: .
574 language: ts
575 d:
576 dir: .
577 language: node
578 e:
579 dir: .
580 language: unknown_lang
581"#,
582 );
583 assert_eq!(c.scopes[0].language, Language::Rust);
584 assert_eq!(c.scopes[1].language, Language::TypeScript);
585 assert_eq!(c.scopes[2].language, Language::TypeScript);
586 assert_eq!(c.scopes[3].language, Language::TypeScript);
587 assert_eq!(
588 c.scopes[4].language,
589 Language::Unknown("unknown_lang".into())
590 );
591 }
592
593 #[test]
596 fn test_registry_parse() {
597 let c: Contract = parse_yaml(
598 r#"
599platform:
600 artifact_registry: pypi
601scopes:
602 s:
603 dir: .
604 registry: github_releases
605"#,
606 );
607 assert_eq!(c.platform.artifact_registry, Registry::PyPI);
608 assert_eq!(c.scopes[0].registry, Registry::GitHubReleases);
609 }
610
611 #[test]
614 fn test_source_type() {
615 let c: Contract = parse_yaml(
616 r#"
617sources:
618 version:
619 type: package.json
620"#,
621 );
622 assert_eq!(c.sources.version.source_type, SourceType::PackageJson);
623 }
624
625 #[test]
628 fn test_scope_release_fallback() {
629 let c: Contract = parse_yaml(
630 r#"
631stages:
632 release:
633 changelog: CHANGELOG.md
634 pre_publish:
635 - cargo publish
636scopes:
637 cli:
638 dir: src/cli
639 language: rust
640"#,
641 );
642 let cli = &c.scopes[0];
643 let rel = c.scope_release(cli);
644 assert_eq!(rel.pre_publish, vec!["cargo publish".to_string()]);
645 }
646
647 #[test]
648 fn test_scope_release_override() {
649 let c: Contract = parse_yaml(
650 r#"
651stages:
652 release:
653 changelog: CHANGELOG.md
654scopes:
655 cli:
656 dir: src/cli
657 language: rust
658 release:
659 changelog: docs/CHANGELOG.md
660"#,
661 );
662 let cli = &c.scopes[0];
663 let rel = c.scope_release(cli);
664 assert_eq!(rel.changelog, "docs/CHANGELOG.md");
665 }
666
667 #[test]
668 fn test_scope_test_threshold() {
669 let c: Contract = parse_yaml(
670 r#"
671stages:
672 test:
673 threshold: 70.0
674scopes:
675 a:
676 dir: .
677 b:
678 dir: .
679 test_threshold: 90.0
680"#,
681 );
682 assert_eq!(c.scope_test_threshold(&c.scopes[0]), 70.0);
683 assert_eq!(c.scope_test_threshold(&c.scopes[1]), 90.0);
684 }
685
686 #[test]
689 fn test_find_scope_by_path() {
690 let c: Contract = parse_yaml(
691 r#"
692scopes:
693 root:
694 dir: .
695 cli:
696 dir: src/cli
697 web:
698 dir: src/web
699"#,
700 );
701 assert_eq!(
702 c.find_scope_by_path(std::path::Path::new("src/cli/sub"))
703 .map(|s| s.name.as_str()),
704 Some("cli")
705 );
706 assert_eq!(
707 c.find_scope_by_path(std::path::Path::new("src/web"))
708 .map(|s| s.name.as_str()),
709 Some("web")
710 );
711 assert_eq!(
712 c.find_scope_by_path(std::path::Path::new("unknown"))
713 .map(|s| s.name.as_str()),
714 Some("root")
715 );
716 }
717
718 #[test]
721 fn test_resolve_language_declared() {
722 let c: Contract = parse_yaml(
723 r#"
724scopes:
725 cli:
726 dir: .
727 language: rust
728"#,
729 );
730 let lang = c.resolve_language(&c.scopes[0], std::path::Path::new("/tmp"));
731 assert_eq!(lang, Language::Rust);
732 }
733
734 #[test]
735 fn test_resolve_language_auto() {
736 let d = tempfile::tempdir().unwrap();
737 std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
738 let c: Contract = parse_yaml(
739 r#"
740scopes:
741 cli:
742 dir: .
743"#,
744 );
745 let lang = c.resolve_language(&c.scopes[0], d.path());
746 assert_eq!(lang, Language::Rust);
747 }
748
749 #[test]
752 fn test_detect_by_files() {
753 let d = tempfile::tempdir().unwrap();
754 assert_eq!(
755 detect_language_by_files(d.path()),
756 Language::Unknown("无法识别".into())
757 );
758 std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
759 assert_eq!(detect_language_by_files(d.path()), Language::Rust);
760 std::fs::write(d.path().join("go.mod"), "").unwrap();
761 assert_eq!(detect_language_by_files(d.path()), Language::Rust);
763 }
764}