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