1use std::collections::HashMap;
8use std::fmt;
9
10pub use crate::prompts::io::extract_metadata;
11pub use crate::prompts::io::extract_partials;
12pub use crate::prompts::io::extract_variables;
13pub use crate::prompts::io::validate_syntax;
14pub use crate::prompts::template_registry::TemplateError;
15pub use crate::prompts::template_validator::{
16 RenderedTemplate, SubstitutionEntry, SubstitutionLog, SubstitutionSource,
17};
18
19pub struct Template {
21 pub content: String,
22}
23
24impl Template {
25 #[must_use]
26 pub fn new(content: &str) -> Self {
27 Self {
28 content: strip_comments(content),
29 }
30 }
31}
32
33impl fmt::Debug for Template {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 f.debug_struct("Template")
36 .field(
37 "content",
38 &self.content.chars().take(50).collect::<String>(),
39 )
40 .finish()
41 }
42}
43
44fn strip_comments(content: &str) -> String {
49 crate::prompts::template_parsing::strip_comments_impl(content)
50}
51
52struct LiteralSegment {
53 token: String,
54 content: String,
55}
56
57struct PartialExpandState<'a> {
58 result: &'a mut String,
59 literal_segments: &'a mut Vec<LiteralSegment>,
60 log: &'a mut SubstitutionLog,
61}
62
63struct LoopRenderLog {
64 token: String,
65 substituted: Vec<SubstitutionEntry>,
66 unsubstituted: Vec<String>,
67}
68
69fn parse_loop_header(full_match: &str) -> Option<(&str, &str)> {
70 crate::prompts::template_parsing::parse_loop_header_impl(full_match)
71}
72
73fn split_loop_items(values: &str) -> Vec<&str> {
74 crate::prompts::template_parsing::split_loop_items_impl(values)
75}
76
77fn render_loop_item(
78 body: &str,
79 item: &str,
80 var_name: &str,
81 variables: &HashMap<&str, String>,
82) -> String {
83 let after_vars = variables
85 .iter()
86 .fold(body.to_string(), |content, (key, val)| {
87 content.replace(&format!("{{{{{}}}}}", key), val)
88 });
89 after_vars.replace(&format!("{{{}}}", var_name), item)
90}
91
92fn find_unsubstituted_vars(item_content: &str, variables: &HashMap<&str, String>) -> Vec<String> {
93 extract_variables(item_content)
94 .iter()
95 .filter(|v| {
96 !variables.contains_key(v.name.as_str())
97 && !item_content.contains(&format!("{{{}}}", v.name))
98 })
99 .map(|v| v.name.clone())
100 .collect()
101}
102
103fn eval_conditional(condition: &str, variables: &HashMap<&str, String>) -> bool {
104 crate::prompts::template_parsing::eval_conditional_impl(condition, variables)
105}
106
107struct LoopMatchResult {
108 full_match: String,
109 var_name: String,
110 body: String,
111}
112
113fn try_parse_loop_for_tag(result: &str, for_start: usize) -> Option<LoopMatchResult> {
114 let for_end = result[for_start..].find("%}")?;
115 let for_end = for_start + for_end;
116 let full_match = result[for_start..for_end + 2].to_string();
117 let full_match_clone = full_match.clone();
118 let (header, body) = parse_loop_header(&full_match_clone)?;
119 let var_name = header.split_whitespace().next()?.to_string();
120 Some(LoopMatchResult {
121 full_match,
122 var_name,
123 body: body.to_string(),
124 })
125}
126
127fn render_loop_items(
128 body: &str,
129 var_name: &str,
130 values: &str,
131 variables: &HashMap<&str, String>,
132) -> (Vec<String>, Vec<String>) {
133 let items = split_loop_items(values);
134 let (rendered_items, unsubstituted_blocks): (Vec<String>, Vec<Vec<String>>) = items
135 .iter()
136 .map(|item| {
137 let item_content = render_loop_item(body, item, var_name, variables);
138 let unsubstituted = find_unsubstituted_vars(&item_content, variables);
139 (item_content, unsubstituted)
140 })
141 .unzip();
142 let unsubstituted = unsubstituted_blocks.into_iter().flatten().collect();
143 (rendered_items, unsubstituted)
144}
145
146fn find_conditional_block(result: &str, if_start: usize) -> Option<(usize, usize, String)> {
147 let tag_end_offset = result[if_start..].find("%}")?;
148 let tag_close = if_start + tag_end_offset + 2;
149 let rest_from_if = &result[if_start..];
150 let endif_offset = rest_from_if.find("{% endif %}")?;
151 let endif_abs = if_start + endif_offset;
152 let full_end = endif_abs + 11;
153 let full_match = result[if_start..full_end].to_string();
154 Some((tag_close, endif_abs, full_match))
155}
156
157fn eval_conditional_body(
158 condition: &str,
159 body: &str,
160 else_body: Option<&str>,
161 variables: &HashMap<&str, String>,
162) -> String {
163 if eval_conditional(condition, variables) {
164 body.trim().to_string()
165 } else {
166 else_body.map(str::trim).unwrap_or("").to_string()
167 }
168}
169
170fn eval_conditional_replacement(
171 condition: &str,
172 body_and_maybe_else: &str,
173 variables: &HashMap<&str, String>,
174) -> String {
175 if let Some(else_offset) = body_and_maybe_else.find("{% else %}") {
176 eval_conditional_body(
177 condition,
178 &body_and_maybe_else[..else_offset],
179 Some(&body_and_maybe_else[else_offset + 10..]),
180 variables,
181 )
182 } else {
183 eval_conditional_body(condition, body_and_maybe_else, None, variables)
184 }
185}
186
187fn process_one_conditional(result: &str, variables: &HashMap<&str, String>) -> Option<String> {
188 let if_start = result.find("{% if ")?;
189 let tag = {
190 let tag_end_offset = result[if_start..].find("%}")?;
191 let tag_close = if_start + tag_end_offset + 2;
192 result[if_start..tag_close].to_string()
193 };
194 let cond_end = tag.find("%}")?;
195 let condition = tag[6..cond_end].trim().to_string();
196 let (tag_close, endif_abs, full_match) = find_conditional_block(result, if_start)?;
197 let body_and_maybe_else = result[tag_close..endif_abs].to_string();
198 let replacement = eval_conditional_replacement(&condition, &body_and_maybe_else, variables);
199 Some(result.replacen(&full_match, &replacement, 1))
200}
201
202struct VarSubResult {
203 new_result: String,
204 entry: Option<SubstitutionEntry>,
205 unsubstituted: Option<String>,
206}
207
208fn determine_substitution_source(value: &str, has_default: bool) -> SubstitutionSource {
209 if value.is_empty() {
210 SubstitutionSource::EmptyWithDefault
211 } else if has_default {
212 SubstitutionSource::Default
213 } else {
214 SubstitutionSource::Value
215 }
216}
217
218fn sub_result_with_value(
219 result: &str,
220 token: &str,
221 name: &str,
222 value: &str,
223 source: SubstitutionSource,
224) -> VarSubResult {
225 VarSubResult {
226 new_result: result.replace(token, value),
227 entry: Some(SubstitutionEntry {
228 name: name.to_string(),
229 source,
230 }),
231 unsubstituted: None,
232 }
233}
234
235fn sub_result_with_default(
236 result: &str,
237 token: &str,
238 name: &str,
239 default_val: String,
240) -> VarSubResult {
241 VarSubResult {
242 new_result: result.replace(token, &default_val),
243 entry: Some(SubstitutionEntry {
244 name: name.to_string(),
245 source: SubstitutionSource::Default,
246 }),
247 unsubstituted: None,
248 }
249}
250
251fn sub_result_unresolved(result: &str, name: &str) -> VarSubResult {
252 VarSubResult {
253 new_result: result.to_string(),
254 entry: None,
255 unsubstituted: Some(name.to_string()),
256 }
257}
258
259fn sub_result_unresolved_none(result: &str) -> VarSubResult {
260 VarSubResult {
261 new_result: result.to_string(),
262 entry: None,
263 unsubstituted: None,
264 }
265}
266
267fn resolve_variable_substitution(
268 result: &str,
269 var: &crate::prompts::template_validator::VariableInfo,
270 variables: &HashMap<&str, String>,
271 token: &str,
272) -> VarSubResult {
273 if let Some(value) = variables.get(var.name.as_str()) {
274 let source = determine_substitution_source(value, var.has_default);
275 sub_result_with_value(result, token, &var.name, value, source)
276 } else if var.has_default {
277 let default_val = var.default_value.clone().unwrap_or_default();
278 sub_result_with_default(result, token, &var.name, default_val)
279 } else {
280 sub_result_unresolved(result, &var.name)
281 }
282}
283
284fn find_variable_token(result: &str, placeholder: &str, name: &str) -> Option<String> {
285 let raw_token = format!("{{{{{}}}}}", placeholder);
286 let clean_token = format!("{{{{{}}}}}", name);
287 if result.contains(&raw_token) {
288 Some(raw_token)
289 } else if result.contains(&clean_token) {
290 Some(clean_token)
291 } else {
292 None
293 }
294}
295
296fn substitute_one_variable(
297 result: &str,
298 var: &crate::prompts::template_validator::VariableInfo,
299 variables: &HashMap<&str, String>,
300) -> VarSubResult {
301 find_variable_token(result, &var.placeholder, &var.name).map_or_else(
302 || sub_result_unresolved_none(result),
303 |token| resolve_variable_substitution(result, var, variables, &token),
304 )
305}
306
307fn build_circular_reference_chain(visited: &[String]) -> String {
308 visited
309 .iter()
310 .rev()
311 .cloned()
312 .collect::<Vec<_>>()
313 .join(" -> ")
314}
315
316fn collect_missing_from_loop_logs(
317 loop_logs: &[LoopRenderLog],
318 result: &str,
319 unsubstituted: Vec<String>,
320) -> Vec<String> {
321 loop_logs
322 .iter()
323 .filter(|ll| result.contains(&ll.token))
324 .flat_map(|ll| ll.unsubstituted.clone())
325 .chain(unsubstituted)
326 .collect::<std::collections::HashSet<_>>()
327 .into_iter()
328 .collect()
329}
330
331fn extend_log_with_loop_logs(
332 log: &mut SubstitutionLog,
333 loop_logs: Vec<LoopRenderLog>,
334 result: &str,
335) {
336 for loop_log in loop_logs {
337 if !result.contains(&loop_log.token) {
338 continue;
339 }
340 log.substituted.extend(loop_log.substituted);
341 let new_unsub: Vec<String> = loop_log
342 .unsubstituted
343 .into_iter()
344 .filter(|name| !log.unsubstituted.contains(name))
345 .collect();
346 log.unsubstituted.extend(new_unsub);
347 }
348}
349
350fn extend_log_dedup(
351 log: &mut SubstitutionLog,
352 substituted: Vec<SubstitutionEntry>,
353 unsubstituted: Vec<String>,
354) {
355 log.substituted.extend(substituted);
356 let new_unsub: Vec<String> = unsubstituted
357 .into_iter()
358 .filter(|name| !log.unsubstituted.contains(name))
359 .collect();
360 log.unsubstituted.extend(new_unsub);
361}
362
363impl Template {
364 pub fn render(&self, variables: &HashMap<&str, String>) -> Result<String, TemplateError> {
366 let mut literal_segments = Vec::new();
367 let (result, loop_logs) =
368 Self::process_loops_with_log(&self.content, variables, &mut literal_segments);
369
370 let result = Self::process_conditionals(&result, variables);
371
372 let (result_after_sub, _substituted, unsubstituted) =
373 Self::substitute_variables_allow_empty(&result, variables);
374
375 let missing: Vec<String> = loop_logs
376 .iter()
377 .filter(|loop_log| result.contains(&loop_log.token))
378 .flat_map(|loop_log| loop_log.unsubstituted.clone())
379 .chain(unsubstituted)
380 .collect::<std::collections::HashSet<_>>()
381 .into_iter()
382 .collect();
383
384 if let Some(first_missing) = missing.first() {
385 return Err(TemplateError::MissingVariable(first_missing.clone()));
386 }
387
388 Ok(Self::restore_literal_segments(
389 &result_after_sub,
390 &literal_segments,
391 ))
392 }
393
394 pub fn render_with_partials(
396 &self,
397 variables: &HashMap<&str, String>,
398 partials: &HashMap<String, String>,
399 ) -> Result<String, TemplateError> {
400 self.render_with_partials_recursive(variables, partials, &mut Vec::new())
401 }
402
403 pub fn render_with_log(
405 &self,
406 template_name: &str,
407 variables: &HashMap<&str, String>,
408 partials: &HashMap<String, String>,
409 ) -> Result<RenderedTemplate, TemplateError> {
410 self.render_with_log_recursive(template_name, variables, partials, &mut Vec::new())
411 }
412
413 fn expand_one_partial(
414 &self,
415 partial_name: &str,
416 result: &mut String,
417 literal_segments: &mut Vec<LiteralSegment>,
418 variables: &HashMap<&str, String>,
419 partials: &HashMap<String, String>,
420 visited: &mut Vec<String>,
421 ) -> Result<(), TemplateError> {
422 if visited.contains(&partial_name.to_string()) {
423 return Err(TemplateError::CircularReference(
424 build_circular_reference_chain(visited),
425 ));
426 }
427 let partial_content = partials
428 .get(partial_name)
429 .ok_or_else(|| TemplateError::PartialNotFound(partial_name.to_string()))?;
430 let partial_template = Self::new(partial_content);
431 visited.push(partial_name.to_string());
432 let rendered =
433 partial_template.render_with_partials_recursive(variables, partials, visited)?;
434 visited.pop();
435 let full_match = format!("{{{{> {}}}}}", partial_name);
436 let token = Self::next_literal_token(result, &rendered, literal_segments);
437 literal_segments.push(LiteralSegment {
438 token: token.clone(),
439 content: rendered,
440 });
441 *result = result.replace(&full_match, &token);
442 Ok(())
443 }
444
445 fn process_rendered_content(
446 result: &str,
447 variables: &HashMap<&str, String>,
448 literal_segments: &mut Vec<LiteralSegment>,
449 ) -> Result<String, TemplateError> {
450 let (loop_result, loop_logs) =
451 Self::process_loops_with_log(result, variables, literal_segments);
452 let after_cond = Self::process_conditionals(&loop_result, variables);
453 let (result_after_sub, _substituted, unsubstituted) =
454 Self::substitute_variables_allow_empty(&after_cond, variables);
455 let missing = collect_missing_from_loop_logs(&loop_logs, &after_cond, unsubstituted);
456 if let Some(first_missing) = missing.first() {
457 return Err(TemplateError::MissingVariable(first_missing.clone()));
458 }
459 Ok(Self::restore_literal_segments(
460 &result_after_sub,
461 literal_segments,
462 ))
463 }
464
465 fn render_with_partials_recursive(
466 &self,
467 variables: &HashMap<&str, String>,
468 partials: &HashMap<String, String>,
469 visited: &mut Vec<String>,
470 ) -> Result<String, TemplateError> {
471 let mut literal_segments = Vec::new();
472 let mut result = self.content.clone();
473 for partial_name in extract_partials(&result).into_iter().rev() {
474 self.expand_one_partial(
475 &partial_name,
476 &mut result,
477 &mut literal_segments,
478 variables,
479 partials,
480 visited,
481 )?;
482 }
483 Self::process_rendered_content(&result, variables, &mut literal_segments)
484 }
485
486 fn expand_one_partial_with_log(
487 &self,
488 partial_name: &str,
489 template_name: &str,
490 state: &mut PartialExpandState<'_>,
491 variables: &HashMap<&str, String>,
492 partials: &HashMap<String, String>,
493 visited: &mut Vec<String>,
494 ) -> Result<(), TemplateError> {
495 if visited.contains(&partial_name.to_string()) {
496 return Err(TemplateError::CircularReference(
497 build_circular_reference_chain(visited),
498 ));
499 }
500 let partial_content = partials
501 .get(partial_name)
502 .ok_or_else(|| TemplateError::PartialNotFound(partial_name.to_string()))?;
503 let partial_template = Self::new(partial_content);
504 visited.push(partial_name.to_string());
505 let rendered = partial_template.render_with_log_recursive(
506 template_name,
507 variables,
508 partials,
509 visited,
510 )?;
511 visited.pop();
512 let full_match = format!("{{{{> {}}}}}", partial_name);
513 let token =
514 Self::next_literal_token(state.result, &rendered.content, state.literal_segments);
515 state.literal_segments.push(LiteralSegment {
516 token: token.clone(),
517 content: rendered.content,
518 });
519 *state.result = state.result.replace(&full_match, &token);
520 extend_log_dedup(
521 state.log,
522 rendered.log.substituted,
523 rendered.log.unsubstituted,
524 );
525 Ok(())
526 }
527
528 fn render_with_log_recursive(
529 &self,
530 template_name: &str,
531 variables: &HashMap<&str, String>,
532 partials: &HashMap<String, String>,
533 visited: &mut Vec<String>,
534 ) -> Result<RenderedTemplate, TemplateError> {
535 let mut log = SubstitutionLog {
536 template_name: template_name.to_string(),
537 substituted: Vec::new(),
538 unsubstituted: Vec::new(),
539 };
540 let mut literal_segments = Vec::new();
541 let mut result = self.content.clone();
542 for partial_name in extract_partials(&result).into_iter().rev() {
543 let mut state = PartialExpandState {
544 result: &mut result,
545 literal_segments: &mut literal_segments,
546 log: &mut log,
547 };
548 self.expand_one_partial_with_log(
549 &partial_name,
550 template_name,
551 &mut state,
552 variables,
553 partials,
554 visited,
555 )?;
556 }
557 let (loop_result, loop_logs) =
558 Self::process_loops_with_log(&result, variables, &mut literal_segments);
559 let result = Self::process_conditionals(&loop_result, variables);
560 extend_log_with_loop_logs(&mut log, loop_logs, &result);
561 let (result_after_sub, substituted, unsubstituted) =
562 Self::substitute_variables(&result, variables);
563 extend_log_dedup(&mut log, substituted, unsubstituted);
564 Ok(RenderedTemplate {
565 content: Self::restore_literal_segments(&result_after_sub, &literal_segments),
566 log,
567 })
568 }
569
570 fn process_one_loop(
571 result: &str,
572 variables: &HashMap<&str, String>,
573 literal_segments: &mut Vec<LiteralSegment>,
574 token_counter: &mut usize,
575 ) -> Option<(String, LoopRenderLog)> {
576 let for_start = result.find("{% for ")?;
577 let parsed = try_parse_loop_for_tag(result, for_start)?;
578 let values = variables.get(parsed.var_name.as_str())?;
579 let (rendered_items, unsubstituted) =
580 render_loop_items(&parsed.body, &parsed.var_name, values, variables);
581 let loop_token = format!("__LOOP_TOKEN_{}__", token_counter);
582 *token_counter += 1;
583 literal_segments.push(LiteralSegment {
584 token: loop_token.clone(),
585 content: rendered_items.join("\n"),
586 });
587 let log = LoopRenderLog {
588 token: loop_token.clone(),
589 substituted: vec![SubstitutionEntry {
590 name: parsed.var_name.clone(),
591 source: crate::prompts::template_validator::SubstitutionSource::Value,
592 }],
593 unsubstituted,
594 };
595 Some((result.replace(&parsed.full_match, &loop_token), log))
596 }
597
598 fn process_loops_with_log(
599 content: &str,
600 variables: &HashMap<&str, String>,
601 literal_segments: &mut Vec<LiteralSegment>,
602 ) -> (String, Vec<LoopRenderLog>) {
603 let mut result = content.to_string();
604 let mut loop_logs = Vec::new();
605 let mut token_counter = 0;
606 while let Some((new_result, log)) =
607 Self::process_one_loop(&result, variables, literal_segments, &mut token_counter)
608 {
609 result = new_result;
610 loop_logs.push(log);
611 }
612 (result, loop_logs)
613 }
614
615 fn process_conditionals(content: &str, variables: &HashMap<&str, String>) -> String {
616 let mut result = content.to_string();
617 while let Some(new_result) = process_one_conditional(&result, variables) {
618 result = new_result;
619 }
620 result
621 }
622
623 fn substitute_variables_allow_empty(
624 content: &str,
625 variables: &HashMap<&str, String>,
626 ) -> (String, Vec<SubstitutionEntry>, Vec<String>) {
627 let vars = extract_variables(content);
628 vars.iter().fold(
629 (content.to_string(), Vec::new(), Vec::new()),
630 |(result, mut substituted, mut unsubstituted), var| {
631 let sub = substitute_one_variable(&result, var, variables);
632 if let Some(entry) = sub.entry {
633 substituted.push(entry);
634 }
635 if let Some(name) = sub.unsubstituted {
636 unsubstituted.push(name);
637 }
638 (sub.new_result, substituted, unsubstituted)
639 },
640 )
641 }
642
643 fn substitute_variables(
644 content: &str,
645 variables: &HashMap<&str, String>,
646 ) -> (String, Vec<SubstitutionEntry>, Vec<String>) {
647 Self::substitute_variables_allow_empty(content, variables)
648 }
649
650 fn next_literal_token(
651 content: &str,
652 replacement: &str,
653 literal_segments: &[LiteralSegment],
654 ) -> String {
655 let mut token = format!("__LITERAL_{}__", literal_segments.len());
656 while content.contains(&token) || literal_segments.iter().any(|s| s.token == token) {
657 token = format!(
658 "__LITERAL_{}__{}",
659 literal_segments.len(),
660 replacement.len()
661 );
662 }
663 token
664 }
665
666 fn restore_literal_segments(content: &str, literal_segments: &[LiteralSegment]) -> String {
667 let mut result = content.to_string();
668 for segment in literal_segments {
669 result = result.replace(&segment.token, &segment.content);
670 }
671 result
672 }
673}
674
675use crate::checkpoint::execution_history::StepOutcome;
680use crate::checkpoint::restore::ResumeContext;
681use crate::checkpoint::state::PipelinePhase;
682use std::fmt::Write as FmtWrite;
683
684fn format_resume_state(resume_count: u32, rebase_state: &str) -> String {
685 crate::prompts::template_parsing::format_resume_state_impl(resume_count, rebase_state)
686}
687
688fn format_modified_files_summary(
689 detail: &crate::checkpoint::execution_history::ModifiedFilesDetail,
690) -> String {
691 crate::prompts::template_parsing::format_files_summary_impl(detail).unwrap_or_default()
692}
693
694fn format_issues_summary(issues: &crate::checkpoint::execution_history::IssuesSummary) -> String {
695 crate::prompts::template_parsing::format_issues_summary_impl(issues).unwrap_or_default()
696}
697
698fn optional_files_summary(
699 detail: &Option<crate::checkpoint::execution_history::ModifiedFilesDetail>,
700) -> String {
701 detail
702 .as_ref()
703 .map_or(String::new(), format_modified_files_summary)
704}
705
706fn optional_issues_summary(
707 issues: &Option<crate::checkpoint::execution_history::IssuesSummary>,
708) -> String {
709 issues.as_ref().map_or(String::new(), format_issues_summary)
710}
711
712fn optional_commit_line(oid: &Option<String>) -> String {
713 oid.as_deref()
714 .map_or(String::new(), |o| format!(" Commit: {o}\n"))
715}
716
717fn format_recent_step(step: &crate::checkpoint::execution_history::ExecutionStep) -> String {
718 format!(
719 "- [{}] {} (iteration {}): {}\n{}{}{}",
720 step.step_type,
721 step.phase,
722 step.iteration,
723 step.outcome.brief_description(),
724 optional_files_summary(&step.modified_files_detail),
725 optional_issues_summary(&step.issues_summary),
726 optional_commit_line(&step.git_commit_oid),
727 )
728}
729
730fn format_recent_activity(
731 history: &crate::checkpoint::execution_history::ExecutionHistory,
732) -> String {
733 let recent_steps: Vec<_> = history
734 .steps
735 .iter()
736 .rev()
737 .take(5)
738 .collect::<Vec<_>>()
739 .into_iter()
740 .rev()
741 .collect();
742
743 let steps_str: String = recent_steps.iter().map(|s| format_recent_step(s)).collect();
744 format!("RECENT ACTIVITY:\n----------------\n{steps_str}\n")
745}
746
747fn append_resume_and_rebase_state(note: &mut String, context: &ResumeContext) {
748 let rebase_str = format!("{:?}", context.rebase_state);
749 let formatted = format_resume_state(context.resume_count, &rebase_str);
750 note.push_str(&formatted);
751 note.push('\n');
752}
753
754fn append_recent_activity(note: &mut String, context: &ResumeContext) {
755 let Some(ref history) = context.execution_history else {
756 return;
757 };
758 if history.steps.is_empty() {
759 return;
760 }
761 note.push_str(&format_recent_activity(history));
762 note.push('\n');
763}
764
765fn append_guidance(note: &mut String, phase: PipelinePhase) {
766 note.push_str("\nGUIDANCE:\n");
767 note.push_str("--------\n");
768 match phase {
769 PipelinePhase::Development => {
770 note.push_str("Continue working on the implementation tasks from your plan.\n");
771 }
772 PipelinePhase::Review => {
773 note.push_str("Review the code changes and provide feedback.\n");
774 }
775 _ => {}
776 }
777 note.push('\n');
778}
779
780fn append_phase_header(note: &mut String, context: &ResumeContext) {
781 match context.phase {
782 PipelinePhase::Development => {
783 let _ = writeln!(
784 note,
785 "Resuming DEVELOPMENT phase (iteration {} of {})",
786 context.iteration + 1,
787 context.total_iterations
788 );
789 }
790 PipelinePhase::Review => {
791 let _ = writeln!(
792 note,
793 "Resuming REVIEW phase (pass {} of {})",
794 context.reviewer_pass + 1,
795 context.total_reviewer_passes
796 );
797 }
798 _ => {
799 let _ = writeln!(note, "Resuming from phase: {}", context.phase_name());
800 }
801 }
802}
803
804#[must_use]
815pub fn generate_resume_note(context: &ResumeContext) -> String {
816 let mut note = String::from("SESSION RESUME CONTEXT\n");
817 note.push_str("====================\n\n");
818 append_phase_header(&mut note, context);
819 append_resume_and_rebase_state(&mut note, context);
820 append_recent_activity(&mut note, context);
821 note.push_str("Previous progress is preserved in git history.\n");
822 append_guidance(&mut note, context.phase);
823 note
824}
825
826fn brief_description_success(
827 files_modified: &Option<Box<[String]>>,
828 output: &Option<Box<str>>,
829) -> String {
830 use crate::prompts::template_parsing::OutcomeDescription;
831 let files: Option<Vec<String>> = files_modified.as_ref().map(|b| b.to_vec());
832 let output_str: Option<String> = output.as_ref().map(|b| b.to_string());
833 OutcomeDescription::from_outcome(&files, &output_str, &None, &None, &None, &None, &None)
834 .as_string()
835}
836
837fn brief_description_failure(error: &str, recoverable: bool) -> String {
838 use crate::prompts::template_parsing::OutcomeDescription;
839 let error_str: Option<String> = Some(error.to_string());
840 let recoverable_val = Some(recoverable);
841 let desc = OutcomeDescription::from_outcome(
842 &None,
843 &None,
844 &error_str,
845 &recoverable_val,
846 &None,
847 &None,
848 &None,
849 );
850 desc.failure_recoverable
851 .or(desc.failure_fatal)
852 .unwrap_or_default()
853}
854
855fn brief_description_partial(completed: &str, remaining: &str) -> String {
856 use crate::prompts::template_parsing::OutcomeDescription;
857 let completed_str: Option<String> = Some(completed.to_string());
858 let remaining_str: Option<String> = Some(remaining.to_string());
859 OutcomeDescription::from_outcome(
860 &None,
861 &None,
862 &None,
863 &None,
864 &completed_str,
865 &remaining_str,
866 &None,
867 )
868 .partial
869 .unwrap_or_default()
870}
871
872fn brief_description_skipped(reason: &str) -> String {
873 use crate::prompts::template_parsing::OutcomeDescription;
874 let reason_str: Option<String> = Some(reason.to_string());
875 OutcomeDescription::from_outcome(&None, &None, &None, &None, &None, &None, &reason_str)
876 .skipped
877 .unwrap_or_default()
878}
879
880pub trait BriefDescription {
882 fn brief_description(&self) -> String;
883}
884
885impl BriefDescription for StepOutcome {
886 fn brief_description(&self) -> String {
887 match self {
888 Self::Success {
889 files_modified,
890 output,
891 ..
892 } => brief_description_success(files_modified, output),
893 Self::Failure {
894 error, recoverable, ..
895 } => brief_description_failure(error, *recoverable),
896 Self::Partial {
897 completed,
898 remaining,
899 ..
900 } => brief_description_partial(completed, remaining),
901 Self::Skipped { reason } => brief_description_skipped(reason),
902 }
903 }
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909
910 #[test]
911 fn test_render_loop_item_substitutes_variables_and_item() {
912 let body = "Name: {item}, Age: {{age}}, City: {{city}}";
913 let item = "Alice";
914 let var_name = "item";
915 let mut variables = HashMap::new();
916 variables.insert("age", "30".to_string());
917 variables.insert("city", "NYC".to_string());
918
919 let result = render_loop_item(body, item, var_name, &variables);
920
921 assert_eq!(result, "Name: Alice, Age: 30, City: NYC");
922 }
923
924 #[test]
925 fn test_render_loop_item_no_variables() {
926 let body = "Item: {item}";
927 let item = "value";
928 let var_name = "item";
929 let variables = HashMap::new();
930
931 let result = render_loop_item(body, item, var_name, &variables);
932
933 assert_eq!(result, "Item: value");
934 }
935
936 #[test]
937 fn test_render_loop_item_no_item_placeholder() {
938 let body = "Age: {{age}}";
939 let item = "unused";
940 let var_name = "item";
941 let mut variables = HashMap::new();
942 variables.insert("age", "25".to_string());
943
944 let result = render_loop_item(body, item, var_name, &variables);
945
946 assert_eq!(result, "Age: 25");
947 }
948
949 #[test]
950 fn comment_only_line_is_removed() {
951 let t = Template::new("{# this is a comment #}\n");
952 assert_eq!(t.content, "");
953 }
954
955 #[test]
956 fn comment_at_start_stripped() {
957 let t = Template::new("{# header #}\nActual content");
958 assert_eq!(t.content, "Actual content");
959 }
960
961 #[test]
962 fn comment_inline_stripped() {
963 let t = Template::new("before {# mid #} after");
964 assert_eq!(t.content, "before after");
965 }
966
967 #[test]
968 fn multi_line_comment_stripped() {
969 let t = Template::new("{# line1\nline2 #}\ncontent");
970 assert_eq!(t.content, "content");
971 }
972
973 #[test]
974 fn adjacent_comments_stripped() {
975 let t = Template::new("{# a #}\n{# b #}\ncontent");
976 assert_eq!(t.content, "content");
977 }
978
979 #[test]
980 fn no_comments_unchanged() {
981 let t = Template::new("hello {{ NAME }}");
982 assert_eq!(t.content, "hello {{ NAME }}");
983 }
984
985 #[test]
986 fn unclosed_comment_passed_through() {
987 let t = Template::new("{# unclosed");
988 assert_eq!(t.content, "{# unclosed");
989 }
990}