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 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
41/// The built-in destination type: copying bytes to a local path needs no plugin (ADR 0003).
42/// `profile: local` works without a profiles.yml entry; defining a `local` profile overrides it.
43pub const LOCAL_TYPE: &str = "local";
44
45/// What `profile: local` resolves to when profiles.yml doesn't define it, for every target.
46pub 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
51/// The section DRE 0.1 kept connections under; read with a warning in 0.2.x.
52pub const OLD_CONNECTIONS_SECTION: &str = "sources";
53
54/// The run's target (environment), and a profile's entry, when nothing chooses one.
55pub const DEFAULT_TARGET: &str = "dev";
56/// The environment variable choosing the run's target, below `--target`.
57pub const TARGET_ENV: &str = "DRE_TARGET";
58
59/// Which section of profiles.yml a profile lives in.
60#[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    /// The profiles.yml section key.
74    pub fn section(self) -> &'static str {
75        match self {
76            Role::Connection => "connections",
77            Role::Destination => "destinations",
78        }
79    }
80}
81
82/// Where the run's target came from.
83#[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/// The run's target: `--target`, else `DRE_TARGET`, else `dev`. It's `target.name` in templates.
102#[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    /// `--target` (`flag`), else `DRE_TARGET`, else `dev`.
119    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    /// Whether `--target` or `DRE_TARGET` chose it, which sets every profile's entry.
136    pub fn chosen(&self) -> bool {
137        self.from != TargetSource::Default
138    }
139}
140
141/// What a profile uses for the run: its entry, or why it has none.
142#[derive(Debug, Clone, Copy)]
143pub enum Entry<'a> {
144    Use(&'a ProfileTarget),
145    /// `{deliver: false}`: a destination that deliberately delivers nowhere on this target.
146    Nowhere,
147    /// The profile has no entry for its target.
148    Missing,
149    /// No such profile.
150    Unknown,
151}
152
153/// One environment of a profile: a plugin type and its connection fields.
154#[derive(Debug, Clone, Serialize)]
155pub struct ProfileTarget {
156    #[serde(rename = "type")]
157    pub kind: String,
158    /// Every other field, passed through to the plugin unvalidated by core.
159    #[serde(skip)]
160    pub fields: serde_json::Map<String, serde_json::Value>,
161}
162
163#[derive(Debug, Clone, Serialize)]
164pub struct Profile {
165    /// Its own default entry (`target:`), below `--target` and `DRE_TARGET`.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub target: Option<String>,
168    /// The entries that connect or deliver.
169    pub targets: BTreeMap<String, ProfileTarget>,
170    /// The destination entries written `{deliver: false}`.
171    #[serde(skip_serializing_if = "std::collections::BTreeSet::is_empty")]
172    pub nowhere: std::collections::BTreeSet<String>,
173}
174
175impl Profile {
176    /// Every entry's name, delivering or not.
177    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    /// Where DRE looked for the file.
187    pub path: PathBuf,
188    /// Why that directory: `--profiles-dir`, `DRE_PROFILES_DIR`, `the project directory` or `~/.dre`.
189    pub found_by: &'static str,
190    /// `None` when the file doesn't exist; loading problems are reported when it's needed.
191    pub file: Option<YamlFile>,
192    pub connections: BTreeMap<String, Profile>,
193    pub destinations: BTreeMap<String, Profile>,
194    /// The section connections were read from: `connections`, or 0.1's `sources`.
195    connections_section: &'static str,
196    /// The run's target, which picks each profile's entry (see [`Profiles::target_of`]).
197    pub run: RunTarget,
198}
199
200/// Resolve the profiles directory: `--profiles-dir` > `DRE_PROFILES_DIR` > `~/.dre`.
201/// `dre init` writes here; projects look in their own directory first (see [`locate`]).
202pub fn profiles_dir(cli: Option<&Path>) -> PathBuf {
203    locate(cli, None).0
204}
205
206/// Find the profiles directory for a project, with the reason it was chosen:
207/// `--profiles-dir` > `DRE_PROFILES_DIR` > the project directory (when it holds a
208/// profiles.yml) > `~/.dre`. With the default `--project-dir .` this is dbt's order.
209pub 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            // Parsed badly: treat as present-but-empty so references don't pile up extra errors.
235            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    /// The section key a role's profiles were read from (`sources` for an 0.1 file).
333    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    /// Whether a (possibly partially broken) profile of this name is declared in its section.
347    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    /// Whether `name` means the built-in local destination (no `local` profile is defined).
355    pub fn is_builtin_local(&self, name: &str) -> bool {
356        name == LOCAL_TYPE && !self.destinations.contains_key(name)
357    }
358
359    /// Best-effort line of a profile's name in the file.
360    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    /// The entry a profile uses in this run: the run's target when `--target` or `DRE_TARGET`
366    /// chose it, else the profile's own `target:`, else `dev`.
367    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    /// What a profile uses in this run. `profile: local` without a defined `local` profile is
377    /// the built-in local destination, for every target.
378    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    /// A profile's settings for this run, when it has an entry that connects or delivers.
394    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    /// Why a used profile has no entry for this run, with the way to fix it.
402    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}