Skip to main content

yaml_edit/nodes/
document.rs

1use super::{Lang, Mapping, Scalar, Sequence, SyntaxNode, TaggedNode};
2use crate::as_yaml::{AsYaml, YamlKind};
3use crate::error::YamlResult;
4use crate::lex::SyntaxKind;
5use crate::yaml::YamlFile;
6use rowan::ast::AstNode;
7use rowan::GreenNodeBuilder;
8use std::path::Path;
9
10ast_node!(Document, DOCUMENT, "A single YAML document");
11
12impl Document {
13    /// Create a new document
14    pub fn new() -> Document {
15        let mut builder = GreenNodeBuilder::new();
16        builder.start_node(SyntaxKind::DOCUMENT.into());
17        // Add the document start marker "---"
18        builder.token(SyntaxKind::DOC_START.into(), "---");
19        builder.token(SyntaxKind::WHITESPACE.into(), "\n");
20        builder.finish_node();
21        Document(SyntaxNode::new_root_mut(builder.finish()))
22    }
23
24    /// Create a new document with an empty mapping
25    pub fn new_mapping() -> Document {
26        let mut builder = GreenNodeBuilder::new();
27        builder.start_node(SyntaxKind::DOCUMENT.into());
28        // Don't add document start marker "---" for programmatically created documents
29        builder.start_node(SyntaxKind::MAPPING.into());
30        builder.finish_node(); // End MAPPING
31        builder.finish_node(); // End DOCUMENT
32        Document(SyntaxNode::new_root_mut(builder.finish()))
33    }
34
35    /// Load a document from a file
36    ///
37    /// Returns an error if the file contains multiple documents.
38    /// For multi-document YAML files, parse manually with `YamlFile::from_str()`.
39    ///
40    /// This follows the standard Rust naming convention of `from_file()`
41    /// to pair with `to_file()`.
42    pub fn from_file<P: AsRef<Path>>(path: P) -> YamlResult<Document> {
43        use std::str::FromStr;
44        let content = std::fs::read_to_string(path)?;
45        Self::from_str(&content)
46    }
47
48    /// Write the document to a file, creating directories as needed
49    ///
50    /// This follows the standard Rust naming convention of `to_file()`
51    /// to pair with `from_file()`.
52    pub fn to_file<P: AsRef<Path>>(&self, path: P) -> YamlResult<()> {
53        let path = path.as_ref();
54        if let Some(parent) = path.parent() {
55            std::fs::create_dir_all(parent)?;
56        }
57        let mut content = self.to_string();
58        // Ensure the file ends with a newline
59        if !content.ends_with('\n') {
60            content.push('\n');
61        }
62        std::fs::write(path, content)?;
63        Ok(())
64    }
65
66    /// Get the root node of this document (could be mapping, sequence, scalar, or alias)
67    pub(crate) fn root_node(&self) -> Option<SyntaxNode> {
68        self.0.children().find(|child| {
69            matches!(
70                child.kind(),
71                SyntaxKind::MAPPING
72                    | SyntaxKind::SEQUENCE
73                    | SyntaxKind::SCALAR
74                    | SyntaxKind::ALIAS
75                    | SyntaxKind::TAGGED_NODE
76            )
77        })
78    }
79
80    /// Get this document as a mapping, if it is one.
81    ///
82    /// Note: `Mapping` supports mutation even though this method takes `&self`.
83    /// Mutations are applied directly to the underlying syntax tree via rowan's
84    /// persistent data structures. All references to the tree will see the changes.
85    pub fn as_mapping(&self) -> Option<Mapping> {
86        self.root_node().and_then(Mapping::cast)
87    }
88
89    /// Get this document's root value as a sequence, or `None` if it isn't one.
90    pub fn as_sequence(&self) -> Option<Sequence> {
91        self.root_node().and_then(Sequence::cast)
92    }
93
94    /// Get this document's root value as a scalar, or `None` if it isn't one.
95    pub fn as_scalar(&self) -> Option<Scalar> {
96        self.root_node().and_then(Scalar::cast)
97    }
98
99    /// Returns `true` if this document is a mapping that contains the given key.
100    pub fn contains_key(&self, key: impl crate::AsYaml) -> bool {
101        self.as_mapping().is_some_and(|m| m.contains_key(key))
102    }
103
104    /// Get the value for a key from the document's root mapping.
105    ///
106    /// Returns `None` if the document is not a mapping or the key doesn't exist.
107    pub fn get(&self, key: impl crate::AsYaml) -> Option<crate::as_yaml::YamlNode> {
108        self.as_mapping().and_then(|m| m.get(key))
109    }
110
111    /// Get the raw syntax node for a key in the document's root mapping.
112    ///
113    /// Returns `None` if the document is not a mapping or the key doesn't exist.
114    /// Prefer [`get`](Self::get) for most use cases; this is for advanced CST access.
115    pub(crate) fn get_node(&self, key: impl crate::AsYaml) -> Option<SyntaxNode> {
116        self.as_mapping().and_then(|m| m.get_node(key))
117    }
118
119    /// Set a scalar value in the document (assumes document is a mapping)
120    pub fn set(&self, key: impl crate::AsYaml, value: impl crate::AsYaml) {
121        if let Some(mapping) = self.as_mapping() {
122            mapping.set(key, value);
123            // Changes are applied directly via splice_children, no need to replace
124        } else {
125            // If document is not a mapping, create one and add it to the document
126            let mapping = Mapping::new();
127            mapping.set(key, value);
128
129            // Add the mapping node directly to the document
130            let child_count = self.0.children_with_tokens().count();
131            self.0
132                .splice_children(child_count..child_count, vec![mapping.0.into()]);
133        }
134    }
135
136    /// Set a key-value pair with field ordering support.
137    ///
138    /// If the key exists, updates its value. If the key doesn't exist, inserts it
139    /// at the correct position based on the provided field order.
140    /// Fields not in the order list are placed at the end.
141    pub fn set_with_field_order<I, K>(
142        &self,
143        key: impl crate::AsYaml,
144        value: impl crate::AsYaml,
145        field_order: I,
146    ) where
147        I: IntoIterator<Item = K>,
148        K: crate::AsYaml,
149    {
150        // Collect so we can pass to both branches if needed.
151        let field_order: Vec<K> = field_order.into_iter().collect();
152        if let Some(mapping) = self.as_mapping() {
153            mapping.set_with_field_order(key, value, field_order);
154            // Changes are applied directly via splice_children, no need to replace
155        } else {
156            // If document is not a mapping, create one and splice it in.
157            let mapping = Mapping::new();
158            mapping.set_with_field_order(key, value, field_order);
159            let child_count = self.0.children_with_tokens().count();
160            self.0
161                .splice_children(child_count..child_count, vec![mapping.0.into()]);
162        }
163    }
164
165    /// Remove a key from the document (assumes document is a mapping).
166    ///
167    /// Returns `Some(entry)` if the key existed and was removed, or `None` if
168    /// the key was not found or the document is not a mapping. The returned
169    /// [`MappingEntry`](super::MappingEntry) is detached from the tree; callers can inspect its key
170    /// and value or re-insert it elsewhere.
171    pub fn remove(&self, key: impl crate::AsYaml) -> Option<super::MappingEntry> {
172        self.as_mapping()?.remove(key)
173    }
174
175    /// Get all key nodes from the document (assumes document is a mapping)
176    pub(crate) fn key_nodes(&self) -> impl Iterator<Item = SyntaxNode> + '_ {
177        self.as_mapping()
178            .into_iter()
179            .flat_map(|m| m.key_nodes().collect::<Vec<_>>())
180    }
181
182    /// Iterate over all keys in the document as [`YamlNode`](crate::as_yaml::YamlNode)s.
183    ///
184    /// Each key is a [`YamlNode`](crate::as_yaml::YamlNode) wrapping the
185    /// underlying CST node.  Assumes the document is a mapping; yields nothing
186    /// if it is not.
187    pub fn keys(&self) -> impl Iterator<Item = crate::as_yaml::YamlNode> + '_ {
188        self.key_nodes().filter_map(|key_node| {
189            key_node
190                .children()
191                .next()
192                .and_then(crate::as_yaml::YamlNode::from_syntax)
193        })
194    }
195
196    /// Check if the document is empty
197    pub fn is_empty(&self) -> bool {
198        self.as_mapping().map_or(true, |m| m.is_empty())
199    }
200
201    /// Create a document from a mapping
202    pub fn from_mapping(mapping: Mapping) -> Self {
203        // Create a document directly from the mapping syntax node to avoid recursion
204        let mut builder = GreenNodeBuilder::new();
205        builder.start_node(SyntaxKind::DOCUMENT.into());
206        // Add the document start marker "---"
207        builder.token(SyntaxKind::DOC_START.into(), "---");
208        builder.token(SyntaxKind::WHITESPACE.into(), "\n");
209
210        // Add the mapping with all its children, except trailing newline
211        builder.start_node(SyntaxKind::MAPPING.into());
212        let mapping_green = mapping.0.green();
213        let children: Vec<_> = mapping_green.children().collect();
214
215        // Check if last child is a newline token and skip it
216        let end_index = if let Some(rowan::NodeOrToken::Token(t)) = children.last() {
217            if t.kind() == SyntaxKind::NEWLINE.into() {
218                children.len() - 1
219            } else {
220                children.len()
221            }
222        } else {
223            children.len()
224        };
225
226        for child in &children[..end_index] {
227            match child {
228                rowan::NodeOrToken::Node(n) => {
229                    builder.start_node(n.kind());
230                    Self::add_green_node_children(&mut builder, n);
231                    builder.finish_node();
232                }
233                rowan::NodeOrToken::Token(t) => {
234                    builder.token(t.kind(), t.text());
235                }
236            }
237        }
238
239        // Don't add a trailing newline - entries already have their own newlines
240        builder.finish_node(); // End MAPPING
241        builder.finish_node(); // End DOCUMENT
242
243        Document(SyntaxNode::new_root_mut(builder.finish()))
244    }
245
246    /// Helper method to recursively add green node children to the builder
247    fn add_green_node_children(builder: &mut GreenNodeBuilder, node: &rowan::GreenNodeData) {
248        for child in node.children() {
249            match child {
250                rowan::NodeOrToken::Node(n) => {
251                    builder.start_node(n.kind());
252                    Self::add_green_node_children(builder, n);
253                    builder.finish_node();
254                }
255                rowan::NodeOrToken::Token(t) => {
256                    builder.token(t.kind(), t.text());
257                }
258            }
259        }
260    }
261
262    /// Insert a key-value pair immediately after `after_key` in this document's mapping.
263    ///
264    /// If `key` already exists, its value is updated in-place and it stays at its
265    /// current position (it is **not** moved). Returns `false` if `after_key` is not
266    /// found or the document is not a mapping.
267    ///
268    /// Use [`move_after`](Self::move_after) if you want an existing entry to be
269    /// removed from its current position and re-inserted after `after_key`.
270    pub fn insert_after(
271        &self,
272        after_key: impl crate::AsYaml,
273        key: impl crate::AsYaml,
274        value: impl crate::AsYaml,
275    ) -> bool {
276        if let Some(mapping) = self.as_mapping() {
277            mapping.insert_after(after_key, key, value)
278        } else {
279            false
280        }
281    }
282
283    /// Move a key-value pair to immediately after `after_key` in this document's mapping.
284    ///
285    /// If `key` already exists, it is **removed** from its current position and
286    /// re-inserted after `after_key` with the new value. Returns `false` if
287    /// `after_key` is not found or the document is not a mapping.
288    ///
289    /// Use [`insert_after`](Self::insert_after) if you want an existing entry to be
290    /// updated in-place rather than moved.
291    pub fn move_after(
292        &self,
293        after_key: impl crate::AsYaml,
294        key: impl crate::AsYaml,
295        value: impl crate::AsYaml,
296    ) -> bool {
297        if let Some(mapping) = self.as_mapping() {
298            mapping.move_after(after_key, key, value)
299        } else {
300            false
301        }
302    }
303
304    /// Insert a key-value pair immediately before `before_key` in this document's mapping.
305    ///
306    /// If `key` already exists, its value is updated in-place and it stays at its
307    /// current position (it is **not** moved). Returns `false` if `before_key` is not
308    /// found or the document is not a mapping.
309    ///
310    /// Use [`move_before`](Self::move_before) if you want an existing entry to be
311    /// removed from its current position and re-inserted before `before_key`.
312    pub fn insert_before(
313        &self,
314        before_key: impl crate::AsYaml,
315        key: impl crate::AsYaml,
316        value: impl crate::AsYaml,
317    ) -> bool {
318        if let Some(mapping) = self.as_mapping() {
319            mapping.insert_before(before_key, key, value)
320        } else {
321            false
322        }
323    }
324
325    /// Move a key-value pair to immediately before `before_key` in this document's mapping.
326    ///
327    /// If `key` already exists, it is **removed** from its current position and
328    /// re-inserted before `before_key` with the new value. Returns `false` if
329    /// `before_key` is not found or the document is not a mapping.
330    ///
331    /// Use [`insert_before`](Self::insert_before) if you want an existing entry to be
332    /// updated in-place rather than moved.
333    pub fn move_before(
334        &self,
335        before_key: impl crate::AsYaml,
336        key: impl crate::AsYaml,
337        value: impl crate::AsYaml,
338    ) -> bool {
339        if let Some(mapping) = self.as_mapping() {
340            mapping.move_before(before_key, key, value)
341        } else {
342            false
343        }
344    }
345
346    /// Helper to build a VALUE wrapper node around any AsYaml value
347    pub(crate) fn build_value_content(
348        builder: &mut GreenNodeBuilder,
349        value: impl crate::AsYaml,
350        indent: usize,
351    ) {
352        builder.start_node(SyntaxKind::VALUE.into());
353        value.build_content(builder, indent, false);
354        builder.finish_node(); // VALUE
355    }
356
357    /// Insert a key-value pair at a specific index (assumes document is a mapping).
358    ///
359    /// If the document already contains a mapping, delegates to
360    /// [`Mapping::insert_at_index`]. If no mapping exists yet, creates one and
361    /// uses [`Mapping::insert_at_index_preserving`].
362    pub fn insert_at_index(
363        &self,
364        index: usize,
365        key: impl crate::AsYaml,
366        value: impl crate::AsYaml,
367    ) {
368        // Delegate to Mapping::insert_at_index if we have a mapping
369        if let Some(mapping) = self.as_mapping() {
370            mapping.insert_at_index(index, key, value);
371            return;
372        }
373
374        // No mapping exists yet: create one and replace document contents
375        let mapping = Mapping::new();
376        mapping.insert_at_index_preserving(index, key, value);
377        let new_doc = Self::from_mapping(mapping);
378        let new_children: Vec<_> = new_doc.0.children_with_tokens().collect();
379        let child_count = self.0.children_with_tokens().count();
380        self.0.splice_children(0..child_count, new_children);
381    }
382
383    /// Get the scalar value for `key` as a decoded `String`.
384    ///
385    /// Returns `None` if the key does not exist or its value is not a scalar
386    /// (i.e. the value is a sequence or mapping). Quotes are stripped and
387    /// escape sequences are processed (e.g. `\"` → `"`, `\n` → newline).
388    /// For tagged scalars (e.g. `!!str foo`) the tag is ignored and the
389    /// value part is returned.
390    pub fn get_string(&self, key: impl crate::AsYaml) -> Option<String> {
391        // get_node() returns the content node (SCALAR/MAPPING/SEQUENCE/TAGGED_NODE),
392        // already unwrapped from the VALUE wrapper.
393        let content = self.get_node(key)?;
394        if let Some(tagged_node) = TaggedNode::cast(content.clone()) {
395            tagged_node.value().map(|s| s.as_string())
396        } else {
397            // Returns None if content is a sequence or mapping (not a scalar).
398            Scalar::cast(content).map(|s| s.as_string())
399        }
400    }
401
402    /// Get the number of top-level key-value pairs in this document.
403    ///
404    /// Returns `0` if the document is not a mapping or is empty.
405    pub fn len(&self) -> usize {
406        self.as_mapping().map(|m| m.len()).unwrap_or(0)
407    }
408
409    /// Get a nested mapping value for a key.
410    ///
411    /// Returns `None` if the key doesn't exist or its value is not a mapping.
412    pub fn get_mapping(&self, key: impl crate::AsYaml) -> Option<Mapping> {
413        self.get(key).and_then(|n| n.as_mapping().cloned())
414    }
415
416    /// Get a nested sequence value for a key.
417    ///
418    /// Returns `None` if the key doesn't exist or its value is not a sequence.
419    pub fn get_sequence(&self, key: impl crate::AsYaml) -> Option<Sequence> {
420        self.get(key).and_then(|n| n.as_sequence().cloned())
421    }
422
423    /// Rename a top-level key while preserving its value and formatting.
424    ///
425    /// The new key is automatically escaped/quoted as needed. Returns `true` if
426    /// the key was found and renamed, `false` if `old_key` does not exist.
427    pub fn rename_key(&self, old_key: impl crate::AsYaml, new_key: impl crate::AsYaml) -> bool {
428        self.as_mapping()
429            .map(|m| m.rename_key(old_key, new_key))
430            .unwrap_or(false)
431    }
432
433    /// Returns `true` if `key` exists and its value is a sequence.
434    pub fn is_sequence(&self, key: impl crate::AsYaml) -> bool {
435        self.get(key)
436            .map(|node| node.as_sequence().is_some())
437            .unwrap_or(false)
438    }
439
440    /// Reorder fields in the document's root mapping according to the specified order.
441    ///
442    /// Fields not in the order list will appear after the ordered fields, in their
443    /// original relative order. Has no effect if the document is not a mapping.
444    pub fn reorder_fields<I, K>(&self, order: I)
445    where
446        I: IntoIterator<Item = K>,
447        K: crate::AsYaml,
448    {
449        if let Some(mapping) = self.as_mapping() {
450            mapping.reorder_fields(order);
451        }
452    }
453
454    /// Validate this document against a YAML schema
455    ///
456    /// # Examples
457    ///
458    /// ```rust
459    /// use yaml_edit::{Document, Schema, SchemaValidator};
460    ///
461    /// let yaml = r#"
462    /// name: "John"
463    /// age: 30
464    /// active: true
465    /// "#;
466    ///
467    /// let parsed = yaml.parse::<yaml_edit::YamlFile>().unwrap();
468    /// let doc = parsed.document().unwrap();
469    ///
470    /// // Validate against JSON schema
471    /// let validator = SchemaValidator::json();
472    /// match doc.validate_schema(&validator) {
473    ///     Ok(_) => println!("Valid JSON schema"),
474    ///     Err(errors) => {
475    ///         for error in errors {
476    ///             println!("Validation error: {}", error);
477    ///         }
478    ///     }
479    /// }
480    /// ```
481    pub fn validate_schema(
482        &self,
483        validator: &crate::schema::SchemaValidator,
484    ) -> crate::schema::ValidationResult<()> {
485        validator.validate(self)
486    }
487
488    /// Get the byte offset range of this document in the source text.
489    ///
490    /// Returns the start and end byte offsets as a `TextPosition`.
491    ///
492    /// # Examples
493    ///
494    /// ```
495    /// use yaml_edit::Document;
496    /// use std::str::FromStr;
497    ///
498    /// let text = "name: Alice\nage: 30";
499    /// let doc = Document::from_str(text).unwrap();
500    /// let range = doc.byte_range();
501    /// assert_eq!(range.start, 0);
502    /// ```
503    pub fn byte_range(&self) -> crate::TextPosition {
504        self.0.text_range().into()
505    }
506
507    /// Get the line and column where this document starts.
508    ///
509    /// Requires the original source text to calculate line/column from byte offsets.
510    /// Line and column numbers are 1-indexed.
511    ///
512    /// # Arguments
513    ///
514    /// * `source_text` - The original YAML source text
515    ///
516    /// # Examples
517    ///
518    /// ```
519    /// use yaml_edit::Document;
520    /// use std::str::FromStr;
521    ///
522    /// let text = "name: Alice";
523    /// let doc = Document::from_str(text).unwrap();
524    /// let pos = doc.start_position(text);
525    /// assert_eq!(pos.line, 1);
526    /// assert_eq!(pos.column, 1);
527    /// ```
528    pub fn start_position(&self, source_text: &str) -> crate::LineColumn {
529        let range = self.byte_range();
530        crate::byte_offset_to_line_column(source_text, range.start as usize)
531    }
532
533    /// Get the line and column where this document ends.
534    ///
535    /// Requires the original source text to calculate line/column from byte offsets.
536    /// Line and column numbers are 1-indexed.
537    ///
538    /// # Arguments
539    ///
540    /// * `source_text` - The original YAML source text
541    pub fn end_position(&self, source_text: &str) -> crate::LineColumn {
542        let range = self.byte_range();
543        crate::byte_offset_to_line_column(source_text, range.end as usize)
544    }
545}
546
547impl Default for Document {
548    fn default() -> Self {
549        Self::new()
550    }
551}
552
553impl std::str::FromStr for Document {
554    type Err = crate::error::YamlError;
555
556    /// Parse a document from a YAML string.
557    ///
558    /// Returns an error if the string contains multiple documents.
559    /// For multi-document YAML, use `YamlFile::from_str()` instead.
560    ///
561    /// # Example
562    /// ```
563    /// use yaml_edit::Document;
564    /// use std::str::FromStr;
565    ///
566    /// let doc = Document::from_str("key: value").unwrap();
567    /// assert!(doc.as_mapping().is_some());
568    /// ```
569    fn from_str(s: &str) -> Result<Self, Self::Err> {
570        let parsed = YamlFile::parse(s);
571
572        if !parsed.positioned_errors().is_empty() {
573            let first_error = &parsed.positioned_errors()[0];
574            let lc = crate::byte_offset_to_line_column(s, first_error.range.start as usize);
575            return Err(crate::error::YamlError::Parse {
576                message: first_error.message.clone(),
577                line: Some(lc.line),
578                column: Some(lc.column),
579            });
580        }
581
582        let mut docs = parsed.tree().documents();
583        let first = docs.next().unwrap_or_default();
584
585        if docs.next().is_some() {
586            return Err(crate::error::YamlError::InvalidOperation {
587                operation: "Document::from_str".to_string(),
588                reason: "Input contains multiple YAML documents. Use YamlFile::from_str() for multi-document YAML.".to_string(),
589            });
590        }
591
592        Ok(first)
593    }
594}
595
596impl AsYaml for Document {
597    fn as_node(&self) -> Option<&SyntaxNode> {
598        Some(&self.0)
599    }
600
601    fn kind(&self) -> YamlKind {
602        YamlKind::Document
603    }
604
605    fn build_content(
606        &self,
607        builder: &mut rowan::GreenNodeBuilder,
608        _indent: usize,
609        _flow_context: bool,
610    ) -> bool {
611        crate::as_yaml::copy_node_content(builder, &self.0);
612        self.0
613            .last_token()
614            .map(|t| t.kind() == SyntaxKind::NEWLINE)
615            .unwrap_or(false)
616    }
617
618    fn is_inline(&self) -> bool {
619        // Documents are never inline
620        false
621    }
622}
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use crate::builder::{MappingBuilder, SequenceBuilder};
627    use crate::yaml::YamlFile;
628    use std::str::FromStr;
629
630    #[test]
631    fn test_document_stream_features() {
632        // Test 1: Multi-document with end markers
633        let yaml1 = "---\ndoc1: first\n---\ndoc2: second\n...\n";
634        let parsed1 = YamlFile::from_str(yaml1).unwrap();
635        assert_eq!(parsed1.documents().count(), 2);
636        assert_eq!(parsed1.to_string(), yaml1);
637
638        // Test 2: Single document with explicit markers
639        let yaml2 = "---\nkey: value\n...\n";
640        let parsed2 = YamlFile::from_str(yaml2).unwrap();
641        assert_eq!(parsed2.documents().count(), 1);
642        assert_eq!(parsed2.to_string(), yaml2);
643
644        // Test 3: Document with only end marker
645        let yaml3 = "key: value\n...\n";
646        let parsed3 = YamlFile::from_str(yaml3).unwrap();
647        assert_eq!(parsed3.documents().count(), 1);
648        assert_eq!(parsed3.to_string(), yaml3);
649    }
650    #[test]
651    fn test_document_level_directives() {
652        // Test document-level directives with multi-document stream
653        let yaml = "%YAML 1.2\n%TAG ! tag:example.com,2000:app/\n---\nfirst: doc\n...\n%YAML 1.2\n---\nsecond: doc\n...\n";
654        let parsed = YamlFile::from_str(yaml).unwrap();
655        assert_eq!(parsed.documents().count(), 2);
656        assert_eq!(parsed.to_string(), yaml);
657    }
658    #[test]
659    fn test_document_schema_validation_api() {
660        // Test the new Document API methods for schema validation
661
662        // JSON-compatible document
663        let json_yaml = r#"
664name: "John"
665age: 30
666active: true
667items:
668  - "apple"
669  - 42
670  - true
671"#;
672        let doc = YamlFile::from_str(json_yaml).unwrap().document().unwrap();
673
674        // Test JSON schema validation - should pass
675        assert!(
676            crate::schema::SchemaValidator::json()
677                .validate(&doc)
678                .is_ok(),
679            "JSON-compatible document should pass JSON validation"
680        );
681
682        // Test Core schema validation - should pass
683        assert!(
684            crate::schema::SchemaValidator::core()
685                .validate(&doc)
686                .is_ok(),
687            "Valid document should pass Core validation"
688        );
689
690        // Test Failsafe schema validation - should fail due to numbers and booleans (in strict mode)
691        // Note: Non-strict failsafe might allow coercion, so test strict mode
692        let failsafe_strict = crate::schema::SchemaValidator::failsafe().strict();
693        assert!(
694            doc.validate_schema(&failsafe_strict).is_err(),
695            "Document with numbers and booleans should fail strict Failsafe validation"
696        );
697
698        // YAML-specific document
699        let yaml_specific = r#"
700name: "Test"
701created: 2023-12-25T10:30:45Z
702pattern: !!regex '\d+'
703data: !!binary "SGVsbG8="
704"#;
705        let yaml_doc = YamlFile::from_str(yaml_specific)
706            .unwrap()
707            .document()
708            .unwrap();
709
710        // Test Core schema - should pass
711        assert!(
712            crate::schema::SchemaValidator::core()
713                .validate(&yaml_doc)
714                .is_ok(),
715            "YAML-specific types should pass Core validation"
716        );
717
718        // Test JSON schema - should fail due to timestamp, regex, binary
719        assert!(
720            crate::schema::SchemaValidator::json()
721                .validate(&yaml_doc)
722                .is_err(),
723            "YAML-specific types should fail JSON validation"
724        );
725
726        // Test Failsafe schema - should fail
727        assert!(
728            crate::schema::SchemaValidator::failsafe()
729                .validate(&yaml_doc)
730                .is_err(),
731            "YAML-specific types should fail Failsafe validation"
732        );
733
734        // String-only document
735        let string_only = r#"
736name: hello
737message: world
738items:
739  - apple
740  - banana
741nested:
742  key: value
743"#;
744        let str_doc = YamlFile::from_str(string_only).unwrap().document().unwrap();
745
746        // All schemas should pass (strings are allowed in all schemas)
747        assert!(
748            crate::schema::SchemaValidator::failsafe()
749                .validate(&str_doc)
750                .is_ok(),
751            "String-only document should pass Failsafe validation"
752        );
753        assert!(
754            crate::schema::SchemaValidator::json()
755                .validate(&str_doc)
756                .is_ok(),
757            "String-only document should pass JSON validation"
758        );
759        assert!(
760            crate::schema::SchemaValidator::core()
761                .validate(&str_doc)
762                .is_ok(),
763            "String-only document should pass Core validation"
764        );
765    }
766    #[test]
767    fn test_document_set_preserves_position() {
768        // Test that Document::set() (not just Mapping::set()) preserves position
769        let yaml = r#"Name: original
770Version: 1.0
771Author: Someone
772"#;
773        let parsed = YamlFile::from_str(yaml).unwrap();
774        let doc = parsed.document().expect("Should have a document");
775
776        // Update Version - should stay in middle
777        doc.set("Version", 2.0);
778
779        let output = doc.to_string();
780        let expected = r#"Name: original
781Version: 2.0
782Author: Someone
783"#;
784        assert_eq!(output, expected);
785    }
786    #[test]
787    fn test_document_schema_coercion_api() {
788        // Test the coercion API
789        let coercion_yaml = r#"
790count: "42"
791enabled: "true"
792rate: "3.14"
793items:
794  - "100"
795  - "false"
796"#;
797        let doc = YamlFile::from_str(coercion_yaml)
798            .unwrap()
799            .document()
800            .unwrap();
801        let json_validator = crate::schema::SchemaValidator::json();
802
803        // Test coercion - should pass because strings can be coerced to numbers/booleans
804        assert!(
805            json_validator.can_coerce(&doc).is_ok(),
806            "Strings that look like numbers/booleans should be coercible to JSON types"
807        );
808
809        // Test with non-coercible types
810        let non_coercible = r#"
811timestamp: !!timestamp "2023-01-01"
812pattern: !!regex '\d+'
813"#;
814        let non_coer_doc = YamlFile::from_str(non_coercible)
815            .unwrap()
816            .document()
817            .unwrap();
818
819        // Should fail coercion to JSON schema
820        assert!(
821            json_validator.can_coerce(&non_coer_doc).is_err(),
822            "YAML-specific types should not be coercible to JSON schema"
823        );
824    }
825    #[test]
826    fn test_document_schema_validation_errors() {
827        // Test that error messages contain useful path information
828        let nested_yaml = r#"
829users:
830  - name: "Alice"
831    age: 25
832    metadata:
833      created: !!timestamp "2023-01-01"
834      active: true
835  - name: "Bob"
836    score: 95.5
837"#;
838        let doc = YamlFile::from_str(nested_yaml).unwrap().document().unwrap();
839
840        // Test Failsafe validation with detailed error checking (strict mode)
841        let failsafe_strict = crate::schema::SchemaValidator::failsafe().strict();
842        let failsafe_result = doc.validate_schema(&failsafe_strict);
843        assert!(
844            failsafe_result.is_err(),
845            "Nested document with numbers should fail strict Failsafe validation"
846        );
847
848        let errors = failsafe_result.unwrap_err();
849        assert!(!errors.is_empty());
850
851        // Check that all errors have path information
852        for error in &errors {
853            assert!(!error.path.is_empty(), "Error should have path: {}", error);
854        }
855
856        // Test JSON validation
857        let json_result = crate::schema::SchemaValidator::json().validate(&doc);
858        assert!(
859            json_result.is_err(),
860            "Document with timestamp should fail JSON validation"
861        );
862
863        let json_errors = json_result.unwrap_err();
864        assert!(!json_errors.is_empty());
865        // Verify at least one error is from json schema validation
866        assert!(
867            json_errors.iter().any(|e| e.schema_name == "json"),
868            "Should have JSON schema validation error"
869        );
870    }
871    #[test]
872    fn test_document_schema_validation_with_custom_validator() {
873        // Test using the general validate_schema method
874        let yaml = r#"
875name: "HelloWorld"
876count: 42
877active: true
878"#;
879        let doc = YamlFile::from_str(yaml).unwrap().document().unwrap();
880
881        // Create different validators and test
882        let json_validator = crate::schema::SchemaValidator::json();
883        let core_validator = crate::schema::SchemaValidator::core();
884
885        // Test with custom validators
886        assert!(
887            doc.validate_schema(&core_validator).is_ok(),
888            "Should pass Core validation"
889        );
890        assert!(
891            doc.validate_schema(&json_validator).is_ok(),
892            "Should pass JSON validation"
893        );
894        // Non-strict failsafe might allow coercion, so test strict mode
895        let failsafe_strict = crate::schema::SchemaValidator::failsafe().strict();
896        assert!(
897            doc.validate_schema(&failsafe_strict).is_err(),
898            "Should fail strict Failsafe validation"
899        );
900
901        // Test strict mode
902        let strict_json = crate::schema::SchemaValidator::json().strict();
903        // The document contains integers and booleans which are valid in JSON schema
904        assert!(
905            doc.validate_schema(&strict_json).is_ok(),
906            "Should pass strict JSON validation (integers and booleans are JSON-compatible)"
907        );
908
909        let strict_failsafe = crate::schema::SchemaValidator::failsafe().strict();
910        assert!(
911            doc.validate_schema(&strict_failsafe).is_err(),
912            "Should fail strict Failsafe validation"
913        );
914    }
915    #[test]
916    fn test_document_level_insertion_with_complex_types() {
917        // Test the Document-level API with complex types separately to avoid chaining issues
918
919        // Test Document.insert_after with sequence
920        let doc1 = Document::new();
921        doc1.set("name", "project");
922        let features = SequenceBuilder::new()
923            .item("auth")
924            .item("api")
925            .item("web")
926            .build_document()
927            .as_sequence()
928            .unwrap();
929        let success = doc1.insert_after("name", "features", features);
930        assert!(success);
931        let output1 = doc1.to_string();
932        assert_eq!(
933            output1,
934            "---\nname: project\nfeatures:\n  - auth\n  - api\n  - web\n"
935        );
936
937        // Test Document.insert_before with mapping
938        let doc2 = Document::new();
939        doc2.set("name", "project");
940        doc2.set("version", "1.0.0");
941        let database = MappingBuilder::new()
942            .pair("host", "localhost")
943            .pair("port", 5432)
944            .build_document()
945            .as_mapping()
946            .unwrap();
947        let success = doc2.insert_before("version", "database", database);
948        assert!(success);
949        let output2 = doc2.to_string();
950        assert_eq!(
951            output2,
952            "---\nname: project\ndatabase:\n  host: localhost\n  port: 5432\nversion: 1.0.0\n"
953        );
954
955        // Test Document.insert_at_index with set
956        let doc3 = Document::new();
957        doc3.set("name", "project");
958        // TODO: migrate away from YamlValue once !!set has a non-YamlValue AsYaml impl
959        #[allow(clippy::disallowed_types)]
960        let tag_set = {
961            use crate::value::YamlValue;
962            let mut tags = std::collections::BTreeSet::new();
963            tags.insert("production".to_string());
964            tags.insert("database".to_string());
965            YamlValue::from_set(tags)
966        };
967        doc3.insert_at_index(1, "tags", tag_set);
968        let output3 = doc3.to_string();
969        assert_eq!(
970            output3,
971            "---\nname: project\ntags: !!set\n  database: null\n  production: null\n"
972        );
973
974        // Verify all are valid YAML by parsing
975        assert!(
976            YamlFile::from_str(&output1).is_ok(),
977            "Sequence output should be valid YAML"
978        );
979        assert!(
980            YamlFile::from_str(&output2).is_ok(),
981            "Mapping output should be valid YAML"
982        );
983        assert!(
984            YamlFile::from_str(&output3).is_ok(),
985            "Set output should be valid YAML"
986        );
987    }
988
989    #[test]
990    fn test_document_api_usage() -> crate::error::YamlResult<()> {
991        // Create a new document
992        let doc = Document::new();
993
994        // Check and modify fields
995        assert!(!doc.contains_key("Repository"));
996        doc.set("Repository", "https://github.com/user/repo.git");
997        assert!(doc.contains_key("Repository"));
998
999        // Test get_string
1000        assert_eq!(
1001            doc.get_string("Repository"),
1002            Some("https://github.com/user/repo.git".to_string())
1003        );
1004
1005        // Test is_empty
1006        assert!(!doc.is_empty());
1007
1008        // Test keys
1009        let keys: Vec<_> = doc.keys().collect();
1010        assert_eq!(keys.len(), 1);
1011
1012        // Test remove
1013        assert!(doc.remove("Repository").is_some());
1014        assert!(!doc.contains_key("Repository"));
1015        assert!(doc.is_empty());
1016
1017        Ok(())
1018    }
1019
1020    #[test]
1021    fn test_field_ordering() {
1022        let doc = Document::new();
1023
1024        // Add fields in random order
1025        doc.set("Repository-Browse", "https://github.com/user/repo");
1026        doc.set("Name", "MyProject");
1027        doc.set("Bug-Database", "https://github.com/user/repo/issues");
1028        doc.set("Repository", "https://github.com/user/repo.git");
1029
1030        // Reorder fields
1031        doc.reorder_fields(["Name", "Bug-Database", "Repository", "Repository-Browse"]);
1032
1033        // Check that fields are in the expected order
1034        let keys: Vec<_> = doc.keys().collect();
1035        assert_eq!(keys.len(), 4);
1036        assert_eq!(
1037            keys[0].as_scalar().map(|s| s.as_string()),
1038            Some("Name".to_string())
1039        );
1040        assert_eq!(
1041            keys[1].as_scalar().map(|s| s.as_string()),
1042            Some("Bug-Database".to_string())
1043        );
1044        assert_eq!(
1045            keys[2].as_scalar().map(|s| s.as_string()),
1046            Some("Repository".to_string())
1047        );
1048        assert_eq!(
1049            keys[3].as_scalar().map(|s| s.as_string()),
1050            Some("Repository-Browse".to_string())
1051        );
1052    }
1053
1054    #[test]
1055    fn test_array_detection() {
1056        use crate::scalar::ScalarValue;
1057
1058        // Create a test with array values using set_value
1059        let doc = Document::new();
1060
1061        // Set an array value
1062        let array_value = SequenceBuilder::new()
1063            .item(ScalarValue::string("https://github.com/user/repo.git"))
1064            .item(ScalarValue::string("https://gitlab.com/user/repo.git"))
1065            .build_document()
1066            .as_sequence()
1067            .unwrap();
1068        doc.set("Repository", &array_value);
1069
1070        // Test array detection
1071        assert!(doc.is_sequence("Repository"));
1072        // The sequence is inserted but getting the first element may not work as expected
1073        // due to how the sequence is constructed. Let's just verify the sequence exists.
1074        assert!(doc.get_sequence("Repository").is_some());
1075    }
1076
1077    #[test]
1078    fn test_file_io() -> crate::error::YamlResult<()> {
1079        use std::fs;
1080
1081        // Create a test file path
1082        let test_path = "/tmp/test_yaml_edit.yaml";
1083
1084        // Create and save a document
1085        let doc = Document::new();
1086        doc.set("Name", "TestProject");
1087        doc.set("Repository", "https://example.com/repo.git");
1088
1089        doc.to_file(test_path)?;
1090
1091        // Load it back
1092        let loaded_doc = Document::from_file(test_path)?;
1093
1094        assert_eq!(
1095            loaded_doc.get_string("Name"),
1096            Some("TestProject".to_string())
1097        );
1098        assert_eq!(
1099            loaded_doc.get_string("Repository"),
1100            Some("https://example.com/repo.git".to_string())
1101        );
1102
1103        // Clean up
1104        let _ = fs::remove_file(test_path);
1105
1106        Ok(())
1107    }
1108
1109    #[test]
1110    fn test_document_from_str_single_document() {
1111        // Test parsing a single-document YAML
1112        let yaml = "key: value\nport: 8080";
1113        let doc = Document::from_str(yaml).unwrap();
1114
1115        assert_eq!(doc.get_string("key"), Some("value".to_string()));
1116        assert!(doc.contains_key("port"));
1117    }
1118
1119    #[test]
1120    fn test_document_from_str_multiple_documents_error() {
1121        // Test that multiple documents return an error
1122        let yaml = "---\nkey: value\n---\nother: data";
1123        let result = Document::from_str(yaml);
1124
1125        assert!(result.is_err());
1126        let err = result.unwrap_err();
1127        match err {
1128            crate::error::YamlError::InvalidOperation { operation, reason } => {
1129                assert_eq!(operation, "Document::from_str");
1130                assert_eq!(
1131                    reason,
1132                    "Input contains multiple YAML documents. Use YamlFile::from_str() for multi-document YAML."
1133                );
1134            }
1135            _ => panic!("Expected InvalidOperation error, got {:?}", err),
1136        }
1137    }
1138
1139    #[test]
1140    fn test_document_from_str_empty() {
1141        // Test parsing an empty document
1142        let yaml = "";
1143        let doc = Document::from_str(yaml).unwrap();
1144
1145        // Empty document should be valid
1146        assert!(doc.is_empty());
1147    }
1148
1149    #[test]
1150    fn test_document_from_str_bare_document() {
1151        // Test parsing without explicit document markers
1152        let yaml = "name: test\nversion: 1.0";
1153        let doc = Document::from_str(yaml).unwrap();
1154
1155        assert_eq!(doc.get_string("name"), Some("test".to_string()));
1156        assert_eq!(doc.get_string("version"), Some("1.0".to_string()));
1157    }
1158
1159    #[test]
1160    fn test_document_from_str_with_explicit_marker() {
1161        // Test parsing with explicit --- marker
1162        let yaml = "---\nkey: value";
1163        let doc = Document::from_str(yaml).unwrap();
1164
1165        assert_eq!(doc.get_string("key"), Some("value".to_string()));
1166    }
1167
1168    #[test]
1169    fn test_document_from_str_complex_structure() {
1170        // Test parsing complex nested structure
1171        let yaml = r#"
1172database:
1173  host: localhost
1174  port: 5432
1175  credentials:
1176    username: admin
1177    password: secret
1178features:
1179  - auth
1180  - logging
1181  - metrics
1182"#;
1183        let doc = Document::from_str(yaml).unwrap();
1184
1185        // Verify we can access the structure
1186        assert!(doc.contains_key("database"));
1187        assert!(doc.contains_key("features"));
1188
1189        // Get nested mapping
1190        let db = doc.get("database").unwrap();
1191        if let Some(db_map) = db.as_mapping() {
1192            assert!(db_map.contains_key("host"));
1193        } else {
1194            panic!("Expected mapping for database");
1195        }
1196    }
1197
1198    #[test]
1199    fn test_is_sequence_block() {
1200        let doc = Document::from_str("tags:\n  - alpha\n  - beta\n  - gamma").unwrap();
1201        assert!(doc.is_sequence("tags"));
1202        assert_eq!(
1203            doc.get_sequence("tags")
1204                .and_then(|s| s.get(0))
1205                .and_then(|v| v.as_scalar().map(|s| s.as_string())),
1206            Some("alpha".to_string())
1207        );
1208    }
1209
1210    #[test]
1211    fn test_is_sequence_flow() {
1212        let doc = Document::from_str("tags: [alpha, beta, gamma]").unwrap();
1213        assert!(doc.is_sequence("tags"));
1214        assert_eq!(
1215            doc.get_sequence("tags")
1216                .and_then(|s| s.get(0))
1217                .and_then(|v| v.as_scalar().map(|s| s.as_string())),
1218            Some("alpha".to_string())
1219        );
1220    }
1221
1222    #[test]
1223    fn test_sequence_first_element_flow_quoted_with_comma() {
1224        // Previously the text-splitting approach broke on commas inside quoted values
1225        let doc = Document::from_str("tags: [\"hello, world\", beta]").unwrap();
1226        assert_eq!(
1227            doc.get_sequence("tags")
1228                .and_then(|s| s.get(0))
1229                .and_then(|v| v.as_scalar().map(|s| s.as_string())),
1230            Some("hello, world".to_string())
1231        );
1232    }
1233
1234    #[test]
1235    fn test_is_sequence_missing_key() {
1236        let doc = Document::from_str("name: test").unwrap();
1237        assert!(!doc.is_sequence("tags"));
1238    }
1239
1240    #[test]
1241    fn test_is_sequence_not_sequence() {
1242        let doc = Document::from_str("name: test").unwrap();
1243        assert!(!doc.is_sequence("name"));
1244    }
1245
1246    #[test]
1247    fn test_is_sequence_empty_sequence() {
1248        let doc = Document::from_str("tags: []").unwrap();
1249        assert!(doc.is_sequence("tags"));
1250        assert_eq!(doc.get_sequence("tags").map(|s| s.len()), Some(0));
1251    }
1252
1253    #[test]
1254    fn test_get_string_plain_scalar() {
1255        let doc = Document::from_str("key: hello").unwrap();
1256        assert_eq!(doc.get_string("key"), Some("hello".to_string()));
1257    }
1258
1259    #[test]
1260    fn test_get_string_double_quoted_with_escapes() {
1261        let doc = Document::from_str(r#"key: "hello \"world\"""#).unwrap();
1262        assert_eq!(doc.get_string("key"), Some(r#"hello "world""#.to_string()));
1263    }
1264
1265    #[test]
1266    fn test_get_string_single_quoted() {
1267        let doc = Document::from_str("key: 'it''s fine'").unwrap();
1268        assert_eq!(doc.get_string("key"), Some("it's fine".to_string()));
1269    }
1270
1271    #[test]
1272    fn test_get_string_missing_key() {
1273        let doc = Document::from_str("other: value").unwrap();
1274        assert_eq!(doc.get_string("key"), None);
1275    }
1276
1277    #[test]
1278    fn test_get_string_sequence_value_returns_none() {
1279        let doc = Document::from_str("key:\n  - a\n  - b").unwrap();
1280        assert_eq!(doc.get_string("key"), None);
1281    }
1282
1283    #[test]
1284    fn test_get_string_mapping_value_returns_none() {
1285        let doc = Document::from_str("key:\n  nested: value").unwrap();
1286        assert_eq!(doc.get_string("key"), None);
1287    }
1288
1289    #[test]
1290    fn test_insert_after_preserves_newline() {
1291        // Test with the examples from the bug report
1292        let yaml = "---\nBug-Database: https://github.com/example/example/issues\nBug-Submit: https://github.com/example/example/issues/new\n";
1293        let yaml_obj = YamlFile::from_str(yaml).unwrap();
1294
1295        // For now, test using Document directly since YamlFile::insert_after was removed
1296        if let Some(doc) = yaml_obj.document() {
1297            let result = doc.insert_after(
1298                "Bug-Submit",
1299                "Repository",
1300                "https://github.com/example/example.git",
1301            );
1302            assert!(result, "insert_after should return true when key is found");
1303
1304            // Check the document output directly
1305            let output = doc.to_string();
1306
1307            let expected = "---
1308Bug-Database: https://github.com/example/example/issues
1309Bug-Submit: https://github.com/example/example/issues/new
1310Repository: https://github.com/example/example.git
1311";
1312            assert_eq!(output, expected);
1313        }
1314    }
1315
1316    #[test]
1317    fn test_insert_after_without_trailing_newline() {
1318        // Test the specific bug case - YAML without trailing newline
1319        let yaml = "---\nBug-Database: https://github.com/example/example/issues\nBug-Submit: https://github.com/example/example/issues/new";
1320        let yaml_obj = YamlFile::from_str(yaml).unwrap();
1321
1322        if let Some(doc) = yaml_obj.document() {
1323            let result = doc.insert_after(
1324                "Bug-Submit",
1325                "Repository",
1326                "https://github.com/example/example.git",
1327            );
1328            assert!(result, "insert_after should return true when key is found");
1329
1330            let output = doc.to_string();
1331
1332            let expected = "---
1333Bug-Database: https://github.com/example/example/issues
1334Bug-Submit: https://github.com/example/example/issues/new
1335Repository: https://github.com/example/example.git
1336";
1337            assert_eq!(output, expected);
1338        }
1339    }
1340}