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) => segment
155                    .parse::<usize>()
156                    .ok()
157                    .and_then(|index| values.get(index)),
158                JsonKind::Scalar => None,
159            }
160            .ok_or_else(|| {
161                Error::Validation(format!("JSON pointer segment {segment:?} was not found"))
162            })?;
163        }
164        Ok(current)
165    }
166}
167
168struct JsonParser<'a> {
169    text: &'a str,
170    cursor: usize,
171}
172
173impl<'a> JsonParser<'a> {
174    fn new(text: &'a str) -> Self {
175        Self { text, cursor: 0 }
176    }
177
178    fn parse(mut self) -> Result<JsonNode> {
179        let node = self.value()?;
180        self.whitespace();
181        if self.cursor != self.text.len() {
182            return Err(Error::Validation("trailing content in JSON".to_owned()));
183        }
184        Ok(node)
185    }
186
187    fn value(&mut self) -> Result<JsonNode> {
188        self.whitespace();
189        let start = self.cursor;
190        let byte = self.peek().ok_or_else(|| {
191            Error::Validation("unexpected end of JSON while reading value".to_owned())
192        })?;
193        let kind = match byte {
194            b'{' => self.object()?,
195            b'[' => self.array()?,
196            b'"' => {
197                self.string()?;
198                JsonKind::Scalar
199            }
200            _ => {
201                while let Some(byte) = self.peek() {
202                    if byte.is_ascii_whitespace() || b",]}".contains(&byte) {
203                        break;
204                    }
205                    self.cursor += 1;
206                }
207                if self.cursor == start {
208                    return Err(Error::Validation("invalid JSON scalar".to_owned()));
209                }
210                JsonKind::Scalar
211            }
212        };
213        Ok(JsonNode {
214            range: start..self.cursor,
215            kind,
216        })
217    }
218
219    fn object(&mut self) -> Result<JsonKind> {
220        self.expect(b'{')?;
221        self.whitespace();
222        let mut values = BTreeMap::new();
223        if self.take(b'}') {
224            return Ok(JsonKind::Object(values));
225        }
226        loop {
227            self.whitespace();
228            let key_range = self.string()?;
229            let key: String = serde_json::from_str(&self.text[key_range])
230                .map_err(|error| Error::Validation(format!("invalid JSON object key: {error}")))?;
231            self.whitespace();
232            self.expect(b':')?;
233            let value = self.value()?;
234            values.insert(key, value);
235            self.whitespace();
236            if self.take(b'}') {
237                break;
238            }
239            self.expect(b',')?;
240        }
241        Ok(JsonKind::Object(values))
242    }
243
244    fn array(&mut self) -> Result<JsonKind> {
245        self.expect(b'[')?;
246        self.whitespace();
247        let mut values = Vec::new();
248        if self.take(b']') {
249            return Ok(JsonKind::Array(values));
250        }
251        loop {
252            values.push(self.value()?);
253            self.whitespace();
254            if self.take(b']') {
255                break;
256            }
257            self.expect(b',')?;
258        }
259        Ok(JsonKind::Array(values))
260    }
261
262    fn string(&mut self) -> Result<Range<usize>> {
263        let start = self.cursor;
264        self.expect(b'"')?;
265        let mut escaped = false;
266        while let Some(byte) = self.peek() {
267            self.cursor += 1;
268            if escaped {
269                escaped = false;
270            } else if byte == b'\\' {
271                escaped = true;
272            } else if byte == b'"' {
273                return Ok(start..self.cursor);
274            }
275        }
276        Err(Error::Validation("unterminated JSON string".to_owned()))
277    }
278
279    fn whitespace(&mut self) {
280        while self.peek().is_some_and(|byte| byte.is_ascii_whitespace()) {
281            self.cursor += 1;
282        }
283    }
284
285    fn peek(&self) -> Option<u8> {
286        self.text.as_bytes().get(self.cursor).copied()
287    }
288
289    fn take(&mut self, byte: u8) -> bool {
290        if self.peek() == Some(byte) {
291            self.cursor += 1;
292            true
293        } else {
294            false
295        }
296    }
297
298    fn expect(&mut self, byte: u8) -> Result<()> {
299        if self.take(byte) {
300            Ok(())
301        } else {
302            Err(Error::Validation(format!(
303                "expected JSON byte {:?} at offset {}",
304                byte as char, self.cursor
305            )))
306        }
307    }
308}
309
310fn json_pointer(pointer: &str) -> Result<Vec<String>> {
311    if pointer.is_empty() {
312        return Ok(Vec::new());
313    }
314    let rest = pointer
315        .strip_prefix('/')
316        .ok_or_else(|| Error::Validation(format!("JSON pointer must start with '/': {pointer}")))?;
317    Ok(rest
318        .split('/')
319        .map(|part| part.replace("~1", "/").replace("~0", "~"))
320        .collect())
321}
322
323fn key_pointer(pointer: &str) -> Result<Vec<String>> {
324    let parts = if let Some(rest) = pointer.strip_prefix('/') {
325        rest.split('/').collect::<Vec<_>>()
326    } else {
327        pointer.split('.').collect::<Vec<_>>()
328    };
329    if parts.is_empty() || parts.iter().any(|part| part.is_empty()) {
330        return Err(Error::Validation(format!("invalid key pointer {pointer}")));
331    }
332    Ok(parts.into_iter().map(str::to_owned).collect())
333}
334
335fn toml_item_mut<'a>(
336    mut item: &'a mut Item,
337    keys: &[String],
338    pointer: &str,
339) -> Result<&'a mut Item> {
340    for key in keys {
341        item = item
342            .get_mut(key)
343            .ok_or_else(|| Error::Validation(format!("TOML pointer {pointer} was not found")))?;
344    }
345    Ok(item)
346}
347
348fn toml_item<'a>(mut item: &'a Item, keys: &[String], pointer: &str) -> Result<&'a Item> {
349    for key in keys {
350        item = item
351            .get(key)
352            .ok_or_else(|| Error::Validation(format!("TOML pointer {pointer} was not found")))?;
353    }
354    Ok(item)
355}
356
357fn find_yaml_line(text: &str, keys: &[String], pointer: &str) -> Result<Range<usize>> {
358    let mut stack: Vec<(usize, String)> = Vec::new();
359    let mut offset = 0;
360    for line_with_newline in text.split_inclusive('\n') {
361        let line = line_with_newline
362            .strip_suffix('\n')
363            .unwrap_or(line_with_newline);
364        let trimmed = line.trim_start();
365        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
366            offset += line_with_newline.len();
367            continue;
368        }
369        let indent = line.len() - trimmed.len();
370        let Some(colon) = trimmed.find(':') else {
371            offset += line_with_newline.len();
372            continue;
373        };
374        let key = trimmed[..colon].trim().trim_matches(['\'', '"']).to_owned();
375        while stack.last().is_some_and(|(prior, _)| *prior >= indent) {
376            stack.pop();
377        }
378        let mut path = stack
379            .iter()
380            .map(|(_, key)| key.as_str())
381            .collect::<Vec<_>>();
382        path.push(&key);
383        if path == keys.iter().map(String::as_str).collect::<Vec<_>>() {
384            return Ok(offset..offset + line.len());
385        }
386        if trimmed[colon + 1..].trim().is_empty() {
387            stack.push((indent, key));
388        }
389        offset += line_with_newline.len();
390    }
391    Err(Error::Validation(format!(
392        "YAML pointer {pointer} was not found"
393    )))
394}
395
396fn yaml_value_end(line: &str, start: usize) -> usize {
397    let bytes = line.as_bytes();
398    let quote = bytes
399        .get(start)
400        .copied()
401        .filter(|byte| matches!(byte, b'\'' | b'"'));
402    let mut index = start;
403    let mut escaped = false;
404    while index < bytes.len() {
405        let byte = bytes[index];
406        if let Some(quote) = quote {
407            if index > start && byte == quote && (!escaped || quote == b'\'') {
408                index += 1;
409                break;
410            }
411            escaped = byte == b'\\' && !escaped;
412            if byte != b'\\' {
413                escaped = false;
414            }
415        } else if byte == b'#' && (index == start || bytes[index - 1].is_ascii_whitespace()) {
416            break;
417        }
418        index += 1;
419    }
420    while index > start && bytes[index - 1].is_ascii_whitespace() {
421        index -= 1;
422    }
423    index
424}
425
426fn quote_like_yaml(old: &str, value: &str) -> String {
427    if old.starts_with('\'') && old.ends_with('\'') {
428        format!("'{}'", value.replace('\'', "''"))
429    } else if old.starts_with('"') && old.ends_with('"') {
430        serde_json::to_string(value).expect("string serialization cannot fail")
431    } else {
432        value.to_owned()
433    }
434}
435
436fn unquote_yaml(raw: &str) -> &str {
437    if raw.len() >= 2
438        && ((raw.starts_with('\'') && raw.ends_with('\''))
439            || (raw.starts_with('"') && raw.ends_with('"')))
440    {
441        &raw[1..raw.len() - 1]
442    } else {
443        raw
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn json_targeted_edit_preserves_odd_indentation() {
453        let input = include_str!("../../tests/fixtures/formats/odd.json");
454        let output = JsonFormat
455            .edit_text(input, "/metadata/version", "2.0.0")
456            .expect("JSON edit");
457        assert_eq!(output, input.replacen("\"1.2.3\"", "\"2.0.0\"", 1));
458        assert_eq!(
459            JsonFormat
460                .read_text(&output, "/metadata/version")
461                .expect("JSON read"),
462            "2.0.0"
463        );
464    }
465
466    #[test]
467    fn toml_edit_preserves_comments_and_spacing() {
468        let input = include_str!("../../tests/fixtures/formats/comments.toml");
469        let output = TomlFormat
470            .edit_text(input, "/package/version", "2.0.0")
471            .expect("TOML edit");
472        assert!(output.contains("# Package comment"));
473        assert!(output.contains("version = \"2.0.0\" # keep this comment"));
474        assert!(output.contains("version = \"9.9.9\""));
475        assert_eq!(
476            TomlFormat
477                .read_text(&output, "package.version")
478                .expect("TOML read"),
479            "2.0.0"
480        );
481    }
482
483    #[test]
484    fn yaml_edit_preserves_comments_quotes_and_other_keys() {
485        let input = include_str!("../../tests/fixtures/formats/comments.yaml");
486        let output = YamlFormat
487            .edit_text(input, "/package/version", "2.0.0")
488            .expect("YAML edit");
489        assert!(output.contains("version: '2.0.0' # keep this comment"));
490        assert!(output.contains("version: \"9.9.9\""));
491        assert_eq!(
492            YamlFormat
493                .read_text(&output, "/package/version")
494                .expect("YAML read"),
495            "2.0.0"
496        );
497    }
498}