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) && old_phase.is_some() {
130 if let Some(op) = old_phase {
131 triggers.push(format!("exit~{}%{}", old_status, op));
132 }
133 }
134
135 if phase_changed {
137 if let Some(op) = old_phase {
138 triggers.push(format!("exit%{}", op));
139 }
140 }
141
142 if status_changed {
144 triggers.push(format!("exit~{}", old_status));
145 }
146
147 if status_changed {
151 triggers.push(format!("enter~{}", new_status));
152 }
153
154 if phase_changed {
156 if let Some(np) = new_phase {
157 triggers.push(format!("enter%{}", np));
158 }
159 }
160
161 if (status_changed || phase_changed) && new_phase.is_some() {
163 if let Some(np) = new_phase {
164 triggers.push(format!("enter~{}%{}", new_status, np));
165 }
166 }
167
168 triggers
169}
170
171pub fn get_transition_prompts(
176 old_status: &str,
177 old_phase: Option<&str>,
178 new_status: &str,
179 new_phase: Option<&str>,
180 workflows: &WorkflowsConfig,
181) -> Vec<String> {
182 get_transition_triggers(old_status, old_phase, new_status, new_phase)
183 .iter()
184 .filter_map(|trigger| load_prompt(trigger, workflows))
185 .collect()
186}
187
188pub fn get_transition_prompts_with_context(
192 old_status: &str,
193 old_phase: Option<&str>,
194 new_status: &str,
195 new_phase: Option<&str>,
196 workflows: &WorkflowsConfig,
197 ctx: &PromptContext,
198) -> Vec<String> {
199 get_transition_triggers(old_status, old_phase, new_status, new_phase)
200 .iter()
201 .filter_map(|trigger| load_prompt(trigger, workflows))
202 .map(|content| expand_prompt(&content, ctx))
203 .collect()
204}
205
206pub fn list_available_prompts(workflows: &WorkflowsConfig) -> Vec<String> {
208 workflows.list_prompt_triggers()
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn test_triggers_status_change_only() {
217 let triggers = get_transition_triggers("pending", None, "working", None);
218 assert_eq!(triggers, vec!["exit~pending", "enter~working"]);
219 }
220
221 #[test]
222 fn test_triggers_phase_change_only() {
223 let triggers =
224 get_transition_triggers("working", Some("diagnose"), "working", Some("review"));
225 assert_eq!(
226 triggers,
227 vec![
228 "exit~working%diagnose",
229 "exit%diagnose",
230 "enter%review",
231 "enter~working%review"
232 ]
233 );
234 }
235
236 #[test]
237 fn test_triggers_both_change() {
238 let triggers =
239 get_transition_triggers("working", Some("diagnose"), "finished", Some("review"));
240 assert_eq!(
241 triggers,
242 vec![
243 "exit~working%diagnose",
244 "exit%diagnose",
245 "exit~working",
246 "enter~finished",
247 "enter%review",
248 "enter~finished%review"
249 ]
250 );
251 }
252
253 #[test]
254 fn test_triggers_enter_phase_from_none() {
255 let triggers = get_transition_triggers("working", None, "working", Some("diagnose"));
256 assert_eq!(triggers, vec!["enter%diagnose", "enter~working%diagnose"]);
257 }
258
259 #[test]
260 fn test_triggers_exit_phase_to_none() {
261 let triggers = get_transition_triggers("working", Some("diagnose"), "working", None);
262 assert_eq!(triggers, vec!["exit~working%diagnose", "exit%diagnose"]);
263 }
264
265 #[test]
266 fn test_no_triggers_when_unchanged() {
267 let triggers =
268 get_transition_triggers("working", Some("diagnose"), "working", Some("diagnose"));
269 assert!(triggers.is_empty());
270 }
271
272 #[test]
273 fn test_expand_prompt_valid_exits() {
274 let states_config = StatesConfig::default();
275 let phases_config = PhasesConfig::default();
276 let ctx = PromptContext::new("working", None, &states_config, &phases_config);
277
278 let template = "From {{current_status}} you can go to:\n{{valid_exits}}";
279 let result = expand_prompt(template, &ctx);
280
281 assert!(result.contains("From working you can go to:"));
282 assert!(result.contains("`completed`"));
283 assert!(result.contains("`failed`"));
284 assert!(result.contains("`pending`"));
285 }
286
287 #[test]
288 fn test_expand_prompt_current_phase() {
289 let states_config = StatesConfig::default();
290 let phases_config = PhasesConfig::default();
291
292 let ctx = PromptContext::new("working", Some("implement"), &states_config, &phases_config);
294 let template = "Phase: {{current_phase}}";
295 let result = expand_prompt(template, &ctx);
296 assert_eq!(result, "Phase: `implement`");
297
298 let ctx = PromptContext::new("working", None, &states_config, &phases_config);
300 let result = expand_prompt(template, &ctx);
301 assert_eq!(result, "Phase: _(none)_");
302 }
303
304 #[test]
305 fn test_expand_prompt_valid_phases() {
306 let states_config = StatesConfig::default();
307 let phases_config = PhasesConfig::default();
308 let ctx = PromptContext::new("working", None, &states_config, &phases_config);
309
310 let template = "Phases: {{valid_phases}}";
311 let result = expand_prompt(template, &ctx);
312
313 assert!(result.contains("implement"));
315 assert!(result.contains("test"));
316 assert!(result.contains("review"));
317 }
318
319 #[test]
320 fn test_expand_prompt_terminal_state() {
321 let states_config = StatesConfig::default();
322 let phases_config = PhasesConfig::default();
323 let ctx = PromptContext::new("cancelled", None, &states_config, &phases_config);
324
325 let template = "Exits: {{valid_exits}}";
326 let result = expand_prompt(template, &ctx);
327
328 assert!(result.contains("no transitions available"));
330 }
331
332 #[test]
333 fn test_load_prompt_from_workflows() {
334 let workflows = WorkflowsConfig::default();
335
336 let prompt = load_prompt("enter~working", &workflows);
338 assert!(prompt.is_some());
339 assert!(prompt.unwrap().contains("actively working"));
340
341 let prompt = load_prompt("enter%implement", &workflows);
343 assert!(prompt.is_some());
344 assert!(prompt.unwrap().contains("Implementation"));
345 }
346
347 #[test]
348 fn test_get_transition_prompts() {
349 let workflows = WorkflowsConfig::default();
350
351 let prompts = get_transition_prompts("pending", None, "working", None, &workflows);
352
353 assert!(!prompts.is_empty());
355 assert!(prompts.iter().any(|p| p.contains("actively working")));
356 }
357
358 #[test]
359 fn test_list_available_prompts() {
360 let workflows = WorkflowsConfig::default();
361 let prompts = list_available_prompts(&workflows);
362
363 assert!(prompts.contains(&"enter~working".to_string()));
364 assert!(prompts.contains(&"exit~working".to_string()));
365 assert!(prompts.contains(&"enter%implement".to_string()));
366 }
367}