quanttide_devops/contract/
core.rs1use super::{platform::*, scope::*, source::*, stage::*};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub struct Contract {
13 #[serde(default)]
14 pub stages: Stage,
15 #[serde(default)]
16 pub platform: Platform,
17 #[serde(default)]
18 pub sources: Source,
19 #[serde(default, deserialize_with = "deserialize_scopes")]
20 pub scopes: Vec<Scope>,
21}
22
23impl Contract {
26 pub fn scope_release<'a>(&'a self, scope: &'a Scope) -> &'a StageRelease {
28 let has_custom =
29 !scope.release.pre_publish.is_empty() || scope.release.changelog != "CHANGELOG.md";
30 if has_custom {
31 &scope.release
32 } else {
33 &self.stages.release
34 }
35 }
36
37 pub fn scope_test_threshold(&self, scope: &Scope) -> f64 {
39 scope.test_threshold.unwrap_or(self.stages.test.threshold)
40 }
41
42 pub fn find_scope_by_path(&self, current_dir: &Path) -> Option<&Scope> {
47 let current_str = current_dir.to_string_lossy();
48 self.scopes
49 .iter()
50 .filter(|s| current_str.starts_with(&s.dir) || s.dir == ".")
51 .max_by_key(|s| s.dir.len())
52 }
53
54 pub fn resolve_language(&self, scope: &Scope, scope_dir: &Path) -> Language {
56 match &scope.language {
57 Language::Unknown(_) => crate::source::config_file::detect_language(scope_dir),
58 lang => lang.clone(),
59 }
60 }
61
62 pub fn validate(&self, repo_path: &Path) -> Vec<String> {
75 let mut errors = Vec::new();
76 for scope in &self.scopes {
77 let dir = repo_path.join(&scope.dir);
78 if !dir.exists() {
79 errors.push(format!("scope '{}' 目录不存在: {}", scope.name, scope.dir));
80 }
81 }
82 errors
83 }
84}
85
86#[cfg(test)]
92mod tests {
93 use super::*;
94 use serde_yaml;
95
96 fn parse_yaml(s: &str) -> Contract {
97 serde_yaml::from_str(s).expect("YAML 应能解析")
98 }
99
100 #[test]
103 fn test_full_contract() {
104 let yaml = r#"
105stages:
106 build:
107 command: cargo build
108 test:
109 command: cargo test
110 threshold: 80.0
111 release:
112 changelog: CHANGELOG.md
113 pre_publish:
114 - cargo publish
115
116platform:
117 source_control: github
118 pipeline: github_actions
119 artifact_registry: crates
120
121sources:
122 version:
123 type: cargo
124
125scopes:
126 cli:
127 dir: src/cli
128 language: rust
129 build_tool: cargo
130 registry: crates
131 test_threshold: 90.0
132 web:
133 dir: src/web
134 language: typescript
135 build_tool: npm
136"#;
137 let c: Contract = parse_yaml(yaml);
138 assert_eq!(c.stages.build.command.as_deref(), Some("cargo build"));
139 assert_eq!(c.stages.test.threshold, 80.0);
140 assert_eq!(c.stages.test.command.as_deref(), Some("cargo test"));
141 assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
142 assert_eq!(
143 c.stages.release.pre_publish,
144 vec!["cargo publish".to_string()]
145 );
146
147 assert_eq!(c.platform.source_control, SourceControl::Github);
148 assert_eq!(c.platform.pipeline, Pipeline::GithubActions);
149 assert_eq!(c.platform.artifact_registry, Registry::Crates);
150
151 assert_eq!(c.sources.version.source_type, SourceType::Cargo);
152
153 assert_eq!(c.scopes.len(), 2);
154
155 let cli = &c.scopes[0];
156 assert_eq!(cli.name, "cli");
157 assert_eq!(cli.dir, "src/cli");
158 assert_eq!(cli.language, Language::Rust);
159 assert_eq!(cli.build_tool, BuildTool::Cargo);
160 assert_eq!(cli.registry, Registry::Crates);
161 assert_eq!(cli.test_threshold, Some(90.0));
162
163 let web = &c.scopes[1];
164 assert_eq!(web.name, "web");
165 assert_eq!(web.language, Language::TypeScript);
166 assert_eq!(web.build_tool, BuildTool::Npm);
167 }
168
169 #[test]
172 fn test_empty_contract() {
173 let yaml = r#"
174stages:
175scopes:
176"#;
177 let c: Contract = parse_yaml(yaml);
178 assert_eq!(c.stages.build.command, None);
179 assert_eq!(c.stages.test.threshold, 80.0);
180 assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
181 assert_eq!(c.platform.source_control, SourceControl::Github);
182 assert_eq!(c.sources.version.source_type, SourceType::Auto);
183 assert!(c.scopes.is_empty());
184 }
185
186 #[test]
187 fn test_fully_empty_yaml() {
188 let c: Contract = serde_yaml::from_str("").unwrap_or_default();
189 assert_eq!(c.stages.test.threshold, 80.0);
190 assert!(c.scopes.is_empty());
191 }
192
193 #[test]
196 fn test_language_parse() {
197 let c: Contract = parse_yaml(
198 r#"
199scopes:
200 a:
201 dir: .
202 language: rust
203 b:
204 dir: .
205 language: typescript
206 c:
207 dir: .
208 language: ts
209 d:
210 dir: .
211 language: node
212 e:
213 dir: .
214 language: unknown_lang
215"#,
216 );
217 assert_eq!(c.scopes[0].language, Language::Rust);
218 assert_eq!(c.scopes[1].language, Language::TypeScript);
219 assert_eq!(c.scopes[2].language, Language::TypeScript);
220 assert_eq!(c.scopes[3].language, Language::TypeScript);
221 assert_eq!(
222 c.scopes[4].language,
223 Language::Unknown("unknown_lang".into())
224 );
225 }
226
227 #[test]
230 fn test_registry_parse() {
231 let c: Contract = parse_yaml(
232 r#"
233platform:
234 artifact_registry: pypi
235scopes:
236 s:
237 dir: .
238 registry: github_releases
239"#,
240 );
241 assert_eq!(c.platform.artifact_registry, Registry::PyPI);
242 assert_eq!(c.scopes[0].registry, Registry::GitHubReleases);
243 }
244
245 #[test]
248 fn test_source_type() {
249 let c: Contract = parse_yaml(
250 r#"
251sources:
252 version:
253 type: package.json
254"#,
255 );
256 assert_eq!(c.sources.version.source_type, SourceType::PackageJson);
257 }
258
259 #[test]
262 fn test_scope_release_fallback() {
263 let c: Contract = parse_yaml(
264 r#"
265stages:
266 release:
267 changelog: CHANGELOG.md
268 pre_publish:
269 - cargo publish
270scopes:
271 cli:
272 dir: src/cli
273 language: rust
274"#,
275 );
276 let cli = &c.scopes[0];
277 let rel = c.scope_release(cli);
278 assert_eq!(rel.pre_publish, vec!["cargo publish".to_string()]);
279 }
280
281 #[test]
282 fn test_scope_release_override() {
283 let c: Contract = parse_yaml(
284 r#"
285stages:
286 release:
287 changelog: CHANGELOG.md
288scopes:
289 cli:
290 dir: src/cli
291 language: rust
292 release:
293 changelog: docs/CHANGELOG.md
294"#,
295 );
296 let cli = &c.scopes[0];
297 let rel = c.scope_release(cli);
298 assert_eq!(rel.changelog, "docs/CHANGELOG.md");
299 }
300
301 #[test]
302 fn test_scope_test_threshold() {
303 let c: Contract = parse_yaml(
304 r#"
305stages:
306 test:
307 threshold: 70.0
308scopes:
309 a:
310 dir: .
311 b:
312 dir: .
313 test_threshold: 90.0
314"#,
315 );
316 assert_eq!(c.scope_test_threshold(&c.scopes[0]), 70.0);
317 assert_eq!(c.scope_test_threshold(&c.scopes[1]), 90.0);
318 }
319
320 #[test]
323 fn test_find_scope_by_path() {
324 let c: Contract = parse_yaml(
325 r#"
326scopes:
327 root:
328 dir: .
329 cli:
330 dir: src/cli
331 web:
332 dir: src/web
333"#,
334 );
335 assert_eq!(
336 c.find_scope_by_path(std::path::Path::new("src/cli/sub"))
337 .map(|s| s.name.as_str()),
338 Some("cli")
339 );
340 assert_eq!(
341 c.find_scope_by_path(std::path::Path::new("src/web"))
342 .map(|s| s.name.as_str()),
343 Some("web")
344 );
345 assert_eq!(
346 c.find_scope_by_path(std::path::Path::new("unknown"))
347 .map(|s| s.name.as_str()),
348 Some("root")
349 );
350 }
351
352 #[test]
355 fn test_resolve_language_declared() {
356 let c: Contract = parse_yaml(
357 r#"
358scopes:
359 cli:
360 dir: .
361 language: rust
362"#,
363 );
364 let lang = c.resolve_language(&c.scopes[0], std::path::Path::new("/tmp"));
365 assert_eq!(lang, Language::Rust);
366 }
367
368 #[test]
369 fn test_resolve_language_auto() {
370 let d = tempfile::tempdir().unwrap();
371 std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
372 let c: Contract = parse_yaml(
373 r#"
374scopes:
375 cli:
376 dir: .
377"#,
378 );
379 let lang = c.resolve_language(&c.scopes[0], d.path());
380 assert_eq!(lang, Language::Rust);
381 }
382}