Skip to main content

dre_core/
profiles.rs

1//! `profiles.yml`: connection definitions, kept outside the project.
2//!
3//! Two sections keep database connections apart from delivery targets:
4//!
5//! ```yaml
6//! connections:
7//!   warehouse:
8//!     targets:
9//!       dev: {type: duckdb, path: dev.duckdb}
10//! destinations:
11//!   client_sftp:
12//!     targets:
13//!       dev: {type: sftp, host: sftp.example.com}
14//! ```
15//!
16//! A connection profile is referenced by `default_profile`/`profile:` (and a source's
17//! `profile:`), a destination profile by `output.destination.profile`; each is looked up in its
18//! own section only. Each profile lists one entry per target (environment). Each profile a run
19//! uses picks its entry by `--target`, else `DRE_TARGET` (either sets every profile), else the
20//! profile's own `target:`, else `dev`. A used profile without that entry is an error; a
21//! destination entry `{deliver: false}` deliberately delivers nowhere.
22//!
23//! DRE 0.1 called the connections section `sources:`; it still loads in 0.2.x, with a warning.
24//!
25//! Location: `--profiles-dir` > `DRE_PROFILES_DIR` > `~/.dre`, one file, never merged. These are
26//! read directly at startup, never through the `env_var()` Jinja function. `env_var()` calls
27//! *inside* the file are rendered at run time, just before a connection config is handed to a
28//! plugin.
29
30use 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
43/// The built-in destination type: copying bytes to a local path needs no plugin (ADR 0003).
44/// `profile: local` works without a profiles.yml entry; defining a `local` profile overrides it.
45pub const LOCAL_TYPE: &str = "local";
46
47/// What `profile: local` resolves to when profiles.yml doesn't define it, for every target.
48pub 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
53/// The section DRE 0.1 kept connections under; read with a warning in 0.2.x.
54pub const OLD_CONNECTIONS_SECTION: &str = "sources";
55
56/// The run's target (environment), and a profile's entry, when nothing chooses one.
57pub const DEFAULT_TARGET: &str = "dev";
58/// The environment variable choosing the run's target, below `--target`.
59pub const TARGET_ENV: &str = crate::settings::TARGET;
60
61/// Which section of profiles.yml a profile lives in.
62#[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    /// The profiles.yml section key.
76    pub fn section(self) -> &'static str {
77        match self {
78            Role::Connection => "connections",
79            Role::Destination => "destinations",
80        }
81    }
82}
83
84/// Where the run's target came from.
85#[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/// The run's target: `--target`, else `DRE_TARGET`, else `dev`. It's `target.name` in templates.
104#[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    /// `--target` (`flag`), else `DRE_TARGET`, else `dev`.
121    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    /// Whether `--target` or `DRE_TARGET` chose it, which sets every profile's entry.
138    pub fn chosen(&self) -> bool {
139        self.from != TargetSource::Default
140    }
141}
142
143/// What a profile uses for the run: its entry, or why it has none.
144#[derive(Debug, Clone, Copy)]
145pub enum Entry<'a> {
146    Use(&'a ProfileTarget),
147    /// `{deliver: false}`: a destination that deliberately delivers nowhere on this target.
148    Nowhere,
149    /// The profile has no entry for its target.
150    Missing,
151    /// No such profile.
152    Unknown,
153}
154
155/// One environment of a profile: a plugin type and its connection fields.
156#[derive(Debug, Clone, Serialize)]
157pub struct ProfileTarget {
158    #[serde(rename = "type")]
159    pub kind: String,
160    /// Every other field, passed through to the plugin unvalidated by core.
161    #[serde(skip)]
162    pub fields: serde_json::Map<String, serde_json::Value>,
163}
164
165#[derive(Debug, Clone, Serialize)]
166pub struct Profile {
167    /// Its own default entry (`target:`), below `--target` and `DRE_TARGET`.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub target: Option<String>,
170    /// The entries that connect or deliver.
171    pub targets: BTreeMap<String, ProfileTarget>,
172    /// The destination entries written `{deliver: false}`.
173    #[serde(skip_serializing_if = "std::collections::BTreeSet::is_empty")]
174    pub nowhere: std::collections::BTreeSet<String>,
175}
176
177impl Profile {
178    /// Every entry's name, delivering or not.
179    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    /// Where DRE looked for the file.
189    pub path: PathBuf,
190    /// Why that directory: `--profiles-dir`, `DRE_PROFILES_DIR`, `the project directory` or `~/.dre`.
191    pub found_by: &'static str,
192    /// The file as shown in diagnostics; `None` when it doesn't exist. Loading problems are
193    /// reported when it's needed.
194    pub file: Option<PathBuf>,
195    /// Every profile each section declares, broken ones too, with its name's line.
196    declared: BTreeMap<Role, BTreeMap<String, usize>>,
197    pub connections: BTreeMap<String, Profile>,
198    pub destinations: BTreeMap<String, Profile>,
199    /// The section connections were read from: `connections`, or 0.1's `sources`.
200    connections_section: &'static str,
201    /// The run's target, which picks each profile's entry (see [`Profiles::target_of`]).
202    pub run: RunTarget,
203}
204
205/// Resolve the profiles directory: `--profiles-dir` > `DRE_PROFILES_DIR` > `~/.dre`.
206/// `dre init` writes here; projects look in their own directory first (see [`locate`]).
207pub fn profiles_dir(cli: Option<&Path>) -> PathBuf {
208    locate(cli, None).0
209}
210
211/// Find the profiles directory for a project, with the reason it was chosen:
212/// `--profiles-dir` > `DRE_PROFILES_DIR` > the project directory (when it holds a
213/// profiles.yml) > `~/.dre`. With the default `--project-dir .` this is dbt's order.
214pub 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        // Parsed badly or not, the file exists: references to it don't pile up extra errors.
239        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    /// The section key a role's profiles were read from (`sources` for an 0.1 file).
339    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    /// Whether a (possibly partially broken) profile of this name is declared in its section.
353    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    /// Whether `name` means the built-in local destination (no `local` profile is defined).
358    pub fn is_builtin_local(&self, name: &str) -> bool {
359        name == LOCAL_TYPE && !self.destinations.contains_key(name)
360    }
361
362    /// The line of a profile's name in the file.
363    pub fn line_of(&self, role: Role, name: &str) -> Option<usize> {
364        self.declared.get(&role)?.get(name).copied()
365    }
366
367    /// The entry a profile uses in this run: the run's target when `--target` or `DRE_TARGET`
368    /// chose it, else the profile's own `target:`, else `dev`.
369    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    /// What a profile uses in this run. `profile: local` without a defined `local` profile is
379    /// the built-in local destination, for every target.
380    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    /// A profile's settings for this run, when it has an entry that connects or delivers.
396    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    /// Why a used profile has no entry for this run, with the way to fix it.
404    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
430/// A section's profiles, and every profile name it declares with its line.
431fn 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
585// The file's shape. These types are what's read, and they generate profiles.schema.json; their
586// doc comments are its descriptions. Values the loader checks itself, to say what's wrong in
587// DRE's words, are `Loose`.
588
589/// A section of profiles by name, each checked by the loader.
590type Section<P> = Located<Loose<Map<Located<Loose<P>>>>>;
591
592/// Connections and destinations, in `profiles.yml`: what reports read from and where outputs go. Kept outside the project (`~/.dre`, `--profiles-dir` or `DRE_PROFILES_DIR`). Every profile lists its targets (environments). Each profile the run uses picks one: `--target`, else `DRE_TARGET` (either sets every profile), else the profile's own `target`, else `dev`; a used profile without that entry is an error.
593#[derive(Deserialize, JsonSchema)]
594#[schemars(title = "DRE profiles", deny_unknown_fields)]
595#[schemars(extend("not" = {"required": ["connections", "sources"]}))]
596pub struct ProfilesFile {
597    /// Database connections, referenced by `default_profile`, `profile:` (report, query, Set, folder `+profile`) and a source's `profile`.
598    connections: Option<Section<ConnectionProfile>>,
599    /// DRE 0.1's name for `connections:`. Still read in 0.2.x, with a warning: rename it to `connections:`.
600    #[schemars(extend("deprecated" = true))]
601    sources: Option<Section<ConnectionProfile>>,
602    /// Delivery targets, referenced by `output.destination.profile`.
603    destinations: Option<Section<DestinationProfile>>,
604    #[serde(rename = "$unknown", default)]
605    #[schemars(skip)]
606    unknown: UnknownKeys,
607}
608
609/// A named connection with one entry per target (environment).
610#[derive(Deserialize, JsonSchema)]
611#[schemars(rename = "connection", deny_unknown_fields)]
612pub struct ConnectionProfile {
613    /// This profile's entry when neither `--target` nor `DRE_TARGET` is set (dbt's key). Default: `dev`. It doesn't change `target.name`, the run's target.
614    target: Option<Located<Loose<String>>>,
615    /// The environments of this connection, by name (e.g. `dev`, `prod`).
616    #[schemars(required, extend("minProperties" = 1))]
617    targets: Option<Located<Loose<Map<Located<ConnectionEntry>>>>>,
618    /// DRE 0.1's name for `targets`, read only to say so.
619    #[schemars(skip)]
620    outputs: Option<serde::de::IgnoredAny>,
621}
622
623/// A named destination with one entry per target (environment).
624#[derive(Deserialize, JsonSchema)]
625#[schemars(rename = "destination", deny_unknown_fields)]
626pub struct DestinationProfile {
627    /// The environments of this destination, by name (e.g. `dev`, `prod`). An entry is a delivery configuration, or `{deliver: false}` to deliver nowhere on that target.
628    #[schemars(required, extend("minProperties" = 1))]
629    targets: Option<Located<Loose<Map<Located<DestinationEntry>>>>>,
630    /// This profile's entry when neither `--target` nor `DRE_TARGET` is set (dbt's key). Default: `dev`. It doesn't change `target.name`, the run's target.
631    target: Option<Located<Loose<String>>>,
632    #[schemars(skip)]
633    outputs: Option<serde::de::IgnoredAny>,
634}
635
636/// Either kind of profile, as the loader checks it.
637pub 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/// A connection's target: `type` and the plugin's fields, passed on unchecked.
679#[derive(Deserialize)]
680#[serde(transparent)]
681pub struct ConnectionEntry(serde_json::Value);
682
683/// A destination's target: a delivery configuration, or `{deliver: false}`.
684#[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/// One target of a profile: a connection or delivery configuration. `type` names the plugin; every other key is a field of that plugin (see the plugins reference), and secrets belong in `{{ env_var('NAME') }}`.
727#[derive(JsonSchema)]
728#[schemars(rename = "target", extend("additionalProperties" = true, "not" = {"required": ["deliver"]}))]
729#[allow(dead_code)]
730struct TargetEntry {
731    /// The plugin type of the connection, e.g. `duckdb`, `postgres`, `databricks`, `sftp`, `s3`. `local` needs no plugin.
732    #[serde(rename = "type")]
733    kind: String,
734    /// Connections only: how many Bindings may run on this entry at once in a `dre run` (default 1: one after another). A DuckDB file is always 1. `--threads` and `DRE_THREADS` cap the whole run. Concurrent Bindings must not write the same permanent table, and peak memory grows with it.
735    #[schemars(range(min = 1))]
736    threads: Option<u64>,
737}
738
739/// A destination entry is a delivery configuration (`type` and the plugin's fields, as for a connection), or `{deliver: false}`: it deliberately delivers nowhere. The output stays in the target path, the run logs it, and `run_results.json` records the delivery as `not_delivered`. Destinations only.
740#[derive(JsonSchema)]
741#[schemars(rename = "no_delivery", deny_unknown_fields)]
742#[allow(dead_code)]
743struct NoDelivery {
744    /// `false`: this target delivers nowhere. Takes no other keys.
745    #[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        // Searching the text for `wat:` finds the profile on line 2 first.
775        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}