quillmark_core/document/
meta.rs1use 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
26pub(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
39pub(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
170pub 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
188pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum CardKindError {
207 InvalidName,
209 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}