minion_engine/prompts/
registry.rs1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::StepError;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub struct Registry {
11 pub version: u32,
12 pub detection_order: Vec<String>,
13 pub stacks: HashMap<String, StackDef>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct StackDef {
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub parent: Option<String>,
20 #[serde(default, skip_serializing_if = "Vec::is_empty")]
21 pub file_markers: Vec<String>,
22 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
23 pub content_match: HashMap<String, String>,
24 #[serde(default)]
25 pub tools: HashMap<String, String>,
26}
27
28impl Registry {
29 pub async fn from_file(path: &Path) -> Result<Registry, StepError> {
31 let content = tokio::fs::read_to_string(path).await.map_err(|e| {
32 if e.kind() == std::io::ErrorKind::NotFound {
33 StepError::Fail(format!(
34 "Registry file not found: '{}': No such file",
35 path.display()
36 ))
37 } else {
38 StepError::Fail(format!(
39 "Failed to read registry file '{}': {}",
40 path.display(),
41 e
42 ))
43 }
44 })?;
45
46 let registry: Registry = serde_yaml::from_str(&content).map_err(|e| StepError::Config {
47 field: "registry.yaml".into(),
48 message: e.to_string(),
49 })?;
50
51 Ok(registry)
52 }
53}
54
55#[cfg(test)]
56mod tests {
57 use super::*;
58 use std::io::Write as _;
59 use tempfile::NamedTempFile;
60
61 const MINIMAL_YAML: &str = r#"
62version: 1
63detection_order:
64 - rust
65 - python
66
67stacks:
68 _default:
69 tools:
70 lint: "echo 'no linter configured'"
71 test: "echo 'no test runner configured'"
72 build: "echo 'no build configured'"
73 install: "echo 'no installer configured'"
74
75 rust:
76 parent: _default
77 file_markers: ["Cargo.toml"]
78 tools:
79 lint: "cargo clippy -- -D warnings"
80 test: "cargo test"
81 build: "cargo build --release"
82 install: "cargo fetch"
83
84 python:
85 parent: _default
86 file_markers: ["pyproject.toml", "setup.py", "requirements.txt"]
87 tools:
88 lint: "ruff check ."
89 test: "pytest"
90 build: "python -m build"
91 install: "pip install -r requirements.txt"
92"#;
93
94 #[tokio::test]
95 async fn test_successful_yaml_parsing() {
96 let mut tmp = NamedTempFile::new().unwrap();
97 tmp.write_all(MINIMAL_YAML.as_bytes()).unwrap();
98
99 let registry = Registry::from_file(tmp.path()).await.unwrap();
100
101 assert_eq!(registry.version, 1);
102 assert_eq!(registry.detection_order, vec!["rust", "python"]);
103 assert!(registry.stacks.contains_key("_default"));
104 assert!(registry.stacks.contains_key("rust"));
105 assert!(registry.stacks.contains_key("python"));
106 }
107
108 #[tokio::test]
109 async fn test_stackdef_fields_deserialize_correctly() {
110 let mut tmp = NamedTempFile::new().unwrap();
111 tmp.write_all(MINIMAL_YAML.as_bytes()).unwrap();
112
113 let registry = Registry::from_file(tmp.path()).await.unwrap();
114
115 let rust_stack = registry.stacks.get("rust").unwrap();
116 assert_eq!(rust_stack.parent, Some("_default".to_string()));
117 assert_eq!(rust_stack.file_markers, vec!["Cargo.toml"]);
118 assert!(rust_stack.content_match.is_empty());
119 assert_eq!(rust_stack.tools.get("test").unwrap(), "cargo test");
120 assert_eq!(rust_stack.tools.get("lint").unwrap(), "cargo clippy -- -D warnings");
121 }
122
123 #[tokio::test]
124 async fn test_default_stack_has_no_parent_and_no_file_markers() {
125 let mut tmp = NamedTempFile::new().unwrap();
126 tmp.write_all(MINIMAL_YAML.as_bytes()).unwrap();
127
128 let registry = Registry::from_file(tmp.path()).await.unwrap();
129
130 let default_stack = registry.stacks.get("_default").unwrap();
131 assert!(default_stack.parent.is_none());
132 assert!(default_stack.file_markers.is_empty());
133 assert_eq!(
134 default_stack.tools.get("lint").unwrap(),
135 "echo 'no linter configured'"
136 );
137 }
138
139 #[tokio::test]
140 async fn test_missing_file_returns_step_error_fail() {
141 let path = Path::new("/nonexistent/path/registry.yaml");
142 let result = Registry::from_file(path).await;
143
144 assert!(result.is_err());
145 let err = result.unwrap_err();
146 let msg = err.to_string();
147 assert!(msg.contains("Registry file not found"), "Expected 'Registry file not found' in: {msg}");
148 assert!(msg.contains("No such file"), "Expected 'No such file' in: {msg}");
149 }
150
151 #[tokio::test]
152 async fn test_invalid_yaml_returns_step_error_config() {
153 let mut tmp = NamedTempFile::new().unwrap();
154 tmp.write_all(b"version: 1\nstacks: [this is not valid yaml mapping: {{{").unwrap();
155
156 let result = Registry::from_file(tmp.path()).await;
157
158 assert!(result.is_err());
159 let err = result.unwrap_err();
160 match err {
161 StepError::Config { field, .. } => {
162 assert_eq!(field, "registry.yaml");
163 }
164 other => panic!("Expected StepError::Config, got: {other:?}"),
165 }
166 }
167}