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