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