quillmark_core/document/
meta.rs1use std::str::FromStr;
19
20use serde_json::Value as JsonValue;
21
22use super::payload::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::Ext { .. } => Some("$ext"),
35 PayloadItem::Field { .. } | PayloadItem::Comment { .. } => None,
36 }
37}
38
39pub(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
161pub 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
179pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum CardKindError {
198 InvalidName,
200 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}