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