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))], })
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
288mod 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 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
433fn 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
523pub 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 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
569pub 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
654pub const VALID_SCHEMA_FIELDS: &[&str] = &[":kind", ":args", ":return", ":rest", ":generics"];
656
657fn 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
674fn check_no_excess_quotes(node: &Cirru) -> Result<(), String> {
678 match node {
679 Cirru::Leaf(s) => {
680 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
702fn 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
721fn parse_generics_vars(node: &Cirru) -> HashSet<String> {
724 let mut vars = HashSet::new();
725 if let Cirru::List(items) = node {
726 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
738pub const PRIMITIVE_SCHEMA_TAGS: &[&str] = &[
740 "bool", "number", "string", "symbol", "tag", "list", "map", "set", "fn", "tuple", "ref", "buffer", "dynamic", "unit",
741];
742
743pub 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 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 parse_schema_data(schema)?;
797
798 check_no_nil_type(schema)?;
800
801 check_no_excess_quotes(schema)?;
803
804 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 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 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 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 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 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 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 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#[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
1084pub 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 (&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#[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
1393pub fn render_snapshot_content(snapshot: &Snapshot) -> Result<String, String> {
1396 validate_snapshot_schemas_for_write(snapshot)?;
1397
1398 let mut edn_map = EdnMapView::default();
1400
1401 edn_map.insert_key("package", Edn::Str(snapshot.package.as_str().into()));
1403
1404 edn_map.insert_key("about", Edn::Str(SNAPSHOT_ABOUT_MESSAGE.into()));
1406
1407 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 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 let mut files_map = EdnMapView::default();
1442 for (k, v) in &snapshot.files {
1443 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 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
1476pub 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 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 let core_file_content = fs::read_to_string("src/cirru/calcit-core.cirru").expect("Failed to read calcit-core.cirru");
1537
1538 let edn_data = cirru_edn::parse(&core_file_content).expect("Failed to parse cirru content as EDN");
1540
1541 let snapshot: Snapshot = load_snapshot_data(&edn_data, "calcit-core.cirru").expect("Failed to parse snapshot");
1543
1544 assert!(snapshot.files.contains_key("calcit.core"));
1546
1547 let core_file = &snapshot.files["calcit.core"];
1548
1549 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), ];
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 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 assert_eq!(code_entry.examples.len(), 2);
1612
1613 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 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 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 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 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 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 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 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 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("e_wrapped).is_err(),
1738 "quote-wrapped should fail (caller must unwrap)"
1739 );
1740 }
1741
1742 #[test]
1743 fn test_typevar_consistency_validation() {
1744 fn quote(name: &str) -> Cirru {
1746 Cirru::List(vec![Cirru::leaf("quote"), Cirru::leaf(name)])
1747 }
1748
1749 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 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 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 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("ed).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 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 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 let entry_edn: Edn = Edn::from(&entry);
2032
2033 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 let reloaded: CodeEntry = parsed_edn.try_into().expect("TryFrom<Edn> should succeed");
2047
2048 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 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 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 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 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 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}