quillmark_core/document/
meta.rs1use 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
22pub(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
35pub(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
166pub 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
184pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202#[non_exhaustive]
203pub enum CardKindError {
204 InvalidName,
206 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}