Skip to main content

git_policy_cli/
dependent_version_release.rs

1//! What: Patch-bump a plain `major.minor.patch` release version, and quote text the way
2//!       `JSON.stringify` does.
3//! Why: A dependent receives a patch bump. Only a plain release can be bumped
4//!      automatically; anything else (a prerelease, build metadata, a leading zero, a
5//!      missing component) is reported for a bump by hand, with the incumbent's message
6//!      (`dependent-version-bump.ts:87-177`). The patch component is incremented as a
7//!      decimal digit string, so it is exact at any size.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! // patchBumpVersion({ name, version }); JSON.stringify(text);
12//! ```
13
14/// What: The UTF-16 code unit of `.`.
15/// Why:  Versions are kept as code units; components are split on this unit.
16///
17/// In TS you'd write (pseudocode):
18/// ```ts
19/// const DOT = '.'.charCodeAt(0);
20/// ```
21const DOT: u16 = 0x2e;
22
23/// What: A dependent whose version cannot be bumped automatically. `Vec<u16>` keeps the
24///       version as the code units JavaScript holds.
25/// Why:  The policy turns this into a `dependent-version-unsupported` finding without a
26///       patch, and the plan stops, exactly as the incumbent's throw stops it.
27///
28/// In TS you'd write (pseudocode):
29/// ```ts
30/// class UnsupportedVersionError extends Error { name: string; version: string }
31/// ```
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct UnsupportedVersion {
34    /// The dependent's package name.
35    pub name: String,
36    /// The version found in its manifest.
37    pub version: Vec<u16>,
38}
39
40/// What: The incumbent's message for an unsupported version, verbatim.
41/// Why:  The finding text tells the person to bump that package by hand.
42///
43/// In TS you'd write (pseudocode):
44/// ```ts
45/// `${name} has version ${JSON.stringify(version)}, which is not a plain major.minor.patch release; bump it by hand in the same commit.`
46/// ```
47pub fn unsupported_message(unsupported: &UnsupportedVersion) -> String {
48    return format!(
49        "{} has version {}, which is not a plain major.minor.patch release; bump it by hand in the same commit.",
50        unsupported.name,
51        json_quote_units(&unsupported.version)
52    );
53}
54
55/// What: One automatic patch bump: the version before and after.
56/// Why:  Both appear in the finding message and the text edit.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// type ReleaseBump = { from: string; to: string };
61/// ```
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct ReleaseBump {
64    /// The current version.
65    pub from: String,
66    /// The version with the patch component increased by one.
67    pub to: String,
68}
69
70/// What: Whether one component is a non-empty run of ASCII digits without a leading zero.
71///       `&[u16]` borrows code units.
72/// Why:  Semantic versioning's numeric identifiers; `0` alone is allowed.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// function isReleaseComponent(text: string): boolean;
77/// ```
78fn is_release_component(component: &[u16]) -> bool {
79    let digits: bool = !component.is_empty()
80        && component
81            .iter()
82            .all(|unit: &u16| return (u16::from(b'0')..=u16::from(b'9')).contains(unit));
83    return digits && !(component.len() > 1 && component[0] == u16::from(b'0'));
84}
85
86/// What: Add one to a decimal digit string. `.rev()` walks from the last digit.
87/// Why:  An exact increment at any size; the incumbent's `Number(component) + 1` rounds
88///       above 2^53 and prints exponent notation from 10^21.
89///
90/// In TS you'd write (pseudocode):
91/// ```ts
92/// function incrementDecimal(digits: string): string;
93/// ```
94fn increment_decimal(digits: &[u16]) -> String {
95    let mut result: Vec<u8> = Vec::new();
96    let mut carry: bool = true;
97    for unit in digits.iter().rev() {
98        // Every unit is an ASCII digit here, so the narrowing keeps its value.
99        let digit: u8 = u8::try_from(*unit).unwrap_or(b'0');
100        if carry && digit == b'9' {
101            result.push(b'0');
102        } else if carry {
103            result.push(digit + 1);
104            carry = false;
105        } else {
106            result.push(digit);
107        }
108    }
109    if carry {
110        result.push(b'1');
111    }
112    // `.rev()` restores most-significant-first order; `char::from` widens each byte.
113    return result
114        .iter()
115        .rev()
116        .map(|byte: &u8| return char::from(*byte))
117        .collect();
118}
119
120/// What: Bump the patch component of a plain release version.
121/// Why:  Only plain releases are bumped automatically.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// function patchBumpVersion({ name, version }): string; // throws UnsupportedVersionError
126/// ```
127pub fn patch_bump_version(name: &str, version: &[u16]) -> Result<ReleaseBump, UnsupportedVersion> {
128    let components: Vec<&[u16]> = version.split(|unit: &u16| return *unit == DOT).collect();
129    let [major, minor, patch] = components.as_slice() else {
130        return Err(UnsupportedVersion {
131            name: String::from(name),
132            version: version.to_vec(),
133        });
134    };
135    if !(is_release_component(major) && is_release_component(minor) && is_release_component(patch))
136    {
137        return Err(UnsupportedVersion {
138            name: String::from(name),
139            version: version.to_vec(),
140        });
141    }
142    // `String::from_utf16_lossy` is exact here: every unit is an ASCII digit or a dot.
143    return Ok(ReleaseBump {
144        from: String::from_utf16_lossy(version),
145        to: format!(
146            "{}.{}.{}",
147            String::from_utf16_lossy(major),
148            String::from_utf16_lossy(minor),
149            increment_decimal(patch)
150        ),
151    });
152}
153
154/// What: Quote code units as `JSON.stringify` quotes a string. `char::decode_utf16` pairs
155///       surrogates and yields an error for each unpaired one.
156/// Why:  Messages and the version edit compare against the incumbent's quoting: `"`, `\`
157///       and the short control escapes are escaped, other units below U+0020 and unpaired
158///       surrogates become lower-case `\u` escapes, and everything else stays literal.
159///
160/// In TS you'd write (pseudocode):
161/// ```ts
162/// const quoted = JSON.stringify(text);
163/// ```
164pub fn json_quote_units(units: &[u16]) -> String {
165    let mut quoted: String = String::from("\"");
166    for decoded in char::decode_utf16(units.iter().copied()) {
167        match decoded {
168            Ok('"') => quoted.push_str("\\\""),
169            Ok('\\') => quoted.push_str("\\\\"),
170            Ok('\u{8}') => quoted.push_str("\\b"),
171            Ok('\u{c}') => quoted.push_str("\\f"),
172            Ok('\n') => quoted.push_str("\\n"),
173            Ok('\r') => quoted.push_str("\\r"),
174            Ok('\t') => quoted.push_str("\\t"),
175            Ok(control) if control < ' ' => {
176                quoted.push_str(&format!("\\u{:04x}", u32::from(control)));
177            }
178            Ok(literal) => quoted.push(literal),
179            Err(unpaired) => {
180                quoted.push_str(&format!("\\u{:04x}", unpaired.unpaired_surrogate()));
181            }
182        }
183    }
184    quoted.push('"');
185    return quoted;
186}
187
188/// Release checks, exact increments and quoting.
189#[cfg(test)]
190#[path = "dependent_version_release_tests.rs"]
191mod tests;