Skip to main content

iac_forge/
sexpr.rs

1//! Canonical s-expression interchange for the platform-independent IR.
2//!
3//! Typed ASTs remain the construction surface — you can never build an
4//! invalid `IacResource`, an unbalanced `RubyNode`, an unterminated class.
5//! But every typed value can also emit a canonical, human-readable
6//! s-expression form and round-trip through it.
7//!
8//! The interchange unlocks:
9//! - **Attestation**: tameshi/sekiban can hash a canonical form that is
10//!   the same across machines, languages, and revisions.
11//! - **Scripting**: the same reader pattern used by
12//!   [`crate::transform::script`] operates over typed IR values.
13//! - **Cross-process**: emit as sexpr, send over a socket, re-parse on
14//!   the other side — same shape guaranteed.
15//! - **Audit queries**: shinryu can store an IR snapshot as text and
16//!   SQL over it without losing structure.
17//!
18//! Non-goals:
19//! - **Wire efficiency** — this is a human-readable, debuggable format.
20//!   For bytes-on-the-wire use serde_json / protobuf.
21//! - **Schema evolution** — every round-trip must be lossless against
22//!   the *current* type definitions. Adding a field means updating impls.
23//!
24//! # Format
25//!
26//! - **Unit enum variants**: the variant tag as a kebab-case symbol.
27//!   `IacType::String` → `string`.
28//! - **Tuple enum variants**: `(tag arg1 arg2 …)`.
29//!   `IacType::List(inner)` → `(list <inner>)`.
30//! - **Struct enum variants**: `(tag (:field val) …)`.
31//!   `IacType::Enum { values, underlying }` →
32//!   `(enum (:values (list "tcp" "udp")) (:underlying string))`.
33//! - **Structs**: `(struct-name (:field val) …)`.
34//!   `IacAttribute { api_name: "x", … }` →
35//!   `(attribute (:api-name "x") …)`.
36//! - **Vec**: `(list item1 item2 …)`. Empty vec: `(list)`.
37//! - **Option**: `nil` for None, the value otherwise.
38//! - **String**: double-quoted with `\n`, `\t`, `\"`, `\\` escapes.
39//! - **Bool**: `true` / `false` symbols.
40//! - **Integer / Float**: numeric literals. Integers have no decimal point.
41//!
42//! # Examples
43//!
44//! ```
45//! use iac_forge::sexpr::{FromSExpr, SExpr, ToSExpr};
46//! use iac_forge::ir::IacType;
47//!
48//! let ty = IacType::List(Box::new(IacType::Integer));
49//! let s = ty.to_sexpr();
50//! assert_eq!(s.emit(), "(list integer)");
51//!
52//! let roundtrip = IacType::from_sexpr(&SExpr::parse("(list integer)").unwrap()).unwrap();
53//! assert_eq!(ty, roundtrip);
54//! ```
55
56use std::fmt;
57
58/// BLAKE3 content hash over a canonical emission.
59///
60/// Displayed as lowercase hex (64 chars). Constructed only via
61/// [`SExpr::content_hash`] to guarantee the hash always corresponds to
62/// canonical emission bytes.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub struct ContentHash(pub [u8; 32]);
65
66impl ContentHash {
67    /// Lowercase hex string (64 chars).
68    #[must_use]
69    pub fn to_hex(&self) -> String {
70        crate::hex::encode(&self.0)
71    }
72}
73
74impl fmt::Display for ContentHash {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str(&self.to_hex())
77    }
78}
79
80/// Canonical s-expression value.
81#[derive(Debug, Clone, PartialEq)]
82pub enum SExpr {
83    /// Identifier / variant tag / keyword label (with `:` prefix).
84    Symbol(String),
85    /// Double-quoted string literal.
86    String(String),
87    /// Integer literal (no decimal point).
88    Integer(i64),
89    /// Float literal (has decimal point or exponent).
90    Float(f64),
91    /// `true` or `false`.
92    Bool(bool),
93    /// `nil` — serves as both "empty" and "None".
94    Nil,
95    /// `(...)` form.
96    List(Vec<SExpr>),
97}
98
99/// Conversion errors during `FromSExpr` parsing.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum SExprError {
102    /// Top-level parse failure (bad tokens, unbalanced parens, etc.).
103    Parse(String),
104    /// Value shape didn't match what the destination type expected.
105    Shape(String),
106    /// A named field required by the destination was missing.
107    MissingField(String),
108    /// Unknown / unsupported variant tag.
109    UnknownVariant(String),
110}
111
112impl fmt::Display for SExprError {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        match self {
115            Self::Parse(s) => write!(f, "sexpr parse: {s}"),
116            Self::Shape(s) => write!(f, "sexpr shape: {s}"),
117            Self::MissingField(s) => write!(f, "sexpr missing field: {s}"),
118            Self::UnknownVariant(s) => write!(f, "sexpr unknown variant: {s}"),
119        }
120    }
121}
122
123impl std::error::Error for SExprError {}
124
125impl SExpr {
126    /// Emit this value as canonical source text.
127    #[must_use]
128    pub fn emit(&self) -> String {
129        let mut out = String::new();
130        self.emit_into(&mut out);
131        out
132    }
133
134    /// Compute a BLAKE3 content hash over the canonical emission.
135    ///
136    /// This is the content address of the value: structurally-equal
137    /// values produce byte-equal emissions (proven by the round-trip
138    /// proptests) and therefore equal hashes. Structurally-different
139    /// values produce different emissions and (overwhelmingly) different
140    /// hashes.
141    ///
142    /// The hash is suitable for:
143    /// - **Cache keys** — skip regeneration when IR hasn't changed
144    /// - **Attestation** — pair with tameshi Merkle trees at leaf level
145    /// - **Audit** — shinryu rows carry the hash for historical
146    ///   correlation without storing the full sexpr
147    #[must_use]
148    pub fn content_hash(&self) -> ContentHash {
149        let emitted = self.emit();
150        let hash = blake3::hash(emitted.as_bytes());
151        ContentHash(*hash.as_bytes())
152    }
153
154    fn emit_into(&self, out: &mut String) {
155        match self {
156            Self::Symbol(s) => out.push_str(s),
157            Self::String(s) => {
158                out.push('"');
159                for c in s.chars() {
160                    match c {
161                        '"' => out.push_str("\\\""),
162                        '\\' => out.push_str("\\\\"),
163                        '\n' => out.push_str("\\n"),
164                        '\t' => out.push_str("\\t"),
165                        c => out.push(c),
166                    }
167                }
168                out.push('"');
169            }
170            Self::Integer(i) => out.push_str(&i.to_string()),
171            Self::Float(fl) => {
172                // Always include a decimal to distinguish from Integer.
173                let s = fl.to_string();
174                if s.contains('.')
175                    || s.contains('e')
176                    || s.contains('E')
177                    || s == "inf"
178                    || s == "-inf"
179                    || s == "NaN"
180                {
181                    out.push_str(&s);
182                } else {
183                    out.push_str(&s);
184                    out.push_str(".0");
185                }
186            }
187            Self::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
188            Self::Nil => out.push_str("nil"),
189            Self::List(items) => {
190                out.push('(');
191                for (i, item) in items.iter().enumerate() {
192                    if i > 0 {
193                        out.push(' ');
194                    }
195                    item.emit_into(out);
196                }
197                out.push(')');
198            }
199        }
200    }
201
202    /// Parse canonical source text into an SExpr.
203    ///
204    /// # Errors
205    /// Returns `SExprError::Parse` with a human-readable message on
206    /// unterminated strings, unbalanced parens, bad number tokens, etc.
207    pub fn parse(source: &str) -> Result<Self, SExprError> {
208        let mut chars = source.chars().peekable();
209        skip_ws_and_comments(&mut chars);
210        let val = read_one(&mut chars)?;
211        skip_ws_and_comments(&mut chars);
212        if chars.peek().is_some() {
213            return Err(SExprError::Parse("trailing content after value".into()));
214        }
215        Ok(val)
216    }
217
218    /// Interpret as a list reference or return a shape error.
219    ///
220    /// # Errors
221    /// Returns `SExprError::Shape` if this is not a `List`.
222    pub fn as_list(&self) -> Result<&[SExpr], SExprError> {
223        match self {
224            Self::List(items) => Ok(items),
225            other => Err(SExprError::Shape(format!("expected list, got {other:?}"))),
226        }
227    }
228
229    /// Interpret as a symbol and return a shape error otherwise.
230    ///
231    /// # Errors
232    /// Returns `SExprError::Shape` if this is not a `Symbol`.
233    pub fn as_symbol(&self) -> Result<&str, SExprError> {
234        match self {
235            Self::Symbol(s) => Ok(s),
236            other => Err(SExprError::Shape(format!("expected symbol, got {other:?}"))),
237        }
238    }
239
240    /// Interpret as a string and return a shape error otherwise.
241    ///
242    /// # Errors
243    /// Returns `SExprError::Shape` if this is not a `String`.
244    pub fn as_str(&self) -> Result<&str, SExprError> {
245        match self {
246            Self::String(s) => Ok(s),
247            other => Err(SExprError::Shape(format!("expected string, got {other:?}"))),
248        }
249    }
250}
251
252// ── Reader ──────────────────────────────────────────────────────────
253
254fn skip_ws_and_comments(chars: &mut std::iter::Peekable<std::str::Chars>) {
255    loop {
256        match chars.peek().copied() {
257            Some(c) if c.is_whitespace() => {
258                chars.next();
259            }
260            Some(';') => {
261                for c in chars.by_ref() {
262                    if c == '\n' {
263                        break;
264                    }
265                }
266            }
267            _ => break,
268        }
269    }
270}
271
272fn read_one(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<SExpr, SExprError> {
273    skip_ws_and_comments(chars);
274    match chars.peek().copied() {
275        None => Err(SExprError::Parse("unexpected EOF".into())),
276        Some('(') => read_list(chars),
277        Some('"') => read_string(chars),
278        Some(_) => read_atom(chars),
279    }
280}
281
282fn read_list(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<SExpr, SExprError> {
283    debug_assert_eq!(chars.next(), Some('('));
284    let mut items = Vec::new();
285    loop {
286        skip_ws_and_comments(chars);
287        match chars.peek().copied() {
288            None => return Err(SExprError::Parse("unterminated list".into())),
289            Some(')') => {
290                chars.next();
291                return Ok(SExpr::List(items));
292            }
293            Some(_) => items.push(read_one(chars)?),
294        }
295    }
296}
297
298fn read_string(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<SExpr, SExprError> {
299    debug_assert_eq!(chars.next(), Some('"'));
300    let mut buf = String::new();
301    loop {
302        match chars.next() {
303            None => return Err(SExprError::Parse("unterminated string".into())),
304            Some('"') => return Ok(SExpr::String(buf)),
305            Some('\\') => match chars.next() {
306                Some('n') => buf.push('\n'),
307                Some('t') => buf.push('\t'),
308                Some('"') => buf.push('"'),
309                Some('\\') => buf.push('\\'),
310                Some(c) => {
311                    return Err(SExprError::Parse(format!("unknown escape: \\{c}")));
312                }
313                None => return Err(SExprError::Parse("dangling \\ at EOF".into())),
314            },
315            Some(c) => buf.push(c),
316        }
317    }
318}
319
320fn read_atom(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<SExpr, SExprError> {
321    let mut buf = String::new();
322    while let Some(&c) = chars.peek() {
323        if c.is_whitespace() || c == '(' || c == ')' || c == ';' || c == '"' {
324            break;
325        }
326        buf.push(c);
327        chars.next();
328    }
329    if buf.is_empty() {
330        return Err(SExprError::Parse("empty atom".into()));
331    }
332    // Order matters: try bool, nil, integer, float, symbol.
333    match buf.as_str() {
334        "true" => return Ok(SExpr::Bool(true)),
335        "false" => return Ok(SExpr::Bool(false)),
336        "nil" => return Ok(SExpr::Nil),
337        _ => {}
338    }
339    // Integer: only if no decimal point or exponent.
340    if !buf.contains('.') && !buf.contains('e') && !buf.contains('E') {
341        if let Ok(i) = buf.parse::<i64>() {
342            return Ok(SExpr::Integer(i));
343        }
344    }
345    if let Ok(f) = buf.parse::<f64>() {
346        return Ok(SExpr::Float(f));
347    }
348    Ok(SExpr::Symbol(buf))
349}
350
351// ── Traits ──────────────────────────────────────────────────────────
352
353/// Produce a canonical SExpr for this value.
354///
355/// Must be the inverse of `FromSExpr::from_sexpr` — every implementation
356/// is expected to satisfy the round-trip law
357/// `T::from_sexpr(&x.to_sexpr())? == x`.
358pub trait ToSExpr {
359    fn to_sexpr(&self) -> SExpr;
360
361    /// Convenience: content hash of this value's canonical emission.
362    ///
363    /// Equivalent to `self.to_sexpr().content_hash()`. Every type that
364    /// implements `ToSExpr` gets a stable content address for free.
365    #[must_use]
366    fn content_hash(&self) -> ContentHash {
367        self.to_sexpr().content_hash()
368    }
369}
370
371/// Parse an SExpr back into this value.
372pub trait FromSExpr: Sized {
373    /// # Errors
374    /// Returns `SExprError` if the structure or content doesn't match.
375    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError>;
376}
377
378// ── Primitive impls ─────────────────────────────────────────────────
379
380impl ToSExpr for String {
381    fn to_sexpr(&self) -> SExpr {
382        SExpr::String(self.clone())
383    }
384}
385
386impl FromSExpr for String {
387    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
388        Ok(s.as_str()?.to_string())
389    }
390}
391
392impl ToSExpr for bool {
393    fn to_sexpr(&self) -> SExpr {
394        SExpr::Bool(*self)
395    }
396}
397
398impl ToSExpr for i64 {
399    fn to_sexpr(&self) -> SExpr {
400        SExpr::Integer(*self)
401    }
402}
403
404impl FromSExpr for i64 {
405    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
406        match s {
407            SExpr::Integer(i) => Ok(*i),
408            other => Err(SExprError::Shape(format!(
409                "expected integer, got {other:?}"
410            ))),
411        }
412    }
413}
414
415impl ToSExpr for f64 {
416    fn to_sexpr(&self) -> SExpr {
417        SExpr::Float(*self)
418    }
419}
420
421impl FromSExpr for f64 {
422    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
423        match s {
424            SExpr::Float(f) => Ok(*f),
425            SExpr::Integer(i) => Ok(*i as f64), // widen int to float
426            other => Err(SExprError::Shape(format!("expected float, got {other:?}"))),
427        }
428    }
429}
430
431impl FromSExpr for bool {
432    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
433        match s {
434            SExpr::Bool(b) => Ok(*b),
435            other => Err(SExprError::Shape(format!("expected bool, got {other:?}"))),
436        }
437    }
438}
439
440impl<T: ToSExpr> ToSExpr for Option<T> {
441    fn to_sexpr(&self) -> SExpr {
442        match self {
443            Some(v) => v.to_sexpr(),
444            None => SExpr::Nil,
445        }
446    }
447}
448
449impl<T: FromSExpr> FromSExpr for Option<T> {
450    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
451        match s {
452            SExpr::Nil => Ok(None),
453            other => Ok(Some(T::from_sexpr(other)?)),
454        }
455    }
456}
457
458impl<T: ToSExpr> ToSExpr for Vec<T> {
459    fn to_sexpr(&self) -> SExpr {
460        let mut items = Vec::with_capacity(self.len() + 1);
461        items.push(SExpr::Symbol("list".into()));
462        for v in self {
463            items.push(v.to_sexpr());
464        }
465        SExpr::List(items)
466    }
467}
468
469impl<T: FromSExpr> FromSExpr for Vec<T> {
470    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
471        let items = s.as_list()?;
472        let Some((head, rest)) = items.split_first() else {
473            return Err(SExprError::Shape("expected (list ...) form, got ()".into()));
474        };
475        let tag = head.as_symbol()?;
476        if tag != "list" {
477            return Err(SExprError::Shape(format!(
478                "expected list head 'list', got '{tag}'"
479            )));
480        }
481        rest.iter().map(T::from_sexpr).collect()
482    }
483}
484
485// ── Helpers for struct emission / parsing ───────────────────────────
486
487/// Build a struct-form SExpr: `(name (:field val) …)`.
488#[must_use]
489pub fn struct_expr(name: &str, fields: Vec<(&str, SExpr)>) -> SExpr {
490    let mut items = Vec::with_capacity(fields.len() + 1);
491    items.push(SExpr::Symbol(name.to_string()));
492    for (k, v) in fields {
493        items.push(SExpr::List(vec![SExpr::Symbol(format!(":{k}")), v]));
494    }
495    SExpr::List(items)
496}
497
498/// Pull a struct-form list by name, returning its field map.
499///
500/// # Errors
501/// Returns `SExprError::Shape` if the head symbol doesn't match `expected_name`
502/// or the form isn't a list of keyword pairs.
503pub fn parse_struct<'a>(
504    s: &'a SExpr,
505    expected_name: &str,
506) -> Result<std::collections::BTreeMap<String, &'a SExpr>, SExprError> {
507    let items = s.as_list()?;
508    let (head, rest) = items
509        .split_first()
510        .ok_or_else(|| SExprError::Shape(format!("expected ({expected_name} …), got ()")))?;
511    let name = head.as_symbol()?;
512    if name != expected_name {
513        return Err(SExprError::Shape(format!(
514            "expected '{expected_name}', got '{name}'"
515        )));
516    }
517    let mut out = std::collections::BTreeMap::new();
518    for field in rest {
519        let pair = field.as_list()?;
520        if pair.len() != 2 {
521            return Err(SExprError::Shape(format!(
522                "expected (:field value) pair, got {pair:?}"
523            )));
524        }
525        let key = pair[0].as_symbol()?;
526        if !key.starts_with(':') {
527            return Err(SExprError::Shape(format!(
528                "expected keyword (:name), got '{key}'"
529            )));
530        }
531        out.insert(key[1..].to_string(), &pair[1]);
532    }
533    Ok(out)
534}
535
536/// Extract a required field from a struct-form map.
537///
538/// # Errors
539/// Returns `SExprError::MissingField` if the field is absent.
540pub fn take_field<'a>(
541    fields: &std::collections::BTreeMap<String, &'a SExpr>,
542    name: &str,
543) -> Result<&'a SExpr, SExprError> {
544    fields
545        .get(name)
546        .copied()
547        .ok_or_else(|| SExprError::MissingField(name.to_string()))
548}
549
550// ── Tests ──────────────────────────────────────────────────────────
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn emit_symbol() {
558        assert_eq!(SExpr::Symbol("hello".into()).emit(), "hello");
559    }
560
561    #[test]
562    fn emit_string_with_escapes() {
563        assert_eq!(SExpr::String("a\"b\nc".into()).emit(), "\"a\\\"b\\nc\"",);
564    }
565
566    #[test]
567    fn emit_integer() {
568        assert_eq!(SExpr::Integer(42).emit(), "42");
569        assert_eq!(SExpr::Integer(-7).emit(), "-7");
570    }
571
572    #[test]
573    fn emit_float_adds_decimal_if_missing() {
574        assert_eq!(SExpr::Float(1.0).emit(), "1.0");
575        assert_eq!(SExpr::Float(2.75).emit(), "2.75");
576    }
577
578    #[test]
579    fn emit_bool() {
580        assert_eq!(SExpr::Bool(true).emit(), "true");
581        assert_eq!(SExpr::Bool(false).emit(), "false");
582    }
583
584    #[test]
585    fn emit_nil() {
586        assert_eq!(SExpr::Nil.emit(), "nil");
587    }
588
589    #[test]
590    fn emit_empty_list() {
591        assert_eq!(SExpr::List(vec![]).emit(), "()");
592    }
593
594    #[test]
595    fn emit_nested_list() {
596        let s = SExpr::List(vec![
597            SExpr::Symbol("foo".into()),
598            SExpr::Integer(1),
599            SExpr::List(vec![SExpr::Symbol("bar".into()), SExpr::String("x".into())]),
600        ]);
601        assert_eq!(s.emit(), "(foo 1 (bar \"x\"))");
602    }
603
604    #[test]
605    fn parse_primitives() {
606        assert_eq!(SExpr::parse("42").unwrap(), SExpr::Integer(42));
607        assert_eq!(SExpr::parse("2.75").unwrap(), SExpr::Float(2.75));
608        assert_eq!(SExpr::parse("true").unwrap(), SExpr::Bool(true));
609        assert_eq!(SExpr::parse("false").unwrap(), SExpr::Bool(false));
610        assert_eq!(SExpr::parse("nil").unwrap(), SExpr::Nil);
611        assert_eq!(
612            SExpr::parse("hello").unwrap(),
613            SExpr::Symbol("hello".into())
614        );
615        assert_eq!(SExpr::parse("\"hi\"").unwrap(), SExpr::String("hi".into()));
616    }
617
618    #[test]
619    fn parse_string_escapes() {
620        assert_eq!(
621            SExpr::parse("\"a\\nb\"").unwrap(),
622            SExpr::String("a\nb".into())
623        );
624        assert_eq!(
625            SExpr::parse("\"a\\\"b\"").unwrap(),
626            SExpr::String("a\"b".into())
627        );
628    }
629
630    #[test]
631    fn parse_nested_list() {
632        let s = SExpr::parse("(foo 1 (bar \"x\"))").unwrap();
633        assert_eq!(
634            s,
635            SExpr::List(vec![
636                SExpr::Symbol("foo".into()),
637                SExpr::Integer(1),
638                SExpr::List(vec![SExpr::Symbol("bar".into()), SExpr::String("x".into()),]),
639            ])
640        );
641    }
642
643    #[test]
644    fn parse_rejects_unterminated_list() {
645        assert!(matches!(SExpr::parse("(foo"), Err(SExprError::Parse(_))));
646    }
647
648    #[test]
649    fn parse_rejects_trailing_content() {
650        assert!(matches!(SExpr::parse("1 2"), Err(SExprError::Parse(_))));
651    }
652
653    #[test]
654    fn parse_tolerates_comments() {
655        let s = SExpr::parse("; leading\n(a ; mid\n b)\n; trailing").unwrap();
656        assert_eq!(
657            s,
658            SExpr::List(vec![SExpr::Symbol("a".into()), SExpr::Symbol("b".into())])
659        );
660    }
661
662    #[test]
663    fn round_trip_string() {
664        let s = "hello \"world\"\n".to_string();
665        let round = String::from_sexpr(&s.to_sexpr()).unwrap();
666        assert_eq!(s, round);
667    }
668
669    #[test]
670    fn round_trip_bool() {
671        assert_eq!(bool::from_sexpr(&true.to_sexpr()).unwrap(), true);
672        assert_eq!(bool::from_sexpr(&false.to_sexpr()).unwrap(), false);
673    }
674
675    #[test]
676    fn round_trip_option_some_and_none() {
677        let some: Option<String> = Some("hi".into());
678        let none: Option<String> = None;
679        assert_eq!(
680            Option::<String>::from_sexpr(&some.to_sexpr()).unwrap(),
681            some
682        );
683        assert_eq!(
684            Option::<String>::from_sexpr(&none.to_sexpr()).unwrap(),
685            none
686        );
687    }
688
689    #[test]
690    fn round_trip_vec() {
691        let v: Vec<String> = vec!["a".into(), "b".into()];
692        let s = v.to_sexpr();
693        assert_eq!(s.emit(), "(list \"a\" \"b\")");
694        assert_eq!(Vec::<String>::from_sexpr(&s).unwrap(), v);
695    }
696
697    #[test]
698    fn round_trip_empty_vec() {
699        let v: Vec<String> = vec![];
700        let s = v.to_sexpr();
701        assert_eq!(s.emit(), "(list)");
702        assert_eq!(Vec::<String>::from_sexpr(&s).unwrap(), v);
703    }
704
705    #[test]
706    fn struct_expr_and_parse_struct_round_trip() {
707        let s = struct_expr(
708            "attribute",
709            vec![
710                ("api-name", "foo".to_string().to_sexpr()),
711                ("required", true.to_sexpr()),
712            ],
713        );
714        assert_eq!(s.emit(), "(attribute (:api-name \"foo\") (:required true))");
715
716        let parsed = parse_struct(&s, "attribute").unwrap();
717        assert_eq!(parsed.len(), 2);
718        assert!(parsed.contains_key("api-name"));
719        assert!(parsed.contains_key("required"));
720    }
721
722    #[test]
723    fn parse_struct_errors_on_wrong_name() {
724        let s = struct_expr("attribute", vec![]);
725        let err = parse_struct(&s, "resource").unwrap_err();
726        assert!(matches!(err, SExprError::Shape(_)));
727    }
728
729    #[test]
730    fn take_field_missing_errors_cleanly() {
731        let s = struct_expr("x", vec![]);
732        let map = parse_struct(&s, "x").unwrap();
733        let err = take_field(&map, "foo").unwrap_err();
734        assert!(matches!(err, SExprError::MissingField(_)));
735    }
736
737    #[test]
738    fn float_and_integer_distinguished_on_parse() {
739        assert_eq!(SExpr::parse("1").unwrap(), SExpr::Integer(1));
740        assert_eq!(SExpr::parse("1.0").unwrap(), SExpr::Float(1.0));
741    }
742
743    // ── Content addressing ────────────────────────────────────
744
745    #[test]
746    fn content_hash_is_64_hex_chars() {
747        let h = SExpr::Integer(42).content_hash();
748        assert_eq!(h.to_hex().len(), 64);
749        assert!(h.to_hex().chars().all(|c| c.is_ascii_hexdigit()));
750        assert_eq!(h.to_string(), h.to_hex());
751    }
752
753    #[test]
754    fn content_hash_is_deterministic() {
755        let s = SExpr::List(vec![
756            SExpr::Symbol("foo".into()),
757            SExpr::Integer(1),
758            SExpr::String("bar".into()),
759        ]);
760        assert_eq!(s.content_hash(), s.content_hash());
761    }
762
763    #[test]
764    fn content_hash_differs_for_different_values() {
765        let a = SExpr::Integer(1).content_hash();
766        let b = SExpr::Integer(2).content_hash();
767        assert_ne!(a, b);
768    }
769
770    #[test]
771    fn content_hash_matches_emission_hash() {
772        // The hash is defined as BLAKE3 over canonical emission, so
773        // computing it independently via the same path must agree.
774        let s = SExpr::String("hello world".into());
775        let expected = {
776            let emitted = s.emit();
777            let h = blake3::hash(emitted.as_bytes());
778            ContentHash(*h.as_bytes())
779        };
780        assert_eq!(s.content_hash(), expected);
781    }
782
783    #[test]
784    fn to_sexpr_blanket_content_hash() {
785        // String implements ToSExpr; the blanket content_hash must
786        // equal the raw sexpr form's content_hash.
787        let s = "hello".to_string();
788        assert_eq!(s.content_hash(), s.to_sexpr().content_hash());
789    }
790
791    #[test]
792    fn content_hash_stable_across_clones() {
793        let s = SExpr::List(vec![SExpr::Symbol("x".into()), SExpr::Integer(42)]);
794        let clone = s.clone();
795        assert_eq!(s.content_hash(), clone.content_hash());
796    }
797}