Skip to main content

quillmark_core/document/
meta.rs

1//! Validation helpers for card-yaml `$`-prefixed system metadata.
2//!
3//! The closed set of `$` keys (`$quill`, `$kind`, `$id`, `$ext`, `$seed`) and
4//! their typed values are stored as variants of [`super::PayloadItem`] inside a
5//! card's unified [`super::Payload`] item list — they sit alongside user
6//! fields and comments in source order, which is what makes inline-comment
7//! preservation symmetric across the `$`/non-`$` boundary.
8//!
9//! This module retains only the validation primitives shared between the
10//! parser, the editor surface, and the storage DTO:
11//!
12//! - `extract_meta_items` (private) — strip `$` keys from a parsed YAML
13//!   mapping and validate each into a typed system-metadata
14//!   [`super::PayloadItem`].
15//! - [`is_valid_kind_name`] / [`validate_composable_kind`] — name checks
16//!   for `$kind`.
17
18use std::str::FromStr;
19
20use serde_json::Value as JsonValue;
21
22use super::payload::{MetaKey, PayloadItem};
23use crate::error::ParseError;
24use crate::version::QuillReference;
25
26/// The `$key` string a system-metadata [`PayloadItem`] variant corresponds
27/// to, or `None` for non-system variants ([`PayloadItem::Field`] and
28/// [`PayloadItem::Comment`]).
29pub(super) fn meta_key(item: &PayloadItem) -> Option<&'static str> {
30    match item {
31        PayloadItem::Quill { .. } => Some("$quill"),
32        PayloadItem::Kind { .. } => Some("$kind"),
33        PayloadItem::Id { .. } => Some("$id"),
34        PayloadItem::Meta { key, .. } => Some(key.as_str()),
35        PayloadItem::Field { .. } | PayloadItem::Comment { .. } => None,
36    }
37}
38
39/// Walk the parsed YAML payload, extracting `$`-prefixed reserved keys into
40/// typed system-metadata [`PayloadItem`]s (`Quill` / `Kind` / `Id` / `Ext`)
41/// in source order. The keys are removed from `payload` so the caller can
42/// build the user-field portion from what remains.
43///
44/// The accepted keys are the closed set `{$quill, $kind, $id, $ext, $seed}`.
45/// Any other `$`-prefixed key is a parse error. Duplicate keys cannot arise
46/// here — the YAML parser rejects them as duplicate mapping keys before
47/// this function runs.
48///
49/// `$quill` and `$kind` require string scalars (non-string YAML types are
50/// rejected). `$id` accepts any scalar and stringifies it. `$ext` and `$seed`
51/// each require a YAML mapping (object); `$ext` contents are carried opaquely,
52/// while `$seed` is a map keyed by card-kind interpreted by the seeding layer.
53pub(super) fn extract_meta_items(payload: &mut JsonValue) -> Result<Vec<PayloadItem>, ParseError> {
54    let map = match payload {
55        JsonValue::Object(m) => m,
56        _ => return Ok(Vec::new()),
57    };
58
59    let dollar_keys: Vec<String> = map.keys().filter(|k| k.starts_with('$')).cloned().collect();
60
61    let mut out = Vec::with_capacity(dollar_keys.len());
62    for key in dollar_keys {
63        let value = map
64            .shift_remove(&key)
65            .expect("key was just enumerated from the same map");
66        let meta = match key.as_str() {
67            "$quill" => {
68                let s = require_string("$quill reference", value)?;
69                let reference = QuillReference::from_str(&s).map_err(|reason| {
70                    ParseError::InvalidQuillReference {
71                        value: s.clone(),
72                        reason,
73                    }
74                })?;
75                PayloadItem::Quill { reference }
76            }
77            "$kind" => {
78                let s = match value {
79                    JsonValue::String(s) => s,
80                    other => {
81                        return Err(ParseError::InvalidStructure(format!(
82                            "Invalid `$kind` value — a card kind must be a string \
83                             matching `[a-z_][a-z0-9_]*` (got {})",
84                            yaml_type_name(&other)
85                        )));
86                    }
87                };
88                if !is_valid_kind_name(&s) {
89                    return Err(ParseError::InvalidStructure(format!(
90                        "Invalid `$kind` value '{}' — a card kind must match \
91                         `[a-z_][a-z0-9_]*`",
92                        s
93                    )));
94                }
95                PayloadItem::Kind { value: s }
96            }
97            "$id" => PayloadItem::Id {
98                value: scalar_to_string(&key, value)?,
99            },
100            "$ext" | "$seed" => {
101                let meta_key = MetaKey::from_key_str(&key).expect("matched $ext/$seed above");
102                match value {
103                    JsonValue::Object(map) => PayloadItem::Meta {
104                        key: meta_key,
105                        value: map,
106                        nested_comments: Vec::new(),
107                    },
108                    other => {
109                        return Err(ParseError::InvalidStructure(format!(
110                            "Invalid `{}` value — expected a mapping, got {}",
111                            meta_key.as_str(),
112                            yaml_type_name(&other)
113                        )));
114                    }
115                }
116            }
117            other => {
118                return Err(ParseError::InvalidStructure(format!(
119                    "Unknown `{}` system-metadata key — the card-yaml block \
120                     accepts only `$quill`, `$kind`, `$id`, `$ext`, and `$seed`",
121                    other
122                )));
123            }
124        };
125        out.push(meta);
126    }
127
128    Ok(out)
129}
130
131fn require_string(label: &str, value: JsonValue) -> Result<String, ParseError> {
132    match value {
133        JsonValue::String(s) => Ok(s),
134        other => Err(ParseError::InvalidStructure(format!(
135            "Invalid {} — expected a string scalar, got {}",
136            label,
137            yaml_type_name(&other)
138        ))),
139    }
140}
141
142fn scalar_to_string(key: &str, value: JsonValue) -> Result<String, ParseError> {
143    match value {
144        JsonValue::String(s) => Ok(s),
145        JsonValue::Bool(b) => Ok(b.to_string()),
146        JsonValue::Number(n) => Ok(n.to_string()),
147        JsonValue::Null => Err(ParseError::InvalidStructure(format!(
148            "`{}` cannot be null — provide a scalar value",
149            key
150        ))),
151        other => Err(ParseError::InvalidStructure(format!(
152            "`{}` must be a scalar value, got {}",
153            key,
154            yaml_type_name(&other)
155        ))),
156    }
157}
158
159fn yaml_type_name(value: &JsonValue) -> &'static str {
160    match value {
161        JsonValue::Null => "null",
162        JsonValue::Bool(_) => "boolean",
163        JsonValue::Number(_) => "number",
164        JsonValue::String(_) => "string",
165        JsonValue::Array(_) => "sequence",
166        JsonValue::Object(_) => "mapping",
167    }
168}
169
170/// `true` when `name` matches `[a-z_][a-z0-9_]*`.
171pub fn is_valid_kind_name(name: &str) -> bool {
172    if name.is_empty() {
173        return false;
174    }
175    let mut chars = name.chars();
176    let first = chars.next().unwrap();
177    if !first.is_ascii_lowercase() && first != '_' {
178        return false;
179    }
180    for ch in chars {
181        if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '_' {
182            return false;
183        }
184    }
185    true
186}
187
188/// Validate a composable card kind: must match `[a-z_][a-z0-9_]*` and must
189/// not be the reserved root kind `"main"`.
190///
191/// Single source of truth for the composable-kind rule, used by
192/// [`crate::Card::new`], [`crate::Document::set_card_kind`], and the storage
193/// DTO conversion so the rule cannot drift between editor and reader paths.
194pub fn validate_composable_kind(kind: &str) -> Result<(), CardKindError> {
195    if !is_valid_kind_name(kind) {
196        return Err(CardKindError::InvalidName);
197    }
198    if kind == "main" {
199        return Err(CardKindError::Reserved);
200    }
201    Ok(())
202}
203
204/// Reason [`validate_composable_kind`] rejected a kind string.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum CardKindError {
207    /// Kind did not match `[a-z_][a-z0-9_]*`.
208    InvalidName,
209    /// Kind was `"main"`, reserved for the document root.
210    Reserved,
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use serde_json::json;
217
218    #[test]
219    fn extracts_quill_kind_and_leaves_data_intact() {
220        let mut payload = json!({
221            "$quill": "foo@0.1",
222            "$kind": "main",
223            "title": "Doc",
224        });
225        let items = extract_meta_items(&mut payload).unwrap();
226        assert_eq!(items.len(), 2);
227        assert!(matches!(items[0], PayloadItem::Quill { .. }));
228        assert!(matches!(items[1], PayloadItem::Kind { .. }));
229        assert_eq!(payload, json!({"title": "Doc"}));
230    }
231
232    #[test]
233    fn extracts_id_from_number() {
234        let mut payload = json!({"$id": 42});
235        let items = extract_meta_items(&mut payload).unwrap();
236        assert!(matches!(items[0], PayloadItem::Id { ref value } if value == "42"));
237    }
238
239    #[test]
240    fn rejects_unknown_dollar_key() {
241        let mut payload = json!({"$unknown": "x"});
242        let err = extract_meta_items(&mut payload).unwrap_err();
243        assert!(err.to_string().contains("Unknown `$unknown`"));
244    }
245
246    #[test]
247    fn rejects_non_string_quill() {
248        let mut payload = json!({"$quill": 42});
249        let err = extract_meta_items(&mut payload).unwrap_err();
250        assert!(err.to_string().contains("$quill reference"));
251    }
252
253    #[test]
254    fn rejects_invalid_kind_pattern() {
255        let mut payload = json!({"$kind": "Bad-Kind"});
256        let err = extract_meta_items(&mut payload).unwrap_err();
257        assert!(err.to_string().contains("Invalid `$kind`"));
258    }
259
260    #[test]
261    fn validate_composable_kind_rejects_main() {
262        assert_eq!(
263            validate_composable_kind("main"),
264            Err(CardKindError::Reserved)
265        );
266    }
267
268    #[test]
269    fn validate_composable_kind_rejects_bad_name() {
270        assert_eq!(
271            validate_composable_kind("Bad-Name"),
272            Err(CardKindError::InvalidName)
273        );
274    }
275
276    #[test]
277    fn validate_composable_kind_accepts_valid() {
278        assert!(validate_composable_kind("indorsement").is_ok());
279    }
280}