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