1use crate::config::workflows::WorkflowsConfig;
25use crate::config::{PhasesConfig, StatesConfig};
26
27#[derive(Debug, Clone)]
33pub struct PromptContext<'a> {
34 pub status: &'a str,
36 pub phase: Option<&'a str>,
38 pub states_config: &'a StatesConfig,
40 pub phases_config: &'a PhasesConfig,
42 pub task_id: Option<&'a str>,
44 pub task_title: Option<&'a str>,
46 pub task_priority: Option<i32>,
48 pub task_tags: Option<&'a [String]>,
50 pub agent_id: Option<&'a str>,
52 pub agent_role: Option<&'a str>,
54 pub agent_tags: Option<&'a [String]>,
56}
57
58impl<'a> PromptContext<'a> {
59 pub fn new(
65 status: &'a str,
66 phase: Option<&'a str>,
67 states_config: &'a StatesConfig,
68 phases_config: &'a PhasesConfig,
69 ) -> Self {
70 Self {
71 status,
72 phase,
73 states_config,
74 phases_config,
75 task_id: None,
76 task_title: None,
77 task_priority: None,
78 task_tags: None,
79 agent_id: None,
80 agent_role: None,
81 agent_tags: None,
82 }
83 }
84
85 pub fn with_task(
87 mut self,
88 id: &'a str,
89 title: &'a str,
90 priority: i32,
91 tags: &'a [String],
92 ) -> Self {
93 self.task_id = Some(id);
94 self.task_title = Some(title);
95 self.task_priority = Some(priority);
96 self.task_tags = Some(tags);
97 self
98 }
99
100 pub fn with_agent(
102 mut self,
103 agent_id: &'a str,
104 role: Option<&'a str>,
105 tags: &'a [String],
106 ) -> Self {
107 self.agent_id = Some(agent_id);
108 self.agent_role = role;
109 self.agent_tags = Some(tags);
110 self
111 }
112}
113
114pub fn load_prompt(trigger: &str, workflows: &WorkflowsConfig) -> Option<String> {
118 workflows.get_prompt(trigger).map(|s| s.to_string())
119}
120
121pub fn expand_prompt(content: &str, ctx: &PromptContext) -> String {
142 let mut result = content.to_string();
143
144 result = result.replace("{{current_status}}", ctx.status);
148
149 if result.contains("{{valid_exits}}") {
151 let exits = ctx.states_config.get_exits(ctx.status);
152 let exits_md = if exits.is_empty() {
153 "- _(no transitions available - terminal state)_".to_string()
154 } else {
155 exits
156 .iter()
157 .map(|s| format!("- `{}`", s))
158 .collect::<Vec<_>>()
159 .join("\n")
160 };
161 result = result.replace("{{valid_exits}}", &exits_md);
162 }
163
164 if result.contains("{{current_phase}}") {
166 let phase_str = ctx
167 .phase
168 .map(|p| format!("`{}`", p))
169 .unwrap_or_else(|| "_(none)_".to_string());
170 result = result.replace("{{current_phase}}", &phase_str);
171 }
172
173 if result.contains("{{valid_phases}}") {
175 let mut phases: Vec<&str> = ctx.phases_config.phase_names();
176 phases.sort();
177 let phases_str = phases.join(", ");
178 result = result.replace("{{valid_phases}}", &phases_str);
179 }
180
181 if result.contains("{{task_id}}") {
184 let val = ctx.task_id.unwrap_or("_unknown_");
185 result = result.replace("{{task_id}}", val);
186 }
187
188 if result.contains("{{task_title}}") {
189 let val = ctx.task_title.unwrap_or("_untitled_");
190 result = result.replace("{{task_title}}", val);
191 }
192
193 if result.contains("{{task_priority}}") {
194 let val = ctx
195 .task_priority
196 .map(|p| p.to_string())
197 .unwrap_or_else(|| "_unset_".to_string());
198 result = result.replace("{{task_priority}}", &val);
199 }
200
201 if result.contains("{{task_tags}}") {
202 let val = ctx
203 .task_tags
204 .map(|tags| {
205 if tags.is_empty() {
206 "_(none)_".to_string()
207 } else {
208 tags.join(", ")
209 }
210 })
211 .unwrap_or_else(|| "_(none)_".to_string());
212 result = result.replace("{{task_tags}}", &val);
213 }
214
215 if result.contains("{{agent_id}}") {
218 let val = ctx.agent_id.unwrap_or("_unknown_");
219 result = result.replace("{{agent_id}}", val);
220 }
221
222 if result.contains("{{agent_role}}") {
223 let val = ctx
224 .agent_role
225 .map(|r| format!("`{}`", r))
226 .unwrap_or_else(|| "_(none)_".to_string());
227 result = result.replace("{{agent_role}}", &val);
228 }
229
230 if result.contains("{{agent_tags}}") {
231 let val = ctx
232 .agent_tags
233 .map(|tags| {
234 if tags.is_empty() {
235 "_(none)_".to_string()
236 } else {
237 tags.join(", ")
238 }
239 })
240 .unwrap_or_else(|| "_(none)_".to_string());
241 result = result.replace("{{agent_tags}}", &val);
242 }
243
244 result
245}
246
247pub fn get_transition_triggers(
251 old_status: &str,
252 old_phase: Option<&str>,
253 new_status: &str,
254 new_phase: Option<&str>,
255) -> Vec<String> {
256 let mut triggers = Vec::new();
257
258 let status_changed = old_status != new_status;
259 let phase_changed = old_phase != new_phase;
260
261 if (status_changed || phase_changed)
265 && old_phase.is_some()
266 && let Some(op) = old_phase
267 {
268 triggers.push(format!("exit~{}%{}", old_status, op));
269 }
270
271 if phase_changed && let Some(op) = old_phase {
273 triggers.push(format!("exit%{}", op));
274 }
275
276 if status_changed {
278 triggers.push(format!("exit~{}", old_status));
279 }
280
281 if status_changed {
285 triggers.push(format!("enter~{}", new_status));
286 }
287
288 if phase_changed && let Some(np) = new_phase {
290 triggers.push(format!("enter%{}", np));
291 }
292
293 if (status_changed || phase_changed)
295 && new_phase.is_some()
296 && let Some(np) = new_phase
297 {
298 triggers.push(format!("enter~{}%{}", new_status, np));
299 }
300
301 triggers
302}
303
304pub fn get_transition_prompts(
309 old_status: &str,
310 old_phase: Option<&str>,
311 new_status: &str,
312 new_phase: Option<&str>,
313 workflows: &WorkflowsConfig,
314) -> Vec<String> {
315 get_transition_triggers(old_status, old_phase, new_status, new_phase)
316 .iter()
317 .filter_map(|trigger| load_prompt(trigger, workflows))
318 .collect()
319}
320
321pub fn get_transition_prompts_with_context(
325 old_status: &str,
326 old_phase: Option<&str>,
327 new_status: &str,
328 new_phase: Option<&str>,
329 workflows: &WorkflowsConfig,
330 ctx: &PromptContext,
331) -> Vec<String> {
332 get_transition_triggers(old_status, old_phase, new_status, new_phase)
333 .iter()
334 .filter_map(|trigger| load_prompt(trigger, workflows))
335 .map(|content| expand_prompt(&content, ctx))
336 .collect()
337}
338
339pub fn list_available_prompts(workflows: &WorkflowsConfig) -> Vec<String> {
341 workflows.list_prompt_triggers()
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn test_triggers_status_change_only() {
350 let triggers = get_transition_triggers("pending", None, "working", None);
351 assert_eq!(triggers, vec!["exit~pending", "enter~working"]);
352 }
353
354 #[test]
355 fn test_triggers_phase_change_only() {
356 let triggers =
357 get_transition_triggers("working", Some("diagnose"), "working", Some("review"));
358 assert_eq!(
359 triggers,
360 vec![
361 "exit~working%diagnose",
362 "exit%diagnose",
363 "enter%review",
364 "enter~working%review"
365 ]
366 );
367 }
368
369 #[test]
370 fn test_triggers_both_change() {
371 let triggers =
372 get_transition_triggers("working", Some("diagnose"), "finished", Some("review"));
373 assert_eq!(
374 triggers,
375 vec![
376 "exit~working%diagnose",
377 "exit%diagnose",
378 "exit~working",
379 "enter~finished",
380 "enter%review",
381 "enter~finished%review"
382 ]
383 );
384 }
385
386 #[test]
387 fn test_triggers_enter_phase_from_none() {
388 let triggers = get_transition_triggers("working", None, "working", Some("diagnose"));
389 assert_eq!(triggers, vec!["enter%diagnose", "enter~working%diagnose"]);
390 }
391
392 #[test]
393 fn test_triggers_exit_phase_to_none() {
394 let triggers = get_transition_triggers("working", Some("diagnose"), "working", None);
395 assert_eq!(triggers, vec!["exit~working%diagnose", "exit%diagnose"]);
396 }
397
398 #[test]
399 fn test_no_triggers_when_unchanged() {
400 let triggers =
401 get_transition_triggers("working", Some("diagnose"), "working", Some("diagnose"));
402 assert!(triggers.is_empty());
403 }
404
405 #[test]
406 fn test_expand_prompt_valid_exits() {
407 let states_config = StatesConfig::default();
408 let phases_config = PhasesConfig::default();
409 let ctx = PromptContext::new("working", None, &states_config, &phases_config);
410
411 let template = "From {{current_status}} you can go to:\n{{valid_exits}}";
412 let result = expand_prompt(template, &ctx);
413
414 assert!(result.contains("From working you can go to:"));
415 assert!(result.contains("`completed`"));
416 assert!(result.contains("`failed`"));
417 assert!(result.contains("`pending`"));
418 }
419
420 #[test]
421 fn test_expand_prompt_current_phase() {
422 let states_config = StatesConfig::default();
423 let phases_config = PhasesConfig::default();
424
425 let ctx = PromptContext::new("working", Some("implement"), &states_config, &phases_config);
427 let template = "Phase: {{current_phase}}";
428 let result = expand_prompt(template, &ctx);
429 assert_eq!(result, "Phase: `implement`");
430
431 let ctx = PromptContext::new("working", None, &states_config, &phases_config);
433 let result = expand_prompt(template, &ctx);
434 assert_eq!(result, "Phase: _(none)_");
435 }
436
437 #[test]
438 fn test_expand_prompt_valid_phases() {
439 let states_config = StatesConfig::default();
440 let phases_config = PhasesConfig::default();
441 let ctx = PromptContext::new("working", None, &states_config, &phases_config);
442
443 let template = "Phases: {{valid_phases}}";
444 let result = expand_prompt(template, &ctx);
445
446 assert!(result.contains("implement"));
448 assert!(result.contains("test"));
449 assert!(result.contains("review"));
450 }
451
452 #[test]
453 fn test_expand_prompt_terminal_state() {
454 let states_config = StatesConfig::default();
455 let phases_config = PhasesConfig::default();
456 let ctx = PromptContext::new("cancelled", None, &states_config, &phases_config);
457
458 let template = "Exits: {{valid_exits}}";
459 let result = expand_prompt(template, &ctx);
460
461 assert!(result.contains("no transitions available"));
463 }
464
465 #[test]
466 fn test_load_prompt_from_workflows() {
467 let workflows = WorkflowsConfig::default();
468
469 let prompt = load_prompt("enter~working", &workflows);
471 assert!(prompt.is_some());
472 assert!(prompt.unwrap().contains("actively working"));
473
474 let prompt = load_prompt("enter%implement", &workflows);
476 assert!(prompt.is_some());
477 assert!(prompt.unwrap().contains("Implementation"));
478 }
479
480 #[test]
481 fn test_get_transition_prompts() {
482 let workflows = WorkflowsConfig::default();
483
484 let prompts = get_transition_prompts("pending", None, "working", None, &workflows);
485
486 assert!(!prompts.is_empty());
488 assert!(prompts.iter().any(|p| p.contains("actively working")));
489 }
490
491 #[test]
492 fn test_list_available_prompts() {
493 let workflows = WorkflowsConfig::default();
494 let prompts = list_available_prompts(&workflows);
495
496 assert!(prompts.contains(&"enter~working".to_string()));
497 assert!(prompts.contains(&"exit~working".to_string()));
498 assert!(prompts.contains(&"enter%implement".to_string()));
499 }
500
501 #[test]
504 fn test_expand_prompt_task_context() {
505 let states_config = StatesConfig::default();
506 let phases_config = PhasesConfig::default();
507 let tags = vec!["backend".to_string(), "api".to_string()];
508 let ctx = PromptContext::new("working", Some("implement"), &states_config, &phases_config)
509 .with_task("fix-auth-bug", "Fix authentication bypass", 8, &tags);
510
511 let template = "Working on {{task_id}}: {{task_title}} (priority {{task_priority}}, tags: {{task_tags}})";
512 let result = expand_prompt(template, &ctx);
513
514 assert_eq!(
515 result,
516 "Working on fix-auth-bug: Fix authentication bypass (priority 8, tags: backend, api)"
517 );
518 }
519
520 #[test]
521 fn test_expand_prompt_task_context_empty_tags() {
522 let states_config = StatesConfig::default();
523 let phases_config = PhasesConfig::default();
524 let tags: Vec<String> = vec![];
525 let ctx = PromptContext::new("working", None, &states_config, &phases_config).with_task(
526 "my-task",
527 "Some task",
528 5,
529 &tags,
530 );
531
532 let template = "Tags: {{task_tags}}";
533 let result = expand_prompt(template, &ctx);
534
535 assert_eq!(result, "Tags: _(none)_");
536 }
537
538 #[test]
539 fn test_expand_prompt_task_context_missing() {
540 let states_config = StatesConfig::default();
541 let phases_config = PhasesConfig::default();
542 let ctx = PromptContext::new("working", None, &states_config, &phases_config);
544
545 let template = "Task: {{task_id}} / {{task_title}} / {{task_priority}} / {{task_tags}}";
546 let result = expand_prompt(template, &ctx);
547
548 assert_eq!(result, "Task: _unknown_ / _untitled_ / _unset_ / _(none)_");
549 }
550
551 #[test]
552 fn test_expand_prompt_agent_context() {
553 let states_config = StatesConfig::default();
554 let phases_config = PhasesConfig::default();
555 let agent_tags = vec!["worker".to_string(), "implement".to_string()];
556 let ctx = PromptContext::new("working", None, &states_config, &phases_config).with_agent(
557 "worker-21",
558 Some("worker"),
559 &agent_tags,
560 );
561
562 let template = "Agent {{agent_id}} (role: {{agent_role}}, tags: {{agent_tags}})";
563 let result = expand_prompt(template, &ctx);
564
565 assert_eq!(
566 result,
567 "Agent worker-21 (role: `worker`, tags: worker, implement)"
568 );
569 }
570
571 #[test]
572 fn test_expand_prompt_agent_context_no_role() {
573 let states_config = StatesConfig::default();
574 let phases_config = PhasesConfig::default();
575 let agent_tags = vec!["generic".to_string()];
576 let ctx = PromptContext::new("working", None, &states_config, &phases_config).with_agent(
577 "worker-5",
578 None,
579 &agent_tags,
580 );
581
582 let template = "Role: {{agent_role}}";
583 let result = expand_prompt(template, &ctx);
584
585 assert_eq!(result, "Role: _(none)_");
586 }
587
588 #[test]
589 fn test_expand_prompt_agent_context_missing() {
590 let states_config = StatesConfig::default();
591 let phases_config = PhasesConfig::default();
592 let ctx = PromptContext::new("working", None, &states_config, &phases_config);
594
595 let template = "{{agent_id}} / {{agent_role}} / {{agent_tags}}";
596 let result = expand_prompt(template, &ctx);
597
598 assert_eq!(result, "_unknown_ / _(none)_ / _(none)_");
599 }
600
601 #[test]
602 fn test_expand_prompt_combined_context() {
603 let states_config = StatesConfig::default();
604 let phases_config = PhasesConfig::default();
605 let task_tags = vec!["design".to_string()];
606 let agent_tags = vec!["worker".to_string(), "design".to_string()];
607 let ctx = PromptContext::new("working", Some("design"), &states_config, &phases_config)
608 .with_task(
609 "prompt-guidance",
610 "Context-sensitive prompts",
611 7,
612 &task_tags,
613 )
614 .with_agent("worker-21", Some("worker"), &agent_tags);
615
616 let template = "{{agent_id}} is working on {{task_id}} in phase {{current_phase}} with status {{current_status}}";
617 let result = expand_prompt(template, &ctx);
618
619 assert_eq!(
620 result,
621 "worker-21 is working on prompt-guidance in phase `design` with status working"
622 );
623 }
624
625 #[test]
626 fn test_prompt_context_builder_pattern() {
627 let states_config = StatesConfig::default();
628 let phases_config = PhasesConfig::default();
629 let task_tags = vec![];
630 let agent_tags = vec!["worker".to_string()];
631
632 let ctx = PromptContext::new("pending", None, &states_config, &phases_config)
634 .with_task("t1", "Title", 5, &task_tags)
635 .with_agent("w1", Some("worker"), &agent_tags);
636
637 assert_eq!(ctx.task_id, Some("t1"));
638 assert_eq!(ctx.task_title, Some("Title"));
639 assert_eq!(ctx.task_priority, Some(5));
640 assert_eq!(ctx.agent_id, Some("w1"));
641 assert_eq!(ctx.agent_role, Some("worker"));
642 }
643}