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 #[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 #[serde(default)]
60 pub default_profile: Option<DefaultProfileSelection>,
61 #[serde(default)]
63 pub profiles: HashMap<String, DistributorProfileConfig>,
64 #[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 #[serde(default)]
90 pub name: Option<String>,
91 #[serde(default)]
93 pub base_url: Option<String>,
94 #[serde(default)]
96 pub url: Option<String>,
97 #[serde(default)]
99 pub token: Option<String>,
100 #[serde(default)]
102 pub tenant_id: Option<String>,
103 #[serde(default)]
105 pub environment_id: Option<String>,
106 #[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}