Skip to main content

subcog/io/formats/
yaml.rs

1//! YAML format adapter for import/export.
2//!
3//! Supports YAML document streams (multiple documents separated by `---`).
4
5use crate::io::traits::{ExportSink, ExportableMemory, ImportSource, ImportedMemory};
6use crate::{Error, Result};
7use std::io::{BufRead, Write};
8
9/// YAML import source.
10///
11/// Reads YAML document streams where each document is a memory object.
12/// Documents are separated by `---` markers.
13pub struct YamlImportSource {
14    /// Pre-parsed memories from the YAML stream.
15    memories: Vec<ImportedMemory>,
16    /// Current index.
17    index: usize,
18}
19
20impl YamlImportSource {
21    /// Creates a new YAML import source.
22    ///
23    /// Parses all documents upfront since YAML requires full parsing.
24    ///
25    /// # Errors
26    ///
27    /// Returns an error if YAML parsing fails.
28    pub fn new<R: BufRead>(mut reader: R) -> Result<Self> {
29        let mut content = String::new();
30        reader
31            .read_to_string(&mut content)
32            .map_err(|e| Error::OperationFailed {
33                operation: "read_yaml".to_string(),
34                cause: e.to_string(),
35            })?;
36
37        if content.trim().is_empty() {
38            return Ok(Self {
39                memories: Vec::new(),
40                index: 0,
41            });
42        }
43
44        // Try parsing as a sequence first (array of memories)
45        if let Ok(memories) = serde_yaml_ng::from_str::<Vec<ImportedMemory>>(&content) {
46            return Ok(Self { memories, index: 0 });
47        }
48
49        // Try parsing as multi-document stream
50        let mut memories = Vec::new();
51        for (doc_index, document) in serde_yaml_ng::Deserializer::from_str(&content).enumerate() {
52            let memory: ImportedMemory =
53                serde::Deserialize::deserialize(document).map_err(|e| {
54                    Error::InvalidInput(format!(
55                        "Document {}: Failed to parse YAML: {e}",
56                        doc_index + 1
57                    ))
58                })?;
59            memories.push(memory);
60        }
61
62        Ok(Self { memories, index: 0 })
63    }
64}
65
66impl ImportSource for YamlImportSource {
67    fn next(&mut self) -> Result<Option<ImportedMemory>> {
68        if self.index < self.memories.len() {
69            let memory = self.memories[self.index].clone();
70            self.index += 1;
71            Ok(Some(memory))
72        } else {
73            Ok(None)
74        }
75    }
76
77    fn size_hint(&self) -> Option<usize> {
78        Some(self.memories.len())
79    }
80}
81
82/// YAML export sink.
83///
84/// Writes memories as a YAML document stream with `---` separators.
85pub struct YamlExportSink<W: Write> {
86    writer: W,
87    /// Number of records written.
88    count: usize,
89}
90
91impl<W: Write> YamlExportSink<W> {
92    /// Creates a new YAML export sink.
93    #[must_use]
94    pub const fn new(writer: W) -> Self {
95        Self { writer, count: 0 }
96    }
97}
98
99impl<W: Write + Send> ExportSink for YamlExportSink<W> {
100    fn write(&mut self, memory: &ExportableMemory) -> Result<()> {
101        // Write document separator (except for first document)
102        if self.count > 0 {
103            writeln!(self.writer, "---").map_err(|e| Error::OperationFailed {
104                operation: "write_yaml".to_string(),
105                cause: e.to_string(),
106            })?;
107        }
108
109        serde_yaml_ng::to_writer(&mut self.writer, memory).map_err(|e| Error::OperationFailed {
110            operation: "write_yaml".to_string(),
111            cause: e.to_string(),
112        })?;
113        self.count += 1;
114        Ok(())
115    }
116
117    fn finalize(mut self: Box<Self>) -> Result<()> {
118        self.writer.flush().map_err(|e| Error::OperationFailed {
119            operation: "flush_yaml".to_string(),
120            cause: e.to_string(),
121        })?;
122        Ok(())
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use std::io::Cursor;
130
131    #[test]
132    fn test_import_single_document() {
133        let input = r"
134content: Test memory
135namespace: decisions
136tags:
137  - rust
138  - test
139";
140        let mut source = YamlImportSource::new(Cursor::new(input)).unwrap();
141
142        let memory = source.next().unwrap().unwrap();
143        assert_eq!(memory.content, "Test memory");
144        assert_eq!(memory.namespace, Some("decisions".to_string()));
145        assert_eq!(memory.tags, vec!["rust", "test"]);
146
147        assert!(source.next().unwrap().is_none());
148    }
149
150    #[test]
151    fn test_import_multi_document() {
152        let input = r"---
153content: First memory
154---
155content: Second memory
156namespace: learnings
157";
158        let mut source = YamlImportSource::new(Cursor::new(input)).unwrap();
159
160        let first = source.next().unwrap().unwrap();
161        assert_eq!(first.content, "First memory");
162
163        let second = source.next().unwrap().unwrap();
164        assert_eq!(second.content, "Second memory");
165        assert_eq!(second.namespace, Some("learnings".to_string()));
166
167        assert!(source.next().unwrap().is_none());
168    }
169
170    #[test]
171    fn test_import_array_format() {
172        let input = r"
173- content: First memory
174- content: Second memory
175  tags:
176    - test
177";
178        let mut source = YamlImportSource::new(Cursor::new(input)).unwrap();
179
180        let first = source.next().unwrap().unwrap();
181        assert_eq!(first.content, "First memory");
182
183        let second = source.next().unwrap().unwrap();
184        assert_eq!(second.content, "Second memory");
185
186        assert!(source.next().unwrap().is_none());
187    }
188
189    #[test]
190    fn test_export_yaml() {
191        let mut output = Vec::new();
192        {
193            let mut sink = YamlExportSink::new(&mut output);
194            sink.write(&ExportableMemory {
195                id: "1".to_string(),
196                content: "Test".to_string(),
197                namespace: "decisions".to_string(),
198                domain: "project".to_string(),
199                project_id: None,
200                branch: None,
201                file_path: None,
202                status: "active".to_string(),
203                created_at: 0,
204                updated_at: 0,
205                tags: vec![],
206                source: None,
207            })
208            .unwrap();
209            sink.write(&ExportableMemory {
210                id: "2".to_string(),
211                content: "Second".to_string(),
212                namespace: "learnings".to_string(),
213                domain: "user".to_string(),
214                project_id: None,
215                branch: None,
216                file_path: None,
217                status: "active".to_string(),
218                created_at: 0,
219                updated_at: 0,
220                tags: vec![],
221                source: None,
222            })
223            .unwrap();
224            Box::new(sink).finalize().unwrap();
225        }
226
227        let output_str = String::from_utf8(output).unwrap();
228        assert!(output_str.contains("content: Test"));
229        assert!(output_str.contains("---"));
230        assert!(output_str.contains("content: Second"));
231    }
232
233    #[test]
234    fn test_empty_input() {
235        let input = "";
236        let mut source = YamlImportSource::new(Cursor::new(input)).unwrap();
237        assert!(source.next().unwrap().is_none());
238    }
239}