intentional_core/adapters/
format.rs1use crate::error::{Error, Result};
9use std::collections::BTreeMap;
10use std::ops::Range;
11use std::path::Path;
12use toml_edit::{value, DocumentMut, Item};
13
14pub trait FormatAdapter {
16 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 fn edit_text(&self, text: &str, pointer: &str, value: &str) -> Result<String>;
24
25 fn read_text(&self, text: &str, pointer: &str) -> Result<String>;
27
28 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#[derive(Debug, Clone, Copy, Default)]
41pub struct JsonFormat;
42
43#[derive(Debug, Clone, Copy, Default)]
45pub struct TomlFormat;
46
47#[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 ¤t.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
307fn 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}