use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use crate::document::{DocumentError, DocumentResult, Format, KeyedList, Value};
#[derive(Debug, Clone)]
pub struct Document {
value: Value,
format: Format,
}
impl Document {
pub fn parse(source: &str, format: Format) -> DocumentResult<Document> {
let value = format.load(source)?;
Ok(Document { value, format })
}
pub fn from_reader<R: std::io::Read>(
mut reader: R,
format: Format,
) -> DocumentResult<Document> {
let mut source = String::new();
reader.read_to_string(&mut source)?;
Document::parse(&source, format)
}
pub fn value(&self) -> &Value {
&self.value
}
pub fn value_mut(&mut self) -> &mut Value {
&mut self.value
}
pub fn format(&self) -> Format {
self.format
}
pub fn encode(&self) -> DocumentResult<String> {
self.format.save(&self.value)
}
}
#[derive(Debug)]
pub struct DocumentFile {
path: PathBuf,
format: Format,
source: String,
value: Value,
}
impl DocumentFile {
pub fn open(
path: impl AsRef<Path>,
format_override: Option<Format>,
) -> DocumentResult<DocumentFile> {
let path = path.as_ref().to_path_buf();
let format = match format_override {
Some(format) => format,
None => Format::detect(&path).ok_or_else(|| DocumentError::ParseError {
format: "format".to_string(),
detail: format!(
"cannot detect format from file extension `{}`; pass an explicit format",
path.display()
),
})?,
};
let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
detail: format!("read `{}`: {error}", path.display()),
})?;
let value = format.load(&source)?;
Ok(DocumentFile {
path,
format,
source,
value,
})
}
pub fn open_capped(
path: impl AsRef<Path>,
format_override: Option<Format>,
max_bytes: u64,
) -> DocumentResult<DocumentFile> {
let path = path.as_ref();
let metadata = fs::metadata(path).map_err(|error| DocumentError::IoError {
detail: format!("read `{}`: {error}", path.display()),
})?;
if !metadata.is_file() {
return Err(DocumentError::IoError {
detail: format!("`{}` is not a regular file", path.display()),
});
}
if metadata.len() > max_bytes {
return Err(DocumentError::IoError {
detail: format!(
"`{}` exceeds the {max_bytes}-byte read limit",
path.display()
),
});
}
DocumentFile::open(path, format_override)
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn value(&self) -> &Value {
&self.value
}
pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
crate::document::get_path(&self.value, path, &[])
}
pub fn format(&self) -> Format {
self.format
}
pub fn source(&self) -> &str {
&self.source
}
pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
guard_mutation(&self.path, operation)?;
Ok(())
}
pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
guard_mutation(&self.path, "set")?;
let mut new_doc = self.value.clone();
self.format.ensure_writable("set")?;
crate::document::set_path(&mut new_doc, key, &value, &[])?;
let target = crate::document::get_path(&new_doc, key, &[])?;
#[allow(unreachable_patterns)]
let output = match self.format {
#[cfg(feature = "toml")]
Format::Toml => {
crate::document::format::toml::set_scalar_preserving(&self.source, key, &target)?
}
#[cfg(feature = "yaml")]
Format::Yaml => {
crate::document::format::yaml::set_scalar_preserving(&self.source, key, &target)?
}
Format::Json => {
crate::document::format::json::set_scalar_preserving(&self.source, key, &target)?
}
#[cfg(feature = "dotenv")]
Format::Dotenv => {
crate::document::format::dotenv::set_scalar_preserving(&self.source, key, &target)?
}
#[cfg(feature = "ini")]
Format::Ini => {
crate::document::format::ini::set_scalar_preserving(&self.source, key, &target)?
}
#[cfg(feature = "toml")]
Format::TomlFrontmatter => {
let parts = crate::document::format::frontmatter::split(
&self.source,
crate::document::format::frontmatter::Delimiter::Plus,
)?;
let new_fm = crate::document::format::toml::set_scalar_preserving(
parts.frontmatter,
key,
&target,
)?;
format!("{}{}{}", parts.pre, new_fm, parts.post)
}
#[cfg(feature = "yaml")]
Format::YamlFrontmatter => {
let parts = crate::document::format::frontmatter::split(
&self.source,
crate::document::format::frontmatter::Delimiter::Dash,
)?;
let new_fm = crate::document::format::yaml::set_scalar_preserving(
parts.frontmatter,
key,
&target,
)?;
format!("{}{}{}", parts.pre, new_fm, parts.post)
}
_ => self.format.save(&new_doc)?,
};
self.save_atomic(&output)?;
self.source = output;
self.value = new_doc;
Ok(())
}
pub fn add(
&mut self,
key: &str,
slug: &str,
slug_field: &str,
fields: &[(String, Value)],
) -> DocumentResult<()> {
guard_mutation(&self.path, "add")?;
let mut value = self.value.clone();
self.format.ensure_writable("add")?;
let keyed_lists = [KeyedList {
prefix: key,
slug_field,
}];
crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
let output: String = match self.format {
Format::Json => {
let array = crate::document::get_path(&value, key, &keyed_lists)?;
let item = array
.as_array()
.and_then(|items| items.last())
.ok_or_else(|| DocumentError::UnsupportedOperation {
format: "JSON".to_string(),
operation: "add".to_string(),
detail: "keyed list did not produce an array item".to_string(),
})?;
crate::document::format::json::append_array_item_preserving(
&self.source,
key,
item,
)?
}
#[cfg(feature = "yaml")]
Format::Yaml => {
let array = crate::document::get_path(&value, key, &keyed_lists)?;
let item = array
.as_array()
.and_then(|items| items.last())
.ok_or_else(|| DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "add".to_string(),
detail: "keyed list did not produce an array item".to_string(),
})?;
crate::document::format::yaml::append_array_item_preserving(
&self.source,
key,
item,
)?
}
_ => {
return Err(DocumentError::UnsupportedOperation {
format: self.format.name().to_string(),
operation: "add".to_string(),
detail: "keyed collection source editor is not implemented for this backend"
.to_string(),
});
}
};
self.save_atomic(&output)?;
self.source = output;
self.value = value;
Ok(())
}
pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
guard_mutation(&self.path, "remove")?;
let mut value = self.value.clone();
self.format.ensure_writable("remove")?;
let keyed_lists = [KeyedList {
prefix: key,
slug_field,
}];
let original_array = crate::document::get_path(&value, key, &keyed_lists)?;
let removed_index = original_array
.as_array()
.and_then(|items| {
items
.iter()
.position(|item| item.get(slug_field).and_then(Value::as_str) == Some(slug))
})
.ok_or_else(|| DocumentError::SlugNotFound {
prefix: key.to_string(),
slug: slug.to_string(),
})?;
#[cfg(not(feature = "yaml"))]
let _ = removed_index;
crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
let output: String = match self.format {
Format::Json => crate::document::format::json::remove_array_item_preserving(
&self.source,
key,
slug,
slug_field,
)?,
#[cfg(feature = "yaml")]
Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
&self.source,
key,
removed_index,
)?,
_ => {
return Err(DocumentError::UnsupportedOperation {
format: self.format.name().to_string(),
operation: "remove".to_string(),
detail: "keyed collection source editor is not implemented for this backend"
.to_string(),
});
}
};
self.save_atomic(&output)?;
self.source = output;
self.value = value;
Ok(())
}
pub fn unset(&mut self, key: &str) -> DocumentResult<()> {
guard_mutation(&self.path, "unset")?;
let mut value = self.value.clone();
self.format.ensure_writable("unset")?;
crate::document::unset_path(&mut value, key)?;
#[allow(unreachable_patterns)]
let output = match self.format {
Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
#[cfg(feature = "toml")]
Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
#[cfg(feature = "yaml")]
Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
#[cfg(feature = "dotenv")]
Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
#[cfg(feature = "ini")]
Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
#[cfg(feature = "toml")]
Format::TomlFrontmatter => {
let parts = crate::document::format::frontmatter::split(
&self.source,
crate::document::format::frontmatter::Delimiter::Plus,
)?;
let new_fm =
crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
format!("{}{}{}", parts.pre, new_fm, parts.post)
}
#[cfg(feature = "yaml")]
Format::YamlFrontmatter => {
let parts = crate::document::format::frontmatter::split(
&self.source,
crate::document::format::frontmatter::Delimiter::Dash,
)?;
let new_fm =
crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
format!("{}{}{}", parts.pre, new_fm, parts.post)
}
_ => self.format.save(&value)?,
};
self.save_atomic(&output)?;
self.source = output;
self.value = value;
Ok(())
}
pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
write_atomic(&self.path, new_source.as_bytes(), "write")
}
}
fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
detail: format!("{operation} preflight `{}`: {error}", path.display()),
})?;
if metadata.file_type().is_symlink() {
return Err(DocumentError::UnsupportedOperation {
format: "filesystem".to_string(),
operation: operation.to_string(),
detail: format!("refusing to mutate symlink `{}`", path.display()),
});
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if metadata.nlink() > 1 {
return Err(DocumentError::UnsupportedOperation {
format: "filesystem".to_string(),
operation: operation.to_string(),
detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
});
}
}
Ok(metadata)
}
fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
let metadata = guard_mutation(path, operation)?;
let parent = path.parent().ok_or_else(|| DocumentError::IoError {
detail: format!(
"{operation} has no parent directory for `{}`",
path.display()
),
})?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| DocumentError::IoError {
detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
})?;
let pid = std::process::id();
let mut temp_path = None;
let mut temp_file = None;
for attempt in 0..32_u32 {
let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(file) => {
temp_path = Some(candidate);
temp_file = Some(file);
break;
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(DocumentError::IoError {
detail: format!(
"{operation} create temporary file in `{}`: {error}",
parent.display()
),
});
}
}
}
let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
detail: format!(
"{operation} could not allocate temporary file in `{}`",
parent.display()
),
})?;
let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
detail: format!("{operation} temporary file handle missing"),
})?;
let result = (|| -> DocumentResult<()> {
temp_file
.write_all(bytes)
.map_err(|error| DocumentError::IoError {
detail: format!("{operation} write `{}`: {error}", path.display()),
})?;
temp_file
.sync_all()
.map_err(|error| DocumentError::IoError {
detail: format!("{operation} fsync `{}`: {error}", path.display()),
})?;
drop(temp_file);
fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
DocumentError::IoError {
detail: format!(
"{operation} preserve permissions `{}`: {error}",
path.display()
),
}
})?;
fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
})?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temp_path);
}
result
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
use super::*;
use std::io::Cursor;
fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, contents).unwrap();
path
}
#[test]
fn round_trip_open_json() {
let dir = tempfile::tempdir().unwrap();
let contents = r#"{"host": "example.com", "port": 993}"#;
let path = write_temp(dir.path(), "config.json", contents);
let doc = DocumentFile::open(&path, None).unwrap();
assert_eq!(doc.format(), Format::Json);
assert_eq!(
doc.value().get("host").and_then(Value::as_str),
Some("example.com")
);
assert_eq!(doc.source(), contents);
}
#[test]
fn value_at_reads_a_nested_address() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp(
dir.path(),
"config.json",
r#"{"database": {"url": "postgres://x"}}"#,
);
let doc = DocumentFile::open(&path, None).unwrap();
assert_eq!(
doc.value_at("database.url").unwrap(),
Value::String("postgres://x".to_string())
);
assert_eq!(
doc.value_at("database.missing").unwrap_err().code(),
"document_path_not_found"
);
}
#[test]
fn open_capped_enforces_size_and_regular_file() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
assert_eq!(err.code(), "document_io_failed");
assert!(err.to_string().contains("read limit"));
let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
assert_eq!(dir_err.code(), "document_io_failed");
}
#[cfg(feature = "toml")]
#[test]
fn round_trip_open_toml() {
let dir = tempfile::tempdir().unwrap();
let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
let path = write_temp(dir.path(), "config.toml", contents);
let doc = DocumentFile::open(&path, None).unwrap();
assert_eq!(doc.format(), Format::Toml);
assert_eq!(
doc.value().get("host").and_then(Value::as_str),
Some("example.com")
);
assert_eq!(doc.source(), contents);
}
#[cfg(feature = "toml")]
#[test]
fn set_scalar_preserves_toml_comments_and_formatting() {
let dir = tempfile::tempdir().unwrap();
let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
let path = write_temp(dir.path(), "config.toml", contents);
let mut doc = DocumentFile::open(&path, None).unwrap();
doc.set("port", Value::Integer(1024)).unwrap();
let saved = fs::read_to_string(&path).unwrap();
assert!(saved.contains("# leading comment"));
assert!(saved.contains("port = 1024"));
assert_eq!(
doc.value().get("port").and_then(Value::as_integer),
Some(1024)
);
assert_eq!(doc.source(), saved);
}
#[cfg(unix)]
#[test]
fn atomic_save_preserves_file_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
let mut doc = DocumentFile::open(&path, None).unwrap();
doc.set("port", Value::Integer(1024)).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o640);
}
#[cfg(unix)]
#[test]
fn symlink_target_is_rejected_for_mutation() {
let dir = tempfile::tempdir().unwrap();
let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
let link = dir.path().join("link.json");
std::os::unix::fs::symlink(&target, &link).unwrap();
let mut doc = DocumentFile::open(&link, None).unwrap();
let err = doc.set("port", Value::Integer(1024)).unwrap_err();
assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
let target_contents = fs::read_to_string(&target).unwrap();
assert_eq!(target_contents, r#"{"port": 993}"#);
}
#[test]
fn from_reader_parses_in_memory_cursor() {
let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
let doc = Document::from_reader(cursor, Format::Json).unwrap();
assert_eq!(
doc.value().get("host").and_then(Value::as_str),
Some("example.com")
);
}
#[test]
fn document_from_str_encode_round_trip() {
let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
let encoded = doc.encode().unwrap();
let reparsed = Document::parse(&encoded, Format::Json).unwrap();
assert_eq!(
reparsed.value().get("a").and_then(Value::as_integer),
Some(1)
);
}
}