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 #[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 #[serde(default)]
64 pub default_profile: Option<DefaultProfileSelection>,
65 #[serde(default)]
67 pub profiles: HashMap<String, DistributorProfileConfig>,
68 #[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 #[serde(default)]
94 pub name: Option<String>,
95 #[serde(default)]
97 pub base_url: Option<String>,
98 #[serde(default)]
100 pub url: Option<String>,
101 #[serde(default)]
103 pub token: Option<String>,
104 #[serde(default)]
106 pub tenant_id: Option<String>,
107 #[serde(default)]
109 pub environment_id: Option<String>,
110 #[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}