minion_engine/prompts/
resolver.rs1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::error::StepError;
5
6pub use super::detector::StackInfo;
8
9pub struct PromptResolver;
17
18impl PromptResolver {
19 pub async fn resolve(
23 function: &str,
24 stack: &StackInfo,
25 prompts_dir: &Path,
26 ) -> Result<PathBuf, StepError> {
27 let mut candidates: Vec<&str> = Vec::new();
29 candidates.push(&stack.name);
30 for parent in &stack.parent_chain {
31 candidates.push(parent.as_str());
32 }
33
34 let mut seen: HashSet<&str> = HashSet::new();
36 let mut chain_display: Vec<&str> = Vec::new();
37 for name in &candidates {
38 if !seen.insert(name) {
39 chain_display.push(name);
40 return Err(StepError::Fail(format!(
41 "Circular parent chain detected: {}. Check registry.yaml parent fields.",
42 candidates.join(" -> ")
43 )));
44 }
45 chain_display.push(name);
46 }
47
48 for name in &candidates {
50 let path = prompts_dir
51 .join(function)
52 .join(format!("{}.md.tera", name));
53 if tokio::fs::metadata(&path).await.is_ok() {
54 return Ok(path);
55 }
56 }
57
58 let default_path = prompts_dir
60 .join(function)
61 .join("_default.md.tera");
62 if tokio::fs::metadata(&default_path).await.is_ok() {
63 return Ok(default_path);
64 }
65
66 Err(StepError::Fail(format!(
68 "No prompt for {}/{} — create prompts/{}/{}.md.tera or prompts/{}/_default.md.tera",
69 function, stack.name, function, stack.name, function
70 )))
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77 use std::collections::HashMap;
78 use tokio::fs;
79
80 fn make_stack(name: &str, parents: &[&str]) -> StackInfo {
81 StackInfo {
82 name: name.to_string(),
83 parent_chain: parents.iter().map(|s| s.to_string()).collect(),
84 tools: HashMap::new(),
85 }
86 }
87
88 #[tokio::test]
89 async fn direct_match_returns_correct_path() {
90 let tmp = tempfile::tempdir().unwrap();
91 let prompts_dir = tmp.path();
92
93 fs::create_dir_all(prompts_dir.join("fix-lint")).await.unwrap();
94 let expected = prompts_dir.join("fix-lint").join("react.md.tera");
95 fs::write(&expected, "# fix-lint for react").await.unwrap();
96
97 let stack = make_stack("react", &["typescript", "javascript"]);
98 let result = PromptResolver::resolve("fix-lint", &stack, prompts_dir).await;
99
100 assert!(result.is_ok(), "Expected Ok, got {:?}", result);
101 assert_eq!(result.unwrap(), expected);
102 }
103
104 #[tokio::test]
105 async fn fallback_to_parent_when_direct_missing() {
106 let tmp = tempfile::tempdir().unwrap();
107 let prompts_dir = tmp.path();
108
109 fs::create_dir_all(prompts_dir.join("fix-lint")).await.unwrap();
110 let expected = prompts_dir.join("fix-lint").join("typescript.md.tera");
112 fs::write(&expected, "# fix-lint for typescript").await.unwrap();
113
114 let stack = make_stack("react", &["typescript", "javascript"]);
115 let result = PromptResolver::resolve("fix-lint", &stack, prompts_dir).await;
116
117 assert!(result.is_ok(), "Expected Ok, got {:?}", result);
118 assert_eq!(result.unwrap(), expected);
119 }
120
121 #[tokio::test]
122 async fn fallback_to_default_when_no_stack_match() {
123 let tmp = tempfile::tempdir().unwrap();
124 let prompts_dir = tmp.path();
125
126 fs::create_dir_all(prompts_dir.join("fix-lint")).await.unwrap();
127 let default = prompts_dir.join("fix-lint").join("_default.md.tera");
128 fs::write(&default, "# fix-lint default").await.unwrap();
129
130 let stack = make_stack("react", &["typescript", "javascript"]);
131 let result = PromptResolver::resolve("fix-lint", &stack, prompts_dir).await;
132
133 assert!(result.is_ok(), "Expected Ok, got {:?}", result);
134 assert_eq!(result.unwrap(), default);
135 }
136
137 #[tokio::test]
138 async fn missing_prompt_returns_descriptive_error() {
139 let tmp = tempfile::tempdir().unwrap();
140 let prompts_dir = tmp.path();
141
142 let stack = make_stack("react", &["typescript", "javascript"]);
144 let result = PromptResolver::resolve("fix-lint", &stack, prompts_dir).await;
145
146 assert!(result.is_err(), "Expected Err");
147 let msg = result.unwrap_err().to_string();
148 assert!(
149 msg.contains("No prompt for fix-lint/react"),
150 "Error should mention function and stack: {msg}"
151 );
152 assert!(
153 msg.contains("_default.md.tera"),
154 "Error should suggest _default.md.tera: {msg}"
155 );
156 }
157
158 #[tokio::test]
159 async fn circular_parent_chain_returns_error() {
160 let tmp = tempfile::tempdir().unwrap();
161 let prompts_dir = tmp.path();
162
163 let stack = make_stack("a", &["b", "a"]);
165 let result = PromptResolver::resolve("fix-lint", &stack, prompts_dir).await;
166
167 assert!(result.is_err(), "Expected Err for circular chain");
168 let msg = result.unwrap_err().to_string();
169 assert!(
170 msg.contains("Circular parent chain detected"),
171 "Error should mention circular chain: {msg}"
172 );
173 assert!(
174 msg.contains("registry.yaml"),
175 "Error should mention registry.yaml: {msg}"
176 );
177 }
178}