Skip to main content

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;