1use std::collections::BTreeMap;
30use std::path::{Path, PathBuf};
31
32use serde::Serialize;
33use serde_yaml_ng::Value;
34
35use crate::diag::Diagnostics;
36use crate::yaml::YamlFile;
37
38pub const PROFILES_FILE: &str = "profiles.yml";
39
40pub const LOCAL_TYPE: &str = "local";
43
44pub static BUILTIN_LOCAL: std::sync::LazyLock<ProfileTarget> = std::sync::LazyLock::new(|| ProfileTarget {
46 kind: LOCAL_TYPE.into(),
47 fields: serde_json::Map::new(),
48});
49
50pub const OLD_CONNECTIONS_SECTION: &str = "sources";
52
53pub const DEFAULT_TARGET: &str = "dev";
55pub const TARGET_ENV: &str = "DRE_TARGET";
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
60pub enum Role {
61 Connection,
62 Destination,
63}
64
65impl Role {
66 pub fn as_str(self) -> &'static str {
67 match self {
68 Role::Connection => "connection",
69 Role::Destination => "destination",
70 }
71 }
72 pub fn section(self) -> &'static str {
74 match self {
75 Role::Connection => "connections",
76 Role::Destination => "destinations",
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub enum TargetSource {
84 Flag,
85 Env,
86 Project,
87 #[default]
88 Default,
89}
90
91impl std::fmt::Display for TargetSource {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.write_str(match self {
94 TargetSource::Flag => "--target",
95 TargetSource::Env => TARGET_ENV,
96 TargetSource::Project => "`target` in dre_project.yml",
97 TargetSource::Default => "the default",
98 })
99 }
100}
101
102pub fn resolve_target(flag: Option<&str>, project: Option<&str>) -> (String, TargetSource) {
104 if let Some(t) = flag.filter(|t| !t.is_empty()) {
105 return (t.to_string(), TargetSource::Flag);
106 }
107 if let Some(t) = std::env::var(TARGET_ENV).ok().filter(|t| !t.is_empty()) {
108 return (t, TargetSource::Env);
109 }
110 if let Some(t) = project.filter(|t| !t.is_empty()) {
111 return (t.to_string(), TargetSource::Project);
112 }
113 (DEFAULT_TARGET.to_string(), TargetSource::Default)
114}
115
116#[derive(Debug, Clone, Serialize)]
118pub struct ProfileTarget {
119 #[serde(rename = "type")]
120 pub kind: String,
121 #[serde(skip)]
123 pub fields: serde_json::Map<String, serde_json::Value>,
124}
125
126#[derive(Debug, Clone, Serialize)]
127pub struct Profile {
128 pub targets: BTreeMap<String, ProfileTarget>,
129}
130
131#[derive(Debug, Clone, Default)]
132pub struct Profiles {
133 pub path: PathBuf,
135 pub found_by: &'static str,
137 pub file: Option<YamlFile>,
139 pub connections: BTreeMap<String, Profile>,
140 pub destinations: BTreeMap<String, Profile>,
141 connections_section: &'static str,
143}
144
145pub fn profiles_dir(cli: Option<&Path>) -> PathBuf {
148 locate(cli, None).0
149}
150
151pub fn locate(cli: Option<&Path>, project: Option<&Path>) -> (PathBuf, &'static str) {
155 if let Some(p) = cli {
156 return (p.to_path_buf(), "--profiles-dir");
157 }
158 if let Some(p) = std::env::var_os("DRE_PROFILES_DIR").filter(|p| !p.is_empty()) {
159 return (PathBuf::from(p), "DRE_PROFILES_DIR");
160 }
161 if let Some(p) = project.filter(|p| p.join(PROFILES_FILE).is_file()) {
162 return (p.to_path_buf(), "the project directory");
163 }
164 (crate::dre_home(), "~/.dre")
165}
166
167impl Profiles {
168 pub fn load(dir: &Path, found_by: &'static str, diags: &mut Diagnostics) -> Profiles {
169 let path = crate::slash(&dir.join(PROFILES_FILE));
170 let mut out = Profiles {
171 path: path.clone(),
172 found_by,
173 ..Default::default()
174 };
175 if !path.is_file() {
176 return out;
177 }
178 let Some(yf) = YamlFile::load(&path, path.clone(), diags) else {
179 out.file = Some(YamlFile {
181 display: path,
182 text: String::new(),
183 value: Value::Null,
184 });
185 return out;
186 };
187 let file = Some(path.clone());
188 match &yf.value {
189 Value::Mapping(m) => {
190 let both = m.contains_key("connections") && m.contains_key(OLD_CONNECTIONS_SECTION);
191 for (k, v) in m {
192 let key = k.as_str().unwrap_or_default();
193 let role = match key {
194 "connections" => Role::Connection,
195 OLD_CONNECTIONS_SECTION if both => {
196 diags.error(
197 "invalid-profiles",
198 file.clone(),
199 yf.line_of(key, None),
200 "profiles.yml has both `connections:` and `sources:`; `sources:` is the old name of `connections:`, so move its profiles under `connections:`",
201 );
202 continue;
203 }
204 OLD_CONNECTIONS_SECTION => {
205 diags.warning(
206 "profiles-sources-renamed",
207 file.clone(),
208 yf.line_of(key, None),
209 "`sources:` in profiles.yml is now `connections:` (DRE 0.2); rename it. `sources:` still works in 0.2.x",
210 );
211 out.connections_section = OLD_CONNECTIONS_SECTION;
212 Role::Connection
213 }
214 "destinations" => Role::Destination,
215 _ => {
216 diags.error(
217 "invalid-profiles",
218 file.clone(),
219 yf.line_of(key, None),
220 format!(
221 "unknown profiles.yml section `{key}`; profiles go under `connections:` or `destinations:`"
222 ),
223 );
224 continue;
225 }
226 };
227 let section_line = yf.line_of(key, None);
228 let parsed = match v {
229 Value::Mapping(ps) => ps
230 .iter()
231 .filter_map(|(k, v)| {
232 let name = k.as_str()?;
233 parse_profile(role, name, v, &yf, section_line, diags)
234 .map(|p| (name.to_string(), p))
235 })
236 .collect(),
237 Value::Null => BTreeMap::new(),
238 _ => {
239 diags.error(
240 "invalid-profiles",
241 file.clone(),
242 section_line,
243 format!("`{key}` must be a map of profile names"),
244 );
245 BTreeMap::new()
246 }
247 };
248 match role {
249 Role::Connection => out.connections = parsed,
250 Role::Destination => out.destinations = parsed,
251 }
252 }
253 }
254 Value::Null => {}
255 _ => diags.error(
256 "invalid-profiles",
257 file,
258 None,
259 "profiles.yml must be a map with `connections:` and/or `destinations:`",
260 ),
261 }
262 out.file = Some(yf);
263 out
264 }
265
266 pub fn exists(&self) -> bool {
267 self.file.is_some()
268 }
269
270 fn section(&self, role: Role) -> &BTreeMap<String, Profile> {
271 match role {
272 Role::Connection => &self.connections,
273 Role::Destination => &self.destinations,
274 }
275 }
276
277 pub fn section_key(&self, role: Role) -> &'static str {
279 match role {
280 Role::Connection if self.connections_section == OLD_CONNECTIONS_SECTION => {
281 OLD_CONNECTIONS_SECTION
282 }
283 r => r.section(),
284 }
285 }
286
287 pub fn get(&self, role: Role, name: &str) -> Option<&Profile> {
288 self.section(role).get(name)
289 }
290
291 pub fn declares(&self, role: Role, name: &str) -> bool {
293 self.file
294 .as_ref()
295 .and_then(|f| f.value.get(self.section_key(role)))
296 .is_some_and(|s| s.get(name).is_some())
297 }
298
299 pub fn is_builtin_local(&self, name: &str) -> bool {
301 name == LOCAL_TYPE && !self.destinations.contains_key(name)
302 }
303
304 pub fn line_of(&self, role: Role, name: &str) -> Option<usize> {
306 let f = self.file.as_ref()?;
307 f.line_of(name, f.line_of(self.section_key(role), None))
308 }
309
310 pub fn target(&self, role: Role, profile: &str, target: &str) -> Option<&ProfileTarget> {
312 self.get(role, profile)?.targets.get(target)
313 }
314}
315
316fn parse_profile(
317 role: Role,
318 name: &str,
319 v: &Value,
320 yf: &YamlFile,
321 section_line: Option<usize>,
322 diags: &mut Diagnostics,
323) -> Option<Profile> {
324 let file = Some(yf.display.clone());
325 let line = yf.line_of(name, section_line);
326 let what = format!("{} profile `{name}`", role.as_str());
327 let Some(m) = v.as_mapping() else {
328 diags.error(
329 "invalid-profile",
330 file,
331 line,
332 format!("{what} must be a map with `targets`"),
333 );
334 return None;
335 };
336 if m.contains_key("target") {
337 diags.warning(
338 "profile-target-ignored",
339 file.clone(),
340 yf.line_of("target", line),
341 format!(
342 "{what}: a profile's own `target:` is ignored since DRE 0.2; the run picks one target for every profile (--target, DRE_TARGET, `target:` in dre_project.yml, else `dev`). Remove it"
343 ),
344 );
345 }
346 let Some(targets) = m.get("targets").and_then(Value::as_mapping) else {
347 let hint = if m.contains_key("outputs") {
348 " (`outputs:` is now `targets:`)"
349 } else {
350 ""
351 };
352 diags.error(
353 "invalid-profile",
354 file,
355 line,
356 format!("{what} needs a map of named `targets`{hint}"),
357 );
358 return None;
359 };
360 let mut parsed = BTreeMap::new();
361 let mut ok = true;
362 for (k, o) in targets {
363 let Some(tname) = k.as_str() else { continue };
364 let kind = o.get("type").and_then(Value::as_str);
365 let Some(kind) = kind else {
366 diags.error(
367 "invalid-profile",
368 file.clone(),
369 yf.line_of(tname, line),
370 format!("target `{tname}` of {what} has no `type`"),
371 );
372 ok = false;
373 continue;
374 };
375 let fields = match serde_json::to_value(o) {
376 Ok(serde_json::Value::Object(mut f)) => {
377 f.remove("type");
378 f
379 }
380 _ => serde_json::Map::new(),
381 };
382 parsed.insert(
383 tname.to_string(),
384 ProfileTarget {
385 kind: kind.to_string(),
386 fields,
387 },
388 );
389 }
390 ok.then_some(Profile { targets: parsed })
391}