jsonschema_annotator/annotator/
yaml.rs

1use super::{Annotator, AnnotatorConfig};
2use crate::error::{AnnotatorError, AnnotatorErrorKind, Error};
3use crate::schema::{Annotation, AnnotationMap};
4
5/// YAML document annotator using string-based line injection
6///
7/// Since yaml-edit doesn't support comment injection, we use a string-based
8/// approach that tracks indentation to map lines to paths.
9pub struct YamlAnnotator {
10    config: AnnotatorConfig,
11}
12
13impl YamlAnnotator {
14    pub fn new(config: AnnotatorConfig) -> Self {
15        Self { config }
16    }
17
18    fn format_comment(&self, annotation: &Annotation, indent: usize) -> Option<String> {
19        let mut lines = Vec::new();
20        let indent_str = " ".repeat(indent);
21
22        if self.config.include_title {
23            if let Some(title) = &annotation.title {
24                lines.push(format!("{}# {}", indent_str, title));
25            }
26        }
27
28        if self.config.include_description {
29            if let Some(desc) = &annotation.description {
30                let width = self.config.max_line_width.unwrap_or(78).saturating_sub(indent + 2);
31                for line in textwrap::wrap(desc, width) {
32                    lines.push(format!("{}# {}", indent_str, line));
33                }
34            }
35        }
36
37        if lines.is_empty() {
38            None
39        } else {
40            Some(lines.join("\n"))
41        }
42    }
43
44    /// Build a map of line numbers to (path, indent) for YAML content
45    fn build_line_path_map(&self, content: &str) -> Vec<(usize, String, usize)> {
46        let mut result = Vec::new();
47        let mut path_stack: Vec<(String, usize)> = Vec::new();
48
49        for (line_num, line) in content.lines().enumerate() {
50            // Skip empty lines and comments
51            if line.trim().is_empty() || line.trim().starts_with('#') {
52                continue;
53            }
54
55            // Calculate indentation
56            let indent = line.len() - line.trim_start().len();
57
58            // Pop path components that are at same or deeper indentation
59            while let Some((_, prev_indent)) = path_stack.last() {
60                if indent <= *prev_indent {
61                    path_stack.pop();
62                } else {
63                    break;
64                }
65            }
66
67            // Extract key from line (handle "key:" and "key: value" formats)
68            if let Some(key) = extract_yaml_key(line) {
69                // Build current path
70                let path = if path_stack.is_empty() {
71                    key.clone()
72                } else {
73                    let parent_path: Vec<_> = path_stack.iter().map(|(k, _)| k.as_str()).collect();
74                    format!("{}.{}", parent_path.join("."), key)
75                };
76
77                result.push((line_num, path.clone(), indent));
78
79                // Check if this line starts a nested object (ends with ":" or has nested content)
80                if line.trim().ends_with(':') || is_mapping_start(line) {
81                    path_stack.push((key, indent));
82                }
83            }
84        }
85
86        result
87    }
88}
89
90/// Extract the key from a YAML line like "key: value" or "key:"
91fn extract_yaml_key(line: &str) -> Option<String> {
92    let trimmed = line.trim();
93
94    // Skip list items for now (lines starting with -)
95    if trimmed.starts_with('-') {
96        return None;
97    }
98
99    // Find the colon
100    let colon_pos = trimmed.find(':')?;
101    let key = trimmed[..colon_pos].trim();
102
103    // Skip if key is empty or quoted (complex keys)
104    if key.is_empty() {
105        return None;
106    }
107
108    Some(key.to_string())
109}
110
111/// Check if a line is a mapping start (key with no inline value)
112fn is_mapping_start(line: &str) -> bool {
113    let trimmed = line.trim();
114    if let Some(colon_pos) = trimmed.find(':') {
115        let after_colon = trimmed[colon_pos + 1..].trim();
116        after_colon.is_empty() || after_colon.starts_with('#')
117    } else {
118        false
119    }
120}
121
122impl Annotator for YamlAnnotator {
123    fn annotate(
124        &self,
125        content: &str,
126        annotations: &AnnotationMap,
127    ) -> Result<String, AnnotatorError> {
128        // Validate YAML syntax by attempting to parse
129        let _: serde_yaml::Value = serde_yaml::from_str(content)
130            .map_err(|e| Error::new(AnnotatorErrorKind::Parse).with_source(e))?;
131
132        let line_paths = self.build_line_path_map(content);
133
134        // Collect insertions: (line_num, comment, indent)
135        let mut insertions: Vec<(usize, String, usize)> = Vec::new();
136
137        for (line_num, path, indent) in &line_paths {
138            if let Some(ann) = annotations.get(path) {
139                if let Some(comment) = self.format_comment(ann, *indent) {
140                    insertions.push((*line_num, comment, *indent));
141                }
142            }
143        }
144
145        // Sort by line number descending to insert from bottom up
146        insertions.sort_by(|a, b| b.0.cmp(&a.0));
147
148        // Insert comments
149        let mut lines: Vec<String> = content.lines().map(String::from).collect();
150
151        for (line_num, comment, _indent) in insertions {
152            // Insert comment lines before the target line
153            let comment_lines: Vec<String> = comment.lines().map(String::from).collect();
154            for (i, comment_line) in comment_lines.into_iter().enumerate() {
155                lines.insert(line_num + i, comment_line);
156            }
157        }
158
159        // Preserve trailing newline if original had one
160        let mut result = lines.join("\n");
161        if content.ends_with('\n') {
162            result.push('\n');
163        }
164
165        Ok(result)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::schema::Annotation;
173    use insta::assert_snapshot;
174
175    fn make_annotations(items: &[(&str, Option<&str>, Option<&str>)]) -> AnnotationMap {
176        let mut map = AnnotationMap::new();
177        for (path, title, desc) in items {
178            let mut ann = Annotation::new(*path);
179            if let Some(t) = title {
180                ann = ann.with_title(*t);
181            }
182            if let Some(d) = desc {
183                ann = ann.with_description(*d);
184            }
185            map.insert(ann);
186        }
187        map
188    }
189
190    #[test]
191    fn test_simple_annotation() {
192        let content = "port: 8080\n";
193        let annotations = make_annotations(&[("port", Some("Port"), Some("Server port number"))]);
194
195        let annotator = YamlAnnotator::new(AnnotatorConfig::default());
196        let result = annotator.annotate(content, &annotations).unwrap();
197
198        assert_snapshot!(result);
199    }
200
201    #[test]
202    fn test_nested_mapping() {
203        let content = r#"server:
204  port: 8080
205  host: localhost
206"#;
207        let annotations = make_annotations(&[
208            ("server", Some("Server Config"), None),
209            ("server.port", Some("Port"), Some("The port to listen on")),
210            ("server.host", Some("Host"), None),
211        ]);
212
213        let annotator = YamlAnnotator::new(AnnotatorConfig::default());
214        let result = annotator.annotate(content, &annotations).unwrap();
215
216        assert_snapshot!(result);
217    }
218
219    #[test]
220    fn test_title_only() {
221        let content = "name: test\n";
222        let annotations = make_annotations(&[("name", Some("Name"), Some("Full description"))]);
223
224        let annotator = YamlAnnotator::new(AnnotatorConfig::titles_only());
225        let result = annotator.annotate(content, &annotations).unwrap();
226
227        assert_snapshot!(result);
228    }
229
230    #[test]
231    fn test_description_only() {
232        let content = "name: test\n";
233        let annotations = make_annotations(&[("name", Some("Name"), Some("Full description"))]);
234
235        let annotator = YamlAnnotator::new(AnnotatorConfig::descriptions_only());
236        let result = annotator.annotate(content, &annotations).unwrap();
237
238        assert_snapshot!(result);
239    }
240
241    #[test]
242    fn test_preserve_existing_comments() {
243        let content = "# Existing comment\nport: 8080\n";
244        let annotations = make_annotations(&[("port", Some("Port"), None)]);
245
246        let annotator = YamlAnnotator::new(AnnotatorConfig::default());
247        let result = annotator.annotate(content, &annotations).unwrap();
248
249        assert_snapshot!(result);
250    }
251
252    #[test]
253    fn test_no_matching_annotations() {
254        let content = "name: test\nage: 30\n";
255        let annotations = make_annotations(&[("other", Some("Other"), None)]);
256
257        let annotator = YamlAnnotator::new(AnnotatorConfig::default());
258        let result = annotator.annotate(content, &annotations).unwrap();
259
260        assert_snapshot!(result);
261    }
262
263    #[test]
264    fn test_deeply_nested() {
265        let content = r#"database:
266  connection:
267    host: localhost
268    port: 5432
269"#;
270        let annotations = make_annotations(&[
271            ("database", Some("Database"), None),
272            ("database.connection", Some("Connection Settings"), None),
273            ("database.connection.host", Some("Host"), Some("Database server hostname")),
274            ("database.connection.port", Some("Port"), None),
275        ]);
276
277        let annotator = YamlAnnotator::new(AnnotatorConfig::default());
278        let result = annotator.annotate(content, &annotations).unwrap();
279
280        assert_snapshot!(result);
281    }
282
283    #[test]
284    fn test_inline_values() {
285        let content = r#"server:
286  host: localhost
287  port: 8080
288  enabled: true
289"#;
290        let annotations = make_annotations(&[
291            ("server.host", Some("Hostname"), None),
292            ("server.port", Some("Port Number"), None),
293            ("server.enabled", Some("Enabled"), Some("Whether the server is enabled")),
294        ]);
295
296        let annotator = YamlAnnotator::new(AnnotatorConfig::default());
297        let result = annotator.annotate(content, &annotations).unwrap();
298
299        assert_snapshot!(result);
300    }
301}