jsonschema_annotator/annotator/
toml.rs1use toml_edit::{DocumentMut, Item, Table};
2
3use super::{Annotator, AnnotatorConfig, ExistingCommentBehavior};
4use crate::error::{AnnotatorError, AnnotatorErrorKind, Error};
5use crate::schema::{Annotation, AnnotationMap};
6
7pub struct TomlAnnotator {
9 config: AnnotatorConfig,
10}
11
12impl TomlAnnotator {
13 pub fn new(config: AnnotatorConfig) -> Self {
14 Self { config }
15 }
16
17 fn format_comment(&self, annotation: &Annotation) -> Option<String> {
18 let mut lines = Vec::new();
19
20 if self.config.include_title {
21 if let Some(title) = &annotation.title {
22 lines.push(format!("# {}", title));
23 }
24 }
25
26 if self.config.include_description {
27 if let Some(desc) = &annotation.description {
28 let width = self.config.max_line_width.unwrap_or(78);
29 for line in textwrap::wrap(desc, width.saturating_sub(2)) {
30 lines.push(format!("# {}", line));
31 }
32 }
33 }
34
35 if self.config.include_default {
36 if let Some(default) = &annotation.default {
37 lines.push(format!("# Default: {}", default));
38 }
39 }
40
41 if lines.is_empty() {
42 None
43 } else {
44 Some(lines.join("\n") + "\n")
46 }
47 }
48
49 fn annotate_table(
50 &self,
51 table: &mut Table,
52 path: &[String],
53 annotations: &AnnotationMap,
54 ) {
55 let keys: Vec<String> = table.iter().map(|(k, _)| (*k).to_string()).collect();
58
59 for key_string in keys {
60 let mut current_path = path.to_vec();
61 current_path.push(key_string.clone());
62 let path_string = current_path.join(".");
63
64 if let Some((mut key, item)) = table.get_key_value_mut(&key_string) {
66 match item {
68 Item::Table(nested) => {
69 if let Some(ann) = annotations.get(&path_string) {
71 if let Some(comment) = self.format_comment(ann) {
72 let decor = nested.decor_mut();
73 let existing = decor.prefix().map(|s| s.as_str().unwrap_or("")).unwrap_or("");
74 let has_existing = existing.trim().starts_with('#');
75
76 let new_prefix = match self.config.existing_comments {
77 ExistingCommentBehavior::Skip if has_existing => None,
78 ExistingCommentBehavior::Prepend if has_existing => {
79 Some(format!("{}{}", comment, existing))
80 }
81 ExistingCommentBehavior::Append if has_existing => {
82 Some(format!("{}{}", existing, comment))
83 }
84 _ => Some(comment), };
86
87 if let Some(prefix) = new_prefix {
88 decor.set_prefix(prefix);
89 }
90 }
91 }
92 self.annotate_table(nested, ¤t_path, annotations);
94 }
95 Item::Value(toml_edit::Value::InlineTable(_)) => {
96 }
98 _ => {
99 if let Some(ann) = annotations.get(&path_string) {
101 if let Some(comment) = self.format_comment(ann) {
102 let decor = key.leaf_decor_mut();
103 let existing = decor.prefix().map(|s| s.as_str().unwrap_or("")).unwrap_or("");
104 let has_existing = existing.trim().starts_with('#');
105
106 let new_prefix = match self.config.existing_comments {
107 ExistingCommentBehavior::Skip if has_existing => None,
108 ExistingCommentBehavior::Prepend if has_existing => {
109 Some(format!("{}{}", comment, existing))
110 }
111 ExistingCommentBehavior::Append if has_existing => {
112 Some(format!("{}{}", existing, comment))
113 }
114 _ => Some(comment), };
116
117 if let Some(prefix) = new_prefix {
118 decor.set_prefix(prefix);
119 }
120 }
121 }
122 }
123 }
124 }
125 }
126 }
127}
128
129impl Annotator for TomlAnnotator {
130 fn annotate(
131 &self,
132 content: &str,
133 annotations: &AnnotationMap,
134 ) -> Result<String, AnnotatorError> {
135 let mut doc: DocumentMut = content
136 .parse()
137 .map_err(|e| Error::new(AnnotatorErrorKind::Parse).with_source(e))?;
138
139 self.annotate_table(doc.as_table_mut(), &Vec::new(), annotations);
140
141 Ok(doc.to_string())
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::schema::Annotation;
149 use insta::assert_snapshot;
150
151 fn make_annotations(items: &[(&str, Option<&str>, Option<&str>)]) -> AnnotationMap {
152 let mut map = AnnotationMap::new();
153 for (path, title, desc) in items {
154 let mut ann = Annotation::new(*path);
155 if let Some(t) = title {
156 ann = ann.with_title(*t);
157 }
158 if let Some(d) = desc {
159 ann = ann.with_description(*d);
160 }
161 map.insert(ann);
162 }
163 map
164 }
165
166 #[test]
167 fn test_simple_annotation() {
168 let content = "port = 8080\n";
169 let annotations = make_annotations(&[("port", Some("Port"), Some("Server port number"))]);
170
171 let annotator = TomlAnnotator::new(AnnotatorConfig::default());
172 let result = annotator.annotate(content, &annotations).unwrap();
173
174 assert_snapshot!(result);
175 }
176
177 #[test]
178 fn test_nested_table() {
179 let content = r#"[server]
180port = 8080
181host = "localhost"
182"#;
183 let annotations = make_annotations(&[
184 ("server", Some("Server Config"), None),
185 ("server.port", Some("Port"), Some("The port to listen on")),
186 ("server.host", Some("Host"), None),
187 ]);
188
189 let annotator = TomlAnnotator::new(AnnotatorConfig::default());
190 let result = annotator.annotate(content, &annotations).unwrap();
191
192 assert_snapshot!(result);
193 }
194
195 #[test]
196 fn test_title_only() {
197 let content = "name = \"test\"\n";
198 let annotations = make_annotations(&[("name", Some("Name"), Some("Full description"))]);
199
200 let annotator = TomlAnnotator::new(AnnotatorConfig::titles_only());
201 let result = annotator.annotate(content, &annotations).unwrap();
202
203 assert_snapshot!(result);
204 }
205
206 #[test]
207 fn test_description_only() {
208 let content = "name = \"test\"\n";
209 let annotations = make_annotations(&[("name", Some("Name"), Some("Full description"))]);
210
211 let annotator = TomlAnnotator::new(AnnotatorConfig::descriptions_only());
212 let result = annotator.annotate(content, &annotations).unwrap();
213
214 assert_snapshot!(result);
215 }
216
217 #[test]
218 fn test_preserve_existing_comments() {
219 let content = "# Existing comment\nport = 8080\n";
220 let annotations = make_annotations(&[("port", Some("Port"), None)]);
221
222 let annotator = TomlAnnotator::new(AnnotatorConfig::default());
223 let result = annotator.annotate(content, &annotations).unwrap();
224
225 assert_snapshot!(result);
226 }
227
228 #[test]
229 fn test_no_matching_annotations() {
230 let content = "name = \"test\"\nage = 30\n";
231 let annotations = make_annotations(&[("other", Some("Other"), None)]);
232
233 let annotator = TomlAnnotator::new(AnnotatorConfig::default());
234 let result = annotator.annotate(content, &annotations).unwrap();
235
236 assert_snapshot!(result);
237 }
238
239 #[test]
240 fn test_deeply_nested() {
241 let content = r#"[database]
242[database.connection]
243host = "localhost"
244port = 5432
245"#;
246 let annotations = make_annotations(&[
247 ("database", Some("Database"), None),
248 ("database.connection", Some("Connection Settings"), None),
249 ("database.connection.host", Some("Host"), Some("Database server hostname")),
250 ("database.connection.port", Some("Port"), None),
251 ]);
252
253 let annotator = TomlAnnotator::new(AnnotatorConfig::default());
254 let result = annotator.annotate(content, &annotations).unwrap();
255
256 assert_snapshot!(result);
257 }
258
259 #[test]
260 fn test_long_description_wrapping() {
261 let content = "name = \"test\"\n";
262 let long_desc = "This is a very long description that should be wrapped across multiple lines when the max line width is set to a reasonable value";
263 let annotations = make_annotations(&[("name", None, Some(long_desc))]);
264
265 let config = AnnotatorConfig {
266 max_line_width: Some(40),
267 ..Default::default()
268 };
269 let annotator = TomlAnnotator::new(config);
270 let result = annotator.annotate(content, &annotations).unwrap();
271
272 assert_snapshot!(result);
273 }
274
275 #[test]
276 fn test_skip_existing_comments() {
277 let content = "# Existing comment\nport = 8080\nhost = \"localhost\"\n";
278 let annotations = make_annotations(&[
279 ("port", Some("Port"), None),
280 ("host", Some("Host"), None),
281 ]);
282
283 let config = AnnotatorConfig {
284 existing_comments: ExistingCommentBehavior::Skip,
285 ..Default::default()
286 };
287 let annotator = TomlAnnotator::new(config);
288 let result = annotator.annotate(content, &annotations).unwrap();
289
290 assert_snapshot!(result);
292 }
293
294 #[test]
295 fn test_append_to_existing_comments() {
296 let content = "# Existing comment\nport = 8080\n";
297 let annotations = make_annotations(&[("port", Some("Port"), None)]);
298
299 let config = AnnotatorConfig {
300 existing_comments: ExistingCommentBehavior::Append,
301 ..Default::default()
302 };
303 let annotator = TomlAnnotator::new(config);
304 let result = annotator.annotate(content, &annotations).unwrap();
305
306 assert_snapshot!(result);
307 }
308
309 #[test]
310 fn test_replace_existing_comments() {
311 let content = "# Existing comment\nport = 8080\n";
312 let annotations = make_annotations(&[("port", Some("Port"), None)]);
313
314 let config = AnnotatorConfig {
315 existing_comments: ExistingCommentBehavior::Replace,
316 ..Default::default()
317 };
318 let annotator = TomlAnnotator::new(config);
319 let result = annotator.annotate(content, &annotations).unwrap();
320
321 assert_snapshot!(result);
322 }
323
324 #[test]
325 fn test_include_default_value() {
326 let content = "port = 8080\n";
327
328 let mut map = AnnotationMap::new();
329 map.insert(
330 Annotation::new("port")
331 .with_title("Port")
332 .with_description("The port number")
333 .with_default("3000"),
334 );
335
336 let config = AnnotatorConfig {
337 include_default: true,
338 ..Default::default()
339 };
340 let annotator = TomlAnnotator::new(config);
341 let result = annotator.annotate(content, &map).unwrap();
342
343 assert_snapshot!(result);
344 }
345
346 #[test]
347 fn test_default_value_disabled_by_default() {
348 let content = "port = 8080\n";
349
350 let mut map = AnnotationMap::new();
351 map.insert(
352 Annotation::new("port")
353 .with_title("Port")
354 .with_default("3000"),
355 );
356
357 let annotator = TomlAnnotator::new(AnnotatorConfig::default());
359 let result = annotator.annotate(content, &map).unwrap();
360
361 assert_snapshot!(result);
362 }
363}