1use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32
33use schemars::JsonSchema;
34use serde::{Deserialize, Serialize};
35
36use crate::codes::Code;
37use crate::config::de::{self, Found, Located, Loose, Map, UnknownKeys};
38use crate::config::node;
39use crate::diag::Diagnostics;
40
41pub const PROFILES_FILE: &str = "profiles.yml";
42
43pub const LOCAL_TYPE: &str = "local";
46
47pub static BUILTIN_LOCAL: std::sync::LazyLock<ProfileTarget> = std::sync::LazyLock::new(|| ProfileTarget {
49 kind: LOCAL_TYPE.into(),
50 fields: serde_json::Map::new(),
51});
52
53pub const OLD_CONNECTIONS_SECTION: &str = "sources";
55
56pub const DEFAULT_TARGET: &str = "dev";
58pub const TARGET_ENV: &str = crate::settings::TARGET;
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
63pub enum Role {
64 Connection,
65 Destination,
66}
67
68impl Role {
69 pub fn as_str(self) -> &'static str {
70 match self {
71 Role::Connection => "connection",
72 Role::Destination => "destination",
73 }
74 }
75 pub fn section(self) -> &'static str {
77 match self {
78 Role::Connection => "connections",
79 Role::Destination => "destinations",
80 }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum TargetSource {
87 Flag,
88 Env,
89 #[default]
90 Default,
91}
92
93impl std::fmt::Display for TargetSource {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.write_str(match self {
96 TargetSource::Flag => "--target",
97 TargetSource::Env => TARGET_ENV,
98 TargetSource::Default => "default",
99 })
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct RunTarget {
106 pub name: String,
107 pub from: TargetSource,
108}
109
110impl Default for RunTarget {
111 fn default() -> Self {
112 RunTarget {
113 name: DEFAULT_TARGET.into(),
114 from: TargetSource::Default,
115 }
116 }
117}
118
119impl RunTarget {
120 pub fn resolve(flag: Option<&str>) -> RunTarget {
122 if let Some(t) = flag.filter(|t| !t.is_empty()) {
123 return RunTarget {
124 name: t.to_string(),
125 from: TargetSource::Flag,
126 };
127 }
128 if let Some(t) = crate::settings::env(TARGET_ENV) {
129 return RunTarget {
130 name: t,
131 from: TargetSource::Env,
132 };
133 }
134 RunTarget::default()
135 }
136
137 pub fn chosen(&self) -> bool {
139 self.from != TargetSource::Default
140 }
141}
142
143#[derive(Debug, Clone, Copy)]
145pub enum Entry<'a> {
146 Use(&'a ProfileTarget),
147 Nowhere,
149 Missing,
151 Unknown,
153}
154
155#[derive(Debug, Clone, Serialize)]
157pub struct ProfileTarget {
158 #[serde(rename = "type")]
159 pub kind: String,
160 #[serde(skip)]
162 pub fields: serde_json::Map<String, serde_json::Value>,
163}
164
165#[derive(Debug, Clone, Serialize)]
166pub struct Profile {
167 #[serde(skip_serializing_if = "Option::is_none")]
169 pub target: Option<String>,
170 pub targets: BTreeMap<String, ProfileTarget>,
172 #[serde(skip_serializing_if = "std::collections::BTreeSet::is_empty")]
174 pub nowhere: std::collections::BTreeSet<String>,
175}
176
177impl Profile {
178 pub fn entry_names(&self) -> Vec<String> {
180 let mut names: Vec<String> = self.targets.keys().chain(&self.nowhere).cloned().collect();
181 names.sort();
182 names
183 }
184}
185
186#[derive(Debug, Clone, Default)]
187pub struct Profiles {
188 pub path: PathBuf,
190 pub found_by: &'static str,
192 pub file: Option<PathBuf>,
195 declared: BTreeMap<Role, BTreeMap<String, usize>>,
197 pub connections: BTreeMap<String, Profile>,
198 pub destinations: BTreeMap<String, Profile>,
199 connections_section: &'static str,
201 pub run: RunTarget,
203}
204
205pub fn profiles_dir(cli: Option<&Path>) -> PathBuf {
208 locate(cli, None).0
209}
210
211pub fn locate(cli: Option<&Path>, project: Option<&Path>) -> (PathBuf, &'static str) {
215 if let Some(p) = cli {
216 return (p.to_path_buf(), "--profiles-dir");
217 }
218 if let Some(p) = crate::settings::env(crate::settings::PROFILES_DIR) {
219 return (PathBuf::from(p), "DRE_PROFILES_DIR");
220 }
221 if let Some(p) = project.filter(|p| p.join(PROFILES_FILE).is_file()) {
222 return (p.to_path_buf(), "the project directory");
223 }
224 (crate::dre_home(), "~/.dre")
225}
226
227impl Profiles {
228 pub fn load(dir: &Path, found_by: &'static str, diags: &mut Diagnostics) -> Profiles {
229 let path = crate::slash(&dir.join(PROFILES_FILE));
230 let mut out = Profiles {
231 path: path.clone(),
232 found_by,
233 ..Default::default()
234 };
235 if !path.is_file() {
236 return out;
237 }
238 out.file = Some(path.clone());
240 let text = match std::fs::read_to_string(&path) {
241 Ok(t) => t,
242 Err(e) => {
243 diags.error(Code::IoError, Some(path), None, format!("cannot read file: {e}"));
244 return out;
245 }
246 };
247 let tree = match node::parse(&text) {
248 Ok(n) => n,
249 Err(e) => {
250 diags.error(
251 Code::YamlSyntax,
252 Some(path),
253 e.line,
254 format!("invalid YAML: {}", e.message),
255 );
256 return out;
257 }
258 };
259 let file = Some(path.clone());
260 let raw: Loose<ProfilesFile> = match de::from_node(&tree) {
261 Ok(r) => r,
262 Err(e) => {
263 diags.error(Code::InvalidProfiles, file, e.line, e.message);
264 return out;
265 }
266 };
267 let raw = match raw {
268 Loose::Ok(r) => r,
269 Loose::Bad(Found { kind: "nothing", .. }) => return out,
270 Loose::Bad(_) => {
271 diags.error(
272 Code::InvalidProfiles,
273 file,
274 None,
275 "profiles.yml must be a map with `connections:` and/or `destinations:`",
276 );
277 return out;
278 }
279 };
280 for k in &raw.unknown.0 {
281 diags.error(
282 Code::InvalidProfiles,
283 file.clone(),
284 Some(k.line),
285 format!(
286 "unknown profiles.yml section `{}`; profiles go under `connections:` or `destinations:`",
287 k.name
288 ),
289 );
290 }
291 let connections = match (raw.connections, raw.sources) {
292 (Some(c), Some(s)) => {
293 diags.error(
294 Code::InvalidProfiles,
295 file.clone(),
296 s.line(),
297 "profiles.yml has both `connections:` and `sources:`; `sources:` is the old name of `connections:`, so move its profiles under `connections:`",
298 );
299 Some(("connections", c))
300 }
301 (Some(c), None) => Some(("connections", c)),
302 (None, Some(s)) => {
303 diags.warning(
304 Code::ProfilesSourcesRenamed,
305 file.clone(),
306 s.line(),
307 "`sources:` in profiles.yml is now `connections:` (DRE 0.2); rename it. `sources:` still works in 0.2.x",
308 );
309 out.connections_section = OLD_CONNECTIONS_SECTION;
310 Some((OLD_CONNECTIONS_SECTION, s))
311 }
312 (None, None) => None,
313 };
314 if let Some((key, section)) = connections {
315 let (parsed, declared) = parse_section(Role::Connection, key, section, &path, diags);
316 out.connections = parsed;
317 out.declared.insert(Role::Connection, declared);
318 }
319 if let Some(section) = raw.destinations {
320 let (parsed, declared) = parse_section(Role::Destination, "destinations", section, &path, diags);
321 out.destinations = parsed;
322 out.declared.insert(Role::Destination, declared);
323 }
324 out
325 }
326
327 pub fn exists(&self) -> bool {
328 self.file.is_some()
329 }
330
331 fn section(&self, role: Role) -> &BTreeMap<String, Profile> {
332 match role {
333 Role::Connection => &self.connections,
334 Role::Destination => &self.destinations,
335 }
336 }
337
338 pub fn section_key(&self, role: Role) -> &'static str {
340 match role {
341 Role::Connection if self.connections_section == OLD_CONNECTIONS_SECTION => {
342 OLD_CONNECTIONS_SECTION
343 }
344 r => r.section(),
345 }
346 }
347
348 pub fn get(&self, role: Role, name: &str) -> Option<&Profile> {
349 self.section(role).get(name)
350 }
351
352 pub fn declares(&self, role: Role, name: &str) -> bool {
354 self.declared.get(&role).is_some_and(|s| s.contains_key(name))
355 }
356
357 pub fn is_builtin_local(&self, name: &str) -> bool {
359 name == LOCAL_TYPE && !self.destinations.contains_key(name)
360 }
361
362 pub fn line_of(&self, role: Role, name: &str) -> Option<usize> {
364 self.declared.get(&role)?.get(name).copied()
365 }
366
367 pub fn target_of(&self, role: Role, profile: &str) -> String {
370 if self.run.chosen() {
371 return self.run.name.clone();
372 }
373 self.get(role, profile)
374 .and_then(|p| p.target.clone())
375 .unwrap_or_else(|| self.run.name.clone())
376 }
377
378 pub fn entry(&self, role: Role, profile: &str) -> Entry<'_> {
381 if role == Role::Destination && self.is_builtin_local(profile) {
382 return Entry::Use(&BUILTIN_LOCAL);
383 }
384 let Some(p) = self.get(role, profile) else {
385 return Entry::Unknown;
386 };
387 let t = self.target_of(role, profile);
388 match p.targets.get(&t) {
389 Some(o) => Entry::Use(o),
390 None if p.nowhere.contains(&t) => Entry::Nowhere,
391 None => Entry::Missing,
392 }
393 }
394
395 pub fn target(&self, role: Role, profile: &str) -> Option<&ProfileTarget> {
397 match self.entry(role, profile) {
398 Entry::Use(o) => Some(o),
399 _ => None,
400 }
401 }
402
403 pub fn missing_entry(&self, role: Role, profile: &str) -> String {
405 let t = self.target_of(role, profile);
406 let has = self
407 .get(role, profile)
408 .map(|p| p.entry_names().join(", "))
409 .unwrap_or_default();
410 let why = match self.run.from {
411 TargetSource::Default if self.get(role, profile).is_some_and(|p| p.target.is_some()) => {
412 "its `target:`".to_string()
413 }
414 TargetSource::Default => "the default".to_string(),
415 from => from.to_string(),
416 };
417 let mut msg = format!(
418 "{} `{profile}` has no `{t}` entry (it has: {has}); `{t}` comes from {why}",
419 role.as_str()
420 );
421 if role == Role::Destination {
422 msg.push_str(&format!(
423 ". To deliver nowhere on `{t}`, add `{t}: {{deliver: false}}` to its `targets:`"
424 ));
425 }
426 msg
427 }
428}
429
430fn parse_section<P: Into<RawProfile>>(
432 role: Role,
433 key: &str,
434 section: Section<P>,
435 path: &Path,
436 diags: &mut Diagnostics,
437) -> (BTreeMap<String, Profile>, BTreeMap<String, usize>) {
438 let Loose::Ok(profiles) = section.value else {
439 diags.error(
440 Code::InvalidProfiles,
441 Some(path.to_path_buf()),
442 section.line(),
443 format!("`{key}` must be a map of profile names"),
444 );
445 return Default::default();
446 };
447 let declared = profiles.iter().map(|(k, _)| (k.value.clone(), k.line)).collect();
448 let parsed = profiles
449 .0
450 .into_iter()
451 .filter_map(|(name, p)| {
452 parse_profile(role, &name, p.map(|p| p.map(Into::into)), path, diags).map(|p| (name.value, p))
453 })
454 .collect();
455 (parsed, declared)
456}
457
458fn parse_profile(
459 role: Role,
460 name: &Located<String>,
461 v: Located<Loose<RawProfile>>,
462 path: &Path,
463 diags: &mut Diagnostics,
464) -> Option<Profile> {
465 let file = Some(path.to_path_buf());
466 let line = name.line();
467 let what = format!("{} profile `{}`", role.as_str(), name.value);
468 let Loose::Ok(m) = v.value else {
469 diags.error(
470 Code::InvalidProfile,
471 file,
472 line,
473 format!("{what} must be a map with `targets`"),
474 );
475 return None;
476 };
477 let target_line = m.target.as_ref().and_then(Located::line);
478 let own_target = match m.target {
479 None => None,
480 Some(Located {
481 value: Loose::Ok(t), ..
482 }) if !t.trim().is_empty() => Some(t),
483 Some(t) => {
484 diags.error(
485 Code::InvalidProfile,
486 file.clone(),
487 t.line(),
488 format!("{what}: `target` must be the name of one of its `targets`"),
489 );
490 return None;
491 }
492 };
493 let Some(Loose::Ok(targets)) = m.targets.map(|t| t.value) else {
494 let hint = if m.outputs.is_some() {
495 " (`outputs:` is now `targets:`)"
496 } else {
497 ""
498 };
499 diags.error(
500 Code::InvalidProfile,
501 file,
502 line,
503 format!("{what} needs a map of named `targets`{hint}"),
504 );
505 return None;
506 };
507 let mut parsed = BTreeMap::new();
508 let mut nowhere = std::collections::BTreeSet::new();
509 let mut ok = true;
510 for (tname, o) in targets.0 {
511 let tline = tname.line();
512 let tname = tname.value;
513 let o = o.value;
514 if let Some(d) = o.get("deliver") {
515 let problem = if role == Role::Connection {
516 Some("`deliver: false` is only for destinations; a connection entry needs a `type`")
517 } else if d != &serde_json::Value::Bool(false) {
518 Some("`deliver` can only be `false` (an entry that delivers just has a `type`)")
519 } else if o.as_object().is_some_and(|m| m.len() > 1) {
520 Some("`deliver: false` takes no other settings: the entry delivers nowhere")
521 } else {
522 None
523 };
524 match problem {
525 Some(p) => {
526 diags.error(
527 Code::InvalidProfile,
528 file.clone(),
529 tline,
530 format!("target `{tname}` of {what}: {p}"),
531 );
532 ok = false;
533 }
534 None => {
535 nowhere.insert(tname);
536 }
537 }
538 continue;
539 }
540 let Some(kind) = o.get("type").and_then(serde_json::Value::as_str) else {
541 diags.error(
542 Code::InvalidProfile,
543 file.clone(),
544 tline,
545 format!("target `{tname}` of {what} has no `type`"),
546 );
547 ok = false;
548 continue;
549 };
550 let kind = kind.to_string();
551 let fields = match o {
552 serde_json::Value::Object(mut f) => {
553 f.remove("type");
554 f
555 }
556 _ => serde_json::Map::new(),
557 };
558 parsed.insert(tname, ProfileTarget { kind, fields });
559 }
560 if let Some(t) = &own_target
561 && ok
562 && !parsed.contains_key(t)
563 && !nowhere.contains(t)
564 {
565 let mut has: Vec<&String> = parsed.keys().chain(&nowhere).collect();
566 has.sort();
567 diags.error(
568 Code::InvalidProfile,
569 file.clone(),
570 target_line,
571 format!(
572 "{what}: `target: {t}` isn't one of its targets ({})",
573 has.into_iter().cloned().collect::<Vec<_>>().join(", ")
574 ),
575 );
576 return None;
577 }
578 ok.then_some(Profile {
579 target: own_target,
580 targets: parsed,
581 nowhere,
582 })
583}
584
585type Section<P> = Located<Loose<Map<Located<Loose<P>>>>>;
591
592#[derive(Deserialize, JsonSchema)]
594#[schemars(title = "DRE profiles", deny_unknown_fields)]
595#[schemars(extend("not" = {"required": ["connections", "sources"]}))]
596pub struct ProfilesFile {
597 connections: Option<Section<ConnectionProfile>>,
599 #[schemars(extend("deprecated" = true))]
601 sources: Option<Section<ConnectionProfile>>,
602 destinations: Option<Section<DestinationProfile>>,
604 #[serde(rename = "$unknown", default)]
605 #[schemars(skip)]
606 unknown: UnknownKeys,
607}
608
609#[derive(Deserialize, JsonSchema)]
611#[schemars(rename = "connection", deny_unknown_fields)]
612pub struct ConnectionProfile {
613 target: Option<Located<Loose<String>>>,
615 #[schemars(required, extend("minProperties" = 1))]
617 targets: Option<Located<Loose<Map<Located<ConnectionEntry>>>>>,
618 #[schemars(skip)]
620 outputs: Option<serde::de::IgnoredAny>,
621}
622
623#[derive(Deserialize, JsonSchema)]
625#[schemars(rename = "destination", deny_unknown_fields)]
626pub struct DestinationProfile {
627 #[schemars(required, extend("minProperties" = 1))]
629 targets: Option<Located<Loose<Map<Located<DestinationEntry>>>>>,
630 target: Option<Located<Loose<String>>>,
632 #[schemars(skip)]
633 outputs: Option<serde::de::IgnoredAny>,
634}
635
636pub struct RawProfile {
638 target: Option<Located<Loose<String>>>,
639 targets: Option<Located<Loose<Map<Located<serde_json::Value>>>>>,
640 outputs: Option<serde::de::IgnoredAny>,
641}
642
643fn entries<E: Into<serde_json::Value>>(
644 t: Option<Located<Loose<Map<Located<E>>>>>,
645) -> Option<Located<Loose<Map<Located<serde_json::Value>>>>> {
646 t.map(|t| {
647 t.map(|t| match t {
648 Loose::Ok(m) => Loose::Ok(Map(m
649 .0
650 .into_iter()
651 .map(|(k, v)| (k, v.map(Into::into)))
652 .collect())),
653 Loose::Bad(f) => Loose::Bad(f),
654 })
655 })
656}
657
658impl From<ConnectionProfile> for RawProfile {
659 fn from(p: ConnectionProfile) -> RawProfile {
660 RawProfile {
661 target: p.target,
662 targets: entries(p.targets),
663 outputs: p.outputs,
664 }
665 }
666}
667
668impl From<DestinationProfile> for RawProfile {
669 fn from(p: DestinationProfile) -> RawProfile {
670 RawProfile {
671 target: p.target,
672 targets: entries(p.targets),
673 outputs: p.outputs,
674 }
675 }
676}
677
678#[derive(Deserialize)]
680#[serde(transparent)]
681pub struct ConnectionEntry(serde_json::Value);
682
683#[derive(Deserialize)]
685#[serde(transparent)]
686pub struct DestinationEntry(serde_json::Value);
687
688impl From<ConnectionEntry> for serde_json::Value {
689 fn from(e: ConnectionEntry) -> Self {
690 e.0
691 }
692}
693
694impl From<DestinationEntry> for serde_json::Value {
695 fn from(e: DestinationEntry) -> Self {
696 e.0
697 }
698}
699
700impl JsonSchema for ConnectionEntry {
701 fn schema_name() -> std::borrow::Cow<'static, str> {
702 "ConnectionEntry".into()
703 }
704 fn inline_schema() -> bool {
705 true
706 }
707 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
708 g.subschema_for::<TargetEntry>()
709 }
710}
711
712impl JsonSchema for DestinationEntry {
713 fn schema_name() -> std::borrow::Cow<'static, str> {
714 "DestinationEntry".into()
715 }
716 fn inline_schema() -> bool {
717 true
718 }
719 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
720 schemars::json_schema!({
721 "oneOf": [g.subschema_for::<TargetEntry>(), g.subschema_for::<NoDelivery>()]
722 })
723 }
724}
725
726#[derive(JsonSchema)]
728#[schemars(rename = "target", extend("additionalProperties" = true, "not" = {"required": ["deliver"]}))]
729#[allow(dead_code)]
730struct TargetEntry {
731 #[serde(rename = "type")]
733 kind: String,
734 #[schemars(range(min = 1))]
736 threads: Option<u64>,
737}
738
739#[derive(JsonSchema)]
741#[schemars(rename = "no_delivery", deny_unknown_fields)]
742#[allow(dead_code)]
743struct NoDelivery {
744 #[schemars(schema_with = "deliver_false")]
746 deliver: bool,
747}
748
749fn deliver_false(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
750 schemars::json_schema!({"const": false, "x-doc-type": "`false`"})
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756
757 fn load(yaml: &str) -> (Profiles, Diagnostics) {
758 let dir = tempfile::tempdir().unwrap();
759 std::fs::write(dir.path().join(PROFILES_FILE), yaml).unwrap();
760 let mut diags = Diagnostics::default();
761 let p = Profiles::load(dir.path(), "--profiles-dir", &mut diags);
762 (p, diags)
763 }
764
765 fn errors(d: &Diagnostics) -> Vec<(Option<usize>, String)> {
766 d.sorted()
767 .into_iter()
768 .map(|d| (d.line, d.message.clone()))
769 .collect()
770 }
771
772 #[test]
773 fn a_name_used_twice_gets_the_line_of_the_one_meant() {
774 let (_, d) = load("connections:\n wat:\n targets:\n dev: {type: duckdb}\nwat: {}\n");
776 assert_eq!(
777 errors(&d),
778 [(
779 Some(5),
780 "unknown profiles.yml section `wat`; profiles go under `connections:` or `destinations:`"
781 .into()
782 )]
783 );
784 }
785
786 #[test]
787 fn a_target_named_like_a_key_gets_its_own_line() {
788 let yaml = "connections:\n w:\n target: target\n targets:\n dev: {type: duckdb}\n target: {path: x}\n";
789 let (_, d) = load(yaml);
790 assert_eq!(
791 errors(&d),
792 [(
793 Some(6),
794 "target `target` of connection profile `w` has no `type`".into()
795 )]
796 );
797 }
798
799 #[test]
800 fn jinja_values_and_yaml_1_1_words_are_strings_passed_on() {
801 let yaml = "connections:\n w:\n targets:\n dev:\n type: postgres\n password: \"{{ env_var('PG_PASSWORD') }}\"\n sslmode: no\n";
802 let (p, d) = load(yaml);
803 assert!(errors(&d).is_empty(), "{:?}", errors(&d));
804 let t = p.get(Role::Connection, "w").unwrap().targets.get("dev").unwrap();
805 assert_eq!(t.kind, "postgres");
806 assert_eq!(t.fields["password"], "{{ env_var('PG_PASSWORD') }}");
807 assert_eq!(t.fields["sslmode"], "no");
808 assert_eq!(p.line_of(Role::Connection, "w"), Some(2));
809 }
810
811 #[test]
812 fn syntax_errors_have_their_line() {
813 let (p, d) = load("connections:\n w: [\n");
814 assert!(p.exists());
815 let e = errors(&d);
816 assert_eq!(e.len(), 1);
817 assert!(e[0].1.starts_with("invalid YAML: "), "{e:?}");
818 assert!(e[0].0.is_some(), "{e:?}");
819 }
820
821 #[test]
822 fn merge_keys_share_settings_between_targets() {
823 let yaml = "connections:\n w:\n targets:\n dev: &dev {type: duckdb, path: dev.duckdb}\n ci: {<<: *dev, path: ci.duckdb}\n";
824 let (p, d) = load(yaml);
825 assert!(errors(&d).is_empty());
826 let ci = p.get(Role::Connection, "w").unwrap().targets.get("ci").unwrap();
827 assert_eq!(
828 (ci.kind.as_str(), &ci.fields["path"]),
829 ("duckdb", &serde_json::json!("ci.duckdb"))
830 );
831 }
832}