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#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
137#[serde(rename_all = "snake_case")]
138pub enum SourceControl {
139 #[default]
140 Github,
141 Gitlab,
142 Gitee,
143}
144
145#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
147#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
148#[serde(rename_all = "snake_case")]
149pub enum Pipeline {
150 #[default]
151 #[serde(rename = "github_actions")]
152 GithubActions,
153 #[serde(rename = "gitlab_ci")]
154 GitlabCi,
155 Jenkins,
156}
157
158#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
162#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
163#[serde(rename_all = "snake_case")]
164pub enum Registry {
165 Crates,
166 #[serde(rename = "pypi")]
167 PyPI,
168 #[serde(rename = "pubdev")]
169 PubDev,
170 Npm,
171 #[serde(rename = "github_releases")]
172 GitHubReleases,
173 Docker,
174 #[default]
175 #[serde(other)]
176 None,
177}
178
179impl fmt::Display for Registry {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 match self {
182 Self::Crates => write!(f, "crates.io"),
183 Self::PyPI => write!(f, "PyPI"),
184 Self::PubDev => write!(f, "pub.dev"),
185 Self::Npm => write!(f, "npm"),
186 Self::GitHubReleases => write!(f, "GitHub Releases"),
187 Self::Docker => write!(f, "Docker"),
188 Self::None => write!(f, "(none)"),
189 }
190 }
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub struct Source {
199 #[serde(default)]
200 pub version: VersionSource,
201}
202
203impl Default for Source {
204 fn default() -> Self {
205 Self {
206 version: VersionSource {
207 source_type: SourceType::Auto,
208 path: None,
209 },
210 }
211 }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub struct VersionSource {
218 #[serde(default, rename = "type")]
220 pub source_type: SourceType,
221 #[serde(default)]
222 pub path: Option<String>,
223}
224
225impl Default for VersionSource {
226 fn default() -> Self {
227 Self {
228 source_type: SourceType::Auto,
229 path: None,
230 }
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
236#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
237#[serde(rename_all = "snake_case")]
238pub enum SourceType {
239 Cargo,
240 Pyproject,
241 TagOnly,
243 Pubspec,
244 #[serde(rename = "package.json")]
245 PackageJson,
246 #[default]
248 Auto,
249}
250
251impl SourceType {
252 pub fn detect(dir: &Path) -> Self {
254 if dir.join("Cargo.toml").exists() {
255 Self::Cargo
256 } else if dir.join("pyproject.toml").exists() {
257 Self::Pyproject
258 } else if dir.join("pubspec.yaml").exists() {
259 Self::Pubspec
260 } else if dir.join("package.json").exists() {
261 Self::PackageJson
262 } else {
263 Self::TagOnly
264 }
265 }
266}
267
268#[derive(Debug, Clone, Serialize)]
274#[serde(rename_all = "snake_case")]
275pub struct Scope {
276 pub name: String,
277 pub dir: String,
278 #[serde(default)]
279 pub language: Language,
280 #[serde(default)]
281 pub framework: String,
282 #[serde(default)]
283 pub build_tool: BuildTool,
284 #[serde(default)]
285 pub registry: Registry,
286 #[serde(default)]
287 pub release: StageRelease,
288 #[serde(default)]
289 pub test_threshold: Option<f64>,
290 #[serde(default)]
291 pub ci_workflow: Option<String>,
292}
293
294#[derive(Debug, Clone, PartialEq, Serialize)]
296#[serde(rename_all = "snake_case")]
297pub enum Language {
298 Rust,
299 Python,
300 Go,
301 Dart,
302 #[serde(rename = "typescript")]
303 TypeScript,
304 Unknown(String),
305}
306
307impl Default for Language {
308 fn default() -> Self {
309 Self::Unknown("auto".into())
310 }
311}
312
313impl Language {
314 pub fn as_str(&self) -> &str {
316 match self {
317 Self::Rust => "rust",
318 Self::Python => "python",
319 Self::Go => "go",
320 Self::Dart => "dart",
321 Self::TypeScript => "typescript",
322 Self::Unknown(s) => s,
323 }
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Serialize)]
329#[serde(rename_all = "snake_case")]
330pub enum BuildTool {
331 Cargo,
332 Uv,
333 Go,
334 Flutter,
335 Npm,
336 Unknown(String),
337}
338
339impl Default for BuildTool {
340 fn default() -> Self {
341 Self::Unknown("auto".into())
342 }
343}
344
345impl BuildTool {
346 pub fn as_str(&self) -> &str {
348 match self {
349 Self::Cargo => "cargo",
350 Self::Uv => "uv",
351 Self::Go => "go",
352 Self::Flutter => "flutter",
353 Self::Npm => "npm",
354 Self::Unknown(s) => s,
355 }
356 }
357}
358
359impl<'de> Deserialize<'de> for Language {
362 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
363 where
364 D: Deserializer<'de>,
365 {
366 let s = String::deserialize(deserializer)?;
367 Ok(match s.as_str() {
368 "rust" => Language::Rust,
369 "python" => Language::Python,
370 "go" => Language::Go,
371 "dart" => Language::Dart,
372 "typescript" | "ts" | "node" => Language::TypeScript,
373 other => Language::Unknown(other.to_string()),
374 })
375 }
376}
377
378impl<'de> Deserialize<'de> for BuildTool {
379 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
380 where
381 D: Deserializer<'de>,
382 {
383 let s = String::deserialize(deserializer)?;
384 Ok(match s.as_str() {
385 "cargo" => BuildTool::Cargo,
386 "uv" | "poetry" | "pdm" => BuildTool::Uv,
387 "go" => BuildTool::Go,
388 "flutter" => BuildTool::Flutter,
389 "npm" | "pnpm" | "yarn" | "bun" => BuildTool::Npm,
390 other => BuildTool::Unknown(other.to_string()),
391 })
392 }
393}
394
395#[derive(Debug, Deserialize)]
399#[serde(rename_all = "snake_case")]
400struct ScopeConfig {
401 dir: String,
402 #[serde(default)]
403 language: Option<Language>,
404 #[serde(default)]
405 framework: Option<String>,
406 #[serde(default)]
407 build_tool: Option<BuildTool>,
408 #[serde(default)]
409 registry: Option<Registry>,
410 #[serde(default)]
411 release: Option<StageRelease>,
412 #[serde(default)]
413 test_threshold: Option<f64>,
414 #[serde(default)]
415 ci_workflow: Option<String>,
416}
417
418fn deserialize_scopes<'de, D>(deserializer: D) -> Result<Vec<Scope>, D::Error>
419where
420 D: Deserializer<'de>,
421{
422 struct ScopesVisitor;
424
425 impl<'de> Visitor<'de> for ScopesVisitor {
426 type Value = Vec<Scope>;
427
428 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
429 f.write_str("作用域映射")
430 }
431
432 fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
433 where
434 M: MapAccess<'de>,
435 {
436 let mut scopes = Vec::new();
437 while let Some((name, config)) = access.next_entry::<String, ScopeConfig>()? {
438 scopes.push(Scope {
439 name,
440 dir: config.dir,
441 language: config.language.unwrap_or(Language::Unknown("auto".into())),
442 framework: config.framework.unwrap_or_default(),
443 build_tool: config
444 .build_tool
445 .unwrap_or(BuildTool::Unknown("auto".into())),
446 registry: config.registry.unwrap_or(Registry::None),
447 release: config.release.unwrap_or_default(),
448 test_threshold: config.test_threshold,
449 ci_workflow: config.ci_workflow,
450 });
451 }
452 Ok(scopes)
453 }
454 }
455
456 deserializer.deserialize_map(ScopesVisitor)
457}
458
459impl Contract {
462 pub fn scope_release<'a>(&'a self, scope: &'a Scope) -> &'a StageRelease {
464 let has_custom =
465 !scope.release.pre_publish.is_empty() || scope.release.changelog != "CHANGELOG.md";
466 if has_custom {
467 &scope.release
468 } else {
469 &self.stages.release
470 }
471 }
472
473 pub fn scope_test_threshold(&self, scope: &Scope) -> f64 {
475 scope.test_threshold.unwrap_or(self.stages.test.threshold)
476 }
477
478 pub fn find_scope_by_path(&self, current_dir: &Path) -> Option<&Scope> {
483 let current_str = current_dir.to_string_lossy();
484 self.scopes
485 .iter()
486 .filter(|s| current_str.starts_with(&s.dir) || s.dir == ".")
487 .max_by_key(|s| s.dir.len())
488 }
489
490 pub fn resolve_language(&self, scope: &Scope, scope_dir: &Path) -> Language {
492 match &scope.language {
493 Language::Unknown(_) => detect_language_by_files(scope_dir),
494 lang => lang.clone(),
495 }
496 }
497
498 pub fn validate(&self, repo_path: &Path) -> Vec<String> {
511 let mut errors = Vec::new();
512 for scope in &self.scopes {
513 let dir = repo_path.join(&scope.dir);
514 if !dir.exists() {
515 errors.push(format!("scope '{}' 目录不存在: {}", scope.name, scope.dir));
516 }
517 }
518 errors
519 }
520}
521
522pub fn detect_language_by_files(dir: &Path) -> Language {
524 if dir.join("Cargo.toml").exists() {
525 Language::Rust
526 } else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
527 Language::Python
528 } else if dir.join("go.mod").exists() {
529 Language::Go
530 } else if dir.join("pubspec.yaml").exists() {
531 Language::Dart
532 } else if dir.join("package.json").exists() {
533 Language::TypeScript
534 } else {
535 Language::Unknown("无法识别".into())
536 }
537}
538
539#[cfg(test)]
544mod tests {
545 use super::*;
546 use serde_yaml;
547
548 fn parse_yaml(s: &str) -> Contract {
549 serde_yaml::from_str(s).expect("YAML 应能解析")
550 }
551
552 #[test]
555 fn test_full_contract() {
556 let yaml = r#"
557stages:
558 build:
559 command: cargo build
560 test:
561 command: cargo test
562 threshold: 80.0
563 release:
564 changelog: CHANGELOG.md
565 pre_publish:
566 - cargo publish
567
568platform:
569 source_control: github
570 pipeline: github_actions
571 artifact_registry: crates
572
573sources:
574 version:
575 type: cargo
576
577scopes:
578 cli:
579 dir: src/cli
580 language: rust
581 build_tool: cargo
582 registry: crates
583 test_threshold: 90.0
584 web:
585 dir: src/web
586 language: typescript
587 build_tool: npm
588"#;
589 let c: Contract = parse_yaml(yaml);
590 assert_eq!(c.stages.build.command.as_deref(), Some("cargo build"));
591 assert_eq!(c.stages.test.threshold, 80.0);
592 assert_eq!(c.stages.test.command.as_deref(), Some("cargo test"));
593 assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
594 assert_eq!(
595 c.stages.release.pre_publish,
596 vec!["cargo publish".to_string()]
597 );
598
599 assert_eq!(c.platform.source_control, SourceControl::Github);
600 assert_eq!(c.platform.pipeline, Pipeline::GithubActions);
601 assert_eq!(c.platform.artifact_registry, Registry::Crates);
602
603 assert_eq!(c.sources.version.source_type, SourceType::Cargo);
604
605 assert_eq!(c.scopes.len(), 2);
606
607 let cli = &c.scopes[0];
608 assert_eq!(cli.name, "cli");
609 assert_eq!(cli.dir, "src/cli");
610 assert_eq!(cli.language, Language::Rust);
611 assert_eq!(cli.build_tool, BuildTool::Cargo);
612 assert_eq!(cli.registry, Registry::Crates);
613 assert_eq!(cli.test_threshold, Some(90.0));
614
615 let web = &c.scopes[1];
616 assert_eq!(web.name, "web");
617 assert_eq!(web.language, Language::TypeScript);
618 assert_eq!(web.build_tool, BuildTool::Npm);
619 }
620
621 #[test]
624 fn test_empty_contract() {
625 let yaml = r#"
626stages:
627scopes:
628"#;
629 let c: Contract = parse_yaml(yaml);
630 assert_eq!(c.stages.build.command, None);
631 assert_eq!(c.stages.test.threshold, 70.0);
632 assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
633 assert_eq!(c.platform.source_control, SourceControl::Github);
634 assert_eq!(c.sources.version.source_type, SourceType::Auto);
635 assert!(c.scopes.is_empty());
636 }
637
638 #[test]
639 fn test_fully_empty_yaml() {
640 let c: Contract = serde_yaml::from_str("").unwrap_or_default();
641 assert_eq!(c.stages.test.threshold, 70.0);
642 assert!(c.scopes.is_empty());
643 }
644
645 #[test]
648 fn test_language_parse() {
649 let c: Contract = parse_yaml(
650 r#"
651scopes:
652 a:
653 dir: .
654 language: rust
655 b:
656 dir: .
657 language: typescript
658 c:
659 dir: .
660 language: ts
661 d:
662 dir: .
663 language: node
664 e:
665 dir: .
666 language: unknown_lang
667"#,
668 );
669 assert_eq!(c.scopes[0].language, Language::Rust);
670 assert_eq!(c.scopes[1].language, Language::TypeScript);
671 assert_eq!(c.scopes[2].language, Language::TypeScript);
672 assert_eq!(c.scopes[3].language, Language::TypeScript);
673 assert_eq!(
674 c.scopes[4].language,
675 Language::Unknown("unknown_lang".into())
676 );
677 }
678
679 #[test]
682 fn test_registry_parse() {
683 let c: Contract = parse_yaml(
684 r#"
685platform:
686 artifact_registry: pypi
687scopes:
688 s:
689 dir: .
690 registry: github_releases
691"#,
692 );
693 assert_eq!(c.platform.artifact_registry, Registry::PyPI);
694 assert_eq!(c.scopes[0].registry, Registry::GitHubReleases);
695 }
696
697 #[test]
700 fn test_source_type() {
701 let c: Contract = parse_yaml(
702 r#"
703sources:
704 version:
705 type: package.json
706"#,
707 );
708 assert_eq!(c.sources.version.source_type, SourceType::PackageJson);
709 }
710
711 #[test]
714 fn test_scope_release_fallback() {
715 let c: Contract = parse_yaml(
716 r#"
717stages:
718 release:
719 changelog: CHANGELOG.md
720 pre_publish:
721 - cargo publish
722scopes:
723 cli:
724 dir: src/cli
725 language: rust
726"#,
727 );
728 let cli = &c.scopes[0];
729 let rel = c.scope_release(cli);
730 assert_eq!(rel.pre_publish, vec!["cargo publish".to_string()]);
731 }
732
733 #[test]
734 fn test_scope_release_override() {
735 let c: Contract = parse_yaml(
736 r#"
737stages:
738 release:
739 changelog: CHANGELOG.md
740scopes:
741 cli:
742 dir: src/cli
743 language: rust
744 release:
745 changelog: docs/CHANGELOG.md
746"#,
747 );
748 let cli = &c.scopes[0];
749 let rel = c.scope_release(cli);
750 assert_eq!(rel.changelog, "docs/CHANGELOG.md");
751 }
752
753 #[test]
754 fn test_scope_test_threshold() {
755 let c: Contract = parse_yaml(
756 r#"
757stages:
758 test:
759 threshold: 70.0
760scopes:
761 a:
762 dir: .
763 b:
764 dir: .
765 test_threshold: 90.0
766"#,
767 );
768 assert_eq!(c.scope_test_threshold(&c.scopes[0]), 70.0);
769 assert_eq!(c.scope_test_threshold(&c.scopes[1]), 90.0);
770 }
771
772 #[test]
775 fn test_find_scope_by_path() {
776 let c: Contract = parse_yaml(
777 r#"
778scopes:
779 root:
780 dir: .
781 cli:
782 dir: src/cli
783 web:
784 dir: src/web
785"#,
786 );
787 assert_eq!(
788 c.find_scope_by_path(std::path::Path::new("src/cli/sub"))
789 .map(|s| s.name.as_str()),
790 Some("cli")
791 );
792 assert_eq!(
793 c.find_scope_by_path(std::path::Path::new("src/web"))
794 .map(|s| s.name.as_str()),
795 Some("web")
796 );
797 assert_eq!(
798 c.find_scope_by_path(std::path::Path::new("unknown"))
799 .map(|s| s.name.as_str()),
800 Some("root")
801 );
802 }
803
804 #[test]
807 fn test_resolve_language_declared() {
808 let c: Contract = parse_yaml(
809 r#"
810scopes:
811 cli:
812 dir: .
813 language: rust
814"#,
815 );
816 let lang = c.resolve_language(&c.scopes[0], std::path::Path::new("/tmp"));
817 assert_eq!(lang, Language::Rust);
818 }
819
820 #[test]
821 fn test_resolve_language_auto() {
822 let d = tempfile::tempdir().unwrap();
823 std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
824 let c: Contract = parse_yaml(
825 r#"
826scopes:
827 cli:
828 dir: .
829"#,
830 );
831 let lang = c.resolve_language(&c.scopes[0], d.path());
832 assert_eq!(lang, Language::Rust);
833 }
834
835 #[test]
838 fn test_detect_by_files() {
839 let d = tempfile::tempdir().unwrap();
840 assert_eq!(
841 detect_language_by_files(d.path()),
842 Language::Unknown("无法识别".into())
843 );
844 std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
845 assert_eq!(detect_language_by_files(d.path()), Language::Rust);
846 std::fs::write(d.path().join("go.mod"), "").unwrap();
847 assert_eq!(detect_language_by_files(d.path()), Language::Rust);
849 }
850}