use crate::cst::Document;
use crate::error::{Error, Result};
use crate::prelude::*;
use crate::value::Value;
#[derive(Debug)]
pub struct Entry<'a> {
doc: &'a mut Document,
path: String,
}
impl<'a> Entry<'a> {
pub(crate) fn new(doc: &'a mut Document, path: String) -> Self {
Self { doc, path }
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
#[must_use]
pub fn exists(&self) -> bool {
self.doc.span_at(&self.path).is_some()
}
#[must_use]
pub fn get(&self) -> Option<&str> {
self.doc.get(&self.path)
}
#[must_use]
pub fn span_at(&self) -> Option<(usize, usize)> {
self.doc.span_at(&self.path)
}
#[must_use]
pub fn comments(&self) -> crate::cst::CommentBundle {
self.doc.comments_at(&self.path)
}
pub fn set(self, fragment: &str) -> Result<()> {
self.doc.set(&self.path, fragment)
}
pub fn set_value(self, value: &Value) -> Result<()> {
self.doc.set_value(&self.path, value)
}
pub fn remove(self) -> Result<()> {
self.doc.remove(&self.path)
}
pub fn insert(self, key: &str, fragment: &str) -> Result<()> {
self.doc.insert_entry(&self.path, key, fragment)
}
pub fn insert_value(self, key: &str, value: &Value) -> Result<()> {
let unit = self.doc.indent_unit();
let quote = self.doc.dominant_quote_style();
let scalar_override = match (value, quote) {
(Value::String(s), crate::ScalarStyle::SingleQuoted) => {
Some(format!("'{}'", s.replace('\'', "''")))
}
(Value::String(s), crate::ScalarStyle::DoubleQuoted) => {
Some(format!("\"{}\"", escape_for_double_quoted(s)))
}
_ => None,
};
let trimmed_owned = match scalar_override {
Some(s) => s,
None => {
let cfg = crate::SerializerConfig::new().indent(unit);
let emitted = crate::to_string_with_config(value, &cfg)?;
emitted.trim_end_matches('\n').to_owned()
}
};
let force_block = matches!(value, Value::Mapping(_) | Value::Sequence(_))
&& !trimmed_owned.contains('\n');
let fragment = if force_block {
format!("\n{trimmed_owned}")
} else {
trimmed_owned
};
self.doc.insert_entry(&self.path, key, &fragment)
}
pub fn push_back(self, fragment: &str) -> Result<()> {
self.doc.push_back(&self.path, fragment)
}
pub fn insert_after(self, fragment: &str) -> Result<()> {
self.doc.insert_after(&self.path, fragment)
}
#[must_use]
pub fn entry(self, child: &str) -> Entry<'a> {
let combined = compose_path(&self.path, child);
Entry::new(self.doc, combined)
}
pub fn or_insert(self, default: &str) -> Result<bool> {
if self.exists() {
return Ok(false);
}
self.insert_at_path(default)?;
Ok(true)
}
pub fn or_insert_with<F>(self, default: F) -> Result<bool>
where
F: FnOnce() -> String,
{
if self.exists() {
return Ok(false);
}
let frag = default();
self.insert_at_path(&frag)?;
Ok(true)
}
pub fn or_insert_value(self, default: &Value) -> Result<bool> {
if self.exists() {
return Ok(false);
}
let unit = self.doc.indent_unit();
let cfg = crate::SerializerConfig::new().indent(unit);
let emitted = crate::to_string_with_config(default, &cfg)?;
let trimmed = emitted.trim_end_matches('\n');
let force_block =
matches!(default, Value::Mapping(_) | Value::Sequence(_)) && !trimmed.contains('\n');
let fragment = if force_block {
format!("\n{trimmed}")
} else {
trimmed.to_owned()
};
self.insert_at_path(&fragment)?;
Ok(true)
}
pub fn and_modify<F>(self, f: F) -> Self
where
F: FnOnce(&mut Document),
{
if self.doc.span_at(&self.path).is_some() {
f(self.doc);
}
self
}
fn insert_at_path(self, fragment: &str) -> Result<()> {
let path = self.path;
if path.contains('[') {
return Err(Error::Parse(format!(
"or_insert: cannot insert at sequence index `{path}`; \
use Entry::push_back or Entry::insert_after instead"
)));
}
match path.rfind('.') {
Some(idx) => {
let (parent, key_with_dot) = path.split_at(idx);
let key = &key_with_dot[1..];
self.doc.insert_entry(parent, key, fragment)
}
None => Err(Error::Parse(format!(
"or_insert: cannot insert at top-level key `{path}` \
on an existing document — use Document::set on a \
non-existent path or insert through a parent mapping"
))),
}
}
}
impl Document {
pub fn entry<'a>(&'a mut self, path: &str) -> Entry<'a> {
Entry::new(self, path.to_owned())
}
}
fn compose_path(parent: &str, child: &str) -> String {
if parent.is_empty() {
return child.to_owned();
}
if child.starts_with('[') {
return format!("{parent}{child}");
}
format!("{parent}.{child}")
}
fn escape_for_double_quoted(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other => out.push(other),
}
}
out
}
impl Error {
#[doc(hidden)]
#[must_use]
pub fn entry_not_found(path: &str) -> Self {
Error::Parse(format!("entry not found at path: {path}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cst::parse_document;
#[test]
fn entry_set_replaces_value_losslessly() {
let mut doc = parse_document("# top comment\nport: 8080 # inline comment\n").unwrap();
doc.entry("port").set("9090").unwrap();
let out = doc.to_string();
assert!(out.contains("port: 9090"));
assert!(out.contains("# top comment"));
assert!(out.contains("# inline comment"));
}
#[test]
fn entry_chains_through_dotted_path() {
let mut doc = parse_document("server:\n host: localhost\n port: 8080\n").unwrap();
doc.entry("server").entry("port").set("9090").unwrap();
assert!(doc.to_string().contains("port: 9090"));
}
#[test]
fn entry_insert_into_mapping() {
let mut doc = parse_document("metadata:\n labels:\n app: noyalib\n").unwrap();
doc.entry("metadata.labels").insert("env", "prod").unwrap();
let out = doc.to_string();
assert!(out.contains("app: noyalib"));
assert!(out.contains("env: prod"));
}
#[test]
fn entry_insert_value_typed() {
let mut doc = parse_document("metadata:\n labels:\n app: noyalib\n").unwrap();
let v = Value::Number(crate::Number::Integer(3));
doc.entry("metadata.labels")
.insert_value("replicas", &v)
.unwrap();
let out = doc.to_string();
assert!(out.contains("replicas: 3"));
}
#[test]
fn entry_remove() {
let mut doc = parse_document("a: 1\nb: 2\nc: 3\n").unwrap();
doc.entry("b").remove().unwrap();
let out = doc.to_string();
assert!(out.contains("a: 1"));
assert!(!out.contains("b:"));
assert!(out.contains("c: 3"));
}
#[test]
fn entry_push_back_to_sequence() {
let mut doc = parse_document("items:\n - one\n - two\n").unwrap();
doc.entry("items").push_back("three").unwrap();
let out = doc.to_string();
assert!(out.contains("- one"));
assert!(out.contains("- two"));
assert!(out.contains("- three"));
}
#[test]
fn entry_insert_after_in_sequence() {
let mut doc = parse_document("items:\n - one\n - three\n").unwrap();
doc.entry("items[0]").insert_after("two").unwrap();
let out = doc.to_string();
let one_pos = out.find("one").unwrap();
let two_pos = out.find("two").unwrap();
let three_pos = out.find("three").unwrap();
assert!(one_pos < two_pos);
assert!(two_pos < three_pos);
}
#[test]
fn entry_get_reads_source_slice() {
let doc = parse_document("port: 8080\n").unwrap();
let mut doc = doc;
let e = doc.entry("port");
assert_eq!(e.get(), Some("8080"));
}
#[test]
fn entry_exists_distinguishes_present_and_absent() {
let mut doc = parse_document("a: 1\n").unwrap();
assert!(doc.entry("a").exists());
assert!(!doc.entry("b").exists());
}
#[test]
fn entry_path_returns_recorded_string() {
let mut doc = parse_document("a:\n b: 1\n").unwrap();
let e = doc.entry("a").entry("b");
assert_eq!(e.path(), "a.b");
}
#[test]
fn entry_with_index_uses_no_dot_separator() {
let mut doc = parse_document("items:\n - one\n - two\n").unwrap();
let e = doc.entry("items").entry("[0]");
assert_eq!(e.path(), "items[0]");
}
#[test]
fn entry_comments_forwards_to_document() {
let mut doc = parse_document("# decorator\nport: 8080 # inline\n").unwrap();
let bundle = doc.entry("port").comments();
assert_eq!(bundle.before.len(), 1);
assert_eq!(bundle.before[0].text, " decorator");
assert_eq!(bundle.inline.as_ref().unwrap().text, " inline");
}
#[test]
fn entry_set_on_nonexistent_path_errors() {
let mut doc = parse_document("a: 1\n").unwrap();
let err = doc.entry("nonexistent").set("2").unwrap_err();
assert!(err.to_string().contains("path not found"));
}
#[test]
fn entry_repeated_edits_compose() {
let mut doc = parse_document("name: noyalib\nversion: 0.0.1\n").unwrap();
doc.entry("name").set("renamed").unwrap();
doc.entry("version").set("0.0.2").unwrap();
let out = doc.to_string();
assert!(out.contains("name: renamed"));
assert!(out.contains("version: 0.0.2"));
}
}