Skip to main content

minion_engine/prompts/
registry.rs

1use 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    /// Load and parse a registry YAML file.
30    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}