greentic_dev/
config.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::PathBuf;
4
5use anyhow::{Context, Result, bail};
6use serde::Deserialize;
7
8#[derive(Debug, Default, Deserialize, Clone)]
9pub struct GreenticConfig {
10    #[serde(default)]
11    pub tools: ToolsSection,
12    #[serde(default)]
13    pub defaults: DefaultsSection,
14    #[serde(default)]
15    pub distributor: DistributorSection,
16    /// Backward-compatible root-level [profiles.*] table used for distributor.
17    #[serde(default, rename = "profiles")]
18    pub legacy_distributor_profiles: HashMap<String, DistributorProfileConfig>,
19}
20
21impl GreenticConfig {
22    pub fn distributor_profiles(&self) -> HashMap<String, DistributorProfileConfig> {
23        let mut merged = self.distributor.merged_profiles();
24        if merged.is_empty() && !self.legacy_distributor_profiles.is_empty() {
25            merged.extend(self.legacy_distributor_profiles.clone());
26        }
27        merged
28    }
29}
30
31#[derive(Debug, Default, Deserialize, Clone)]
32pub struct ToolsSection {
33    #[serde(rename = "greentic-component", default)]
34    pub greentic_component: ToolEntry,
35}
36
37#[derive(Debug, Default, Deserialize, Clone)]
38pub struct ToolEntry {
39    pub path: Option<PathBuf>,
40}
41
42#[allow(dead_code)]
43#[derive(Debug, Default, Deserialize, Clone)]
44pub struct DefaultsSection {
45    #[serde(default)]
46    pub component: ComponentDefaults,
47}
48
49#[allow(dead_code)]
50#[derive(Debug, Default, Deserialize, Clone)]
51pub struct ComponentDefaults {
52    pub org: Option<String>,
53    pub template: Option<String>,
54}
55
56#[derive(Debug, Default, Deserialize, Clone)]
57pub struct DistributorSection {
58    /// Configures the default distributor profile by name or inline struct.
59    #[serde(default)]
60    pub default_profile: Option<DefaultProfileSelection>,
61    /// Profiles nested under [distributor.profiles.*].
62    #[serde(default)]
63    pub profiles: HashMap<String, DistributorProfileConfig>,
64    /// Backward-compatible: [distributor.<name>] tables.
65    #[serde(default, flatten)]
66    legacy_profiles: HashMap<String, DistributorProfileConfig>,
67}
68
69impl DistributorSection {
70    pub fn merged_profiles(&self) -> HashMap<String, DistributorProfileConfig> {
71        let mut merged = self.profiles.clone();
72        for (name, cfg) in self.legacy_profiles.iter() {
73            merged.entry(name.clone()).or_insert_with(|| cfg.clone());
74        }
75        merged
76    }
77}
78
79#[derive(Debug, Clone, Deserialize)]
80#[serde(untagged)]
81pub enum DefaultProfileSelection {
82    Name(String),
83    Inline(DistributorProfileConfig),
84}
85
86#[derive(Debug, Clone, Deserialize)]
87pub struct DistributorProfileConfig {
88    /// Optional profile name when provided inline.
89    #[serde(default)]
90    pub name: Option<String>,
91    /// Base URL for the distributor (preferred field; falls back to `url` if set).
92    #[serde(default)]
93    pub base_url: Option<String>,
94    /// Deprecated alias for base_url.
95    #[serde(default)]
96    pub url: Option<String>,
97    /// API token; allow env:VAR indirection.
98    #[serde(default)]
99    pub token: Option<String>,
100    /// Tenant identifier for distributor requests.
101    #[serde(default)]
102    pub tenant_id: Option<String>,
103    /// Environment identifier for distributor requests.
104    #[serde(default)]
105    pub environment_id: Option<String>,
106    /// Additional headers (optional).
107    #[serde(default)]
108    pub headers: Option<HashMap<String, String>>,
109}
110
111#[derive(Debug, Clone)]
112pub struct LoadedGreenticConfig {
113    pub config: GreenticConfig,
114    pub loaded_from: Option<PathBuf>,
115    pub attempted_paths: Vec<PathBuf>,
116}
117
118#[derive(Debug, Clone)]
119pub struct ConfigResolution {
120    pub selected: Option<PathBuf>,
121    pub attempted: Vec<PathBuf>,
122    pub forced: Option<ConfigSource>,
123}
124
125#[derive(Debug, Clone)]
126pub enum ConfigSource {
127    Arg,
128    Env(&'static str),
129}
130
131pub fn load() -> Result<GreenticConfig> {
132    load_with_meta(None).map(|loaded| loaded.config)
133}
134
135pub fn load_from(path_override: Option<&str>) -> Result<GreenticConfig> {
136    load_with_meta(path_override).map(|loaded| loaded.config)
137}
138
139pub fn load_with_meta(path_override: Option<&str>) -> Result<LoadedGreenticConfig> {
140    let resolution = resolve_config_path(path_override);
141    let forced_source = resolution.forced.clone();
142    let attempted_paths = resolution.attempted.clone();
143
144    let Some(selected) = resolution.selected else {
145        return Ok(LoadedGreenticConfig {
146            config: GreenticConfig::default(),
147            loaded_from: None,
148            attempted_paths,
149        });
150    };
151
152    if !selected.exists() {
153        let reason = match forced_source {
154            Some(ConfigSource::Arg) => "explicit config override",
155            Some(ConfigSource::Env(var)) => var,
156            None => "config discovery",
157        };
158        bail!(
159            "config file {} set via {} does not exist (searched: {})",
160            selected.display(),
161            reason,
162            format_attempted(&resolution.attempted)
163        );
164    }
165
166    let raw = fs::read_to_string(&selected)
167        .with_context(|| format!("failed to read config at {}", selected.display()))?;
168    let config: GreenticConfig = toml::from_str(&raw)
169        .with_context(|| format!("failed to parse config at {}", selected.display()))?;
170
171    Ok(LoadedGreenticConfig {
172        config,
173        loaded_from: Some(selected),
174        attempted_paths,
175    })
176}
177
178fn format_attempted(paths: &[PathBuf]) -> String {
179    if paths.is_empty() {
180        return "(none)".to_string();
181    }
182    paths
183        .iter()
184        .map(|p| p.display().to_string())
185        .collect::<Vec<_>>()
186        .join(", ")
187}
188
189pub fn resolve_config_path(path_override: Option<&str>) -> ConfigResolution {
190    let mut attempted = Vec::new();
191
192    if let Some(raw) = path_override {
193        let path = PathBuf::from(raw);
194        attempted.push(path.clone());
195        return ConfigResolution {
196            selected: Some(path),
197            attempted,
198            forced: Some(ConfigSource::Arg),
199        };
200    }
201
202    for (var, source) in [
203        (
204            "GREENTIC_DEV_CONFIG_FILE",
205            ConfigSource::Env("GREENTIC_DEV_CONFIG_FILE"),
206        ),
207        (
208            "GREENTIC_CONFIG_FILE",
209            ConfigSource::Env("GREENTIC_CONFIG_FILE"),
210        ),
211        ("GREENTIC_CONFIG", ConfigSource::Env("GREENTIC_CONFIG")),
212    ] {
213        if let Ok(raw) = std::env::var(var)
214            && !raw.is_empty()
215        {
216            let path = PathBuf::from(raw);
217            attempted.push(path.clone());
218            return ConfigResolution {
219                selected: Some(path),
220                attempted,
221                forced: Some(source),
222            };
223        }
224    }
225
226    let mut candidates = Vec::new();
227    let xdg_config = std::env::var_os("XDG_CONFIG_HOME")
228        .map(PathBuf::from)
229        .or_else(dirs::config_dir);
230    if let Some(mut dir) = xdg_config {
231        dir.push("greentic-dev");
232        dir.push("config.toml");
233        push_unique(&mut candidates, dir);
234    }
235    if let Some(mut home) = dirs::home_dir() {
236        let mut legacy = home.clone();
237        legacy.push(".config");
238        legacy.push("greentic-dev");
239        legacy.push("config.toml");
240        push_unique(&mut candidates, legacy);
241
242        home.push(".greentic");
243        home.push("config.toml");
244        push_unique(&mut candidates, home);
245    }
246
247    let selected = candidates.iter().find(|path| path.exists()).cloned();
248    attempted.extend(candidates);
249
250    ConfigResolution {
251        selected,
252        attempted,
253        forced: None,
254    }
255}
256
257pub fn config_path() -> Option<PathBuf> {
258    resolve_config_path(None).attempted.into_iter().next()
259}
260
261fn push_unique(vec: &mut Vec<PathBuf>, path: PathBuf) {
262    if !vec.iter().any(|existing| existing == &path) {
263        vec.push(path);
264    }
265}