1use crate::checkpoint::execution_history::{IssuesSummary, ModifiedFilesDetail};
7use crate::prompts::template_validator::VariableInfo;
8use std::collections::HashMap;
9
10pub fn parse_variable_spec_impl(var_spec: &str) -> Option<(&str, Option<String>)> {
14 let trimmed = var_spec.trim();
15 if trimmed.starts_with('>') || trimmed.is_empty() {
16 return None;
17 }
18 let (name, default_value) = trimmed.find('|').map_or((trimmed, None), |pipe_pos| {
19 let name = trimmed[..pipe_pos].trim();
20 let rest = &trimmed[pipe_pos + 1..];
21 rest.find('=').map_or((name, None), |eq_pos| {
22 let key = rest[..eq_pos].trim();
23 if key == "default" {
24 let value = rest[eq_pos + 1..].trim();
25 let value = if (value.starts_with('"') && value.ends_with('"'))
26 || (value.starts_with('\'') && value.ends_with('\''))
27 {
28 &value[1..value.len() - 1]
29 } else {
30 value
31 };
32 (name, Some(value.to_string()))
33 } else {
34 (name, None)
35 }
36 })
37 });
38 Some((name, default_value))
39}
40
41pub fn parse_metadata_line_impl(line: &str) -> Option<(Option<String>, Option<String>)> {
48 let inner = line.get(2..line.len().saturating_sub(2))?.trim();
49 let version = inner.strip_prefix("Version:").map(|s| s.trim().to_string());
50 let purpose = inner.strip_prefix("PURPOSE:").map(|s| s.trim().to_string());
51 Some((version, purpose))
52}
53
54pub fn make_variable_info(
58 var_name: &str,
59 line: usize,
60 default_value: Option<String>,
61) -> VariableInfo {
62 VariableInfo {
63 name: var_name.to_string(),
64 line,
65 has_default: default_value.is_some(),
66 default_value,
67 placeholder: var_name.to_string(),
68 }
69}
70
71pub fn is_metadata_comment(line: &str) -> bool {
75 line.trim().starts_with("{#") && line.trim().ends_with("#}")
76}
77
78pub fn parse_loop_header_impl(full_match: &str) -> Option<(&str, &str)> {
84 let in_pos = full_match.find(" in ")?;
85 let header = &full_match[7..in_pos];
86 let body_start = full_match.find("%}").map_or(full_match.len(), |p| p + 2);
87 let body = &full_match[body_start..full_match.len() - 2];
88 Some((header, body))
89}
90
91pub fn split_loop_items_impl(values: &str) -> Vec<&str> {
95 if values.contains(',') {
96 values.split(',').map(str::trim).collect()
97 } else {
98 values
99 .lines()
100 .map(str::trim)
101 .filter(|s| !s.is_empty())
102 .collect()
103 }
104}
105
106pub fn eval_conditional_impl(condition: &str, variables: &HashMap<&str, String>) -> bool {
108 variables
109 .get(condition)
110 .map(|v| !v.is_empty())
111 .unwrap_or(false)
112}
113
114pub fn format_resume_state_impl(resume_count: u32, rebase_state: &str) -> String {
115 match (resume_count > 0, rebase_state != "NotStarted") {
116 (true, true) => format!(
117 "This session has been resumed {resume_count} time(s)\nRebase state: {rebase_state}"
118 ),
119 (true, false) => format!("This session has been resumed {resume_count} time(s)\n"),
120 (false, true) => format!("Rebase state: {rebase_state}"),
121 (false, false) => String::new(),
122 }
123}
124
125pub fn format_files_summary_impl(detail: &ModifiedFilesDetail) -> Option<String> {
126 let added_count = detail.added.as_ref().map_or(0, |v| v.len());
127 let modified_count = detail.modified.as_ref().map_or(0, |v| v.len());
128 let deleted_count = detail.deleted.as_ref().map_or(0, |v| v.len());
129 let total_files = added_count + modified_count + deleted_count;
130 if total_files == 0 {
131 return None;
132 }
133
134 let s = format!(" Files: {total_files} changed");
135 let parts: Vec<String> = [
136 (added_count, "added"),
137 (modified_count, "modified"),
138 (deleted_count, "deleted"),
139 ]
140 .iter()
141 .filter_map(|&(count, label)| {
142 if count > 0 {
143 Some(format!("({count} {label})"))
144 } else {
145 None
146 }
147 })
148 .collect();
149 let suffix = if parts.is_empty() {
150 String::new()
151 } else {
152 format!(" {}", parts.join(" "))
153 };
154 Some(format!("{s}{suffix}\n"))
155}
156
157pub fn format_issues_summary_impl(issues: &IssuesSummary) -> Option<String> {
158 if issues.found == 0 && issues.fixed == 0 {
159 return None;
160 }
161
162 let s = format!(" Issues: {} found, {} fixed", issues.found, issues.fixed);
163 let s = issues
164 .description
165 .as_ref()
166 .map_or_else(|| s.clone(), |desc| format!("{s} ({desc})"));
167 Some(format!("{s}\n"))
168}
169
170fn find_variable_end(bytes: &[u8], start: usize) -> Option<usize> {
175 bytes[start..]
176 .windows(2)
177 .position(|w| w[0] == b'}' && w[1] == b'}')
178 .map(|i| start + i)
179}
180
181fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
182 bytes[start + 2..]
183 .windows(2)
184 .position(|w| w[0] == b'#' && w[1] == b'}')
185 .map(|i| start + i + 4)
186}
187
188fn find_tag_end(bytes: &[u8], start: usize) -> Option<usize> {
189 bytes[start..]
190 .windows(2)
191 .position(|w| w[0] == b'%' && w[1] == b'}')
192 .map(|i| start + i)
193}
194
195fn skip_comment(bytes: &[u8], start: usize) -> Option<usize> {
196 find_comment_end(bytes, start)
197}
198
199fn skip_comment_partial(bytes: &[u8], start: usize) -> Option<usize> {
200 find_comment_end(bytes, start)
201}
202
203pub fn extract_variables_impl(content: &str) -> Vec<VariableInfo> {
204 extract_vars_iterative(content.as_bytes())
205}
206
207struct VarCursorState<'a> {
209 bytes: &'a [u8],
210 pos: usize,
211 line: usize,
212}
213
214fn advance_var_cursor(
219 state: VarCursorState<'_>,
220) -> Option<(Option<VariableInfo>, VarCursorState<'_>)> {
221 let VarCursorState { bytes, pos, line } = state;
222
223 if pos >= bytes.len().saturating_sub(1) {
224 return None;
225 }
226
227 if bytes[pos] == b'\n' {
229 return Some((
230 None,
231 VarCursorState {
232 bytes,
233 pos: pos + 1,
234 line: line + 1,
235 },
236 ));
237 }
238
239 if pos + 1 < bytes.len() && bytes[pos] == b'{' && bytes[pos + 1] == b'#' {
241 return find_comment_end(bytes, pos).map(|next| {
242 (
243 None,
244 VarCursorState {
245 bytes,
246 pos: next,
247 line,
248 },
249 )
250 });
251 }
252
253 if bytes[pos] == b'{' && pos + 1 < bytes.len() && bytes[pos + 1] == b'{' {
255 if let Some(var_end) = find_variable_end(bytes, pos + 2) {
256 let info = std::str::from_utf8(&bytes[pos + 2..var_end])
257 .ok()
258 .and_then(|raw_spec| parse_variable_spec_impl(raw_spec))
259 .map(|(var_name, default_value)| VariableInfo {
260 name: var_name.to_string(),
261 line,
262 has_default: default_value.is_some(),
263 default_value,
264 placeholder: bytes[pos + 2..var_end]
265 .iter()
266 .copied()
267 .map(|b| b as char)
268 .collect::<String>()
269 .trim()
270 .to_string(),
271 });
272 return Some((
273 info,
274 VarCursorState {
275 bytes,
276 pos: var_end + 2,
277 line,
278 },
279 ));
280 }
281 }
282
283 Some((
285 None,
286 VarCursorState {
287 bytes,
288 pos: pos + 1,
289 line,
290 },
291 ))
292}
293
294fn extract_vars_iterative(bytes: &[u8]) -> Vec<VariableInfo> {
298 let initial = VarCursorState {
299 bytes,
300 pos: 0,
301 line: 0,
302 };
303 std::iter::successors(Some((None::<VariableInfo>, initial)), |(_, state)| {
304 advance_var_cursor(VarCursorState {
305 bytes: state.bytes,
306 pos: state.pos,
307 line: state.line,
308 })
309 })
310 .filter_map(|(info, _)| info)
311 .collect()
312}
313
314pub fn extract_partials_impl(content: &str) -> Vec<String> {
315 extract_partials_iterative(content.as_bytes(), content)
316}
317
318struct PartialCursorState<'a> {
320 bytes: &'a [u8],
321 content: &'a str,
322 pos: usize,
323}
324
325fn advance_partial_cursor(
330 state: PartialCursorState<'_>,
331) -> Option<(Option<String>, PartialCursorState<'_>)> {
332 let PartialCursorState {
333 bytes,
334 content,
335 pos,
336 } = state;
337
338 if pos >= bytes.len().saturating_sub(2) {
339 return None;
340 }
341
342 if pos + 1 < bytes.len() && bytes[pos] == b'{' && bytes[pos + 1] == b'#' {
344 return skip_comment_partial(bytes, pos).map(|next| {
345 (
346 None,
347 PartialCursorState {
348 bytes,
349 content,
350 pos: next,
351 },
352 )
353 });
354 }
355
356 if bytes[pos] == b'{' && bytes[pos + 1] == b'{' && pos + 2 < bytes.len() {
358 let after_braces = pos + 2;
359 let after_ws = after_braces
360 + bytes[after_braces..]
361 .iter()
362 .take_while(|&&b| b == b' ' || b == b'\t')
363 .count();
364
365 if after_ws < bytes.len() && bytes[after_ws] == b'>' {
366 let after_gt = after_ws + 1;
367 let name_start = after_gt
368 + bytes[after_gt..]
369 .iter()
370 .take_while(|&&b| b == b' ' || b == b'\t')
371 .count();
372 let name_end = name_start
373 + bytes[name_start..]
374 .iter()
375 .take_while(|&&b| b != b'}')
376 .count();
377
378 if name_end < bytes.len()
379 && bytes[name_end] == b'}'
380 && name_end + 1 < bytes.len()
381 && bytes[name_end + 1] == b'}'
382 {
383 let name = content[name_start..name_end].trim();
384 let partial = if name.is_empty() {
385 None
386 } else {
387 Some(name.to_string())
388 };
389 return Some((
390 partial,
391 PartialCursorState {
392 bytes,
393 content,
394 pos: name_end + 2,
395 },
396 ));
397 }
398 }
399 }
400
401 Some((
403 None,
404 PartialCursorState {
405 bytes,
406 content,
407 pos: pos + 1,
408 },
409 ))
410}
411
412fn extract_partials_iterative(bytes: &[u8], content: &str) -> Vec<String> {
416 let initial = PartialCursorState {
417 bytes,
418 content,
419 pos: 0,
420 };
421 std::iter::successors(Some((None::<String>, initial)), |(_, state)| {
422 advance_partial_cursor(PartialCursorState {
423 bytes: state.bytes,
424 content: state.content,
425 pos: state.pos,
426 })
427 })
428 .filter_map(|(partial, _)| partial)
429 .collect()
430}
431
432#[derive(Debug, Clone)]
433pub enum ValidationError {
434 UnclosedComment { line: usize },
435 UnclosedConditional { line: usize },
436 UnclosedLoop { line: usize },
437 InvalidConditional { line: usize, syntax: String },
438 InvalidLoop { line: usize, syntax: String },
439}
440
441#[derive(Default)]
442pub struct ValidationState {
443 pub errors: Vec<ValidationError>,
444 pub conditional_stack: Vec<(usize, &'static str)>,
445 pub loop_stack: Vec<(usize, &'static str)>,
446}
447
448pub fn validate_template_bytes(content: &str, bytes: &[u8]) -> ValidationState {
449 bytes
450 .iter()
451 .enumerate()
452 .fold(ValidationState::default(), |state, (i, &byte)| {
453 process_byte(content, bytes, state, i, byte)
454 })
455}
456
457fn process_byte(
458 content: &str,
459 bytes: &[u8],
460 state: ValidationState,
461 i: usize,
462 _byte: u8,
463) -> ValidationState {
464 if i >= bytes.len() {
465 let state = match state.conditional_stack.first() {
466 Some((line, _)) => ValidationState {
467 errors: state
468 .errors
469 .into_iter()
470 .chain(std::iter::once(ValidationError::UnclosedConditional {
471 line: *line,
472 }))
473 .collect(),
474 conditional_stack: state.conditional_stack,
475 loop_stack: state.loop_stack,
476 },
477 None => state,
478 };
479 return match state.loop_stack.first() {
480 Some((line, _)) => ValidationState {
481 errors: state
482 .errors
483 .into_iter()
484 .chain(std::iter::once(ValidationError::UnclosedLoop {
485 line: *line,
486 }))
487 .collect(),
488 conditional_stack: state.conditional_stack,
489 loop_stack: state.loop_stack,
490 },
491 None => state,
492 };
493 }
494
495 if bytes[i] == b'\n' {
496 return process_byte(content, bytes, state, i + 1, bytes[i]);
497 }
498
499 if i + 1 < bytes.len() && bytes[i] == b'{' && bytes[i + 1] == b'#' {
500 return match skip_comment(bytes, i) {
501 Some(next) => process_byte(content, bytes, state, next, bytes[next]),
502 None => ValidationState {
503 errors: state
504 .errors
505 .into_iter()
506 .chain(std::iter::once(ValidationError::UnclosedComment {
507 line: 0,
508 }))
509 .collect(),
510 conditional_stack: state.conditional_stack,
511 loop_stack: state.loop_stack,
512 },
513 };
514 }
515
516 if i + 5 < bytes.len()
517 && bytes[i] == b'{'
518 && bytes[i + 1] == b'%'
519 && bytes[i + 2] == b' '
520 && bytes[i + 3] == b'i'
521 && bytes[i + 4] == b'f'
522 && bytes[i + 5] == b' '
523 {
524 return match find_tag_end(bytes, i + 6) {
525 Some(cond_end) => {
526 let condition = &content[i + 6..cond_end].trim();
527 let errors =
528 if condition.is_empty() || condition.contains('{') || condition.contains('}') {
529 state
530 .errors
531 .into_iter()
532 .chain(std::iter::once(ValidationError::InvalidConditional {
533 line: 0,
534 syntax: condition.to_string(),
535 }))
536 .collect()
537 } else {
538 state.errors
539 };
540 let conditional_stack = state
541 .conditional_stack
542 .into_iter()
543 .chain(std::iter::once((0usize, "if")))
544 .collect();
545 let next_state = ValidationState {
546 errors,
547 conditional_stack,
548 loop_stack: state.loop_stack,
549 };
550 process_byte(
551 content,
552 bytes,
553 next_state,
554 cond_end + 2,
555 bytes[cond_end + 2],
556 )
557 }
558 None => ValidationState {
559 errors: state
560 .errors
561 .into_iter()
562 .chain(std::iter::once(ValidationError::UnclosedConditional {
563 line: 0,
564 }))
565 .collect(),
566 conditional_stack: state.conditional_stack,
567 loop_stack: state.loop_stack,
568 },
569 };
570 }
571
572 if i + 9 < bytes.len()
573 && bytes[i] == b'{'
574 && bytes[i + 1] == b'%'
575 && bytes[i + 2] == b' '
576 && bytes[i + 3] == b'e'
577 && bytes[i + 4] == b'n'
578 && bytes[i + 5] == b'd'
579 && bytes[i + 6] == b'i'
580 && bytes[i + 7] == b'f'
581 && bytes[i + 8] == b' '
582 && bytes[i + 9] == b'%'
583 {
584 let conditional_stack: Vec<_> = state
585 .conditional_stack
586 .into_iter()
587 .rev()
588 .skip(1)
589 .collect::<Vec<_>>()
590 .into_iter()
591 .rev()
592 .collect();
593 let next_state = ValidationState {
594 errors: state.errors,
595 conditional_stack,
596 loop_stack: state.loop_stack,
597 };
598 return process_byte(content, bytes, next_state, i + 11, bytes[i + 11]);
599 }
600
601 if i + 6 < bytes.len()
602 && bytes[i] == b'{'
603 && bytes[i + 1] == b'%'
604 && bytes[i + 2] == b' '
605 && bytes[i + 3] == b'f'
606 && bytes[i + 4] == b'o'
607 && bytes[i + 5] == b'r'
608 && bytes[i + 6] == b' '
609 {
610 return match find_tag_end(bytes, i + 7) {
611 Some(header_end) => {
612 let condition = &content[i + 7..header_end].trim();
613 let errors = if !condition.contains(" in ") || condition.split(" in ").count() != 2
614 {
615 state
616 .errors
617 .into_iter()
618 .chain(std::iter::once(ValidationError::InvalidLoop {
619 line: 0,
620 syntax: condition.to_string(),
621 }))
622 .collect()
623 } else {
624 state.errors
625 };
626 let loop_stack = state
627 .loop_stack
628 .into_iter()
629 .chain(std::iter::once((0usize, "for")))
630 .collect();
631 let next_state = ValidationState {
632 errors,
633 conditional_stack: state.conditional_stack,
634 loop_stack,
635 };
636 process_byte(
637 content,
638 bytes,
639 next_state,
640 header_end + 2,
641 bytes[header_end + 2],
642 )
643 }
644 None => ValidationState {
645 errors: state
646 .errors
647 .into_iter()
648 .chain(std::iter::once(ValidationError::UnclosedLoop { line: 0 }))
649 .collect(),
650 conditional_stack: state.conditional_stack,
651 loop_stack: state.loop_stack,
652 },
653 };
654 }
655
656 if i + 10 < bytes.len()
657 && bytes[i] == b'{'
658 && bytes[i + 1] == b'%'
659 && bytes[i + 2] == b' '
660 && bytes[i + 3] == b'e'
661 && bytes[i + 4] == b'n'
662 && bytes[i + 5] == b'd'
663 && bytes[i + 6] == b'i'
664 && bytes[i + 7] == b'f'
665 && bytes[i + 8] == b'o'
666 && bytes[i + 9] == b'r'
667 && bytes[i + 10] == b' '
668 {
669 let loop_stack: Vec<_> = state
670 .loop_stack
671 .into_iter()
672 .rev()
673 .skip(1)
674 .collect::<Vec<_>>()
675 .into_iter()
676 .rev()
677 .collect();
678 let next_state = ValidationState {
679 errors: state.errors,
680 conditional_stack: state.conditional_stack,
681 loop_stack,
682 };
683 return process_byte(content, bytes, next_state, i + 12, bytes[i + 12]);
684 }
685
686 state
687}
688
689const PARTIAL_FIELD_MAX_CHARS: usize = 120;
690
691fn truncate_one_line(input: &str, max_chars: usize) -> String {
692 let first_line = input.lines().next().unwrap_or("").trim();
693 let out: String = first_line.chars().take(max_chars).collect();
694 if first_line.chars().count() > max_chars {
695 format!("{}...(truncated)", out)
696 } else {
697 out
698 }
699}
700
701#[derive(Debug, Clone)]
702pub struct OutcomeDescription {
703 pub success_with_output: Option<String>,
704 pub success_with_files: Option<String>,
705 pub success_plain: String,
706 pub failure_recoverable: Option<String>,
707 pub failure_fatal: Option<String>,
708 pub partial: Option<String>,
709 pub skipped: Option<String>,
710}
711
712impl OutcomeDescription {
713 pub fn from_outcome(
714 files_modified: &Option<Vec<String>>,
715 output: &Option<String>,
716 error: &Option<String>,
717 recoverable: &Option<bool>,
718 completed: &Option<String>,
719 remaining: &Option<String>,
720 reason: &Option<String>,
721 ) -> Self {
722 let success_with_output = output.as_ref().and_then(|out| {
723 if out.is_empty() {
724 None
725 } else {
726 Some(format!("Success - {}", out.lines().next().unwrap_or("")))
727 }
728 });
729 let success_with_files = files_modified.as_ref().and_then(|files| {
730 if files.is_empty() {
731 None
732 } else {
733 Some(format!("Success - {} files modified", files.len()))
734 }
735 });
736 let success_plain = "Success".to_string();
737 let failure_recoverable = error.as_ref().and_then(|e| {
738 recoverable.and_then(|r| {
739 if r {
740 Some(format!(
741 "Recoverable error - {}",
742 e.lines().next().unwrap_or("")
743 ))
744 } else {
745 None
746 }
747 })
748 });
749 let failure_fatal = error.as_ref().and_then(|e| {
750 if recoverable.unwrap_or(true) {
751 None
752 } else {
753 Some(format!("Failed - {}", e.lines().next().unwrap_or("")))
754 }
755 });
756 let partial = match (completed.as_ref(), remaining.as_ref()) {
757 (Some(c), Some(r)) => {
758 let c = truncate_one_line(c, PARTIAL_FIELD_MAX_CHARS);
759 let r = truncate_one_line(r, PARTIAL_FIELD_MAX_CHARS);
760 Some(format!("Partial - {c} done, {r}"))
761 }
762 _ => None,
763 };
764 let skipped = reason.as_ref().map(|r| format!("Skipped - {r}"));
765 Self {
766 success_with_output,
767 success_with_files,
768 success_plain,
769 failure_recoverable,
770 failure_fatal,
771 partial,
772 skipped,
773 }
774 }
775
776 pub fn as_string(&self) -> String {
777 self.success_with_output
778 .clone()
779 .or_else(|| self.success_with_files.clone())
780 .unwrap_or_else(|| self.success_plain.clone())
781 }
782}
783
784#[cfg(test)]
785mod proptest_parsers {
786 use super::{
787 extract_partials_impl, extract_variables_impl, parse_loop_header_impl,
788 parse_metadata_line_impl, parse_variable_spec_impl,
789 };
790 use proptest::prelude::*;
791
792 proptest! {
793 #[test]
795 fn parse_variable_spec_impl_never_panics(s in ".*") {
796 let _ = parse_variable_spec_impl(&s);
797 }
798
799 #[test]
802 fn parse_metadata_line_impl_never_panics(s in ".*") {
803 let _ = parse_metadata_line_impl(&s);
804 }
805
806 #[test]
809 fn parse_metadata_line_impl_extracts_version(v in "[A-Za-z0-9._-]{1,20}") {
810 let line = format!("{{# Version: {v} #}}");
811 let result = parse_metadata_line_impl(&line);
812 prop_assert!(result.is_some());
813 let (version, _purpose) = result.unwrap();
814 prop_assert_eq!(version, Some(v));
815 }
816
817 #[test]
819 fn parse_loop_header_impl_never_panics(s in ".*") {
820 let _ = parse_loop_header_impl(&s);
821 }
822
823 #[test]
825 fn extract_variables_impl_never_panics(s in ".*") {
826 let _ = extract_variables_impl(&s);
827 }
828
829 #[test]
831 fn extract_partials_impl_never_panics(s in ".*") {
832 let _ = extract_partials_impl(&s);
833 }
834
835 #[test]
837 fn extract_variables_impl_empty_on_no_braces(s in "[^{]*") {
838 let vars = extract_variables_impl(&s);
839 prop_assert!(vars.is_empty());
840 }
841
842 #[test]
844 fn extract_partials_impl_names_are_nonempty(s in ".*") {
845 let partials = extract_partials_impl(&s);
846 for name in &partials {
847 prop_assert!(!name.is_empty());
848 }
849 }
850 }
851}