use std::path::Path;
#[derive(Debug)]
pub struct Contract {
pub stages: Stages,
pub platforms: Platforms,
pub sources: Sources,
pub scopes: Vec<Scope>,
}
#[derive(Debug, Clone)]
pub struct Stages {
pub build: StageBuild,
pub test: StageTest,
pub release: StageRelease,
}
impl Default for Stages {
fn default() -> Self {
Self {
build: StageBuild { command: None },
test: StageTest {
command: None,
threshold: 70.0,
},
release: StageRelease {
changelog: "CHANGELOG.md".into(),
pre_publish: Vec::new(),
},
}
}
}
#[derive(Debug, Clone)]
pub struct StageBuild {
pub command: Option<String>,
}
#[derive(Debug, Clone)]
pub struct StageTest {
pub command: Option<String>,
pub threshold: f64,
}
#[derive(Debug, Clone)]
pub struct StageRelease {
pub changelog: String,
pub pre_publish: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Platforms {
pub source_control: String,
pub ci: String,
pub artifact_registry: Registry,
}
impl Default for Platforms {
fn default() -> Self {
Self {
source_control: "github".into(),
ci: "github_actions".into(),
artifact_registry: Registry::None,
}
}
}
#[derive(Debug, Clone)]
pub struct Sources {
pub version: VersionSource,
}
impl Default for Sources {
fn default() -> Self {
Self {
version: VersionSource {
source_type: SourceType::Auto,
path: None,
},
}
}
}
#[derive(Debug, Clone)]
pub struct VersionSource {
pub source_type: SourceType,
pub path: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SourceType {
Cargo,
Pyproject,
TagOnly,
Pubspec,
PackageJson,
Auto,
}
#[derive(Debug, Clone)]
pub struct Scope {
pub name: String,
pub dir: String,
pub language: Language,
pub framework: String,
pub build_tool: BuildTool,
pub registry: Registry,
pub release: StageRelease,
pub test_threshold: Option<f64>,
pub ci_workflow: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Language {
Rust,
Python,
Go,
Dart,
TypeScript,
Unknown(String),
}
impl Language {
pub fn is_supported(&self) -> bool {
!matches!(self, Language::Unknown(_))
}
pub fn name(&self) -> &str {
match self {
Language::Rust => "Rust",
Language::Python => "Python",
Language::Go => "Go",
Language::Dart => "Dart",
Language::TypeScript => "TypeScript",
Language::Unknown(s) => s,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum BuildTool {
Cargo,
Uv,
Go,
Flutter,
Npm,
Unknown(String),
}
impl BuildTool {
pub fn is_supported(&self) -> bool {
!matches!(self, BuildTool::Unknown(_))
}
pub fn name(&self) -> &str {
match self {
BuildTool::Cargo => "cargo",
BuildTool::Uv => "uv",
BuildTool::Go => "go build",
BuildTool::Flutter => "flutter build",
BuildTool::Npm => "npm",
BuildTool::Unknown(s) => s,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Registry {
Crates,
PyPI,
PubDev,
Npm,
GitHubReleases,
Docker,
None,
}
impl Registry {
pub fn name(&self) -> &str {
match self {
Registry::Crates => "crates.io",
Registry::PyPI => "PyPI",
Registry::PubDev => "pub.dev",
Registry::Npm => "npm",
Registry::GitHubReleases => "GitHub Releases",
Registry::Docker => "Docker",
Registry::None => "无",
}
}
}
#[derive(Debug)]
pub struct VersionStatus {
pub tag_version: Option<String>,
pub config_version: Option<String>,
pub consistent: bool,
pub config_files: Vec<(String, Option<String>)>,
}
pub fn load(repo_path: &Path) -> Contract {
let path = repo_path.join(".quanttide/devops/contract.yaml");
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => {
eprintln!(" ℹ contract.yaml 不存在,使用默认契约");
return default_contract();
}
};
parse(&content)
}
fn parse(content: &str) -> Contract {
if let Ok(parsed) = serde_yaml::from_str::<ContractYaml>(content) {
return parsed.into_contract();
}
if serde_yaml::from_str::<serde_yaml::Value>(content).is_ok() {
eprintln!("⚠ contract.yaml: 无法按新格式解析,使用默认值");
}
default_contract()
}
fn default_contract() -> Contract {
Contract {
stages: Stages::default(),
platforms: Platforms::default(),
sources: Sources::default(),
scopes: Vec::new(),
}
}
pub fn scope_release<'a>(contract: &'a Contract, 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 {
&contract.stages.release
}
}
pub fn scope_test_threshold(contract: &Contract, scope: &Scope) -> f64 {
scope
.test_threshold
.unwrap_or(contract.stages.test.threshold)
}
pub fn resolve_language(scope: &Scope, scope_dir: &Path) -> Language {
match &scope.language {
Language::Unknown(_) => detect_by_files(scope_dir),
lang => lang.clone(),
}
}
pub fn detect_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())
}
}
pub fn version_status(repo_path: &Path, scope: &Scope) -> VersionStatus {
let tag_version = latest_tag_for_scope(repo_path, &scope.name);
let scope_dir = repo_path.join(&scope.dir);
let config_files = read_all_config_versions(&scope_dir);
let config_version = config_files
.iter()
.find(|(_, v)| v.is_some())
.and_then(|(_, v)| v.clone());
let consistent = match &tag_version {
Some(t) => config_files.iter().all(|(_, v)| match v {
Some(cv) => cv == t,
None => true, }),
None => config_version.is_none(),
};
VersionStatus {
tag_version,
config_version,
consistent,
config_files,
}
}
pub fn read_all_config_versions(dir: &Path) -> Vec<(String, Option<String>)> {
let checks: &[(&str, fn(&str) -> Option<String>)] = &[
("Cargo.toml", |c| extract_kv_version(c, "version")),
("pyproject.toml", |c| extract_kv_version(c, "version")),
("package.json", extract_json_version),
("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
];
checks
.iter()
.filter_map(|(name, extract)| {
let path = dir.join(name);
if path.exists() {
let content = std::fs::read_to_string(&path).ok()?;
Some((name.to_string(), extract(&content)))
} else {
None
}
})
.collect()
}
fn extract_kv_version(content: &str, key: &str) -> Option<String> {
let p = format!("{} = \"", key);
for line in content.lines() {
let t = line.trim();
if let Some(r) = t.strip_prefix(&p) {
if let Some(e) = r.find('"') {
let v = r[..e].to_string();
if !v.is_empty() {
return Some(v);
}
}
}
}
None
}
fn extract_json_version(content: &str) -> Option<String> {
for line in content.lines() {
let t = line.trim();
if let Some(r) = t.strip_prefix("\"version\":") {
let v = r.trim().trim_matches('"').trim_matches(',').trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
None
}
fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
let p = format!("{}:", key);
for line in content.lines() {
let t = line.trim();
if let Some(r) = t.strip_prefix(&p) {
let v = r.trim();
if !v.is_empty() && !v.starts_with('#') {
return Some(v.to_string());
}
}
}
None
}
fn latest_tag_for_scope(repo_path: &Path, scope_name: &str) -> Option<String> {
let output = std::process::Command::new("git")
.args(["tag", "--sort=-version:refname"])
.current_dir(repo_path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let prefix = format!("{}/", scope_name);
let tags: Vec<&str> = std::str::from_utf8(&output.stdout)
.ok()?
.lines()
.filter(|t| t.starts_with(&prefix) || !t.contains('/'))
.collect();
let scoped = tags.iter().find(|t| t.starts_with(&prefix));
match scoped {
Some(t) => Some(normalize_version(t)),
None => tags.first().map(|t| normalize_version(t)),
}
}
fn normalize_version(version: &str) -> String {
let after_scope = version.split('/').last().unwrap_or(version);
after_scope
.strip_prefix('v')
.unwrap_or(after_scope)
.to_string()
}
#[derive(Debug, serde::Deserialize)]
struct ContractYaml {
#[serde(default)]
stages: Option<StagesYaml>,
#[serde(default)]
platforms: Option<PlatformsYaml>,
#[serde(default)]
sources: Option<SourcesYaml>,
#[serde(default)]
scopes: Option<std::collections::BTreeMap<String, ScopeYaml>>,
}
#[derive(Debug, serde::Deserialize)]
struct StagesYaml {
#[serde(default)]
build: Option<BuildYaml>,
#[serde(default)]
test: Option<TestYaml>,
#[serde(default)]
release: Option<ReleaseYaml>,
}
#[derive(Debug, serde::Deserialize)]
struct BuildYaml {
command: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct TestYaml {
command: Option<String>,
#[serde(default)]
threshold: Option<f64>,
}
#[derive(Debug, serde::Deserialize)]
struct ReleaseYaml {
#[serde(default)]
changelog: Option<String>,
#[serde(default)]
pre_publish: Option<Vec<String>>,
}
#[derive(Debug, serde::Deserialize)]
struct PlatformsYaml {
#[serde(default)]
source_control: Option<String>,
#[serde(default)]
ci: Option<String>,
#[serde(default)]
artifact_registry: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct SourcesYaml {
#[serde(default)]
version: Option<VersionSourceYaml>,
}
#[derive(Debug, serde::Deserialize)]
struct VersionSourceYaml {
#[serde(rename = "type")]
source_type: Option<String>,
path: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct ScopeYaml {
dir: String,
#[serde(default)]
language: Option<String>,
#[serde(default)]
framework: Option<String>,
#[serde(default)]
build_tool: Option<String>,
#[serde(default)]
registry: Option<String>,
#[serde(default)]
release: Option<ReleaseYaml>,
#[serde(default)]
test_threshold: Option<f64>,
#[serde(default)]
ci_workflow: Option<String>,
}
impl ContractYaml {
fn into_contract(self) -> Contract {
let stages = self
.stages
.map(|s| Stages {
build: StageBuild {
command: s.build.and_then(|b| b.command),
},
test: StageTest {
command: s.test.as_ref().and_then(|t| t.command.clone()),
threshold: s.test.as_ref().and_then(|t| t.threshold).unwrap_or(70.0),
},
release: s
.release
.map(|r| StageRelease {
changelog: r.changelog.unwrap_or_else(|| "CHANGELOG.md".into()),
pre_publish: r.pre_publish.unwrap_or_default(),
})
.unwrap_or_default(),
})
.unwrap_or_default();
let platforms = self
.platforms
.map(|p| Platforms {
source_control: p.source_control.unwrap_or_else(|| "github".into()),
ci: p.ci.unwrap_or_else(|| "github_actions".into()),
artifact_registry: parse_registry(p.artifact_registry.as_deref()),
})
.unwrap_or_default();
let sources = self
.sources
.map(|s| Sources {
version: s
.version
.map(|v| VersionSource {
source_type: parse_source_type(v.source_type.as_deref()),
path: v.path,
})
.unwrap_or_default(),
})
.unwrap_or_default();
let scopes = self
.scopes
.unwrap_or_default()
.into_iter()
.map(|(name, cfg)| {
let lang = match cfg.language.as_deref() {
Some("rust") => Language::Rust,
Some("python") => Language::Python,
Some("go") => Language::Go,
Some("dart") => Language::Dart,
Some("typescript") | Some("ts") | Some("node") => Language::TypeScript,
Some(other) => Language::Unknown(other.into()),
None => Language::Unknown("auto".into()),
};
let build_tool = match cfg.build_tool.as_deref() {
Some("cargo") => BuildTool::Cargo,
Some("uv") => BuildTool::Uv,
Some("go") => BuildTool::Go,
Some("flutter") => BuildTool::Flutter,
Some("npm") => BuildTool::Npm,
Some(other) => BuildTool::Unknown(other.into()),
None => BuildTool::Unknown("auto".into()),
};
let release = cfg
.release
.map(|r| StageRelease {
changelog: r.changelog.unwrap_or_else(|| "CHANGELOG.md".into()),
pre_publish: r.pre_publish.unwrap_or_default(),
})
.unwrap_or_default();
Scope {
name,
dir: cfg.dir,
language: lang,
framework: cfg.framework.unwrap_or_default(),
build_tool,
registry: parse_registry(cfg.registry.as_deref()),
release,
test_threshold: cfg.test_threshold,
ci_workflow: cfg.ci_workflow.clone(),
}
})
.collect();
Contract {
stages,
platforms,
sources,
scopes,
}
}
}
fn parse_registry(s: Option<&str>) -> Registry {
match s {
Some("crates") => Registry::Crates,
Some("pypi") => Registry::PyPI,
Some("pubdev") => Registry::PubDev,
Some("npm") => Registry::Npm,
Some("github") | Some("github_releases") => Registry::GitHubReleases,
Some("docker") => Registry::Docker,
_ => Registry::None,
}
}
fn parse_source_type(s: Option<&str>) -> SourceType {
match s {
Some("cargo") => SourceType::Cargo,
Some("pyproject") => SourceType::Pyproject,
Some("tag") => SourceType::TagOnly,
Some("pubspec") => SourceType::Pubspec,
Some("package.json") | Some("node") | Some("typescript") => SourceType::PackageJson,
_ => SourceType::Auto,
}
}
impl Default for StageRelease {
fn default() -> Self {
Self {
changelog: "CHANGELOG.md".into(),
pre_publish: Vec::new(),
}
}
}
impl Default for VersionSource {
fn default() -> Self {
Self {
source_type: SourceType::Auto,
path: None,
}
}
}
pub fn load_scopes(repo_path: &Path) -> Vec<Scope> {
load(repo_path).scopes
}
pub fn detect_language(dir: &Path) -> Language {
detect_by_files(dir)
}
pub fn find_scope_by_path<'a>(scopes: &'a [Scope], current_dir: &Path) -> Option<&'a Scope> {
let current_str = current_dir.to_string_lossy();
scopes
.iter()
.filter(|s| current_str.starts_with(&s.dir) || s.dir == ".")
.max_by_key(|s| s.dir.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_new_format_full() {
let d = tempfile::tempdir().unwrap();
let dir = d.path().join(".quanttide/devops");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("contract.yaml"),
r#"
stages:
build:
command: cargo build --release
test:
command: cargo test
threshold: 80
release:
changelog: CHANGELOG.md
pre_publish:
- scripts/preflight.sh
platforms:
source_control: github
ci: github_actions
artifact_registry: crates
sources:
version:
type: cargo
path: Cargo.toml
scopes:
cli:
dir: src/cli
language: rust
framework: clap
build_tool: cargo
registry: crates
studio:
dir: src/studio
language: dart
framework: flutter
build_tool: flutter
registry: pubdev
release:
changelog: src/studio/CHANGELOG.md
"#,
)
.unwrap();
let c = load(d.path());
assert_eq!(
c.stages.build.command.as_deref(),
Some("cargo build --release")
);
assert_eq!(c.stages.test.threshold, 80.0);
assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
assert_eq!(c.stages.release.pre_publish.len(), 1);
assert_eq!(c.platforms.source_control, "github");
assert_eq!(c.platforms.artifact_registry, Registry::Crates);
assert_eq!(c.sources.version.source_type, SourceType::Cargo);
assert_eq!(c.scopes.len(), 2);
assert_eq!(c.scopes[0].name, "cli");
assert_eq!(c.scopes[0].language, Language::Rust);
assert_eq!(c.scopes[0].registry, Registry::Crates);
assert_eq!(c.scopes[1].name, "studio");
assert_eq!(c.scopes[1].language, Language::Dart);
assert_eq!(c.scopes[1].release.changelog, "src/studio/CHANGELOG.md");
}
#[test]
fn test_load_new_format_minimal() {
let d = tempfile::tempdir().unwrap();
let dir = d.path().join(".quanttide/devops");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("contract.yaml"),
"scopes:\n cli:\n dir: src/cli\n",
)
.unwrap();
let c = load(d.path());
assert_eq!(c.scopes.len(), 1);
assert_eq!(c.scopes[0].name, "cli");
assert_eq!(c.stages.test.threshold, 70.0);
assert_eq!(c.platforms.source_control, "github");
}
#[test]
fn test_load_no_file() {
let d = tempfile::tempdir().unwrap();
let c = load(d.path());
assert!(c.scopes.is_empty());
}
#[test]
fn test_resolve_language_declared() {
let s = Scope {
name: "cli".into(),
dir: ".".into(),
language: Language::Rust,
framework: String::new(),
build_tool: BuildTool::Cargo,
registry: Registry::Crates,
release: StageRelease::default(),
test_threshold: None,
ci_workflow: None,
};
assert_eq!(resolve_language(&s, Path::new("/tmp")), Language::Rust);
}
#[test]
fn test_scope_test_threshold_custom() {
let mut c = default_contract();
c.stages.test.threshold = 70.0;
let s = Scope {
name: "cli".into(),
dir: ".".into(),
language: Language::Rust,
framework: String::new(),
build_tool: BuildTool::Cargo,
registry: Registry::Crates,
release: StageRelease::default(),
test_threshold: Some(90.0),
ci_workflow: None,
};
assert_eq!(scope_test_threshold(&c, &s), 90.0);
}
#[test]
fn test_scope_test_threshold_global() {
let mut c = default_contract();
c.stages.test.threshold = 70.0;
let s = Scope {
name: "cli".into(),
dir: ".".into(),
language: Language::Rust,
framework: String::new(),
build_tool: BuildTool::Cargo,
registry: Registry::Crates,
release: StageRelease::default(),
test_threshold: None,
ci_workflow: None,
};
assert_eq!(scope_test_threshold(&c, &s), 70.0);
}
#[test]
fn test_detect_by_files_rust() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
assert_eq!(detect_by_files(d.path()), Language::Rust);
}
#[test]
fn test_detect_by_files_unknown() {
let d = tempfile::tempdir().unwrap();
assert!(matches!(detect_by_files(d.path()), Language::Unknown(_)));
}
#[test]
fn test_normalize_version_v_prefix() {
assert_eq!(normalize_version("v1.2.3"), "1.2.3");
}
#[test]
fn test_normalize_version_scoped() {
assert_eq!(normalize_version("cli/v0.1.0"), "0.1.0");
}
#[test]
fn test_read_all_config_versions_cargo_only() {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("Cargo.toml"),
"[package]\nname = \"foo\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let files = read_all_config_versions(d.path());
assert_eq!(files.len(), 1);
assert_eq!(files[0].0, "Cargo.toml");
assert_eq!(files[0].1.as_deref(), Some("0.1.0"));
}
#[test]
fn test_read_all_config_versions_multi() {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("Cargo.toml"),
"[package]\nversion = \"0.2.0\"\n",
)
.unwrap();
std::fs::write(
d.path().join("pyproject.toml"),
"[project]\nversion = \"0.2.0\"\n",
)
.unwrap();
let files = read_all_config_versions(d.path());
assert_eq!(files.len(), 2);
assert!(files.iter().all(|(_, v)| v.as_deref() == Some("0.2.0")));
}
#[test]
fn test_read_all_config_versions_mismatch() {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("Cargo.toml"),
"[package]\nversion = \"0.2.0\"\n",
)
.unwrap();
std::fs::write(
d.path().join("pyproject.toml"),
"[project]\nversion = \"0.1.0\"\n",
)
.unwrap();
let files = read_all_config_versions(d.path());
assert_eq!(files.len(), 2);
assert_ne!(files[0].1, files[1].1);
}
#[test]
fn test_unknown_language_in_yaml() {
let content =
"stages:\n test:\n threshold: 70\nscopes:\n ziggy:\n dir: src/ziggy\n language: zig\n";
let c = parse(content);
assert_eq!(c.scopes.len(), 1);
assert_eq!(c.scopes[0].language, Language::Unknown("zig".into()));
}
#[test]
fn test_normalize_version_rc() {
assert_eq!(normalize_version("v1.0.0-rc.1"), "1.0.0-rc.1");
}
#[test]
fn test_normalize_version_strips_v_only() {
assert_eq!(normalize_version("v0.0.1"), "0.0.1");
assert_eq!(normalize_version("0.0.1"), "0.0.1");
}
#[test]
fn test_normalize_version_scoped_with_rc() {
assert_eq!(normalize_version("cli/v1.0.0-rc.1"), "1.0.0-rc.1");
}
#[test]
fn test_find_scope_by_path_exact_match() {
let scopes = vec![
Scope {
name: "root".into(),
dir: ".".into(),
language: Language::Unknown("auto".into()),
..scope_default()
},
Scope {
name: "cli".into(),
dir: "src/cli".into(),
language: Language::Rust,
..scope_default()
},
];
let found = find_scope_by_path(&scopes, Path::new("src/cli"));
assert_eq!(found.map(|s| s.name.as_str()), Some("cli"));
}
#[test]
fn test_find_scope_by_path_subdir() {
let scopes = vec![
Scope {
name: "root".into(),
dir: ".".into(),
language: Language::Unknown("auto".into()),
..scope_default()
},
Scope {
name: "cli".into(),
dir: "src/cli".into(),
language: Language::Rust,
..scope_default()
},
];
let found = find_scope_by_path(&scopes, Path::new("src/cli/sub/foo"));
assert_eq!(found.map(|s| s.name.as_str()), Some("cli"));
}
#[test]
fn test_find_scope_by_path_root_fallback() {
let scopes = vec![
Scope {
name: "root".into(),
dir: ".".into(),
language: Language::Unknown("auto".into()),
..scope_default()
},
Scope {
name: "cli".into(),
dir: "src/cli".into(),
language: Language::Rust,
..scope_default()
},
];
let found = find_scope_by_path(&scopes, Path::new("docs"));
assert_eq!(found.map(|s| s.name.as_str()), Some("root"));
}
#[test]
fn test_find_scope_by_path_no_match() {
let scopes = vec![];
let found = find_scope_by_path(&scopes, Path::new("src/cli"));
assert!(found.is_none());
}
fn scope_default() -> Scope {
Scope {
name: String::new(),
dir: ".".into(),
language: Language::Unknown("auto".into()),
framework: String::new(),
build_tool: BuildTool::Unknown("auto".into()),
registry: Registry::None,
release: StageRelease::default(),
test_threshold: None,
ci_workflow: None,
}
}
}