jsonschema_annotator/schema/
annotation.rs

1use std::collections::HashMap;
2
3/// Annotation data extracted from a JSON Schema property
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct Annotation {
6    /// Dot-separated path (e.g., "server.port")
7    pub path: String,
8    /// Schema `title` field
9    pub title: Option<String>,
10    /// Schema `description` field
11    pub description: Option<String>,
12    /// Schema `default` field (as a string representation)
13    pub default: Option<String>,
14}
15
16impl Annotation {
17    /// Create a new annotation
18    pub fn new(path: impl Into<String>) -> Self {
19        Self {
20            path: path.into(),
21            title: None,
22            description: None,
23            default: None,
24        }
25    }
26
27    /// Set the title
28    pub fn with_title(mut self, title: impl Into<String>) -> Self {
29        self.title = Some(title.into());
30        self
31    }
32
33    /// Set the description
34    pub fn with_description(mut self, description: impl Into<String>) -> Self {
35        self.description = Some(description.into());
36        self
37    }
38
39    /// Set the default value
40    pub fn with_default(mut self, default: impl Into<String>) -> Self {
41        self.default = Some(default.into());
42        self
43    }
44
45    /// Format as comment lines
46    pub fn to_comment_lines(&self, max_width: Option<usize>) -> Vec<String> {
47        let mut lines = Vec::new();
48
49        if let Some(title) = &self.title {
50            lines.push(format!("# {}", title));
51        }
52
53        if let Some(desc) = &self.description {
54            let width = max_width.unwrap_or(78);
55            for line in textwrap::wrap(desc, width) {
56                lines.push(format!("# {}", line));
57            }
58        }
59
60        lines
61    }
62
63    /// Check if this annotation has any content
64    pub fn is_empty(&self) -> bool {
65        self.title.is_none() && self.description.is_none() && self.default.is_none()
66    }
67}
68
69/// Collection of annotations indexed by path
70#[derive(Debug, Clone, Default)]
71pub struct AnnotationMap {
72    inner: HashMap<String, Annotation>,
73}
74
75impl AnnotationMap {
76    /// Create a new empty annotation map
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Get an annotation by path
82    pub fn get(&self, path: &str) -> Option<&Annotation> {
83        self.inner.get(path)
84    }
85
86    /// Insert an annotation
87    pub fn insert(&mut self, annotation: Annotation) {
88        if !annotation.is_empty() {
89            self.inner.insert(annotation.path.clone(), annotation);
90        }
91    }
92
93    /// Iterate over all annotations
94    pub fn iter(&self) -> impl Iterator<Item = (&String, &Annotation)> {
95        self.inner.iter()
96    }
97
98    /// Get the number of annotations
99    pub fn len(&self) -> usize {
100        self.inner.len()
101    }
102
103    /// Check if the map is empty
104    pub fn is_empty(&self) -> bool {
105        self.inner.is_empty()
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn test_annotation_builder() {
115        let ann = Annotation::new("server.port")
116            .with_title("Port")
117            .with_description("The server port number");
118
119        assert_eq!(ann.path, "server.port");
120        assert_eq!(ann.title, Some("Port".to_string()));
121        assert_eq!(ann.description, Some("The server port number".to_string()));
122    }
123
124    #[test]
125    fn test_annotation_to_comment_lines() {
126        let ann = Annotation::new("test")
127            .with_title("Title")
128            .with_description("Description");
129
130        let lines = ann.to_comment_lines(None);
131        assert_eq!(lines, vec!["# Title", "# Description"]);
132    }
133
134    #[test]
135    fn test_annotation_to_comment_lines_wrapping() {
136        let ann = Annotation::new("test")
137            .with_description("This is a very long description that should be wrapped");
138
139        let lines = ann.to_comment_lines(Some(30));
140        assert!(lines.len() > 1);
141        for line in &lines {
142            assert!(line.len() <= 32); // 30 + "# " prefix
143        }
144    }
145
146    #[test]
147    fn test_annotation_map() {
148        let mut map = AnnotationMap::new();
149
150        map.insert(Annotation::new("a").with_title("A"));
151        map.insert(Annotation::new("b").with_title("B"));
152
153        assert_eq!(map.len(), 2);
154        assert_eq!(map.get("a").unwrap().title, Some("A".to_string()));
155        assert_eq!(map.get("b").unwrap().title, Some("B".to_string()));
156        assert!(map.get("c").is_none());
157    }
158
159    #[test]
160    fn test_empty_annotation_not_inserted() {
161        let mut map = AnnotationMap::new();
162        map.insert(Annotation::new("empty"));
163        assert!(map.is_empty());
164    }
165}