Skip to main content

create_grafana_plugin/
config.rs

1use anyhow::{Context, Result};
2use dialoguer::{Confirm, Input, Select};
3use serde::{Deserialize, Serialize};
4
5/// Plugin type variants
6#[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/// Resolved project configuration after merging all input sources
25#[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
38/// Template layer directories in merge order — single source of truth for scaffold and [`crate::updater::update`].
39pub 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
61/// Ensures flag combinations match what the scaffold can emit.
62///
63/// # Errors
64///
65/// Returns an error when options are inconsistent (e.g. mock without Docker).
66pub 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/// TOML config file structure
76#[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
89/// Convert plugin name to valid kebab-case
90pub 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
106/// Parse plugin type string from `plugin.json` or CLI.
107///
108/// # Errors
109///
110/// Returns an error when `s` is not a supported plugin type string.
111pub 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/// Build config from CLI args, falling back to TOML file, then interactive prompts.
121///
122/// # Errors
123///
124/// Returns an error when config values are invalid or files cannot be read.
125///
126#[allow(clippy::too_many_lines)]
127pub fn resolve_config(args: &crate::cli::Args) -> Result<ProjectConfig> {
128    // Load TOML config if specified
129    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    // Interactive mode
200    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}