1use anyhow::{Context, Result};
2use dialoguer::{Confirm, Input, Select};
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum PluginType {
9 Panel,
10 Datasource,
11 App,
12}
13
14impl std::fmt::Display for PluginType {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 match self {
17 Self::Panel => write!(f, "panel"),
18 Self::Datasource => write!(f, "datasource"),
19 Self::App => write!(f, "app"),
20 }
21 }
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ProjectConfig {
27 pub name: String,
28 pub description: String,
29 pub author: String,
30 pub org: String,
31 pub plugin_type: PluginType,
32 pub has_wasm: bool,
33 pub has_docker: bool,
34 pub has_mock: bool,
35}
36
37pub fn template_directory_stack(config: &ProjectConfig) -> Vec<&'static str> {
39 let mut dirs = vec!["base"];
40
41 match config.plugin_type {
42 PluginType::Panel => dirs.push("panel"),
43 PluginType::Datasource => dirs.push("datasource"),
44 PluginType::App => dirs.push("app"),
45 }
46
47 if config.has_wasm {
48 dirs.push("wasm");
49 }
50 if config.has_docker {
51 dirs.push("docker");
52 }
53 if config.has_mock && config.has_docker {
54 dirs.push("mock");
55 }
56
57 dirs
58}
59
60pub fn validate_project_config(config: &ProjectConfig) -> Result<()> {
66 if config.has_mock && !config.has_docker {
67 anyhow::bail!(
68 "Mock data generator requires Docker: pass --docker with --mock, or set docker = true in .grafana-plugin.toml"
69 );
70 }
71 Ok(())
72}
73
74#[derive(Debug, Deserialize)]
76struct TomlConfig {
77 name: Option<String>,
78 description: Option<String>,
79 author: Option<String>,
80 org: Option<String>,
81 r#type: Option<String>,
82 wasm: Option<bool>,
83 docker: Option<bool>,
84 mock: Option<bool>,
85}
86
87pub fn to_kebab_case(s: &str) -> String {
89 s.chars()
90 .map(|c| {
91 if c.is_alphanumeric() {
92 c.to_ascii_lowercase()
93 } else {
94 '-'
95 }
96 })
97 .collect::<String>()
98 .split('-')
99 .filter(|p| !p.is_empty())
100 .collect::<Vec<_>>()
101 .join("-")
102}
103
104pub fn parse_plugin_type(s: &str) -> Result<PluginType> {
110 match s.to_lowercase().as_str() {
111 "panel" => Ok(PluginType::Panel),
112 "datasource" | "data-source" => Ok(PluginType::Datasource),
113 "app" => Ok(PluginType::App),
114 _ => anyhow::bail!("Invalid plugin type: {s}. Use: panel, datasource, or app"),
115 }
116}
117
118#[allow(clippy::too_many_lines)]
125pub fn resolve_config(args: &crate::cli::Args) -> Result<ProjectConfig> {
126 let toml_cfg = if let Some(ref path) = args.config {
128 let content = std::fs::read_to_string(path)
129 .with_context(|| format!("Failed to read config file: {path}"))?;
130 Some(
131 toml::from_str::<TomlConfig>(&content)
132 .with_context(|| format!("Failed to parse config file: {path}"))?,
133 )
134 } else {
135 None
136 };
137
138 let name = args
139 .name
140 .clone()
141 .or_else(|| toml_cfg.as_ref().and_then(|c| c.name.clone()));
142 let description = args
143 .description
144 .clone()
145 .or_else(|| toml_cfg.as_ref().and_then(|c| c.description.clone()));
146 let author = args
147 .author
148 .clone()
149 .or_else(|| toml_cfg.as_ref().and_then(|c| c.author.clone()));
150 let org = args
151 .org
152 .clone()
153 .or_else(|| toml_cfg.as_ref().and_then(|c| c.org.clone()));
154 let plugin_type_str = args
155 .r#type
156 .clone()
157 .or_else(|| toml_cfg.as_ref().and_then(|c| c.r#type.clone()));
158 let has_wasm = if args.wasm {
159 Some(true)
160 } else {
161 toml_cfg.as_ref().and_then(|c| c.wasm)
162 };
163 let has_docker = if args.docker {
164 Some(true)
165 } else {
166 toml_cfg.as_ref().and_then(|c| c.docker)
167 };
168 let has_mock = if args.mock {
169 Some(true)
170 } else {
171 toml_cfg.as_ref().and_then(|c| c.mock)
172 };
173 if let (Some(name_val), Some(ptype_val), Some(author_val), Some(org_val)) = (
174 name.as_deref(),
175 plugin_type_str.as_deref(),
176 author.as_deref(),
177 org.as_deref(),
178 ) {
179 let cfg = ProjectConfig {
180 name: to_kebab_case(name_val),
181 description: description.clone().unwrap_or_default(),
182 author: author_val.to_string(),
183 org: org_val.to_string(),
184 plugin_type: parse_plugin_type(ptype_val)?,
185 has_wasm: has_wasm.unwrap_or(false),
186 has_docker: has_docker.unwrap_or(false),
187 has_mock: has_mock.unwrap_or(false),
188 };
189 validate_project_config(&cfg)?;
190 return Ok(cfg);
191 }
192
193 println!("\n 🔧 Grafana Plugin Creator\n");
195
196 let name = name.map_or_else(
197 || {
198 Input::<String>::new()
199 .with_prompt(" Plugin name")
200 .interact_text()
201 .map(|s| to_kebab_case(&s))
202 },
203 |n| Ok(to_kebab_case(&n)),
204 )?;
205
206 let description = description.map_or_else(
207 || {
208 Input::<String>::new()
209 .with_prompt(" Description")
210 .default("A Grafana plugin".to_string())
211 .interact_text()
212 },
213 Ok,
214 )?;
215
216 let author = author.map_or_else(
217 || {
218 Input::<String>::new()
219 .with_prompt(" Author")
220 .interact_text()
221 },
222 Ok,
223 )?;
224
225 let org = org.map_or_else(
226 || {
227 Input::<String>::new()
228 .with_prompt(" Organization")
229 .interact_text()
230 },
231 Ok,
232 )?;
233
234 let plugin_type = if let Some(ref t) = plugin_type_str {
235 parse_plugin_type(t)?
236 } else {
237 let types = ["Panel", "Datasource", "App"];
238 let idx = Select::new()
239 .with_prompt(" Plugin type")
240 .items(&types)
241 .default(0)
242 .interact()?;
243 match idx {
244 0 => PluginType::Panel,
245 1 => PluginType::Datasource,
246 _ => PluginType::App,
247 }
248 };
249
250 let has_wasm = has_wasm.map_or_else(
251 || {
252 Confirm::new()
253 .with_prompt(" Include Rust WASM engine?")
254 .default(false)
255 .interact()
256 },
257 Ok,
258 )?;
259
260 let has_docker = has_docker.map_or_else(
261 || {
262 Confirm::new()
263 .with_prompt(" Include Docker dev environment?")
264 .default(true)
265 .interact()
266 },
267 Ok,
268 )?;
269
270 let has_mock = if has_docker {
271 has_mock.map_or_else(
272 || {
273 Confirm::new()
274 .with_prompt(" Include mock data generator?")
275 .default(true)
276 .interact()
277 },
278 Ok,
279 )?
280 } else {
281 false
282 };
283
284 let cfg = ProjectConfig {
285 name,
286 description,
287 author,
288 org,
289 plugin_type,
290 has_wasm,
291 has_docker,
292 has_mock,
293 };
294 validate_project_config(&cfg)?;
295 Ok(cfg)
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 fn sample_cfg(has_docker: bool, has_mock: bool) -> ProjectConfig {
303 ProjectConfig {
304 name: "x".to_string(),
305 description: String::new(),
306 author: String::new(),
307 org: String::new(),
308 plugin_type: PluginType::Panel,
309 has_wasm: false,
310 has_docker,
311 has_mock,
312 }
313 }
314
315 #[test]
316 fn validate_rejects_mock_without_docker() {
317 let err = validate_project_config(&sample_cfg(false, true)).unwrap_err();
318 assert!(
319 err.to_string().contains("Mock"),
320 "unexpected message: {err}"
321 );
322 }
323
324 #[test]
325 fn validate_accepts_mock_with_docker() {
326 validate_project_config(&sample_cfg(true, true)).unwrap();
327 }
328
329 #[test]
330 fn template_stack_includes_mock_only_with_docker() {
331 let with = template_directory_stack(&sample_cfg(true, true));
332 assert!(with.contains(&"mock"));
333
334 let without = template_directory_stack(&sample_cfg(true, false));
335 assert!(!without.contains(&"mock"));
336
337 let mock_but_no_docker = template_directory_stack(&sample_cfg(false, true));
338 assert!(!mock_but_no_docker.contains(&"mock"));
339 }
340}