1mod edit;
19mod error;
20mod parse;
21mod render;
22mod schema;
23mod validate;
24
25pub use error::{EditError, Problem, RenderError, SchemaError};
26pub use parse::parse_schema;
27pub use schema::{Card, FieldSchema, FieldType, Head, ListStyle, Match, Node, Schema, SchemaOpts};
28
29#[cfg(test)]
30mod tests {
31 use super::*;
32
33 fn parse(src: &str) -> Schema {
34 parse_schema(src).expect("schema parses")
35 }
36
37 #[test]
38 fn defaults_are_strict() {
39 let s = parse("> @x");
40 assert!(s.opts.ordered && s.opts.strict && !s.opts.frontmatter_open);
41 }
42
43 #[test]
44 fn directives_override_defaults() {
45 let s = parse("%ordered = false\n%strict = false\n%frontmatter = open\n> @x");
46 assert!(!s.opts.ordered && !s.opts.strict && s.opts.frontmatter_open);
47 }
48
49 #[test]
50 fn frontmatter_types_and_alias() {
51 let s = parse(
52 "---\n\
53 title: string\n\
54 status: enum(planned, done)\n\
55 tags: [string]+\n\
56 owner?: string\n\
57 spec_url? @spec: /^https/\n\
58 ---\n",
59 );
60 let f = &s.frontmatter;
61 assert_eq!(f.len(), 5);
62 assert_eq!(f[0].key, "title");
63 assert_eq!(f[0].ty, FieldType::Str);
64 assert_eq!(
65 f[1].ty,
66 FieldType::Enum(vec!["planned".into(), "done".into()])
67 );
68 assert_eq!(f[2].ty, FieldType::List(Box::new(FieldType::Str)));
69 assert!(f[3].optional && f[3].alias == "owner");
70 assert_eq!(f[4].key, "spec_url");
72 assert_eq!(f[4].alias, "spec");
73 assert!(f[4].optional);
74 assert!(matches!(f[4].ty, FieldType::Regex(_)));
75 }
76
77 #[test]
78 fn body_head_card_name_label() {
79 let s = parse(
80 "## @manual Manual verification\n\
81 \x20 ### @setup Setup\n\
82 \x20 - +@items\n\
83 \x20 ### @procedure Procedure\n\
84 \x20 1. +@steps\n\
85 \x20 - ?@note\n",
86 );
87 assert_eq!(s.body.len(), 1);
89 let Node::Heading {
90 level,
91 title,
92 head,
93 children,
94 } = &s.body[0]
95 else {
96 panic!("expected heading");
97 };
98 assert_eq!(*level, 2);
99 assert_eq!(head.name.as_deref(), Some("manual"));
100 assert_eq!(*title, Match::Literal("Manual verification".into()));
101 assert_eq!(children.len(), 2);
102
103 let Node::Heading {
105 children: setup, ..
106 } = &children[0]
107 else {
108 panic!();
109 };
110 assert_eq!(setup.len(), 1);
111 let Node::List { style, head, .. } = &setup[0] else {
112 panic!("expected list");
113 };
114 assert_eq!(*style, ListStyle::Bullet);
115 assert_eq!(head.name.as_deref(), Some("items"));
116 assert_eq!(head.card, Card::Plus);
117
118 let Node::Heading { children: proc, .. } = &children[1] else {
120 panic!();
121 };
122 let Node::List {
123 style,
124 children: steps_kids,
125 ..
126 } = &proc[0]
127 else {
128 panic!();
129 };
130 assert_eq!(*style, ListStyle::Ordered);
131 assert_eq!(steps_kids.len(), 1);
132 let Node::List { head, .. } = &steps_kids[0] else {
133 panic!();
134 };
135 assert_eq!(head.card, Card::Optional);
136 assert_eq!(head.name.as_deref(), Some("note"));
137 }
138
139 #[test]
140 fn labels_regex_literal_and_bare_with_card() {
141 let s = parse("# @title /.+/");
143 let Node::Heading { title, .. } = &s.body[0] else {
144 panic!();
145 };
146 assert_eq!(*title, Match::Regex(".+".into()));
147
148 let s = parse("## + Docs:");
150 let Node::Heading { title, head, .. } = &s.body[0] else {
151 panic!();
152 };
153 assert_eq!(head.card, Card::Plus);
154 assert_eq!(head.name, None);
155 assert_eq!(*title, Match::Literal("Docs:".into()));
156
157 let s = parse("- {1,5}@points");
159 let Node::List { head, .. } = &s.body[0] else {
160 panic!();
161 };
162 assert_eq!(head.card, Card::Range(1, Some(5)));
163 assert_eq!(head.name.as_deref(), Some("points"));
164 }
165
166 #[test]
167 fn description_is_fenced_and_label_may_contain_dashes() {
168 let s = parse("## @t Trade-offs: speed -- vs -- safety <? the desc ?>");
169 let Node::Heading { title, head, .. } = &s.body[0] else {
170 panic!();
171 };
172 assert_eq!(
173 *title,
174 Match::Literal("Trade-offs: speed -- vs -- safety".into())
175 );
176 assert_eq!(head.desc.as_deref(), Some("the desc"));
177 }
178
179 #[test]
180 fn escaping_leading_special() {
181 let s = parse("- \\+literal-plus");
182 let Node::List { item, head, .. } = &s.body[0] else {
183 panic!();
184 };
185 assert_eq!(head.card, Card::Required); assert_eq!(*item, Some(Match::Literal("+literal-plus".into())));
187 }
188
189 #[test]
190 fn unterminated_frontmatter_errors() {
191 let err = parse_schema("---\ntitle: string\n").unwrap_err();
192 assert!(err.message.contains("unterminated frontmatter"));
193 }
194
195 const SCHEMA: &str = "## @manual Manual verification\n\
196 \x20 ### @setup Setup\n\
197 \x20 - +@items\n\
198 \x20 ### @procedure Procedure\n\
199 \x20 1. +@steps\n\
200 ## @plan Test plan\n\
201 \x20 - [ ] +@cases\n";
202
203 #[test]
204 fn validate_accepts_a_conforming_document() {
205 let s = parse(SCHEMA);
206 let doc = "## Manual verification\n\
207 ### Setup\n\
208 - external monitor\n\n\
209 ### Procedure\n\
210 1. open a tab\n\
211 2. run it\n\n\
212 ## Test plan\n\
213 - [x] one\n\
214 - [ ] two\n";
215 let problems = s.validate(doc);
216 assert!(problems.is_empty(), "{problems:?}");
217 }
218
219 #[test]
220 fn validate_flags_missing_section_and_empty_list() {
221 let s = parse(SCHEMA);
222 let doc = "## Manual verification\n\
224 ### Setup\n\n\
225 ### Procedure\n\
226 1. open\n";
227 let problems = s.validate(doc);
228 let joined: Vec<String> = problems.iter().map(|p| p.to_string()).collect();
229 let all = joined.join("\n");
230 assert!(all.contains("plan"), "missing Test plan: {all}");
231 assert!(
232 all.contains("setup") && all.contains("at least one item"),
233 "empty Setup list: {all}"
234 );
235 }
236
237 #[test]
238 fn validate_respects_strict_ordering() {
239 let s = parse("## @m M\n ### @setup Setup\n > @x\n ### @proc Procedure\n > @y\n");
241 let reversed = "## M\n### Procedure\nbody\n\n### Setup\nbody\n";
242 let problems = s.validate(reversed);
243 assert!(!problems.is_empty(), "strict order should flag reversal");
244 }
245
246 #[test]
247 fn extract_builds_the_capture_object() {
248 let s = parse(SCHEMA);
249 let doc = "## Manual verification\n\
250 ### Setup\n\
251 - external monitor\n\n\
252 ### Procedure\n\
253 1. open a tab\n\
254 2. run it\n\n\
255 ## Test plan\n\
256 - [x] one\n\
257 - [ ] two\n";
258 let v = s.extract(doc).expect("conforms");
259 assert_eq!(v["manual"]["setup"]["items"][0], "external monitor");
260 assert_eq!(v["manual"]["procedure"]["steps"][1], "run it");
261 assert_eq!(v["plan"]["cases"], serde_json::json!(["one", "two"]));
262 }
263
264 #[test]
265 fn extract_errors_on_nonconforming() {
266 let s = parse(SCHEMA);
267 let bad = "## Manual verification\n### Setup\n\n### Procedure\n1. x\n";
268 assert!(s.extract(bad).is_err());
269 }
270
271 #[test]
272 fn scaffold_emits_required_only() {
273 let s = parse(
274 "---\n\
275 title: string\n\
276 status: enum(planned, done)\n\
277 tags: [string]+\n\
278 owner?: string\n\
279 ---\n\
280 ## @manual Manual verification\n\
281 \x20 ### @setup Setup\n\
282 \x20 - +@items\n\
283 ## ?@refs References\n\
284 \x20 - *@links\n",
285 );
286 let out = s.scaffold();
287 assert!(out.contains("title: \n"), "{out}");
288 assert!(out.contains("status: planned\n"), "{out}"); assert!(out.contains("tags: []\n"), "{out}");
290 assert!(!out.contains("owner"), "optional key omitted: {out}");
291 assert!(out.contains("## Manual verification\n"), "{out}");
292 assert!(out.contains("### Setup\n"), "{out}");
293 assert!(out.contains("- \n") || out.contains("- "), "{out}");
294 assert!(
295 !out.contains("References"),
296 "optional heading omitted: {out}"
297 );
298 }
299
300 #[test]
301 fn render_produces_conforming_document() {
302 let s = parse(SCHEMA);
303 let data = serde_json::json!({
304 "manual": {
305 "setup": { "items": ["external monitor", "test device"] },
306 "procedure": { "steps": ["open a tab", "run it"] }
307 },
308 "plan": { "cases": ["smoke", "edge case"] }
309 });
310 let md = s.render(&data).expect("renders");
311 let problems = s.validate(&md);
313 assert!(
314 problems.is_empty(),
315 "rendered doc fails validate: {problems:?}\n---\n{md}"
316 );
317 }
318
319 #[test]
320 fn render_errors_on_missing_required_field() {
321 let s = parse(SCHEMA);
322 let data = serde_json::json!({
324 "manual": {
325 "setup": { "items": ["a"] },
326 "procedure": { "steps": ["b"] }
327 }
328 });
329 let err = s.render(&data).unwrap_err();
330 assert!(matches!(err, RenderError::MissingField(_)));
331 }
332
333 #[test]
334 fn round_trip_preserves_markdown_significant_text() {
335 let schema = parse("- +@items\n");
343 let md = "- run `printf '\\033\\\\x'` then press <ctrl>c\n";
346 let data1 = schema.extract(md).expect("extract");
347 let text = data1["items"][0].as_str().unwrap();
348 assert!(text.contains("<ctrl>c"), "angle-bracket text lost: {text}");
349 assert!(text.contains("\\033\\\\x"), "backslashes lost: {text}");
350 let rendered = schema.render(&data1).expect("render");
352 let data2 = schema.extract(&rendered).expect("re-extract");
353 assert_eq!(data1, data2);
354 }
355
356 #[test]
357 fn edit_replaces_list_item_text() {
358 let s = parse(SCHEMA);
359 let doc = "## Manual verification\n\
360 ### Setup\n\
361 - external monitor\n\n\
362 ### Procedure\n\
363 1. open a tab\n\
364 2. run it\n\n\
365 ## Test plan\n\
366 - [x] one\n\
367 - [ ] two\n";
368 let edited = s
370 .edit(doc, "manual.setup.items.0", "projector")
371 .expect("edit succeeds");
372 assert!(edited.contains("- projector\n"), "item replaced: {edited}");
373 assert!(
374 !edited.contains("external monitor"),
375 "old text gone: {edited}"
376 );
377 assert!(edited.contains("open a tab"), "rest preserved: {edited}");
379 let problems = s.validate(&edited);
381 assert!(problems.is_empty(), "edited doc conforms: {problems:?}");
382 }
383
384 #[test]
385 fn edit_replaces_checklist_item_preserving_checkbox() {
386 let s = parse(SCHEMA);
387 let doc = "## Manual verification\n\
388 ### Setup\n\
389 - a\n\n\
390 ### Procedure\n\
391 1. step\n\n\
392 ## Test plan\n\
393 - [x] old case\n\
394 - [ ] second\n";
395 let edited = s
396 .edit(doc, "plan.cases.0", "new case")
397 .expect("edit succeeds");
398 assert!(
400 edited.contains("- [x] new case\n"),
401 "checkbox preserved: {edited}"
402 );
403 assert!(!edited.contains("old case"), "old text gone: {edited}");
404 }
405
406 #[test]
407 fn edit_returns_target_not_found_for_bad_path() {
408 let s = parse(SCHEMA);
409 let doc = "## Manual verification\n\
410 ### Setup\n\
411 - a\n\n\
412 ### Procedure\n\
413 1. b\n\n\
414 ## Test plan\n\
415 - [ ] c\n";
416 let err = s.edit(doc, "nonexistent.path", "x").unwrap_err();
417 assert!(matches!(err, EditError::TargetNotFound));
418 }
419}