Skip to main content

mz_deploy/cli/
error.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//! Error types for CLI operations.
11//!
12//! This module provides error types for high-level CLI commands that wrap
13//! lower-level errors from the client and project modules.
14
15use crate::cli::commands::test::TestValidationError;
16use crate::client::{
17    ConflictRecord, ConnectionError, DatabaseValidationError, ProductionClusterRecord,
18};
19use crate::config::ConfigError;
20use crate::project::analysis::deployment_snapshot::DeploymentSnapshotError;
21use crate::project::compiler::typecheck::TypeCheckError;
22use crate::project::error::{DependencyError, ProjectError};
23use crate::project::ir::object_id::ObjectId;
24use crate::secret_resolver::SecretResolveError;
25use crate::types::TypesError;
26use chrono::{DateTime, Local};
27use owo_colors::{OwoColorize, Stream, Style};
28use thiserror::Error;
29
30/// An object `setup::verify` expected to find but didn't.
31///
32/// Used to build an actionable hint on [`CliError::SetupRequired`] that names
33/// the missing pieces so the user knows whether to run `setup` for the first
34/// time or re-run it after an mz-deploy upgrade.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum MissingObject {
37    /// The `_mz_deploy_server` cluster.
38    Cluster(String),
39    /// The `_mz_deploy` database.
40    Database(String),
41    /// A table, view, or index inside `_mz_deploy`.
42    SchemaObject {
43        schema: String,
44        name: String,
45        kind: String,
46    },
47    /// One of the `materialize_*` roles.
48    Role(String),
49}
50
51/// Top-level error type for CLI operations.
52///
53/// This wraps errors from project loading, database operations, and
54/// adds CLI-specific error variants.
55#[derive(Debug, Error)]
56pub enum CliError {
57    /// Error during project compilation/loading
58    #[error(transparent)]
59    Project(#[from] ProjectError),
60
61    /// Configuration loading error
62    #[error(transparent)]
63    Config(#[from] ConfigError),
64
65    /// Database connection error
66    #[error(transparent)]
67    Connection(#[from] ConnectionError),
68
69    /// Deployment snapshot operation error
70    #[error(transparent)]
71    DeploymentSnapshot(#[from] DeploymentSnapshotError),
72
73    /// Dependency analysis error
74    #[error(transparent)]
75    Dependency(#[from] DependencyError),
76
77    /// Validation error (missing databases, schemas, clusters)
78    #[error(transparent)]
79    Validation(DatabaseValidationError),
80
81    /// Types lock file error
82    #[error(transparent)]
83    Types(#[from] TypesError),
84
85    /// Deployment conflict detected - schemas were updated after deployment started
86    #[error("deployment conflict: {count} schema{plural} updated since deployment started",
87        count = conflicts.len(),
88        plural = if conflicts.len() == 1 { "" } else { "s" })]
89    DeploymentConflict { conflicts: Vec<ConflictRecord> },
90
91    /// Failed to determine git SHA
92    #[error("failed to determine git SHA for staging environment name")]
93    GitShaFailed,
94
95    /// Git repository has uncommitted changes
96    #[error("git repository has uncommitted changes")]
97    GitDirty,
98
99    /// No schemas to deploy
100    #[error("no schemas found to deploy")]
101    NoSchemas,
102
103    /// Attempting to overwrite production objects without proper safety flags
104    #[error("refusing to overwrite production objects")]
105    ProductionOverwriteNotAllowed {
106        objects: Vec<(String, String, String)>, // (database, schema, object)
107    },
108
109    /// `dev` would deploy to a cluster that hosts a promoted deployment.
110    #[error("refusing to deploy dev overlay onto production cluster")]
111    DevTargetsProductionCluster { cluster: ProductionClusterRecord },
112
113    /// Required mz-deploy infrastructure is missing or partially installed.
114    /// Emitted by every non-`setup` command when `setup::verify` fails.
115    #[error("mz-deploy infrastructure is not fully initialized")]
116    SetupRequired { missing: Vec<MissingObject> },
117
118    /// `setup` was invoked by a role that is not the owner of the existing
119    /// `_mz_deploy` database. Only the owner can re-run `setup`.
120    #[error("`_mz_deploy` is owned by role '{owner}', not by the current role '{current_role}'")]
121    SetupNotDatabaseOwner { owner: String, current_role: String },
122
123    /// `setup` was invoked by a non-superuser. Setup issues
124    /// `GRANT ... ON SYSTEM` statements that only superusers can execute.
125    #[error("`mz-deploy setup` requires a superuser role (current role: '{current_role}')")]
126    SetupRequiresSuperuser { current_role: String },
127
128    /// A unit test targets an object that isn't a view or materialized view.
129    /// Unlike assertion mismatches, this is a project-definition error: the
130    /// `test` run aborts rather than reporting it as a failed test.
131    #[error("unit test '{test_name}' on '{object_id}' cannot be desugared: {reason}")]
132    InvalidUnitTestTarget {
133        test_name: String,
134        object_id: String,
135        reason: String,
136    },
137
138    /// Failed to create deployment table
139    #[error("failed to create deployment tracking table: {source}")]
140    DeploymentTableCreationFailed { source: ConnectionError },
141
142    /// Failed to execute SQL during deployment
143    #[error("failed to execute SQL statement: {source}")]
144    SqlExecutionFailed {
145        statement: String,
146        source: ConnectionError,
147    },
148
149    /// Failed to repoint a sink to a new upstream object
150    #[error("failed to repoint sink {sink}: {reason}")]
151    SinkRepointFailed { sink: String, reason: String },
152
153    /// Failed to write deployment state
154    #[error("failed to write deployment state to tracking table: {source}")]
155    DeploymentStateWriteFailed { source: ConnectionError },
156
157    /// Invalid staging environment name
158    #[error("invalid staging environment name: '{name}'")]
159    InvalidEnvironmentName { name: String },
160
161    /// Schema does not exist in database
162    #[error("schema '{schema}' does not exist in database '{database}'")]
163    SchemaNotFound { database: String, schema: String },
164
165    /// Cluster does not exist
166    #[error("cluster '{name}' does not exist")]
167    ClusterNotFound { name: String },
168
169    /// Tests failed during execution
170    #[error("{failed} test{plural} failed, {passed} passed",
171        plural = if *failed == 1 { "" } else { "s" })]
172    TestsFailed { failed: usize, passed: usize },
173
174    /// Tests filter did not match anythinh
175    #[error("no tests matched filter '{filter}'")]
176    TestsFilterMissed { filter: String },
177
178    /// Test validation failed (schema mismatch, missing mocks)
179    #[error(transparent)]
180    TestValidationFailed(#[from] TestValidationError),
181
182    /// Type check failed
183    #[error(transparent)]
184    TypeCheckFailed(#[from] TypeCheckError),
185
186    /// Timeout waiting for deployment to be ready
187    #[error("timeout waiting for deployment '{name}' to be ready after {seconds} seconds")]
188    ReadyTimeout { name: String, seconds: u64 },
189
190    /// I/O error
191    #[error("I/O error: {0}")]
192    Io(#[from] std::io::Error),
193
194    /// Clusters are not yet hydrated
195    #[error("some clusters are still hydrating")]
196    ClustersHydrating,
197
198    /// Deployment is failing due to cluster health issues
199    #[error("deployment '{name}' is failing due to cluster health issues")]
200    DeploymentFailing { name: String },
201
202    /// Cannot add new objects to an existing stable schema during incremental deployment
203    #[error("cannot add new objects to existing stable schema '{database}.{schema}'")]
204    NewObjectInExistingStableSchema {
205        database: String,
206        schema: String,
207        objects: Vec<String>,
208    },
209
210    /// Secret resolution failed
211    #[error("failed to resolve secret '{secret_name}': {source}")]
212    SecretResolution {
213        secret_name: String,
214        source: SecretResolveError,
215    },
216
217    /// Cluster is not ready for mz-deploy operations
218    #[error("cluster '{cluster}' is not ready: {reason}")]
219    ClusterNotReady { cluster: String, reason: String },
220
221    /// Current role has no mz-deploy role membership
222    #[error("current role is not a member of any mz-deploy role")]
223    NoMzDeployRole,
224
225    /// Current role has multiple mz-deploy role memberships
226    #[error("current role is a member of multiple mz-deploy roles: {}", roles.join(", "))]
227    MultipleMzDeployRoles { roles: Vec<String> },
228
229    /// Current role is not authorized for this operation
230    #[error(
231        "this command requires the '{required_role}' role, but you are connected as '{current_role}'"
232    )]
233    RoleNotAuthorized {
234        current_role: String,
235        required_role: String,
236    },
237
238    /// Current role lacks CREATEDB privilege required to create overlay databases
239    #[error(
240        "user {role} lacks CREATEDB privilege, required to create overlay \
241         database {overlay_db}.\n\n\
242         Ask an administrator to run:\n    GRANT CREATEDB ON SYSTEM TO {role};"
243    )]
244    MissingCreatedb { role: String, overlay_db: String },
245
246    /// Project directory has no basename usable as a project identifier
247    /// (e.g. root or `.`).
248    #[error("cannot derive project name from directory '{path}'")]
249    InvalidProjectDirectory { path: String },
250
251    /// Project references external objects not declared in project.toml
252    #[error("undeclared external dependencies")]
253    UndeclaredDependencies { undeclared: Vec<ObjectId> },
254
255    /// Declared dependencies not found in the target database during lock
256    #[error("declared dependencies not found in target database")]
257    DeclaredDependenciesMissing { missing: Vec<ObjectId> },
258
259    /// Generic error message
260    #[error("{0}")]
261    Message(String),
262}
263
264impl CliError {
265    /// Get contextual hint for resolving this error.
266    ///
267    /// Returns `None` for errors that wrap other error types (they provide their own hints).
268    pub fn hint(&self) -> Option<String> {
269        match self {
270            Self::Project(_) => None,
271            Self::Connection(e) => match e {
272                ConnectionError::DeploymentNotFound { deploy_id } => Some(format!(
273                    "verify the staging environment name '{}' is correct, or deploy to staging first using:\n  \
274                     {} {} {} --name {}",
275                    deploy_id.if_supports_color(Stream::Stderr, |t| t.yellow()),
276                    "mz-deploy".if_supports_color(Stream::Stderr, |t| t.cyan()),
277                    "stage".if_supports_color(Stream::Stderr, |t| t.cyan()),
278                    ".".if_supports_color(Stream::Stderr, |t| t.cyan()),
279                    deploy_id.if_supports_color(Stream::Stderr, |t| t.cyan())
280                )),
281                ConnectionError::DeploymentAlreadyPromoted { .. } => Some(
282                    "this staging environment has already been applied to production.\n\
283                     Deploy a new staging environment to make changes"
284                        .to_string(),
285                ),
286                _ => None,
287            },
288            Self::DeploymentConflict { conflicts } => {
289                let conflict_list = conflicts
290                    .iter()
291                    .map(|c| {
292                        let promoted_datetime: DateTime<Local> = c.promoted_at.into();
293                        let promoted_str = promoted_datetime
294                            .format("%a %b %d %H:%M:%S %Y %z")
295                            .to_string();
296                        format!("  - {}.{} (last promoted by '{}' at {})",
297                            c.database.if_supports_color(Stream::Stderr, |t| t.yellow()),
298                            c.schema.if_supports_color(Stream::Stderr, |t| t.yellow()),
299                            c.deploy_id,
300                            promoted_str)
301                    })
302                    .collect::<Vec<_>>()
303                    .join("\n");
304                let force_style = Style::new().yellow().bold();
305                Some(format!(
306                    "the following schemas were updated in production after your deployment started:\n{}\n\n\
307                     Rebase your deployment by running:\n  \
308                     {} {} {}\n  \
309                     {} {} {} --name <staging-env>\n\n\
310                     Or use {} to force the deployment (may overwrite recent changes)",
311                    conflict_list,
312                    "mz-deploy".if_supports_color(Stream::Stderr, |t| t.cyan()),
313                    "abort".if_supports_color(Stream::Stderr, |t| t.cyan()),
314                    "--name <staging-env>".if_supports_color(Stream::Stderr, |t| t.cyan()),
315                    "mz-deploy".if_supports_color(Stream::Stderr, |t| t.cyan()),
316                    "stage".if_supports_color(Stream::Stderr, |t| t.cyan()),
317                    ".".if_supports_color(Stream::Stderr, |t| t.cyan()),
318                    "--force".if_supports_color(Stream::Stderr, |t| force_style.style(t))
319                ))
320            }
321            Self::GitShaFailed => Some(
322                "either run mz-deploy from inside a git repository, or provide a staging environment name using:\n  \
323                 mz-deploy stage . --name <environment-name>"
324                    .to_string(),
325            ),
326            Self::GitDirty => Some(
327                "commit or stash your changes before deploying, or use the --allow-dirty flag to deploy anyway"
328                    .to_string(),
329            ),
330            Self::NoSchemas => Some(
331                "create at least one schema directory under your project directory (e.g., materialize/public/)"
332                    .to_string(),
333            ),
334            Self::ProductionOverwriteNotAllowed { objects } => {
335                let object_list = objects
336                    .iter()
337                    .take(5)
338                    .map(|(db, schema, obj)| {
339                        format!(
340                            "  - {}.{}.{}",
341                            db.if_supports_color(Stream::Stderr, |t| t.yellow()),
342                            schema.if_supports_color(Stream::Stderr, |t| t.yellow()),
343                            obj.if_supports_color(Stream::Stderr, |t| t.yellow())
344                        )
345                    })
346                    .collect::<Vec<_>>()
347                    .join("\n");
348                let more = if objects.len() > 5 {
349                    format!("\n  ... and {} more", objects.len() - 5)
350                } else {
351                    String::new()
352                };
353                Some(format!(
354                    "the following objects already exist in production:\n{}{}\n\n\
355                     To update existing objects, use blue/green deployment:\n  \
356                     {} {} {}\n  \
357                     {} {} {} <staging-env>",
358                    object_list,
359                    more,
360                    "mz-deploy".if_supports_color(Stream::Stderr, |t| t.cyan()),
361                    "stage".if_supports_color(Stream::Stderr, |t| t.cyan()),
362                    ".".if_supports_color(Stream::Stderr, |t| t.cyan()),
363                    "mz-deploy".if_supports_color(Stream::Stderr, |t| t.cyan()),
364                    "apply".if_supports_color(Stream::Stderr, |t| t.cyan()),
365                    "--staging-env".if_supports_color(Stream::Stderr, |t| t.cyan())
366                ))
367            }
368            Self::SetupRequired { missing } => {
369                let list = missing
370                    .iter()
371                    .take(5)
372                    .map(|m| match m {
373                        MissingObject::Cluster(name) => {
374                            format!("  • cluster {}", name.if_supports_color(Stream::Stderr, |t| t.yellow()))
375                        }
376                        MissingObject::Database(name) => {
377                            format!("  • database {}", name.if_supports_color(Stream::Stderr, |t| t.yellow()))
378                        }
379                        MissingObject::SchemaObject { schema, name, kind } => {
380                            format!(
381                                "  • {} _mz_deploy.{}.{}",
382                                kind,
383                                schema.if_supports_color(Stream::Stderr, |t| t.yellow()),
384                                name.if_supports_color(Stream::Stderr, |t| t.yellow()),
385                            )
386                        }
387                        MissingObject::Role(name) => {
388                            format!("  • role {}", name.if_supports_color(Stream::Stderr, |t| t.yellow()))
389                        }
390                    })
391                    .collect::<Vec<_>>()
392                    .join("\n");
393                let more = if missing.len() > 5 {
394                    format!("\n  ... and {} more", missing.len() - 5)
395                } else {
396                    String::new()
397                };
398                Some(format!(
399                    "the following mz-deploy objects are missing:\n{}{}\n\n\
400                     run {} as an admin role with the {}, {}, and {} system \
401                     privileges to initialize or self-heal the installation.",
402                    list,
403                    more,
404                    "mz-deploy setup".if_supports_color(Stream::Stderr, |t| t.cyan()),
405                    "CREATECLUSTER".if_supports_color(Stream::Stderr, |t| t.cyan()),
406                    "CREATEDB".if_supports_color(Stream::Stderr, |t| t.cyan()),
407                    "CREATEROLE".if_supports_color(Stream::Stderr, |t| t.cyan()),
408                ))
409            }
410            Self::InvalidUnitTestTarget { .. } => Some(
411                "unit tests can only target CREATE VIEW or CREATE MATERIALIZED VIEW \
412                 statements. Move the test to a view/MV or remove it from the target \
413                 object's file."
414                    .to_string(),
415            ),
416            Self::SetupNotDatabaseOwner {
417                owner,
418                current_role,
419            } => Some(format!(
420                "{} is the only command that writes to `_mz_deploy`, and only \
421                 the owning role can do so.\n\n  \
422                 Re-run as {}, or have {} run {} to transfer ownership.",
423                "mz-deploy setup".if_supports_color(Stream::Stderr, |t| t.cyan()),
424                owner.if_supports_color(Stream::Stderr, |t| t.cyan()),
425                owner.if_supports_color(Stream::Stderr, |t| t.cyan()),
426                format!("ALTER DATABASE _mz_deploy OWNER TO {}", current_role).if_supports_color(Stream::Stderr, |t| t.cyan()),
427            )),
428            Self::SetupRequiresSuperuser { current_role } => Some(format!(
429                "{} grants {} and {} on the system. With RBAC enabled, only \
430                 a superuser can issue system grants, and the active role \
431                 {} is not a superuser.\n\n  \
432                 Re-run setup using a Materialize admin user, or have an \
433                 admin run it once on your behalf. After setup completes, \
434                 ordinary deployer/developer/monitor roles use mz-deploy \
435                 normally — only this bootstrap step requires elevated \
436                 privileges.",
437                "mz-deploy setup".if_supports_color(Stream::Stderr, |t| t.cyan()),
438                "CREATEDB".if_supports_color(Stream::Stderr, |t| t.cyan()),
439                "CREATECLUSTER".if_supports_color(Stream::Stderr, |t| t.cyan()),
440                current_role.if_supports_color(Stream::Stderr, |t| t.cyan()),
441            )),
442            Self::DevTargetsProductionCluster { cluster } => {
443                let promoted_local: DateTime<Local> = cluster.promoted_at.into();
444                let promoted_str = promoted_local.format("%b %d, %Y").to_string();
445                Some(format!(
446                    "cluster {} hosts a promoted deployment ({}.{}, promoted {}) \
447                     and cannot be targeted by dev.\n\n\
448                     re-run with a non-production cluster, e.g.:\n\n  \
449                     mz-deploy dev <dev-cluster>",
450                    cluster
451                        .cluster_name
452                        .if_supports_color(Stream::Stderr, |t| t.yellow()),
453                    cluster.database,
454                    cluster.schema,
455                    promoted_str,
456                ))
457            }
458            Self::DeploymentTableCreationFailed { .. } => Some(
459                "ensure your database user has CREATE privileges on the database"
460                    .to_string(),
461            ),
462            Self::SqlExecutionFailed { statement, .. } => Some(format!(
463                "SQL statement:\n  {}",
464                statement.lines().take(5).collect::<Vec<_>>().join("\n  ")
465            )),
466            Self::SinkRepointFailed { sink, .. } => Some(format!(
467                "the sink '{}' could not be repointed to the new upstream object.\n\
468                 This may happen if:\n  \
469                 - The new object has an incompatible schema (e.g., Avro schema mismatch)\n  \
470                 - The replacement object doesn't exist in the new schema\n\n\
471                 To proceed, you may need to manually drop and recreate the sink",
472                sink.if_supports_color(Stream::Stderr, |t| t.yellow())
473            )),
474            Self::DeploymentStateWriteFailed { .. } => Some(
475                "the SQL was applied successfully, but deployment tracking failed.\n\
476                 The next deployment may re-apply some objects"
477                    .to_string(),
478            ),
479            Self::InvalidEnvironmentName { .. } => Some(
480                "environment names must contain only alphanumeric characters, hyphens, and underscores"
481                    .to_string(),
482            ),
483            Self::SchemaNotFound { database, schema } => Some(format!(
484                "create the schema first, or check that you're connected to the correct database.\n  \
485                 CREATE SCHEMA {}.{}",
486                database.if_supports_color(Stream::Stderr, |t| t.cyan()),
487                schema.if_supports_color(Stream::Stderr, |t| t.cyan())
488            )),
489            Self::ClusterNotFound { name } => Some(format!(
490                "create the cluster first:\n  \
491                 CREATE CLUSTER {} SIZE = '{}' REPLICATION FACTOR = 1",
492                name.if_supports_color(Stream::Stderr, |t| t.cyan()),
493                "M.1-small".if_supports_color(Stream::Stderr, |t| t.cyan())
494            )),
495            Self::TestsFailed { .. } => None,
496            Self::TestValidationFailed(_) => Some(
497                "review the validation error above and update your test to match the schema.\n\
498                 Run 'mz-deploy compile' to regenerate the type cache if needed"
499                    .to_string(),
500            ),
501            Self::TypeCheckFailed(_) => None,
502            Self::ReadyTimeout { .. } => Some(
503                "deployment is taking longer than expected to hydrate. You can:\n  \
504                 - Increase timeout with --timeout flag\n  \
505                 - Check cluster replica status with: mz-deploy ready <env>"
506                    .to_string(),
507            ),
508            Self::ClustersHydrating => Some("check cluster replica status with: mz-deploy ready <env>".to_string()),
509            Self::DeploymentFailing { .. } => Some(
510                "one or more clusters are not ready. Check for:\n  \
511                 - Missing replicas (cluster has no replicas configured)\n  \
512                 - OOM-looping replicas (3+ OOM kills in 24 hours)\n\n\
513                 Use 'mz-deploy ready <env>' for details"
514                    .to_string(),
515            ),
516            Self::SecretResolution { .. } => Some(
517                "check that environment variables are set and function arguments are string literals"
518                    .to_string(),
519            ),
520            Self::NewObjectInExistingStableSchema { objects, .. } => {
521                let object_list = objects
522                    .iter()
523                    .take(5)
524                    .map(|obj| format!("  - {}", obj.if_supports_color(Stream::Stderr, |t| t.yellow())))
525                    .collect::<Vec<_>>()
526                    .join("\n");
527                let more = if objects.len() > 5 {
528                    format!("\n  ... and {} more", objects.len() - 5)
529                } else {
530                    String::new()
531                };
532                Some(format!(
533                    "the following new objects cannot be added during incremental deployment:\n{}{}\n\n\
534                     To add new objects, either:\n  \
535                     - Place them in a new schema (new schemas deploy via blue-green swap)\n  \
536                     - Deploy them separately before modifying existing objects in the same schema",
537                    object_list,
538                    more,
539                ))
540            }
541            Self::ClusterNotReady { cluster, reason } => {
542                if reason.contains("replication factor") {
543                    Some(format!(
544                        "the cluster '{}' has no replicas running. Ensure the cluster is scaled up:\n  \
545                         ALTER CLUSTER {} SET (REPLICATION FACTOR = 1)",
546                        cluster.if_supports_color(Stream::Stderr, |t| t.yellow()),
547                        cluster.if_supports_color(Stream::Stderr, |t| t.cyan())
548                    ))
549                } else {
550                    Some(format!(
551                        "grant USAGE on the cluster to your role:\n  \
552                         GRANT USAGE ON CLUSTER {} TO <your-role>",
553                        cluster.if_supports_color(Stream::Stderr, |t| t.cyan())
554                    ))
555                }
556            }
557            Self::NoMzDeployRole => Some(
558                "your role must be a member of one of: materialize_deployer, materialize_developer, or materialize_monitor.\n\
559                 Run 'mz-deploy setup' first, then grant a role:\n  \
560                 GRANT \"materialize_developer\" TO <your-role>"
561                    .to_string(),
562            ),
563            Self::MultipleMzDeployRoles { .. } => Some(
564                "each database role should have exactly one mz-deploy role.\n\
565                 Set up distinct profiles with separate roles for deploying vs. monitoring"
566                    .to_string(),
567            ),
568            Self::RoleNotAuthorized { current_role, required_role } => {
569                let action = if current_role == "materialize_developer" {
570                    "developing"
571                } else {
572                    "monitoring"
573                };
574                Some(format!(
575                    "your current profile is configured for {}, but this command requires deploying privileges.\n\
576                     Switch to a profile whose role is a member of '{}'",
577                    action,
578                    required_role.if_supports_color(Stream::Stderr, |t| t.cyan())
579                ))
580            }
581            Self::UndeclaredDependencies { undeclared } => {
582                let dep_list = undeclared
583                    .iter()
584                    .map(|d| format!("\t\"{}\",", d))
585                    .collect::<Vec<_>>()
586                    .join("\n");
587                Some(format!(
588                    "add these to project.toml:\n\n    dependencies = [\n{}\n    ]\n\n  \
589                     then run `{}` to fetch their schemas.",
590                    dep_list,
591                    "mz-deploy lock".if_supports_color(Stream::Stderr, |t| t.cyan())
592                ))
593            }
594            Self::DeclaredDependenciesMissing { missing } => {
595                let list = missing
596                    .iter()
597                    .map(|d| format!("  {} {}", "×".if_supports_color(Stream::Stderr, |t| t.red()), d))
598                    .collect::<Vec<_>>()
599                    .join("\n");
600                Some(format!(
601                    "{}\n\nthese objects must exist before running `lock`. Check that they \
602                     are deployed and that your profile points to the correct environment.",
603                    list
604                ))
605            }
606            Self::Config(ConfigError::NoProfileConfigured) => Some(format!(
607                "record a default profile for this project:\n  \
608                 {}\n\n\
609                 or pass {} for a one-off run, or export {}.",
610                "mz-deploy profile set <name>".if_supports_color(Stream::Stderr, |t| t.cyan()),
611                "--profile <name>".if_supports_color(Stream::Stderr, |t| t.cyan()),
612                "MZ_DEPLOY_PROFILE=<name>".if_supports_color(Stream::Stderr, |t| t.cyan()),
613            )),
614            Self::Config(_)
615            | Self::Validation(_)
616            | Self::Types(_)
617            | Self::DeploymentSnapshot(_)
618            | Self::Dependency(_) => {
619                // These errors provide their own context via transparent wrapping
620                None
621            }
622            Self::MissingCreatedb { .. } => None,
623            Self::InvalidProjectDirectory { .. } => None,
624            Self::Io(_) | Self::Message(_) | Self::TestsFilterMissed { .. } => None,
625        }
626    }
627}
628
629impl From<DatabaseValidationError> for CliError {
630    fn from(error: DatabaseValidationError) -> Self {
631        CliError::Validation(error)
632    }
633}
634
635impl From<String> for CliError {
636    fn from(msg: String) -> Self {
637        CliError::Message(msg)
638    }
639}