Skip to main content

data_modelling_core/import/
sketch.rs

1//! Sketch importer
2//!
3//! Parses Excalidraw sketch YAML files (.sketch.yaml) and converts them to Sketch models.
4//! Also handles the sketch index file (sketches.yaml).
5
6use super::ImportError;
7use crate::models::sketch::{Sketch, SketchIndex};
8
9#[cfg(feature = "schema-validation")]
10use crate::validation::schema::validate_sketch_internal;
11
12/// Sketch importer for parsing Excalidraw sketch YAML files
13pub struct SketchImporter;
14
15impl SketchImporter {
16    /// Create a new Sketch importer instance
17    pub fn new() -> Self {
18        Self
19    }
20
21    /// Import a sketch from YAML content
22    ///
23    /// Optionally validates against the JSON schema if the `schema-validation` feature is enabled.
24    ///
25    /// # Arguments
26    ///
27    /// * `yaml_content` - Sketch YAML content as a string
28    ///
29    /// # Returns
30    ///
31    /// A `Sketch` parsed from the YAML content
32    ///
33    /// # Example
34    ///
35    /// ```rust
36    /// use data_modelling_core::import::sketch::SketchImporter;
37    ///
38    /// let importer = SketchImporter::new();
39    /// let yaml = r#"
40    /// id: 770e8400-e29b-41d4-a716-446655440001
41    /// number: 1
42    /// title: "Architecture Diagram"
43    /// sketchType: architecture
44    /// status: published
45    /// excalidrawData: "{}"
46    /// createdAt: "2024-01-15T10:00:00Z"
47    /// updatedAt: "2024-01-15T10:00:00Z"
48    /// "#;
49    /// let sketch = importer.import(yaml).unwrap();
50    /// assert_eq!(sketch.title, "Architecture Diagram");
51    /// ```
52    pub fn import(&self, yaml_content: &str) -> Result<Sketch, ImportError> {
53        // Validate against JSON Schema if feature is enabled
54        #[cfg(feature = "schema-validation")]
55        {
56            validate_sketch_internal(yaml_content).map_err(ImportError::ValidationError)?;
57        }
58
59        // Parse the YAML content
60        Sketch::from_yaml(yaml_content)
61            .map_err(|e| ImportError::ParseError(format!("Failed to parse sketch YAML: {}", e)))
62    }
63
64    /// Import a sketch without schema validation
65    ///
66    /// Use this when you want to skip schema validation for performance
67    /// or when importing from a trusted source.
68    ///
69    /// # Arguments
70    ///
71    /// * `yaml_content` - Sketch YAML content as a string
72    ///
73    /// # Returns
74    ///
75    /// A `Sketch` parsed from the YAML content
76    pub fn import_without_validation(&self, yaml_content: &str) -> Result<Sketch, ImportError> {
77        Sketch::from_yaml(yaml_content)
78            .map_err(|e| ImportError::ParseError(format!("Failed to parse sketch YAML: {}", e)))
79    }
80
81    /// Import a sketch index from YAML content
82    ///
83    /// # Arguments
84    ///
85    /// * `yaml_content` - Sketch index YAML content (sketches.yaml)
86    ///
87    /// # Returns
88    ///
89    /// A `SketchIndex` parsed from the YAML content
90    ///
91    /// # Example
92    ///
93    /// ```rust
94    /// use data_modelling_core::import::sketch::SketchImporter;
95    ///
96    /// let importer = SketchImporter::new();
97    /// let yaml = r#"
98    /// schemaVersion: "1.0"
99    /// sketches: []
100    /// nextNumber: 1
101    /// "#;
102    /// let index = importer.import_index(yaml).unwrap();
103    /// assert_eq!(index.next_number, 1);
104    /// ```
105    pub fn import_index(&self, yaml_content: &str) -> Result<SketchIndex, ImportError> {
106        SketchIndex::from_yaml(yaml_content).map_err(|e| {
107            ImportError::ParseError(format!("Failed to parse sketch index YAML: {}", e))
108        })
109    }
110
111    /// Import multiple sketches from a directory
112    ///
113    /// Loads all `.sketch.yaml` files from the specified directory.
114    ///
115    /// # Arguments
116    ///
117    /// * `dir_path` - Path to the directory containing sketch files
118    ///
119    /// # Returns
120    ///
121    /// A vector of parsed `Sketch` objects and any import errors
122    pub fn import_from_directory(
123        &self,
124        dir_path: &std::path::Path,
125    ) -> Result<(Vec<Sketch>, Vec<ImportError>), ImportError> {
126        let mut sketches = Vec::new();
127        let mut errors = Vec::new();
128
129        if !dir_path.exists() {
130            return Err(ImportError::IoError(format!(
131                "Directory does not exist: {}",
132                dir_path.display()
133            )));
134        }
135
136        if !dir_path.is_dir() {
137            return Err(ImportError::IoError(format!(
138                "Path is not a directory: {}",
139                dir_path.display()
140            )));
141        }
142
143        // Read all .sketch.yaml files
144        let entries = std::fs::read_dir(dir_path)
145            .map_err(|e| ImportError::IoError(format!("Failed to read directory: {}", e)))?;
146
147        for entry in entries.flatten() {
148            let path = entry.path();
149            if path.extension().and_then(|s| s.to_str()) == Some("yaml")
150                && path
151                    .file_name()
152                    .and_then(|s| s.to_str())
153                    .is_some_and(|name| name.ends_with(".sketch.yaml"))
154            {
155                match std::fs::read_to_string(&path) {
156                    Ok(content) => match self.import(&content) {
157                        Ok(sketch) => sketches.push(sketch),
158                        Err(e) => errors.push(ImportError::ParseError(format!(
159                            "Failed to import {}: {}",
160                            path.display(),
161                            e
162                        ))),
163                    },
164                    Err(e) => errors.push(ImportError::IoError(format!(
165                        "Failed to read {}: {}",
166                        path.display(),
167                        e
168                    ))),
169                }
170            }
171        }
172
173        // Sort sketches by number
174        sketches.sort_by(|a, b| a.number.cmp(&b.number));
175
176        Ok((sketches, errors))
177    }
178
179    /// Import sketches filtered by domain
180    ///
181    /// # Arguments
182    ///
183    /// * `dir_path` - Path to the directory containing sketch files
184    /// * `domain` - Domain to filter by
185    ///
186    /// # Returns
187    ///
188    /// A vector of parsed `Sketch` objects for the specified domain
189    pub fn import_by_domain(
190        &self,
191        dir_path: &std::path::Path,
192        domain: &str,
193    ) -> Result<(Vec<Sketch>, Vec<ImportError>), ImportError> {
194        let (sketches, errors) = self.import_from_directory(dir_path)?;
195
196        let filtered: Vec<Sketch> = sketches
197            .into_iter()
198            .filter(|s| s.domain.as_deref() == Some(domain))
199            .collect();
200
201        Ok((filtered, errors))
202    }
203}
204
205impl Default for SketchImporter {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_import_sketch() {
217        let importer = SketchImporter::new();
218        let yaml = r#"
219id: 770e8400-e29b-41d4-a716-446655440001
220number: 1
221title: "Architecture Diagram"
222sketchType: architecture
223status: published
224excalidrawData: "{}"
225createdAt: "2024-01-15T10:00:00Z"
226updatedAt: "2024-01-15T10:00:00Z"
227"#;
228        let result = importer.import_without_validation(yaml);
229        assert!(result.is_ok());
230        let sketch = result.unwrap();
231        assert_eq!(sketch.title, "Architecture Diagram");
232        assert_eq!(sketch.number, 1);
233    }
234
235    #[test]
236    fn test_import_sketch_index() {
237        let importer = SketchImporter::new();
238        let yaml = r#"
239schemaVersion: "1.0"
240sketches: []
241nextNumber: 1
242"#;
243        let result = importer.import_index(yaml);
244        assert!(result.is_ok());
245        let index = result.unwrap();
246        assert_eq!(index.next_number, 1);
247        assert_eq!(index.schema_version, "1.0");
248    }
249
250    #[test]
251    fn test_import_invalid_yaml() {
252        let importer = SketchImporter::new();
253        let yaml = "not: valid: yaml: at: all";
254        let result = importer.import_without_validation(yaml);
255        assert!(result.is_err());
256    }
257
258    #[test]
259    fn test_import_sketch_with_all_fields() {
260        let importer = SketchImporter::new();
261        let yaml = r#"
262id: 770e8400-e29b-41d4-a716-446655440001
263number: 1
264title: "Sales Domain Architecture"
265sketchType: architecture
266status: published
267domain: sales
268description: "High-level architecture diagram"
269excalidrawData: '{"elements":[]}'
270thumbnailPath: thumbnails/sketch-0001.png
271authors:
272  - architect@company.com
273tags:
274  - architecture
275  - sales
276createdAt: "2024-01-15T10:00:00Z"
277updatedAt: "2024-01-15T10:00:00Z"
278"#;
279        let result = importer.import_without_validation(yaml);
280        assert!(result.is_ok());
281        let sketch = result.unwrap();
282        assert_eq!(sketch.title, "Sales Domain Architecture");
283        assert_eq!(sketch.domain, Some("sales".to_string()));
284        assert_eq!(
285            sketch.thumbnail_path,
286            Some("thumbnails/sketch-0001.png".to_string())
287        );
288        assert_eq!(sketch.authors.len(), 1);
289    }
290}