Skip to main content

intentional_core/adapters/
format.rs

1// ---
2// relationships:
3//   implements: intent-driven-polyglot-release
4// ---
5
6//! Format adapters that replace only a configured version value.
7
8use crate::error::{Error, Result};
9use std::collections::BTreeMap;
10use std::ops::Range;
11use std::path::Path;
12use toml_edit::{value, DocumentMut, Item};
13
14/// A format-preserving version-value adapter.
15pub trait FormatAdapter {
16    /// Read a string value selected by `pointer`.
17    fn read(&self, file: &Path, pointer: &str) -> Result<String> {
18        let text = std::fs::read_to_string(file).map_err(|error| Error::io(file, error))?;
19        self.read_text(&text, pointer)
20    }
21
22    /// Produce updated text without touching the filesystem.
23    fn edit_text(&self, text: &str, pointer: &str, value: &str) -> Result<String>;
24
25    /// Read a selected string from in-memory text.
26    fn read_text(&self, text: &str, pointer: &str) -> Result<String>;
27
28    /// Write a selected value unless `dry_run` is enabled.
29    fn write(&self, file: &Path, pointer: &str, new_value: &str, dry_run: bool) -> Result<()> {
30        let text = std::fs::read_to_string(file).map_err(|error| Error::io(file, error))?;
31        let edited = self.edit_text(&text, pointer, new_value)?;
32        if !dry_run && edited != text {
33            std::fs::write(file, edited).map_err(|error| Error::io(file, error))?;
34        }
35        Ok(())
36    }
37}
38
39/// Targeted JSON string adapter.
40#[derive(Debug, Clone, Copy, Default)]
41pub struct JsonFormat;
42
43/// Comment-preserving TOML adapter backed by `toml_edit`.
44#[derive(Debug, Clone, Copy, Default)]
45pub struct TomlFormat;
46
47/// Targeted YAML scalar-line adapter.
48#[derive(Debug, Clone, Copy, Default)]
49pub struct YamlFormat;
50
51impl FormatAdapter for JsonFormat {
52    fn edit_text(&self, text: &str, pointer: &str, new_value: &str) -> Result<String> {
53        let node = JsonParser::new(text).parse()?;
54        let target = node.find(&json_pointer(pointer)?)?;
55        let mut edited = text.to_owned();
56        let replacement = serde_json::to_string(new_value)
57            .map_err(|error| Error::Validation(format!("JSON value is invalid: {error}")))?;
58        edited.replace_range(target.range.clone(), &replacement);
59        Ok(edited)
60    }
61
62    fn read_text(&self, text: &str, pointer: &str) -> Result<String> {
63        let node = JsonParser::new(text).parse()?;
64        let target = node.find(&json_pointer(pointer)?)?;
65        serde_json::from_str(&text[target.range.clone()]).map_err(|error| {
66            Error::Validation(format!(
67                "JSON pointer {pointer} does not select a string: {error}"
68            ))
69        })
70    }
71}
72
73impl FormatAdapter for TomlFormat {
74    fn edit_text(&self, text: &str, pointer: &str, new_value: &str) -> Result<String> {
75        let mut document = text
76            .parse::<DocumentMut>()
77            .map_err(|error| Error::Validation(format!("invalid TOML: {error}")))?;
78        let item = toml_item_mut(document.as_item_mut(), &key_pointer(pointer)?, pointer)?;
79        let decor = item.as_value().map(|old| old.decor().clone());
80        *item = value(new_value);
81        if let (Some(decor), Some(new)) = (decor, item.as_value_mut()) {
82            *new.decor_mut() = decor;
83        }
84        Ok(document.to_string())
85    }
86
87    fn read_text(&self, text: &str, pointer: &str) -> Result<String> {
88        let document = text
89            .parse::<DocumentMut>()
90            .map_err(|error| Error::Validation(format!("invalid TOML: {error}")))?;
91        let item = toml_item(document.as_item(), &key_pointer(pointer)?, pointer)?;
92        item.as_str()
93            .map(str::to_owned)
94            .ok_or_else(|| Error::Validation(format!("TOML pointer {pointer} is not a string")))
95    }
96}
97
98impl FormatAdapter for YamlFormat {
99    fn edit_text(&self, text: &str, pointer: &str, new_value: &str) -> Result<String> {
100        let keys = key_pointer(pointer)?;
101        let line = find_yaml_line(text, &keys, pointer)?;
102        let source = &text[line.clone()];
103        let colon = source
104            .find(':')
105            .ok_or_else(|| Error::Validation(format!("invalid YAML line for {pointer}")))?;
106        let after_colon = &source[colon + 1..];
107        let value_offset = after_colon.len() - after_colon.trim_start().len();
108        let value_start = colon + 1 + value_offset;
109        let value_end = yaml_value_end(source, value_start);
110        let old = source[value_start..value_end].trim_end();
111        let replacement = quote_like_yaml(old, new_value);
112        let mut new_line = String::new();
113        new_line.push_str(&source[..value_start]);
114        new_line.push_str(&replacement);
115        new_line.push_str(&source[value_end..]);
116        let mut edited = text.to_owned();
117        edited.replace_range(line, &new_line);
118        Ok(edited)
119    }
120
121    fn read_text(&self, text: &str, pointer: &str) -> Result<String> {
122        let keys = key_pointer(pointer)?;
123        let line = find_yaml_line(text, &keys, pointer)?;
124        let source = &text[line];
125        let colon = source
126            .find(':')
127            .ok_or_else(|| Error::Validation(format!("invalid YAML line for {pointer}")))?;
128        let start = colon + 1 + source[colon + 1..].len() - source[colon + 1..].trim_start().len();
129        let end = yaml_value_end(source, start);
130        let raw = source[start..end].trim();
131        Ok(unquote_yaml(raw).to_owned())
132    }
133}
134
135#[derive(Debug)]
136struct JsonNode {
137    range: Range<usize>,
138    kind: JsonKind,
139}
140
141#[derive(Debug)]
142enum JsonKind {
143    Object(BTreeMap<String, JsonNode>),
144    Array(Vec<JsonNode>),
145    Scalar,
146}
147
148impl JsonNode {
149    fn find(&self, pointer: &[String]) -> Result<&Self> {
150        let mut current = self;
151        for segment in pointer {
152            current = match &current.kind {
153                JsonKind::Object(values) => values.get(segment),
154                JsonKind::Array(values) => array_index(segment).and_then(|index| values.get(index)),
155                JsonKind::Scalar => None,
156            }
157            .ok_or_else(|| {
158                Error::Validation(format!("JSON pointer segment {segment:?} was not found"))
159            })?;
160        }
161        Ok(current)
162    }
163}
164
165struct JsonParser<'a> {
166    text: &'a str,
167    cursor: usize,
168}
169
170impl<'a> JsonParser<'a> {
171    fn new(text: &'a str) -> Self {
172        Self { text, cursor: 0 }
173    }
174
175    fn parse(mut self) -> Result<JsonNode> {
176        let node = self.value()?;
177        self.whitespace();
178        if self.cursor != self.text.len() {
179            return Err(Error::Validation("trailing content in JSON".to_owned()));
180        }
181        Ok(node)
182    }
183
184    fn value(&mut self) -> Result<JsonNode> {
185        self.whitespace();
186        let start = self.cursor;
187        let byte = self.peek().ok_or_else(|| {
188            Error::Validation("unexpected end of JSON while reading value".to_owned())
189        })?;
190        let kind = match byte {
191            b'{' => self.object()?,
192            b'[' => self.array()?,
193            b'"' => {
194                self.string()?;
195                JsonKind::Scalar
196            }
197            _ => {
198                while let Some(byte) = self.peek() {
199                    if byte.is_ascii_whitespace() || b",]}".contains(&byte) {
200                        break;
201                    }
202                    self.cursor += 1;
203                }
204                if self.cursor == start {
205                    return Err(Error::Validation("invalid JSON scalar".to_owned()));
206                }
207                JsonKind::Scalar
208            }
209        };
210        Ok(JsonNode {
211            range: start..self.cursor,
212            kind,
213        })
214    }
215
216    fn object(&mut self) -> Result<JsonKind> {
217        self.expect(b'{')?;
218        self.whitespace();
219        let mut values = BTreeMap::new();
220        if self.take(b'}') {
221            return Ok(JsonKind::Object(values));
222        }
223        loop {
224            self.whitespace();
225            let key_range = self.string()?;
226            let key: String = serde_json::from_str(&self.text[key_range])
227                .map_err(|error| Error::Validation(format!("invalid JSON object key: {error}")))?;
228            self.whitespace();
229            self.expect(b':')?;
230            let value = self.value()?;
231            values.insert(key, value);
232            self.whitespace();
233            if self.take(b'}') {
234                break;
235            }
236            self.expect(b',')?;
237        }
238        Ok(JsonKind::Object(values))
239    }
240
241    fn array(&mut self) -> Result<JsonKind> {
242        self.expect(b'[')?;
243        self.whitespace();
244        let mut values = Vec::new();
245        if self.take(b']') {
246            return Ok(JsonKind::Array(values));
247        }
248        loop {
249            values.push(self.value()?);
250            self.whitespace();
251            if self.take(b']') {
252                break;
253            }
254            self.expect(b',')?;
255        }
256        Ok(JsonKind::Array(values))
257    }
258
259    fn string(&mut self) -> Result<Range<usize>> {
260        let start = self.cursor;
261        self.expect(b'"')?;
262        let mut escaped = false;
263        while let Some(byte) = self.peek() {
264            self.cursor += 1;
265            if escaped {
266                escaped = false;
267            } else if byte == b'\\' {
268                escaped = true;
269            } else if byte == b'"' {
270                return Ok(start..self.cursor);
271            }
272        }
273        Err(Error::Validation("unterminated JSON string".to_owned()))
274    }
275
276    fn whitespace(&mut self) {
277        while self.peek().is_some_and(|byte| byte.is_ascii_whitespace()) {
278            self.cursor += 1;
279        }
280    }
281
282    fn peek(&self) -> Option<u8> {
283        self.text.as_bytes().get(self.cursor).copied()
284    }
285
286    fn take(&mut self, byte: u8) -> bool {
287        if self.peek() == Some(byte) {
288            self.cursor += 1;
289            true
290        } else {
291            false
292        }
293    }
294
295    fn expect(&mut self, byte: u8) -> Result<()> {
296        if self.take(byte) {
297            Ok(())
298        } else {
299            Err(Error::Validation(format!(
300                "expected JSON byte {:?} at offset {}",
301                byte as char, self.cursor
302            )))
303        }
304    }
305}
306
307/// An RFC 6901 array index: `0`, or digits without a leading zero. Any
308/// other spelling selects no element, so one element has one pointer.
309fn array_index(segment: &str) -> Option<usize> {
310    let canonical = segment == "0"
311        || (segment.bytes().all(|byte| byte.is_ascii_digit()) && !segment.starts_with('0'));
312    if canonical {
313        segment.parse().ok()
314    } else {
315        None
316    }
317}
318
319pub(crate) fn json_pointer(pointer: &str) -> Result<Vec<String>> {
320    if pointer.is_empty() {
321        return Ok(Vec::new());
322    }
323    let rest = pointer
324        .strip_prefix('/')
325        .ok_or_else(|| Error::Validation(format!("JSON pointer must start with '/': {pointer}")))?;
326    Ok(rest
327        .split('/')
328        .map(|part| part.replace("~1", "/").replace("~0", "~"))
329        .collect())
330}
331
332pub(crate) fn key_pointer(pointer: &str) -> Result<Vec<String>> {
333    let parts = if let Some(rest) = pointer.strip_prefix('/') {
334        rest.split('/').collect::<Vec<_>>()
335    } else {
336        pointer.split('.').collect::<Vec<_>>()
337    };
338    if parts.is_empty() || parts.iter().any(|part| part.is_empty()) {
339        return Err(Error::Validation(format!("invalid key pointer {pointer}")));
340    }
341    Ok(parts.into_iter().map(str::to_owned).collect())
342}
343
344fn toml_item_mut<'a>(
345    mut item: &'a mut Item,
346    keys: &[String],
347    pointer: &str,
348) -> Result<&'a mut Item> {
349    for key in keys {
350        item = item
351            .get_mut(key)
352            .ok_or_else(|| Error::Validation(format!("TOML pointer {pointer} was not found")))?;
353    }
354    Ok(item)
355}
356
357fn toml_item<'a>(mut item: &'a Item, keys: &[String], pointer: &str) -> Result<&'a Item> {
358    for key in keys {
359        item = item
360            .get(key)
361            .ok_or_else(|| Error::Validation(format!("TOML pointer {pointer} was not found")))?;
362    }
363    Ok(item)
364}
365
366fn find_yaml_line(text: &str, keys: &[String], pointer: &str) -> Result<Range<usize>> {
367    let mut stack: Vec<(usize, String)> = Vec::new();
368    let mut offset = 0;
369    for line_with_newline in text.split_inclusive('\n') {
370        let line = line_with_newline
371            .strip_suffix('\n')
372            .unwrap_or(line_with_newline);
373        let trimmed = line.trim_start();
374        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
375            offset += line_with_newline.len();
376            continue;
377        }
378        let indent = line.len() - trimmed.len();
379        let Some(colon) = trimmed.find(':') else {
380            offset += line_with_newline.len();
381            continue;
382        };
383        let key = trimmed[..colon].trim().trim_matches(['\'', '"']).to_owned();
384        while stack.last().is_some_and(|(prior, _)| *prior >= indent) {
385            stack.pop();
386        }
387        let mut path = stack
388            .iter()
389            .map(|(_, key)| key.as_str())
390            .collect::<Vec<_>>();
391        path.push(&key);
392        if path == keys.iter().map(String::as_str).collect::<Vec<_>>() {
393            return Ok(offset..offset + line.len());
394        }
395        if trimmed[colon + 1..].trim().is_empty() {
396            stack.push((indent, key));
397        }
398        offset += line_with_newline.len();
399    }
400    Err(Error::Validation(format!(
401        "YAML pointer {pointer} was not found"
402    )))
403}
404
405fn yaml_value_end(line: &str, start: usize) -> usize {
406    let bytes = line.as_bytes();
407    let quote = bytes
408        .get(start)
409        .copied()
410        .filter(|byte| matches!(byte, b'\'' | b'"'));
411    let mut index = start;
412    let mut escaped = false;
413    while index < bytes.len() {
414        let byte = bytes[index];
415        if let Some(quote) = quote {
416            if index > start && byte == quote && (!escaped || quote == b'\'') {
417                index += 1;
418                break;
419            }
420            escaped = byte == b'\\' && !escaped;
421            if byte != b'\\' {
422                escaped = false;
423            }
424        } else if byte == b'#' && (index == start || bytes[index - 1].is_ascii_whitespace()) {
425            break;
426        }
427        index += 1;
428    }
429    while index > start && bytes[index - 1].is_ascii_whitespace() {
430        index -= 1;
431    }
432    index
433}
434
435fn quote_like_yaml(old: &str, value: &str) -> String {
436    if old.starts_with('\'') && old.ends_with('\'') {
437        format!("'{}'", value.replace('\'', "''"))
438    } else if old.starts_with('"') && old.ends_with('"') {
439        serde_json::to_string(value).expect("string serialization cannot fail")
440    } else {
441        value.to_owned()
442    }
443}
444
445fn unquote_yaml(raw: &str) -> &str {
446    if raw.len() >= 2
447        && ((raw.starts_with('\'') && raw.ends_with('\''))
448            || (raw.starts_with('"') && raw.ends_with('"')))
449    {
450        &raw[1..raw.len() - 1]
451    } else {
452        raw
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn json_array_index_has_one_spelling() {
462        let text = r#"{ "versions": ["0.1.0", "1.0.0"] }"#;
463        assert_eq!(
464            JsonFormat.read_text(text, "/versions/0").expect("index 0"),
465            "0.1.0"
466        );
467        assert_eq!(
468            JsonFormat.read_text(text, "/versions/1").expect("index 1"),
469            "1.0.0"
470        );
471        for pointer in [
472            "/versions/01",
473            "/versions/+1",
474            "/versions/00",
475            "/versions/-0",
476            "/versions/ 1",
477        ] {
478            assert!(
479                JsonFormat.read_text(text, pointer).is_err(),
480                "{pointer} selected an element"
481            );
482            assert!(
483                JsonFormat.edit_text(text, pointer, "2.0.0").is_err(),
484                "{pointer} edited an element"
485            );
486        }
487        let keyed = r#"{ "versions": { "01": "0.1.0", "1": "1.0.0" } }"#;
488        assert_eq!(
489            JsonFormat
490                .read_text(keyed, "/versions/01")
491                .expect("object key"),
492            "0.1.0"
493        );
494    }
495
496    #[test]
497    fn json_targeted_edit_preserves_odd_indentation() {
498        let input = include_str!("../../tests/fixtures/formats/odd.json");
499        let output = JsonFormat
500            .edit_text(input, "/metadata/version", "2.0.0")
501            .expect("JSON edit");
502        assert_eq!(output, input.replacen("\"1.2.3\"", "\"2.0.0\"", 1));
503        assert_eq!(
504            JsonFormat
505                .read_text(&output, "/metadata/version")
506                .expect("JSON read"),
507            "2.0.0"
508        );
509    }
510
511    #[test]
512    fn toml_edit_preserves_comments_and_spacing() {
513        let input = include_str!("../../tests/fixtures/formats/comments.toml");
514        let output = TomlFormat
515            .edit_text(input, "/package/version", "2.0.0")
516            .expect("TOML edit");
517        assert!(output.contains("# Package comment"));
518        assert!(output.contains("version = \"2.0.0\" # keep this comment"));
519        assert!(output.contains("version = \"9.9.9\""));
520        assert_eq!(
521            TomlFormat
522                .read_text(&output, "package.version")
523                .expect("TOML read"),
524            "2.0.0"
525        );
526    }
527
528    #[test]
529    fn yaml_edit_preserves_comments_quotes_and_other_keys() {
530        let input = include_str!("../../tests/fixtures/formats/comments.yaml");
531        let output = YamlFormat
532            .edit_text(input, "/package/version", "2.0.0")
533            .expect("YAML edit");
534        assert!(output.contains("version: '2.0.0' # keep this comment"));
535        assert!(output.contains("version: \"9.9.9\""));
536        assert_eq!(
537            YamlFormat
538                .read_text(&output, "/package/version")
539                .expect("YAML read"),
540            "2.0.0"
541        );
542    }
543}