1use 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
35const DOCKER_IMAGE_BASE: &str = "materialize/materialized";
37
38pub fn default_docker_image() -> String {
40 format!("{DOCKER_IMAGE_BASE}:latest")
41}
42
43#[derive(Debug, Deserialize, Clone, Default)]
45#[serde(deny_unknown_fields)]
46pub struct SecurityConfig {
47 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#[derive(Debug, Deserialize, Clone, Default)]
62pub struct ProfileConfig {
63 pub profile_suffix: Option<String>,
68 #[serde(default)]
70 pub security: SecurityConfig,
71 #[serde(default)]
74 pub variables: BTreeMap<String, String>,
75 #[serde(default)]
79 pub emulator: bool,
80}
81
82#[derive(Debug, Deserialize, Clone)]
92pub struct ProjectSettings {
93 pub mz_version: Option<String>,
94
95 #[serde(flatten)]
96 pub profiles: BTreeMap<String, ProfileConfig>,
97 #[serde(default, rename = "dependencies")]
100 raw_dependencies: Vec<String>,
101}
102
103pub const MZPROFILE_FILENAME: &str = ".mzprofile";
110
111pub 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
137pub 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 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 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 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#[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#[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 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#[derive(Debug, Clone)]
321pub struct Profile {
322 pub name: String,
323 pub host: Option<String>,
325 pub port: u16,
326 pub username: String,
327 pub password: Option<String>,
328 pub options: BTreeMap<String, String>,
330 pub sslmode: Option<SslMode>,
333 pub sslrootcert: Option<PathBuf>,
336 pub http_host: Option<String>,
338}
339
340impl Profile {
341 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 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
384pub const EMULATOR_PROFILE_NAME: &str = "emulator";
391
392fn 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
407fn 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
422fn 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#[derive(Debug)]
442pub struct ProfilesConfig {
443 profiles: BTreeMap<String, Profile>,
444 source_path: PathBuf,
445}
446
447impl ProfilesConfig {
448 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 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 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 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 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 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 Err(ConfigError::ProfilesNotFound { .. }) if name == EMULATOR_PROFILE_NAME => {
598 Ok(emulator_profile())
599 }
600 Err(e) => Err(e),
601 }
602 }
603}
604
605#[derive(Debug, Clone)]
617pub struct Settings {
618 pub directory: PathBuf,
620 pub profile_name: Option<String>,
623 pub docker_image: String,
625 pub profile_config: ProfileConfig,
628 pub dependencies: BTreeSet<ObjectId>,
630 connection: Option<Profile>,
632}
633
634impl Settings {
635 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 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 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 pub fn profile_name(&self) -> Option<&str> {
698 self.profile_name.as_deref()
699 }
700
701 pub fn profile_suffix(&self) -> Option<&str> {
703 self.profile_config.profile_suffix.as_deref()
704 }
705
706 pub fn variables(&self) -> &BTreeMap<String, String> {
708 &self.profile_config.variables
709 }
710
711 pub fn emulator(&self) -> bool {
714 self.profile_config.emulator
715 }
716
717 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 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 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}