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