1use crate::file_info::FileInformation;
4use crate::types::FileId;
5use serde::{Deserialize, Serialize};
6
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct SourceContext {
12 files: Vec<SourceFile>,
13 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
16 file_id_map: HashMap<usize, usize>, }
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SourceFile {
22 pub path: String,
24 #[serde(skip_serializing_if = "Option::is_none")]
28 pub content: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub file_info: Option<FileInformation>,
32 pub metadata: FileMetadata,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct FileMetadata {
39 pub file_type: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
44 pub origin: Option<crate::file_origin::FileOrigin>,
45}
46
47impl SourceContext {
48 pub fn new() -> Self {
50 SourceContext {
51 files: Vec::new(),
52 file_id_map: HashMap::new(),
53 }
54 }
55
56 pub fn add_file(&mut self, path: String, content: Option<String>) -> FileId {
64 let id = FileId(self.files.len());
65
66 let (stored_content, file_info) = match content {
69 Some(c) => {
70 let info = FileInformation::new(&c);
72 (Some(c), Some(info))
73 }
74 None => {
75 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 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 pub fn add_file_with_id(
125 &mut self,
126 id: FileId,
127 path: String,
128 content: Option<String>,
129 ) -> FileId {
130 if self.get_file(id).is_some() {
132 panic!("FileId {:?} already exists in SourceContext", id);
133 }
134
135 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 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 self.file_id_map.insert(id.0, index);
163
164 id
165 }
166
167 pub fn get_file(&self, id: FileId) -> Option<&SourceFile> {
169 if let Some(&index) = self.file_id_map.get(&id.0) {
171 return self.files.get(index);
172 }
173
174 self.files.get(id.0)
176 }
177
178 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 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(), file_info: None,
201 metadata: f.metadata.clone(),
202 })
203 .collect(),
204 file_id_map: self.file_id_map.clone(), }
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 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 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 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 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}