Skip to main content

quarto_source_map/
context.rs

1//! Source context for managing files
2
3use crate::file_info::FileInformation;
4use crate::types::FileId;
5use serde::{Deserialize, Serialize};
6
7use std::collections::HashMap;
8
9/// Context for managing source files
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct SourceContext {
12    files: Vec<SourceFile>,
13    /// Sparse mapping for non-sequential file IDs (e.g., from hash-based IDs)
14    /// Only populated when add_file_with_id is used
15    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
16    file_id_map: HashMap<usize, usize>, // Maps FileId.0 -> index in files vec
17}
18
19/// A source file with content and metadata
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SourceFile {
22    /// File path or identifier
23    pub path: String,
24    /// File content (for ephemeral/in-memory files)
25    /// When Some, content is stored in memory (e.g., for <anonymous> or test files)
26    /// When None, content should be read from disk using the path
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub content: Option<String>,
29    /// File information for efficient location lookups (optional for serialization)
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub file_info: Option<FileInformation>,
32    /// File metadata
33    pub metadata: FileMetadata,
34}
35
36/// Metadata about a source file
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct FileMetadata {
39    /// File type (qmd, yaml, md, etc.)
40    pub file_type: Option<String>,
41    /// Structured provenance for a *virtual* file — content extracted
42    /// from another file (e.g. a notebook cell). `None` for real files.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub origin: Option<crate::file_origin::FileOrigin>,
45}
46
47impl SourceContext {
48    /// Create a new empty source context
49    pub fn new() -> Self {
50        SourceContext {
51            files: Vec::new(),
52            file_id_map: HashMap::new(),
53        }
54    }
55
56    /// Add a file to the context and return its ID
57    ///
58    /// - If content is Some: Creates an ephemeral (in-memory) file. Content is stored and used for ariadne rendering.
59    /// - If content is None: Creates a disk-backed file. Content will be read from disk when needed (path must exist).
60    ///
61    /// For ephemeral files, FileInformation is created immediately from the provided content.
62    /// For disk-backed files, FileInformation is created by reading from disk if the path exists.
63    pub fn add_file(&mut self, path: String, content: Option<String>) -> FileId {
64        let id = FileId(self.files.len());
65
66        // For ephemeral files (content provided), store it and create FileInformation
67        // For disk-backed files (no content), try to read from disk for FileInformation only
68        let (stored_content, file_info) = match content {
69            Some(c) => {
70                // Ephemeral file: index the content, then store it (no clone)
71                let info = FileInformation::new(&c);
72                (Some(c), Some(info))
73            }
74            None => {
75                // Disk-backed file: don't store content, but try to read for FileInformation
76                let info = std::fs::read_to_string(&path)
77                    .ok()
78                    .map(|c| FileInformation::new(&c));
79                (None, info)
80            }
81        };
82        self.files.push(SourceFile {
83            path,
84            content: stored_content,
85            file_info,
86            metadata: FileMetadata {
87                file_type: None,
88                origin: None,
89            },
90        });
91        id
92    }
93
94    /// Add a file with pre-computed FileInformation
95    ///
96    /// This is useful when deserializing from formats (like JSON) that include
97    /// serialized FileInformation, avoiding the need to recompute line breaks
98    /// or read from disk.
99    ///
100    /// The file is created without content (content=None), so ariadne rendering
101    /// won't work, but map_offset() will work using the provided FileInformation.
102    pub fn add_file_with_info(&mut self, path: String, file_info: FileInformation) -> FileId {
103        let id = FileId(self.files.len());
104        self.files.push(SourceFile {
105            path,
106            content: None,
107            file_info: Some(file_info),
108            metadata: FileMetadata {
109                file_type: None,
110                origin: None,
111            },
112        });
113        id
114    }
115
116    /// Add a file with a specific FileId
117    ///
118    /// This is useful when interfacing with systems that use hash-based or non-sequential
119    /// FileIds (like quarto-yaml). The FileId must not already exist in the context.
120    ///
121    /// # Panics
122    ///
123    /// Panics if the FileId already exists in the context.
124    pub fn add_file_with_id(
125        &mut self,
126        id: FileId,
127        path: String,
128        content: Option<String>,
129    ) -> FileId {
130        // Check if ID already exists
131        if self.get_file(id).is_some() {
132            panic!("FileId {:?} already exists in SourceContext", id);
133        }
134
135        // Process content same as add_file
136        let (stored_content, file_info) = match content {
137            Some(c) => {
138                let info = FileInformation::new(&c);
139                (Some(c), Some(info))
140            }
141            None => {
142                let info = std::fs::read_to_string(&path)
143                    .ok()
144                    .map(|c| FileInformation::new(&c));
145                (None, info)
146            }
147        };
148
149        // Add to files vec and create mapping
150        let index = self.files.len();
151        self.files.push(SourceFile {
152            path,
153            content: stored_content,
154            file_info,
155            metadata: FileMetadata {
156                file_type: None,
157                origin: None,
158            },
159        });
160
161        // Store mapping from FileId to index
162        self.file_id_map.insert(id.0, index);
163
164        id
165    }
166
167    /// Get a file by ID
168    pub fn get_file(&self, id: FileId) -> Option<&SourceFile> {
169        // First check if this is a mapped ID
170        if let Some(&index) = self.file_id_map.get(&id.0) {
171            return self.files.get(index);
172        }
173
174        // Otherwise use direct indexing (for sequential IDs from add_file)
175        self.files.get(id.0)
176    }
177
178    /// Get a file by ID for mutation — typically to attach
179    /// [`FileMetadata::origin`] after registering a virtual file.
180    pub fn get_file_mut(&mut self, id: FileId) -> Option<&mut SourceFile> {
181        if let Some(&index) = self.file_id_map.get(&id.0) {
182            return self.files.get_mut(index);
183        }
184        self.files.get_mut(id.0)
185    }
186
187    /// Create a copy without FileInformation (for serialization)
188    ///
189    /// Note: This preserves the content field for ephemeral files, as they need
190    /// content to be serialized for proper deserialization. Only FileInformation
191    /// is removed since it can be reconstructed from content.
192    pub fn without_content(&self) -> Self {
193        SourceContext {
194            files: self
195                .files
196                .iter()
197                .map(|f| SourceFile {
198                    path: f.path.clone(),
199                    content: f.content.clone(), // Preserve content for ephemeral files
200                    file_info: None,
201                    metadata: f.metadata.clone(),
202                })
203                .collect(),
204            file_id_map: self.file_id_map.clone(), // Preserve mapping
205        }
206    }
207}
208
209impl Default for SourceContext {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_empty_context() {
221        let ctx = SourceContext::new();
222        assert!(ctx.get_file(FileId(0)).is_none());
223    }
224
225    #[test]
226    fn test_add_and_get_file() {
227        let mut ctx = SourceContext::new();
228        let id = ctx.add_file("test.qmd".to_string(), Some("# Hello".to_string()));
229
230        assert_eq!(id, FileId(0));
231        let file = ctx.get_file(id).unwrap();
232        assert_eq!(file.path, "test.qmd");
233        assert!(file.file_info.is_some());
234
235        // Verify the file info was built correctly
236        let info = file.file_info.as_ref().unwrap();
237        assert_eq!(info.total_length(), 7);
238    }
239
240    #[test]
241    fn test_multiple_files() {
242        let mut ctx = SourceContext::new();
243        let id1 = ctx.add_file("first.qmd".to_string(), Some("First".to_string()));
244        let id2 = ctx.add_file("second.qmd".to_string(), Some("Second".to_string()));
245
246        assert_eq!(id1, FileId(0));
247        assert_eq!(id2, FileId(1));
248
249        let file1 = ctx.get_file(id1).unwrap();
250        let file2 = ctx.get_file(id2).unwrap();
251
252        assert_eq!(file1.path, "first.qmd");
253        assert_eq!(file2.path, "second.qmd");
254        assert!(file1.file_info.is_some());
255        assert!(file2.file_info.is_some());
256        assert_eq!(file1.file_info.as_ref().unwrap().total_length(), 5);
257        assert_eq!(file2.file_info.as_ref().unwrap().total_length(), 6);
258    }
259
260    #[test]
261    fn test_file_without_content() {
262        let mut ctx = SourceContext::new();
263        let id = ctx.add_file("no-content.qmd".to_string(), None);
264
265        let file = ctx.get_file(id).unwrap();
266        assert_eq!(file.path, "no-content.qmd");
267        assert!(file.file_info.is_none());
268    }
269
270    #[test]
271    fn test_without_content() {
272        let mut ctx = SourceContext::new();
273        ctx.add_file("test1.qmd".to_string(), Some("Content 1".to_string()));
274        ctx.add_file("test2.qmd".to_string(), Some("Content 2".to_string()));
275
276        let ctx_no_content = ctx.without_content();
277
278        let file1 = ctx_no_content.get_file(FileId(0)).unwrap();
279        let file2 = ctx_no_content.get_file(FileId(1)).unwrap();
280
281        assert_eq!(file1.path, "test1.qmd");
282        assert_eq!(file2.path, "test2.qmd");
283        assert!(file1.file_info.is_none());
284        assert!(file2.file_info.is_none());
285    }
286
287    #[test]
288    fn test_serialization() {
289        let mut ctx = SourceContext::new();
290        ctx.add_file("test.qmd".to_string(), Some("# Test".to_string()));
291
292        let json = serde_json::to_string(&ctx).unwrap();
293        let deserialized: SourceContext = serde_json::from_str(&json).unwrap();
294
295        let file = deserialized.get_file(FileId(0)).unwrap();
296        assert_eq!(file.path, "test.qmd");
297        assert!(file.file_info.is_some());
298        assert_eq!(file.file_info.as_ref().unwrap().total_length(), 6);
299    }
300
301    #[test]
302    fn test_serialization_without_content() {
303        let mut ctx = SourceContext::new();
304        ctx.add_file("test.qmd".to_string(), Some("# Test".to_string()));
305
306        let ctx_no_content = ctx.without_content();
307        let json = serde_json::to_string(&ctx_no_content).unwrap();
308
309        // Verify that None file_info is skipped in serialization
310        assert!(!json.contains("\"file_info\""));
311    }
312
313    fn notebook_origin() -> crate::file_origin::FileOrigin {
314        crate::file_origin::FileOrigin::NotebookCell {
315            notebook_path: "notebook.ipynb".into(),
316            cell_index: 3,
317            cell_id: Some("cell-abc".into()),
318            cell_type: "code".into(),
319        }
320    }
321
322    #[test]
323    fn get_file_mut_attaches_origin_to_a_mapped_id() {
324        // add_file_with_id registers via file_id_map; the mutable accessor
325        // must resolve through the same mapping get_file uses.
326        let mut ctx = SourceContext::new();
327        let id = ctx.add_file_with_id(
328            FileId(9),
329            "notebook.ipynb[cell 3, code]".to_string(),
330            Some("print(1)\n".to_string()),
331        );
332        ctx.get_file_mut(id).unwrap().metadata.origin = Some(notebook_origin());
333
334        let file = ctx.get_file(FileId(9)).unwrap();
335        assert_eq!(file.metadata.origin, Some(notebook_origin()));
336    }
337
338    #[test]
339    fn origin_survives_without_content_and_serialization_round_trip() {
340        let mut ctx = SourceContext::new();
341        let id = ctx.add_file("cell.qmd".to_string(), Some("x".to_string()));
342        ctx.get_file_mut(id).unwrap().metadata.origin = Some(notebook_origin());
343
344        let json = serde_json::to_string(&ctx.without_content()).unwrap();
345        assert!(json.contains("\"origin\""), "origin must serialize: {json}");
346        let back: SourceContext = serde_json::from_str(&json).unwrap();
347        assert_eq!(
348            back.get_file(id).unwrap().metadata.origin,
349            Some(notebook_origin())
350        );
351    }
352
353    #[test]
354    fn origin_is_omitted_and_defaults_to_none() {
355        let mut ctx = SourceContext::new();
356        ctx.add_file("real.qmd".to_string(), Some("x".to_string()));
357
358        let json = serde_json::to_string(&ctx).unwrap();
359        assert!(
360            !json.contains("\"origin\""),
361            "None origin must be omitted from the wire shape: {json}"
362        );
363        // Old JSON (written before the field existed) must deserialize.
364        let back: SourceContext =
365            serde_json::from_str(r#"{"files":[{"path":"old.qmd","metadata":{"file_type":null}}]}"#)
366                .unwrap();
367        assert_eq!(back.get_file(FileId(0)).unwrap().metadata.origin, None);
368    }
369}