Skip to main content

tideway_cli/commands/
dev.rs

1//! Dev command - run a Tideway app with sensible defaults.
2
3use anyhow::{Context, Result};
4use std::collections::BTreeMap;
5use std::fs;
6use std::net::{TcpStream, ToSocketAddrs};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::str::FromStr;
10use std::time::Duration;
11
12use sqlx::postgres::{PgConnectOptions, PgConnection};
13use sqlx::{Connection, Executor};
14use toml_edit::DocumentMut;
15use url::Url;
16
17use crate::cli::DevArgs;
18use crate::commands::messaging::DEV_FIX_ENV_COMMAND;
19use crate::database::{
20    DatabaseUrlKind, redact_database_url, resolve_database_url, validate_database_url,
21};
22use crate::env::{ensure_env, ensure_project_dir, read_env_map};
23use crate::is_plan_mode;
24use crate::{error_contract, print_info, print_success, print_warning};
25
26pub fn run(args: DevArgs) -> Result<()> {
27    if is_plan_mode() {
28        print_info("Plan: would run tideway dev (cargo run) with env + migrations");
29        print_info("Primary run command for local development.");
30        return Ok(());
31    }
32    let project_dir = PathBuf::from(&args.path);
33    ensure_project_dir(&project_dir)?;
34
35    if !args.no_env {
36        ensure_env(&project_dir, args.fix_env)?;
37    }
38
39    let env_map = if !args.no_env {
40        read_env_map(&project_dir.join(".env"))
41    } else {
42        None
43    };
44
45    preflight_database(&project_dir, &env_map)?;
46
47    let mut command = Command::new("cargo");
48    command.arg("run").current_dir(&project_dir);
49
50    if !args.args.is_empty() {
51        command.args(&args.args);
52    }
53
54    if !args.no_env {
55        if let Some(env_map) = &env_map {
56            command.envs(env_map);
57        }
58    }
59
60    if !args.no_migrate {
61        if let Some(auto_migrate) = explicit_auto_migrate(&env_map) {
62            if auto_migrate.eq_ignore_ascii_case("true") {
63                print_info("DATABASE_AUTO_MIGRATE already set; honoring existing value");
64            } else {
65                print_warning(&format!(
66                    "DATABASE_AUTO_MIGRATE is set to '{}'; skipping automatic override",
67                    auto_migrate
68                ));
69            }
70        } else {
71            command.env("DATABASE_AUTO_MIGRATE", "true");
72            print_info(
73                "Setting DATABASE_AUTO_MIGRATE=true for this run. Use --no-migrate to disable.",
74            );
75        }
76    }
77
78    print_info("Starting Tideway app (primary local run command)...");
79    let status = command.status().context("Failed to run cargo")?;
80
81    if status.success() {
82        print_success("Process exited cleanly");
83        Ok(())
84    } else {
85        Err(anyhow::anyhow!("cargo exited with status {}", status))
86    }
87}
88
89fn explicit_auto_migrate(env_map: &Option<BTreeMap<String, String>>) -> Option<String> {
90    if let Ok(value) = std::env::var("DATABASE_AUTO_MIGRATE") {
91        return Some(value.trim().to_string());
92    }
93
94    if let Some(map) = env_map {
95        if let Some(value) = map.get("DATABASE_AUTO_MIGRATE") {
96            return Some(value.trim().to_string());
97        }
98    }
99
100    None
101}
102
103fn preflight_database(
104    project_dir: &Path,
105    env_map: &Option<BTreeMap<String, String>>,
106) -> Result<()> {
107    if !project_uses_database(project_dir)? {
108        return Ok(());
109    }
110
111    let database_url = resolve_database_url(env_map).ok_or_else(|| {
112        anyhow::anyhow!(error_contract(
113            "DATABASE_URL is missing.",
114            "Set DATABASE_URL in `.env` or your shell, then rerun `tideway dev`.",
115            &format!(
116                "Run {} to bootstrap `.env` from `.env.example`.",
117                DEV_FIX_ENV_COMMAND
118            )
119        ))
120    })?;
121
122    match validate_database_url(&database_url).map_err(|err| {
123        anyhow::anyhow!(error_contract(
124            &err.to_string(),
125            "Use a URL like `postgres://...` or `sqlite:...`.",
126            "Regenerate config with `tideway doctor --fix` and update DATABASE_URL."
127        ))
128    })? {
129        DatabaseUrlKind::Sqlite => Ok(()),
130        DatabaseUrlKind::Postgres => preflight_postgres(project_dir, &database_url),
131    }
132}
133
134fn project_uses_database(project_dir: &Path) -> Result<bool> {
135    let cargo_path = project_dir.join("Cargo.toml");
136    let contents = fs::read_to_string(&cargo_path)
137        .with_context(|| format!("Failed to read {}", cargo_path.display()))?;
138    let doc = contents
139        .parse::<DocumentMut>()
140        .context("Failed to parse Cargo.toml")?;
141
142    Ok(has_dependency(&doc, "sea-orm") || has_tideway_feature(&doc, "database"))
143}
144
145fn has_tideway_feature(doc: &DocumentMut, feature: &str) -> bool {
146    runtime_dependency_sections(doc)
147        .into_iter()
148        .filter_map(|deps| deps.get("tideway"))
149        .filter_map(|tideway| tideway.get("features"))
150        .filter_map(|features| features.as_array())
151        .any(|arr| arr.iter().any(|value| value.as_str() == Some(feature)))
152}
153
154fn has_dependency(doc: &DocumentMut, dependency: &str) -> bool {
155    runtime_dependency_sections(doc)
156        .into_iter()
157        .any(|deps| deps.get(dependency).is_some())
158}
159
160fn runtime_dependency_sections<'a>(doc: &'a DocumentMut) -> Vec<&'a toml_edit::Item> {
161    let mut sections = Vec::new();
162
163    if let Some(item) = doc.get("dependencies") {
164        sections.push(item);
165    }
166
167    if let Some(targets) = doc.get("target").and_then(|item| item.as_table()) {
168        for (_, target) in targets.iter() {
169            if let Some(deps) = target.get("dependencies") {
170                sections.push(deps);
171            }
172        }
173    }
174
175    sections
176}
177
178fn preflight_postgres(project_dir: &Path, database_url: &str) -> Result<()> {
179    let parsed = Url::parse(database_url).map_err(|err| {
180        anyhow::anyhow!(error_contract(
181            &format!("DATABASE_URL could not be parsed: {}", err),
182            "Use a URL like `postgres://user:password@host:5432/database`.",
183            "Regenerate config with `tideway doctor --fix` and update DATABASE_URL."
184        ))
185    })?;
186    let host = parsed.host_str().ok_or_else(|| {
187        anyhow::anyhow!(error_contract(
188            &format!(
189                "DATABASE_URL is missing a host: {}",
190                redact_database_url(database_url)
191            ),
192            "Set DATABASE_URL to a valid Postgres host and rerun `tideway dev`.",
193            "Regenerate config with `tideway doctor --fix` and update DATABASE_URL."
194        ))
195    })?;
196    let port = parsed.port_or_known_default().unwrap_or(5432);
197    let host = host.to_string();
198
199    if !tcp_connectable(&host, port, Duration::from_secs(1)) {
200        let redacted = redact_database_url(database_url);
201        return Err(anyhow::anyhow!(error_contract(
202            &format!("Postgres is not reachable at {}", redacted),
203            &postgres_primary_fix(project_dir, &host),
204            "Start Postgres or update DATABASE_URL to a reachable server, then rerun `tideway dev`."
205        )));
206    }
207
208    let rt = tokio::runtime::Builder::new_current_thread()
209        .enable_all()
210        .build()
211        .context("Failed to create Tokio runtime for Postgres preflight")?;
212
213    rt.block_on(async move {
214        if can_connect_to_postgres(database_url).await? {
215            return Ok(());
216        }
217
218        if is_local_postgres_host(&host) {
219            ensure_local_postgres_database(project_dir, &parsed).await
220        } else {
221            Err(anyhow::anyhow!(error_contract(
222                &format!(
223                    "Failed to open the configured Postgres database at {}",
224                    redact_database_url(database_url)
225                ),
226                "Ensure the database exists and the credentials in DATABASE_URL are correct, then rerun `tideway dev`.",
227                "Point DATABASE_URL at an existing Postgres database if this environment should not auto-provision."
228            )))
229        }
230    })
231}
232
233async fn can_connect_to_postgres(database_url: &str) -> Result<bool> {
234    let options = postgres_connect_options(database_url)?;
235    match PgConnection::connect_with(&options).await {
236        Ok(connection) => {
237            connection.close().await.ok();
238            Ok(true)
239        }
240        Err(_) => Ok(false),
241    }
242}
243
244async fn ensure_local_postgres_database(project_dir: &Path, parsed: &Url) -> Result<()> {
245    let database_name = postgres_database_name(parsed).ok_or_else(|| {
246        anyhow::anyhow!(error_contract(
247            &format!(
248                "DATABASE_URL is missing a database name: {}",
249                redact_database_url(parsed.as_str())
250            ),
251            "Set DATABASE_URL to include the Postgres database name and rerun `tideway dev`.",
252            "Regenerate config with `tideway doctor --fix` and update DATABASE_URL."
253        ))
254    })?;
255    let admin_url = build_postgres_admin_url(parsed)?;
256    let redacted_target = redact_database_url(parsed.as_str());
257    let redacted_admin = redact_database_url(&admin_url);
258
259    let mut connection = PgConnection::connect_with(&postgres_connect_options(&admin_url)?)
260        .await
261        .map_err(|err| {
262            anyhow::anyhow!(error_contract(
263                &format!(
264                    "Failed to connect to Postgres for database preflight at {}: {}",
265                    redacted_admin, err
266                ),
267                &postgres_primary_fix(project_dir, parsed.host_str().unwrap_or("localhost")),
268                "Check DATABASE_URL credentials or create the database manually, then rerun `tideway dev`."
269            ))
270        })?;
271
272    let exists = sqlx::query_scalar::<_, i64>("SELECT 1 FROM pg_database WHERE datname = $1")
273        .bind(database_name.as_str())
274        .fetch_optional(&mut connection)
275        .await
276        .map_err(|err| {
277            anyhow::anyhow!(error_contract(
278                &format!(
279                    "Failed to inspect Postgres databases at {}: {}",
280                    redacted_admin, err
281                ),
282                "Check DATABASE_URL credentials and server permissions, then rerun `tideway dev`.",
283                "Create the database manually if this Postgres role cannot inspect `pg_database`."
284            ))
285        })?;
286
287    if exists.is_some() {
288        return Err(anyhow::anyhow!(error_contract(
289            &format!(
290                "Failed to open the configured Postgres database at {}",
291                redacted_target
292            ),
293            "Check DATABASE_URL credentials and permissions, then rerun `tideway dev`.",
294            "Create the database manually or point DATABASE_URL at a database this role can access."
295        )));
296    }
297
298    let create_statement = format!(
299        "CREATE DATABASE \"{}\"",
300        escape_postgres_identifier(&database_name)
301    );
302    connection
303        .execute(sqlx::query(&create_statement))
304        .await
305        .map_err(|err| {
306            anyhow::anyhow!(error_contract(
307                &format!(
308                    "Failed to create local Postgres database `{}` via {}: {}",
309                    database_name, redacted_admin, err
310                ),
311                "Create the database manually or grant CREATEDB to the configured Postgres role, then rerun `tideway dev`.",
312                "Update DATABASE_URL to an existing database if local auto-provisioning is not appropriate."
313            ))
314        })?;
315
316    print_success(&format!(
317        "Created local Postgres database `{}` before launch",
318        database_name
319    ));
320
321    Ok(())
322}
323
324fn postgres_connect_options(database_url: &str) -> Result<PgConnectOptions> {
325    PgConnectOptions::from_str(database_url)
326        .map_err(|err| anyhow::anyhow!("invalid Postgres DATABASE_URL: {}", err))
327}
328
329fn build_postgres_admin_url(parsed: &Url) -> Result<String> {
330    let mut admin_url = parsed.clone();
331    admin_url.set_path("/postgres");
332    Ok(admin_url.to_string())
333}
334
335fn postgres_database_name(parsed: &Url) -> Option<String> {
336    parsed
337        .path_segments()
338        .and_then(|mut segments| segments.next_back())
339        .map(str::trim)
340        .filter(|segment| !segment.is_empty())
341        .map(ToString::to_string)
342}
343
344fn escape_postgres_identifier(identifier: &str) -> String {
345    identifier.replace('"', "\"\"")
346}
347
348fn tcp_connectable(host: &str, port: u16, timeout: Duration) -> bool {
349    let Ok(addrs) = (host, port).to_socket_addrs() else {
350        return false;
351    };
352
353    addrs
354        .into_iter()
355        .any(|addr| TcpStream::connect_timeout(&addr, timeout).is_ok())
356}
357
358fn is_local_postgres_host(host: &str) -> bool {
359    matches!(host, "localhost" | "127.0.0.1" | "::1")
360}
361
362fn postgres_primary_fix(project_dir: &Path, host: &str) -> String {
363    if is_local_postgres_host(host) && project_dir.join("docker-compose.yml").exists() {
364        "Run `docker compose up -d` in the project root, then rerun `tideway dev --fix-env`."
365            .to_string()
366    } else {
367        "Start Postgres for the configured DATABASE_URL, then rerun `tideway dev`.".to_string()
368    }
369}