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`) and their
4//! 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::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::Ext { .. } => Some("$ext"),
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}`. Any
45/// 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` requires
51/// a YAML mapping (object) — its contents are carried opaquely.
52pub(super) fn extract_meta_items(payload: &mut JsonValue) -> Result<Vec<PayloadItem>, ParseError> {
53    let map = match payload {
54        JsonValue::Object(m) => m,
55        _ => return Ok(Vec::new()),
56    };
57
58    let dollar_keys: Vec<String> = map.keys().filter(|k| k.starts_with('$')).cloned().collect();
59
60    let mut out = Vec::with_capacity(dollar_keys.len());
61    for key in dollar_keys {
62        let value = map
63            .shift_remove(&key)
64            .expect("key was just enumerated from the same map");
65        let meta = match key.as_str() {
66            "$quill" => {
67                let s = require_string("$quill reference", value)?;
68                let reference = QuillReference::from_str(&s).map_err(|reason| {
69                    ParseError::InvalidQuillReference {
70                        value: s.clone(),
71                        reason,
72                    }
73                })?;
74                PayloadItem::Quill { reference }
75            }
76            "$kind" => {
77                let s = match value {
78                    JsonValue::String(s) => s,
79                    other => {
80                        return Err(ParseError::InvalidStructure(format!(
81                            "Invalid `$kind` value — a card kind must be a string \
82                             matching `[a-z_][a-z0-9_]*` (got {})",
83                            yaml_type_name(&other)
84                        )));
85                    }
86                };
87                if !is_valid_kind_name(&s) {
88                    return Err(ParseError::InvalidStructure(format!(
89                        "Invalid `$kind` value '{}' — a card kind must match \
90                         `[a-z_][a-z0-9_]*`",
91                        s
92                    )));
93                }
94                PayloadItem::Kind { value: s }
95            }
96            "$id" => PayloadItem::Id {
97                value: scalar_to_string(&key, value)?,
98            },
99            "$ext" => match value {
100                JsonValue::Object(map) => PayloadItem::Ext {
101                    value: map,
102                    nested_comments: Vec::new(),
103                },
104                other => {
105                    return Err(ParseError::InvalidStructure(format!(
106                        "Invalid `$ext` value — expected a mapping, got {}",
107                        yaml_type_name(&other)
108                    )));
109                }
110            },
111            other => {
112                return Err(ParseError::InvalidStructure(format!(
113                    "Unknown `{}` system-metadata key — the card-yaml block \
114                     accepts only `$quill`, `$kind`, `$id`, and `$ext`",
115                    other
116                )));
117            }
118        };
119        out.push(meta);
120    }
121
122    Ok(out)
123}
124
125fn require_string(label: &str, value: JsonValue) -> Result<String, ParseError> {
126    match value {
127        JsonValue::String(s) => Ok(s),
128        other => Err(ParseError::InvalidStructure(format!(
129            "Invalid {} — expected a string scalar, got {}",
130            label,
131            yaml_type_name(&other)
132        ))),
133    }
134}
135
136fn scalar_to_string(key: &str, value: JsonValue) -> Result<String, ParseError> {
137    match value {
138        JsonValue::String(s) => Ok(s),
139        JsonValue::Bool(b) => Ok(b.to_string()),
140        JsonValue::Number(n) => Ok(n.to_string()),
141        JsonValue::Null => Err(ParseError::InvalidStructure(format!(
142            "`{}` cannot be null — provide a scalar value",
143            key
144        ))),
145        other => Err(ParseError::InvalidStructure(format!(
146            "`{}` must be a scalar value, got {}",
147            key,
148            yaml_type_name(&other)
149        ))),
150    }
151}
152
153fn yaml_type_name(value: &JsonValue) -> &'static str {
154    match value {
155        JsonValue::Null => "null",
156        JsonValue::Bool(_) => "boolean",
157        JsonValue::Number(_) => "number",
158        JsonValue::String(_) => "string",
159        JsonValue::Array(_) => "sequence",
160        JsonValue::Object(_) => "mapping",
161    }
162}
163
164/// `true` when `name` matches `[a-z_][a-z0-9_]*`.
165pub fn is_valid_kind_name(name: &str) -> bool {
166    if name.is_empty() {
167        return false;
168    }
169    let mut chars = name.chars();
170    let first = chars.next().unwrap();
171    if !first.is_ascii_lowercase() && first != '_' {
172        return false;
173    }
174    for ch in chars {
175        if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '_' {
176            return false;
177        }
178    }
179    true
180}
181
182/// Validate a composable card kind: must match `[a-z_][a-z0-9_]*` and must
183/// not be the reserved root kind `"main"`.
184///
185/// Single source of truth for the composable-kind rule, used by
186/// [`crate::Card::new`], [`crate::Document::set_card_kind`], and the storage
187/// DTO conversion so the rule cannot drift between editor and reader paths.
188pub fn validate_composable_kind(kind: &str) -> Result<(), CardKindError> {
189    if !is_valid_kind_name(kind) {
190        return Err(CardKindError::InvalidName);
191    }
192    if kind == "main" {
193        return Err(CardKindError::Reserved);
194    }
195    Ok(())
196}
197
198/// Reason [`validate_composable_kind`] rejected a kind string.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum CardKindError {
201    /// Kind did not match `[a-z_][a-z0-9_]*`.
202    InvalidName,
203    /// Kind was `"main"`, reserved for the document root.
204    Reserved,
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use serde_json::json;
211
212    #[test]
213    fn extracts_quill_kind_and_leaves_data_intact() {
214        let mut payload = json!({
215            "$quill": "foo@0.1",
216            "$kind": "main",
217            "title": "Doc",
218        });
219        let items = extract_meta_items(&mut payload).unwrap();
220        assert_eq!(items.len(), 2);
221        assert!(matches!(items[0], PayloadItem::Quill { .. }));
222        assert!(matches!(items[1], PayloadItem::Kind { .. }));
223        assert_eq!(payload, json!({"title": "Doc"}));
224    }
225
226    #[test]
227    fn extracts_id_from_number() {
228        let mut payload = json!({"$id": 42});
229        let items = extract_meta_items(&mut payload).unwrap();
230        assert!(matches!(items[0], PayloadItem::Id { ref value } if value == "42"));
231    }
232
233    #[test]
234    fn rejects_unknown_dollar_key() {
235        let mut payload = json!({"$unknown": "x"});
236        let err = extract_meta_items(&mut payload).unwrap_err();
237        assert!(err.to_string().contains("Unknown `$unknown`"));
238    }
239
240    #[test]
241    fn rejects_non_string_quill() {
242        let mut payload = json!({"$quill": 42});
243        let err = extract_meta_items(&mut payload).unwrap_err();
244        assert!(err.to_string().contains("$quill reference"));
245    }
246
247    #[test]
248    fn rejects_invalid_kind_pattern() {
249        let mut payload = json!({"$kind": "Bad-Kind"});
250        let err = extract_meta_items(&mut payload).unwrap_err();
251        assert!(err.to_string().contains("Invalid `$kind`"));
252    }
253
254    #[test]
255    fn validate_composable_kind_rejects_main() {
256        assert_eq!(
257            validate_composable_kind("main"),
258            Err(CardKindError::Reserved)
259        );
260    }
261
262    #[test]
263    fn validate_composable_kind_rejects_bad_name() {
264        assert_eq!(
265            validate_composable_kind("Bad-Name"),
266            Err(CardKindError::InvalidName)
267        );
268    }
269
270    #[test]
271    fn validate_composable_kind_accepts_valid() {
272        assert!(validate_composable_kind("indorsement").is_ok());
273    }
274}