1use 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}