1use crate::config::workflows::WorkflowsConfig;
25use crate::config::{PhasesConfig, StatesConfig};
26
27#[derive(Debug, Clone)]
29pub struct PromptContext<'a> {
30 pub status: &'a str,
32 pub phase: Option<&'a str>,
34 pub states_config: &'a StatesConfig,
36 pub phases_config: &'a PhasesConfig,
38}
39
40impl<'a> PromptContext<'a> {
41 pub fn new(
43 status: &'a str,
44 phase: Option<&'a str>,
45 states_config: &'a StatesConfig,
46 phases_config: &'a PhasesConfig,
47 ) -> Self {
48 Self {
49 status,
50 phase,
51 states_config,
52 phases_config,
53 }
54 }
55}
56
57pub fn load_prompt(trigger: &str, workflows: &WorkflowsConfig) -> Option<String> {
61 workflows.get_prompt(trigger).map(|s| s.to_string())
62}
63
64pub fn expand_prompt(content: &str, ctx: &PromptContext) -> String {
72 let mut result = content.to_string();
73
74 result = result.replace("{{current_status}}", ctx.status);
76
77 if result.contains("{{valid_exits}}") {
79 let exits = ctx.states_config.get_exits(ctx.status);
80 let exits_md = if exits.is_empty() {
81 "- _(no transitions available - terminal state)_".to_string()
82 } else {
83 exits
84 .iter()
85 .map(|s| format!("- `{}`", s))
86 .collect::<Vec<_>>()
87 .join("\n")
88 };
89 result = result.replace("{{valid_exits}}", &exits_md);
90 }
91
92 if result.contains("{{current_phase}}") {
94 let phase_str = ctx
95 .phase
96 .map(|p| format!("`{}`", p))
97 .unwrap_or_else(|| "_(none)_".to_string());
98 result = result.replace("{{current_phase}}", &phase_str);
99 }
100
101 if result.contains("{{valid_phases}}") {
103 let mut phases: Vec<&str> = ctx.phases_config.phase_names();
104 phases.sort();
105 let phases_str = phases.join(", ");
106 result = result.replace("{{valid_phases}}", &phases_str);
107 }
108
109 result
110}
111
112pub fn get_transition_triggers(
116 old_status: &str,
117 old_phase: Option<&str>,
118 new_status: &str,
119 new_phase: Option<&str>,
120) -> Vec<String> {
121 let mut triggers = Vec::new();
122
123 let status_changed = old_status != new_status;
124 let phase_changed = old_phase != new_phase;
125
126 if (status_changed || phase_changed)
130 && old_phase.is_some()
131 && let Some(op) = old_phase
132 {
133 triggers.push(format!("exit~{}%{}", old_status, op));
134 }
135
136 if phase_changed && let Some(op) = old_phase {
138 triggers.push(format!("exit%{}", op));
139 }
140
141 if status_changed {
143 triggers.push(format!("exit~{}", old_status));
144 }
145
146 if status_changed {
150 triggers.push(format!("enter~{}", new_status));
151 }
152
153 if phase_changed && let Some(np) = new_phase {
155 triggers.push(format!("enter%{}", np));
156 }
157
158 if (status_changed || phase_changed)
160 && new_phase.is_some()
161 && let Some(np) = new_phase
162 {
163 triggers.push(format!("enter~{}%{}", new_status, np));
164 }
165
166 triggers
167}
168
169pub fn get_transition_prompts(
174 old_status: &str,
175 old_phase: Option<&str>,
176 new_status: &str,
177 new_phase: Option<&str>,
178 workflows: &WorkflowsConfig,
179) -> Vec<String> {
180 get_transition_triggers(old_status, old_phase, new_status, new_phase)
181 .iter()
182 .filter_map(|trigger| load_prompt(trigger, workflows))
183 .collect()
184}
185
186pub fn get_transition_prompts_with_context(
190 old_status: &str,
191 old_phase: Option<&str>,
192 new_status: &str,
193 new_phase: Option<&str>,
194 workflows: &WorkflowsConfig,
195 ctx: &PromptContext,
196) -> Vec<String> {
197 get_transition_triggers(old_status, old_phase, new_status, new_phase)
198 .iter()
199 .filter_map(|trigger| load_prompt(trigger, workflows))
200 .map(|content| expand_prompt(&content, ctx))
201 .collect()
202}
203
204pub fn list_available_prompts(workflows: &WorkflowsConfig) -> Vec<String> {
206 workflows.list_prompt_triggers()
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn test_triggers_status_change_only() {
215 let triggers = get_transition_triggers("pending", None, "working", None);
216 assert_eq!(triggers, vec!["exit~pending", "enter~working"]);
217 }
218
219 #[test]
220 fn test_triggers_phase_change_only() {
221 let triggers =
222 get_transition_triggers("working", Some("diagnose"), "working", Some("review"));
223 assert_eq!(
224 triggers,
225 vec![
226 "exit~working%diagnose",
227 "exit%diagnose",
228 "enter%review",
229 "enter~working%review"
230 ]
231 );
232 }
233
234 #[test]
235 fn test_triggers_both_change() {
236 let triggers =
237 get_transition_triggers("working", Some("diagnose"), "finished", Some("review"));
238 assert_eq!(
239 triggers,
240 vec![
241 "exit~working%diagnose",
242 "exit%diagnose",
243 "exit~working",
244 "enter~finished",
245 "enter%review",
246 "enter~finished%review"
247 ]
248 );
249 }
250
251 #[test]
252 fn test_triggers_enter_phase_from_none() {
253 let triggers = get_transition_triggers("working", None, "working", Some("diagnose"));
254 assert_eq!(triggers, vec!["enter%diagnose", "enter~working%diagnose"]);
255 }
256
257 #[test]
258 fn test_triggers_exit_phase_to_none() {
259 let triggers = get_transition_triggers("working", Some("diagnose"), "working", None);
260 assert_eq!(triggers, vec!["exit~working%diagnose", "exit%diagnose"]);
261 }
262
263 #[test]
264 fn test_no_triggers_when_unchanged() {
265 let triggers =
266 get_transition_triggers("working", Some("diagnose"), "working", Some("diagnose"));
267 assert!(triggers.is_empty());
268 }
269
270 #[test]
271 fn test_expand_prompt_valid_exits() {
272 let states_config = StatesConfig::default();
273 let phases_config = PhasesConfig::default();
274 let ctx = PromptContext::new("working", None, &states_config, &phases_config);
275
276 let template = "From {{current_status}} you can go to:\n{{valid_exits}}";
277 let result = expand_prompt(template, &ctx);
278
279 assert!(result.contains("From working you can go to:"));
280 assert!(result.contains("`completed`"));
281 assert!(result.contains("`failed`"));
282 assert!(result.contains("`pending`"));
283 }
284
285 #[test]
286 fn test_expand_prompt_current_phase() {
287 let states_config = StatesConfig::default();
288 let phases_config = PhasesConfig::default();
289
290 let ctx = PromptContext::new("working", Some("implement"), &states_config, &phases_config);
292 let template = "Phase: {{current_phase}}";
293 let result = expand_prompt(template, &ctx);
294 assert_eq!(result, "Phase: `implement`");
295
296 let ctx = PromptContext::new("working", None, &states_config, &phases_config);
298 let result = expand_prompt(template, &ctx);
299 assert_eq!(result, "Phase: _(none)_");
300 }
301
302 #[test]
303 fn test_expand_prompt_valid_phases() {
304 let states_config = StatesConfig::default();
305 let phases_config = PhasesConfig::default();
306 let ctx = PromptContext::new("working", None, &states_config, &phases_config);
307
308 let template = "Phases: {{valid_phases}}";
309 let result = expand_prompt(template, &ctx);
310
311 assert!(result.contains("implement"));
313 assert!(result.contains("test"));
314 assert!(result.contains("review"));
315 }
316
317 #[test]
318 fn test_expand_prompt_terminal_state() {
319 let states_config = StatesConfig::default();
320 let phases_config = PhasesConfig::default();
321 let ctx = PromptContext::new("cancelled", None, &states_config, &phases_config);
322
323 let template = "Exits: {{valid_exits}}";
324 let result = expand_prompt(template, &ctx);
325
326 assert!(result.contains("no transitions available"));
328 }
329
330 #[test]
331 fn test_load_prompt_from_workflows() {
332 let workflows = WorkflowsConfig::default();
333
334 let prompt = load_prompt("enter~working", &workflows);
336 assert!(prompt.is_some());
337 assert!(prompt.unwrap().contains("actively working"));
338
339 let prompt = load_prompt("enter%implement", &workflows);
341 assert!(prompt.is_some());
342 assert!(prompt.unwrap().contains("Implementation"));
343 }
344
345 #[test]
346 fn test_get_transition_prompts() {
347 let workflows = WorkflowsConfig::default();
348
349 let prompts = get_transition_prompts("pending", None, "working", None, &workflows);
350
351 assert!(!prompts.is_empty());
353 assert!(prompts.iter().any(|p| p.contains("actively working")));
354 }
355
356 #[test]
357 fn test_list_available_prompts() {
358 let workflows = WorkflowsConfig::default();
359 let prompts = list_available_prompts(&workflows);
360
361 assert!(prompts.contains(&"enter~working".to_string()));
362 assert!(prompts.contains(&"exit~working".to_string()));
363 assert!(prompts.contains(&"enter%implement".to_string()));
364 }
365}