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    use std::process::Command;
563
564    fn git(root: &Path, args: &[&str]) {
565        let ok = Command::new("git")
566            .args(args)
567            .current_dir(root)
568            .output()
569            .unwrap()
570            .status
571            .success();
572        assert!(ok, "git {args:?} failed");
573    }
574
575    fn init_repo(root: &Path) {
576        git(root, &["init", "-q"]);
577        git(root, &["config", "user.email", "test@example.com"]);
578        git(root, &["config", "user.name", "Test"]);
579        git(root, &["config", "commit.gpgsign", "false"]);
580        git(root, &["config", "tag.gpgsign", "false"]);
581        git(root, &["config", "core.hooksPath", "/dev/null"]);
582    }
583
584    fn commit(root: &Path, name: &str) {
585        std::fs::write(root.join(name), name).unwrap();
586        git(root, &["add", "."]);
587        git(root, &["commit", "-q", "-m", &format!("add {name}")]);
588    }
589
590    #[test]
591    fn detect_prefers_cargo_then_pyproject_then_package_json() {
592        let dir = tempfile::tempdir().unwrap();
593        assert!(detect_version_file(dir.path()).is_none());
594        std::fs::write(dir.path().join("package.json"), "{\"version\":\"1.0.0\"}").unwrap();
595        assert!(
596            detect_version_file(dir.path())
597                .unwrap()
598                .ends_with("package.json")
599        );
600        std::fs::write(
601            dir.path().join("Cargo.toml"),
602            "[package]\nversion=\"1.0.0\"",
603        )
604        .unwrap();
605        assert!(
606            detect_version_file(dir.path())
607                .unwrap()
608                .ends_with("Cargo.toml")
609        );
610    }
611
612    #[test]
613    fn read_major_from_workspace_package() {
614        let dir = tempfile::tempdir().unwrap();
615        let file = dir.path().join("Cargo.toml");
616        std::fs::write(
617            &file,
618            "[workspace.package]\nversion = \"2.5.7\"\nedition = \"2024\"\n",
619        )
620        .unwrap();
621        assert_eq!(read_major_version(&file).unwrap(), 2);
622    }
623
624    #[test]
625    fn inline_table_version_does_not_shadow_workspace_package() {
626        assert_eq!(parse_section_header("[[bin]]"), Some("bin"));
627
628        let dir = tempfile::tempdir().unwrap();
629        let file = dir.path().join("Cargo.toml");
630        std::fs::write(
631            &file,
632            "[[bin]]\nname = \"devflow\"\n\
633             [workspace.dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n\
634             [workspace.package]\nversion = \"1.2.0\"\n",
635        )
636        .unwrap();
637
638        assert_eq!(read_major_version(&file).unwrap(), 1);
639        write_version(
640            dir.path(),
641            &Version {
642                major: 2,
643                minor: 3,
644                patch: 4,
645            },
646        )
647        .unwrap();
648        let contents = std::fs::read_to_string(file).unwrap();
649        assert!(contents.contains("serde = { version = \"1\""));
650        assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
651    }
652
653    #[test]
654    fn read_major_from_package_json() {
655        let dir = tempfile::tempdir().unwrap();
656        let file = dir.path().join("package.json");
657        std::fs::write(&file, "{\n  \"version\": \"3.1.0\"\n}\n").unwrap();
658        assert_eq!(read_major_version(&file).unwrap(), 3);
659    }
660
661    #[test]
662    fn count_tags_and_commits_drive_minor_and_patch() {
663        let dir = tempfile::tempdir().unwrap();
664        let root = dir.path();
665        init_repo(root);
666        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
667        commit(root, "a.txt");
668        // No tags yet → minor 0, patch counts all commits.
669        assert_eq!(count_git_tags(root).unwrap(), 0);
670        let v = compute_version(root).unwrap();
671        assert_eq!(v.major, 2);
672        assert_eq!(v.minor, 0);
673        assert!(v.patch >= 1);
674
675        git(root, &["tag", "v2.0.0"]);
676        commit(root, "b.txt");
677        commit(root, "c.txt");
678        assert_eq!(count_git_tags(root).unwrap(), 1);
679        assert_eq!(commits_since_last_minor_tag(root).unwrap(), 2);
680
681        let v = compute_version(root).unwrap();
682        assert_eq!(
683            v,
684            Version {
685                major: 2,
686                minor: 1,
687                patch: 2
688            }
689        );
690        assert_eq!(v.to_string(), "2.1.2");
691    }
692
693    #[test]
694    fn write_version_replaces_in_cargo_toml() {
695        let dir = tempfile::tempdir().unwrap();
696        std::fs::write(
697            dir.path().join("Cargo.toml"),
698            "[package]\nversion = \"0.1.0\"\n",
699        )
700        .unwrap();
701        let path = write_version(
702            dir.path(),
703            &Version {
704                major: 2,
705                minor: 3,
706                patch: 4,
707            },
708        )
709        .unwrap();
710        let contents = std::fs::read_to_string(&path).unwrap();
711        assert!(contents.contains("version = \"2.3.4\""));
712    }
713
714    #[test]
715    fn write_version_replaces_in_workspace_cargo_toml() {
716        let dir = tempfile::tempdir().unwrap();
717        std::fs::write(
718            dir.path().join("Cargo.toml"),
719            "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
720        )
721        .unwrap();
722        let path = write_version(
723            dir.path(),
724            &Version {
725                major: 2,
726                minor: 3,
727                patch: 4,
728            },
729        )
730        .unwrap();
731        let contents = std::fs::read_to_string(&path).unwrap();
732        assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
733    }
734
735    #[test]
736    fn write_version_errors_without_version_file() {
737        let dir = tempfile::tempdir().unwrap();
738        assert!(matches!(
739            write_version(
740                dir.path(),
741                &Version {
742                    major: 1,
743                    minor: 0,
744                    patch: 0
745                }
746            ),
747            Err(VersionError::Parse(_))
748        ));
749    }
750
751    #[test]
752    fn read_version_round_trips_through_write_version_in_plain_cargo_toml() {
753        let dir = tempfile::tempdir().unwrap();
754        std::fs::write(
755            dir.path().join("Cargo.toml"),
756            "[package]\nversion = \"0.1.0\"\n",
757        )
758        .unwrap();
759        let written = Version {
760            major: 2,
761            minor: 3,
762            patch: 4,
763        };
764        write_version(dir.path(), &written).unwrap();
765        assert_eq!(read_version(dir.path()).unwrap(), written);
766    }
767
768    #[test]
769    fn read_version_round_trips_through_write_version_in_workspace_cargo_toml() {
770        let dir = tempfile::tempdir().unwrap();
771        std::fs::write(
772            dir.path().join("Cargo.toml"),
773            "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
774        )
775        .unwrap();
776        let written = Version {
777            major: 5,
778            minor: 6,
779            patch: 7,
780        };
781        write_version(dir.path(), &written).unwrap();
782        assert_eq!(read_version(dir.path()).unwrap(), written);
783    }
784
785    #[test]
786    fn read_version_round_trips_through_write_version_in_package_json() {
787        let dir = tempfile::tempdir().unwrap();
788        std::fs::write(
789            dir.path().join("package.json"),
790            "{\n  \"version\": \"0.1.0\"\n}\n",
791        )
792        .unwrap();
793        let written = Version {
794            major: 1,
795            minor: 9,
796            patch: 12,
797        };
798        write_version(dir.path(), &written).unwrap();
799        assert_eq!(read_version(dir.path()).unwrap(), written);
800    }
801
802    #[test]
803    fn read_version_errors_without_version_file() {
804        let dir = tempfile::tempdir().unwrap();
805        assert!(matches!(
806            read_version(dir.path()),
807            Err(VersionError::Parse(_))
808        ));
809    }
810
811    #[test]
812    fn write_version_preserves_trailing_comma_in_package_json() {
813        // GAP-6: replace_version_in_contents reassembles the matched line as
814        // `left.trim_end() + separator + quoted_version + '\n'`, discarding
815        // everything in `value` after the version token. For a real
816        // package.json where `version` is not the last key, that eats the
817        // mandatory trailing comma and produces invalid JSON. Parsing is the
818        // assertion that matters here — a substring check would be a
819        // vacuous fixture that can't reach this defect.
820        let dir = tempfile::tempdir().unwrap();
821        std::fs::write(
822            dir.path().join("package.json"),
823            "{\n  \"name\": \"x\",\n  \"version\": \"0.1.0\",\n  \"private\": true\n}\n",
824        )
825        .unwrap();
826        write_version(
827            dir.path(),
828            &Version {
829                major: 2,
830                minor: 3,
831                patch: 4,
832            },
833        )
834        .unwrap();
835        let contents = std::fs::read_to_string(dir.path().join("package.json")).unwrap();
836        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|err| {
837            panic!("package.json no longer parses as JSON: {err}\n{contents}")
838        });
839        assert_eq!(parsed["name"], "x");
840        assert_eq!(parsed["private"], true);
841        assert_eq!(parsed["version"], "2.3.4");
842    }
843
844    #[test]
845    fn write_version_preserves_trailing_comment_in_toml() {
846        // GAP-6, TOML variant: a trailing `# comment` after the quoted
847        // version is discarded by the same line-reassembly defect.
848        let dir = tempfile::tempdir().unwrap();
849        std::fs::write(
850            dir.path().join("Cargo.toml"),
851            "[package]\nversion = \"0.1.0\"  # pinned\n",
852        )
853        .unwrap();
854        write_version(
855            dir.path(),
856            &Version {
857                major: 2,
858                minor: 3,
859                patch: 4,
860            },
861        )
862        .unwrap();
863        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
864        assert!(
865            contents.contains("version = \"2.3.4\"  # pinned"),
866            "expected trailing comment to survive, got: {contents}"
867        );
868    }
869
870    #[test]
871    fn write_version_preserves_trailing_comment_in_single_quoted_toml() {
872        // GAP-6, TOML literal-string variant (17-13 review IN-03): the
873        // remainder scan keys off the OPENING quote character, so the
874        // single-quote branch is a distinct path from the double-quote case
875        // above and needs its own fixture.
876        let dir = tempfile::tempdir().unwrap();
877        std::fs::write(
878            dir.path().join("Cargo.toml"),
879            "[package]\nversion = '0.1.0'  # pinned\n",
880        )
881        .unwrap();
882        write_version(
883            dir.path(),
884            &Version {
885                major: 2,
886                minor: 3,
887                patch: 4,
888            },
889        )
890        .unwrap();
891        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
892        assert!(
893            contents.contains("version = '2.3.4'  # pinned"),
894            "expected single-quoted value and trailing comment to survive, got: {contents}"
895        );
896    }
897
898    #[test]
899    fn read_version_extracts_clean_value_with_trailing_comment() {
900        // CR-01 (phase 20 review): `find_version_in_contents` used to
901        // `trim_matches` the whole tail of the line, which only strips a
902        // quote sitting at the very end of the remaining string. With a
903        // trailing `# comment` after the closing quote, the real closing
904        // quote is never stripped and the corrupted value fails to parse.
905        // `write_version` already preserves this exact pattern (GAP-6); the
906        // read path must be symmetric with it.
907        let dir = tempfile::tempdir().unwrap();
908        std::fs::write(
909            dir.path().join("Cargo.toml"),
910            "[package]\nversion = \"1.7.0\"  # pinned release version\n",
911        )
912        .unwrap();
913        assert_eq!(
914            read_version(dir.path()).unwrap(),
915            Version {
916                major: 1,
917                minor: 7,
918                patch: 0
919            }
920        );
921    }
922
923    #[test]
924    fn read_version_extracts_clean_value_without_trailing_comment() {
925        // Bare `version = "1.7.0"` (no comment) must still work.
926        let dir = tempfile::tempdir().unwrap();
927        std::fs::write(
928            dir.path().join("Cargo.toml"),
929            "[package]\nversion = \"1.7.0\"\n",
930        )
931        .unwrap();
932        assert_eq!(
933            read_version(dir.path()).unwrap(),
934            Version {
935                major: 1,
936                minor: 7,
937                patch: 0
938            }
939        );
940    }
941
942    #[test]
943    fn read_workspace_self_pins_extracts_clean_workspace_version_with_trailing_comment() {
944        // CR-01: `read_workspace_self_pins` calls `find_version_in_contents`
945        // for `workspace_version` too — a trailing comment next to
946        // `[workspace.package] version` must not corrupt the value
947        // `check_self_pin` compares pins against.
948        let (workspace_version, _pins) = read_workspace_self_pins(
949            "[workspace.package]\nversion = \"1.7.0\"  # pinned release version\nedition = \"2024\"\n",
950        );
951        assert_eq!(workspace_version.as_deref(), Some("1.7.0"));
952    }
953
954    #[test]
955    fn read_version_does_not_recompute_from_git_tags() {
956        // read_version must report exactly what's on disk, not a freshly
957        // computed minor/patch — this is the property VersionBump/
958        // ChangelogAppend ordering depends on (version.rs must never see a
959        // tag VersionBump just created and derive a different number).
960        let dir = tempfile::tempdir().unwrap();
961        let root = dir.path();
962        init_repo(root);
963        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
964        commit(root, "a.txt");
965        write_version(
966            root,
967            &Version {
968                major: 2,
969                minor: 0,
970                patch: 0,
971            },
972        )
973        .unwrap();
974        git(root, &["tag", "v2.0.0"]);
975        commit(root, "b.txt");
976        commit(root, "c.txt");
977        // compute_version would see 1 tag + 2 commits since => 2.1.2.
978        // read_version must still report exactly what's on disk: 2.0.0.
979        assert_eq!(
980            read_version(root).unwrap(),
981            Version {
982                major: 2,
983                minor: 0,
984                patch: 0
985            }
986        );
987    }
988
989    #[test]
990    fn write_version_rewrites_workspace_dependency_self_pin() {
991        // 20a / DEN-49: a published Cargo workspace states its version twice —
992        // once in [workspace.package] version, and again as an explicit
993        // `version` pin on every [workspace.dependencies] entry that points
994        // at a workspace member by `path` (Cargo has no interpolation for
995        // dependency versions, and a path dependency of a *published* crate
996        // requires an explicit version). write_version must rewrite BOTH in
997        // one write, or the self-pin ships stale and `cargo publish` rejects
998        // the upload as a duplicate on release day (shipped broken twice:
999        // v1.5.0 by 7ad260c, v1.6.0 by PR #15).
1000        let dir = tempfile::tempdir().unwrap();
1001        std::fs::write(
1002            dir.path().join("Cargo.toml"),
1003            "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1004             [workspace.dependencies]\n\
1005             devflow-core = { path = \"crates/devflow-core\", version = \"1.6.0\" }\n",
1006        )
1007        .unwrap();
1008        write_version(
1009            dir.path(),
1010            &Version {
1011                major: 1,
1012                minor: 7,
1013                patch: 0,
1014            },
1015        )
1016        .unwrap();
1017        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1018        assert!(
1019            contents.contains("[workspace.package]\nversion = \"1.7.0\""),
1020            "expected [workspace.package] version to be rewritten, got: {contents}"
1021        );
1022        assert!(
1023            contents
1024                .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
1025            "expected the [workspace.dependencies] self-pin to be rewritten to 1.7.0 \
1026             alongside [workspace.package] version, got: {contents}"
1027        );
1028    }
1029
1030    #[test]
1031    fn write_version_no_ops_on_missing_workspace_dependencies_section() {
1032        // 20a/empty: a workspace Cargo.toml with no [workspace.dependencies]
1033        // section at all must not panic — the additive pass simply never
1034        // matches and the file is otherwise rewritten normally.
1035        let dir = tempfile::tempdir().unwrap();
1036        std::fs::write(
1037            dir.path().join("Cargo.toml"),
1038            "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n",
1039        )
1040        .unwrap();
1041        write_version(
1042            dir.path(),
1043            &Version {
1044                major: 1,
1045                minor: 7,
1046                patch: 0,
1047            },
1048        )
1049        .unwrap();
1050        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1051        assert_eq!(
1052            contents,
1053            "[workspace.package]\nversion = \"1.7.0\"\nedition = \"2024\"\n"
1054        );
1055    }
1056
1057    #[test]
1058    fn write_version_no_ops_on_member_with_no_version_key() {
1059        // 20a/empty: a [workspace.dependencies] entry with a local `path`
1060        // but no `version` key at all is left unchanged — nothing to
1061        // rewrite, and no panic.
1062        let dir = tempfile::tempdir().unwrap();
1063        let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1064             [workspace.dependencies]\n\
1065             devflow-core = { path = \"crates/devflow-core\" }\n";
1066        std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1067        write_version(
1068            dir.path(),
1069            &Version {
1070                major: 1,
1071                minor: 7,
1072                patch: 0,
1073            },
1074        )
1075        .unwrap();
1076        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1077        assert!(
1078            contents.contains("devflow-core = { path = \"crates/devflow-core\" }"),
1079            "expected the version-less path member to be left byte-identical, got: {contents}"
1080        );
1081    }
1082
1083    #[test]
1084    fn write_version_leaves_third_party_version_only_dep_untouched() {
1085        // 20a/adjacency: a third-party version-only dep sitting adjacent to
1086        // a local path member is left byte-for-byte unchanged — only the
1087        // path member's version sub-value is rewritten.
1088        let dir = tempfile::tempdir().unwrap();
1089        let third_party_line = "serde = { version = \"1\", features = [\"derive\"] }";
1090        let toml = format!(
1091            "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1092             [workspace.dependencies]\n\
1093             devflow-core = {{ path = \"crates/devflow-core\", version = \"1.6.0\" }}\n\
1094             {third_party_line}\n"
1095        );
1096        std::fs::write(dir.path().join("Cargo.toml"), &toml).unwrap();
1097        write_version(
1098            dir.path(),
1099            &Version {
1100                major: 1,
1101                minor: 7,
1102                patch: 0,
1103            },
1104        )
1105        .unwrap();
1106        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1107        assert!(
1108            contents
1109                .contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
1110            "expected the local path member's version to be rewritten, got: {contents}"
1111        );
1112        assert!(
1113            contents.contains(third_party_line),
1114            "expected the third-party version-only dep to be byte-identical, got: {contents}"
1115        );
1116    }
1117
1118    #[test]
1119    fn write_version_preserves_comment_and_quote_in_workspace_dependency_pin() {
1120        // GAP-6, inline-table variant: a self-pin line with a trailing
1121        // comment and single-quoted values keeps its comment and quote
1122        // style after rewrite.
1123        let dir = tempfile::tempdir().unwrap();
1124        let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1125             [workspace.dependencies]\n\
1126             devflow-core = { path = 'crates/devflow-core', version = '1.6.0' }  # pinned\n";
1127        std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1128        write_version(
1129            dir.path(),
1130            &Version {
1131                major: 1,
1132                minor: 7,
1133                patch: 0,
1134            },
1135        )
1136        .unwrap();
1137        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1138        assert!(
1139            contents.contains(
1140                "devflow-core = { path = 'crates/devflow-core', version = '1.7.0' }  # pinned"
1141            ),
1142            "expected single-quote style and trailing comment to survive the rewrite, got: {contents}"
1143        );
1144    }
1145
1146    #[test]
1147    fn write_version_rewrites_self_pin_regardless_of_key_order() {
1148        // review: inline-table key-order — the version sub-value is
1149        // rewritten whether it appears BEFORE or AFTER path in the inline
1150        // table; the replacement is anchored strictly to the path=/
1151        // version= tokens, not a column offset.
1152        let dir = tempfile::tempdir().unwrap();
1153        let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
1154             [workspace.dependencies]\n\
1155             devflow-core = { version = \"1.6.0\", path = \"crates/devflow-core\" }\n";
1156        std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
1157        write_version(
1158            dir.path(),
1159            &Version {
1160                major: 1,
1161                minor: 7,
1162                patch: 0,
1163            },
1164        )
1165        .unwrap();
1166        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
1167        assert!(
1168            contents
1169                .contains("devflow-core = { version = \"1.7.0\", path = \"crates/devflow-core\" }"),
1170            "expected version to be rewritten regardless of key order, got: {contents}"
1171        );
1172    }
1173}