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;