Skip to main content

minion_engine/prompts/
resolver.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::error::StepError;
5
6// Re-export StackInfo from the canonical definition in detector.rs
7pub use super::detector::StackInfo;
8
9/// Resolves a prompt file for a given (function, stack) pair using the
10/// ADR-02 fallback chain algorithm:
11///
12/// 1. `prompts/{function}/{stack.name}.md.tera`
13/// 2. Walk `stack.parent_chain` in order
14/// 3. `prompts/{function}/_default.md.tera`
15/// 4. Return `StepError::Fail` with an actionable message
16pub struct PromptResolver;
17
18impl PromptResolver {
19    /// Resolve the prompt file path for `function` given `stack`.
20    ///
21    /// All file-existence checks are async via `tokio::fs::metadata`.
22    pub async fn resolve(
23        function: &str,
24        stack: &StackInfo,
25        prompts_dir: &Path,
26    ) -> Result<PathBuf, StepError> {
27        // Build ordered list of candidates: stack name then each parent
28        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        // Detect circular references before doing any I/O
35        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        // Walk the candidate list and return the first file that exists
49        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        // Fall back to _default.md.tera
59        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        // Nothing found — return actionable error
67        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        // No react.md.tera, but typescript.md.tera exists
111        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        // No files created at all
143        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        // A -> B -> A (circular)
164        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}