quanttide_devops/contract/
version.rs1use std::path::Path;
2
3use crate::contract::Scope;
4
5pub fn validate_version(version: &str) -> bool {
21 if version.is_empty() {
22 return false;
23 }
24
25 let ver = if let Some(pos) = version.find('/') {
27 let scope = &version[..pos];
28 if scope.is_empty()
30 || !scope
31 .chars()
32 .all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '-')
33 {
34 return false;
35 }
36 &version[pos + 1..]
37 } else {
38 version
39 };
40
41 let without_v = match ver.strip_prefix('v') {
43 Some(v) => v,
44 None => return false,
45 };
46
47 let (semver, _prerelease) = if let Some(dash) = without_v.find('-') {
49 let sv = &without_v[..dash];
50 let pr = &without_v[dash + 1..];
51 if pr.is_empty() || pr.starts_with('.') {
53 return false;
54 }
55 (sv, Some(pr))
56 } else {
57 (without_v, None)
58 };
59
60 let parts: Vec<&str> = semver.split('.').collect();
62 if parts.len() != 3 {
63 return false;
64 }
65 parts
66 .iter()
67 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
68}
69
70pub fn normalize_version(version: &str) -> String {
78 let after_scope = version.split('/').next_back().unwrap_or(version);
79 after_scope
80 .strip_prefix('v')
81 .unwrap_or(after_scope)
82 .to_string()
83}
84
85#[derive(Debug)]
95pub struct VersionState {
96 pub tag_version: Option<String>,
98 pub config_version: Option<String>,
100 pub consistent: bool,
102 pub config_files: Vec<(String, Option<String>)>,
104}
105
106pub fn check_version_consistency(
137 tag_version: Option<&str>,
138 config_files: &[(String, Option<String>)],
139) -> bool {
140 match tag_version {
141 Some(t) => config_files.iter().all(|(_, v)| match v {
142 Some(cv) => cv == t,
143 None => true,
144 }),
145 None => config_files.iter().all(|(_, v)| v.is_none()),
146 }
147}
148
149pub fn verify_version(
155 repo_path: &Path,
156 scope: &Scope,
157) -> Result<VersionState, Box<dyn std::error::Error>> {
158 let tag_version = crate::source::git_tag::latest_tag(repo_path, &scope.name)?;
159 let scope_dir = repo_path.join(&scope.dir);
160 let config_files = crate::source::config_file::read_config_versions(&scope_dir);
161 let config_version = config_files
162 .iter()
163 .find(|(_, v)| v.is_some())
164 .and_then(|(_, v)| v.clone());
165 let consistent = check_version_consistency(tag_version.as_deref(), &config_files);
166 Ok(VersionState {
167 tag_version,
168 config_version,
169 consistent,
170 config_files,
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
181 fn test_validate_version_standard() {
182 assert!(validate_version("v1.2.3"));
183 }
184
185 #[test]
186 fn test_validate_version_prerelease() {
187 assert!(validate_version("v1.2.3-rc.1"));
188 assert!(validate_version("v1.2.3-alpha"));
189 }
190
191 #[test]
192 fn test_validate_version_scoped() {
193 assert!(validate_version("cli/v1.2.3"));
194 assert!(validate_version("pkg.name/v0.1.0"));
195 }
196
197 #[test]
198 fn test_validate_version_no_v() {
199 assert!(!validate_version("1.2.3"));
200 }
201
202 #[test]
203 fn test_validate_version_incomplete() {
204 assert!(!validate_version("v1.2"));
205 assert!(!validate_version("v1"));
206 }
207
208 #[test]
209 fn test_validate_version_empty() {
210 assert!(!validate_version(""));
211 }
212
213 #[test]
214 fn test_validate_version_scope_only() {
215 assert!(!validate_version("cli/"));
216 }
217
218 #[test]
219 fn test_validate_version_invalid_scope_chars() {
220 assert!(!validate_version("bad space/v1.2.3"));
221 assert!(!validate_version("/v1.2.3"));
222 }
223
224 #[test]
225 fn test_validate_version_empty_prerelease() {
226 assert!(!validate_version("v1.2.3-"));
227 assert!(!validate_version("v1.2.3-."));
228 }
229
230 #[test]
233 fn test_normalize_version_v_prefix() {
234 assert_eq!(normalize_version("v1.2.3"), "1.2.3");
235 }
236
237 #[test]
238 fn test_normalize_version_scoped() {
239 assert_eq!(normalize_version("cli/v0.5.0"), "0.5.0");
240 }
241
242 #[test]
243 fn test_normalize_version_no_prefix() {
244 assert_eq!(normalize_version("1.2.3"), "1.2.3");
245 }
246
247 #[test]
250 fn test_consistency_matches() {
251 assert!(check_version_consistency(
252 Some("0.1.0"),
253 &[("Cargo.toml".into(), Some("0.1.0".into()))]
254 ));
255 }
256
257 #[test]
258 fn test_consistency_mismatch() {
259 assert!(!check_version_consistency(
260 Some("0.1.0"),
261 &[("Cargo.toml".into(), Some("0.2.0".into()))]
262 ));
263 }
264
265 #[test]
266 fn test_consistency_config_no_version() {
267 assert!(check_version_consistency(
268 Some("0.1.0"),
269 &[("Cargo.toml".into(), None)]
270 ));
271 }
272
273 #[test]
274 fn test_consistency_no_tag_no_config() {
275 assert!(check_version_consistency(
276 None,
277 &[("Cargo.toml".into(), None)]
278 ));
279 }
280
281 #[test]
282 fn test_consistency_no_tag_but_config_has_version() {
283 assert!(!check_version_consistency(
284 None,
285 &[("Cargo.toml".into(), Some("0.1.0".into()))]
286 ));
287 }
288
289 #[test]
290 fn test_consistency_multi_file_all_match() {
291 let files = vec![
292 ("Cargo.toml".into(), Some("0.1.0".into())),
293 ("pyproject.toml".into(), Some("0.1.0".into())),
294 ];
295 assert!(check_version_consistency(Some("0.1.0"), &files));
296 }
297
298 #[test]
299 fn test_consistency_multi_file_one_mismatch() {
300 let files = vec![
301 ("Cargo.toml".into(), Some("0.1.0".into())),
302 ("pyproject.toml".into(), Some("0.2.0".into())),
303 ];
304 assert!(!check_version_consistency(Some("0.1.0"), &files));
305 }
306}