git_policy_cli/dependent_version_text.rs
1//! What: Rewrite the top-level `version` value in a manifest's text, leaving every other
2//! byte unchanged.
3//! Why: The bump is delivered as a full-content patch, so indentation, key order, line
4//! endings and the trailing newline must survive exactly. This is the incumbent's
5//! scanner (`manifest-text.ts:184-333`) over bytes: it tracks `{`/`[` depth outside
6//! strings, finds the first `"version"` key at depth one, and requires its raw value to
7//! be exactly the JSON quoting of the expected version.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! // replaceManifestVersion({ path, text, from, to });
12//! ```
13
14/// What: `use` brings names from sibling files into this file.
15/// Why: Failures are shape problems with the incumbent's messages; versions are quoted as
16/// `JSON.stringify` quotes them.
17///
18/// In TS you'd write (pseudocode):
19/// ```ts
20/// import { shape } from './dependent_version_manifest.ts';
21/// ```
22use super::dependent_version_content::PolicyIncomplete;
23/// The incumbent's shape-problem message for a manifest.
24use super::dependent_version_manifest::shape;
25/// `JSON.stringify` quoting of the expected and new versions.
26use super::dependent_version_release::json_quote_units;
27
28/// What: The raw spelling of the key this module looks for, with its quotes.
29/// Why: An escaped spelling such as `"version"` is a different key to the scanner,
30/// as it is to the incumbent.
31///
32/// In TS you'd write (pseudocode):
33/// ```ts
34/// const VERSION_KEY = '"version"';
35/// ```
36const VERSION_KEY: &[u8] = b"\"version\"";
37
38/// What: The closing quote of the JSON string literal whose opening quote is at `start`.
39/// `.enumerate().skip(n)` visits `(index, byte)` pairs from index `n` on.
40/// Why: A backslash hides the byte after it, whatever that byte is.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// function stringLiteralEnd({ text, start }): number | undefined;
45/// ```
46fn literal_end(bytes: &[u8], start: usize) -> Option<usize> {
47 let mut escaped: bool = false;
48 for (index, byte) in bytes.iter().enumerate().skip(start + 1) {
49 if escaped {
50 escaped = false;
51 } else if *byte == b'\\' {
52 escaped = true;
53 } else if *byte == b'"' {
54 return Some(index);
55 }
56 }
57 return None;
58}
59
60/// What: The index of the first byte at or after `start` that is not JSON whitespace, or
61/// the length. `.position(...)` returns the offset of the first match.
62/// Why: JSON allows space, tab, carriage return and line feed around `:`.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// function skipWhitespace({ text, start }): number;
67/// ```
68fn skip_whitespace(bytes: &[u8], start: usize) -> usize {
69 return bytes
70 .iter()
71 .skip(start)
72 .position(|byte: &u8| return !matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
73 .map_or(bytes.len(), |offset: usize| return start + offset);
74}
75
76/// What: The incumbent's failure for a literal that never closes; it names no manifest.
77/// Why: Unreachable for text that parsed as JSON, but reachable for arbitrary input.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// throw new ManifestShapeError('manifest ends inside a string literal');
82/// ```
83fn unterminated(path: &[u8]) -> PolicyIncomplete {
84 return PolicyIncomplete::ManifestShape {
85 path: path.to_vec(),
86 problem: String::from("manifest ends inside a string literal"),
87 };
88}
89
90/// What: Replace the value of the first top-level `"version"` key, which must be exactly
91/// `from` quoted, with `to` quoted. `&str` borrows text; the result is owned.
92/// Why: Only the version changes; a manifest whose first top-level `version` holds
93/// something else (an escaped spelling, a different value, a duplicate key whose
94/// first value differs) is refused rather than guessed at.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// function replaceManifestVersion({ path, text, from, to }): string;
99/// ```
100pub fn replace_manifest_version(
101 path: &[u8],
102 text: &str,
103 from: &str,
104 to: &str,
105) -> Result<String, PolicyIncomplete> {
106 let bytes: &[u8] = text.as_bytes();
107 let mut depth: i64 = 0;
108 // Bytes before `resume` belong to a string literal already read.
109 let mut resume: usize = 0;
110 for (index, byte) in bytes.iter().enumerate() {
111 if index < resume {
112 continue;
113 }
114 match byte {
115 b'{' | b'[' => depth += 1,
116 b'}' | b']' => depth -= 1,
117 b'"' => {
118 let end: usize =
119 literal_end(bytes, index).ok_or_else(|| return unterminated(path))?;
120 let after: usize = skip_whitespace(bytes, end + 1);
121 if depth == 1
122 && &bytes[index..=end] == VERSION_KEY
123 && bytes.get(after) == Some(&b':')
124 {
125 return replace_value(path, text, after + 1, from, to);
126 }
127 resume = end + 1;
128 }
129 _ => {}
130 }
131 }
132 return Err(shape(path, "has no top-level \"version\""));
133}
134
135/// What: Replace the string value that starts after optional whitespace at `start`.
136/// Why: The value must be a string literal spelled exactly as `JSON.stringify(from)`.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// return `${text.slice(0, valueStart)}${JSON.stringify(to)}${text.slice(valueEnd + 1)}`;
141/// ```
142fn replace_value(
143 path: &[u8],
144 text: &str,
145 start: usize,
146 from: &str,
147 to: &str,
148) -> Result<String, PolicyIncomplete> {
149 let bytes: &[u8] = text.as_bytes();
150 let value_start: usize = skip_whitespace(bytes, start);
151 if bytes.get(value_start) != Some(&b'"') {
152 return Err(shape(path, "has a non-string top-level \"version\""));
153 }
154 let value_end: usize =
155 literal_end(bytes, value_start).ok_or_else(|| return unterminated(path))?;
156 // Both ends are ASCII quotes, so these byte offsets are character boundaries.
157 let literal: &str = &text[value_start..=value_end];
158 let expected: String = json_quote_units(&from.encode_utf16().collect::<Vec<u16>>());
159 if literal != expected {
160 return Err(shape(
161 path,
162 &format!("declares version {literal}, expected {expected}"),
163 ));
164 }
165 let replacement: String = json_quote_units(&to.encode_utf16().collect::<Vec<u16>>());
166 return Ok(format!(
167 "{}{replacement}{}",
168 &text[..value_start],
169 &text[value_end + 1..]
170 ));
171}
172
173/// The version rewrite, including every case of the incumbent's unit tests.
174#[cfg(test)]
175#[path = "dependent_version_text_tests.rs"]
176mod tests;