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