Skip to main content

shape_runtime/
extensions_config.rs

1//! Global extension configuration loading from TOML.
2//!
3//! Loads extension definitions from `extensions.toml` using the same schema
4//! across CLI, runtime tooling, and LSP.
5//!
6//! Search order:
7//! 1. `$SHAPE_CONFIG_DIR/extensions.toml` (if set)
8//! 2. `~/.config/shape/extensions.toml`
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15/// Root configuration structure for `extensions.toml`.
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct ExtensionsConfig {
18    /// List of extension entries.
19    #[serde(default)]
20    pub extensions: Vec<ExtensionEntry>,
21}
22
23/// A single extension entry in the configuration.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ExtensionEntry {
26    /// Module name (for logs/diagnostics and namespace registration).
27    pub name: String,
28    /// Path to the shared library (.so/.dylib/.dll).
29    pub path: PathBuf,
30    /// Configuration passed to module initialization.
31    #[serde(default)]
32    pub config: HashMap<String, toml::Value>,
33}
34
35impl ExtensionEntry {
36    /// Convert TOML config values to JSON for runtime plugin initialization.
37    pub fn config_as_json(&self) -> serde_json::Value {
38        toml_to_json(&toml::Value::Table(
39            self.config
40                .iter()
41                .map(|(k, v)| (k.clone(), v.clone()))
42                .collect(),
43        ))
44    }
45}
46
47/// Load extension configuration from the default location.
48///
49/// Returns an empty config if no file exists.
50pub fn load_extensions_config() -> Result<ExtensionsConfig> {
51    let config_path = get_config_path()?;
52    if !config_path.exists() {
53        return Ok(ExtensionsConfig::default());
54    }
55    load_extensions_config_from(&config_path)
56}
57
58/// Load extension configuration from a specific file path.
59pub fn load_extensions_config_from(path: &Path) -> Result<ExtensionsConfig> {
60    let content = std::fs::read_to_string(path)
61        .with_context(|| format!("Failed to read extension config from {:?}", path))?;
62    let config: ExtensionsConfig = toml::from_str(&content)
63        .with_context(|| format!("Failed to parse extension config from {:?}", path))?;
64    Ok(config)
65}
66
67/// Resolve the default extension config path.
68pub fn get_config_path() -> Result<PathBuf> {
69    if let Ok(config_dir) = std::env::var("SHAPE_CONFIG_DIR") {
70        return Ok(PathBuf::from(config_dir).join("extensions.toml"));
71    }
72
73    let config_dir = dirs::config_dir()
74        .ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
75    Ok(config_dir.join("shape").join("extensions.toml"))
76}
77
78fn toml_to_json(value: &toml::Value) -> serde_json::Value {
79    match value {
80        toml::Value::String(s) => serde_json::Value::String(s.clone()),
81        toml::Value::Integer(i) => serde_json::Value::Number((*i).into()),
82        toml::Value::Float(f) => serde_json::Number::from_f64(*f)
83            .map(serde_json::Value::Number)
84            .unwrap_or(serde_json::Value::Null),
85        toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
86        toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
87        toml::Value::Array(arr) => serde_json::Value::Array(arr.iter().map(toml_to_json).collect()),
88        toml::Value::Table(table) => {
89            let map: serde_json::Map<String, serde_json::Value> = table
90                .iter()
91                .map(|(k, v)| (k.clone(), toml_to_json(v)))
92                .collect();
93            serde_json::Value::Object(map)
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn test_parse_empty_config() {
104        let config: ExtensionsConfig = toml::from_str("").unwrap();
105        assert!(config.extensions.is_empty());
106    }
107
108    #[test]
109    fn test_parse_single_module() {
110        let toml_str = r#"
111[[extensions]]
112name = "files"
113path = "./libshape_plugin_files.so"
114
115[extensions.config]
116base_dir = "./data"
117"#;
118
119        let config: ExtensionsConfig = toml::from_str(toml_str).unwrap();
120        assert_eq!(config.extensions.len(), 1);
121        assert_eq!(config.extensions[0].name, "files");
122        assert_eq!(
123            config.extensions[0].path,
124            PathBuf::from("./libshape_plugin_files.so")
125        );
126
127        let json_config = config.extensions[0].config_as_json();
128        assert_eq!(json_config["base_dir"], "./data");
129    }
130
131    #[test]
132    fn test_parse_multiple_modules() {
133        let toml_str = r#"
134[[extensions]]
135name = "market-data"
136path = "./libshape_plugin_market_data.so"
137
138[extensions.config]
139duckdb_path = "/path/to/market.duckdb"
140default_timeframe = "1d"
141read_only = true
142
143[[extensions]]
144name = "files"
145path = "./libshape_plugin_files.so"
146
147[extensions.config]
148base_dir = "./data"
149"#;
150
151        let config: ExtensionsConfig = toml::from_str(toml_str).unwrap();
152        assert_eq!(config.extensions.len(), 2);
153        assert_eq!(config.extensions[0].name, "market-data");
154        let json0 = config.extensions[0].config_as_json();
155        assert_eq!(json0["duckdb_path"], "/path/to/market.duckdb");
156        assert_eq!(json0["default_timeframe"], "1d");
157        assert_eq!(json0["read_only"], true);
158        assert_eq!(config.extensions[1].name, "files");
159    }
160}