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