1use std::collections::HashMap;
7
8use crate::{
9 error::{CommitGenError, Result},
10 types::CommitType,
11};
12
13fn normalize_escaped_whitespace(text: &str) -> String {
25 let real = text.matches('\n').count();
26 let literal = text.matches("\\n").count();
27 if literal == 0 || literal < real {
28 return text.to_string();
29 }
30 text
31 .replace("\\r\\n", "\n")
32 .replace("\\n", "\n")
33 .replace("\\r", "\n")
34 .replace("\\t", "\t")
35}
36
37fn strip_fences(text: &str) -> String {
41 let normalized = normalize_escaped_whitespace(text);
42 let t = normalized.trim();
43 if let Some(after_fence) = t.strip_prefix("```") {
45 let after_open = after_fence.split_once('\n').map_or("", |x| x.1);
47 let body = match after_open.rfind("```") {
48 Some(end) => &after_open[..end],
49 None => after_open,
50 };
51 return body.trim().to_string();
52 }
53 t.lines()
55 .filter(|l| l.trim_start().trim_end() != "```" && !l.trim_start().starts_with("```"))
56 .collect::<Vec<_>>()
57 .join("\n")
58 .trim()
59 .to_string()
60}
61
62fn strip_wrapping_quotes(s: &str) -> String {
64 let s = s.trim();
65 let pairs = [('"', '"'), ('\'', '\''), ('`', '`'), ('“', '”'), ('‘', '’')];
66 let chars: Vec<char> = s.chars().collect();
67 if chars.len() >= 2 {
68 let first = chars[0];
69 let last = chars[chars.len() - 1];
70 for (open, close) in pairs {
71 if first == open && last == close {
72 let inner: String = chars[1..chars.len() - 1].iter().collect();
73 return inner.trim().to_string();
74 }
75 }
76 }
77 s.to_string()
78}
79
80fn strip_label_prefix(s: &str) -> String {
82 if let Some(colon) = s.find(':') {
83 let label = s[..colon].trim().to_lowercase();
84 if matches!(label.as_str(), "title" | "summary" | "description" | "result") {
85 return s[colon + 1..].trim().to_string();
86 }
87 }
88 s.to_string()
89}
90
91fn strip_heading_markers(s: &str) -> String {
93 let mut t = s.trim();
94 t = t.trim_start_matches('#').trim_start();
96 for marker in ["**", "*", "__", "_"] {
98 if t.starts_with(marker) && t.ends_with(marker) && t.len() > 2 * marker.len() {
99 t = t[marker.len()..t.len() - marker.len()].trim();
100 }
101 }
102 t.to_string()
103}
104
105fn bullet_content(line: &str) -> Option<&str> {
107 let t = line.trim_start();
108 for glyph in ["- ", "* ", "• ", "– ", "+ "] {
109 if let Some(rest) = t.strip_prefix(glyph) {
110 return Some(rest.trim());
111 }
112 }
113 None
114}
115
116fn extract_tag_lenient(text: &str, tag: &str) -> Option<String> {
120 let lower = text.to_lowercase();
121 let open = format!("<{tag}");
122 let open_pos = lower.find(&open)?;
123 let after_open_rel = text[open_pos..].find('>')? + 1;
125 let content_start = open_pos + after_open_rel;
126 let rest = &text[content_start..];
127 let end = rest.find("</").unwrap_or(rest.len());
129 Some(rest[..end].trim().to_string())
130}
131
132struct AnalysisParts {
135 commit_type: String,
136 scope: Option<String>,
137 summary: String,
138 details: Vec<String>,
139 issue_refs: Vec<String>,
140}
141
142fn parse_analysis_parts(text: &str) -> Result<AnalysisParts> {
143 let unfenced = strip_fences(text);
144 let lines: Vec<&str> = unfenced.lines().collect();
145
146 let mut heading_idx = None;
148 let mut parsed_heading = None;
149 for (i, line) in lines.iter().enumerate() {
150 let candidate = strip_heading_markers(line);
151 if let Some(h) = parse_heading(&candidate) {
152 heading_idx = Some(i);
153 parsed_heading = Some(h);
154 break;
155 }
156 if i >= 5 {
158 break;
159 }
160 }
161
162 let (commit_type, scope, summary) = parsed_heading.ok_or_else(|| {
163 CommitGenError::Other(
164 "markdown analysis: no `type(scope): summary` heading found".to_string(),
165 )
166 })?;
167 let start = heading_idx.unwrap_or(0) + 1;
168
169 let mut details = Vec::new();
170 let mut issue_refs = Vec::new();
171
172 for line in &lines[start..] {
173 let trimmed_line = line.trim();
174 let lower = trimmed_line.to_lowercase();
175
176 if let Some(detail) = bullet_content(trimmed_line) {
177 if !detail.is_empty() {
178 details.push(detail.to_string());
179 }
180 } else if let Some(rest) = lower
181 .strip_prefix("fixes:")
182 .or_else(|| lower.strip_prefix("closes:"))
183 .or_else(|| lower.strip_prefix("resolves:"))
184 {
185 let orig = &trimmed_line[trimmed_line.len() - rest.len()..];
187 for ref_str in orig.split(',') {
188 let r = ref_str.trim();
189 if !r.is_empty() {
190 issue_refs.push(r.to_string());
191 }
192 }
193 }
194 }
195
196 Ok(AnalysisParts { commit_type, scope, summary, details, issue_refs })
197}
198
199pub fn parse_conventional_analysis(text: &str) -> Result<serde_json::Value> {
206 let p = parse_analysis_parts(text)?;
207 let details: Vec<serde_json::Value> = p
208 .details
209 .into_iter()
210 .map(|t| serde_json::json!({ "text": t }))
211 .collect();
212 Ok(serde_json::json!({
213 "type": p.commit_type,
214 "scope": p.scope,
215 "summary": p.summary,
216 "details": details,
217 "issue_refs": p.issue_refs
218 }))
219}
220
221pub fn parse_fast_commit(text: &str) -> Result<serde_json::Value> {
224 let p = parse_analysis_parts(text)?;
225 Ok(serde_json::json!({
226 "type": p.commit_type,
227 "scope": p.scope,
228 "summary": p.summary,
229 "details": p.details
230 }))
231}
232
233fn parse_heading(line: &str) -> Option<(String, Option<String>, String)> {
236 let colon = line.find(':')?;
237 let type_scope = line[..colon].trim();
238 let summary = line[colon + 1..].trim().to_string();
239 if type_scope.is_empty() || summary.is_empty() {
240 return None;
241 }
242
243 let (ty, scope) = if let Some(p_start) = type_scope.find('(') {
244 let p_end = type_scope.find(')')?;
245 if p_end < p_start {
246 return None;
247 }
248 let ty = type_scope[..p_start].trim().to_string();
249 let sc = type_scope[p_start + 1..p_end].trim();
250 (
251 ty,
252 if sc.is_empty() {
253 None
254 } else {
255 Some(sc.to_string())
256 },
257 )
258 } else {
259 (type_scope.to_string(), None)
260 };
261
262 if CommitType::new(&ty).is_err() {
268 return None;
269 }
270 Some((ty, scope, summary))
271}
272
273pub fn parse_summary_output(text: &str) -> Result<serde_json::Value> {
279 let unfenced = strip_fences(text);
280
281 let raw = extract_tag_lenient(&unfenced, "summary").unwrap_or_else(|| unfenced.clone());
283
284 let stripped = strip_heading_markers(&raw);
286 let stripped = strip_label_prefix(&stripped);
287 let stripped = strip_wrapping_quotes(&stripped);
288 let summary_text = stripped.split_whitespace().collect::<Vec<_>>().join(" ");
289
290 if summary_text.is_empty() {
291 return Err(CommitGenError::Other("markdown summary: empty summary text".to_string()));
292 }
293
294 Ok(serde_json::json!({ "summary": summary_text }))
295}
296
297pub fn parse_changelog_response(text: &str) -> Result<serde_json::Value> {
303 const KNOWN: [&str; 7] =
304 ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security", "Breaking"];
305
306 let unfenced = strip_fences(text);
307
308 let mut entries: HashMap<String, Vec<String>> = HashMap::new();
309 let mut current_category: Option<String> = None;
310
311 let canonical = |name: &str| -> Option<String> {
312 let n = name.trim().trim_end_matches(':').trim();
313 KNOWN
314 .iter()
315 .find(|k| k.eq_ignore_ascii_case(n))
316 .map(|k| (*k).to_string())
317 };
318
319 for line in unfenced.lines() {
320 let trimmed_line = line.trim();
321 if trimmed_line.is_empty() {
322 continue; }
324
325 let header = if trimmed_line.starts_with('#') {
330 let h = trimmed_line
331 .trim_start_matches('#')
332 .trim()
333 .trim_end_matches(':')
334 .trim();
335 Some(canonical(h).unwrap_or_else(|| h.to_string()))
336 } else {
337 canonical(trimmed_line)
338 };
339
340 if let Some(h) = header {
341 current_category = Some(h);
342 continue;
343 }
344
345 let entry = bullet_content(trimmed_line).unwrap_or(trimmed_line).trim();
347 if let Some(cat) = ¤t_category
348 && !entry.is_empty()
349 {
350 entries
351 .entry(cat.clone())
352 .or_default()
353 .push(entry.to_string());
354 }
355 }
356
357 if entries.is_empty() {
358 return Err(CommitGenError::Other(
359 "markdown changelog: no entries found (format: ## Category\\n- entry)".to_string(),
360 ));
361 }
362
363 Ok(serde_json::json!({ "entries": entries }))
364}
365
366pub fn parse_compose_intent(text: &str) -> Result<serde_json::Value> {
372 let trimmed = strip_fences(text);
373
374 let mut groups = Vec::new();
375 let mut group_map: HashMap<String, usize> = HashMap::new();
376
377 for line in trimmed.lines() {
379 let trimmed_line = line.trim();
380 if let Some(assign_pos) = trimmed_line.find(":=") {
381 let gid = trimmed_line[..assign_pos].trim().to_string();
382 let rest = &trimmed_line[assign_pos + 2..].trim();
383
384 if let Some(colon_pos) = rest.find(':') {
385 let type_scope = &rest[..colon_pos].trim();
386 let rationale = rest[colon_pos + 1..].trim().to_string();
387
388 let (gtype, scope) = if let Some(paren_start) = type_scope.find('(') {
389 if let Some(paren_end) = type_scope.find(')') {
390 let t = type_scope[..paren_start].trim();
391 let s = type_scope[paren_start + 1..paren_end].trim();
392 (t.to_string(), Some(s.to_string()))
393 } else {
394 (type_scope.to_string(), None)
395 }
396 } else {
397 (type_scope.to_string(), None)
398 };
399
400 group_map.insert(gid.clone(), groups.len());
401
402 let group_obj = serde_json::json!({
403 "group_id": gid,
404 "type": normalize_commit_type(>ype),
405 "scope": scope,
406 "rationale": rationale,
407 "file_ids": Vec::<String>::new(),
408 "dependencies": Vec::<String>::new()
409 });
410 groups.push(group_obj);
411 }
412 }
413 }
414
415 for line in trimmed.lines() {
417 let trimmed_line = line.trim();
418 if let Some(dep_pos) = trimmed_line.find("<-") {
419 let gid = trimmed_line[..dep_pos].trim().to_string();
420 let deps_str = trimmed_line[dep_pos + 2..].trim();
421
422 if let Some(idx) = group_map.get(&gid) {
423 let mut dependencies = Vec::new();
424 for dep_id in deps_str.split(',') {
425 let trimmed_dep = dep_id.trim();
426 if !trimmed_dep.is_empty() {
427 dependencies.push(trimmed_dep.to_string());
428 }
429 }
430 if let Some(group_obj) = groups.get_mut(*idx) {
431 group_obj["dependencies"] = serde_json::Value::Array(
432 dependencies
433 .into_iter()
434 .map(serde_json::Value::String)
435 .collect(),
436 );
437 }
438 }
439 }
440 }
441
442 let mut in_files_section = false;
444 for line in trimmed.lines() {
445 let trimmed_line = line.trim();
446
447 if trimmed_line.to_lowercase().starts_with("files:") {
448 in_files_section = true;
449 continue;
450 }
451
452 if in_files_section
453 && let Some(bullet) = bullet_content(trimmed_line)
454 && let Some(colon_pos) = bullet.find(':')
455 {
456 let gid = bullet[..colon_pos].trim().to_string();
457 let files_str = bullet[colon_pos + 1..].trim();
458
459 if let Some(idx) = group_map.get(&gid)
460 && let Some(group_obj) = groups.get_mut(*idx)
461 {
462 group_obj["file_ids"] = serde_json::Value::Array(
463 files_str
464 .split(',')
465 .map(|f| serde_json::Value::String(f.trim().to_string()))
466 .collect(),
467 );
468 }
469 }
470 }
471
472 if groups.is_empty() {
473 return Err(CommitGenError::Other(
474 "markdown compose intent: no groups found (format: G1 := type(scope): rationale)"
475 .to_string(),
476 ));
477 }
478
479 Ok(serde_json::json!({
480 "groups": groups
481 }))
482}
483
484pub fn parse_compose_binding(text: &str) -> Result<serde_json::Value> {
489 let trimmed = strip_fences(text);
490
491 let mut assignments = Vec::new();
492 let mut current_group: Option<String> = None;
493 let mut current_hunks: Vec<String> = Vec::new();
494
495 for line in trimmed.lines() {
496 let trimmed_line = line.trim();
497
498 if trimmed_line.starts_with('#') {
499 if let Some(gid) = current_group.take() {
501 assignments.push(serde_json::json!({
502 "group_id": gid,
503 "hunk_ids": std::mem::take(&mut current_hunks)
504 }));
505 }
506 let new_gid = trimmed_line
508 .trim_start_matches('#')
509 .trim()
510 .trim_end_matches(':')
511 .trim()
512 .to_string();
513 current_group = Some(new_gid);
514 } else if let Some(hunk_id) = bullet_content(trimmed_line) {
515 current_hunks.push(hunk_id.to_string());
516 }
517 }
518
519 if let Some(gid) = current_group.take() {
521 assignments.push(serde_json::json!({
522 "group_id": gid,
523 "hunk_ids": std::mem::take(&mut current_hunks)
524 }));
525 }
526
527 if assignments.is_empty() {
528 return Err(CommitGenError::Other(
529 "markdown compose binding: no assignments found (format: # group_id\\n- hunk_id)"
530 .to_string(),
531 ));
532 }
533
534 Ok(serde_json::json!({
535 "assignments": assignments
536 }))
537}
538
539pub fn parse_batch_observations(text: &str) -> Result<serde_json::Value> {
546 let unfenced = strip_fences(text);
547
548 let mut files: Vec<serde_json::Value> = Vec::new();
549 let mut current_path: Option<String> = None;
550 let mut current_obs: Vec<String> = Vec::new();
551
552 for line in unfenced.lines() {
553 let t = line.trim();
554 if t.is_empty() {
555 continue;
556 }
557
558 if t.starts_with('#') {
559 if let Some(path) = current_path.take() {
561 files.push(serde_json::json!({
562 "path": path,
563 "observations": std::mem::take(&mut current_obs),
564 }));
565 }
566 current_path = Some(t.trim_start_matches('#').trim().to_string());
567 } else if current_path.is_some() {
568 let obs = bullet_content(t).unwrap_or(t).trim();
570 if !obs.is_empty() {
571 current_obs.push(obs.to_string());
572 }
573 }
574 }
575
576 if let Some(path) = current_path.take() {
577 files.push(serde_json::json!({
578 "path": path,
579 "observations": current_obs,
580 }));
581 }
582
583 if files.is_empty() {
584 return Err(CommitGenError::Other(
585 "markdown observations: no file sections found (format: ## path\\n- observation)"
586 .to_string(),
587 ));
588 }
589
590 Ok(serde_json::json!({ "files": files }))
591}
592
593fn normalize_commit_type(s: &str) -> String {
595 match s.to_lowercase().as_str() {
596 "feat" | "feature" => "feat".to_string(),
597 "fix" | "bugfix" => "fix".to_string(),
598 "docs" | "documentation" => "docs".to_string(),
599 "style" | "formatting" => "style".to_string(),
600 "refactor" | "refactoring" => "refactor".to_string(),
601 "perf" | "performance" => "perf".to_string(),
602 "test" | "tests" => "test".to_string(),
603 "build" | "builder" => "build".to_string(),
604 "ci" | "cicd" => "ci".to_string(),
605 "chore" | "maintenance" => "chore".to_string(),
606 "revert" | "reversion" => "revert".to_string(),
607 "deps" | "dependencies" | "dependency" => "deps".to_string(),
608 "security" | "sec" => "security".to_string(),
609 "config" | "configuration" => "config".to_string(),
610 "ux" | "ergonomics" => "ux".to_string(),
611 "release" | "version" => "release".to_string(),
612 "hotfix" => "hotfix".to_string(),
613 "infra" | "infrastructure" => "infra".to_string(),
614 "init" | "initialization" => "init".to_string(),
615 "merge" | "merging" => "merge".to_string(),
616 "hack" | "hacky" => "hack".to_string(),
617 "wip" | "work-in-progress" => "wip".to_string(),
618 other => other.to_string(),
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
629 fn test_conventional_analysis() {
630 let md = "# feat(api): add user authentication endpoint\n\n- Added POST /auth/login \
631 endpoint\n- Implemented bcrypt password hashing\n\nFixes: #123";
632 let r = parse_conventional_analysis(md).unwrap();
633 assert_eq!(r["type"], "feat");
634 assert_eq!(r["scope"], "api");
635 assert_eq!(r["details"].as_array().unwrap().len(), 2);
636 assert_eq!(r["issue_refs"][0], "#123");
637 }
638
639 #[test]
640 fn test_analysis_lenient_variations() {
641 let md = "```md\n**fix(core): corrected null deref**\n\n* fixed a crash\n* guarded the \
643 pointer\n\nCloses: #7, #8\n```";
644 let r = parse_conventional_analysis(md).unwrap();
645 assert_eq!(r["type"], "fix");
646 assert_eq!(r["scope"], "core");
647 assert_eq!(r["details"].as_array().unwrap().len(), 2);
648 assert_eq!(r["issue_refs"].as_array().unwrap().len(), 2);
649 }
650
651 #[test]
652 fn test_analysis_no_scope_and_leading_blank_lines() {
653 let md = "\n\n\n# chore: bumped version\n";
654 let r = parse_conventional_analysis(md).unwrap();
655 assert_eq!(r["type"], "chore");
656 assert!(r["scope"].is_null());
657 }
658
659 #[test]
660 fn test_heading_requires_known_type_not_json_key() {
661 let json_ish = "{\n \"type\": \"refactor\",\n \"summary\": \"did things\"\n}";
664 assert!(parse_conventional_analysis(json_ish).is_err());
665 assert!(parse_conventional_analysis("summary: did a thing\nscope: core").is_err());
667 }
668
669 #[test]
670 fn test_fast_commit_details_are_plain_strings() {
671 let md = "# refactor(web): derive provider order from options\n\n- Derived the metadata \
674 dynamically.\n- Reprioritized the default sequence.";
675 let r = parse_fast_commit(md).unwrap();
676 assert_eq!(r["type"], "refactor");
677 assert_eq!(r["scope"], "web");
678 let details = r["details"].as_array().unwrap();
679 assert_eq!(details.len(), 2);
680 assert!(details[0].is_string(), "fast details must be strings");
681 #[derive(serde::Deserialize)]
683 struct FastShape {
684 #[serde(rename = "type")]
685 _t: String,
686 details: Vec<String>,
687 }
688 let parsed: FastShape = serde_json::from_value(r).unwrap();
689 assert_eq!(parsed.details.len(), 2);
690 }
691
692 #[test]
695 fn test_summary_variations() {
696 let cases = [
697 "<summary>Added JWT auth</summary>",
698 "Added JWT auth", "\"Added JWT auth\"", "<summary>\"Added JWT auth\"</title>", "```md\n<summary>\nAdded JWT auth\n</summary>\n```", "Title: Added JWT auth", "# Added JWT auth", "\n\n Added JWT auth \n\n", ];
706 for c in cases {
707 let r = parse_summary_output(c).unwrap();
708 assert_eq!(r["summary"], "Added JWT auth", "input was: {c:?}");
709 }
710 }
711
712 #[test]
715 fn test_changelog_hash_and_dash() {
716 let md = "# Added\n- POST /auth/login endpoint\n\n# Fixed\n- Race condition";
717 let r = parse_changelog_response(md).unwrap();
718 let e = r["entries"].as_object().unwrap();
719 assert_eq!(e["Added"].as_array().unwrap().len(), 1);
720 assert_eq!(e["Fixed"].as_array().unwrap().len(), 1);
721 }
722
723 #[test]
724 fn test_changelog_lenient_mixed() {
725 let md = "## Added\n- one\n* two\n\n\nFixed:\nthree\n- four\n\n# Security\n\n five ";
728 let r = parse_changelog_response(md).unwrap();
729 let e = r["entries"].as_object().unwrap();
730 assert_eq!(e["Added"].as_array().unwrap().len(), 2, "Added");
731 assert_eq!(e["Fixed"].as_array().unwrap().len(), 2, "Fixed (bare + dash)");
732 assert_eq!(e["Security"].as_array().unwrap().len(), 1, "Security (bare item)");
733 }
734
735 #[test]
736 fn test_changelog_bare_category_not_confused_with_item() {
737 let md = "# Security\n- Added rate limiting on auth endpoints";
739 let r = parse_changelog_response(md).unwrap();
740 let e = r["entries"].as_object().unwrap();
741 assert!(e.contains_key("Security"));
742 assert!(!e.contains_key("Added"));
743 assert_eq!(e["Security"][0], "Added rate limiting on auth endpoints");
744 }
745
746 #[test]
747 fn test_changelog_fenced() {
748 let md = "```\n# Added\n- thing\n```";
749 let r = parse_changelog_response(md).unwrap();
750 assert_eq!(r["entries"]["Added"][0], "thing");
751 }
752
753 #[test]
756 fn test_literal_backslash_n_analysis() {
757 let md = "# feat(api): add auth\\n\\n- did a thing\\n- did another\\n\\nFixes: #1";
759 let r = parse_conventional_analysis(md).unwrap();
760 assert_eq!(r["type"], "feat");
761 assert_eq!(r["scope"], "api");
762 assert_eq!(r["details"].as_array().unwrap().len(), 2);
763 assert_eq!(r["issue_refs"][0], "#1");
764 }
765
766 #[test]
767 fn test_literal_backslash_n_changelog() {
768 let md = "# Added\\n- one\\n- two\\n# Fixed\\n- three";
769 let r = parse_changelog_response(md).unwrap();
770 let e = r["entries"].as_object().unwrap();
771 assert_eq!(e["Added"].as_array().unwrap().len(), 2);
772 assert_eq!(e["Fixed"].as_array().unwrap().len(), 1);
773 }
774
775 #[test]
776 fn test_real_newlines_with_stray_backslash_preserved() {
777 let md = "# docs: explain C:\\\\path usage\n- noted the path C:\\nope is literal";
779 let r = parse_conventional_analysis(md).unwrap();
780 assert_eq!(r["type"], "docs");
781 assert_eq!(r["details"].as_array().unwrap().len(), 1);
783 }
784
785 #[test]
788 fn test_compose_intent_fenced() {
789 let md = "```\nG1 := feat(api): add endpoints\nG2 := test(api): add tests\n\nG2 <- \
790 G1\n\nFiles:\n- G1: a.rs, b.rs\n* G2: c.test.ts\n```";
791 let r = parse_compose_intent(md).unwrap();
792 let g = r["groups"].as_array().unwrap();
793 assert_eq!(g.len(), 2);
794 assert_eq!(g[0]["file_ids"].as_array().unwrap().len(), 2);
795 assert_eq!(g[1]["dependencies"][0], "G1");
796 assert_eq!(g[1]["file_ids"][0], "c.test.ts"); }
798
799 #[test]
800 fn test_compose_binding_lenient() {
801 let md = "```\n## G1:\n- h1\n* h2\n# G2\n- h3\n```";
802 let r = parse_compose_binding(md).unwrap();
803 let a = r["assignments"].as_array().unwrap();
804 assert_eq!(a.len(), 2);
805 assert_eq!(a[0]["group_id"], "G1"); assert_eq!(a[0]["hunk_ids"].as_array().unwrap().len(), 2);
807 }
808
809 #[test]
812 fn test_batch_observations() {
813 let md = "## src/config.rs\n- added TOML loading\n- changed timeout\n\n## src/main.rs\n- \
814 wired CLI flag\n\n## src/empty.rs";
815 let r = parse_batch_observations(md).unwrap();
816 let files = r["files"].as_array().unwrap();
817 assert_eq!(files.len(), 3);
818 assert_eq!(files[0]["path"], "src/config.rs");
819 assert_eq!(files[0]["observations"].as_array().unwrap().len(), 2);
820 assert_eq!(files[1]["observations"].as_array().unwrap().len(), 1);
821 assert_eq!(files[2]["observations"].as_array().unwrap().len(), 0); }
823
824 #[test]
825 fn test_batch_observations_fenced_and_literal_newlines() {
826 let md = "```\\n## a.rs\\n- did x\\n* did y\\n## b.rs\\n- did z\\n```";
827 let r = parse_batch_observations(md).unwrap();
828 let files = r["files"].as_array().unwrap();
829 assert_eq!(files.len(), 2);
830 assert_eq!(files[0]["path"], "a.rs");
831 assert_eq!(files[0]["observations"].as_array().unwrap().len(), 2);
832 }
833}