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}
36
37/// Template layer directories in merge order — single source of truth for scaffold and [`crate::updater::update`].
38pub 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
60/// Ensures flag combinations match what the scaffold can emit.
61///
62/// # Errors
63///
64/// Returns an error when options are inconsistent (e.g. mock without Docker).
65pub 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/// TOML config file structure
75#[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
87/// Convert plugin name to valid kebab-case
88pub 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
104/// Parse plugin type string from `plugin.json` or CLI.
105///
106/// # Errors
107///
108/// Returns an error when `s` is not a supported plugin type string.
109pub 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/// Build config from CLI args, falling back to TOML file, then interactive prompts.
119///
120/// # Errors
121///
122/// Returns an error when config values are invalid or files cannot be read.
123///
124#[allow(clippy::too_many_lines)]
125pub fn resolve_config(args: &crate::cli::Args) -> Result<ProjectConfig> {
126    // Load TOML config if specified
127    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    // Interactive mode
194    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}