Skip to main content

quillmark_core/quill/
resolved.rs

1//! The resolved-value view — [`Quill::resolve`].
2//!
3//! A projection that makes field resolution observable *data* rather than an
4//! inferred behavior chain: for every declared field, the value the render
5//! projection would use and the [`FieldSource`] rung it came from. It cuts the
6//! one commitment ladder (`prose/canon/SCHEMAS.md` § "Value sources and
7//! projections") through the shared producer
8//! `resolve_card_sourced` (in `super::compose`), whose sourced ladder
9//! (`ladder_sourced`) the render plate cuts too — never a parallel precedence
10//! policy.
11//!
12//! Values only: diagnostics stay [`Quill::validate`]'s job (the editor merges
13//! `validate()` with its own producers regardless, so bucketing here would
14//! delete no consumer code), and schema guidance (`example:`, labels, groups)
15//! reads from [`Quill::schema`]. The view answers one question — what value
16//! renders, and from which rung.
17
18use indexmap::IndexMap;
19use serde::Serialize;
20
21use super::compose::resolve_card_sourced;
22use super::{CardSchema, Quill, QuillConfig};
23use crate::{Card, Document, QuillValue};
24
25/// The rung of the commitment ladder that produced a [`ResolvedField::value`].
26/// Serializes lowercase (`"authored" | "default" | "zero"`).
27#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
28#[serde(rename_all = "lowercase")]
29pub enum FieldSource {
30    /// The authored value — the document's own content.
31    Authored,
32    /// The schema `default:` (or its content form for a content field).
33    Default,
34    /// The type-empty [`zero_value`](crate::quill::zero_value) floor.
35    Zero,
36}
37
38/// One resolved row: its `name`, the value the render projection would use, and
39/// the [`FieldSource`] rung that value came from. A row carries its own name so
40/// declaration order is structural — an ordered array, not JSON object key
41/// order. The card body is a [`ResolvedMain::body`] / [`ResolvedCard::body`]
42/// sibling, never a row in `fields`, so a consumer iterating declared fields
43/// never trips over it.
44#[derive(Debug, Clone, PartialEq, Serialize)]
45pub struct ResolvedField {
46    pub name: String,
47    pub value: QuillValue,
48    pub source: FieldSource,
49}
50
51/// The main card's resolved rows in declaration order, plus its body row when
52/// the main enables a body.
53#[derive(Debug, Clone, PartialEq, Serialize)]
54pub struct ResolvedMain {
55    pub fields: Vec<ResolvedField>,
56    pub body: Option<ResolvedField>,
57}
58
59/// One composable card's resolved rows, with its authored `kind` (present even
60/// for an unknown kind, which carries its fields verbatim), its document-array
61/// `index`, and its body row when the kind enables a body.
62#[derive(Debug, Clone, PartialEq, Serialize)]
63pub struct ResolvedCard {
64    pub kind: Option<String>,
65    pub index: usize,
66    pub fields: Vec<ResolvedField>,
67    pub body: Option<ResolvedField>,
68}
69
70/// The whole resolved-value view: the main card and every composable card.
71#[derive(Debug, Clone, PartialEq, Serialize)]
72pub struct Resolved {
73    pub main: ResolvedMain,
74    pub cards: Vec<ResolvedCard>,
75}
76
77impl Quill {
78    /// The resolved-value view of `doc` against this quill's schema.
79    ///
80    /// For every declared field, the value [`compile_data`] emits into the plate
81    /// — the two cut the *same* sourced ladder (`ladder_sourced`) over equal
82    /// coerced input, so the value is the plate's by construction — tagged with
83    /// the [`FieldSource`] rung it came from. Completeness and errors stay
84    /// [`Quill::validate`]'s; this view carries no diagnostics.
85    ///
86    /// [`compile_data`]: Quill::compile_data
87    pub fn resolve(&self, doc: &Document) -> Resolved {
88        let config = self.config();
89        let (fields, body) = resolve_card_fields(&config.main, doc.main());
90        let main = ResolvedMain { fields, body };
91        let cards = doc
92            .cards()
93            .iter()
94            .enumerate()
95            .map(|(index, card)| card_states(config, card, index))
96            .collect();
97        Resolved { main, cards }
98    }
99}
100
101/// Resolve one card (main or a schema-declared kind) into its ordered
102/// [`ResolvedField`] rows and its body row (present iff the kind enables a body).
103///
104/// The value and source of every field come from the one shared resolver
105/// [`resolve_card_sourced`] — the same producer [`compile_data`] cuts for the
106/// plate — so the two projections cannot drift. This layer only re-cuts the
107/// **presentation order**: declared fields first in declaration order (the canon
108/// ordering contract, carried structurally by the row array — not the validation
109/// walker's alphabetical sort), then undeclared authored fields in authored
110/// order. The body is a sibling row, never an entry in `fields`.
111///
112/// [`compile_data`]: crate::Quill::compile_data
113fn resolve_card_fields(schema: &CardSchema, card: &Card) -> (Vec<ResolvedField>, Option<ResolvedField>) {
114    let sourced: IndexMap<String, (QuillValue, FieldSource)> = resolve_card_sourced(schema, card);
115    let mut fields = Vec::new();
116
117    // Declared rows in schema declaration order. Every declared field is present
118    // in the map — the resolver's ladder inserts each one — so the lookup holds.
119    for (name, _field_schema) in &schema.fields {
120        let (value, source) = sourced
121            .get(name)
122            .cloned()
123            .expect("resolve_card_sourced emits every declared field");
124        fields.push(ResolvedField {
125            name: name.clone(),
126            value,
127            source,
128        });
129    }
130
131    // Undeclared authored fields, appended in authored order under their NFC
132    // keys: the schema is a floor, not an allowlist, so these reach both
133    // projections too — value verbatim, source Authored.
134    for (name, (value, source)) in &sourced {
135        if !schema.fields.contains_key(name) {
136            fields.push(ResolvedField {
137                name: name.clone(),
138                value: value.clone(),
139                source: *source,
140            });
141        }
142    }
143
144    let body = schema.body_enabled().then(|| body_state(card));
145    (fields, body)
146}
147
148/// Resolve one composable card. A card whose `$kind` names a schema resolves
149/// through the ladder; an unknown-kind card (declared `$kind` with no schema, or
150/// a kindless card) carries its authored fields verbatim — no coercion, no
151/// ladder, no `$body` row.
152fn card_states(config: &QuillConfig, card: &Card, index: usize) -> ResolvedCard {
153    // The raw authored kind rides the entry even when it names no schema — the
154    // card reports what it *claimed* to be.
155    let kind = card.kind().map(String::from);
156    match card.kind().and_then(|k| config.card_kind(k)) {
157        Some(schema) => {
158            let (fields, body) = resolve_card_fields(schema, card);
159            ResolvedCard {
160                kind,
161                index,
162                fields,
163                body,
164            }
165        }
166        None => {
167            // An unknown-kind card carries its authored fields verbatim — no
168            // schema, no ladder, and `to_index_map` drops `$` keys, so no body.
169            let fields = card
170                .payload()
171                .to_index_map()
172                .into_iter()
173                .map(|(name, value)| ResolvedField {
174                    name,
175                    value,
176                    source: FieldSource::Authored,
177                })
178                .collect();
179            ResolvedCard {
180                kind,
181                index,
182                fields,
183                body: None,
184            }
185        }
186    }
187}
188
189/// The body row (`name: "body"`). The value is byte-identical to the plate's
190/// `$body` (canonical Content-JSON of the card body). A body has no `default:`
191/// rung, so its source is only ever [`Authored`](FieldSource::Authored)
192/// (non-blank) or [`Zero`](FieldSource::Zero) (blank).
193fn body_state(card: &Card) -> ResolvedField {
194    let value = QuillValue::from_json(quillmark_content::serial::to_canonical_value(card.body()));
195    let source = if card.body().is_blank() {
196        FieldSource::Zero
197    } else {
198        FieldSource::Authored
199    };
200    ResolvedField {
201        name: "body".to_string(),
202        value,
203        source,
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::quill::FileTreeNode;
211    use crate::{Card, Document, Payload, Quill};
212    use std::collections::HashMap as StdHashMap;
213
214    /// Build a minimal [`Quill`] from inline `Quill.yaml` with no filesystem deps.
215    fn quill_from_yaml(yaml: &str) -> Quill {
216        let mut files = StdHashMap::new();
217        files.insert(
218            "Quill.yaml".to_string(),
219            FileTreeNode::File {
220                contents: yaml.as_bytes().to_vec(),
221            },
222        );
223        let root = FileTreeNode::Directory { files };
224        Quill::from_tree(root).expect("quill_from_yaml: from_tree failed")
225    }
226
227    fn parse(md: &str) -> Document {
228        Document::parse(md).expect("document should parse").document
229    }
230
231    /// Look up a resolved row by name — rows are an ordered array now.
232    fn row<'a>(fields: &'a [ResolvedField], name: &str) -> &'a ResolvedField {
233        fields
234            .iter()
235            .find(|f| f.name == name)
236            .unwrap_or_else(|| panic!("no row `{name}`"))
237    }
238
239    fn has_row(fields: &[ResolvedField], name: &str) -> bool {
240        fields.iter().any(|f| f.name == name)
241    }
242
243    const QUILL: &str = r#"
244quill:
245  name: fs_test
246  version: "1.0"
247  backend: typst
248  description: Field-state tests
249main:
250  body:
251    example: "Example body prose."
252  fields:
253    title:
254      type: string
255    status:
256      type: string
257      default: draft
258    notes:
259      type: string
260    intro:
261      type: richtext
262      default: "**hi**"
263    recipients:
264      type: array
265      items:
266        type: object
267        properties:
268          name: { type: string }
269card_kinds:
270  note:
271    fields:
272      author:
273        type: string
274        example: A. Author
275      tag:
276        type: string
277"#;
278
279    // ── Sources ──────────────────────────────────────────────────────────────
280
281    #[test]
282    fn scalar_sources_authored_default_zero() {
283        let quill = quill_from_yaml(QUILL);
284        // title authored; status absent (has a default); notes absent (no default).
285        let doc = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: Hello\n~~~\n");
286        let states = quill.resolve(&doc);
287        let f = &states.main.fields;
288
289        assert_eq!(row(f, "title").source, FieldSource::Authored);
290        assert_eq!(row(f, "title").value.as_json(), &serde_json::json!("Hello"));
291
292        assert_eq!(row(f, "status").source, FieldSource::Default);
293        assert_eq!(row(f, "status").value.as_json(), &serde_json::json!("draft"));
294
295        assert_eq!(row(f, "notes").source, FieldSource::Zero);
296        assert_eq!(row(f, "notes").value.as_json(), &serde_json::json!(""));
297    }
298
299    #[test]
300    fn richtext_default_reports_default_and_matches_plate() {
301        let quill = quill_from_yaml(QUILL);
302        // intro absent → its richtext `default:` (committed as content).
303        let doc = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n");
304        let states = quill.resolve(&doc);
305        let intro = row(&states.main.fields, "intro");
306
307        assert_eq!(intro.source, FieldSource::Default);
308        // The value is the content form of the default, byte-equal to the plate.
309        let plate = quill.compile_data(&doc).expect("compile");
310        assert_eq!(intro.value.as_json(), &plate["intro"]);
311        // And it is content, not the raw markdown string.
312        assert!(intro.value.as_json().is_object());
313    }
314
315    #[test]
316    fn present_null_is_absent_takes_default_rung() {
317        let quill = quill_from_yaml(QUILL);
318        // `status:` is a present-null → treated as absent → default rung.
319        let doc = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\nstatus:\n~~~\n");
320        let states = quill.resolve(&doc);
321        let status = row(&states.main.fields, "status");
322        assert_eq!(status.source, FieldSource::Default);
323        assert_eq!(status.value.as_json(), &serde_json::json!("draft"));
324    }
325
326    // ── Byte-for-byte with the render projection ─────────────────────────────
327    // Both projections cut the one shared ladder (`ladder_sourced`), but over
328    // separately-conformed input — the render gate's fallible conform vs the
329    // view's keep-raw `conform_card_render`. This pins that those two conform
330    // paths agree on a gated document, plus the plate-build wiring (order, meta,
331    // body) against the row view.
332
333    #[test]
334    fn every_row_is_byte_for_byte_with_compile_data() {
335        let quill = quill_from_yaml(QUILL);
336        let md = "~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\n\
337                  title: Hello\nintro: \"**bold**\"\nrecipients:\n  - name: Alice\n~~~\n\n\
338                  Body prose here.\n\n\
339                  ~~~card-yaml\n$kind: note\nauthor: Zed\n~~~\nNote body.\n";
340        let doc = parse(md);
341        let states = quill.resolve(&doc);
342        let plate = quill.compile_data(&doc).expect("compile");
343
344        // Every declared main row equals its plate field; the body equals plate `$body`.
345        for name in ["title", "status", "notes", "intro", "recipients"] {
346            assert_eq!(
347                row(&states.main.fields, name).value.as_json(),
348                &plate[name],
349                "main row `{name}` must be byte-for-byte with the plate"
350            );
351        }
352        assert_eq!(
353            states.main.body.as_ref().unwrap().value.as_json(),
354            &plate["$body"]
355        );
356
357        // The card's declared rows equal its plate card; the body equals plate `$body`.
358        let plate_card = &plate["$cards"][0];
359        let card = &states.cards[0];
360        for name in ["author", "tag"] {
361            assert_eq!(
362                row(&card.fields, name).value.as_json(),
363                &plate_card[name],
364                "card row `{name}` must be byte-for-byte with the plate"
365            );
366        }
367        assert_eq!(
368            card.body.as_ref().unwrap().value.as_json(),
369            &plate_card["$body"]
370        );
371    }
372
373    #[test]
374    fn non_nfc_key_on_a_constructed_payload_rows_under_its_nfc_spelling() {
375        // Every validated ingress (parse, the mutators) restricts field names
376        // to ASCII, so a non-NFC key only enters through direct construction
377        // (`Payload::from_index_map`). Render NFC-normalizes it between
378        // coercion and the ladder; the view mirrors that, rowing it under the
379        // NFC key the plate carries — not the raw decomposed one.
380        let quill = quill_from_yaml(QUILL);
381        let mut map = IndexMap::new();
382        // `e` + U+0301 combining acute — NFC-composes to U+00E9.
383        map.insert(
384            "cafe\u{301}".to_string(),
385            QuillValue::from_json(serde_json::json!("hot")),
386        );
387        let mut payload = Payload::from_index_map(map);
388        payload.set_quill("fs_test@1.0".parse().unwrap());
389        payload.set_kind("main");
390        let main = Card::from_parts(payload, quillmark_content::Content::empty());
391        let doc = Document::from_main_and_cards(main, Vec::new());
392        let states = quill.resolve(&doc);
393
394        assert!(!has_row(&states.main.fields, "cafe\u{301}"));
395        let r = row(&states.main.fields, "caf\u{e9}");
396        assert_eq!(r.source, FieldSource::Authored);
397        assert_eq!(r.value.as_json(), &serde_json::json!("hot"));
398    }
399
400    // ── The body row ─────────────────────────────────────────────────────────
401
402    #[test]
403    fn body_row_authored_vs_blank_source() {
404        let quill = quill_from_yaml(QUILL);
405
406        let authored =
407            parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n\nHello body.\n");
408        assert_eq!(
409            quill.resolve(&authored).main.body.unwrap().source,
410            FieldSource::Authored
411        );
412
413        let blank = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n");
414        let states = quill.resolve(&blank);
415        let body = states.main.body.as_ref().unwrap();
416        assert_eq!(body.source, FieldSource::Zero);
417        assert!(body.value.as_json().is_object(), "blank body is empty content");
418    }
419
420    const BODY_DISABLED_QUILL: &str = r#"
421quill:
422  name: bd_test
423  version: "1.0"
424  backend: typst
425  description: Body-disabled test
426main:
427  fields:
428    title:
429      type: string
430card_kinds:
431  stamp:
432    body:
433      enabled: false
434    fields:
435      label:
436        type: string
437"#;
438
439    #[test]
440    fn body_disabled_kind_omits_body_row() {
441        let quill = quill_from_yaml(BODY_DISABLED_QUILL);
442        let doc = parse(
443            "~~~card-yaml\n$quill: bd_test@1.0\n$kind: main\ntitle: T\n~~~\n\n\
444             ~~~card-yaml\n$kind: stamp\nlabel: L\n~~~\nStray prose.\n",
445        );
446        let states = quill.resolve(&doc);
447        let card = &states.cards[0];
448        assert!(card.body.is_none(), "a body-disabled kind has no body row");
449        assert!(has_row(&card.fields, "label"), "declared rows still present");
450    }
451
452    // ── Unknown-kind card ────────────────────────────────────────────────────
453
454    #[test]
455    fn unknown_kind_card_shape() {
456        let quill = quill_from_yaml(QUILL);
457        let doc = parse(
458            "~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n\n\
459             ~~~card-yaml\n$kind: mystery\nfoo: bar\n~~~\nUnread body.\n",
460        );
461        let states = quill.resolve(&doc);
462        let card = &states.cards[0];
463
464        assert_eq!(card.kind.as_deref(), Some("mystery"));
465        assert_eq!(card.index, 0);
466        // Authored fields only — no body row, no ladder.
467        assert_eq!(row(&card.fields, "foo").source, FieldSource::Authored);
468        assert_eq!(row(&card.fields, "foo").value.as_json(), &serde_json::json!("bar"));
469        assert!(card.body.is_none());
470    }
471
472    // ── Undeclared authored field ────────────────────────────────────────────
473
474    #[test]
475    fn undeclared_authored_field_row_is_authored() {
476        let quill = quill_from_yaml(QUILL);
477        let doc = parse(
478            "~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\nextra: whatever\n~~~\n",
479        );
480        let states = quill.resolve(&doc);
481        let r = row(&states.main.fields, "extra");
482        assert_eq!(r.source, FieldSource::Authored);
483        assert_eq!(r.value.as_json(), &serde_json::json!("whatever"));
484    }
485
486    // ── Wire shape ───────────────────────────────────────────────────────────
487
488    #[test]
489    fn field_source_serializes_lowercase() {
490        assert_eq!(
491            serde_json::to_string(&FieldSource::Authored).unwrap(),
492            "\"authored\""
493        );
494        assert_eq!(
495            serde_json::to_string(&FieldSource::Default).unwrap(),
496            "\"default\""
497        );
498        assert_eq!(serde_json::to_string(&FieldSource::Zero).unwrap(), "\"zero\"");
499    }
500
501    #[test]
502    fn field_state_is_name_value_and_source_only() {
503        let state = ResolvedField {
504            name: "x".to_string(),
505            value: QuillValue::from_json(serde_json::json!("v")),
506            source: FieldSource::Authored,
507        };
508        let json = serde_json::to_value(&state).unwrap();
509        let obj = json.as_object().unwrap();
510        assert_eq!(obj.len(), 3, "only name + value + source on the wire: {json}");
511        assert!(
512            obj.contains_key("name") && obj.contains_key("value") && obj.contains_key("source")
513        );
514    }
515}