Skip to main content

ralph_workflow/prompts/
runtime.rs

1//! Runtime boundary module for prompts.
2//!
3//! This module contains imperative code (template parsing, rendering) that cannot
4//! be easily converted to functional style. It satisfies the dylint boundary-module
5//! check.
6
7use 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
19/// Template for rendering prompts with variable substitution.
20pub 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
44// =========================================================================
45// Template rendering runtime - imperative rendering logic.
46// =========================================================================
47
48fn 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    // First apply all variable substitutions via fold, then apply item substitution
84    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    /// Render the template with the provided variables.
365    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    /// Render the template with variables and partials support.
395    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    /// Render the template with variables and partials, returning substitution log.
404    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
675// =========================================================================
676// Resume context note generation - boundary module.
677// =========================================================================
678
679use 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/// Generate a rich resume note from resume context.
805///
806/// Creates a detailed, context-aware note that helps agents understand
807/// where they are in the pipeline when resuming from a checkpoint.
808///
809/// The note includes:
810/// - Phase and iteration information
811/// - Recent execution history (files modified, issues found/fixed)
812/// - Git commits made during the session
813/// - Guidance on what to focus on
814#[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
880/// Helper trait for brief outcome descriptions.
881pub 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}