Skip to main content

mz_deploy/
config.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Configuration loading for profiles and project settings.
11//!
12//! Loads connection profiles from `profiles.toml` (resolved via `--profiles-dir`,
13//! `MZ_DEPLOY_PROFILES_DIR`, or `~/.mz/` default) and project settings
14//! from `project.toml`. Key types:
15//!
16//! - [`Profile`] — Resolved connection details (host, port, credentials).
17//! - [`ProfilesConfig`] — All profiles loaded from a single `profiles.toml`.
18//! - [`ProjectSettings`] — Per-project config: active profile name, optional
19//!   Materialize version / Docker image override, and an optional `dependencies`
20//!   array of fully qualified `database.schema.object` names that this project
21//!   reads from but does not own.
22//!
23//! Passwords can be pulled from the environment via an inline `${VAR}` in the
24//! password field, or via `MZ_PROFILE_<NAME>_PASSWORD` (see
25//! [`ProfilesConfig::expand_env_vars`] for details).
26
27use serde::Deserialize;
28use std::collections::{BTreeMap, BTreeSet};
29use std::fs;
30use std::path::{Path, PathBuf};
31use thiserror::Error;
32
33use crate::project::ir::object_id::ObjectId;
34
35/// Repository path for the Materialize Docker image, without a tag.
36const DOCKER_IMAGE_BASE: &str = "materialize/materialized";
37
38/// The Docker image used when no `mz_version` is configured.
39pub fn default_docker_image() -> String {
40    format!("{DOCKER_IMAGE_BASE}:latest")
41}
42
43/// Security-related settings for a profile (e.g., AWS credentials for secret resolution).
44#[derive(Debug, Deserialize, Clone, Default)]
45#[serde(deny_unknown_fields)]
46pub struct SecurityConfig {
47    /// AWS profile name for loading secrets from AWS Secrets Manager.
48    aws_profile: Option<String>,
49}
50
51impl SecurityConfig {
52    pub fn aws_profile(&self) -> Option<&str> {
53        self.aws_profile.as_deref()
54    }
55}
56
57/// Per-profile configuration from `project.toml`.
58///
59/// Each profile can specify a name suffix, security settings, and psql-style
60/// variables that are resolved in SQL files before parsing.
61#[derive(Debug, Deserialize, Clone, Default)]
62pub struct ProfileConfig {
63    /// Optional suffix to append to database and cluster names for this profile.
64    /// For example, `profile_suffix = "_staging"` would rename `materialize` to
65    /// `materialize_staging` and `analytics` to `analytics_staging`.
66    /// The suffix includes the delimiter (user provides `"_staging"`, not `"staging"`).
67    pub profile_suffix: Option<String>,
68    /// Security-related configuration (e.g., AWS profile for secret resolution).
69    #[serde(default)]
70    pub security: SecurityConfig,
71    /// psql-style variables resolved in SQL files before parsing.
72    /// Defined per profile as `[<profile>.variables]` in `project.toml`.
73    #[serde(default)]
74    pub variables: BTreeMap<String, String>,
75    /// When true, treat the target as if RBAC is disabled: skip all role
76    /// creation, grants, and role-membership checks. Intended for the
77    /// single-user Materialize emulator.
78    #[serde(default)]
79    pub emulator: bool,
80}
81
82/// Parsed contents of `project.toml`.
83///
84/// Specifies optional Materialize version override, per-profile configuration
85/// sections, and an optional list of external dependency object names (fully
86/// qualified `database.schema.object` strings).
87///
88/// The active profile is **not** stored here — it's resolved from `--profile`,
89/// `MZ_DEPLOY_PROFILE`, or the per-project `.mzprofile` file. See
90/// [`read_mzprofile`].
91#[derive(Debug, Deserialize, Clone)]
92pub struct ProjectSettings {
93    pub mz_version: Option<String>,
94
95    #[serde(flatten)]
96    pub profiles: BTreeMap<String, ProfileConfig>,
97    /// Raw dependency strings from the `dependencies` array in `project.toml`.
98    /// Each entry must be a fully qualified `database.schema.object` name.
99    #[serde(default, rename = "dependencies")]
100    raw_dependencies: Vec<String>,
101}
102
103/// Filename of the per-project default-profile pointer.
104///
105/// Lives at the project root (alongside `project.toml`), holds a single
106/// profile name as plain text. Analogous to kubectl's
107/// `current-context`. Machine-local — should be listed in `.gitignore` so
108/// developers on the same project can each set their own default.
109pub const MZPROFILE_FILENAME: &str = ".mzprofile";
110
111/// Read the default profile name from `<project_directory>/.mzprofile`.
112///
113/// Returns `Ok(None)` if the file doesn't exist. Blank lines and lines
114/// beginning with `#` are ignored; the first remaining trimmed line is the
115/// profile name.
116pub fn read_mzprofile(project_directory: &Path) -> Result<Option<String>, ConfigError> {
117    let path = project_directory.join(MZPROFILE_FILENAME);
118    match fs::read_to_string(&path) {
119        Ok(content) => {
120            for line in content.lines() {
121                let trimmed = line.trim();
122                if trimmed.is_empty() || trimmed.starts_with('#') {
123                    continue;
124                }
125                return Ok(Some(trimmed.to_string()));
126            }
127            Ok(None)
128        }
129        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
130        Err(source) => Err(ConfigError::ReadError {
131            path: path.display().to_string(),
132            source,
133        }),
134    }
135}
136
137/// Write `profile_name` to `<project_directory>/.mzprofile`, replacing any
138/// existing content.
139pub fn write_mzprofile(project_directory: &Path, profile_name: &str) -> Result<(), ConfigError> {
140    let path = project_directory.join(MZPROFILE_FILENAME);
141    fs::write(&path, format!("{}\n", profile_name)).map_err(|source| ConfigError::WriteError {
142        path: path.display().to_string(),
143        source,
144    })
145}
146
147impl ProjectSettings {
148    pub fn load(project_directory: &Path) -> Result<Self, ConfigError> {
149        let path = project_directory.join("project.toml");
150        match fs::read_to_string(&path) {
151            Ok(content) => toml::from_str(&content).map_err(|source| ConfigError::ParseError {
152                path: path.display().to_string(),
153                source,
154            }),
155            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
156                Err(ConfigError::ProjectSettingsNotFound {
157                    path: path.display().to_string(),
158                })
159            }
160            Err(source) => Err(ConfigError::ReadError {
161                path: path.display().to_string(),
162                source,
163            }),
164        }
165    }
166
167    /// Returns the profile config for the given profile name.
168    ///
169    /// Falls back to `ProfileConfig::default()` if no entry exists, except for
170    /// the built-in [`EMULATOR_PROFILE_NAME`] profile, which defaults to
171    /// `emulator = true`. A user-defined entry of the same name wins.
172    pub fn config_for_profile(&self, profile_name: &str) -> ProfileConfig {
173        if let Some(config) = self.profiles.get(profile_name) {
174            return config.clone();
175        }
176        if profile_name == EMULATOR_PROFILE_NAME {
177            return ProfileConfig {
178                emulator: true,
179                ..Default::default()
180            };
181        }
182        ProfileConfig::default()
183    }
184
185    /// Returns the profile suffix for the given profile name, if configured.
186    pub fn suffix_for_profile(&self, profile_name: &str) -> Option<&str> {
187        self.profiles
188            .get(profile_name)
189            .and_then(|c| c.profile_suffix.as_deref())
190    }
191
192    pub fn docker_image(&self) -> String {
193        match self.mz_version.as_deref() {
194            None | Some("cloud") => default_docker_image(),
195            Some(tag) => format!("{DOCKER_IMAGE_BASE}:{tag}"),
196        }
197    }
198
199    /// Parse and validate the `dependencies` array from `project.toml`.
200    ///
201    /// Each entry must be a fully qualified `database.schema.object` name.
202    /// Returns `ConfigError::InvalidDependency` for entries that are not
203    /// three-dot-separated parts, and `ConfigError::DuplicateDependency` if
204    /// the same object appears more than once.
205    pub fn validate_dependencies(&self) -> Result<BTreeSet<ObjectId>, ConfigError> {
206        let mut seen = BTreeSet::new();
207        for entry in &self.raw_dependencies {
208            let id = entry
209                .parse::<ObjectId>()
210                .map_err(|_| ConfigError::InvalidDependency {
211                    entry: entry.clone(),
212                })?;
213            if !seen.insert(id) {
214                return Err(ConfigError::DuplicateDependency {
215                    entry: entry.clone(),
216                });
217            }
218        }
219        Ok(seen)
220    }
221}
222
223/// Errors that can occur when loading or resolving configuration from
224/// `profiles.toml` and `project.toml`.
225#[derive(Debug, Error)]
226pub enum ConfigError {
227    #[error(
228        "profiles configuration file not found at {path}\n\nSee mz-deploy help profiles for more information"
229    )]
230    ProfilesNotFound { path: String },
231    #[error("failed to read {path}: {source}")]
232    ReadError {
233        path: String,
234        source: std::io::Error,
235    },
236    #[error("failed to write {path}: {source}")]
237    WriteError {
238        path: String,
239        source: std::io::Error,
240    },
241    #[error("failed to parse {path}: {source}")]
242    ParseError {
243        path: String,
244        source: toml::de::Error,
245    },
246    #[error(
247        "no profile selected: pass --profile, set MZ_DEPLOY_PROFILE, or run `mz-deploy profile set <name>`"
248    )]
249    NoProfileConfigured,
250    #[error("could not determine home directory; set $HOME or pass --profiles-dir")]
251    HomeDirNotFound,
252    #[error("project.toml not found at {path}")]
253    ProjectSettingsNotFound { path: String },
254    #[error("profile '{name}' not found in configuration")]
255    ProfileNotFound { name: String },
256    #[error("environment variable '{var}' not found for profile '{profile}'")]
257    EnvVarNotFound { var: String, profile: String },
258    #[error(
259        "invalid option key '{key}' in profile '{profile}': option keys must be \
260         valid identifiers (alphanumeric and underscore, starting with a letter \
261         or underscore)"
262    )]
263    InvalidOptionKey { key: String, profile: String },
264    #[error(
265        "invalid dependency '{entry}': expected a fully qualified 'database.schema.object' name"
266    )]
267    InvalidDependency { entry: String },
268    #[error(
269        "duplicate dependency '{entry}': each object may appear at most once in 'dependencies'"
270    )]
271    DuplicateDependency { entry: String },
272    #[error(
273        "profile '{profile}' has no host configured: set 'host' (SQL pgwire) \
274         or 'http_host' (HTTP API) in profiles.toml"
275    )]
276    ProfileMissingAnyHost { profile: String },
277    #[error(
278        "profile '{profile}' has no SQL host configured: this command requires \
279         a SQL connection. Set 'host' in profiles.toml."
280    )]
281    ProfileMissingSqlHost { profile: String },
282    #[error(
283        "profile '{profile}' has no HTTP host configured: 'mz-deploy mcp' \
284         requires the HTTP API hostname. Set 'http_host' in profiles.toml."
285    )]
286    ProfileMissingHttpHost { profile: String },
287}
288
289/// TLS mode selection for a profile, matching libpq's `sslmode` vocabulary
290#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
291#[serde(rename_all = "kebab-case")]
292pub enum SslMode {
293    Disable,
294    Prefer,
295    Require,
296    VerifyCa,
297    VerifyFull,
298}
299
300impl SslMode {
301    /// Wire name accepted by libpq (`PGSSLMODE`, `sslmode=` in conninfo).
302    pub(crate) const fn libpq_name(self) -> &'static str {
303        match self {
304            SslMode::Disable => "disable",
305            SslMode::Prefer => "prefer",
306            SslMode::Require => "require",
307            SslMode::VerifyCa => "verify-ca",
308            SslMode::VerifyFull => "verify-full",
309        }
310    }
311}
312
313/// Resolved connection details for a Materialize region.
314///
315/// Constructed from a `profiles.toml` entry after environment variable expansion.
316///
317/// Either `host` or `http_host` must be set. SQL commands require `host`;
318/// `mz-deploy mcp` requires `http_host`. A profile that only sets `http_host`
319/// is valid for MCP-only use.
320#[derive(Debug, Clone)]
321pub struct Profile {
322    pub name: String,
323    /// SQL pgwire host. Required for any command that opens a SQL connection.
324    pub host: Option<String>,
325    pub port: u16,
326    pub username: String,
327    pub password: Option<String>,
328    /// Session variables to set via libpq's `options` parameter (`-c key=value` flags).
329    pub options: BTreeMap<String, String>,
330    /// TLS mode override. `None` means "pick a default based on host":
331    /// `Prefer` for loopback, `Require` otherwise.
332    pub sslmode: Option<SslMode>,
333    /// Explicit CA bundle path for `verify-ca` / `verify-full`. `None` falls
334    /// through to the platform CA hunt.
335    pub sslrootcert: Option<PathBuf>,
336    /// Hostname of the Materialize HTTP API.
337    pub http_host: Option<String>,
338}
339
340impl Profile {
341    /// Borrow the SQL host, returning a clear error if the profile only
342    /// configures the HTTP API.
343    pub fn require_host(&self) -> Result<&str, ConfigError> {
344        self.host
345            .as_deref()
346            .ok_or_else(|| ConfigError::ProfileMissingSqlHost {
347                profile: self.name.clone(),
348            })
349    }
350
351    /// Borrow the HTTP host, returning a clear error if the profile only
352    /// configures the SQL pgwire endpoint.
353    pub fn require_http_host(&self) -> Result<&str, ConfigError> {
354        self.http_host
355            .as_deref()
356            .ok_or_else(|| ConfigError::ProfileMissingHttpHost {
357                profile: self.name.clone(),
358            })
359    }
360}
361
362#[derive(Debug, Deserialize, Clone)]
363struct ProfileData {
364    #[serde(default)]
365    pub host: Option<String>,
366    #[serde(default = "default_port")]
367    pub port: u16,
368    pub username: String,
369    pub password: Option<String>,
370    #[serde(default)]
371    pub options: BTreeMap<String, String>,
372    #[serde(default)]
373    pub sslmode: Option<SslMode>,
374    #[serde(default)]
375    pub sslrootcert: Option<PathBuf>,
376    #[serde(default)]
377    pub http_host: Option<String>,
378}
379
380fn default_port() -> u16 {
381    6875
382}
383
384/// Name of the built-in profile that targets a local Materialize emulator.
385///
386/// Available without any `profiles.toml` or `project.toml` entry: it resolves
387/// to `localhost:6875` as user `materialize` with [`ProfileConfig::emulator`]
388/// set, so a fresh checkout can deploy against a local emulator with zero
389/// setup. A user-defined profile of the same name takes precedence.
390pub const EMULATOR_PROFILE_NAME: &str = "emulator";
391
392/// The built-in connection profile for the local Materialize emulator.
393fn emulator_profile() -> Profile {
394    Profile {
395        name: EMULATOR_PROFILE_NAME.to_string(),
396        host: Some("localhost".to_string()),
397        port: default_port(),
398        username: "materialize".to_string(),
399        password: None,
400        options: BTreeMap::new(),
401        sslmode: None,
402        sslrootcert: None,
403        http_host: None,
404    }
405}
406
407/// Uppercase a profile name and replace any non-alphanumeric character with
408/// `_` so it can be embedded in a shell-legal env var identifier like
409/// `MZ_PROFILE_MY_PROD_PASSWORD`.
410fn sanitize_profile_for_env(name: &str) -> String {
411    name.chars()
412        .map(|c| {
413            if c.is_ascii_alphanumeric() {
414                c.to_ascii_uppercase()
415            } else {
416                '_'
417            }
418        })
419        .collect()
420}
421
422/// Check whether `key` is a valid identifier for a profile `[options]` entry.
423///
424/// Keys must start with an ASCII letter or underscore, followed by any number
425/// of ASCII letters, digits, or underscores. Empty keys are rejected.
426fn is_valid_option_key(key: &str) -> bool {
427    let mut chars = key.chars();
428    match chars.next() {
429        None => false,
430        Some(c) if c.is_ascii_alphabetic() || c == '_' => {
431            chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
432        }
433        _ => false,
434    }
435}
436
437/// All connection profiles loaded from a `profiles.toml` file.
438///
439/// Provides lookup by profile name and environment variable expansion
440/// for password fields.
441#[derive(Debug)]
442pub struct ProfilesConfig {
443    profiles: BTreeMap<String, Profile>,
444    source_path: PathBuf,
445}
446
447impl ProfilesConfig {
448    /// Load profiles configuration from a directory.
449    ///
450    /// # Arguments
451    /// * `profiles_dir` - Optional directory containing `profiles.toml`.
452    ///                     If None, defaults to `~/.mz`.
453    pub fn load(profiles_dir: Option<&Path>) -> Result<Self, ConfigError> {
454        let dir = match profiles_dir {
455            Some(d) => d.to_path_buf(),
456            None => dirs::home_dir()
457                .ok_or(ConfigError::HomeDirNotFound)?
458                .join(".mz"),
459        };
460
461        let path = dir.join("profiles.toml");
462
463        let content = match fs::read_to_string(&path) {
464            Ok(c) => c,
465            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
466                return Err(ConfigError::ProfilesNotFound {
467                    path: path.display().to_string(),
468                });
469            }
470            Err(source) => {
471                return Err(ConfigError::ReadError {
472                    path: path.display().to_string(),
473                    source,
474                });
475            }
476        };
477
478        let profiles_data: BTreeMap<String, ProfileData> =
479            toml::from_str(&content).map_err(|source| ConfigError::ParseError {
480                path: path.display().to_string(),
481                source,
482            })?;
483
484        // Convert ProfileData to Profile by adding the name field
485        let mut profiles = BTreeMap::new();
486        for (name, data) in profiles_data {
487            for key in data.options.keys() {
488                if !is_valid_option_key(key) {
489                    return Err(ConfigError::InvalidOptionKey {
490                        profile: name.clone(),
491                        key: key.clone(),
492                    });
493                }
494            }
495            if data.host.is_none() && data.http_host.is_none() {
496                return Err(ConfigError::ProfileMissingAnyHost {
497                    profile: name.clone(),
498                });
499            }
500            profiles.insert(
501                name.clone(),
502                Profile {
503                    name: name.clone(),
504                    host: data.host,
505                    port: data.port,
506                    username: data.username,
507                    password: data.password,
508                    options: data.options,
509                    sslmode: data.sslmode,
510                    sslrootcert: data.sslrootcert,
511                    http_host: data.http_host,
512                },
513            );
514        }
515
516        // The emulator profile is built in. A user-defined entry of the same
517        // name wins, so only insert it when absent.
518        profiles
519            .entry(EMULATOR_PROFILE_NAME.to_string())
520            .or_insert_with(emulator_profile);
521
522        Ok(ProfilesConfig {
523            profiles,
524            source_path: path,
525        })
526    }
527
528    /// Get a profile by name
529    pub fn get_profile(&self, name: &str) -> Result<Profile, ConfigError> {
530        self.profiles
531            .get(name)
532            .cloned()
533            .ok_or_else(|| ConfigError::ProfileNotFound {
534                name: name.to_string(),
535            })
536    }
537
538    /// Resolve a profile's password, applying environment variable overrides.
539    ///
540    /// Two override mechanisms, applied in order:
541    ///
542    /// 1. **Inline `${VAR_NAME}`** in `profiles.toml` — when the `password` field
543    ///    is exactly `"${SOMETHING}"` (no surrounding text), the referenced env
544    ///    var is read and substituted. Missing vars produce an error.
545    /// 2. **`MZ_PROFILE_<NAME>_PASSWORD`** — always checked; if set, overrides
546    ///    whatever the file (or step 1) produced. The profile name is
547    ///    uppercased and non-alphanumeric characters are replaced with `_` to
548    ///    form a valid shell identifier (so `my-prod` → `MY_PROD`). Profile
549    ///    names that differ only in such characters will collide on the same
550    ///    env var.
551    pub fn expand_env_vars(&self, mut profile: Profile) -> Result<Profile, ConfigError> {
552        if let Some(password) = &profile.password
553            && password.starts_with("${")
554            && password.ends_with("}")
555        {
556            let var_name = &password[2..password.len() - 1];
557            let env_value = std::env::var(var_name).map_err(|_| ConfigError::EnvVarNotFound {
558                var: var_name.to_string(),
559                profile: profile.name.clone(),
560            })?;
561            profile.password = Some(env_value);
562        }
563
564        let env_var_name = format!(
565            "MZ_PROFILE_{}_PASSWORD",
566            sanitize_profile_for_env(&profile.name)
567        );
568        if let Ok(password) = std::env::var(&env_var_name) {
569            profile.password = Some(password);
570        }
571
572        Ok(profile)
573    }
574
575    pub fn profile_names(&self) -> Vec<&str> {
576        self.profiles.keys().map(|s| s.as_str()).collect()
577    }
578
579    pub fn source_path(&self) -> &Path {
580        &self.source_path
581    }
582
583    /// Convenience method to resolve a profile by name from the configured
584    /// profiles file and expand its env-var references.
585    pub fn resolve_profile(
586        profiles_dir: Option<&Path>,
587        name: &str,
588    ) -> Result<Profile, ConfigError> {
589        match Self::load(profiles_dir) {
590            Ok(config) => {
591                let profile = config.get_profile(name)?;
592                config.expand_env_vars(profile)
593            }
594            // The built-in emulator profile must resolve even with no
595            // `profiles.toml` present, so a fresh checkout can target a local
596            // emulator with zero setup.
597            Err(ConfigError::ProfilesNotFound { .. }) if name == EMULATOR_PROFILE_NAME => {
598                Ok(emulator_profile())
599            }
600            Err(e) => Err(e),
601        }
602    }
603}
604
605/// Resolved settings for an mz-deploy execution.
606///
607/// Constructed once in `main.rs` from CLI args + `project.toml` + `profiles.toml`,
608/// then passed to every command. Commands extract what they need (`directory`,
609/// `profile_name`, `profile_suffix`, `docker_image`, `connection()`, `profile_config`,
610/// `dependencies`).
611///
612/// `profile_name` is `None` for commands that don't require a connection
613/// (`compile`, `test`, `explain`) when no profile is set. In that mode
614/// `profile_config` is the default (no variables, no suffix), and any SQL
615/// referencing an unresolved variable will fail with a hint to set a profile.
616#[derive(Debug, Clone)]
617pub struct Settings {
618    /// Project root directory (from --directory, default ".").
619    pub directory: PathBuf,
620    /// Resolved profile name (CLI --profile overrides project.toml default).
621    /// `None` when no profile is configured and the command doesn't require one.
622    pub profile_name: Option<String>,
623    /// Resolved Docker image for type checking and tests.
624    pub docker_image: String,
625    /// Per-profile config (security, profile_suffix) — used for SecretResolver.
626    /// Default-constructed (empty variables, no suffix) when `profile_name` is `None`.
627    pub profile_config: ProfileConfig,
628    /// Validated external dependencies declared in `project.toml`.
629    pub dependencies: BTreeSet<ObjectId>,
630    /// Database connection profile. None for commands that don't connect (compile, test).
631    connection: Option<Profile>,
632}
633
634impl Settings {
635    /// Load settings from CLI args, project.toml, and profiles.toml.
636    ///
637    /// `needs_connection` controls whether a connection profile is loaded from
638    /// `profiles.toml`. Commands like `compile` and `test` don't need one and
639    /// will succeed with no profile selected; in that case `profile_name`
640    /// is `None` and `profile_config` is default.
641    pub fn load(
642        directory: PathBuf,
643        cli_profile: Option<&str>,
644        docker_image_override: Option<&str>,
645        needs_connection: bool,
646        profiles_dir: Option<&Path>,
647    ) -> Result<Self, ConfigError> {
648        let project_settings = ProjectSettings::load(&directory)?;
649
650        // Resolution order: --profile flag / MZ_DEPLOY_PROFILE env var (both
651        // arrive via `cli_profile`), then the project-root pointer written
652        // by `mz-deploy profile set`. When all three are missing:
653        //   - commands that connect (`needs_connection: true`) hard-error
654        //   - commands that don't connect proceed with `profile_name: None`
655        let profile_name: Option<String> = match cli_profile {
656            Some(p) => Some(p.to_string()),
657            None => match read_mzprofile(&directory)? {
658                Some(p) => Some(p),
659                None if needs_connection => return Err(ConfigError::NoProfileConfigured),
660                None => None,
661            },
662        };
663
664        let profile_config = match &profile_name {
665            Some(name) => project_settings.config_for_profile(name),
666            None => ProfileConfig::default(),
667        };
668
669        let docker_image = match docker_image_override {
670            Some(image) => image.to_string(),
671            None => project_settings.docker_image(),
672        };
673
674        let dependencies = project_settings.validate_dependencies()?;
675
676        let connection = if needs_connection {
677            // Safe to unwrap: if we got here with needs_connection, profile_name is Some.
678            let name = profile_name
679                .as_deref()
680                .expect("needs_connection requires a profile name");
681            Some(ProfilesConfig::resolve_profile(profiles_dir, name)?)
682        } else {
683            None
684        };
685
686        Ok(Settings {
687            directory,
688            profile_name,
689            docker_image,
690            profile_config,
691            dependencies,
692            connection,
693        })
694    }
695
696    /// The active profile name, if one is set.
697    pub fn profile_name(&self) -> Option<&str> {
698        self.profile_name.as_deref()
699    }
700
701    /// Profile suffix applied to both database and cluster names (e.g., `"_staging"`).
702    pub fn profile_suffix(&self) -> Option<&str> {
703        self.profile_config.profile_suffix.as_deref()
704    }
705
706    /// psql-style variables for this profile.
707    pub fn variables(&self) -> &BTreeMap<String, String> {
708        &self.profile_config.variables
709    }
710
711    /// Whether this profile targets the single-user emulator, in which case the
712    /// RBAC role/grant machinery is treated as disabled.
713    pub fn emulator(&self) -> bool {
714        self.profile_config.emulator
715    }
716
717    /// Returns the database connection profile.
718    ///
719    /// # Panics
720    /// Panics if `Settings` was loaded with `needs_connection: false`.
721    /// Calling this on a non-connected `Settings` is a programmer error.
722    pub fn connection(&self) -> &Profile {
723        self.connection
724            .as_ref()
725            .expect("Settings::connection() called but needs_connection was false")
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    #[mz_ore::test]
734    fn test_profile_config_deserializes_profile_suffix() {
735        let toml = r#"
736
737            [staging]
738            profile_suffix = "_staging"
739        "#;
740        let settings: ProjectSettings = toml::from_str(toml).unwrap();
741        let config = settings.config_for_profile("staging");
742        assert_eq!(config.profile_suffix.as_deref(), Some("_staging"));
743    }
744
745    #[mz_ore::test]
746    fn test_profile_config_deserializes_emulator() {
747        let toml = r#"
748
749            [local]
750            emulator = true
751        "#;
752        let settings: ProjectSettings = toml::from_str(toml).unwrap();
753        let config = settings.config_for_profile("local");
754        assert!(config.emulator);
755    }
756
757    #[mz_ore::test]
758    fn test_profile_config_emulator_defaults_false() {
759        let toml = r#"
760
761            [staging]
762            profile_suffix = "_staging"
763        "#;
764        let settings: ProjectSettings = toml::from_str(toml).unwrap();
765        let config = settings.config_for_profile("staging");
766        assert!(!config.emulator);
767    }
768
769    #[mz_ore::test]
770    fn test_builtin_emulator_config_defaults_emulator_true() {
771        let settings: ProjectSettings = toml::from_str("").unwrap();
772        let config = settings.config_for_profile(EMULATOR_PROFILE_NAME);
773        assert!(config.emulator);
774    }
775
776    #[mz_ore::test]
777    fn test_user_defined_emulator_config_overrides_builtin() {
778        let toml = r#"
779
780            [emulator]
781            profile_suffix = "_local"
782        "#;
783        let settings: ProjectSettings = toml::from_str(toml).unwrap();
784        let config = settings.config_for_profile(EMULATOR_PROFILE_NAME);
785        // The user's entry wins, so `emulator` is whatever they set (default false).
786        assert!(!config.emulator);
787        assert_eq!(config.profile_suffix.as_deref(), Some("_local"));
788    }
789
790    #[mz_ore::test]
791    fn test_profile_config_profile_suffix_optional() {
792        let toml = r#"
793
794            [prod.security]
795            aws_profile = "prod-aws"
796        "#;
797        let settings: ProjectSettings = toml::from_str(toml).unwrap();
798        let config = settings.config_for_profile("prod");
799        assert!(config.profile_suffix.is_none());
800        assert_eq!(config.security.aws_profile(), Some("prod-aws"));
801    }
802
803    #[mz_ore::test]
804    fn test_suffix_for_profile_returns_suffix() {
805        let toml = r#"
806
807            [staging]
808            profile_suffix = "_staging"
809        "#;
810        let settings: ProjectSettings = toml::from_str(toml).unwrap();
811        assert_eq!(settings.suffix_for_profile("staging"), Some("_staging"));
812    }
813
814    #[mz_ore::test]
815    fn test_suffix_for_profile_missing_profile() {
816        let toml = r#"
817
818            [staging]
819            profile_suffix = "_staging"
820        "#;
821        let settings: ProjectSettings = toml::from_str(toml).unwrap();
822        assert_eq!(settings.suffix_for_profile("nonexistent"), None);
823    }
824
825    #[mz_ore::test]
826    fn test_suffix_for_profile_no_profiles_section() {
827        let toml = r#"
828        "#;
829        let settings: ProjectSettings = toml::from_str(toml).unwrap();
830        assert_eq!(settings.suffix_for_profile("staging"), None);
831    }
832
833    #[mz_ore::test]
834    fn test_config_for_profile_without_security_section() {
835        let toml = r#"
836
837            [prod]
838            profile_suffix = "_prod"
839        "#;
840        let settings: ProjectSettings = toml::from_str(toml).unwrap();
841        let config = settings.config_for_profile("prod");
842        assert_eq!(config.profile_suffix.as_deref(), Some("_prod"));
843        assert_eq!(config.security.aws_profile(), None);
844    }
845
846    #[mz_ore::test]
847    fn test_profile_config_deserializes_variables() {
848        let toml = r#"
849
850            [staging.variables]
851            cluster = "staging_cluster"
852            pg_host = "staging-replica.internal"
853        "#;
854        let settings: ProjectSettings = toml::from_str(toml).unwrap();
855        let config = settings.config_for_profile("staging");
856        assert_eq!(
857            config.variables.get("cluster").map(|s| s.as_str()),
858            Some("staging_cluster")
859        );
860        assert_eq!(
861            config.variables.get("pg_host").map(|s| s.as_str()),
862            Some("staging-replica.internal")
863        );
864    }
865
866    #[mz_ore::test]
867    fn test_profile_config_variables_default_empty() {
868        let toml = r#"
869
870            [prod]
871            profile_suffix = "_prod"
872        "#;
873        let settings: ProjectSettings = toml::from_str(toml).unwrap();
874        let config = settings.config_for_profile("prod");
875        assert!(config.variables.is_empty());
876    }
877
878    #[mz_ore::test]
879    fn test_profile_config_variables_missing_profile() {
880        let toml = r#"
881        "#;
882        let settings: ProjectSettings = toml::from_str(toml).unwrap();
883        let config = settings.config_for_profile("nonexistent");
884        assert!(config.variables.is_empty());
885    }
886
887    #[mz_ore::test]
888    fn test_dependencies_parses_valid_entries() {
889        let toml = r#"
890
891            dependencies = [
892                "ontology.public.customers",
893                "ontology.public.orders",
894            ]
895        "#;
896        let settings: ProjectSettings = toml::from_str(toml).unwrap();
897        let deps = settings.validate_dependencies().unwrap();
898        assert_eq!(deps.len(), 2);
899        assert!(deps.iter().any(|d| d.object() == "customers"));
900        assert!(deps.iter().any(|d| d.object() == "orders"));
901    }
902
903    #[mz_ore::test]
904    fn test_dependencies_defaults_to_empty() {
905        let toml = r#"
906        "#;
907        let settings: ProjectSettings = toml::from_str(toml).unwrap();
908        assert!(settings.validate_dependencies().unwrap().is_empty());
909    }
910
911    #[mz_ore::test]
912    fn test_dependencies_rejects_two_part_name() {
913        let toml = r#"
914            dependencies = ["public.orders"]
915        "#;
916        let settings: ProjectSettings = toml::from_str(toml).unwrap();
917        let err = settings.validate_dependencies().unwrap_err();
918        assert!(matches!(err, ConfigError::InvalidDependency { .. }));
919    }
920
921    #[mz_ore::test]
922    fn test_dependencies_rejects_duplicates() {
923        let toml = r#"
924            dependencies = [
925                "ontology.public.customers",
926                "ontology.public.customers",
927            ]
928        "#;
929        let settings: ProjectSettings = toml::from_str(toml).unwrap();
930        let err = settings.validate_dependencies().unwrap_err();
931        assert!(matches!(err, ConfigError::DuplicateDependency { .. }));
932    }
933
934    #[mz_ore::test]
935    fn test_profile_deserializes_options() {
936        let toml = r#"
937            [staging]
938            host = "staging.example.com"
939            username = "deploy_bot"
940
941            [staging.options]
942            cluster = "staging_cluster"
943            search_path = "public,reporting"
944        "#;
945        let data: BTreeMap<String, ProfileData> = toml::from_str(toml).unwrap();
946        let staging = data.get("staging").unwrap();
947        assert_eq!(
948            staging.options.get("cluster").map(String::as_str),
949            Some("staging_cluster")
950        );
951        assert_eq!(
952            staging.options.get("search_path").map(String::as_str),
953            Some("public,reporting")
954        );
955    }
956
957    #[mz_ore::test]
958    fn test_profile_options_default_empty() {
959        let toml = r#"
960            [prod]
961            host = "prod.example.com"
962            username = "deploy_bot"
963        "#;
964        let data: BTreeMap<String, ProfileData> = toml::from_str(toml).unwrap();
965        assert!(data.get("prod").unwrap().options.is_empty());
966    }
967
968    #[mz_ore::test]
969    fn test_is_valid_option_key_accepts_identifiers() {
970        assert!(is_valid_option_key("cluster"));
971        assert!(is_valid_option_key("search_path"));
972        assert!(is_valid_option_key("_mz_internal"));
973        assert!(is_valid_option_key("a1"));
974        assert!(is_valid_option_key("A"));
975    }
976
977    #[mz_ore::test]
978    fn test_is_valid_option_key_rejects_invalid() {
979        assert!(!is_valid_option_key(""));
980        assert!(!is_valid_option_key("search path"));
981        assert!(!is_valid_option_key("1cluster"));
982        assert!(!is_valid_option_key("cluster-name"));
983        assert!(!is_valid_option_key("cluster.name"));
984        assert!(!is_valid_option_key("'"));
985        assert!(!is_valid_option_key("café"));
986    }
987
988    #[mz_ore::test]
989    fn test_profiles_config_rejects_invalid_option_key() {
990        let dir = tempfile::tempdir().unwrap();
991        let path = dir.path().join("profiles.toml");
992        fs::write(
993            &path,
994            r#"
995                [staging]
996                host = "staging.example.com"
997                username = "deploy_bot"
998
999                [staging.options]
1000                "search path" = "public"
1001            "#,
1002        )
1003        .unwrap();
1004        let err = ProfilesConfig::load(Some(dir.path())).unwrap_err();
1005        match err {
1006            ConfigError::InvalidOptionKey { profile, key } => {
1007                assert_eq!(profile, "staging");
1008                assert_eq!(key, "search path");
1009            }
1010            other => panic!("expected InvalidOptionKey, got {other:?}"),
1011        }
1012    }
1013
1014    #[mz_ore::test]
1015    fn test_builtin_emulator_profile_present_in_loaded_config() {
1016        let dir = tempfile::tempdir().unwrap();
1017        fs::write(
1018            dir.path().join("profiles.toml"),
1019            r#"
1020                [staging]
1021                host = "staging.example.com"
1022                username = "deploy_bot"
1023            "#,
1024        )
1025        .unwrap();
1026        let config = ProfilesConfig::load(Some(dir.path())).unwrap();
1027        assert!(config.profile_names().contains(&EMULATOR_PROFILE_NAME));
1028        let profile = config.get_profile(EMULATOR_PROFILE_NAME).unwrap();
1029        assert_eq!(profile.host.as_deref(), Some("localhost"));
1030        assert_eq!(profile.port, 6875);
1031        assert_eq!(profile.username, "materialize");
1032    }
1033
1034    #[mz_ore::test]
1035    fn test_builtin_emulator_profile_resolves_without_profiles_file() {
1036        let dir = tempfile::tempdir().unwrap();
1037        // No profiles.toml written.
1038        let profile =
1039            ProfilesConfig::resolve_profile(Some(dir.path()), EMULATOR_PROFILE_NAME).unwrap();
1040        assert_eq!(profile.host.as_deref(), Some("localhost"));
1041        assert_eq!(profile.port, 6875);
1042        assert_eq!(profile.username, "materialize");
1043    }
1044
1045    #[mz_ore::test]
1046    fn test_user_defined_emulator_profile_overrides_builtin() {
1047        let dir = tempfile::tempdir().unwrap();
1048        fs::write(
1049            dir.path().join("profiles.toml"),
1050            r#"
1051                [emulator]
1052                host = "custom.example.com"
1053                username = "alice"
1054            "#,
1055        )
1056        .unwrap();
1057        let config = ProfilesConfig::load(Some(dir.path())).unwrap();
1058        let profile = config.get_profile(EMULATOR_PROFILE_NAME).unwrap();
1059        assert_eq!(profile.host.as_deref(), Some("custom.example.com"));
1060        assert_eq!(profile.username, "alice");
1061    }
1062
1063    #[mz_ore::test]
1064    fn mzprofile_absent_returns_none() {
1065        let dir = tempfile::tempdir().unwrap();
1066        assert_eq!(read_mzprofile(dir.path()).unwrap(), None);
1067    }
1068
1069    #[mz_ore::test]
1070    fn mzprofile_reads_single_line() {
1071        let dir = tempfile::tempdir().unwrap();
1072        fs::write(dir.path().join(MZPROFILE_FILENAME), "staging\n").unwrap();
1073        assert_eq!(
1074            read_mzprofile(dir.path()).unwrap().as_deref(),
1075            Some("staging")
1076        );
1077    }
1078
1079    #[mz_ore::test]
1080    fn mzprofile_skips_comments_and_blanks() {
1081        let dir = tempfile::tempdir().unwrap();
1082        fs::write(
1083            dir.path().join(MZPROFILE_FILENAME),
1084            "# set by `mz-deploy profile set`\n\n  dev\n",
1085        )
1086        .unwrap();
1087        assert_eq!(read_mzprofile(dir.path()).unwrap().as_deref(), Some("dev"));
1088    }
1089
1090    #[mz_ore::test]
1091    fn mzprofile_write_then_read_roundtrip() {
1092        let dir = tempfile::tempdir().unwrap();
1093        write_mzprofile(dir.path(), "prod").unwrap();
1094        assert_eq!(read_mzprofile(dir.path()).unwrap().as_deref(), Some("prod"));
1095    }
1096
1097    #[mz_ore::test]
1098    fn project_toml_rejects_string_under_profile_key() {
1099        let err = toml::from_str::<ProjectSettings>(r#"profile = "default""#).unwrap_err();
1100        assert!(
1101            err.to_string().contains("expected struct ProfileConfig"),
1102            "got: {err}"
1103        );
1104    }
1105
1106    #[mz_ore::test]
1107    fn validate_dependencies_accepts_user_three_part() {
1108        let settings: ProjectSettings =
1109            toml::from_str(r#"dependencies = ["materialize.public.foo"]"#).unwrap();
1110        let deps = settings.validate_dependencies().unwrap();
1111        assert_eq!(deps.len(), 1);
1112        let dep = deps.iter().next().unwrap();
1113        assert_eq!(dep.database(), Some("materialize"));
1114        assert_eq!(dep.schema(), "public");
1115        assert_eq!(dep.object(), "foo");
1116    }
1117
1118    #[mz_ore::test]
1119    fn validate_dependencies_accepts_system_two_part() {
1120        let settings: ProjectSettings =
1121            toml::from_str(r#"dependencies = ["mz_catalog.mz_objects", "pg_catalog.pg_class"]"#)
1122                .unwrap();
1123        let deps = settings.validate_dependencies().unwrap();
1124        assert_eq!(deps.len(), 2);
1125        for dep in &deps {
1126            assert_eq!(dep.database(), None);
1127        }
1128    }
1129
1130    #[mz_ore::test]
1131    fn validate_dependencies_rejects_non_system_two_part() {
1132        let settings: ProjectSettings = toml::from_str(r#"dependencies = ["public.foo"]"#).unwrap();
1133        let err = settings.validate_dependencies().unwrap_err();
1134        assert!(matches!(err, ConfigError::InvalidDependency { .. }));
1135    }
1136
1137    #[mz_ore::test]
1138    fn validate_dependencies_rejects_one_part() {
1139        let settings: ProjectSettings = toml::from_str(r#"dependencies = ["foo"]"#).unwrap();
1140        let err = settings.validate_dependencies().unwrap_err();
1141        assert!(matches!(err, ConfigError::InvalidDependency { .. }));
1142    }
1143
1144    #[mz_ore::test]
1145    fn validate_dependencies_rejects_duplicates() {
1146        let settings: ProjectSettings =
1147            toml::from_str(r#"dependencies = ["mz_catalog.mz_objects", "mz_catalog.mz_objects"]"#)
1148                .unwrap();
1149        let err = settings.validate_dependencies().unwrap_err();
1150        assert!(matches!(err, ConfigError::DuplicateDependency { .. }));
1151    }
1152}