use super::{platform::*, scope::*, source::*, stage::*};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct Contract {
#[serde(default)]
pub stages: Stage,
#[serde(default)]
pub platform: Platform,
#[serde(default)]
pub sources: Source,
#[serde(default, deserialize_with = "deserialize_scopes")]
pub scopes: Vec<Scope>,
}
impl Contract {
pub fn scope_release<'a>(&'a self, scope: &'a Scope) -> &'a StageRelease {
let has_custom =
!scope.release.pre_publish.is_empty() || scope.release.changelog != "CHANGELOG.md";
if has_custom {
&scope.release
} else {
&self.stages.release
}
}
pub fn scope_test_threshold(&self, scope: &Scope) -> f64 {
scope.test_threshold.unwrap_or(self.stages.test.threshold)
}
pub fn find_scope_by_path(&self, current_dir: &Path) -> Option<&Scope> {
let current_str = current_dir.to_string_lossy();
self.scopes
.iter()
.filter(|s| current_str.starts_with(&s.dir) || s.dir == ".")
.max_by_key(|s| s.dir.len())
}
pub fn resolve_language(&self, scope: &Scope, scope_dir: &Path) -> Language {
match &scope.language {
Language::Unknown(_) => detect_language_by_files(scope_dir),
lang => lang.clone(),
}
}
pub fn validate(&self, repo_path: &Path) -> Vec<String> {
let mut errors = Vec::new();
for scope in &self.scopes {
let dir = repo_path.join(&scope.dir);
if !dir.exists() {
errors.push(format!("scope '{}' 目录不存在: {}", scope.name, scope.dir));
}
}
errors
}
}
pub fn detect_language_by_files(dir: &Path) -> Language {
if dir.join("Cargo.toml").exists() {
Language::Rust
} else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
Language::Python
} else if dir.join("go.mod").exists() {
Language::Go
} else if dir.join("pubspec.yaml").exists() {
Language::Dart
} else if dir.join("package.json").exists() {
Language::TypeScript
} else {
Language::Unknown("无法识别".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_yaml;
fn parse_yaml(s: &str) -> Contract {
serde_yaml::from_str(s).expect("YAML 应能解析")
}
#[test]
fn test_full_contract() {
let yaml = r#"
stages:
build:
command: cargo build
test:
command: cargo test
threshold: 80.0
release:
changelog: CHANGELOG.md
pre_publish:
- cargo publish
platform:
source_control: github
pipeline: github_actions
artifact_registry: crates
sources:
version:
type: cargo
scopes:
cli:
dir: src/cli
language: rust
build_tool: cargo
registry: crates
test_threshold: 90.0
web:
dir: src/web
language: typescript
build_tool: npm
"#;
let c: Contract = parse_yaml(yaml);
assert_eq!(c.stages.build.command.as_deref(), Some("cargo build"));
assert_eq!(c.stages.test.threshold, 80.0);
assert_eq!(c.stages.test.command.as_deref(), Some("cargo test"));
assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
assert_eq!(
c.stages.release.pre_publish,
vec!["cargo publish".to_string()]
);
assert_eq!(c.platform.source_control, SourceControl::Github);
assert_eq!(c.platform.pipeline, Pipeline::GithubActions);
assert_eq!(c.platform.artifact_registry, Registry::Crates);
assert_eq!(c.sources.version.source_type, SourceType::Cargo);
assert_eq!(c.scopes.len(), 2);
let cli = &c.scopes[0];
assert_eq!(cli.name, "cli");
assert_eq!(cli.dir, "src/cli");
assert_eq!(cli.language, Language::Rust);
assert_eq!(cli.build_tool, BuildTool::Cargo);
assert_eq!(cli.registry, Registry::Crates);
assert_eq!(cli.test_threshold, Some(90.0));
let web = &c.scopes[1];
assert_eq!(web.name, "web");
assert_eq!(web.language, Language::TypeScript);
assert_eq!(web.build_tool, BuildTool::Npm);
}
#[test]
fn test_empty_contract() {
let yaml = r#"
stages:
scopes:
"#;
let c: Contract = parse_yaml(yaml);
assert_eq!(c.stages.build.command, None);
assert_eq!(c.stages.test.threshold, 70.0);
assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
assert_eq!(c.platform.source_control, SourceControl::Github);
assert_eq!(c.sources.version.source_type, SourceType::Auto);
assert!(c.scopes.is_empty());
}
#[test]
fn test_fully_empty_yaml() {
let c: Contract = serde_yaml::from_str("").unwrap_or_default();
assert_eq!(c.stages.test.threshold, 70.0);
assert!(c.scopes.is_empty());
}
#[test]
fn test_language_parse() {
let c: Contract = parse_yaml(
r#"
scopes:
a:
dir: .
language: rust
b:
dir: .
language: typescript
c:
dir: .
language: ts
d:
dir: .
language: node
e:
dir: .
language: unknown_lang
"#,
);
assert_eq!(c.scopes[0].language, Language::Rust);
assert_eq!(c.scopes[1].language, Language::TypeScript);
assert_eq!(c.scopes[2].language, Language::TypeScript);
assert_eq!(c.scopes[3].language, Language::TypeScript);
assert_eq!(
c.scopes[4].language,
Language::Unknown("unknown_lang".into())
);
}
#[test]
fn test_registry_parse() {
let c: Contract = parse_yaml(
r#"
platform:
artifact_registry: pypi
scopes:
s:
dir: .
registry: github_releases
"#,
);
assert_eq!(c.platform.artifact_registry, Registry::PyPI);
assert_eq!(c.scopes[0].registry, Registry::GitHubReleases);
}
#[test]
fn test_source_type() {
let c: Contract = parse_yaml(
r#"
sources:
version:
type: package.json
"#,
);
assert_eq!(c.sources.version.source_type, SourceType::PackageJson);
}
#[test]
fn test_scope_release_fallback() {
let c: Contract = parse_yaml(
r#"
stages:
release:
changelog: CHANGELOG.md
pre_publish:
- cargo publish
scopes:
cli:
dir: src/cli
language: rust
"#,
);
let cli = &c.scopes[0];
let rel = c.scope_release(cli);
assert_eq!(rel.pre_publish, vec!["cargo publish".to_string()]);
}
#[test]
fn test_scope_release_override() {
let c: Contract = parse_yaml(
r#"
stages:
release:
changelog: CHANGELOG.md
scopes:
cli:
dir: src/cli
language: rust
release:
changelog: docs/CHANGELOG.md
"#,
);
let cli = &c.scopes[0];
let rel = c.scope_release(cli);
assert_eq!(rel.changelog, "docs/CHANGELOG.md");
}
#[test]
fn test_scope_test_threshold() {
let c: Contract = parse_yaml(
r#"
stages:
test:
threshold: 70.0
scopes:
a:
dir: .
b:
dir: .
test_threshold: 90.0
"#,
);
assert_eq!(c.scope_test_threshold(&c.scopes[0]), 70.0);
assert_eq!(c.scope_test_threshold(&c.scopes[1]), 90.0);
}
#[test]
fn test_find_scope_by_path() {
let c: Contract = parse_yaml(
r#"
scopes:
root:
dir: .
cli:
dir: src/cli
web:
dir: src/web
"#,
);
assert_eq!(
c.find_scope_by_path(std::path::Path::new("src/cli/sub"))
.map(|s| s.name.as_str()),
Some("cli")
);
assert_eq!(
c.find_scope_by_path(std::path::Path::new("src/web"))
.map(|s| s.name.as_str()),
Some("web")
);
assert_eq!(
c.find_scope_by_path(std::path::Path::new("unknown"))
.map(|s| s.name.as_str()),
Some("root")
);
}
#[test]
fn test_resolve_language_declared() {
let c: Contract = parse_yaml(
r#"
scopes:
cli:
dir: .
language: rust
"#,
);
let lang = c.resolve_language(&c.scopes[0], std::path::Path::new("/tmp"));
assert_eq!(lang, Language::Rust);
}
#[test]
fn test_resolve_language_auto() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
let c: Contract = parse_yaml(
r#"
scopes:
cli:
dir: .
"#,
);
let lang = c.resolve_language(&c.scopes[0], d.path());
assert_eq!(lang, Language::Rust);
}
#[test]
fn test_detect_by_files() {
let d = tempfile::tempdir().unwrap();
assert_eq!(
detect_language_by_files(d.path()),
Language::Unknown("无法识别".into())
);
std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
assert_eq!(detect_language_by_files(d.path()), Language::Rust);
std::fs::write(d.path().join("go.mod"), "").unwrap();
assert_eq!(detect_language_by_files(d.path()), Language::Rust);
}
}