1use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32
33use serde::Serialize;
34use serde_yaml_ng::Value;
35
36use crate::diag::Diagnostics;
37use crate::yaml::YamlFile;
38
39pub const PROFILES_FILE: &str = "profiles.yml";
40
41pub const LOCAL_TYPE: &str = "local";
44
45pub static BUILTIN_LOCAL: std::sync::LazyLock<ProfileTarget> = std::sync::LazyLock::new(|| ProfileTarget {
47 kind: LOCAL_TYPE.into(),
48 fields: serde_json::Map::new(),
49});
50
51pub const OLD_CONNECTIONS_SECTION: &str = "sources";
53
54pub const DEFAULT_TARGET: &str = "dev";
56pub const TARGET_ENV: &str = "DRE_TARGET";
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61pub enum Role {
62 Connection,
63 Destination,
64}
65
66impl Role {
67 pub fn as_str(self) -> &'static str {
68 match self {
69 Role::Connection => "connection",
70 Role::Destination => "destination",
71 }
72 }
73 pub fn section(self) -> &'static str {
75 match self {
76 Role::Connection => "connections",
77 Role::Destination => "destinations",
78 }
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub enum TargetSource {
85 Flag,
86 Env,
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::Default => "default",
97 })
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct RunTarget {
104 pub name: String,
105 pub from: TargetSource,
106}
107
108impl Default for RunTarget {
109 fn default() -> Self {
110 RunTarget {
111 name: DEFAULT_TARGET.into(),
112 from: TargetSource::Default,
113 }
114 }
115}
116
117impl RunTarget {
118 pub fn resolve(flag: Option<&str>) -> RunTarget {
120 if let Some(t) = flag.filter(|t| !t.is_empty()) {
121 return RunTarget {
122 name: t.to_string(),
123 from: TargetSource::Flag,
124 };
125 }
126 if let Some(t) = std::env::var(TARGET_ENV).ok().filter(|t| !t.is_empty()) {
127 return RunTarget {
128 name: t,
129 from: TargetSource::Env,
130 };
131 }
132 RunTarget::default()
133 }
134
135 pub fn chosen(&self) -> bool {
137 self.from != TargetSource::Default
138 }
139}
140
141#[derive(Debug, Clone, Copy)]
143pub enum Entry<'a> {
144 Use(&'a ProfileTarget),
145 Nowhere,
147 Missing,
149 Unknown,
151}
152
153#[derive(Debug, Clone, Serialize)]
155pub struct ProfileTarget {
156 #[serde(rename = "type")]
157 pub kind: String,
158 #[serde(skip)]
160 pub fields: serde_json::Map<String, serde_json::Value>,
161}
162
163#[derive(Debug, Clone, Serialize)]
164pub struct Profile {
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub target: Option<String>,
168 pub targets: BTreeMap<String, ProfileTarget>,
170 #[serde(skip_serializing_if = "std::collections::BTreeSet::is_empty")]
172 pub nowhere: std::collections::BTreeSet<String>,
173}
174
175impl Profile {
176 pub fn entry_names(&self) -> Vec<String> {
178 let mut names: Vec<String> = self.targets.keys().chain(&self.nowhere).cloned().collect();
179 names.sort();
180 names
181 }
182}
183
184#[derive(Debug, Clone, Default)]
185pub struct Profiles {
186 pub path: PathBuf,
188 pub found_by: &'static str,
190 pub file: Option<YamlFile>,
192 pub connections: BTreeMap<String, Profile>,
193 pub destinations: BTreeMap<String, Profile>,
194 connections_section: &'static str,
196 pub run: RunTarget,
198}
199
200pub fn profiles_dir(cli: Option<&Path>) -> PathBuf {
203 locate(cli, None).0
204}
205
206pub fn locate(cli: Option<&Path>, project: Option<&Path>) -> (PathBuf, &'static str) {
210 if let Some(p) = cli {
211 return (p.to_path_buf(), "--profiles-dir");
212 }
213 if let Some(p) = std::env::var_os("DRE_PROFILES_DIR").filter(|p| !p.is_empty()) {
214 return (PathBuf::from(p), "DRE_PROFILES_DIR");
215 }
216 if let Some(p) = project.filter(|p| p.join(PROFILES_FILE).is_file()) {
217 return (p.to_path_buf(), "the project directory");
218 }
219 (crate::dre_home(), "~/.dre")
220}
221
222impl Profiles {
223 pub fn load(dir: &Path, found_by: &'static str, diags: &mut Diagnostics) -> Profiles {
224 let path = crate::slash(&dir.join(PROFILES_FILE));
225 let mut out = Profiles {
226 path: path.clone(),
227 found_by,
228 ..Default::default()
229 };
230 if !path.is_file() {
231 return out;
232 }
233 let Some(yf) = YamlFile::load(&path, path.clone(), diags) else {
234 out.file = Some(YamlFile {
236 display: path,
237 text: String::new(),
238 value: Value::Null,
239 });
240 return out;
241 };
242 let file = Some(path.clone());
243 match &yf.value {
244 Value::Mapping(m) => {
245 let both = m.contains_key("connections") && m.contains_key(OLD_CONNECTIONS_SECTION);
246 for (k, v) in m {
247 let key = k.as_str().unwrap_or_default();
248 let role = match key {
249 "connections" => Role::Connection,
250 OLD_CONNECTIONS_SECTION if both => {
251 diags.error(
252 "invalid-profiles",
253 file.clone(),
254 yf.line_of(key, None),
255 "profiles.yml has both `connections:` and `sources:`; `sources:` is the old name of `connections:`, so move its profiles under `connections:`",
256 );
257 continue;
258 }
259 OLD_CONNECTIONS_SECTION => {
260 diags.warning(
261 "profiles-sources-renamed",
262 file.clone(),
263 yf.line_of(key, None),
264 "`sources:` in profiles.yml is now `connections:` (DRE 0.2); rename it. `sources:` still works in 0.2.x",
265 );
266 out.connections_section = OLD_CONNECTIONS_SECTION;
267 Role::Connection
268 }
269 "destinations" => Role::Destination,
270 _ => {
271 diags.error(
272 "invalid-profiles",
273 file.clone(),
274 yf.line_of(key, None),
275 format!(
276 "unknown profiles.yml section `{key}`; profiles go under `connections:` or `destinations:`"
277 ),
278 );
279 continue;
280 }
281 };
282 let section_line = yf.line_of(key, None);
283 let parsed = match v {
284 Value::Mapping(ps) => ps
285 .iter()
286 .filter_map(|(k, v)| {
287 let name = k.as_str()?;
288 parse_profile(role, name, v, &yf, section_line, diags)
289 .map(|p| (name.to_string(), p))
290 })
291 .collect(),
292 Value::Null => BTreeMap::new(),
293 _ => {
294 diags.error(
295 "invalid-profiles",
296 file.clone(),
297 section_line,
298 format!("`{key}` must be a map of profile names"),
299 );
300 BTreeMap::new()
301 }
302 };
303 match role {
304 Role::Connection => out.connections = parsed,
305 Role::Destination => out.destinations = parsed,
306 }
307 }
308 }
309 Value::Null => {}
310 _ => diags.error(
311 "invalid-profiles",
312 file,
313 None,
314 "profiles.yml must be a map with `connections:` and/or `destinations:`",
315 ),
316 }
317 out.file = Some(yf);
318 out
319 }
320
321 pub fn exists(&self) -> bool {
322 self.file.is_some()
323 }
324
325 fn section(&self, role: Role) -> &BTreeMap<String, Profile> {
326 match role {
327 Role::Connection => &self.connections,
328 Role::Destination => &self.destinations,
329 }
330 }
331
332 pub fn section_key(&self, role: Role) -> &'static str {
334 match role {
335 Role::Connection if self.connections_section == OLD_CONNECTIONS_SECTION => {
336 OLD_CONNECTIONS_SECTION
337 }
338 r => r.section(),
339 }
340 }
341
342 pub fn get(&self, role: Role, name: &str) -> Option<&Profile> {
343 self.section(role).get(name)
344 }
345
346 pub fn declares(&self, role: Role, name: &str) -> bool {
348 self.file
349 .as_ref()
350 .and_then(|f| f.value.get(self.section_key(role)))
351 .is_some_and(|s| s.get(name).is_some())
352 }
353
354 pub fn is_builtin_local(&self, name: &str) -> bool {
356 name == LOCAL_TYPE && !self.destinations.contains_key(name)
357 }
358
359 pub fn line_of(&self, role: Role, name: &str) -> Option<usize> {
361 let f = self.file.as_ref()?;
362 f.line_of(name, f.line_of(self.section_key(role), None))
363 }
364
365 pub fn target_of(&self, role: Role, profile: &str) -> String {
368 if self.run.chosen() {
369 return self.run.name.clone();
370 }
371 self.get(role, profile)
372 .and_then(|p| p.target.clone())
373 .unwrap_or_else(|| self.run.name.clone())
374 }
375
376 pub fn entry(&self, role: Role, profile: &str) -> Entry<'_> {
379 if role == Role::Destination && self.is_builtin_local(profile) {
380 return Entry::Use(&BUILTIN_LOCAL);
381 }
382 let Some(p) = self.get(role, profile) else {
383 return Entry::Unknown;
384 };
385 let t = self.target_of(role, profile);
386 match p.targets.get(&t) {
387 Some(o) => Entry::Use(o),
388 None if p.nowhere.contains(&t) => Entry::Nowhere,
389 None => Entry::Missing,
390 }
391 }
392
393 pub fn target(&self, role: Role, profile: &str) -> Option<&ProfileTarget> {
395 match self.entry(role, profile) {
396 Entry::Use(o) => Some(o),
397 _ => None,
398 }
399 }
400
401 pub fn missing_entry(&self, role: Role, profile: &str) -> String {
403 let t = self.target_of(role, profile);
404 let has = self
405 .get(role, profile)
406 .map(|p| p.entry_names().join(", "))
407 .unwrap_or_default();
408 let why = match self.run.from {
409 TargetSource::Default if self.get(role, profile).is_some_and(|p| p.target.is_some()) => {
410 "its `target:`".to_string()
411 }
412 TargetSource::Default => "the default".to_string(),
413 from => from.to_string(),
414 };
415 let mut msg = format!(
416 "{} `{profile}` has no `{t}` entry (it has: {has}); `{t}` comes from {why}",
417 role.as_str()
418 );
419 if role == Role::Destination {
420 msg.push_str(&format!(
421 ". To deliver nowhere on `{t}`, add `{t}: {{deliver: false}}` to its `targets:`"
422 ));
423 }
424 msg
425 }
426}
427
428fn parse_profile(
429 role: Role,
430 name: &str,
431 v: &Value,
432 yf: &YamlFile,
433 section_line: Option<usize>,
434 diags: &mut Diagnostics,
435) -> Option<Profile> {
436 let file = Some(yf.display.clone());
437 let line = yf.line_of(name, section_line);
438 let what = format!("{} profile `{name}`", role.as_str());
439 let Some(m) = v.as_mapping() else {
440 diags.error(
441 "invalid-profile",
442 file,
443 line,
444 format!("{what} must be a map with `targets`"),
445 );
446 return None;
447 };
448 let own_target = match m.get("target") {
449 None => None,
450 Some(Value::String(t)) if !t.trim().is_empty() => Some(t.clone()),
451 Some(_) => {
452 diags.error(
453 "invalid-profile",
454 file.clone(),
455 yf.line_of("target", line),
456 format!("{what}: `target` must be the name of one of its `targets`"),
457 );
458 return None;
459 }
460 };
461 let Some(targets) = m.get("targets").and_then(Value::as_mapping) else {
462 let hint = if m.contains_key("outputs") {
463 " (`outputs:` is now `targets:`)"
464 } else {
465 ""
466 };
467 diags.error(
468 "invalid-profile",
469 file,
470 line,
471 format!("{what} needs a map of named `targets`{hint}"),
472 );
473 return None;
474 };
475 let mut parsed = BTreeMap::new();
476 let mut nowhere = std::collections::BTreeSet::new();
477 let mut ok = true;
478 for (k, o) in targets {
479 let Some(tname) = k.as_str() else { continue };
480 if let Some(d) = o.get("deliver") {
481 let problem = if role == Role::Connection {
482 Some("`deliver: false` is only for destinations; a connection entry needs a `type`")
483 } else if d != &Value::Bool(false) {
484 Some("`deliver` can only be `false` (an entry that delivers just has a `type`)")
485 } else if o.as_mapping().is_some_and(|m| m.len() > 1) {
486 Some("`deliver: false` takes no other settings: the entry delivers nowhere")
487 } else {
488 None
489 };
490 match problem {
491 Some(p) => {
492 diags.error(
493 "invalid-profile",
494 file.clone(),
495 yf.line_of(tname, line),
496 format!("target `{tname}` of {what}: {p}"),
497 );
498 ok = false;
499 }
500 None => {
501 nowhere.insert(tname.to_string());
502 }
503 }
504 continue;
505 }
506 let kind = o.get("type").and_then(Value::as_str);
507 let Some(kind) = kind else {
508 diags.error(
509 "invalid-profile",
510 file.clone(),
511 yf.line_of(tname, line),
512 format!("target `{tname}` of {what} has no `type`"),
513 );
514 ok = false;
515 continue;
516 };
517 let fields = match serde_json::to_value(o) {
518 Ok(serde_json::Value::Object(mut f)) => {
519 f.remove("type");
520 f
521 }
522 _ => serde_json::Map::new(),
523 };
524 parsed.insert(
525 tname.to_string(),
526 ProfileTarget {
527 kind: kind.to_string(),
528 fields,
529 },
530 );
531 }
532 if let Some(t) = &own_target
533 && ok
534 && !parsed.contains_key(t)
535 && !nowhere.contains(t)
536 {
537 let mut has: Vec<&String> = parsed.keys().chain(&nowhere).collect();
538 has.sort();
539 diags.error(
540 "invalid-profile",
541 file.clone(),
542 yf.line_of("target", line),
543 format!(
544 "{what}: `target: {t}` isn't one of its targets ({})",
545 has.into_iter().cloned().collect::<Vec<_>>().join(", ")
546 ),
547 );
548 return None;
549 }
550 ok.then_some(Profile {
551 target: own_target,
552 targets: parsed,
553 nowhere,
554 })
555}