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 crate::secrets::register_env_references(&text);
248 let tree = match node::parse(&text) {
249 Ok(n) => n,
250 Err(e) => {
251 diags.error(
252 Code::YamlSyntax,
253 Some(path),
254 e.line,
255 format!("invalid YAML: {}", e.message),
256 );
257 return out;
258 }
259 };
260 let file = Some(path.clone());
261 let raw: Loose<ProfilesFile> = match de::from_node(&tree) {
262 Ok(r) => r,
263 Err(e) => {
264 diags.error(Code::InvalidProfiles, file, e.line, e.message);
265 return out;
266 }
267 };
268 let raw = match raw {
269 Loose::Ok(r) => r,
270 Loose::Bad(Found { kind: "nothing", .. }) => return out,
271 Loose::Bad(_) => {
272 diags.error(
273 Code::InvalidProfiles,
274 file,
275 None,
276 "profiles.yml must be a map with `connections:` and/or `destinations:`",
277 );
278 return out;
279 }
280 };
281 for k in &raw.unknown.0 {
282 diags.error(
283 Code::InvalidProfiles,
284 file.clone(),
285 Some(k.line),
286 format!(
287 "unknown profiles.yml section `{}`; profiles go under `connections:` or `destinations:`",
288 k.name
289 ),
290 );
291 }
292 let connections = match (raw.connections, raw.sources) {
293 (Some(c), Some(s)) => {
294 diags.error(
295 Code::InvalidProfiles,
296 file.clone(),
297 s.line(),
298 "profiles.yml has both `connections:` and `sources:`; `sources:` is the old name of `connections:`, so move its profiles under `connections:`",
299 );
300 Some(("connections", c))
301 }
302 (Some(c), None) => Some(("connections", c)),
303 (None, Some(s)) => {
304 diags.warning(
305 Code::ProfilesSourcesRenamed,
306 file.clone(),
307 s.line(),
308 "`sources:` in profiles.yml is now `connections:` (DRE 0.2); rename it. `sources:` still works in 0.2.x",
309 );
310 out.connections_section = OLD_CONNECTIONS_SECTION;
311 Some((OLD_CONNECTIONS_SECTION, s))
312 }
313 (None, None) => None,
314 };
315 if let Some((key, section)) = connections {
316 let (parsed, declared) = parse_section(Role::Connection, key, section, &path, diags);
317 out.connections = parsed;
318 out.declared.insert(Role::Connection, declared);
319 }
320 if let Some(section) = raw.destinations {
321 let (parsed, declared) = parse_section(Role::Destination, "destinations", section, &path, diags);
322 out.destinations = parsed;
323 out.declared.insert(Role::Destination, declared);
324 }
325 out
326 }
327
328 pub fn exists(&self) -> bool {
329 self.file.is_some()
330 }
331
332 fn section(&self, role: Role) -> &BTreeMap<String, Profile> {
333 match role {
334 Role::Connection => &self.connections,
335 Role::Destination => &self.destinations,
336 }
337 }
338
339 pub fn section_key(&self, role: Role) -> &'static str {
341 match role {
342 Role::Connection if self.connections_section == OLD_CONNECTIONS_SECTION => {
343 OLD_CONNECTIONS_SECTION
344 }
345 r => r.section(),
346 }
347 }
348
349 pub fn get(&self, role: Role, name: &str) -> Option<&Profile> {
350 self.section(role).get(name)
351 }
352
353 pub fn declares(&self, role: Role, name: &str) -> bool {
355 self.declared.get(&role).is_some_and(|s| s.contains_key(name))
356 }
357
358 pub fn is_builtin_local(&self, name: &str) -> bool {
360 name == LOCAL_TYPE && !self.destinations.contains_key(name)
361 }
362
363 pub fn line_of(&self, role: Role, name: &str) -> Option<usize> {
365 self.declared.get(&role)?.get(name).copied()
366 }
367
368 pub fn target_of(&self, role: Role, profile: &str) -> String {
371 if self.run.chosen() {
372 return self.run.name.clone();
373 }
374 self.get(role, profile)
375 .and_then(|p| p.target.clone())
376 .unwrap_or_else(|| self.run.name.clone())
377 }
378
379 pub fn entry(&self, role: Role, profile: &str) -> Entry<'_> {
382 if role == Role::Destination && self.is_builtin_local(profile) {
383 return Entry::Use(&BUILTIN_LOCAL);
384 }
385 let Some(p) = self.get(role, profile) else {
386 return Entry::Unknown;
387 };
388 let t = self.target_of(role, profile);
389 match p.targets.get(&t) {
390 Some(o) => Entry::Use(o),
391 None if p.nowhere.contains(&t) => Entry::Nowhere,
392 None => Entry::Missing,
393 }
394 }
395
396 pub fn target(&self, role: Role, profile: &str) -> Option<&ProfileTarget> {
398 match self.entry(role, profile) {
399 Entry::Use(o) => Some(o),
400 _ => None,
401 }
402 }
403
404 pub fn missing_entry(&self, role: Role, profile: &str) -> String {
406 let t = self.target_of(role, profile);
407 let has = self
408 .get(role, profile)
409 .map(|p| p.entry_names().join(", "))
410 .unwrap_or_default();
411 let why = match self.run.from {
412 TargetSource::Default if self.get(role, profile).is_some_and(|p| p.target.is_some()) => {
413 "its `target:`".to_string()
414 }
415 TargetSource::Default => "the default".to_string(),
416 from => from.to_string(),
417 };
418 let mut msg = format!(
419 "{} `{profile}` has no `{t}` entry (it has: {has}); `{t}` comes from {why}",
420 role.as_str()
421 );
422 if role == Role::Destination {
423 msg.push_str(&format!(
424 ". To deliver nowhere on `{t}`, add `{t}: {{deliver: false}}` to its `targets:`"
425 ));
426 }
427 msg
428 }
429}
430
431fn parse_section<P: Into<RawProfile>>(
433 role: Role,
434 key: &str,
435 section: Section<P>,
436 path: &Path,
437 diags: &mut Diagnostics,
438) -> (BTreeMap<String, Profile>, BTreeMap<String, usize>) {
439 let Loose::Ok(profiles) = section.value else {
440 diags.error(
441 Code::InvalidProfiles,
442 Some(path.to_path_buf()),
443 section.line(),
444 format!("`{key}` must be a map of profile names"),
445 );
446 return Default::default();
447 };
448 let declared = profiles.iter().map(|(k, _)| (k.value.clone(), k.line)).collect();
449 let parsed = profiles
450 .0
451 .into_iter()
452 .filter_map(|(name, p)| {
453 parse_profile(role, &name, p.map(|p| p.map(Into::into)), path, diags).map(|p| (name.value, p))
454 })
455 .collect();
456 (parsed, declared)
457}
458
459fn parse_profile(
460 role: Role,
461 name: &Located<String>,
462 v: Located<Loose<RawProfile>>,
463 path: &Path,
464 diags: &mut Diagnostics,
465) -> Option<Profile> {
466 let file = Some(path.to_path_buf());
467 let line = name.line();
468 let what = format!("{} profile `{}`", role.as_str(), name.value);
469 let Loose::Ok(m) = v.value else {
470 diags.error(
471 Code::InvalidProfile,
472 file,
473 line,
474 format!("{what} must be a map with `targets`"),
475 );
476 return None;
477 };
478 let target_line = m.target.as_ref().and_then(Located::line);
479 let own_target = match m.target {
480 None => None,
481 Some(Located {
482 value: Loose::Ok(t), ..
483 }) if !t.trim().is_empty() => Some(t),
484 Some(t) => {
485 diags.error(
486 Code::InvalidProfile,
487 file.clone(),
488 t.line(),
489 format!("{what}: `target` must be the name of one of its `targets`"),
490 );
491 return None;
492 }
493 };
494 let Some(Loose::Ok(targets)) = m.targets.map(|t| t.value) else {
495 let hint = if m.outputs.is_some() {
496 " (`outputs:` is now `targets:`)"
497 } else {
498 ""
499 };
500 diags.error(
501 Code::InvalidProfile,
502 file,
503 line,
504 format!("{what} needs a map of named `targets`{hint}"),
505 );
506 return None;
507 };
508 let mut parsed = BTreeMap::new();
509 let mut nowhere = std::collections::BTreeSet::new();
510 let mut ok = true;
511 for (tname, o) in targets.0 {
512 let tline = tname.line();
513 let tname = tname.value;
514 let o = o.value;
515 if let Some(d) = o.get("deliver") {
516 let problem = if role == Role::Connection {
517 Some("`deliver: false` is only for destinations; a connection entry needs a `type`")
518 } else if d != &serde_json::Value::Bool(false) {
519 Some("`deliver` can only be `false` (an entry that delivers just has a `type`)")
520 } else if o.as_object().is_some_and(|m| m.len() > 1) {
521 Some("`deliver: false` takes no other settings: the entry delivers nowhere")
522 } else {
523 None
524 };
525 match problem {
526 Some(p) => {
527 diags.error(
528 Code::InvalidProfile,
529 file.clone(),
530 tline,
531 format!("target `{tname}` of {what}: {p}"),
532 );
533 ok = false;
534 }
535 None => {
536 nowhere.insert(tname);
537 }
538 }
539 continue;
540 }
541 let Some(kind) = o.get("type").and_then(serde_json::Value::as_str) else {
542 diags.error(
543 Code::InvalidProfile,
544 file.clone(),
545 tline,
546 format!("target `{tname}` of {what} has no `type`"),
547 );
548 ok = false;
549 continue;
550 };
551 let kind = kind.to_string();
552 let fields = match o {
553 serde_json::Value::Object(mut f) => {
554 f.remove("type");
555 f
556 }
557 _ => serde_json::Map::new(),
558 };
559 parsed.insert(tname, ProfileTarget { kind, fields });
560 }
561 if let Some(t) = &own_target
562 && ok
563 && !parsed.contains_key(t)
564 && !nowhere.contains(t)
565 {
566 let mut has: Vec<&String> = parsed.keys().chain(&nowhere).collect();
567 has.sort();
568 diags.error(
569 Code::InvalidProfile,
570 file.clone(),
571 target_line,
572 format!(
573 "{what}: `target: {t}` isn't one of its targets ({})",
574 has.into_iter().cloned().collect::<Vec<_>>().join(", ")
575 ),
576 );
577 return None;
578 }
579 ok.then_some(Profile {
580 target: own_target,
581 targets: parsed,
582 nowhere,
583 })
584}
585
586type Section<P> = Located<Loose<Map<Located<Loose<P>>>>>;
592
593#[derive(Deserialize, JsonSchema)]
595#[schemars(title = "DRE profiles", deny_unknown_fields)]
596#[schemars(extend("not" = {"required": ["connections", "sources"]}))]
597pub struct ProfilesFile {
598 connections: Option<Section<ConnectionProfile>>,
600 #[schemars(extend("deprecated" = true))]
602 sources: Option<Section<ConnectionProfile>>,
603 destinations: Option<Section<DestinationProfile>>,
605 #[serde(rename = "$unknown", default)]
606 #[schemars(skip)]
607 unknown: UnknownKeys,
608}
609
610#[derive(Deserialize, JsonSchema)]
612#[schemars(rename = "connection", deny_unknown_fields)]
613pub struct ConnectionProfile {
614 target: Option<Located<Loose<String>>>,
616 #[schemars(required, extend("minProperties" = 1))]
618 targets: Option<Located<Loose<Map<Located<ConnectionEntry>>>>>,
619 #[schemars(skip)]
621 outputs: Option<serde::de::IgnoredAny>,
622}
623
624#[derive(Deserialize, JsonSchema)]
626#[schemars(rename = "destination", deny_unknown_fields)]
627pub struct DestinationProfile {
628 #[schemars(required, extend("minProperties" = 1))]
630 targets: Option<Located<Loose<Map<Located<DestinationEntry>>>>>,
631 target: Option<Located<Loose<String>>>,
633 #[schemars(skip)]
634 outputs: Option<serde::de::IgnoredAny>,
635}
636
637pub struct RawProfile {
639 target: Option<Located<Loose<String>>>,
640 targets: Option<Located<Loose<Map<Located<serde_json::Value>>>>>,
641 outputs: Option<serde::de::IgnoredAny>,
642}
643
644fn entries<E: Into<serde_json::Value>>(
645 t: Option<Located<Loose<Map<Located<E>>>>>,
646) -> Option<Located<Loose<Map<Located<serde_json::Value>>>>> {
647 t.map(|t| {
648 t.map(|t| match t {
649 Loose::Ok(m) => Loose::Ok(Map(m
650 .0
651 .into_iter()
652 .map(|(k, v)| (k, v.map(Into::into)))
653 .collect())),
654 Loose::Bad(f) => Loose::Bad(f),
655 })
656 })
657}
658
659impl From<ConnectionProfile> for RawProfile {
660 fn from(p: ConnectionProfile) -> RawProfile {
661 RawProfile {
662 target: p.target,
663 targets: entries(p.targets),
664 outputs: p.outputs,
665 }
666 }
667}
668
669impl From<DestinationProfile> for RawProfile {
670 fn from(p: DestinationProfile) -> RawProfile {
671 RawProfile {
672 target: p.target,
673 targets: entries(p.targets),
674 outputs: p.outputs,
675 }
676 }
677}
678
679#[derive(Deserialize)]
681#[serde(transparent)]
682pub struct ConnectionEntry(serde_json::Value);
683
684#[derive(Deserialize)]
686#[serde(transparent)]
687pub struct DestinationEntry(serde_json::Value);
688
689impl From<ConnectionEntry> for serde_json::Value {
690 fn from(e: ConnectionEntry) -> Self {
691 e.0
692 }
693}
694
695impl From<DestinationEntry> for serde_json::Value {
696 fn from(e: DestinationEntry) -> Self {
697 e.0
698 }
699}
700
701impl JsonSchema for ConnectionEntry {
702 fn schema_name() -> std::borrow::Cow<'static, str> {
703 "ConnectionEntry".into()
704 }
705 fn inline_schema() -> bool {
706 true
707 }
708 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
709 g.subschema_for::<TargetEntry>()
710 }
711}
712
713impl JsonSchema for DestinationEntry {
714 fn schema_name() -> std::borrow::Cow<'static, str> {
715 "DestinationEntry".into()
716 }
717 fn inline_schema() -> bool {
718 true
719 }
720 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
721 schemars::json_schema!({
722 "oneOf": [g.subschema_for::<TargetEntry>(), g.subschema_for::<NoDelivery>()]
723 })
724 }
725}
726
727#[derive(JsonSchema)]
729#[schemars(rename = "target", extend("additionalProperties" = true, "not" = {"required": ["deliver"]}))]
730#[allow(dead_code)]
731struct TargetEntry {
732 #[serde(rename = "type")]
734 kind: String,
735 #[schemars(range(min = 1))]
737 threads: Option<u64>,
738}
739
740#[derive(JsonSchema)]
742#[schemars(rename = "no_delivery", deny_unknown_fields)]
743#[allow(dead_code)]
744struct NoDelivery {
745 #[schemars(schema_with = "deliver_false")]
747 deliver: bool,
748}
749
750fn deliver_false(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
751 schemars::json_schema!({"const": false, "x-doc-type": "`false`"})
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757
758 fn load(yaml: &str) -> (Profiles, Diagnostics) {
759 let dir = tempfile::tempdir().unwrap();
760 std::fs::write(dir.path().join(PROFILES_FILE), yaml).unwrap();
761 let mut diags = Diagnostics::default();
762 let p = Profiles::load(dir.path(), "--profiles-dir", &mut diags);
763 (p, diags)
764 }
765
766 fn errors(d: &Diagnostics) -> Vec<(Option<usize>, String)> {
767 d.sorted()
768 .into_iter()
769 .map(|d| (d.line, d.message.clone()))
770 .collect()
771 }
772
773 #[test]
774 fn a_name_used_twice_gets_the_line_of_the_one_meant() {
775 let (_, d) = load("connections:\n wat:\n targets:\n dev: {type: duckdb}\nwat: {}\n");
777 assert_eq!(
778 errors(&d),
779 [(
780 Some(5),
781 "unknown profiles.yml section `wat`; profiles go under `connections:` or `destinations:`"
782 .into()
783 )]
784 );
785 }
786
787 #[test]
788 fn a_target_named_like_a_key_gets_its_own_line() {
789 let yaml = "connections:\n w:\n target: target\n targets:\n dev: {type: duckdb}\n target: {path: x}\n";
790 let (_, d) = load(yaml);
791 assert_eq!(
792 errors(&d),
793 [(
794 Some(6),
795 "target `target` of connection profile `w` has no `type`".into()
796 )]
797 );
798 }
799
800 #[test]
801 fn jinja_values_and_yaml_1_1_words_are_strings_passed_on() {
802 let yaml = "connections:\n w:\n targets:\n dev:\n type: postgres\n password: \"{{ env_var('PG_PASSWORD') }}\"\n sslmode: no\n";
803 let (p, d) = load(yaml);
804 assert!(errors(&d).is_empty(), "{:?}", errors(&d));
805 let t = p.get(Role::Connection, "w").unwrap().targets.get("dev").unwrap();
806 assert_eq!(t.kind, "postgres");
807 assert_eq!(t.fields["password"], "{{ env_var('PG_PASSWORD') }}");
808 assert_eq!(t.fields["sslmode"], "no");
809 assert_eq!(p.line_of(Role::Connection, "w"), Some(2));
810 }
811
812 #[test]
813 fn syntax_errors_have_their_line() {
814 let (p, d) = load("connections:\n w: [\n");
815 assert!(p.exists());
816 let e = errors(&d);
817 assert_eq!(e.len(), 1);
818 assert!(e[0].1.starts_with("invalid YAML: "), "{e:?}");
819 assert!(e[0].0.is_some(), "{e:?}");
820 }
821
822 #[test]
823 fn merge_keys_share_settings_between_targets() {
824 let yaml = "connections:\n w:\n targets:\n dev: &dev {type: duckdb, path: dev.duckdb}\n ci: {<<: *dev, path: ci.duckdb}\n";
825 let (p, d) = load(yaml);
826 assert!(errors(&d).is_empty());
827 let ci = p.get(Role::Connection, "w").unwrap().targets.get("ci").unwrap();
828 assert_eq!(
829 (ci.kind.as_str(), &ci.fields["path"]),
830 ("duckdb", &serde_json::json!("ci.duckdb"))
831 );
832 }
833}