Skip to content

Commit 8211eca

Browse files
authored
feat: implement parse_version_from_tag (#57)
- implement parse_version_from_tag - implement vcs_data_to_zerv_vars
1 parent c5fb6dd commit 8211eca

File tree

9 files changed

+402
-12
lines changed

9 files changed

+402
-12
lines changed

.dev/03-phase2-implementation-plan.md

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ fn populate_vars_by_tier(vars: &mut ZervVars, tier: VersionTier, schema: &ZervFo
8686
```rust
8787
#[derive(Parser)]
8888
struct VersionArgs {
89+
/// Version string (only used with --source string)
90+
version: Option<String>,
91+
8992
#[arg(long, default_value = "git")]
9093
source: String,
9194

@@ -291,14 +294,33 @@ tests/integration/
291294

292295
## Implementation Order
293296

294-
### Step 1: VCS Integration (2-3 days)
297+
### Step 1: VCS Integration ✅ COMPLETE (3 days)
298+
299+
**Key Achievements:**
300+
301+
- **Type-safe pipeline functions**: `parse_version_from_tag` and `vcs_data_to_zerv_vars` with clean separation of concerns
302+
- **Auto-detection logic**: SemVer-first parsing with PEP440 fallback for maximum compatibility
303+
- **Real VCS data testing**: Docker-based fixtures with `OnceLock` session-like caching for edge case discovery
304+
- **Reusable test infrastructure**: VCS fixtures moved to `test_utils` for cross-module usage
305+
- **Comprehensive test coverage**: 38 parameterized test cases covering edge cases and unnormalized forms
306+
- **Production ready**: All linting issues resolved, 1131 tests passing, 97.38% coverage maintained
295307

296-
1. Create `src/pipeline/vcs_integration.rs`
297-
2. Implement `vcs_data_to_zerv_vars`
298-
3. Add tag parsing logic
299-
4. Unit tests for conversion
308+
**Files Created/Modified:**
300309

301-
### Step 2: Schema System (1-2 days)
310+
- `src/pipeline/mod.rs` - Module orchestration with re-exports
311+
- `src/pipeline/parse_version_from_tag.rs` - Version parsing with auto-detection
312+
- `src/pipeline/vcs_data_to_zerv_vars.rs` - VCS to ZervVars conversion
313+
- `src/test_utils/vcs_fixtures.rs` - Reusable real VCS data fixtures
314+
- `src/version/version_object.rs` - Enhanced with unified format handling
315+
316+
1. ✅ Create `src/pipeline/` module with focused functions
317+
2. ✅ Implement `parse_version_from_tag` - extracts base version from tag string with auto-detection (SemVer first, then PEP440)
318+
3. ✅ Implement `vcs_data_to_zerv_vars` - converts VcsData to ZervVars using elegant `VersionObject::into()` pattern
319+
4. ✅ Comprehensive unit tests with real VCS data fixtures using `OnceLock` session-like caching
320+
5. ✅ Moved VCS fixtures to `test_utils` for reusability across codebase
321+
6. ✅ All 1131 tests pass with 97.38% coverage maintained
322+
323+
### Step 2: Schema System (1-2 days) 🔄 NEXT
302324

303325
1. Create `src/schema/` module
304326
2. Implement RON parsing
@@ -335,15 +357,17 @@ pub fn run_version_pipeline(args: VersionArgs) -> Result<String> {
335357
let vcs = detect_vcs(&std::env::current_dir()?)?;
336358
let vcs_data = vcs.get_vcs_data()?;
337359

338-
// 2. Convert to ZervVars
339-
let base_version = parse_version_from_tag(&vcs_data.tag_version);
340-
let mut vars = vcs_data_to_zerv_vars(vcs_data, base_version);
360+
// 2. Resolve version source (VCS tag or CLI string)
361+
let vcs_data = resolve_version_source(vcs_data, args.version)?;
362+
363+
// 3. Convert to ZervVars
364+
let vars = vcs_data_to_zerv_vars(vcs_data)?;
341365

342-
// 3. Apply schema
366+
// 4. Apply schema
343367
let schema = get_schema(&args.schema, &args.schema_ron)?;
344368
let zerv = Zerv::new(schema, vars);
345369

346-
// 4. Output format
370+
// 5. Output format
347371
match args.output_format.as_deref() {
348372
Some("pep440") => Ok(PEP440::from_zerv(&zerv)?.to_string()),
349373
Some("semver") => Ok(SemVer::from_zerv(&zerv)?.to_string()),
@@ -442,10 +466,25 @@ $ zerv check "1.2.3" --format pep440
442466
## Timeline Estimate
443467

444468
- **Total**: 5-7 days focused development
445-
- **Milestone 1**: VCS integration working (Day 3)
469+
- **Milestone 1**: VCS integration working ✅ COMPLETE (Day 3)
446470
- **Milestone 2**: Basic CLI pipeline + check command (Day 5)
447471
- **Milestone 3**: Full Phase 2 complete (Day 7)
448472

473+
## Progress Summary
474+
475+
**✅ COMPLETED (Day 3):**
476+
477+
- Step 1: VCS Integration with comprehensive testing and real data fixtures
478+
- Enhanced type safety with `VersionObject` enum and unified format handling
479+
- Production-ready code with full lint compliance and test coverage
480+
481+
**🔄 NEXT STEPS:**
482+
483+
- Step 2: Schema System (RON parsing, presets)
484+
- Step 3: CLI Pipeline (version command implementation)
485+
- Step 4: Check Command (validation with auto-detection)
486+
- Step 5: Integration Tests (end-to-end validation)
487+
449488
**Note**: `zerv check` adds minimal complexity since we already have complete PEP440/SemVer parsers.
450489

451490
This plan leverages the solid Phase 1 foundation to rapidly implement the core pipeline functionality needed for a functional alpha release.

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod cli;
22
pub mod error;
3+
pub mod pipeline;
34
#[cfg(any(test, feature = "test-utils"))]
45
pub mod test_utils;
56
pub mod vcs;

src/pipeline/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pub mod parse_version_from_tag;
2+
pub mod vcs_data_to_zerv_vars;
3+
4+
pub use parse_version_from_tag::parse_version_from_tag;
5+
pub use vcs_data_to_zerv_vars::vcs_data_to_zerv_vars;
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
use crate::version::{PEP440, SemVer, VersionObject};
2+
use std::str::FromStr;
3+
4+
/// Parse version from tag string with optional format specification
5+
pub fn parse_version_from_tag(tag: &str, input_format: Option<&str>) -> Option<VersionObject> {
6+
match input_format {
7+
Some(format) => VersionObject::parse_with_format(tag, format),
8+
None => {
9+
// Auto-detection: try SemVer first, then PEP440
10+
if let Ok(semver) = SemVer::from_str(tag) {
11+
return Some(VersionObject::SemVer(semver));
12+
}
13+
14+
if let Ok(pep440) = PEP440::from_str(tag) {
15+
return Some(VersionObject::PEP440(pep440));
16+
}
17+
18+
None
19+
}
20+
}
21+
}
22+
23+
#[cfg(test)]
24+
mod tests {
25+
use super::*;
26+
use rstest::rstest;
27+
28+
#[rstest]
29+
// Basic SemVer cases (auto-detection should pick SemVer first)
30+
#[case("1.2.3", "1.2.3", "semver")]
31+
#[case("v1.2.3", "1.2.3", "semver")]
32+
#[case("0.0.0", "0.0.0", "semver")]
33+
#[case("10.20.30", "10.20.30", "semver")]
34+
// SemVer with pre-release
35+
#[case("1.0.0-alpha", "1.0.0-alpha", "semver")]
36+
#[case("1.0.0-alpha.1", "1.0.0-alpha.1", "semver")]
37+
#[case("1.0.0-beta.2", "1.0.0-beta.2", "semver")]
38+
#[case("1.0.0-rc.1", "1.0.0-rc.1", "semver")]
39+
// SemVer with build metadata
40+
#[case("1.0.0+build.1", "1.0.0+build.1", "semver")]
41+
#[case("1.0.0-alpha.1+build.123", "1.0.0-alpha.1+build.123", "semver")]
42+
// PEP440-specific cases (should fall back to PEP440)
43+
#[case("1.2.3a1", "1.2.3a1", "pep440")]
44+
#[case("1.2.3b2", "1.2.3b2", "pep440")]
45+
#[case("1.2.3rc3", "1.2.3rc3", "pep440")]
46+
#[case("1.2.3.post1", "1.2.3.post1", "pep440")]
47+
#[case("1.2.3.dev1", "1.2.3.dev1", "pep440")]
48+
#[case("2!1.2.3", "2!1.2.3", "pep440")]
49+
// PEP440 unnormalized forms
50+
#[case("1.2.3_alpha1", "1.2.3a1", "pep440")]
51+
#[case("1.2.3.ALPHA.1", "1.2.3a1", "pep440")]
52+
#[case("1.2.3.POST.1", "1.2.3.post1", "pep440")]
53+
// Complex PEP440 cases
54+
#[case(
55+
"2!1.2.3a1.post1.dev1+local.1",
56+
"2!1.2.3a1.post1.dev1+local.1",
57+
"pep440"
58+
)]
59+
fn test_parse_version_from_tag_auto_detection(
60+
#[case] tag: &str,
61+
#[case] expected_str: &str,
62+
#[case] expected_format: &str,
63+
) {
64+
let version = parse_version_from_tag(tag, None).unwrap();
65+
66+
// Direct comparison - no conversion needed!
67+
assert_eq!(version.format_str(), expected_format);
68+
69+
match expected_format {
70+
"semver" => {
71+
if let VersionObject::SemVer(semver) = version {
72+
let expected: SemVer = expected_str.parse().unwrap();
73+
assert_eq!(semver, expected);
74+
} else {
75+
panic!("Expected SemVer, got {version:?}");
76+
}
77+
}
78+
"pep440" => {
79+
if let VersionObject::PEP440(pep440) = version {
80+
let expected: PEP440 = expected_str.parse().unwrap();
81+
assert_eq!(pep440, expected);
82+
} else {
83+
panic!("Expected PEP440, got {version:?}");
84+
}
85+
}
86+
_ => panic!("Unknown expected format: {expected_format}"),
87+
}
88+
}
89+
90+
#[rstest]
91+
#[case("1.2.3", "1.2.3")]
92+
#[case("v1.2.3", "1.2.3")]
93+
#[case("1.2.3a1", "1.2.3a1")]
94+
#[case("2!1.2.3.post1.dev1", "2!1.2.3.post1.dev1")]
95+
#[case("1.2.3_alpha1", "1.2.3a1")] // unnormalized
96+
fn test_parse_version_from_tag_explicit_pep440(#[case] tag: &str, #[case] expected_str: &str) {
97+
let version = parse_version_from_tag(tag, Some("pep440")).unwrap();
98+
99+
if let VersionObject::PEP440(pep440) = version {
100+
let expected: PEP440 = expected_str.parse().unwrap();
101+
assert_eq!(pep440, expected);
102+
} else {
103+
panic!("Expected PEP440, got {version:?}");
104+
}
105+
}
106+
107+
#[rstest]
108+
#[case("1.2.3", "1.2.3")]
109+
#[case("v1.2.3", "1.2.3")]
110+
#[case("1.0.0-alpha.1", "1.0.0-alpha.1")]
111+
#[case("1.0.0+build.123", "1.0.0+build.123")]
112+
#[case("1.0.0-alpha.1+build.123", "1.0.0-alpha.1+build.123")]
113+
fn test_parse_version_from_tag_explicit_semver(#[case] tag: &str, #[case] expected_str: &str) {
114+
let version = parse_version_from_tag(tag, Some("semver")).unwrap();
115+
116+
if let VersionObject::SemVer(semver) = version {
117+
let expected: SemVer = expected_str.parse().unwrap();
118+
assert_eq!(semver, expected);
119+
} else {
120+
panic!("Expected SemVer, got {version:?}");
121+
}
122+
}
123+
124+
#[rstest]
125+
#[case("invalid")]
126+
#[case("")]
127+
#[case("abc.def.ghi")]
128+
fn test_parse_version_from_tag_invalid(#[case] tag: &str) {
129+
let version = parse_version_from_tag(tag, None);
130+
assert!(version.is_none());
131+
}
132+
133+
#[rstest]
134+
#[case("1.2.3", "unknown")]
135+
#[case("1.2.3", "invalid")]
136+
#[case("1.2.3", "custom")]
137+
fn test_parse_version_from_tag_unknown_format(#[case] tag: &str, #[case] format: &str) {
138+
let version = parse_version_from_tag(tag, Some(format));
139+
assert!(version.is_none());
140+
}
141+
142+
#[rstest]
143+
#[case("1.2.3a1", "semver")] // PEP440 format with SemVer parser
144+
#[case("1.2.3.post1", "semver")] // PEP440 post-release with SemVer parser
145+
fn test_parse_version_from_tag_wrong_format(#[case] tag: &str, #[case] format: &str) {
146+
let version = parse_version_from_tag(tag, Some(format));
147+
assert!(version.is_none());
148+
}
149+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
use super::parse_version_from_tag::parse_version_from_tag;
2+
use crate::error::ZervError;
3+
use crate::vcs::VcsData;
4+
use crate::version::ZervVars;
5+
6+
/// Convert VCS data to ZervVars
7+
pub fn vcs_data_to_zerv_vars(vcs_data: VcsData) -> Result<ZervVars, ZervError> {
8+
// Parse version from tag_version
9+
let version = if let Some(ref tag_version) = vcs_data.tag_version {
10+
parse_version_from_tag(tag_version, None).ok_or_else(|| {
11+
ZervError::Io(std::io::Error::other("Failed to parse version from tag"))
12+
})?
13+
} else {
14+
return Err(ZervError::Io(std::io::Error::other("No version tag found")));
15+
};
16+
17+
let mut vars: ZervVars = version.into();
18+
19+
// VCS-specific fields
20+
vars.distance = Some(vcs_data.distance as u64);
21+
vars.current_branch = vcs_data.current_branch;
22+
vars.dirty = Some(vcs_data.is_dirty);
23+
vars.current_commit_hash = Some(vcs_data.commit_hash_short);
24+
vars.tag_timestamp = vcs_data.tag_timestamp.map(|t| t as u64);
25+
26+
Ok(vars)
27+
}
28+
29+
#[cfg(test)]
30+
mod tests {
31+
use super::*;
32+
use crate::test_utils::{get_real_pep440_vcs_data, get_real_semver_vcs_data};
33+
34+
#[test]
35+
#[ignore = "docker"]
36+
fn test_vcs_data_to_zerv_vars_real_semver() {
37+
let vcs_data = get_real_semver_vcs_data().clone();
38+
let vars = vcs_data_to_zerv_vars(vcs_data).unwrap();
39+
40+
assert_eq!(
41+
(vars.major, vars.minor, vars.patch),
42+
(Some(1), Some(2), Some(3))
43+
);
44+
assert_eq!(vars.distance, Some(1)); // 1 commit after tag
45+
assert!(vars.current_commit_hash.is_some());
46+
assert!(vars.tag_timestamp.is_some());
47+
}
48+
49+
#[test]
50+
#[ignore = "docker"]
51+
fn test_vcs_data_to_zerv_vars_real_pep440() {
52+
let vcs_data = get_real_pep440_vcs_data().clone();
53+
let vars = vcs_data_to_zerv_vars(vcs_data).unwrap();
54+
55+
assert_eq!(
56+
(vars.major, vars.minor, vars.patch),
57+
(Some(2), Some(0), Some(1))
58+
);
59+
assert_eq!(vars.distance, Some(1)); // 1 commit after tag
60+
assert!(vars.current_commit_hash.is_some());
61+
assert!(vars.tag_timestamp.is_some());
62+
}
63+
64+
#[test]
65+
fn test_vcs_data_to_zerv_vars_no_tag() {
66+
let vcs_data = VcsData {
67+
tag_version: None,
68+
..Default::default()
69+
};
70+
let result = vcs_data_to_zerv_vars(vcs_data);
71+
assert!(result.is_err());
72+
}
73+
}

src/test_utils/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
pub mod dir;
22
pub mod git;
3+
pub mod vcs_fixtures;
34

45
pub use dir::TestDir;
56
pub use git::DockerGit;
7+
pub use vcs_fixtures::{get_real_pep440_vcs_data, get_real_semver_vcs_data};

src/test_utils/vcs_fixtures.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
use super::{DockerGit, TestDir};
2+
use crate::vcs::{Vcs, VcsData, git::GitVcs};
3+
use std::sync::OnceLock;
4+
5+
static SEMVER_VCS_DATA: OnceLock<VcsData> = OnceLock::new();
6+
static PEP440_VCS_DATA: OnceLock<VcsData> = OnceLock::new();
7+
8+
fn create_vcs_data_with_tag(tag: &str, filename: &str, content: &str, commit_msg: &str) -> VcsData {
9+
let test_dir = TestDir::new().expect("Failed to create test dir");
10+
let docker_git = DockerGit::new();
11+
12+
docker_git
13+
.init_repo(&test_dir)
14+
.expect("Failed to init repo");
15+
docker_git
16+
.create_tag(&test_dir, tag)
17+
.expect("Failed to create tag");
18+
19+
test_dir
20+
.create_file(filename, content)
21+
.expect("Failed to create file");
22+
docker_git
23+
.create_commit(&test_dir, commit_msg)
24+
.expect("Failed to create commit");
25+
26+
let git_vcs = GitVcs::new(test_dir.path()).expect("Failed to create GitVcs");
27+
git_vcs.get_vcs_data().expect("Failed to get VCS data")
28+
}
29+
30+
/// Get real VCS data with SemVer tag (v1.2.3) and 1 commit distance
31+
pub fn get_real_semver_vcs_data() -> &'static VcsData {
32+
SEMVER_VCS_DATA.get_or_init(|| {
33+
create_vcs_data_with_tag("v1.2.3", "feature.txt", "new feature", "Add feature")
34+
})
35+
}
36+
37+
/// Get real VCS data with PEP440 tag (2.0.1a1) and 1 commit distance
38+
pub fn get_real_pep440_vcs_data() -> &'static VcsData {
39+
PEP440_VCS_DATA
40+
.get_or_init(|| create_vcs_data_with_tag("2.0.1a1", "fix.txt", "bug fix", "Fix bug"))
41+
}

0 commit comments

Comments
 (0)