Skip to main content

calcit/
snapshot.rs

1use cirru_edn::{Edn, EdnMapView, EdnRecordView, EdnSetView, EdnTag, from_edn};
2use cirru_parser::Cirru;
3use serde::{Deserialize, Serialize};
4use std::collections::hash_map::HashMap;
5use std::collections::hash_set::HashSet;
6use std::path::Path;
7use std::sync::Arc;
8
9use crate::calcit::{CalcitFnTypeAnnotation, CalcitTypeAnnotation, DYNAMIC_TYPE, SchemaKind, with_type_annotation_warning_context};
10
11const SNAPSHOT_ABOUT_MESSAGE: &str = "Machine-generated snapshot. Do not edit directly — changes will be overwritten. Use `cr query` to inspect and `cr edit`/`cr tree` to modify. Run `cr docs agents --full` first. Manual edits must follow format and schema conventions, then run `cr edit format`.";
12
13fn default_version() -> String {
14  "0.0.0".to_owned()
15}
16
17fn format_edn_preview(value: &Edn) -> String {
18  let raw = cirru_edn::format(value, true).unwrap_or_else(|_| format!("{value:?}"));
19  const LIMIT: usize = 220;
20  if raw.chars().count() > LIMIT {
21    let truncated = raw.chars().take(LIMIT).collect::<String>();
22    format!("{truncated}…")
23  } else {
24    raw
25  }
26}
27
28fn schema_path_label(path: &[String]) -> String {
29  if path.is_empty() { "<root>".to_owned() } else { path.join("") }
30}
31
32fn map_key_path_segment(key: &Edn) -> String {
33  match key {
34    Edn::Tag(tag) => format!(".{}", tag.ref_str()),
35    Edn::Str(text) => format!(".{text}"),
36    Edn::Symbol(text) => format!(".{text}"),
37    _ => ".<key>".to_owned(),
38  }
39}
40
41fn canonical_schema_field_name(text: &str) -> Option<&'static str> {
42  match text.trim_start_matches(':') {
43    "kind" => Some("kind"),
44    "args" => Some("args"),
45    "return" => Some("return"),
46    "rest" => Some("rest"),
47    "generics" => Some("generics"),
48    _ => None,
49  }
50}
51
52fn canonical_schema_kind_name(text: &str) -> Option<&'static str> {
53  match text.trim_start_matches(':') {
54    "fn" => Some("fn"),
55    "macro" => Some("macro"),
56    _ => None,
57  }
58}
59
60fn normalize_schema_map(map: &EdnMapView) -> EdnMapView {
61  let mut normalized = EdnMapView::default();
62
63  for (key, value) in map.0.iter() {
64    let normalized_key = match key {
65      Edn::Tag(tag) => Edn::tag(tag.ref_str()),
66      Edn::Str(text) => canonical_schema_field_name(text).map(Edn::tag).unwrap_or_else(|| key.clone()),
67      Edn::Symbol(text) => canonical_schema_field_name(text).map(Edn::tag).unwrap_or_else(|| key.clone()),
68      _ => key.clone(),
69    };
70
71    let normalized_value = match (&normalized_key, value) {
72      (Edn::Tag(tag), Edn::Str(text)) | (Edn::Tag(tag), Edn::Symbol(text)) if tag.ref_str() == "kind" => {
73        canonical_schema_kind_name(text).map(Edn::tag).unwrap_or_else(|| value.clone())
74      }
75      _ => value.clone(),
76    };
77
78    normalized.insert(normalized_key, normalized_value);
79  }
80
81  normalized
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct SnapshotConfigs {
86  #[serde(rename = "init-fn")]
87  pub init_fn: String,
88  #[serde(rename = "reload-fn")]
89  pub reload_fn: String,
90  #[serde(default)]
91  pub modules: Vec<String>,
92  #[serde(default = "default_version")]
93  pub version: String,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct NsEntry {
98  pub doc: String,
99  pub code: Cirru,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct FileInSnapShot {
104  pub ns: NsEntry,
105  pub defs: HashMap<String, CodeEntry>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109struct RawCodeEntry {
110  pub doc: String,
111  #[serde(default)]
112  pub examples: Vec<Cirru>,
113  pub code: Cirru,
114  #[serde(default)]
115  pub schema: Option<Edn>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119struct RawFileInSnapShot {
120  pub ns: NsEntry,
121  pub defs: HashMap<String, RawCodeEntry>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125struct RawSnapshot {
126  pub package: String,
127  pub about: Option<String>,
128  pub configs: SnapshotConfigs,
129  pub entries: HashMap<String, SnapshotConfigs>,
130  pub files: HashMap<String, RawFileInSnapShot>,
131}
132
133impl RawCodeEntry {
134  fn into_code_entry(self, owner: &str) -> Result<CodeEntry, String> {
135    let schema = match self.schema {
136      None | Some(Edn::Nil) => DYNAMIC_TYPE.clone(),
137      Some(value) => with_type_annotation_warning_context(owner.to_owned(), || parse_loaded_schema_annotation(&value, owner))?,
138    };
139
140    Ok(CodeEntry {
141      doc: self.doc,
142      examples: self.examples,
143      code: self.code,
144      schema,
145    })
146  }
147}
148
149pub fn decode_binary_snapshot(bytes: &[u8]) -> Result<Snapshot, String> {
150  let raw: RawSnapshot = rmp_serde::from_slice(bytes).map_err(|e| e.to_string())?;
151  let mut files: HashMap<String, FileInSnapShot> = HashMap::with_capacity(raw.files.len());
152
153  for (file_name, raw_file) in raw.files {
154    let ns = raw_file.ns;
155    let mut defs: HashMap<String, CodeEntry> = HashMap::with_capacity(raw_file.defs.len());
156
157    for (def_name, raw_entry) in raw_file.defs {
158      let owner = format!("{file_name}/{def_name}");
159      defs.insert(def_name, raw_entry.into_code_entry(&owner)?);
160    }
161
162    files.insert(file_name, FileInSnapShot { ns, defs });
163  }
164
165  Ok(Snapshot {
166    package: raw.package,
167    about: raw.about,
168    configs: raw.configs,
169    entries: raw.entries,
170    files,
171  })
172}
173
174impl From<&FileInSnapShot> for Edn {
175  fn from(data: &FileInSnapShot) -> Edn {
176    let mut defs_map = EdnMapView::default();
177    for (k, v) in &data.defs {
178      defs_map.insert(Edn::str(k.as_str()), Edn::from(v));
179    }
180    Edn::Record(EdnRecordView {
181      tag: EdnTag::new("FileEntry"),
182      pairs: vec![("defs".into(), Edn::from(defs_map)), ("ns".into(), Edn::from(&data.ns))], // TODO
183    })
184  }
185}
186
187impl TryFrom<Edn> for FileInSnapShot {
188  type Error = String;
189  fn try_from(data: Edn) -> Result<Self, String> {
190    match data {
191      Edn::Map(_) => from_edn(data).map_err(|e| format!("failed to parse FileInSnapShot: {e}")),
192      Edn::Record(record) => {
193        let mut ns = None;
194        let mut defs = None;
195
196        for (key, value) in record.pairs.iter() {
197          match key.arc_str().as_ref() {
198            "ns" => {
199              ns = Some(value.to_owned().try_into().map_err(|e| format!("failed to parse ns: {e}"))?);
200            }
201            "defs" => {
202              defs = Some(value.to_owned().try_into().map_err(|e| format!("failed to parse defs: {e}"))?);
203            }
204            _ => {}
205          }
206        }
207
208        let ns = ns.ok_or("Missing ns field in FileEntry")?;
209        let defs = defs.ok_or("Missing defs field in FileEntry")?;
210        Ok(FileInSnapShot { ns, defs })
211      }
212      _ => Err(format!("Expected FileInSnapShot map or record, but got: {data:?}")),
213    }
214  }
215}
216
217impl From<FileInSnapShot> for Edn {
218  fn from(data: FileInSnapShot) -> Edn {
219    let mut defs_map = EdnMapView::default();
220    for (k, v) in data.defs {
221      defs_map.insert(Edn::str(k.as_str()), Edn::from(v));
222    }
223    Edn::map_from_iter([("defs".into(), Edn::from(defs_map)), ("ns".into(), data.ns.into())])
224  }
225}
226
227impl TryFrom<Edn> for NsEntry {
228  type Error = String;
229  fn try_from(data: Edn) -> Result<Self, String> {
230    let mut doc = String::new();
231    let mut code: Option<Cirru> = None;
232
233    match data {
234      Edn::Record(record) => {
235        for (key, value) in &record.pairs {
236          match key.arc_str().as_ref() {
237            "doc" => {
238              doc = from_edn(value.to_owned()).map_err(|e| format!("failed to parse NsEntry.doc: {e}"))?;
239            }
240            "code" => {
241              code = Some(from_edn(value.to_owned()).map_err(|e| format!("failed to parse NsEntry.code: {e}"))?);
242            }
243            _ => {}
244          }
245        }
246      }
247      Edn::Map(map) => {
248        if let Some(value) = map.get(&Edn::Tag(EdnTag::new("doc"))) {
249          doc = from_edn(value.to_owned()).map_err(|e| format!("failed to parse NsEntry.doc: {e}"))?;
250        }
251        if let Some(value) = map.get(&Edn::Tag(EdnTag::new("code"))) {
252          code = Some(from_edn(value.to_owned()).map_err(|e| format!("failed to parse NsEntry.code: {e}"))?);
253        }
254      }
255      other => {
256        return Err(format!("failed to parse NsEntry: expected record/map, got: {other:?}"));
257      }
258    }
259
260    Ok(NsEntry {
261      doc,
262      code: code.ok_or_else(|| "failed to parse NsEntry: missing code field".to_owned())?,
263    })
264  }
265}
266
267impl From<NsEntry> for Edn {
268  fn from(data: NsEntry) -> Self {
269    Edn::record_from_pairs(
270      "NsEntry".into(),
271      &[("doc".into(), data.doc.into()), ("code".into(), data.code.into())],
272    )
273  }
274}
275
276impl From<&NsEntry> for Edn {
277  fn from(data: &NsEntry) -> Self {
278    Edn::record_from_pairs(
279      "NsEntry".into(),
280      &[
281        ("doc".into(), data.doc.to_owned().into()),
282        ("code".into(), data.code.to_owned().into()),
283      ],
284    )
285  }
286}
287
288/// Custom serde for `CodeEntry::schema`.
289/// The binary RMP format stores schemas as `Option<Edn>` (compatible with `build.rs`);
290/// at runtime we keep a parsed `Arc<CalcitTypeAnnotation>` for direct use.
291mod schema_serde {
292  use super::*;
293
294  pub fn default_schema() -> Arc<CalcitTypeAnnotation> {
295    DYNAMIC_TYPE.clone()
296  }
297
298  pub fn serialize<S>(schema: &Arc<CalcitTypeAnnotation>, s: S) -> Result<S::Ok, S::Error>
299  where
300    S: serde::Serializer,
301  {
302    let edn: Option<Edn> = match schema.as_ref() {
303      CalcitTypeAnnotation::Dynamic => None,
304      CalcitTypeAnnotation::Fn(fn_annot) => Some(fn_annot.to_schema_edn()),
305      _ => None,
306    };
307    edn.serialize(s)
308  }
309
310  pub fn deserialize<'de, D>(d: D) -> Result<Arc<CalcitTypeAnnotation>, D::Error>
311  where
312    D: serde::Deserializer<'de>,
313  {
314    let opt = Option::<Edn>::deserialize(d)?;
315    Ok(match opt {
316      None | Some(Edn::Nil) => DYNAMIC_TYPE.clone(),
317      Some(v) => parse_loaded_schema_annotation(&v, "CodeEntry.schema").map_err(serde::de::Error::custom)?,
318    })
319  }
320}
321
322fn parse_loaded_schema_annotation(value: &Edn, owner: &str) -> Result<Arc<CalcitTypeAnnotation>, String> {
323  if matches!(value, Edn::Nil) {
324    return Ok(DYNAMIC_TYPE.clone());
325  }
326
327  // Primitive type tag stored as a plain EDN tag (e.g. :string, :number).
328  if let Edn::Tag(tag) = value {
329    let tag_name = tag.ref_str();
330    if PRIMITIVE_SCHEMA_TAGS.contains(&tag_name) {
331      return Ok(Arc::new(CalcitTypeAnnotation::from_tag_name(tag_name)));
332    }
333    return Err(format!(
334      "unknown primitive schema tag `:{tag_name}` in {owner}; valid tags: {}",
335      PRIMITIVE_SCHEMA_TAGS.join(", ")
336    ));
337  }
338
339  let normalized =
340    normalize_schema_edn(value).map_err(|e| format!("failed to normalize {owner}: {e}; schema={}", format_edn_preview(value)))?;
341  let schema_cirru = parse_schema_cirru_from_edn(&normalized).map_err(|e| {
342    format!(
343      "failed to convert {owner} into Cirru: {e}; schema={}",
344      format_edn_preview(&normalized)
345    )
346  })?;
347  parse_schema_data(&schema_cirru)
348    .map_err(|e| format!("failed to validate {owner}: {e}; schema={}", format_edn_preview(&normalized)))?;
349
350  CalcitTypeAnnotation::parse_fn_schema_from_edn(&normalized)
351    .map(|s| Arc::new(CalcitTypeAnnotation::Fn(Arc::new(s))))
352    .ok_or_else(|| {
353      format!(
354        "failed to parse {owner} as function schema after normalization; schema={}",
355        format_edn_preview(&normalized)
356      )
357    })
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
361pub struct CodeEntry {
362  pub doc: String,
363  #[serde(default)]
364  pub examples: Vec<Cirru>,
365  pub code: Cirru,
366  #[serde(default = "schema_serde::default_schema", with = "schema_serde")]
367  pub schema: Arc<CalcitTypeAnnotation>,
368}
369
370impl TryFrom<Edn> for CodeEntry {
371  type Error = String;
372  fn try_from(data: Edn) -> Result<Self, String> {
373    let mut doc = String::new();
374    let mut examples: Vec<Cirru> = vec![];
375    let mut code: Option<Cirru> = None;
376    let mut schema: Arc<CalcitTypeAnnotation> = DYNAMIC_TYPE.clone();
377
378    match data {
379      Edn::Record(record) => {
380        for (key, value) in &record.pairs {
381          match key.arc_str().as_ref() {
382            "doc" => {
383              doc = from_edn(value.to_owned()).map_err(|e| format!("failed to parse CodeEntry.doc: {e}"))?;
384            }
385            "examples" => {
386              examples = from_edn(value.to_owned()).map_err(|e| format!("failed to parse CodeEntry.examples: {e}"))?;
387            }
388            "code" => {
389              code = Some(from_edn(value.to_owned()).map_err(|e| format!("failed to parse CodeEntry.code: {e}"))?);
390            }
391            "schema" => {
392              if !matches!(value, Edn::Nil) {
393                schema = parse_loaded_schema_annotation(value, "CodeEntry.schema")?;
394              }
395            }
396            _ => {}
397          }
398        }
399      }
400      Edn::Map(map) => {
401        if let Some(value) = map.get(&Edn::Tag(EdnTag::new("doc"))) {
402          doc = from_edn(value.to_owned()).map_err(|e| format!("failed to parse CodeEntry.doc: {e}"))?;
403        }
404        if let Some(value) = map.get(&Edn::Tag(EdnTag::new("examples"))) {
405          examples = from_edn(value.to_owned()).map_err(|e| format!("failed to parse CodeEntry.examples: {e}"))?;
406        }
407        if let Some(value) = map.get(&Edn::Tag(EdnTag::new("code"))) {
408          code = Some(from_edn(value.to_owned()).map_err(|e| format!("failed to parse CodeEntry.code: {e}"))?);
409        }
410        if let Some(value) = map.get(&Edn::Tag(EdnTag::new("schema")))
411          && !matches!(value, Edn::Nil)
412        {
413          schema = parse_loaded_schema_annotation(value, "CodeEntry.schema")?;
414        }
415      }
416      other => {
417        return Err(format!("failed to parse CodeEntry: expected record/map, got: {other:?}"));
418      }
419    }
420
421    let code = code.ok_or_else(|| "failed to parse CodeEntry: missing code field".to_owned())?;
422    let schema = normalize_schema_for_code(&code, &schema);
423
424    Ok(CodeEntry {
425      doc,
426      examples,
427      code,
428      schema,
429    })
430  }
431}
432
433/// Normalize a schema Edn value.
434/// Wrapped `(:: :fn ({} ...))` / `(:: :macro ({} ...))` forms are converted to a direct map Edn.
435/// Direct map format is returned as-is.
436fn normalize_schema_edn(value: &Edn) -> Result<Edn, String> {
437  if matches!(value, Edn::Map(_)) {
438    let Edn::Map(map) = value else { unreachable!() };
439    let normalized = Edn::Map(normalize_schema_map(map));
440    validate_schema_edn_no_legacy_quotes(&normalized)?;
441    return Ok(normalized);
442  }
443
444  if let Edn::Tuple(view) = value
445    && matches!(view.tag.as_ref(), Edn::Tag(tag) if matches!(tag.ref_str(), "fn" | "macro"))
446    && let Some(Edn::Map(map)) = view.extra.first()
447  {
448    let mut normalized_map = normalize_schema_map(map);
449    if normalized_map.tag_get("kind").is_none() && matches!(view.tag.as_ref(), Edn::Tag(tag) if tag.ref_str() == "macro") {
450      normalized_map.insert_key("kind", Edn::tag("macro"));
451    }
452    let normalized = Edn::Map(normalized_map);
453    validate_schema_edn_no_legacy_quotes(&normalized)?;
454    return Ok(normalized);
455  }
456
457  Err(format!(
458    "invalid schema format: expected wrapped `(:: :fn ({{}} ...))` / `(:: :macro ({{}} ...))` or a normalized schema map, got {}",
459    format_edn_preview(value)
460  ))
461}
462
463fn validate_schema_edn_no_legacy_quotes(value: &Edn) -> Result<(), String> {
464  fn walk(value: &Edn, path: &mut Vec<String>) -> Result<(), String> {
465    match value {
466      Edn::Symbol(s) => {
467        if s.starts_with('\'') {
468          let inner = s.trim_start_matches('\'');
469          return Err(format!(
470            "invalid schema generic symbol `{s}` at {}. Use source syntax like `'{inner}`, but store it as plain EDN symbol `{inner}`.",
471            schema_path_label(path)
472          ));
473        }
474        Ok(())
475      }
476      Edn::List(xs) => {
477        for (idx, item) in xs.0.iter().enumerate() {
478          path.push(format!("[{idx}]"));
479          walk(item, path)?;
480          path.pop();
481        }
482        Ok(())
483      }
484      Edn::Map(map) => {
485        for (k, v) in map.0.iter() {
486          path.push(map_key_path_segment(k));
487          walk(v, path)?;
488          path.pop();
489        }
490        Ok(())
491      }
492      Edn::Tuple(view) => {
493        path.push(".tag".to_owned());
494        walk(view.tag.as_ref(), path)?;
495        path.pop();
496        for (idx, item) in view.extra.iter().enumerate() {
497          path.push(format!("[{idx}]"));
498          walk(item, path)?;
499          path.pop();
500        }
501        Ok(())
502      }
503      Edn::Set(set) => {
504        for (idx, item) in set.0.iter().enumerate() {
505          path.push(format!("[#{idx}]"));
506          walk(item, path)?;
507          path.pop();
508        }
509        Ok(())
510      }
511      Edn::Record(record) => {
512        let _ = record;
513        Ok(())
514      }
515      _ => Ok(()),
516    }
517  }
518
519  let mut path = vec![];
520  walk(value, &mut path)
521}
522
523/// Convert a schema Edn value to Cirru for operations that require Cirru (validation, runtime).
524/// Handles both old Quote-wrapped format and new direct map format.
525pub fn schema_edn_to_cirru(value: &Edn) -> Result<Cirru, String> {
526  parse_schema_cirru_from_edn(value)
527}
528
529fn parse_schema_cirru_from_edn(value: &Edn) -> Result<Cirru, String> {
530  // Do not use `from_edn::<Cirru>` here: EDN symbols such as `Edn::Symbol("T")`
531  // would become Cirru leaves like `'T`, while valid schema source should round-trip
532  // through the parser into `(quote T)` / `'T` syntax without embedding quote
533  // characters inside leaf names.
534  let schema_text = cirru_edn::format(value, true).map_err(|e| format!("Failed to format schema EDN to Cirru: {e}"))?;
535  let schema_nodes = cirru_parser::parse(&schema_text).map_err(|e| format!("Failed to parse schema Cirru from EDN text: {e}"))?;
536
537  if schema_nodes.len() != 1 {
538    return Err(format!(
539      "Schema EDN should convert to exactly 1 Cirru expression, got {}",
540      schema_nodes.len()
541    ));
542  }
543  Ok(schema_nodes[0].to_owned())
544}
545
546pub fn parse_schema_data(schema: &Cirru) -> Result<(), String> {
547  if let Cirru::List(items) = schema {
548    if let Some(Cirru::Leaf(head)) = items.first() {
549      if &**head == ":optional" {
550        if items.len() != 2 {
551          return Err("schema `:optional` expects exactly one payload".to_owned());
552        }
553        return parse_schema_data(&items[1]);
554      }
555      if &**head == "::" && items.len() == 3 && matches!(items.get(1), Some(Cirru::Leaf(tag)) if &**tag == ":optional") {
556        return parse_schema_data(&items[2]);
557      }
558    }
559  }
560
561  let schema_text =
562    cirru_parser::format(std::slice::from_ref(schema), true.into()).map_err(|e| format!("Failed to format schema to Cirru: {e}"))?;
563
564  cirru_edn::parse(&schema_text).map_err(|e| format!("Failed to parse schema as Cirru EDN: {e}"))?;
565
566  Ok(())
567}
568
569/// Convert a Cirru schema tree to a direct Edn value (not Quote-wrapped).
570/// Used when serializing CodeEntry to file: the schema is stored as a native
571/// EDN map instead of a quoted Cirru expression.
572/// `cr edit format` normalises old quote-wrapped schemas to this format.
573/// Returns `Edn::Nil` if conversion fails (should not happen for valid schemas).
574pub fn schema_cirru_to_edn(schema: Cirru) -> Edn {
575  let text = match cirru_parser::format(&[schema], true.into()) {
576    Ok(t) => t,
577    Err(_) => return Edn::Nil,
578  };
579  match cirru_edn::parse(&text) {
580    Ok(edn) => edn,
581    Err(_) => Edn::Nil,
582  }
583}
584
585fn validate_schema_for_snapshot_write(owner: &str, schema: &Arc<CalcitTypeAnnotation>) -> Result<(), String> {
586  let CalcitTypeAnnotation::Fn(fn_annot) = schema.as_ref() else {
587    return Ok(());
588  };
589
590  let schema_edn = fn_annot.to_wrapped_schema_edn();
591  let schema_text =
592    cirru_edn::format(&schema_edn, true).map_err(|e| format!("{owner}: failed to format `:schema` for snapshot write: {e}"))?;
593  let schema_nodes = cirru_parser::parse(&schema_text)
594    .map_err(|e| format!("{owner}: failed to parse serialized `:schema` during snapshot write validation: {e}"))?;
595
596  if schema_nodes.len() != 1 {
597    return Err(format!(
598      "{owner}: serialized `:schema` should produce exactly 1 Cirru expression, got {}",
599      schema_nodes.len()
600    ));
601  }
602
603  validate_schema_for_write(&schema_nodes[0])
604    .map_err(|e| format!("{owner}: serialized `:schema` becomes invalid during snapshot write: {e}; schema={schema_text}"))
605}
606
607fn validate_snapshot_schemas_for_write(snapshot: &Snapshot) -> Result<(), String> {
608  for (ns_name, file_data) in &snapshot.files {
609    if ns_name.ends_with(".$meta") {
610      continue;
611    }
612
613    for (def_name, code_entry) in &file_data.defs {
614      validate_schema_for_snapshot_write(&format!("{ns_name}/{def_name}"), &code_entry.schema)?;
615    }
616  }
617
618  Ok(())
619}
620
621fn validate_serialized_snapshot_content(content: &str) -> Result<(), String> {
622  fn walk(node: &Cirru, path: &mut Vec<usize>) -> Result<(), String> {
623    if let Cirru::List(items) = node {
624      if let Some(Cirru::Leaf(head)) = items.first()
625        && &**head == ":schema"
626        && let Some(schema_node) = items.get(1)
627      {
628        if matches!(schema_node, Cirru::Leaf(s) if s.as_ref() == "nil") {
629          return Ok(());
630        }
631        return validate_schema_for_write(schema_node)
632          .map_err(|e| format!("serialized snapshot has invalid `:schema` at {path:?}: {e}"));
633      }
634
635      for (idx, item) in items.iter().enumerate() {
636        path.push(idx);
637        walk(item, path)?;
638        path.pop();
639      }
640    }
641    Ok(())
642  }
643
644  let nodes = cirru_parser::parse(content).map_err(|e| format!("Failed to parse serialized snapshot content: {e}"))?;
645  let mut path = vec![];
646  for (idx, node) in nodes.iter().enumerate() {
647    path.push(idx);
648    walk(node, &mut path)?;
649    path.pop();
650  }
651  Ok(())
652}
653
654/// Valid top-level field names accepted in a schema map.
655pub const VALID_SCHEMA_FIELDS: &[&str] = &[":kind", ":args", ":return", ":rest", ":generics"];
656
657/// Recursively check a Cirru schema tree for deprecated `:nil` type annotations.
658fn check_no_nil_type(node: &Cirru) -> Result<(), String> {
659  match node {
660    Cirru::Leaf(s) if s.as_ref() == ":nil" => Err(
661      "`:nil` is no longer a valid schema type. Use `:unit` for functions returning nil/unit, or `:dynamic` for unknown types."
662        .to_owned(),
663    ),
664    Cirru::List(items) => {
665      for item in items.iter() {
666        check_no_nil_type(item)?;
667      }
668      Ok(())
669    }
670    _ => Ok(()),
671  }
672}
673
674/// Recursively check for symbols with excess leading single-quotes.
675/// In schema source, a valid generic type variable is written as `'T`, so a single
676/// leading quote in a leaf is valid, but `''T` and deeper are malformed.
677fn check_no_excess_quotes(node: &Cirru) -> Result<(), String> {
678  match node {
679    Cirru::Leaf(s) => {
680      // A leaf with one leading quote is valid schema source syntax for an EDN symbol.
681      // More than one means the underlying symbol name itself also contains quote chars.
682      let name = s.as_ref();
683      if name.starts_with('\'') && !name.trim_start_matches('\'').is_empty() {
684        let inner = name.trim_start_matches('\'');
685        if name.chars().filter(|c| *c == '\'').count() > 1 {
686          return Err(format!(
687            "Type variable `{name}` has excess leading quotes. Use a single-quoted uppercase symbol like `'{inner}`."
688          ));
689        }
690      }
691      Ok(())
692    }
693    Cirru::List(items) => {
694      for item in items.iter() {
695        check_no_excess_quotes(item)?;
696      }
697      Ok(())
698    }
699  }
700}
701
702/// Recursively collect all type-variable names from a Cirru node.
703/// A type variable is represented as `(quote Name)` in the Cirru AST,
704/// i.e. the source form `'T` parses to `(quote T)`.
705fn collect_type_vars(node: &Cirru, out: &mut HashSet<String>) {
706  if let Cirru::List(items) = node {
707    if items.len() == 2 {
708      if let (Some(Cirru::Leaf(head)), Some(Cirru::Leaf(name))) = (items.first(), items.get(1)) {
709        if head.as_ref() == "quote" {
710          out.insert(name.to_string());
711          return;
712        }
713      }
714    }
715    for item in items.iter() {
716      collect_type_vars(item, out);
717    }
718  }
719}
720
721/// Extract the list of declared generic type-variable names from a `:generics` value node.
722/// Accepts `([] 'T 'U ...)` — each `(quote X)` child is one variable.
723fn parse_generics_vars(node: &Cirru) -> HashSet<String> {
724  let mut vars = HashSet::new();
725  if let Cirru::List(items) = node {
726    // skip leading `[]` head if present
727    let start = match items.first() {
728      Some(Cirru::Leaf(s)) if s.as_ref() == "[]" => 1,
729      _ => 0,
730    };
731    for item in items.iter().skip(start) {
732      collect_type_vars(item, &mut vars);
733    }
734  }
735  vars
736}
737
738/// Allowed primitive tag types usable as a bare leaf schema (e.g. `:string`, `:number`).
739pub const PRIMITIVE_SCHEMA_TAGS: &[&str] = &[
740  "bool", "number", "string", "symbol", "tag", "list", "map", "set", "fn", "tuple", "ref", "buffer", "dynamic", "unit",
741];
742
743/// Strict validation for schemas submitted via `cr edit schema`.
744/// Ensures the schema is a `{}` map or wrapped `(:: :fn ({} ...))` form, has a recognised `:kind`, and contains
745/// only permitted fields.  Loading (read-only) only requires the weaker
746/// `parse_schema_data` check.
747pub fn validate_schema_for_write(schema: &Cirru) -> Result<(), String> {
748  let mut wrapped_kind: Option<&str> = None;
749  let raw_items = match schema {
750    Cirru::List(items) => items,
751    Cirru::Leaf(s) => {
752      // Allow known primitive type tags as a bare leaf schema (e.g. :string, :number).
753      let tag_name = s.trim_start_matches(':');
754      if PRIMITIVE_SCHEMA_TAGS.contains(&tag_name) {
755        return Ok(());
756      }
757      return Err(format!(
758        "Schema must be a `{{}}` map or `(:: :fn ({{}} ...))` / `(:: :macro ({{}} ...))`, got leaf: `{s}`. \
759         Primitive type tags (e.g. `:string`, `:number`, `:bool`) are accepted."
760      ));
761    }
762  };
763
764  let items: &[Cirru] = if matches!(raw_items.first(), Some(Cirru::Leaf(head)) if head.as_ref() == "::") {
765    if raw_items.len() != 3 {
766      return Err("Wrapped schema `(:: :fn schema-map)` or `(:: :macro schema-map)` expects exactly 3 items".to_owned());
767    }
768    match (&raw_items[1], &raw_items[2]) {
769      (Cirru::Leaf(tag), Cirru::List(inner_items)) if tag.as_ref() == ":fn" || tag.as_ref() == ":macro" => {
770        wrapped_kind = Some(tag);
771        inner_items
772      }
773      (Cirru::Leaf(tag), _) => {
774        return Err(format!(
775          "Wrapped schema tag must be `:fn` or `:macro`, got: `{tag}`. Example: `(:: :fn ({{}} (:args ([] :string)) (:return :bool)))`"
776        ));
777      }
778      _ => return Err("Wrapped schema second item must be `:fn` or `:macro` and third item must be a `{}` map".to_owned()),
779    }
780  } else {
781    raw_items
782  };
783
784  let Some(Cirru::Leaf(head)) = items.first() else {
785    return Err("Schema must be a non-empty list starting with `{}`".to_owned());
786  };
787
788  if head.as_ref() != "{}" {
789    return Err(format!(
790      "Schema top-level must start with `{{}}` or be wrapped as `(:: :fn ({{}} ...))` / `(:: :macro ({{}} ...))`, got: `{head}`. \
791       Example: `(:: :fn ({{}} (:args ([] :string)) (:return :bool)))`"
792    ));
793  }
794
795  // EDN-level validity
796  parse_schema_data(schema)?;
797
798  // Reject deprecated :nil type annotation
799  check_no_nil_type(schema)?;
800
801  // Reject excess-quoted type variables like ''T.
802  check_no_excess_quotes(schema)?;
803
804  // Field-level validation
805  let mut has_kind = wrapped_kind.is_some();
806  for pair in items.iter().skip(1) {
807    let Cirru::List(xs) = pair else {
808      let text = cirru_parser::format(&[pair.clone()], true.into()).unwrap_or_else(|_| format!("{pair:?}"));
809      return Err(format!("Each schema field must be a `(:key val)` pair list, got: {text}"));
810    };
811
812    if xs.len() < 2 {
813      return Err(format!(
814        "Schema field pair must have exactly 2 elements, got {} in: {xs:?}",
815        xs.len()
816      ));
817    }
818
819    let Some(Cirru::Leaf(key)) = xs.first() else {
820      return Err(format!("Schema field key must be a leaf tag, got: {:?}", xs.first()));
821    };
822
823    if !VALID_SCHEMA_FIELDS.contains(&key.as_ref()) {
824      return Err(format!(
825        "Unknown schema field: `{key}`. Valid fields: {}",
826        VALID_SCHEMA_FIELDS.join(", ")
827      ));
828    }
829
830    if key.as_ref() == ":kind" {
831      has_kind = true;
832      match xs.get(1) {
833        Some(Cirru::Leaf(val)) if val.as_ref() == ":fn" || val.as_ref() == ":macro" => {}
834        Some(Cirru::Leaf(val)) => {
835          return Err(format!("Schema `:kind` must be `:fn` or `:macro`, got: `{val}`"));
836        }
837        _ => return Err("Schema `:kind` value must be a leaf tag (`:fn` or `:macro`)".to_owned()),
838      }
839
840      if let (Some(wrapped), Some(Cirru::Leaf(val))) = (wrapped_kind, xs.get(1))
841        && wrapped != val.as_ref()
842      {
843        return Err(format!(
844          "Wrapped schema tag `{wrapped}` conflicts with inner `:kind {val}`; keep only one kind or make them match"
845        ));
846      }
847    }
848  }
849
850  if !has_kind {
851    return Err("Schema must have a `:kind` field (`:fn` or `:macro`), unless the wrapped tag already provides it".to_owned());
852  }
853
854  // --- Type-variable consistency check ---
855  // Collect declared generics, args, and return from the schema pairs.
856  let mut generics_node: Option<&Cirru> = None;
857  let mut args_node: Option<&Cirru> = None;
858  let mut return_node: Option<&Cirru> = None;
859  let mut rest_node: Option<&Cirru> = None;
860
861  for pair in items.iter().skip(1) {
862    if let Cirru::List(xs) = pair {
863      if let (Some(Cirru::Leaf(key)), Some(val)) = (xs.first(), xs.get(1)) {
864        match key.as_ref() {
865          ":generics" => generics_node = Some(val),
866          ":args" => args_node = Some(val),
867          ":return" => return_node = Some(val),
868          ":rest" => rest_node = Some(val),
869          _ => {}
870        }
871      }
872    }
873  }
874
875  if let Some(gen_node) = generics_node {
876    let declared: HashSet<String> = parse_generics_vars(gen_node);
877
878    // Collect used type vars from :args, :return, :rest
879    let mut used: HashSet<String> = HashSet::new();
880    if let Some(node) = args_node {
881      collect_type_vars(node, &mut used);
882    }
883    if let Some(node) = return_node {
884      collect_type_vars(node, &mut used);
885    }
886    if let Some(node) = rest_node {
887      collect_type_vars(node, &mut used);
888    }
889
890    // Every declared var must be used at least once
891    for var in &declared {
892      if !used.contains(var) {
893        return Err(format!(
894          "Generic type variable `'{var}` is declared in `:generics` but never used in `:args`, `:rest`, or `:return`."
895        ));
896      }
897    }
898
899    // Every used var must be declared in :generics
900    for var in &used {
901      if !declared.contains(var) {
902        return Err(format!(
903          "Type variable `'{var}` is used in `:args`/`:rest`/`:return` but not declared in `:generics`."
904        ));
905      }
906    }
907  } else {
908    // No :generics — any type var usage is an error
909    let mut used: HashSet<String> = HashSet::new();
910    if let Some(node) = args_node {
911      collect_type_vars(node, &mut used);
912    }
913    if let Some(node) = return_node {
914      collect_type_vars(node, &mut used);
915    }
916    if let Some(node) = rest_node {
917      collect_type_vars(node, &mut used);
918    }
919    if let Some(var) = used.iter().next() {
920      return Err(format!("Type variable `'{var}` is used but no `:generics` field is declared."));
921    }
922  }
923
924  Ok(())
925}
926
927impl From<CodeEntry> for Edn {
928  fn from(data: CodeEntry) -> Self {
929    let schema = normalize_schema_for_code(&data.code, &data.schema);
930    let schema_edn: Edn = match schema.as_ref() {
931      CalcitTypeAnnotation::Dynamic => Edn::tag("dynamic"),
932      CalcitTypeAnnotation::Fn(fn_annot) => fn_annot.to_wrapped_schema_edn(),
933      other => {
934        // Primitive type tag schema (e.g. :string, :number) — serialize as plain EDN tag.
935        other.builtin_tag_name().map(Edn::tag).unwrap_or(Edn::tag("dynamic"))
936      }
937    };
938    Edn::record_from_pairs(
939      "CodeEntry".into(),
940      &[
941        ("doc".into(), data.doc.into()),
942        ("examples".into(), data.examples.into()),
943        ("code".into(), data.code.into()),
944        ("schema".into(), schema_edn),
945      ],
946    )
947  }
948}
949
950impl From<&CodeEntry> for Edn {
951  fn from(data: &CodeEntry) -> Self {
952    let schema = normalize_schema_for_code(&data.code, &data.schema);
953    let schema_edn: Edn = match schema.as_ref() {
954      CalcitTypeAnnotation::Dynamic => Edn::tag("dynamic"),
955      CalcitTypeAnnotation::Fn(fn_annot) => fn_annot.to_wrapped_schema_edn(),
956      other => {
957        // Primitive type tag schema (e.g. :string, :number) — serialize as plain EDN tag.
958        other.builtin_tag_name().map(Edn::tag).unwrap_or(Edn::tag("dynamic"))
959      }
960    };
961    Edn::record_from_pairs(
962      "CodeEntry".into(),
963      &[
964        ("doc".into(), data.doc.to_owned().into()),
965        ("examples".into(), data.examples.to_owned().into()),
966        ("code".into(), data.code.to_owned().into()),
967        ("schema".into(), schema_edn),
968      ],
969    )
970  }
971}
972
973impl CodeEntry {
974  pub fn from_code(code: Cirru) -> Self {
975    CodeEntry {
976      doc: "".to_owned(),
977      examples: vec![],
978      code,
979      schema: DYNAMIC_TYPE.clone(),
980    }
981  }
982}
983
984fn code_declares_macro(code: &Cirru) -> bool {
985  matches!(code, Cirru::List(items) if matches!(items.first(), Some(Cirru::Leaf(head)) if head.as_ref() == "defmacro"))
986}
987
988fn normalize_schema_for_code(code: &Cirru, schema: &Arc<CalcitTypeAnnotation>) -> Arc<CalcitTypeAnnotation> {
989  let CalcitTypeAnnotation::Fn(fn_annot) = schema.as_ref() else {
990    return schema.clone();
991  };
992
993  if !code_declares_macro(code) || matches!(fn_annot.fn_kind, SchemaKind::Macro) {
994    return schema.clone();
995  }
996
997  Arc::new(CalcitTypeAnnotation::Fn(Arc::new(CalcitFnTypeAnnotation {
998    generics: fn_annot.generics.clone(),
999    arg_types: fn_annot.arg_types.clone(),
1000    return_type: fn_annot.return_type.clone(),
1001    fn_kind: SchemaKind::Macro,
1002    rest_type: fn_annot.rest_type.clone(),
1003  })))
1004}
1005
1006/// structure of `compact.cirru` file
1007#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1008pub struct Snapshot {
1009  pub package: String,
1010  pub about: Option<String>,
1011  pub configs: SnapshotConfigs,
1012  pub entries: HashMap<String, SnapshotConfigs>,
1013  pub files: HashMap<String, FileInSnapShot>,
1014}
1015
1016impl TryFrom<Edn> for SnapshotConfigs {
1017  type Error = String;
1018  fn try_from(data: Edn) -> Result<SnapshotConfigs, String> {
1019    parse_snapshot_configs_with_context(data, "configs")
1020  }
1021}
1022
1023fn parse_snapshot_config_string_field(data: &EdnMapView, key: &str, owner: &str) -> Result<String, String> {
1024  let value = data.get(&Edn::tag(key)).ok_or_else(|| format!("{owner}: missing `:{key}` field"))?;
1025
1026  let text: Arc<str> = value
1027    .to_owned()
1028    .try_into()
1029    .map_err(|e| format!("{owner}.{key}: {e}; got {}", format_edn_preview(value)))?;
1030
1031  if key == "version" && (text.trim().is_empty() || text.as_ref() == "|") {
1032    return Err(format!(
1033      "{owner}.version cannot be empty; check `:configs (:version ...)`; got {}",
1034      format_edn_preview(value)
1035    ));
1036  }
1037
1038  Ok(text.to_string())
1039}
1040
1041fn parse_snapshot_configs_with_context(data: Edn, owner: &str) -> Result<SnapshotConfigs, String> {
1042  let data = data
1043    .view_map()
1044    .map_err(|e| format!("{owner}: failed to parse config map: {e}; got {}", format_edn_preview(&data)))?;
1045
1046  let init_fn = parse_snapshot_config_string_field(&data, "init-fn", owner)?;
1047  let reload_fn = parse_snapshot_config_string_field(&data, "reload-fn", owner)?;
1048
1049  let version = match data.get(&Edn::tag("version")) {
1050    Some(_) => parse_snapshot_config_string_field(&data, "version", owner)?,
1051    None => default_version(),
1052  };
1053
1054  let modules = match data.get(&Edn::tag("modules")) {
1055    Some(value) => from_edn(value.to_owned()).map_err(|e| format!("{owner}.modules: {e}; got {}", format_edn_preview(value)))?,
1056    None => Vec::new(),
1057  };
1058
1059  Ok(SnapshotConfigs {
1060    init_fn,
1061    reload_fn,
1062    modules,
1063    version,
1064  })
1065}
1066
1067fn parse_entries_with_context(data: &Edn) -> Result<HashMap<String, SnapshotConfigs>, String> {
1068  let entries_map = data
1069    .view_map()
1070    .map_err(|e| format!("entries: failed to parse entries map: {e}; got {}", format_edn_preview(data)))?;
1071
1072  let mut entries = HashMap::with_capacity(entries_map.0.len());
1073  for (entry_key, entry_value) in entries_map.0.iter() {
1074    let entry_name: String = from_edn(entry_key.to_owned())
1075      .map_err(|e| format!("entries: failed to parse entry name: {e}; got {}", format_edn_preview(entry_key)))?;
1076    let owner = format!("entries.{entry_name}");
1077    let entry = parse_snapshot_configs_with_context(entry_value.to_owned(), &owner)?;
1078    entries.insert(entry_name, entry);
1079  }
1080
1081  Ok(entries)
1082}
1083
1084/// parse snapshot
1085pub fn load_snapshot_data(data: &Edn, path: &str) -> Result<Snapshot, String> {
1086  let data = data.view_map()?;
1087  let pkg: Arc<str> = data.get_or_nil("package").try_into()?;
1088  let mut files: HashMap<String, FileInSnapShot> = parse_files_with_context(&data.get_or_nil("files"))?;
1089  let about = match data.get_or_nil("about") {
1090    Edn::Nil => None,
1091    value => {
1092      let s: Arc<str> = value.try_into()?;
1093      Some(s.to_string())
1094    }
1095  };
1096  let meta_ns = format!("{pkg}.$meta");
1097  files.insert(meta_ns.to_owned(), gen_meta_ns(&meta_ns, path));
1098  let s = Snapshot {
1099    package: pkg.to_string(),
1100    about,
1101    configs: parse_snapshot_configs_with_context(data.get_or_nil("configs"), "configs")?,
1102    entries: parse_entries_with_context(&data.get_or_nil("entries"))?,
1103    files,
1104  };
1105  Ok(s)
1106}
1107
1108fn parse_code_entry_with_context(data: Edn, owner: &str) -> Result<CodeEntry, String> {
1109  with_type_annotation_warning_context(owner.to_owned(), || data.try_into()).map_err(|e| format!("{owner}: {e}"))
1110}
1111
1112fn parse_file_in_snapshot_with_context(data: Edn, file_name: &str) -> Result<FileInSnapShot, String> {
1113  match data {
1114    Edn::Map(map) => {
1115      let ns_value = map
1116        .get(&Edn::tag("ns"))
1117        .ok_or_else(|| format!("{file_name}: missing `:ns` field in FileEntry"))?;
1118      let defs_value = map
1119        .get(&Edn::tag("defs"))
1120        .ok_or_else(|| format!("{file_name}: missing `:defs` field in FileEntry"))?;
1121
1122      let ns: NsEntry = ns_value
1123        .to_owned()
1124        .try_into()
1125        .map_err(|e: String| format!("{file_name}/:ns: {e}"))?;
1126      let defs_map = defs_value.view_map().map_err(|e| {
1127        format!(
1128          "{file_name}: failed to parse `:defs` as map: {e}; got {}",
1129          format_edn_preview(defs_value)
1130        )
1131      })?;
1132
1133      let mut defs = HashMap::with_capacity(defs_map.0.len());
1134      for (def_key, def_value) in defs_map.0.iter() {
1135        let def_name: String = from_edn(def_key.to_owned())
1136          .map_err(|e| format!("{file_name}: failed to parse def name: {e}; got {}", format_edn_preview(def_key)))?;
1137        let owner = format!("{file_name}/{def_name}");
1138        defs.insert(def_name, parse_code_entry_with_context(def_value.to_owned(), &owner)?);
1139      }
1140
1141      Ok(FileInSnapShot { ns, defs })
1142    }
1143    Edn::Record(record) => {
1144      let mut ns: Option<NsEntry> = None;
1145      let mut defs = HashMap::new();
1146
1147      for (key, value) in record.pairs.iter() {
1148        match key.arc_str().as_ref() {
1149          "ns" => {
1150            ns = Some(value.to_owned().try_into().map_err(|e: String| format!("{file_name}/:ns: {e}"))?);
1151          }
1152          "defs" => {
1153            let defs_map = value.view_map().map_err(|e| {
1154              format!(
1155                "{file_name}: failed to parse `:defs` as map: {e}; got {}",
1156                format_edn_preview(value)
1157              )
1158            })?;
1159            for (def_key, def_value) in defs_map.0.iter() {
1160              let def_name: String = from_edn(def_key.to_owned())
1161                .map_err(|e| format!("{file_name}: failed to parse def name: {e}; got {}", format_edn_preview(def_key)))?;
1162              let owner = format!("{file_name}/{def_name}");
1163              defs.insert(def_name, parse_code_entry_with_context(def_value.to_owned(), &owner)?);
1164            }
1165          }
1166          _ => {}
1167        }
1168      }
1169
1170      Ok(FileInSnapShot {
1171        ns: ns.ok_or_else(|| format!("{file_name}: missing `:ns` field in FileEntry"))?,
1172        defs,
1173      })
1174    }
1175    other => Err(format!(
1176      "{file_name}: expected FileEntry map/record, got {}",
1177      format_edn_preview(&other)
1178    )),
1179  }
1180}
1181
1182fn parse_files_with_context(data: &Edn) -> Result<HashMap<String, FileInSnapShot>, String> {
1183  let files_map = data
1184    .view_map()
1185    .map_err(|e| format!("failed to parse snapshot `:files` as map: {e}; got {}", format_edn_preview(data)))?;
1186  let mut files = HashMap::with_capacity(files_map.0.len());
1187  for (file_key, file_value) in files_map.0.iter() {
1188    let file_name: String = from_edn(file_key.to_owned())
1189      .map_err(|e| format!("failed to parse snapshot file key: {e}; got {}", format_edn_preview(file_key)))?;
1190    files.insert(
1191      file_name.clone(),
1192      parse_file_in_snapshot_with_context(file_value.to_owned(), &file_name)?,
1193    );
1194  }
1195  Ok(files)
1196}
1197
1198pub fn gen_meta_ns(ns: &str, path: &str) -> FileInSnapShot {
1199  let path_data = Path::new(path);
1200  let parent = path_data.parent().expect("parent path");
1201  let parent_str = parent.to_str().expect("get path string");
1202
1203  let def_dict: HashMap<String, CodeEntry> = HashMap::from_iter([
1204    (
1205      "calcit-filename".into(),
1206      CodeEntry::from_code(vec!["def", "calcit-filename", &format!("|{}", path.escape_default())].into()),
1207    ),
1208    (
1209      "calcit-dirname".into(),
1210      CodeEntry::from_code(vec!["def", "calcit-dirname", &format!("|{}", parent_str.escape_default())].into()),
1211    ),
1212  ]);
1213
1214  FileInSnapShot {
1215    ns: NsEntry {
1216      doc: "".to_owned(),
1217      code: vec!["ns", ns].into(),
1218    },
1219    defs: def_dict,
1220  }
1221}
1222
1223impl Default for Snapshot {
1224  fn default() -> Snapshot {
1225    Snapshot {
1226      package: "app".into(),
1227      about: Some(SNAPSHOT_ABOUT_MESSAGE.to_string()),
1228      configs: SnapshotConfigs {
1229        init_fn: "app.main/main!".into(),
1230        reload_fn: "app.main/reload!".into(),
1231        version: "0.0.0".to_string(),
1232        modules: vec![],
1233      },
1234      entries: HashMap::new(),
1235      files: HashMap::new(),
1236    }
1237  }
1238}
1239
1240pub fn create_file_from_snippet(raw: &str) -> Result<FileInSnapShot, String> {
1241  match cirru_parser::parse(raw) {
1242    Ok(lines) => {
1243      let mut ns_code: Cirru = vec!["ns", "app.main"].into();
1244      let mut body_start = 0;
1245      if let Some(Cirru::List(items)) = lines.first()
1246        && let Some(Cirru::Leaf(head)) = items.first()
1247        && &**head == "ns"
1248      {
1249        if items.len() < 2 {
1250          return Err("Invalid `ns` expression in snippet: expected namespace after `ns`".to_string());
1251        }
1252        let mut merged_ns = vec![Cirru::leaf("ns"), Cirru::leaf("app.main")];
1253        merged_ns.extend(items.iter().skip(2).cloned());
1254        ns_code = Cirru::List(merged_ns);
1255        body_start = 1;
1256      }
1257
1258      let mut def_dict: HashMap<String, CodeEntry> = HashMap::with_capacity(2);
1259      let mut func_code = vec![Cirru::leaf("defn"), "main!".into(), Cirru::List(vec![])];
1260      for line in lines.into_iter().skip(body_start) {
1261        func_code.push(line.to_owned());
1262      }
1263      def_dict.insert("main!".into(), CodeEntry::from_code(Cirru::List(func_code)));
1264      def_dict.insert(
1265        "reload!".into(),
1266        CodeEntry::from_code(vec![Cirru::leaf("defn"), "reload!".into(), Cirru::List(vec![])].into()),
1267      );
1268      Ok(FileInSnapShot {
1269        ns: NsEntry {
1270          doc: "".to_owned(),
1271          code: ns_code,
1272        },
1273        defs: def_dict,
1274      })
1275    }
1276    Err(e) => {
1277      eprintln!("\nFailed to parse code snippet:");
1278      eprintln!("{}", e.format_detailed(Some(raw)));
1279      Err("Failed to parse code snippet".to_string())
1280    }
1281  }
1282}
1283
1284#[derive(Debug, PartialEq, Clone, Eq)]
1285pub struct FileChangeInfo {
1286  pub ns: Option<Cirru>,
1287  pub added_defs: HashMap<String, Cirru>,
1288  pub removed_defs: HashSet<String>,
1289  pub changed_defs: HashMap<String, Cirru>,
1290}
1291
1292impl From<&FileChangeInfo> for Edn {
1293  fn from(data: &FileChangeInfo) -> Edn {
1294    let mut map = EdnMapView::default();
1295    if let Some(ns) = &data.ns {
1296      map.insert_key("ns", Edn::Quote(ns.to_owned()));
1297    }
1298
1299    if !data.added_defs.is_empty() {
1300      #[allow(clippy::mutable_key_type)]
1301      let defs: HashMap<Edn, Edn> = data
1302        .added_defs
1303        .iter()
1304        .map(|(name, def)| (Edn::str(&**name), Edn::Quote(def.to_owned())))
1305        .collect();
1306      map.insert_key("added-defs", Edn::from(defs));
1307    }
1308    if !data.removed_defs.is_empty() {
1309      map.insert_key(
1310        "removed-defs",
1311        Edn::Set(EdnSetView(data.removed_defs.iter().map(|s| Edn::str(&**s)).collect())),
1312      );
1313    }
1314    if !data.changed_defs.is_empty() {
1315      map.insert_key(
1316        "changed-defs",
1317        Edn::Map(EdnMapView(
1318          data
1319            .changed_defs
1320            .iter()
1321            .map(|(name, def)| (Edn::str(&**name), Edn::Quote(def.to_owned())))
1322            .collect(),
1323        )),
1324      );
1325    }
1326    map.into()
1327  }
1328}
1329
1330impl From<FileChangeInfo> for Edn {
1331  fn from(data: FileChangeInfo) -> Edn {
1332    // call previous implementation to convert
1333    (&data).into()
1334  }
1335}
1336
1337impl TryFrom<Edn> for FileChangeInfo {
1338  type Error = String;
1339
1340  fn try_from(data: Edn) -> Result<Self, Self::Error> {
1341    let data = data.view_map()?;
1342    Ok(Self {
1343      ns: match data.get_or_nil("ns") {
1344        Edn::Nil => None,
1345        ns => Some(ns.try_into()?),
1346      },
1347      added_defs: data.get_or_nil("added-defs").try_into()?,
1348      removed_defs: data.get_or_nil("removed-defs").try_into()?,
1349      changed_defs: data.get_or_nil("changed-defs").try_into()?,
1350    })
1351  }
1352}
1353
1354/// TODO: Support for :doc and :examples fields has been added, needs to be handled properly
1355#[derive(Debug, PartialEq, Clone, Eq, Default)]
1356pub struct ChangesDict {
1357  pub added: HashMap<Arc<str>, FileInSnapShot>,
1358  pub removed: HashSet<Arc<str>>,
1359  pub changed: HashMap<Arc<str>, FileChangeInfo>,
1360}
1361
1362impl ChangesDict {
1363  pub fn is_empty(&self) -> bool {
1364    self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
1365  }
1366}
1367
1368impl TryFrom<Edn> for ChangesDict {
1369  type Error = String;
1370
1371  fn try_from(data: Edn) -> Result<Self, Self::Error> {
1372    let data = data.view_map()?;
1373    Ok(Self {
1374      added: data.get_or_nil("added").try_into()?,
1375      changed: data.get_or_nil("changed").try_into()?,
1376      removed: data.get_or_nil("removed").try_into()?,
1377    })
1378  }
1379}
1380
1381impl TryFrom<ChangesDict> for Edn {
1382  type Error = String;
1383
1384  fn try_from(x: ChangesDict) -> Result<Edn, Self::Error> {
1385    let mut map = EdnMapView::default();
1386    map.insert_key("added", x.added.into());
1387    map.insert_key("changed", x.changed.into());
1388    map.insert_key("removed", x.removed.into());
1389    Ok(Edn::Map(map))
1390  }
1391}
1392
1393/// Save snapshot to compact.cirru file
1394/// This is a shared utility function used by CLI edit commands
1395pub fn render_snapshot_content(snapshot: &Snapshot) -> Result<String, String> {
1396  validate_snapshot_schemas_for_write(snapshot)?;
1397
1398  // Build root level Edn mapping
1399  let mut edn_map = EdnMapView::default();
1400
1401  // Build package
1402  edn_map.insert_key("package", Edn::Str(snapshot.package.as_str().into()));
1403
1404  // Insert about message (always enforce canonical hint)
1405  edn_map.insert_key("about", Edn::Str(SNAPSHOT_ABOUT_MESSAGE.into()));
1406
1407  // Build configs
1408  let mut configs_map = EdnMapView::default();
1409  configs_map.insert_key("init-fn", Edn::Str(snapshot.configs.init_fn.as_str().into()));
1410  configs_map.insert_key("reload-fn", Edn::Str(snapshot.configs.reload_fn.as_str().into()));
1411  configs_map.insert_key("version", Edn::Str(snapshot.configs.version.as_str().into()));
1412  configs_map.insert_key(
1413    "modules",
1414    Edn::from(
1415      snapshot
1416        .configs
1417        .modules
1418        .iter()
1419        .map(|s| Edn::Str(s.as_str().into()))
1420        .collect::<Vec<_>>(),
1421    ),
1422  );
1423  edn_map.insert_key("configs", configs_map.into());
1424
1425  // Build entries
1426  let mut entries_map = EdnMapView::default();
1427  for (k, v) in &snapshot.entries {
1428    let mut entry_map = EdnMapView::default();
1429    entry_map.insert_key("init-fn", Edn::Str(v.init_fn.as_str().into()));
1430    entry_map.insert_key("reload-fn", Edn::Str(v.reload_fn.as_str().into()));
1431    entry_map.insert_key("version", Edn::Str(v.version.as_str().into()));
1432    entry_map.insert_key(
1433      "modules",
1434      Edn::from(v.modules.iter().map(|s| Edn::Str(s.as_str().into())).collect::<Vec<_>>()),
1435    );
1436    entries_map.insert_key(k.as_str(), entry_map.into());
1437  }
1438  edn_map.insert_key("entries", entries_map.into());
1439
1440  // Build files
1441  let mut files_map = EdnMapView::default();
1442  for (k, v) in &snapshot.files {
1443    // Skip $meta namespaces as they are special and should not be serialized to file
1444    if k.ends_with(".$meta") {
1445      continue;
1446    }
1447    files_map.insert(Edn::str(k.as_str()), Edn::from(v));
1448  }
1449  edn_map.insert_key("files", files_map.into());
1450
1451  let edn_data = Edn::from(edn_map);
1452
1453  // Normalize on AST directly, avoiding parse-after-format roundtrip.
1454  let normalized = normalize_pipe_prefixed_leaf(edn_data.cirru());
1455  let content = cirru_parser::format(std::slice::from_ref(&normalized), true.into())
1456    .map_err(|e| format!("Failed to format snapshot as Cirru: {e}"))?;
1457
1458  validate_serialized_snapshot_content(&content)?;
1459
1460  Ok(content)
1461}
1462
1463fn normalize_pipe_prefixed_leaf(node: Cirru) -> Cirru {
1464  match node {
1465    Cirru::Leaf(token) => {
1466      if let Some(rest) = token.strip_prefix('"') {
1467        Cirru::leaf(format!("|{rest}"))
1468      } else {
1469        Cirru::Leaf(token)
1470      }
1471    }
1472    Cirru::List(items) => Cirru::List(items.into_iter().map(normalize_pipe_prefixed_leaf).collect()),
1473  }
1474}
1475
1476/// Save snapshot to compact.cirru file
1477/// This is a shared utility function used by CLI edit commands
1478pub fn save_snapshot_to_file<P: AsRef<Path>>(compact_cirru_path: P, snapshot: &Snapshot) -> Result<(), String> {
1479  let content = render_snapshot_content(snapshot)?;
1480
1481  // Write to file
1482  std::fs::write(compact_cirru_path, content).map_err(|e| format!("Failed to write compact.cirru: {e}"))?;
1483
1484  Ok(())
1485}
1486
1487#[cfg(test)]
1488mod tests {
1489  use super::*;
1490  use crate::calcit::CalcitFnTypeAnnotation;
1491  use cirru_edn::EdnListView;
1492
1493  use std::fs;
1494
1495  #[test]
1496  fn normalizes_simple_quoted_tokens_to_pipe_prefix() {
1497    let input = "{} (:a \"|&\") (:b \"|56px\") (:c \"|hello-world\")";
1498    let nodes = cirru_parser::parse(input).expect("input should parse");
1499    let output_node = normalize_pipe_prefixed_leaf(nodes[0].to_owned());
1500    let output = cirru_parser::format(std::slice::from_ref(&output_node), true.into()).expect("output should format");
1501    assert_eq!(output.trim(), "{} (:a |&) (:b |56px) (:c |hello-world)");
1502  }
1503
1504  #[test]
1505  fn normalizes_all_quote_prefixed_leaves_from_ast() {
1506    let input = "{} (:a \"|hello world\") (:b \"|line\\nfeed\") (:c \"|x(y)\")";
1507    let nodes = cirru_parser::parse(input).expect("input should parse");
1508    let output_node = normalize_pipe_prefixed_leaf(nodes[0].to_owned());
1509    let output = cirru_parser::format(std::slice::from_ref(&output_node), true.into()).expect("output should format");
1510
1511    let nodes = cirru_parser::parse(&output).expect("normalized output should still be parseable");
1512    let Cirru::List(root_items) = &nodes[0] else {
1513      panic!("expected one root list");
1514    };
1515
1516    for pair in root_items.iter().skip(1) {
1517      let Cirru::List(pair_items) = pair else {
1518        continue;
1519      };
1520      if pair_items.len() < 2 {
1521        continue;
1522      }
1523      let Cirru::Leaf(value) = &pair_items[1] else {
1524        continue;
1525      };
1526      assert!(
1527        value.starts_with('|'),
1528        "expected string leaf to be normalized to pipe-prefix in AST, got: {value}"
1529      );
1530    }
1531  }
1532
1533  #[test]
1534  fn test_examples_field_parsing() {
1535    // 读取实际的 calcit-core.cirru 文件
1536    let core_file_content = fs::read_to_string("src/cirru/calcit-core.cirru").expect("Failed to read calcit-core.cirru");
1537
1538    // 直接解析为 EDN
1539    let edn_data = cirru_edn::parse(&core_file_content).expect("Failed to parse cirru content as EDN");
1540
1541    // 解析为 Snapshot
1542    let snapshot: Snapshot = load_snapshot_data(&edn_data, "calcit-core.cirru").expect("Failed to parse snapshot");
1543
1544    // 验证文件存在
1545    assert!(snapshot.files.contains_key("calcit.core"));
1546
1547    let core_file = &snapshot.files["calcit.core"];
1548
1549    // 验证我们添加了 examples 的函数
1550    let functions_with_examples = vec![
1551      ("+", 2),
1552      ("-", 2),
1553      ("*", 6),
1554      ("/", 2),
1555      ("map", 2),
1556      ("filter", 2),
1557      ("first", 3),
1558      ("count", 3),
1559      ("concat", 1),
1560      ("inc", 2),
1561      ("reduce", 1), // 原本就有的,只有1个example
1562    ];
1563
1564    println!("Verifying examples in calcit-core.cirru:");
1565    for (func_name, expected_count) in functions_with_examples {
1566      if let Some(func_def) = core_file.defs.get(func_name) {
1567        println!("  {}: {} examples", func_name, func_def.examples.len());
1568        assert_eq!(
1569          func_def.examples.len(),
1570          expected_count,
1571          "Function '{func_name}' should have {expected_count} examples"
1572        );
1573      } else {
1574        panic!("Function '{func_name}' not found in calcit.core");
1575      }
1576    }
1577  }
1578
1579  #[test]
1580  fn test_code_entry_with_examples() {
1581    // 创建一个带有 examples 的 CodeEntry
1582    let examples = vec![
1583      Cirru::List(vec![Cirru::leaf("add"), Cirru::leaf("1"), Cirru::leaf("2")]),
1584      Cirru::List(vec![Cirru::leaf("add"), Cirru::leaf("10"), Cirru::leaf("20")]),
1585    ];
1586
1587    let code_entry = CodeEntry {
1588      doc: "Test function".to_string(),
1589      code: Cirru::List(vec![
1590        Cirru::leaf("defn"),
1591        Cirru::leaf("add"),
1592        Cirru::List(vec![Cirru::leaf("a"), Cirru::leaf("b")]),
1593        Cirru::List(vec![Cirru::leaf("+"), Cirru::leaf("a"), Cirru::leaf("b")]),
1594      ]),
1595      examples,
1596      schema: {
1597        let schema_edn = schema_cirru_to_edn(Cirru::List(vec![
1598          Cirru::leaf("{}"),
1599          Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":fn")]),
1600          Cirru::List(vec![Cirru::leaf(":name"), Cirru::leaf("'add")]),
1601          Cirru::List(vec![Cirru::leaf(":args"), Cirru::List(vec![Cirru::leaf("[]")])]),
1602          Cirru::List(vec![Cirru::leaf(":return"), Cirru::leaf(":number")]),
1603        ]));
1604        CalcitTypeAnnotation::parse_fn_schema_from_edn(&schema_edn)
1605          .map(|s| std::sync::Arc::new(CalcitTypeAnnotation::Fn(std::sync::Arc::new(s))))
1606          .unwrap_or_else(|| DYNAMIC_TYPE.clone())
1607      },
1608    };
1609
1610    // 验证 examples 字段
1611    assert_eq!(code_entry.examples.len(), 2);
1612
1613    // 验证第一个 example
1614    if let Cirru::List(list) = &code_entry.examples[0] {
1615      assert_eq!(list.len(), 3);
1616      if let Cirru::Leaf(s) = &list[0] {
1617        assert_eq!(&**s, "add");
1618      }
1619    }
1620
1621    // 转换为 EDN 再转换回来,验证序列化/反序列化
1622    let edn: Edn = code_entry.clone().into();
1623    let parsed_entry: CodeEntry = edn.try_into().expect("Failed to parse CodeEntry from EDN");
1624
1625    assert_eq!(parsed_entry.examples.len(), 2);
1626
1627    // 验证解析后的第一个 example
1628    if let Cirru::List(list) = &parsed_entry.examples[0] {
1629      assert_eq!(list.len(), 3);
1630      if let Cirru::Leaf(s) = &list[0] {
1631        assert_eq!(&**s, "add");
1632      }
1633    }
1634
1635    println!("✅ CodeEntry with examples test passed!");
1636  }
1637
1638  #[test]
1639  fn test_parse_schema_data_valid_and_invalid() {
1640    let valid = Cirru::List(vec![
1641      Cirru::leaf("{}"),
1642      Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":fn")]),
1643      Cirru::List(vec![Cirru::leaf(":name"), Cirru::leaf("'demo")]),
1644      Cirru::List(vec![Cirru::leaf(":args"), Cirru::List(vec![Cirru::leaf("[]")])]),
1645      Cirru::List(vec![Cirru::leaf(":return"), Cirru::leaf(":dynamic")]),
1646    ]);
1647    assert!(parse_schema_data(&valid).is_ok());
1648
1649    let missing_return = Cirru::List(vec![
1650      Cirru::leaf("{}"),
1651      Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":fn")]),
1652      Cirru::List(vec![Cirru::leaf(":name"), Cirru::leaf("'demo")]),
1653      Cirru::List(vec![Cirru::leaf(":args"), Cirru::List(vec![Cirru::leaf("[]")])]),
1654    ]);
1655    assert!(parse_schema_data(&missing_return).is_ok());
1656
1657    let optional_wrapped = Cirru::List(vec![Cirru::leaf(":optional"), valid.clone()]);
1658    assert!(parse_schema_data(&optional_wrapped).is_ok());
1659
1660    let optional_wrapped_by_tuple = Cirru::List(vec![Cirru::leaf("::"), Cirru::leaf(":optional"), valid]);
1661    assert!(parse_schema_data(&optional_wrapped_by_tuple).is_ok());
1662
1663    let invalid_edn = Cirru::List(vec![Cirru::leaf("~"), Cirru::leaf("x")]);
1664    assert!(parse_schema_data(&invalid_edn).is_err());
1665  }
1666
1667  #[test]
1668  fn test_validate_schema_for_write() {
1669    let valid = Cirru::List(vec![
1670      Cirru::leaf("{}"),
1671      Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":fn")]),
1672      Cirru::List(vec![
1673        Cirru::leaf(":args"),
1674        Cirru::List(vec![Cirru::leaf("[]"), Cirru::leaf(":string")]),
1675      ]),
1676      Cirru::List(vec![Cirru::leaf(":return"), Cirru::leaf(":bool")]),
1677    ]);
1678    assert!(validate_schema_for_write(&valid).is_ok(), "valid schema should pass");
1679
1680    let wrapped_macro = Cirru::List(vec![
1681      Cirru::leaf("::"),
1682      Cirru::leaf(":macro"),
1683      Cirru::List(vec![
1684        Cirru::leaf("{}"),
1685        Cirru::List(vec![
1686          Cirru::leaf(":args"),
1687          Cirru::List(vec![Cirru::leaf("[]"), Cirru::leaf(":dynamic")]),
1688        ]),
1689        Cirru::List(vec![Cirru::leaf(":return"), Cirru::leaf(":dynamic")]),
1690      ]),
1691    ]);
1692    assert!(
1693      validate_schema_for_write(&wrapped_macro).is_ok(),
1694      "wrapped macro schema should pass"
1695    );
1696
1697    // Missing :kind
1698    let no_kind = Cirru::List(vec![
1699      Cirru::leaf("{}"),
1700      Cirru::List(vec![Cirru::leaf(":args"), Cirru::List(vec![Cirru::leaf("[]")])]),
1701    ]);
1702    assert!(validate_schema_for_write(&no_kind).is_err(), "missing :kind should fail");
1703
1704    // Unknown field
1705    let unknown_field = Cirru::List(vec![
1706      Cirru::leaf("{}"),
1707      Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":fn")]),
1708      Cirru::List(vec![Cirru::leaf(":foobar"), Cirru::leaf(":dynamic")]),
1709    ]);
1710    assert!(validate_schema_for_write(&unknown_field).is_err(), "unknown field should fail");
1711
1712    // Bad :kind value
1713    let bad_kind = Cirru::List(vec![
1714      Cirru::leaf("{}"),
1715      Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":something-else")]),
1716    ]);
1717    assert!(validate_schema_for_write(&bad_kind).is_err(), "bad :kind value should fail");
1718
1719    // Primitive type tag leaves are now accepted.
1720    let leaf_string = Cirru::Leaf(Arc::from(":string"));
1721    assert!(validate_schema_for_write(&leaf_string).is_ok(), ":string leaf should pass");
1722    let leaf_fn = Cirru::Leaf(Arc::from(":fn"));
1723    assert!(validate_schema_for_write(&leaf_fn).is_ok(), ":fn leaf should pass");
1724    let leaf_number = Cirru::Leaf(Arc::from(":number"));
1725    assert!(validate_schema_for_write(&leaf_number).is_ok(), ":number leaf should pass");
1726
1727    // Unknown leaf (not a known primitive type) must still fail.
1728    let leaf_unknown = Cirru::Leaf(Arc::from(":not-a-type"));
1729    assert!(validate_schema_for_write(&leaf_unknown).is_err(), "unknown leaf should fail");
1730
1731    // Wrong head (still quote-wrapped - must be unwrapped by caller first)
1732    let quote_wrapped = Cirru::List(vec![
1733      Cirru::leaf("quote"),
1734      Cirru::List(vec![Cirru::leaf("{}"), Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":fn")])]),
1735    ]);
1736    assert!(
1737      validate_schema_for_write(&quote_wrapped).is_err(),
1738      "quote-wrapped should fail (caller must unwrap)"
1739    );
1740  }
1741
1742  #[test]
1743  fn test_typevar_consistency_validation() {
1744    // Helper: make a (quote X) node representing 'X type var
1745    fn quote(name: &str) -> Cirru {
1746      Cirru::List(vec![Cirru::leaf("quote"), Cirru::leaf(name)])
1747    }
1748
1749    // Valid: 'T declared and used in both args and return
1750    let valid_generic = Cirru::List(vec![
1751      Cirru::leaf("{}"),
1752      Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":fn")]),
1753      Cirru::List(vec![Cirru::leaf(":generics"), Cirru::List(vec![Cirru::leaf("[]"), quote("T")])]),
1754      Cirru::List(vec![
1755        Cirru::leaf(":args"),
1756        Cirru::List(vec![
1757          Cirru::leaf("[]"),
1758          Cirru::List(vec![Cirru::leaf("::"), Cirru::leaf(":list"), quote("T")]),
1759        ]),
1760      ]),
1761      Cirru::List(vec![Cirru::leaf(":return"), quote("T")]),
1762    ]);
1763    assert!(validate_schema_for_write(&valid_generic).is_ok(), "valid generics should pass");
1764
1765    // Invalid: 'K used in :return but not declared in :generics
1766    let undeclared = Cirru::List(vec![
1767      Cirru::leaf("{}"),
1768      Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":fn")]),
1769      Cirru::List(vec![Cirru::leaf(":generics"), Cirru::List(vec![Cirru::leaf("[]"), quote("T")])]),
1770      Cirru::List(vec![
1771        Cirru::leaf(":args"),
1772        Cirru::List(vec![
1773          Cirru::leaf("[]"),
1774          Cirru::List(vec![Cirru::leaf("::"), Cirru::leaf(":list"), quote("T")]),
1775        ]),
1776      ]),
1777      Cirru::List(vec![Cirru::leaf(":return"), quote("K")]),
1778    ]);
1779    assert!(
1780      validate_schema_for_write(&undeclared).is_err(),
1781      "undeclared type var 'K should fail"
1782    );
1783
1784    // Invalid: 'U declared but never used
1785    let unused_declared = Cirru::List(vec![
1786      Cirru::leaf("{}"),
1787      Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":fn")]),
1788      Cirru::List(vec![
1789        Cirru::leaf(":generics"),
1790        Cirru::List(vec![Cirru::leaf("[]"), quote("T"), quote("U")]),
1791      ]),
1792      Cirru::List(vec![
1793        Cirru::leaf(":args"),
1794        Cirru::List(vec![
1795          Cirru::leaf("[]"),
1796          Cirru::List(vec![Cirru::leaf("::"), Cirru::leaf(":list"), quote("T")]),
1797        ]),
1798      ]),
1799      Cirru::List(vec![Cirru::leaf(":return"), quote("T")]),
1800    ]);
1801    assert!(
1802      validate_schema_for_write(&unused_declared).is_err(),
1803      "unused declared 'U should fail"
1804    );
1805
1806    // Invalid: type var used without any :generics
1807    let typevar_no_generics = Cirru::List(vec![
1808      Cirru::leaf("{}"),
1809      Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":fn")]),
1810      Cirru::List(vec![Cirru::leaf(":args"), Cirru::List(vec![Cirru::leaf("[]"), quote("T")])]),
1811      Cirru::List(vec![Cirru::leaf(":return"), quote("T")]),
1812    ]);
1813    assert!(
1814      validate_schema_for_write(&typevar_no_generics).is_err(),
1815      "type var without :generics should fail"
1816    );
1817  }
1818
1819  #[test]
1820  fn test_schema_cirru_to_edn_no_quote_wrapper() {
1821    let schema = Cirru::List(vec![
1822      Cirru::leaf("{}"),
1823      Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":fn")]),
1824      Cirru::List(vec![Cirru::leaf(":return"), Cirru::leaf(":string")]),
1825    ]);
1826    let edn = schema_cirru_to_edn(schema);
1827    assert!(!matches!(edn, Edn::Nil), "should not produce Nil for valid schema");
1828    assert!(
1829      !matches!(edn, Edn::Quote(_)),
1830      "output must NOT be Quote-wrapped (new direct-map format)"
1831    );
1832  }
1833
1834  #[test]
1835  fn test_schema_generics_round_trip_uses_single_quote_source_syntax() {
1836    let schema_text = "{} (:kind :fn) (:args ([] :number)) (:generics ([] 'T)) (:return :number)";
1837    let schema_cirru = cirru_parser::parse(schema_text)
1838      .expect("should parse")
1839      .into_iter()
1840      .next()
1841      .expect("should have one node");
1842
1843    let schema_edn = schema_cirru_to_edn(schema_cirru);
1844    let fn_schema = CalcitTypeAnnotation::parse_fn_schema_from_edn(&schema_edn).expect("must parse generic schema");
1845    assert_eq!(fn_schema.generics.as_ref(), &[Arc::from("T")]);
1846
1847    let saved_edn = fn_schema.to_schema_edn();
1848    let Edn::Map(saved_map) = &saved_edn else {
1849      panic!("saved schema must be a map, got {saved_edn:?}");
1850    };
1851    let Some(Edn::List(generics)) = saved_map.tag_get("generics") else {
1852      panic!("saved schema must contain :generics, got {saved_edn:?}");
1853    };
1854    assert_eq!(generics.0, vec![Edn::Symbol(Arc::from("T"))]);
1855
1856    let saved_cirru = schema_edn_to_cirru(&saved_edn).expect("schema edn to cirru");
1857    validate_schema_for_write(&saved_cirru).expect("saved schema should still be writable");
1858    let saved_text = cirru_parser::format(&[saved_cirru], true.into()).expect("format schema");
1859    assert!(
1860      saved_text.contains(":generics $ [] 'T"),
1861      "saved schema should use single-quoted source syntax: {saved_text}"
1862    );
1863    assert!(
1864      !saved_text.contains("''T"),
1865      "saved schema must not contain double-leading-quote generics: {saved_text}"
1866    );
1867  }
1868
1869  #[test]
1870  fn test_schema_named_type_refs_round_trip_without_becoming_type_vars() {
1871    let schema_text = "{} (:kind :fn) (:generics ([] 'T 'E)) (:args ([] 'T)) (:return (:: 'Result 'T 'E))";
1872    let schema_cirru = cirru_parser::parse(schema_text)
1873      .expect("should parse")
1874      .into_iter()
1875      .next()
1876      .expect("should have one node");
1877
1878    let schema_edn = schema_cirru_to_edn(schema_cirru);
1879    let fn_schema = CalcitTypeAnnotation::parse_fn_schema_from_edn(&schema_edn).expect("must parse named ref schema");
1880
1881    assert!(
1882      matches!(fn_schema.arg_types.first().map(|t| t.as_ref()), Some(CalcitTypeAnnotation::TypeVar(name)) if name.as_ref() == "T")
1883    );
1884    assert!(
1885      matches!(fn_schema.return_type.as_ref(), CalcitTypeAnnotation::TypeRef(name, args) if name.as_ref() == "Result" && args.len() == 2)
1886    );
1887
1888    let saved_text = cirru_parser::format(
1889      &[schema_edn_to_cirru(&fn_schema.to_schema_edn()).expect("schema edn to cirru")],
1890      true.into(),
1891    )
1892    .expect("format schema");
1893    assert!(
1894      saved_text.contains(":return $ :: 'Result 'T 'E"),
1895      "saved schema should keep named type reference syntax: {saved_text}"
1896    );
1897  }
1898
1899  #[test]
1900  fn test_normalize_schema_rejects_legacy_quoted_generic_symbol() {
1901    let schema = Edn::Map(EdnMapView::from(HashMap::from([
1902      (Edn::tag("kind"), Edn::tag("fn")),
1903      (Edn::tag("args"), Edn::List(cirru_edn::EdnListView(vec![Edn::tag("number")]))),
1904      (
1905        Edn::tag("generics"),
1906        Edn::List(cirru_edn::EdnListView(vec![Edn::Symbol(Arc::from("'T"))])),
1907      ),
1908      (Edn::tag("return"), Edn::tag("number")),
1909    ])));
1910
1911    let err = normalize_schema_edn(&schema).expect_err("legacy quoted generic symbol should fail on load");
1912    assert!(err.contains("invalid schema generic symbol"), "unexpected error: {err}");
1913  }
1914
1915  #[test]
1916  fn test_schema_write_rejects_double_quoted_generics() {
1917    let schema_text = "{} (:kind :fn) (:args ([] :number)) (:generics ([] ''T)) (:return :number)";
1918    let schema_cirru = cirru_parser::parse(schema_text)
1919      .expect("should parse")
1920      .into_iter()
1921      .next()
1922      .expect("should have one node");
1923
1924    let err = validate_schema_for_write(&schema_cirru).expect_err("double-quoted generic should be rejected");
1925    assert!(err.contains("excess leading quotes"), "unexpected error: {err}");
1926  }
1927
1928  #[test]
1929  fn test_normalize_schema_rejects_quoted_singleton_list() {
1930    let quoted = Edn::Quote(Cirru::List(vec![
1931      Cirru::leaf("[]"),
1932      Cirru::List(vec![
1933        Cirru::leaf("{}"),
1934        Cirru::List(vec![Cirru::leaf(":kind"), Cirru::leaf(":fn")]),
1935        Cirru::List(vec![Cirru::leaf(":args"), Cirru::List(vec![Cirru::leaf("[]")])]),
1936        Cirru::List(vec![Cirru::leaf(":return"), Cirru::leaf(":dynamic")]),
1937      ]),
1938    ]));
1939
1940    let err = normalize_schema_edn(&quoted).expect_err("legacy quoted schema should be rejected");
1941    assert!(err.contains("invalid schema"), "unexpected error: {err}");
1942  }
1943
1944  #[test]
1945  fn test_normalize_schema_unwraps_wrapped_fn_tuple() {
1946    let wrapped = Edn::tuple(
1947      Edn::tag("fn"),
1948      vec![Edn::Map(EdnMapView::from(HashMap::from([
1949        (Edn::tag("kind"), Edn::tag("fn")),
1950        (Edn::tag("args"), Edn::List(EdnListView(vec![]))),
1951        (Edn::tag("return"), Edn::tag("dynamic")),
1952      ])))],
1953    );
1954
1955    let normalized = normalize_schema_edn(&wrapped).expect("wrapped schema should normalize");
1956    let Edn::Map(map) = normalized else {
1957      panic!("normalized schema should be a map");
1958    };
1959    assert!(matches!(map.tag_get("kind"), Some(Edn::Tag(tag)) if tag.ref_str() == "fn"));
1960  }
1961
1962  #[test]
1963  fn test_normalize_schema_unwraps_wrapped_macro_tuple() {
1964    let wrapped = Edn::tuple(
1965      Edn::tag("macro"),
1966      vec![Edn::Map(EdnMapView::from(HashMap::from([
1967        (Edn::tag("args"), Edn::List(EdnListView(vec![]))),
1968        (Edn::tag("return"), Edn::tag("dynamic")),
1969      ])))],
1970    );
1971
1972    let normalized = normalize_schema_edn(&wrapped).expect("wrapped macro schema should normalize");
1973    let Edn::Map(map) = normalized else {
1974      panic!("normalized schema should be a map");
1975    };
1976    assert!(matches!(map.tag_get("kind"), Some(Edn::Tag(tag)) if tag.ref_str() == "macro"));
1977  }
1978
1979  #[test]
1980  fn test_normalize_schema_canonicalizes_string_keys_and_kind_values() {
1981    let wrapped = Edn::tuple(
1982      Edn::tag("fn"),
1983      vec![Edn::Map(EdnMapView::from(HashMap::from([
1984        (Edn::Str(Arc::from(":args")), Edn::List(EdnListView(vec![Edn::tag("set")]))),
1985        (Edn::Str(Arc::from(":return")), Edn::tag("bool")),
1986        (Edn::Str(Arc::from(":kind")), Edn::Str(Arc::from(":fn"))),
1987      ])))],
1988    );
1989
1990    let normalized = normalize_schema_edn(&wrapped).expect("string-key schema should normalize");
1991    let Edn::Map(map) = normalized else {
1992      panic!("normalized schema should be a map");
1993    };
1994
1995    assert!(matches!(map.tag_get("args"), Some(Edn::List(_))));
1996    assert!(matches!(map.tag_get("return"), Some(Edn::Tag(tag)) if tag.ref_str() == "bool"));
1997    assert!(matches!(map.tag_get("kind"), Some(Edn::Tag(tag)) if tag.ref_str() == "fn"));
1998    assert!(CalcitTypeAnnotation::parse_fn_schema_from_edn(&Edn::Map(map)).is_some());
1999  }
2000
2001  #[test]
2002  fn test_macro_schema_full_file_round_trip() {
2003    use crate::calcit::SchemaKind;
2004    // Simulate saving + loading via the actual file format:
2005    // 1. Write a CodeEntry with :kind :macro schema to Edn (as done by save_snapshot_to_file)
2006    // 2. Format it to Cirru string (via cirru_edn::format)
2007    // 3. Parse it back (via cirru_edn::parse)
2008    // 4. TryFrom<Edn> for CodeEntry
2009    // 5. Check entry.schema is Fn with fn_kind: Macro
2010
2011    let schema_text = "{} (:kind :macro) (:return :bool) (:args ([] :number :number))";
2012    let schema_cirru = cirru_parser::parse(schema_text)
2013      .expect("should parse")
2014      .into_iter()
2015      .next()
2016      .expect("should have one node");
2017    let schema_edn = schema_cirru_to_edn(schema_cirru);
2018
2019    let fn_schema = CalcitTypeAnnotation::parse_fn_schema_from_edn(&schema_edn).expect("must parse");
2020    assert_eq!(fn_schema.fn_kind, SchemaKind::Macro);
2021
2022    // Build a minimal CodeEntry with this schema
2023    let entry = CodeEntry {
2024      doc: "test fn".to_owned(),
2025      examples: vec![],
2026      code: vec!["defmacro", "test-fn", "(a b)", "nil"].into(),
2027      schema: std::sync::Arc::new(CalcitTypeAnnotation::Fn(std::sync::Arc::new(fn_schema))),
2028    };
2029
2030    // Serialize to Edn (as From<&CodeEntry> for Edn does)
2031    let entry_edn: Edn = Edn::from(&entry);
2032
2033    // Format to Cirru string and parse back (as save_snapshot + load_snapshot do)
2034    let cirru_text = cirru_edn::format(&entry_edn, true).expect("format should succeed");
2035    assert!(
2036      cirru_text.contains(":schema $ :: :macro"),
2037      "macro schema should use wrapped :macro tag: {cirru_text}"
2038    );
2039    assert!(
2040      !cirru_text.contains(":return"),
2041      "macro schema should omit redundant return field during serialization: {cirru_text}"
2042    );
2043    let parsed_edn = cirru_edn::parse(&cirru_text).expect("parse should succeed");
2044
2045    // Deserialize back to CodeEntry
2046    let reloaded: CodeEntry = parsed_edn.try_into().expect("TryFrom<Edn> should succeed");
2047
2048    // Check the schema was preserved
2049    match reloaded.schema.as_ref() {
2050      CalcitTypeAnnotation::Fn(fn_annot) => {
2051        assert_eq!(
2052          fn_annot.fn_kind,
2053          SchemaKind::Macro,
2054          "fn_kind must survive round-trip; cirru_text: {cirru_text:?}"
2055        );
2056        assert_eq!(fn_annot.arg_types.len(), 2, "arg_types must survive round-trip");
2057      }
2058      other => panic!("schema must be Fn after round-trip, got {other:?}; cirru_text: {cirru_text:?}"),
2059    }
2060  }
2061
2062  #[test]
2063  fn test_code_entry_serializes_schema_as_wrapped_fn() {
2064    use crate::calcit::SchemaKind;
2065
2066    let entry = CodeEntry {
2067      doc: "wrapped schema".to_owned(),
2068      examples: vec![],
2069      code: vec!["defn", "wrapped", "()", "nil"].into(),
2070      schema: std::sync::Arc::new(CalcitTypeAnnotation::Fn(std::sync::Arc::new(CalcitFnTypeAnnotation {
2071        generics: std::sync::Arc::new(vec![]),
2072        arg_types: vec![],
2073        return_type: crate::calcit::DYNAMIC_TYPE.clone(),
2074        fn_kind: SchemaKind::Fn,
2075        rest_type: None,
2076      }))),
2077    };
2078
2079    let entry_edn: Edn = Edn::from(&entry);
2080    let schema = match entry_edn {
2081      Edn::Record(record) => record
2082        .pairs
2083        .iter()
2084        .find(|(k, _)| k.arc_str().as_ref() == "schema")
2085        .map(|(_, v)| v.to_owned())
2086        .expect("schema field should exist"),
2087      _ => panic!("expected record edn"),
2088    };
2089
2090    let Edn::Tuple(view) = schema else {
2091      panic!("top-level schema should serialize as wrapped fn tuple");
2092    };
2093    assert!(matches!(view.tag.as_ref(), Edn::Tag(tag) if tag.ref_str() == "fn"));
2094    let Some(Edn::Map(map)) = view.extra.first() else {
2095      panic!("wrapped schema payload should be a map");
2096    };
2097    assert!(
2098      map.tag_get("kind").is_none(),
2099      "wrapped plain fn schema should omit redundant :kind :fn"
2100    );
2101  }
2102
2103  #[test]
2104  fn test_code_entry_serializes_macro_rest_schema_without_losing_rest() {
2105    use crate::calcit::SchemaKind;
2106
2107    let entry = CodeEntry {
2108      doc: "wrapped macro schema".to_owned(),
2109      examples: vec![],
2110      code: vec!["defmacro", "wrapped", "(& body)", "nil"].into(),
2111      schema: std::sync::Arc::new(CalcitTypeAnnotation::Fn(std::sync::Arc::new(CalcitFnTypeAnnotation {
2112        generics: std::sync::Arc::new(vec![]),
2113        arg_types: vec![crate::calcit::DYNAMIC_TYPE.clone()],
2114        return_type: crate::calcit::DYNAMIC_TYPE.clone(),
2115        fn_kind: SchemaKind::Macro,
2116        rest_type: Some(crate::calcit::DYNAMIC_TYPE.clone()),
2117      }))),
2118    };
2119
2120    let entry_edn: Edn = Edn::from(&entry);
2121    let schema = match entry_edn {
2122      Edn::Record(record) => record
2123        .pairs
2124        .iter()
2125        .find(|(k, _)| k.arc_str().as_ref() == "schema")
2126        .map(|(_, v)| v.to_owned())
2127        .expect("schema field should exist"),
2128      _ => panic!("expected record edn"),
2129    };
2130
2131    let Edn::Tuple(view) = schema else {
2132      panic!("top-level schema should serialize as wrapped macro tuple");
2133    };
2134    assert!(matches!(view.tag.as_ref(), Edn::Tag(tag) if tag.ref_str() == "macro"));
2135    let Some(Edn::Map(map)) = view.extra.first() else {
2136      panic!("wrapped schema payload should be a map");
2137    };
2138    assert!(
2139      map.tag_get("kind").is_none(),
2140      "wrapped macro schema should omit redundant inner :kind"
2141    );
2142    assert!(map.tag_get("return").is_none(), "wrapped macro schema should omit redundant return");
2143    assert!(matches!(map.tag_get("rest"), Some(Edn::Tag(tag)) if tag.ref_str() == "dynamic"));
2144  }
2145
2146  #[test]
2147  fn test_defmacro_code_normalizes_fn_schema_kind_on_load() {
2148    let code = cirru_parser::parse("defmacro demo (x) x")
2149      .expect("should parse code")
2150      .into_iter()
2151      .next()
2152      .expect("should have one node");
2153    let schema = Edn::tuple(
2154      Edn::tag("fn"),
2155      vec![Edn::Map(EdnMapView::from(HashMap::from([
2156        (Edn::tag("args"), Edn::List(EdnListView(vec![Edn::tag("dynamic")]))),
2157        (Edn::tag("return"), Edn::tag("dynamic")),
2158      ])))],
2159    );
2160
2161    let entry = Edn::record_from_pairs(
2162      "CodeEntry".into(),
2163      &[
2164        ("doc".into(), Edn::Str(Arc::from(""))),
2165        ("examples".into(), Edn::List(EdnListView(vec![]))),
2166        ("code".into(), code.into()),
2167        ("schema".into(), schema),
2168      ],
2169    );
2170
2171    let entry: CodeEntry = entry.try_into().expect("code entry should parse");
2172    let CalcitTypeAnnotation::Fn(fn_annot) = entry.schema.as_ref() else {
2173      panic!("schema should be fn-like");
2174    };
2175    assert_eq!(fn_annot.fn_kind, SchemaKind::Macro);
2176  }
2177
2178  #[test]
2179  fn test_macro_schema_round_trip() {
2180    use crate::calcit::SchemaKind;
2181    // Simulate writing a :kind :macro schema and reading it back
2182    let schema_text = "{} (:kind :macro) (:return :bool) (:args ([] :number :number))";
2183    let schema_cirru = cirru_parser::parse(schema_text)
2184      .expect("should parse")
2185      .into_iter()
2186      .next()
2187      .expect("should have one node");
2188
2189    // Convert to EDN (as done by handle_schema)
2190    let schema_edn = schema_cirru_to_edn(schema_cirru);
2191    assert!(!matches!(schema_edn, Edn::Nil), "schema_edn must not be Nil: {schema_edn:?}");
2192
2193    // Parse the schema (as done when reading back)
2194    let fn_schema = CalcitTypeAnnotation::parse_fn_schema_from_edn(&schema_edn);
2195    assert!(
2196      fn_schema.is_some(),
2197      "parse_fn_schema_from_edn must return Some for macro schema; schema_edn={schema_edn:?}"
2198    );
2199    let fn_schema = fn_schema.unwrap();
2200    assert_eq!(fn_schema.fn_kind, SchemaKind::Macro, "fn_kind must be Macro");
2201    assert_eq!(fn_schema.arg_types.len(), 2, "must have 2 arg types");
2202
2203    // Simulate a save (to_schema_edn) + reload
2204    let saved_edn = fn_schema.to_schema_edn();
2205    let fn_schema2 = CalcitTypeAnnotation::parse_fn_schema_from_edn(&saved_edn);
2206    assert!(
2207      fn_schema2.is_some(),
2208      "reload: parse_fn_schema_from_edn must return Some; saved_edn={saved_edn:?}"
2209    );
2210    let fn_schema2 = fn_schema2.unwrap();
2211    assert_eq!(fn_schema2.fn_kind, SchemaKind::Macro, "reload: fn_kind must be Macro");
2212    assert_eq!(fn_schema2.arg_types.len(), 2, "reload: must have 2 arg types");
2213
2214    // Simulate normalize_schema_edn path (as used in TryFrom<Edn> for CodeEntry)
2215    let normalized = normalize_schema_edn(&saved_edn).expect("normalize must succeed");
2216    let fn_schema3 = CalcitTypeAnnotation::parse_fn_schema_from_edn(&normalized);
2217    assert!(
2218      fn_schema3.is_some(),
2219      "normalized: parse_fn_schema_from_edn must return Some; normalized={normalized:?}"
2220    );
2221    let fn_schema3 = fn_schema3.unwrap();
2222    assert_eq!(fn_schema3.fn_kind, SchemaKind::Macro, "normalized: fn_kind must be Macro");
2223  }
2224
2225  #[test]
2226  fn test_load_snapshot_preserves_selected_real_world_schemas() {
2227    let core_file_content = fs::read_to_string("src/cirru/calcit-core.cirru").expect("Failed to read calcit-core.cirru");
2228    let edn_data = cirru_edn::parse(&core_file_content).expect("Failed to parse cirru content as EDN");
2229    let snapshot = load_snapshot_data(&edn_data, "src/cirru/calcit-core.cirru").expect("Failed to parse snapshot");
2230
2231    let core_file = snapshot.files.get("calcit.core").expect("calcit.core file should exist");
2232
2233    for def_name in [
2234      "&+",
2235      "%{}",
2236      "deftrait",
2237      "[,]",
2238      "not",
2239      "not=",
2240      "noted",
2241      "nth",
2242      "number?",
2243      "option:map",
2244      "optionally",
2245    ] {
2246      let entry = core_file.defs.get(def_name).unwrap_or_else(|| panic!("missing def: {def_name}"));
2247      assert!(
2248        matches!(entry.schema.as_ref(), CalcitTypeAnnotation::Fn(_)),
2249        "schema for {def_name} should stay fn-like after load, got {:?}",
2250        entry.schema
2251      );
2252    }
2253  }
2254
2255  #[test]
2256  fn test_save_snapshot_round_trip_keeps_real_world_schema_markers() {
2257    let core_file_content = fs::read_to_string("src/cirru/calcit-core.cirru").expect("Failed to read calcit-core.cirru");
2258    let edn_data = cirru_edn::parse(&core_file_content).expect("Failed to parse cirru content as EDN");
2259    let snapshot = load_snapshot_data(&edn_data, "src/cirru/calcit-core.cirru").expect("Failed to parse snapshot");
2260
2261    let temp_path = std::env::temp_dir().join(format!("calcit-schema-roundtrip-{}.cirru", std::process::id()));
2262
2263    save_snapshot_to_file(&temp_path, &snapshot).expect("round-trip save should succeed");
2264    let saved = fs::read_to_string(&temp_path).expect("should read saved snapshot");
2265    let saved_edn = cirru_edn::parse(&saved).expect("saved snapshot should remain valid EDN");
2266    let saved_snapshot =
2267      load_snapshot_data(&saved_edn, temp_path.to_str().expect("temp path should be utf-8")).expect("saved snapshot should load again");
2268
2269    let source_core_file = snapshot.files.get("calcit.core").expect("source calcit.core file should exist");
2270    let saved_core_file = saved_snapshot
2271      .files
2272      .get("calcit.core")
2273      .expect("saved calcit.core file should exist");
2274
2275    for def_name in ["&+", "%{}", "not", "not=", "noted", "nth", "number?", "option:map", "optionally"] {
2276      let source_entry = source_core_file
2277        .defs
2278        .get(def_name)
2279        .unwrap_or_else(|| panic!("missing source def: {def_name}"));
2280      let saved_entry = saved_core_file
2281        .defs
2282        .get(def_name)
2283        .unwrap_or_else(|| panic!("missing saved def: {def_name}"));
2284      assert_eq!(saved_entry.schema, source_entry.schema, "schema should round-trip for {def_name}");
2285    }
2286
2287    let _ = fs::remove_file(&temp_path);
2288
2289    assert!(
2290      saved.contains("|&+ $ %{} :CodeEntry") && saved.contains(":schema $ :: :fn"),
2291      "saved snapshot should retain wrapped fn schemas"
2292    );
2293    assert!(
2294      saved.contains("|%{} $ %{} :CodeEntry") && saved.contains(":schema $ :: :macro"),
2295      "saved snapshot should retain wrapped macro schemas"
2296    );
2297  }
2298
2299  #[test]
2300  fn test_validate_serialized_snapshot_content_rejects_double_quoted_generics() {
2301    let content = r#"{} (:package |mini)
2302  :configs $ {} (:init-fn |mini/main!) (:reload-fn |mini/main!) (:version |0.0.0)
2303    :modules $ []
2304  :entries $ {}
2305  :files $ {}
2306    |mini $ %{} :FileEntry
2307      :ns $ %{} :CodeEntry (:doc |) (:code $ quote (ns mini)) (:examples $ []) (:schema nil)
2308      :defs $ {}
2309        |main! $ %{} :CodeEntry (:doc |)
2310          :code $ quote (defn main! (x) x)
2311          :examples $ []
2312          :schema $ {} (:kind :fn) (:args $ [] :dynamic) (:generics $ [] ''T) (:return :dynamic)
2313"#;
2314
2315    let err = validate_serialized_snapshot_content(content).expect_err("serialized snapshot should reject double-quoted generics");
2316    assert!(
2317      err.contains("serialized snapshot has invalid `:schema`") && err.contains("excess leading quotes"),
2318      "unexpected error: {err}"
2319    );
2320  }
2321
2322  #[test]
2323  fn test_load_snapshot_reports_empty_configs_version_with_field_context() {
2324    let content = r#"{} (:package |mini)
2325  :configs $ {} (:init-fn |mini/main!) (:reload-fn |mini/main!) (:version ||)
2326    :modules $ []
2327  :entries $ {}
2328  :files $ {}
2329    |mini $ %{} :FileEntry
2330      :ns $ %{} :CodeEntry (:doc |) (:code $ quote (ns mini)) (:examples $ []) (:schema nil)
2331      :defs $ {}
2332        |main! $ %{} :CodeEntry (:doc |)
2333          :code $ quote (defn main! () nil)
2334          :examples $ []
2335          :schema nil
2336"#;
2337
2338    let edn_data = cirru_edn::parse(content).expect("snapshot text should parse as EDN");
2339    let err = load_snapshot_data(&edn_data, "mini.cirru").expect_err("empty configs.version should fail on load");
2340
2341    assert!(err.contains("configs.version cannot be empty"), "unexpected error: {err}");
2342    assert!(
2343      err.contains(":configs (:version ...)") || err.contains("got ||"),
2344      "unexpected error: {err}"
2345    );
2346  }
2347}