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) => 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}