1use std::fs::{self, OpenOptions};
30use std::io::Write as _;
31use std::path::{Path, PathBuf};
32
33use crate::document::{DocumentError, DocumentResult, Format, KeyedList, Value};
34
35#[derive(Debug, Clone)]
39pub struct Document {
40 value: Value,
41 format: Format,
42}
43
44impl Document {
45 pub fn parse(source: &str, format: Format) -> DocumentResult<Document> {
51 let value = format.load(source)?;
52 Ok(Document { value, format })
53 }
54
55 pub fn from_reader<R: std::io::Read>(
61 mut reader: R,
62 format: Format,
63 ) -> DocumentResult<Document> {
64 let mut source = String::new();
65 reader.read_to_string(&mut source)?;
66 Document::parse(&source, format)
67 }
68
69 pub fn value(&self) -> &Value {
71 &self.value
72 }
73
74 pub fn value_mut(&mut self) -> &mut Value {
76 &mut self.value
77 }
78
79 pub fn format(&self) -> Format {
81 self.format
82 }
83
84 pub fn encode(&self) -> DocumentResult<String> {
90 self.format.save(&self.value)
91 }
92}
93
94#[derive(Debug)]
102pub struct DocumentFile {
103 path: PathBuf,
104 format: Format,
105 source: String,
106 value: Value,
107}
108
109impl DocumentFile {
110 pub fn open(
116 path: impl AsRef<Path>,
117 format_override: Option<Format>,
118 ) -> DocumentResult<DocumentFile> {
119 let path = path.as_ref().to_path_buf();
120 let format = match format_override {
121 Some(format) => format,
122 None => Format::detect(&path).ok_or_else(|| DocumentError::ParseError {
123 format: "format".to_string(),
124 detail: format!(
125 "cannot detect format from file extension `{}`; pass an explicit format",
126 path.display()
127 ),
128 })?,
129 };
130 let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
131 detail: format!("read `{}`: {error}", path.display()),
132 })?;
133 let value = format.load(&source)?;
134 Ok(DocumentFile {
135 path,
136 format,
137 source,
138 value,
139 })
140 }
141
142 pub fn path(&self) -> &Path {
144 &self.path
145 }
146
147 pub fn value(&self) -> &Value {
150 &self.value
151 }
152
153 pub fn format(&self) -> Format {
155 self.format
156 }
157
158 pub fn source(&self) -> &str {
161 &self.source
162 }
163
164 pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
174 guard_mutation(&self.path, operation)?;
175 Ok(())
176 }
177
178 pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
181 guard_mutation(&self.path, "set")?;
182 let mut new_doc = self.value.clone();
183 self.format.ensure_writable("set")?;
184 crate::document::set_path(&mut new_doc, key, &value, &[])?;
185 let target = crate::document::get_path(&new_doc, key, &[])?;
186 #[allow(unreachable_patterns)]
187 let output = match self.format {
188 #[cfg(feature = "toml")]
189 Format::Toml => {
190 crate::document::format::toml::set_scalar_preserving(&self.source, key, &target)?
191 }
192 #[cfg(feature = "yaml")]
193 Format::Yaml => {
194 crate::document::format::yaml::set_scalar_preserving(&self.source, key, &target)?
195 }
196 Format::Json => {
197 crate::document::format::json::set_scalar_preserving(&self.source, key, &target)?
198 }
199 #[cfg(feature = "dotenv")]
200 Format::Dotenv => {
201 crate::document::format::dotenv::set_scalar_preserving(&self.source, key, &target)?
202 }
203 #[cfg(feature = "ini")]
204 Format::Ini => {
205 crate::document::format::ini::set_scalar_preserving(&self.source, key, &target)?
206 }
207 _ => self.format.save(&new_doc)?,
208 };
209 self.save_atomic(&output)?;
210 self.source = output;
211 self.value = new_doc;
212 Ok(())
213 }
214
215 pub fn add(
223 &mut self,
224 key: &str,
225 slug: &str,
226 slug_field: &str,
227 fields: &[(String, Value)],
228 ) -> DocumentResult<()> {
229 guard_mutation(&self.path, "add")?;
230 let mut value = self.value.clone();
231 self.format.ensure_writable("add")?;
232 let keyed_lists = [KeyedList {
233 prefix: key,
234 slug_field,
235 }];
236 crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
237 let output: String = match self.format {
238 Format::Json => {
239 let array = crate::document::get_path(&value, key, &keyed_lists)?;
240 let item = array
241 .as_array()
242 .and_then(|items| items.last())
243 .ok_or_else(|| DocumentError::UnsupportedOperation {
244 format: "JSON".to_string(),
245 operation: "add".to_string(),
246 detail: "keyed list did not produce an array item".to_string(),
247 })?;
248 crate::document::format::json::append_array_item_preserving(
249 &self.source,
250 key,
251 item,
252 )?
253 }
254 #[cfg(feature = "yaml")]
255 Format::Yaml => {
256 let array = crate::document::get_path(&value, key, &keyed_lists)?;
257 let item = array
258 .as_array()
259 .and_then(|items| items.last())
260 .ok_or_else(|| DocumentError::UnsupportedOperation {
261 format: "YAML".to_string(),
262 operation: "add".to_string(),
263 detail: "keyed list did not produce an array item".to_string(),
264 })?;
265 crate::document::format::yaml::append_array_item_preserving(
266 &self.source,
267 key,
268 item,
269 )?
270 }
271 _ => {
272 return Err(DocumentError::UnsupportedOperation {
273 format: format_name(self.format).to_string(),
274 operation: "add".to_string(),
275 detail: "keyed collection source editor is not implemented for this backend"
276 .to_string(),
277 });
278 }
279 };
280 self.save_atomic(&output)?;
281 self.source = output;
282 self.value = value;
283 Ok(())
284 }
285
286 pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
293 guard_mutation(&self.path, "remove")?;
294 let mut value = self.value.clone();
295 self.format.ensure_writable("remove")?;
296 let keyed_lists = [KeyedList {
297 prefix: key,
298 slug_field,
299 }];
300 let original_array = crate::document::get_path(&value, key, &keyed_lists)?;
301 let removed_index = original_array
302 .as_array()
303 .and_then(|items| {
304 items
305 .iter()
306 .position(|item| item.get(slug_field).and_then(Value::as_str) == Some(slug))
307 })
308 .ok_or_else(|| DocumentError::SlugNotFound {
309 prefix: key.to_string(),
310 slug: slug.to_string(),
311 })?;
312 #[cfg(not(feature = "yaml"))]
313 let _ = removed_index;
314 crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
315 let output: String = match self.format {
316 Format::Json => crate::document::format::json::remove_array_item_preserving(
317 &self.source,
318 key,
319 slug,
320 slug_field,
321 )?,
322 #[cfg(feature = "yaml")]
323 Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
324 &self.source,
325 key,
326 removed_index,
327 )?,
328 _ => {
329 return Err(DocumentError::UnsupportedOperation {
330 format: format_name(self.format).to_string(),
331 operation: "remove".to_string(),
332 detail: "keyed collection source editor is not implemented for this backend"
333 .to_string(),
334 });
335 }
336 };
337 self.save_atomic(&output)?;
338 self.source = output;
339 self.value = value;
340 Ok(())
341 }
342
343 pub fn unset(&mut self, key: &str) -> DocumentResult<()> {
346 guard_mutation(&self.path, "unset")?;
347 let mut value = self.value.clone();
348 self.format.ensure_writable("unset")?;
349 crate::document::unset_path(&mut value, key)?;
350 #[allow(unreachable_patterns)]
351 let output = match self.format {
352 Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
353 #[cfg(feature = "toml")]
354 Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
355 #[cfg(feature = "yaml")]
356 Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
357 #[cfg(feature = "dotenv")]
358 Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
359 #[cfg(feature = "ini")]
360 Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
361 _ => self.format.save(&value)?,
362 };
363 self.save_atomic(&output)?;
364 self.source = output;
365 self.value = value;
366 Ok(())
367 }
368
369 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
385 write_atomic(&self.path, new_source.as_bytes(), "write")
386 }
387}
388
389fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
393 let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
394 detail: format!("{operation} preflight `{}`: {error}", path.display()),
395 })?;
396 if metadata.file_type().is_symlink() {
397 return Err(DocumentError::UnsupportedOperation {
398 format: "filesystem".to_string(),
399 operation: operation.to_string(),
400 detail: format!("refusing to mutate symlink `{}`", path.display()),
401 });
402 }
403 #[cfg(unix)]
404 {
405 use std::os::unix::fs::MetadataExt;
406 if metadata.nlink() > 1 {
407 return Err(DocumentError::UnsupportedOperation {
408 format: "filesystem".to_string(),
409 operation: operation.to_string(),
410 detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
411 });
412 }
413 }
414 Ok(metadata)
415}
416
417fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
420 let metadata = guard_mutation(path, operation)?;
421
422 let parent = path.parent().ok_or_else(|| DocumentError::IoError {
423 detail: format!(
424 "{operation} has no parent directory for `{}`",
425 path.display()
426 ),
427 })?;
428 let file_name = path
429 .file_name()
430 .and_then(|name| name.to_str())
431 .ok_or_else(|| DocumentError::IoError {
432 detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
433 })?;
434 let pid = std::process::id();
435 let mut temp_path = None;
436 let mut temp_file = None;
437 for attempt in 0..32_u32 {
438 let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
439 match OpenOptions::new()
440 .write(true)
441 .create_new(true)
442 .open(&candidate)
443 {
444 Ok(file) => {
445 temp_path = Some(candidate);
446 temp_file = Some(file);
447 break;
448 }
449 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
450 Err(error) => {
451 return Err(DocumentError::IoError {
452 detail: format!(
453 "{operation} create temporary file in `{}`: {error}",
454 parent.display()
455 ),
456 });
457 }
458 }
459 }
460 let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
461 detail: format!(
462 "{operation} could not allocate temporary file in `{}`",
463 parent.display()
464 ),
465 })?;
466 let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
467 detail: format!("{operation} temporary file handle missing"),
468 })?;
469 let result = (|| -> DocumentResult<()> {
470 temp_file
471 .write_all(bytes)
472 .map_err(|error| DocumentError::IoError {
473 detail: format!("{operation} write `{}`: {error}", path.display()),
474 })?;
475 temp_file
476 .sync_all()
477 .map_err(|error| DocumentError::IoError {
478 detail: format!("{operation} fsync `{}`: {error}", path.display()),
479 })?;
480 drop(temp_file);
481 fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
482 DocumentError::IoError {
483 detail: format!(
484 "{operation} preserve permissions `{}`: {error}",
485 path.display()
486 ),
487 }
488 })?;
489 fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
490 detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
491 })?;
492 Ok(())
493 })();
494 if result.is_err() {
495 let _ = fs::remove_file(&temp_path);
496 }
497 result
498}
499
500fn format_name(format: Format) -> &'static str {
501 match format {
502 Format::Json => "JSON",
503 Format::Toml => "TOML",
504 Format::Yaml => "YAML",
505 Format::Dotenv => "dotenv",
506 Format::Ini => "INI",
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
513 use super::*;
514 use std::io::Cursor;
515
516 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
517 let path = dir.join(name);
518 fs::write(&path, contents).unwrap();
519 path
520 }
521
522 #[test]
523 fn round_trip_open_json() {
524 let dir = tempfile::tempdir().unwrap();
525 let contents = r#"{"host": "example.com", "port": 993}"#;
526 let path = write_temp(dir.path(), "config.json", contents);
527
528 let doc = DocumentFile::open(&path, None).unwrap();
529
530 assert_eq!(doc.format(), Format::Json);
531 assert_eq!(
532 doc.value().get("host").and_then(Value::as_str),
533 Some("example.com")
534 );
535 assert_eq!(doc.source(), contents);
536 }
537
538 #[cfg(feature = "toml")]
539 #[test]
540 fn round_trip_open_toml() {
541 let dir = tempfile::tempdir().unwrap();
542 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
543 let path = write_temp(dir.path(), "config.toml", contents);
544
545 let doc = DocumentFile::open(&path, None).unwrap();
546
547 assert_eq!(doc.format(), Format::Toml);
548 assert_eq!(
549 doc.value().get("host").and_then(Value::as_str),
550 Some("example.com")
551 );
552 assert_eq!(doc.source(), contents);
553 }
554
555 #[cfg(feature = "toml")]
556 #[test]
557 fn set_scalar_preserves_toml_comments_and_formatting() {
558 let dir = tempfile::tempdir().unwrap();
559 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
560 let path = write_temp(dir.path(), "config.toml", contents);
561 let mut doc = DocumentFile::open(&path, None).unwrap();
562
563 doc.set("port", Value::Integer(1024)).unwrap();
564
565 let saved = fs::read_to_string(&path).unwrap();
566 assert!(saved.contains("# leading comment"));
567 assert!(saved.contains("port = 1024"));
568 assert_eq!(
569 doc.value().get("port").and_then(Value::as_integer),
570 Some(1024)
571 );
572 assert_eq!(doc.source(), saved);
573 }
574
575 #[cfg(unix)]
576 #[test]
577 fn atomic_save_preserves_file_mode() {
578 use std::os::unix::fs::PermissionsExt;
579
580 let dir = tempfile::tempdir().unwrap();
581 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
582 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
583 let mut doc = DocumentFile::open(&path, None).unwrap();
584
585 doc.set("port", Value::Integer(1024)).unwrap();
586
587 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
588 assert_eq!(mode, 0o640);
589 }
590
591 #[cfg(unix)]
592 #[test]
593 fn symlink_target_is_rejected_for_mutation() {
594 let dir = tempfile::tempdir().unwrap();
595 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
596 let link = dir.path().join("link.json");
597 std::os::unix::fs::symlink(&target, &link).unwrap();
598
599 let mut doc = DocumentFile::open(&link, None).unwrap();
601
602 let err = doc.set("port", Value::Integer(1024)).unwrap_err();
604 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
605
606 let target_contents = fs::read_to_string(&target).unwrap();
608 assert_eq!(target_contents, r#"{"port": 993}"#);
609 }
610
611 #[test]
612 fn from_reader_parses_in_memory_cursor() {
613 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
614
615 let doc = Document::from_reader(cursor, Format::Json).unwrap();
616
617 assert_eq!(
618 doc.value().get("host").and_then(Value::as_str),
619 Some("example.com")
620 );
621 }
622
623 #[test]
624 fn document_from_str_encode_round_trip() {
625 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
626 let encoded = doc.encode().unwrap();
627 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
628 assert_eq!(
629 reparsed.value().get("a").and_then(Value::as_integer),
630 Some(1)
631 );
632 }
633}