Skip to main content

devflow_core/
version.rs

1//! Hybrid Git-based SemVer.
2//!
3//! DevFlow derives the version from a mix of the project's version file and git
4//! history rather than a config-driven scheme:
5//!
6//! - **MAJOR** — read from the auto-detected version file (`Cargo.toml`,
7//!   `pyproject.toml`, or `package.json`). This is the one component a human
8//!   bumps deliberately.
9//! - **MINOR** — the number of git tags (one tag per shipped milestone).
10//! - **PATCH** — commits since the most recent tag.
11
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15/// A computed semantic version.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct Version {
18    /// Major component, from the version file.
19    pub major: u32,
20    /// Minor component, from the git tag count.
21    pub minor: u32,
22    /// Patch component, from commits since the last tag.
23    pub patch: u32,
24}
25
26impl std::fmt::Display for Version {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
29    }
30}
31
32/// Errors produced by version operations.
33#[derive(Debug, thiserror::Error)]
34pub enum VersionError {
35    /// Filesystem operation failed.
36    #[error("version file I/O failed: {0}")]
37    Io(#[from] std::io::Error),
38    /// Version field could not be found or parsed.
39    #[error("version parse failed: {0}")]
40    Parse(String),
41    /// A git command failed.
42    #[error("git command failed: {0}")]
43    Git(String),
44}
45
46/// Detect the project's version file, checking Cargo.toml, then pyproject.toml,
47/// then package.json. Returns the first that exists.
48pub fn detect_version_file(project_root: &Path) -> Option<PathBuf> {
49    for name in ["Cargo.toml", "pyproject.toml", "package.json"] {
50        let path = project_root.join(name);
51        if path.exists() {
52            return Some(path);
53        }
54    }
55    None
56}
57
58/// The dotted field path that holds the version in a given file.
59fn field_for(path: &Path, contents: &str) -> &'static str {
60    match path.file_name().and_then(|n| n.to_str()) {
61        Some("Cargo.toml") => {
62            if contents.contains("[workspace.package]") {
63                "workspace.package.version"
64            } else {
65                "package.version"
66            }
67        }
68        Some("pyproject.toml") => "project.version",
69        Some("package.json") => "version",
70        _ => "version",
71    }
72}
73
74/// Read the MAJOR version component from a version file.
75pub fn read_major_version(path: &Path) -> Result<u32, VersionError> {
76    let contents = std::fs::read_to_string(path)?;
77    let field = field_for(path, &contents);
78    let version = find_version_in_contents(&contents, field)
79        .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
80    let major = version
81        .split(['.', '+', '-'])
82        .next()
83        .unwrap_or("0")
84        .parse::<u32>()
85        .map_err(|err| VersionError::Parse(format!("invalid major in `{version}`: {err}")))?;
86    Ok(major)
87}
88
89/// Count all git tags (the MINOR component).
90pub fn count_git_tags(project_root: &Path) -> Result<u32, VersionError> {
91    let output = Command::new("git")
92        .arg("tag")
93        .current_dir(project_root)
94        .output()
95        .map_err(|err| VersionError::Git(err.to_string()))?;
96    if !output.status.success() {
97        return Err(VersionError::Git(
98            String::from_utf8_lossy(&output.stderr).trim().to_string(),
99        ));
100    }
101    let count = String::from_utf8_lossy(&output.stdout)
102        .lines()
103        .filter(|l| !l.trim().is_empty())
104        .count();
105    Ok(count as u32)
106}
107
108/// Count commits since the most recent tag (the PATCH component). If there are
109/// no tags yet, counts all commits reachable from HEAD.
110pub fn commits_since_last_minor_tag(project_root: &Path) -> Result<u32, VersionError> {
111    let last_tag = Command::new("git")
112        .args(["describe", "--tags", "--abbrev=0"])
113        .current_dir(project_root)
114        .output()
115        .map_err(|err| VersionError::Git(err.to_string()))?;
116
117    let range = if last_tag.status.success() {
118        let tag = String::from_utf8_lossy(&last_tag.stdout).trim().to_string();
119        format!("{tag}..HEAD")
120    } else {
121        "HEAD".to_string()
122    };
123
124    let output = Command::new("git")
125        .args(["rev-list", "--count", &range])
126        .current_dir(project_root)
127        .output()
128        .map_err(|err| VersionError::Git(err.to_string()))?;
129    if !output.status.success() {
130        // No commits yet (e.g. empty repo) → zero patch.
131        return Ok(0);
132    }
133    let count = String::from_utf8_lossy(&output.stdout)
134        .trim()
135        .parse::<u32>()
136        .unwrap_or(0);
137    Ok(count)
138}
139
140/// Compute the full version: MAJOR from the version file, MINOR from the tag
141/// count, PATCH from commits since the last tag.
142pub fn compute_version(project_root: &Path) -> Result<Version, VersionError> {
143    let major = match detect_version_file(project_root) {
144        Some(path) => read_major_version(&path)?,
145        None => 0,
146    };
147    let minor = count_git_tags(project_root)?;
148    let patch = commits_since_last_minor_tag(project_root)?;
149    Ok(Version {
150        major,
151        minor,
152        patch,
153    })
154}
155
156/// Read the full [`Version`] (major/minor/patch) out of whatever version file
157/// `detect_version_file` resolves, mirroring [`write_version`]'s format
158/// handling (including `[workspace.package]`).
159///
160/// Unlike [`compute_version`], this never touches git — it reports exactly
161/// what was last written to the version file, not a freshly recomputed
162/// minor/patch. Callers that need the version a prior [`write_version`] call
163/// actually wrote (e.g. after a tag was just cut) must use this instead of
164/// `compute_version`, which would see the new tag and return a different,
165/// larger version.
166pub fn read_version(project_root: &Path) -> Result<Version, VersionError> {
167    let path = detect_version_file(project_root)
168        .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
169    let contents = std::fs::read_to_string(&path)?;
170    let field = field_for(&path, &contents);
171    let version_str = find_version_in_contents(&contents, field)
172        .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
173    parse_version_str(&version_str)
174}
175
176/// Parse a `MAJOR.MINOR.PATCH` string (optionally followed by `-`/`+`
177/// metadata) into a [`Version`].
178fn parse_version_str(version: &str) -> Result<Version, VersionError> {
179    let mut parts = version.split(['.', '+', '-']);
180    let mut next =
181        |label: &str| -> Result<u32, VersionError> {
182            parts.next().unwrap_or("0").parse::<u32>().map_err(|err| {
183                VersionError::Parse(format!("invalid {label} in `{version}`: {err}"))
184            })
185        };
186    let major = next("major")?;
187    let minor = next("minor")?;
188    let patch = next("patch")?;
189    Ok(Version {
190        major,
191        minor,
192        patch,
193    })
194}
195
196/// Write `version` into the project's auto-detected version file.
197pub fn write_version(project_root: &Path, version: &Version) -> Result<PathBuf, VersionError> {
198    let path = detect_version_file(project_root)
199        .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
200    let contents = std::fs::read_to_string(&path)?;
201    let field = field_for(&path, &contents);
202    let replaced = replace_version_in_contents(&contents, field, &version.to_string())
203        .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found")))?;
204    // 20a / DEN-49: a workspace Cargo.toml states its version twice — once in
205    // [workspace.package] version (just rewritten above), and again as an
206    // explicit `version` pin on every [workspace.dependencies] entry that
207    // points at a workspace member by `path`. This second pass is additive,
208    // not a modification of `replace_version_in_contents`'s single-field
209    // logic — pyproject.toml/package.json/plain Cargo.toml callers never
210    // reach it.
211    let replaced = if field == "workspace.package.version" {
212        rewrite_workspace_member_pins(&replaced, &version.to_string())
213    } else {
214        replaced
215    };
216    std::fs::write(&path, replaced)?;
217    Ok(path)
218}
219
220/// Additive pass (20a / DEN-49): rewrite the `version` sub-value of every
221/// SINGLE-LINE `[workspace.dependencies]` inline-table entry that pins a
222/// local workspace member by `path` (e.g. `devflow-core = { path =
223/// "crates/devflow-core", version = "1.6.0" }`).
224///
225/// This is deliberately additive to `replace_version_in_contents` rather than
226/// a modification of it — that function's `starts_with('{')` guard exists so
227/// single-field callers (`field_for` for pyproject.toml/package.json/plain
228/// Cargo.toml) never touch an inline table, and stays intact.
229///
230/// Scope, by construction:
231/// - Only entries with a local `path` key (one starting with `crates/`) are
232///   rewritten. A `version`-only third-party dependency (`serde = { version
233///   = "1" }`) is left untouched — a dependency on a crate INSIDE this
234///   workspace carries this workspace's version; anything else does not.
235/// - Only SINGLE-LINE inline tables are handled (opening and closing `}` on
236///   the same line as `path`/`version`). A multi-line inline table is a
237///   documented out-of-scope limitation (review: Antigravity/Hermes MEDIUM)
238///   — this repo's own self-pins are single-line (Cargo.toml:20), and the
239///   line-level `starts_with('{')` guard in `find_version_in_contents`/
240///   `replace_version_in_contents` could not see into one anyway.
241/// - The `version = "..."` sub-value is located and replaced independent of
242///   its position relative to `path` within the line (key-order-independent,
243///   anchored to the `version =` token itself, not a column offset) — a
244///   self-pin written `{ version = "1.6.0", path = "crates/..." }` is
245///   rewritten identically to the `path`-before-`version` case.
246/// - Whitespace, quote style, and any trailing comma/comment after the
247///   `version` token are preserved exactly (GAP-6).
248fn rewrite_workspace_member_pins(contents: &str, new_version: &str) -> String {
249    let mut current = String::new();
250    let mut output = String::new();
251    for line in contents.lines() {
252        let trimmed = line.trim();
253        if let Some(header) = parse_section_header(trimmed) {
254            current = header.to_string();
255            output.push_str(line);
256            output.push('\n');
257            continue;
258        }
259        if current == "workspace.dependencies"
260            && trimmed.contains('{')
261            && trimmed.contains('}')
262            && workspace_dependency_has_local_path(trimmed)
263            && let Some(rewritten) = rewrite_inline_table_version(line, new_version)
264        {
265            output.push_str(&rewritten);
266            output.push('\n');
267            continue;
268        }
269        output.push_str(line);
270        output.push('\n');
271    }
272    output
273}
274
275/// Split a single-line inline table's interior (`{ ... }`, braces excluded)
276/// into its top-level `key = value` fragments, alongside each fragment's
277/// absolute byte offset within `line`. Fragments are separated on `,` — this
278/// is a hand-rolled, single-line-only split (see `rewrite_workspace_member_pins`
279/// doc comment), not a general TOML parser.
280fn inline_table_fragments(line: &str) -> Option<Vec<(usize, &str)>> {
281    let brace_start = line.find('{')?;
282    let brace_end = line.rfind('}')?;
283    if brace_end <= brace_start {
284        return None;
285    }
286    let inner = &line[brace_start + 1..brace_end];
287    let mut fragments = Vec::new();
288    let mut offset = brace_start + 1;
289    for fragment in inner.split(',') {
290        fragments.push((offset, fragment));
291        offset += fragment.len() + 1; // +1 for the consumed comma
292    }
293    Some(fragments)
294}
295
296/// Whether a `[workspace.dependencies]` inline-table line carries a `path`
297/// key whose value points at a local workspace member (starts with
298/// `crates/`).
299fn workspace_dependency_has_local_path(line: &str) -> bool {
300    let Some(fragments) = inline_table_fragments(line) else {
301        return false;
302    };
303    for (_, fragment) in fragments {
304        let trimmed = fragment.trim();
305        let Some((key, value)) = trimmed.split_once('=') else {
306            continue;
307        };
308        if key.trim() != "path" {
309            continue;
310        }
311        let value = value.trim();
312        let Some(quote) = value.chars().next() else {
313            return false;
314        };
315        if quote != '"' && quote != '\'' {
316            return false;
317        }
318        let inner_value = &value[1..value.len().saturating_sub(1)];
319        return inner_value.starts_with("crates/");
320    }
321    false
322}
323
324/// Rewrite the `version = "..."` sub-value on a single-line inline-table
325/// line, preserving everything else on the line byte-for-byte. Returns
326/// `None` if the line has no `version` fragment to anchor to (e.g. a
327/// `path`-only member with no explicit version — nothing to rewrite).
328fn rewrite_inline_table_version(line: &str, new_version: &str) -> Option<String> {
329    let fragments = inline_table_fragments(line)?;
330    for (frag_start, fragment) in fragments {
331        let trimmed = fragment.trim();
332        let Some((key, _value)) = trimmed.split_once('=') else {
333            continue;
334        };
335        if key.trim() != "version" {
336            continue;
337        }
338        // Locate `=` in the ORIGINAL (untrimmed) fragment to compute an
339        // absolute offset into `line`.
340        let eq_rel = fragment.find('=')?;
341        let eq_abs = frag_start + eq_rel;
342        let after_eq = eq_abs + 1;
343        let rest = &line[after_eq..];
344        let ws_len = rest.len() - rest.trim_start().len();
345        let value_start = after_eq + ws_len;
346        let value_rest = &line[value_start..];
347        let quote_char = value_rest.chars().next()?;
348        if quote_char != '"' && quote_char != '\'' {
349            return None;
350        }
351        let after_quote = &value_rest[1..];
352        let end_rel = after_quote.find(quote_char)?;
353        let value_end = value_start + 1 + end_rel + 1;
354        let remainder = &line[value_end..];
355
356        let mut rewritten = String::with_capacity(line.len() + new_version.len());
357        rewritten.push_str(&line[..value_start]);
358        rewritten.push(quote_char);
359        rewritten.push_str(new_version);
360        rewritten.push(quote_char);
361        rewritten.push_str(remainder);
362        return Some(rewritten);
363    }
364    None
365}
366
367/// One `[workspace.dependencies]` self-pin discovered by
368/// [`read_workspace_self_pins`] — a local-path dependency's name and its
369/// pinned `version` sub-value.
370#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct SelfPin {
372    /// The dependency's name (left-hand side of `=` in `[workspace.dependencies]`).
373    pub name: String,
374    /// The `version = "..."` value currently pinned in the inline table.
375    pub version: String,
376}
377
378/// Extract `[workspace.package] version` and every local-path
379/// `[workspace.dependencies]` self-pin (crate name + pinned version) from a
380/// workspace Cargo.toml's contents.
381///
382/// Read-only (20d / `devflow release --check`): asserts 20a's invariant
383/// (`write_version` keeps every self-pin equal to the workspace version)
384/// without re-implementing TOML scanning — reuses the same
385/// `parse_section_header`/`find_version_in_contents`/
386/// `workspace_dependency_has_local_path`/`inline_table_fragments` helpers
387/// `write_version`'s additive rewrite pass already uses.
388///
389/// Returns `(workspace_version, pins)`. `workspace_version` is `None` when
390/// the contents have no `[workspace.package] version` field (not a workspace
391/// root Cargo.toml) — callers must treat that as "nothing to assert", not a
392/// drift.
393pub fn read_workspace_self_pins(contents: &str) -> (Option<String>, Vec<SelfPin>) {
394    let workspace_version = find_version_in_contents(contents, "workspace.package.version");
395
396    let mut current = String::new();
397    let mut pins = Vec::new();
398    for line in contents.lines() {
399        let trimmed = line.trim();
400        if let Some(header) = parse_section_header(trimmed) {
401            current = header.to_string();
402            continue;
403        }
404        if current == "workspace.dependencies"
405            && trimmed.contains('{')
406            && trimmed.contains('}')
407            && workspace_dependency_has_local_path(trimmed)
408            && let Some(fragments) = inline_table_fragments(trimmed)
409        {
410            let name = trimmed
411                .split_once('=')
412                .map(|(n, _)| n.trim().to_string())
413                .unwrap_or_default();
414            for (_, fragment) in fragments {
415                let frag = fragment.trim();
416                let Some((key, value)) = frag.split_once('=') else {
417                    continue;
418                };
419                if key.trim() != "version" {
420                    continue;
421                }
422                let value = value.trim().trim_matches(['"', '\'']);
423                pins.push(SelfPin {
424                    name: name.clone(),
425                    version: value.to_string(),
426                });
427            }
428        }
429    }
430    (workspace_version, pins)
431}
432
433/// Split a dotted field path into its TOML section path and the final key.
434fn split_field(field: &str) -> (&str, &str) {
435    match field.rsplit_once('.') {
436        Some((section, key)) => (section, key),
437        None => ("", field),
438    }
439}
440
441/// Return the dotted table path for a TOML section header line, if any.
442fn parse_section_header(trimmed: &str) -> Option<&str> {
443    let inner = if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
444        trimmed.strip_prefix("[[")?.strip_suffix("]]")?
445    } else {
446        trimmed.strip_prefix('[')?.strip_suffix(']')?
447    };
448    Some(inner.trim())
449}
450
451fn find_version_in_contents(contents: &str, field: &str) -> Option<String> {
452    let (section, key) = split_field(field);
453    let mut current = "";
454    for line in contents.lines() {
455        let trimmed = line.trim();
456        if let Some(header) = parse_section_header(trimmed) {
457            current = header;
458            continue;
459        }
460        if current != section {
461            continue;
462        }
463        if let Some((lhs, value)) = trimmed.split_once(['=', ':']) {
464            let lhs_key = lhs.trim().trim_matches('"').trim_matches('\'');
465            if lhs_key != key {
466                continue;
467            }
468            let value = value.trim();
469            if value.starts_with('{') {
470                continue;
471            }
472            // Anchor on the opening quote and scan forward for the matching
473            // closing quote, ignoring everything after it (e.g. a trailing
474            // `# comment`), rather than `trim_matches` on the whole tail —
475            // that would only strip a quote sitting at the very end of the
476            // remaining string, missing it entirely when a comment follows
477            // the closing quote on the same line. Symmetric with
478            // `replace_version_in_contents`'s write-path remainder handling.
479            return match value.chars().next() {
480                Some(q @ ('"' | '\'')) => {
481                    value[1..].find(q).map(|end| value[1..1 + end].to_string())
482                }
483                _ => {
484                    let end = value.find([' ', '\t', ',', '#']).unwrap_or(value.len());
485                    Some(value[..end].to_string())
486                }
487            };
488        }
489    }
490    None
491}
492
493fn replace_version_in_contents(contents: &str, field: &str, new_version: &str) -> Option<String> {
494    let (section, key) = split_field(field);
495    let mut current = "";
496    let mut changed = false;
497    let mut output = String::new();
498    for line in contents.lines() {
499        let trimmed = line.trim();
500        if let Some(header) = parse_section_header(trimmed) {
501            current = header;
502            output.push_str(line);
503            output.push('\n');
504            continue;
505        }
506        if !changed
507            && current == section
508            && let Some((left, value)) = line.split_once(['=', ':'])
509        {
510            let left_key = left.trim().trim_matches('"').trim_matches('\'');
511            if left_key == key && !value.trim().starts_with('{') {
512                let separator: &str = if trimmed.contains('=') { " = " } else { ": " };
513                let trimmed_value = value.trim();
514                let needs_quote = trimmed_value.starts_with('"') || trimmed_value.starts_with('\'');
515                let quote_char: &str = if trimmed_value.starts_with('\'') {
516                    "'"
517                } else {
518                    "\""
519                };
520                // Capture whatever follows the version token itself (a
521                // trailing `,` in JSON, a trailing `# comment` in TOML) so it
522                // survives the rewrite instead of being silently dropped
523                // (GAP-6).
524                let remainder = if needs_quote {
525                    // Token ends at the closing quote; skip the opening
526                    // quote and scan for the matching close.
527                    trimmed_value[1..]
528                        .find(quote_char)
529                        .map(|end| &trimmed_value[end + 2..])
530                        .unwrap_or("")
531                } else {
532                    // Unquoted: token ends at the first whitespace, `,`, or `#`.
533                    let end = trimmed_value
534                        .find([' ', '\t', ',', '#'])
535                        .unwrap_or(trimmed_value.len());
536                    &trimmed_value[end..]
537                };
538                output.push_str(left.trim_end());
539                output.push_str(separator);
540                if needs_quote {
541                    output.push_str(quote_char);
542                    output.push_str(new_version);
543                    output.push_str(quote_char);
544                } else {
545                    output.push_str(new_version);
546                }
547                output.push_str(remainder.trim_end());
548                output.push('\n');
549                changed = true;
550                continue;
551            }
552        }
553        output.push_str(line);
554        output.push('\n');
555    }
556    changed.then_some(output)
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    fn git(root: &Path, args: &[&str]) {
564        let ok = crate::test_support::git_command(root)
565            .args(args)
566            .output()
567            .unwrap()
568            .status
569            .success();
570        assert!(ok, "git {args:?} failed");
571    }
572
573    fn init_repo(root: &Path) {
574        git(root, &["init", "-q"]);
575        git(root, &["config", "user.email", "test@example.com"]);
576        git(root, &["config", "user.name", "Test"]);
577        git(root, &["config", "commit.gpgsign", "false"]);
578        git(root, &["config", "tag.gpgsign", "false"]);
579        git(root, &["config", "core.hooksPath", "/dev/null"]);
580    }
581
582    fn commit(root: &Path, name: &str) {
583        std::fs::write(root.join(name), name).unwrap();
584        git(root, &["add", "."]);
585        git(root, &["commit", "-q", "-m", &format!("add {name}")]);
586    }
587
588    #[test]
589    fn detect_prefers_cargo_then_pyproject_then_package_json() {
590        let dir = tempfile::tempdir().unwrap();
591        assert!(detect_version_file(dir.path()).is_none());
592        std::fs::write(dir.path().join("package.json"), "{\"version\":\"1.0.0\"}").unwrap();
593        assert!(
594            detect_version_file(dir.path())
595                .unwrap()
596                .ends_with("package.json")
597        );
598        std::fs::write(
599            dir.path().join("Cargo.toml"),
600            "[package]\nversion=\"1.0.0\"",
601        )
602        .unwrap();
603        assert!(
604            detect_version_file(dir.path())
605                .unwrap()
606                .ends_with("Cargo.toml")
607        );
608    }
609
610    #[test]
611    fn read_major_from_workspace_package() {
612        let dir = tempfile::tempdir().unwrap();
613        let file = dir.path().join("Cargo.toml");
614        std::fs::write(
615            &file,
616            "[workspace.package]\nversion = \"2.5.7\"\nedition = \"2024\"\n",
617        )
618        .unwrap();
619        assert_eq!(read_major_version(&file).unwrap(), 2);
620    }
621
622    #[test]
623    fn inline_table_version_does_not_shadow_workspace_package() {
624        assert_eq!(parse_section_header("[[bin]]"), Some("bin"));
625
626        let dir = tempfile::tempdir().unwrap();
627        let file = dir.path().join("Cargo.toml");
628        std::fs::write(
629            &file,
630            "[[bin]]\nname = \"devflow\"\n\
631             [workspace.dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n\
632             [workspace.package]\nversion = \"1.2.0\"\n",
633        )
634        .unwrap();
635
636        assert_eq!(read_major_version(&file).unwrap(), 1);
637        write_version(
638            dir.path(),
639            &Version {
640                major: 2,
641                minor: 3,
642                patch: 4,
643            },
644        )
645        .unwrap();
646        let contents = std::fs::read_to_string(file).unwrap();
647        assert!(contents.contains("serde = { version = \"1\""));
648        assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
649    }
650
651    #[test]
652    fn read_major_from_package_json() {
653        let dir = tempfile::tempdir().unwrap();
654        let file = dir.path().join("package.json");
655        std::fs::write(&file, "{\n  \"version\": \"3.1.0\"\n}\n").unwrap();
656        assert_eq!(read_major_version(&file).unwrap(), 3);
657    }
658
659    #[test]
660    fn count_tags_and_commits_drive_minor_and_patch() {
661        let dir = tempfile::tempdir().unwrap();
662        let root = dir.path();
663        init_repo(root);
664        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
665        commit(root, "a.txt");
666        // No tags yet → minor 0, patch counts all commits.
667        assert_eq!(count_git_tags(root).unwrap(), 0);
668        let v = compute_version(root).unwrap();
669        assert_eq!(v.major, 2);
670        assert_eq!(v.minor, 0);
671        assert!(v.patch >= 1);
672
673        git(root, &["tag", "v2.0.0"]);
674        commit(root, "b.txt");
675        commit(root, "c.txt");
676        assert_eq!(count_git_tags(root).unwrap(), 1);
677        assert_eq!(commits_since_last_minor_tag(root).unwrap(), 2);
678
679        let v = compute_version(root).unwrap();
680        assert_eq!(
681            v,
682            Version {
683                major: 2,
684                minor: 1,
685                patch: 2
686            }
687        );
688        assert_eq!(v.to_string(), "2.1.2");
689    }
690
691    #[test]
692    fn write_version_replaces_in_cargo_toml() {
693        let dir = tempfile::tempdir().unwrap();
694        std::fs::write(
695            dir.path().join("Cargo.toml"),
696            "[package]\nversion = \"0.1.0\"\n",
697        )
698        .unwrap();
699        let path = write_version(
700            dir.path(),
701            &Version {
702                major: 2,
703                minor: 3,
704                patch: 4,
705            },
706        )
707        .unwrap();
708        let contents = std::fs::read_to_string(&path).unwrap();
709        assert!(contents.contains("version = \"2.3.4\""));
710    }
711
712    #[test]
713    fn write_version_replaces_in_workspace_cargo_toml() {
714        let dir = tempfile::tempdir().unwrap();
715        std::fs::write(
716            dir.path().join("Cargo.toml"),
717            "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
718        )
719        .unwrap();
720        let path = write_version(
721            dir.path(),
722            &Version {
723                major: 2,
724                minor: 3,
725                patch: 4,
726            },
727        )
728        .unwrap();
729        let contents = std::fs::read_to_string(&path).unwrap();
730        assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
731    }
732
733    #[test]
734    fn write_version_errors_without_version_file() {
735        let dir = tempfile::tempdir().unwrap();
736        assert!(matches!(
737            write_version(
738                dir.path(),
739                &Version {
740                    major: 1,
741                    minor: 0,
742                    patch: 0
743                }
744            ),
745            Err(VersionError::Parse(_))
746        ));
747    }
748
749    #[test]
750    fn read_version_round_trips_through_write_version_in_plain_cargo_toml() {
751        let dir = tempfile::tempdir().unwrap();
752        std::fs::write(
753            dir.path().join("Cargo.toml"),
754            "[package]\nversion = \"0.1.0\"\n",
755        )
756        .unwrap();
757        let written = Version {
758            major: 2,
759            minor: 3,
760            patch: 4,
761        };
762        write_version(dir.path(), &written).unwrap();
763        assert_eq!(read_version(dir.path()).unwrap(), written);
764    }
765
766    #[test]
767    fn read_version_round_trips_through_write_version_in_workspace_cargo_toml() {
768        let dir = tempfile::tempdir().unwrap();
769        std::fs::write(
770            dir.path().join("Cargo.toml"),
771            "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
772        )
773        .unwrap();
774        let written = Version {
775            major: 5,
776            minor: 6,
777            patch: 7,
778        };
779        write_version(dir.path(), &written).unwrap();
780        assert_eq!(read_version(dir.path()).unwrap(), written);
781    }
782
783    #[test]
784    fn read_version_round_trips_through_write_version_in_package_json() {
785        let dir = tempfile::tempdir().unwrap();
786        std::fs::write(
787            dir.path().join("package.json"),
788            "{\n  \"version\": \"0.1.0\"\n}\n",
789        )
790        .unwrap();
791        let written = Version {
792            major: 1,
793            minor: 9,
794            patch: 12,
795        };
796        write_version(dir.path(), &written).unwrap();
797        assert_eq!(read_version(dir.path()).unwrap(), written);
798    }
799
800    #[test]
801    fn read_version_errors_without_version_file() {
802        let dir = tempfile::tempdir().unwrap();
803        assert!(matches!(
804            read_version(dir.path()),
805            Err(VersionError::Parse(_))
806        ));
807    }
808
809    #[test]
810    fn write_version_preserves_trailing_comma_in_package_json() {
811        // GAP-6: replace_version_in_contents reassembles the matched line as
812        // `left.trim_end() + separator + quoted_version + '\n'`, discarding
813        // everything in `value` after the version token. For a real
814        // package.json where `version` is not the last key, that eats the
815        // mandatory trailing comma and produces invalid JSON. Parsing is the
816        // assertion that matters here — a substring check would be a
817        // vacuous fixture that can't reach this defect.
818        let dir = tempfile::tempdir().unwrap();
819        std::fs::write(
820            dir.path().join("package.json"),
821            "{\n  \"name\": \"x\",\n  \"version\": \"0.1.0\",\n  \"private\": true\n}\n",
822        )
823        .unwrap();
824        write_version(
825            dir.path(),
826            &Version {
827                major: 2,
828                minor: 3,
829                patch: 4,
830            },
831        )
832        .unwrap();
833        let contents = std::fs::read_to_string(dir.path().join("package.json")).unwrap();
834        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|err| {
835            panic!("package.json no longer parses as JSON: {err}\n{contents}")
836        });
837        assert_eq!(parsed["name"], "x");
838        assert_eq!(parsed["private"], true);
839        assert_eq!(parsed["version"], "2.3.4");
840    }
841
842    #[test]
843    fn write_version_preserves_trailing_comment_in_toml() {
844        // GAP-6, TOML variant: a trailing `# comment` after the quoted
845        // version is discarded by the same line-reassembly defect.
846        let dir = tempfile::tempdir().unwrap();
847        std::fs::write(
848            dir.path().join("Cargo.toml"),
849            "[package]\nversion = \"0.1.0\"  # pinned\n",
850        )
851        .unwrap();
852        write_version(
853            dir.path(),
854            &Version {
855                major: 2,
856                minor: 3,
857                patch: 4,
858            },
859        )
860        .unwrap();
861        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
862        assert!(
863            contents.contains("version = \"2.3.4\"  # pinned"),
864            "expected trailing comment to survive, got: {contents}"
865        );
866    }
867
868    #[test]
869    fn write_version_preserves_trailing_comment_in_single_quoted_toml() {
870        // GAP-6, TOML literal-string variant (17-13 review IN-03): the
871        // remainder scan keys off the OPENING quote character, so the
872        // single-quote branch is a distinct path from the double-quote case
873        // above and needs its own fixture.
874        let dir = tempfile::tempdir().unwrap();
875        std::fs::write(
876            dir.path().join("Cargo.toml"),
877            "[package]\nversion = '0.1.0'  # pinned\n",
878        )
879        .unwrap();
880        write_version(
881            dir.path(),
882            &Version {
883                major: 2,
884                minor: 3,
885                patch: 4,
886            },
887        )
888        .unwrap();
889        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
890        assert!(
891            contents.contains("version = '2.3.4'  # pinned"),
892            "expected single-quoted value and trailing comment to survive, got: {contents}"
893        );
894    }
895
896    #[test]
897    fn read_version_extracts_clean_value_with_trailing_comment() {
898        // CR-01 (phase 20 review): `find_version_in_contents` used to
899        // `trim_matches` the whole tail of the line, which only strips a
900        // quote sitting at the very end of the remaining string. With a
901        // trailing `# comment` after the closing quote, the real closing
902        // quote is never stripped and the corrupted value fails to parse.
903        // `write_version` already preserves this exact pattern (GAP-6); the
904        // read path must be symmetric with it.
905        let dir = tempfile::tempdir().unwrap();
906        std::fs::write(
907            dir.path().join("Cargo.toml"),
908            "[package]\nversion = \"1.7.0\"  # pinned release version\n",
909        )
910        .unwrap();
911        assert_eq!(
912            read_version(dir.path()).unwrap(),
913            Version {
914                major: 1,
915                minor: 7,
916                patch: 0
917            }
918        );
919    }
920
921    #[test]
922    fn read_version_extracts_clean_value_without_trailing_comment() {
923        // Bare `version = "1.7.0"` (no comment) must still work.
924        let dir = tempfile::tempdir().unwrap();
925        std::fs::write(
926            dir.path().join("Cargo.toml"),
927            "[package]\nversion = \"1.7.0\"\n",
928        )
929        .unwrap();
930        assert_eq!(
931            read_version(dir.path()).unwrap(),
932            Version {
933                major: 1,
934                minor: 7,
935                patch: 0
936            }
937        );
938    }
939
940    #[test]
941    fn read_workspace_self_pins_extracts_clean_workspace_version_with_trailing_comment() {
942        // CR-01: `read_workspace_self_pins` calls `find_version_in_contents`
943        // for `workspace_version` too — a trailing comment next to
944        // `[workspace.package] version` must not corrupt the value
945        // `check_self_pin` compares pins against.
946        let (workspace_version, _pins) = read_workspace_self_pins(
947            "[workspace.package]\nversion = \"1.7.0\"  # pinned release version\nedition = \"2024\"\n",
948        );
949        assert_eq!(workspace_version.as_deref(), Some("1.7.0"));
950    }
951
952    #[test]
953    fn read_version_does_not_recompute_from_git_tags() {
954        // read_version must report exactly what's on disk, not a freshly
955        // computed minor/patch — this is the property VersionBump/
956        // ChangelogAppend ordering depends on (version.rs must never see a
957        // tag VersionBump just created and derive a different number).
958        let dir = tempfile::tempdir().unwrap();
959        let root = dir.path();
960        init_repo(root);
961        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
962        commit(root, "a.txt");
963        write_version(
964            root,
965            &Version {
966                major: 2,
967                minor: 0,
968                patch: 0,
969            },
970        )
971        .unwrap();
972        git(root, &["tag", "v2.0.0"]);
973        commit(root, "b.txt");
974        commit(root, "c.txt");
975        // compute_version would see 1 tag + 2 commits since => 2.1.2.
976        // read_version must still report exactly what's on disk: 2.0.0.
977        assert_eq!(
978            read_version(root).unwrap(),
979            Version {
980                major: 2,
981                minor: 0,
982                patch: 0
983            }
984        );
985    }
986
987    #[test]
988    fn write_version_rewrites_workspace_dependency_self_pin() {
989        // 20a / DEN-49: a published Cargo workspace states its version twice —
990        // once in [workspace.package] version, and again as an explicit
991        // `version` pin on every [workspace.dependencies] entry that points
992        // at a workspace member by `path` (Cargo has no interpolation for
993        // dependency versions, and a path dependency of a *published* crate
994        // requires an explicit version). write_version must rewrite BOTH in
995        // one write, or the self-pin ships stale and `cargo publish` rejects
996        // the upload as a duplicate on release day (shipped broken twice:
997        // v1.5.0 by 7ad260c, v1.6.0 by PR #15).
998        let dir = tempfile::tempdir().unwrap();
999        std::fs::write(
1000            dir.path().join("Cargo.toml"),
1001            "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1002             [workspace.dependencies]\n\
1003             devflow-core = { path = \"crates/devflow-core\", version = \"1.6.0\" }\n",
1004        )
1005        .unwrap();
1006        write_version(
1007            dir.path(),
1008            &Version {
1009                major: 1,
1010                minor: 7,
1011                patch: 0,
1012            },
1013        )
1014        .unwrap();
1015        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1016        assert!(
1017            contents.contains("[workspace.package]\nversion = \"1.7.0\""),
1018            "expected [workspace.package] version to be rewritten, got: {contents}"
1019        );
1020        assert!(
1021            contents
1022                .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
1023            "expected the [workspace.dependencies] self-pin to be rewritten to 1.7.0 \
1024             alongside [workspace.package] version, got: {contents}"
1025        );
1026    }
1027
1028    #[test]
1029    fn write_version_no_ops_on_missing_workspace_dependencies_section() {
1030        // 20a/empty: a workspace Cargo.toml with no [workspace.dependencies]
1031        // section at all must not panic — the additive pass simply never
1032        // matches and the file is otherwise rewritten normally.
1033        let dir = tempfile::tempdir().unwrap();
1034        std::fs::write(
1035            dir.path().join("Cargo.toml"),
1036            "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n",
1037        )
1038        .unwrap();
1039        write_version(
1040            dir.path(),
1041            &Version {
1042                major: 1,
1043                minor: 7,
1044                patch: 0,
1045            },
1046        )
1047        .unwrap();
1048        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1049        assert_eq!(
1050            contents,
1051            "[workspace.package]\nversion = \"1.7.0\"\nedition = \"2024\"\n"
1052        );
1053    }
1054
1055    #[test]
1056    fn write_version_no_ops_on_member_with_no_version_key() {
1057        // 20a/empty: a [workspace.dependencies] entry with a local `path`
1058        // but no `version` key at all is left unchanged — nothing to
1059        // rewrite, and no panic.
1060        let dir = tempfile::tempdir().unwrap();
1061        let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1062             [workspace.dependencies]\n\
1063             devflow-core = { path = \"crates/devflow-core\" }\n";
1064        std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1065        write_version(
1066            dir.path(),
1067            &Version {
1068                major: 1,
1069                minor: 7,
1070                patch: 0,
1071            },
1072        )
1073        .unwrap();
1074        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1075        assert!(
1076            contents.contains("devflow-core = { path = \"crates/devflow-core\" }"),
1077            "expected the version-less path member to be left byte-identical, got: {contents}"
1078        );
1079    }
1080
1081    #[test]
1082    fn write_version_leaves_third_party_version_only_dep_untouched() {
1083        // 20a/adjacency: a third-party version-only dep sitting adjacent to
1084        // a local path member is left byte-for-byte unchanged — only the
1085        // path member's version sub-value is rewritten.
1086        let dir = tempfile::tempdir().unwrap();
1087        let third_party_line = "serde = { version = \"1\", features = [\"derive\"] }";
1088        let toml = format!(
1089            "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1090             [workspace.dependencies]\n\
1091             devflow-core = {{ path = \"crates/devflow-core\", version = \"1.6.0\" }}\n\
1092             {third_party_line}\n"
1093        );
1094        std::fs::write(dir.path().join("Cargo.toml"), &toml).unwrap();
1095        write_version(
1096            dir.path(),
1097            &Version {
1098                major: 1,
1099                minor: 7,
1100                patch: 0,
1101            },
1102        )
1103        .unwrap();
1104        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1105        assert!(
1106            contents
1107                .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
1108            "expected the local path member's version to be rewritten, got: {contents}"
1109        );
1110        assert!(
1111            contents.contains(third_party_line),
1112            "expected the third-party version-only dep to be byte-identical, got: {contents}"
1113        );
1114    }
1115
1116    #[test]
1117    fn write_version_preserves_comment_and_quote_in_workspace_dependency_pin() {
1118        // GAP-6, inline-table variant: a self-pin line with a trailing
1119        // comment and single-quoted values keeps its comment and quote
1120        // style after rewrite.
1121        let dir = tempfile::tempdir().unwrap();
1122        let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1123             [workspace.dependencies]\n\
1124             devflow-core = { path = 'crates/devflow-core', version = '1.6.0' }  # pinned\n";
1125        std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1126        write_version(
1127            dir.path(),
1128            &Version {
1129                major: 1,
1130                minor: 7,
1131                patch: 0,
1132            },
1133        )
1134        .unwrap();
1135        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1136        assert!(
1137            contents.contains(
1138                "devflow-core = { path = 'crates/devflow-core', version = '1.7.0' }  # pinned"
1139            ),
1140            "expected single-quote style and trailing comment to survive the rewrite, got: {contents}"
1141        );
1142    }
1143
1144    #[test]
1145    fn write_version_rewrites_self_pin_regardless_of_key_order() {
1146        // review: inline-table key-order — the version sub-value is
1147        // rewritten whether it appears BEFORE or AFTER path in the inline
1148        // table; the replacement is anchored strictly to the path=/
1149        // version= tokens, not a column offset.
1150        let dir = tempfile::tempdir().unwrap();
1151        let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1152             [workspace.dependencies]\n\
1153             devflow-core = { version = \"1.6.0\", path = \"crates/devflow-core\" }\n";
1154        std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1155        write_version(
1156            dir.path(),
1157            &Version {
1158                major: 1,
1159                minor: 7,
1160                patch: 0,
1161            },
1162        )
1163        .unwrap();
1164        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1165        assert!(
1166            contents
1167                .contains("devflow-core = { version = \"1.7.0\", path = \"crates/devflow-core\" }"),
1168            "expected version to be rewritten regardless of key order, got: {contents}"
1169        );
1170    }
1171}