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)]
202pub enum CardKindError {
203 InvalidName,
205 Reserved,
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use serde_json::json;
213
214 #[test]
215 fn extracts_quill_kind_and_leaves_data_intact() {
216 let mut payload = json!({
217 "$quill": "foo@0.1",
218 "$kind": "main",
219 "title": "Doc",
220 });
221 let items = extract_meta_items(&mut payload).unwrap();
222 assert_eq!(items.len(), 2);
223 assert!(matches!(items[0], PayloadItem::Quill { .. }));
224 assert!(matches!(items[1], PayloadItem::Kind { .. }));
225 assert_eq!(payload, json!({"title": "Doc"}));
226 }
227
228 #[test]
229 fn extracts_id_from_number() {
230 let mut payload = json!({"$id": 42});
231 let items = extract_meta_items(&mut payload).unwrap();
232 assert!(matches!(items[0], PayloadItem::Id { ref value } if value == "42"));
233 }
234
235 #[test]
236 fn rejects_unknown_dollar_key() {
237 let mut payload = json!({"$unknown": "x"});
238 let err = extract_meta_items(&mut payload).unwrap_err();
239 assert!(err.to_string().contains("Unknown `$unknown`"));
240 }
241
242 #[test]
243 fn rejects_non_string_quill() {
244 let mut payload = json!({"$quill": 42});
245 let err = extract_meta_items(&mut payload).unwrap_err();
246 assert!(err.to_string().contains("$quill reference"));
247 }
248
249 #[test]
250 fn rejects_invalid_kind_pattern() {
251 let mut payload = json!({"$kind": "Bad-Kind"});
252 let err = extract_meta_items(&mut payload).unwrap_err();
253 assert!(err.to_string().contains("Invalid `$kind`"));
254 }
255
256 #[test]
257 fn validate_composable_kind_rejects_main() {
258 assert_eq!(
259 validate_composable_kind("main"),
260 Err(CardKindError::Reserved)
261 );
262 }
263
264 #[test]
265 fn validate_composable_kind_rejects_bad_name() {
266 assert_eq!(
267 validate_composable_kind("Bad-Name"),
268 Err(CardKindError::InvalidName)
269 );
270 }
271
272 #[test]
273 fn validate_composable_kind_accepts_valid() {
274 assert!(validate_composable_kind("indorsement").is_ok());
275 }
276}