1use anyhow::{Context, Result};
4use colored::Colorize;
5use std::collections::{BTreeMap, BTreeSet};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::cli::DoctorArgs;
10use crate::{is_json_output, print_info, print_success, print_warning, write_file};
11
12#[derive(Debug, Default)]
13pub struct DoctorReport {
14 pub warnings: Vec<String>,
15 pub info: Vec<String>,
16}
17
18pub fn run(args: DoctorArgs) -> Result<()> {
19 let project_dir = PathBuf::from(args.path);
20 let report = analyze_project(&project_dir, args.fix)?;
21
22 if !is_json_output() {
23 println!(
24 "\n{} {}\n",
25 "tideway".cyan().bold(),
26 "doctor report".blue().bold()
27 );
28 }
29
30 if report.info.is_empty() && report.warnings.is_empty() {
31 print_success("No issues found");
32 return Ok(());
33 }
34
35 for line in report.info {
36 print_info(&line);
37 }
38
39 if !report.warnings.is_empty() {
40 if !is_json_output() {
41 println!();
42 }
43 for warning in report.warnings {
44 print_warning(&warning);
45 }
46 }
47
48 Ok(())
49}
50
51pub fn analyze_project(project_dir: &Path, fix: bool) -> Result<DoctorReport> {
52 let mut report = DoctorReport::default();
53
54 let cargo_toml_path = project_dir.join("Cargo.toml");
55 let cargo_toml = read_cargo_toml(&cargo_toml_path)?;
56 let tideway_features = tideway_features(&cargo_toml);
57
58 let src_dir = project_dir.join("src");
59 let detected = detect_modules(&src_dir);
60
61 if detected.is_empty() {
62 report.info.push("No Tideway modules detected in src/".to_string());
63 }
64
65 for module in &detected {
66 let feature = module_to_feature(module);
67 if !tideway_features.contains(feature) {
68 report.warnings.push(format!(
69 "Detected {} module but Tideway feature '{}' is not enabled in Cargo.toml",
70 module, feature
71 ));
72 }
73 }
74
75 if !tideway_dependency_present(&cargo_toml) {
76 report.warnings.push("Cargo.toml is missing a tideway dependency".to_string());
77 }
78
79 if let Some(message) = validate_package_metadata(&cargo_toml) {
80 report.info.push(message);
81 }
82
83 if !tideway_features.is_empty() && cargo_toml_path.exists() {
84 report.info.push(format!(
85 "Tideway features enabled: {}",
86 tideway_features
87 .iter()
88 .map(|s| s.as_str())
89 .collect::<Vec<_>>()
90 .join(", ")
91 ));
92 }
93
94 let env_file = project_dir.join(".env");
95 let env_example_file = project_dir.join(".env.example");
96 let env_vars = read_env_map(&env_file).unwrap_or_default();
97 let env_example_vars = read_env_map(&env_example_file).unwrap_or_default();
98 let project_name = project_name_from_cargo(&cargo_toml, project_dir);
99
100 let needs_database = tideway_features.contains("database") || detected.contains("database");
101 let needs_auth = tideway_features.contains("auth") || detected.contains("auth");
102
103 if fix {
104 if let Some(lines) = env_example_template(&project_name, needs_database, needs_auth) {
105 if !env_example_file.exists() {
106 write_env_example(&env_example_file, &lines)?;
107 report.info.push("Created .env.example".to_string());
108 }
109 }
110 }
111
112 if needs_database {
113 let db_value = check_env_var(
114 "DATABASE_URL",
115 &env_file,
116 &env_example_file,
117 &env_vars,
118 &env_example_vars,
119 &mut report,
120 );
121 if let Some(value) = db_value {
122 if let Some(message) = validate_database_url(&value) {
123 report.warnings.push(message);
124 }
125 }
126 }
127
128 if needs_auth {
129 check_env_var(
130 "JWT_SECRET",
131 &env_file,
132 &env_example_file,
133 &env_vars,
134 &env_example_vars,
135 &mut report,
136 );
137 }
138
139 if !has_log_config(&env_vars, &env_example_vars) {
140 report.info.push(
141 "No log level configured (set TIDEWAY_LOG_LEVEL or RUST_LOG for more output)"
142 .to_string(),
143 );
144 }
145
146 if !has_port_config(&env_vars, &env_example_vars) {
147 report.info.push(
148 "No port configured (set TIDEWAY_PORT or PORT for deploy environments)".to_string(),
149 );
150 }
151
152 if tideway_features.contains("openapi") {
153 check_openapi_setup(&src_dir, project_dir, &mut report);
154 check_openapi_doc_coverage(&src_dir, &mut report);
155 }
156
157 if needs_database {
158 check_migration_setup(project_dir, &mut report);
159 check_database_wiring(&src_dir, &mut report);
160 check_migration_execution_hint(project_dir, &src_dir, &env_vars, &env_example_vars, &mut report);
161 }
162
163 Ok(report)
164}
165
166fn read_cargo_toml(path: &Path) -> Result<toml::Value> {
167 let contents = fs::read_to_string(path)
168 .with_context(|| format!("Failed to read {}", path.display()))?;
169 contents
170 .parse::<toml::Value>()
171 .with_context(|| format!("Failed to parse {}", path.display()))
172}
173
174fn tideway_features(cargo_toml: &toml::Value) -> BTreeSet<String> {
175 let mut features = BTreeSet::new();
176
177 let deps = cargo_toml.get("dependencies");
178 let tideway = deps.and_then(|d| d.get("tideway"));
179
180 match tideway {
181 Some(toml::Value::Table(table)) => {
182 if let Some(toml::Value::Array(values)) = table.get("features") {
183 for value in values {
184 if let Some(feature) = value.as_str() {
185 features.insert(feature.to_string());
186 }
187 }
188 }
189 }
190 Some(toml::Value::String(_)) => {
191 }
193 _ => {}
194 }
195
196 features
197}
198
199fn tideway_dependency_present(cargo_toml: &toml::Value) -> bool {
200 cargo_toml
201 .get("dependencies")
202 .and_then(|deps| deps.get("tideway"))
203 .is_some()
204}
205
206fn detect_modules(src_dir: &Path) -> BTreeSet<String> {
207 let mut modules = BTreeSet::new();
208
209 let module_dirs = [
210 "auth",
211 "billing",
212 "organizations",
213 "admin",
214 "jobs",
215 "cache",
216 "session",
217 "email",
218 "websocket",
219 "metrics",
220 "validation",
221 "openapi",
222 ];
223
224 for module in module_dirs {
225 let path = src_dir.join(module);
226 if path.is_dir() {
227 modules.insert(module.to_string());
228 }
229 }
230
231 modules
232}
233
234fn module_to_feature(module: &str) -> &str {
235 match module {
236 "session" => "sessions",
237 other => other,
238 }
239}
240
241fn read_env_map(path: &Path) -> Result<BTreeMap<String, String>> {
242 let contents = fs::read_to_string(path)
243 .with_context(|| format!("Failed to read {}", path.display()))?;
244 Ok(parse_env_map(&contents))
245}
246
247fn parse_env_map(contents: &str) -> BTreeMap<String, String> {
248 let mut vars = BTreeMap::new();
249 for line in contents.lines() {
250 let trimmed = line.trim();
251 if trimmed.is_empty() || trimmed.starts_with('#') {
252 continue;
253 }
254 if let Some((key, value)) = trimmed.split_once('=') {
255 let key = key.trim();
256 if !key.is_empty() {
257 let value = value.trim().trim_matches('"').trim_matches('\'');
258 vars.insert(key.to_string(), value.to_string());
259 }
260 }
261 }
262 vars
263}
264
265fn check_env_var(
266 key: &str,
267 env_path: &Path,
268 env_example_path: &Path,
269 env_vars: &BTreeMap<String, String>,
270 env_example_vars: &BTreeMap<String, String>,
271 report: &mut DoctorReport,
272) -> Option<String> {
273 if let Some(value) = env_vars.get(key) {
274 return Some(value.clone());
275 }
276
277 if env_example_vars.contains_key(key) {
278 report.warnings.push(format!(
279 "{} missing in .env (found in .env.example) - copy .env.example and fill values",
280 key
281 ));
282 return env_example_vars.get(key).cloned();
283 }
284
285 if env_path.exists() || env_example_path.exists() {
286 report.warnings.push(format!(
287 "{} missing in .env and .env.example",
288 key
289 ));
290 return None;
291 }
292
293 report.warnings.push(format!(
294 "{} missing - create .env.example (and .env) for local setup",
295 key
296 ));
297 None
298}
299
300fn validate_database_url(value: &str) -> Option<String> {
301 if !value.contains("://") {
302 return Some("DATABASE_URL format looks invalid (missing scheme)".to_string());
303 }
304
305 let lower = value.to_lowercase();
306 let valid = lower.starts_with("postgres://")
307 || lower.starts_with("postgresql://")
308 || lower.starts_with("sqlite:");
309
310 if !valid {
311 return Some(format!(
312 "DATABASE_URL scheme looks invalid: {}",
313 value
314 ));
315 }
316
317 None
318}
319
320fn has_log_config(
321 env_vars: &BTreeMap<String, String>,
322 env_example_vars: &BTreeMap<String, String>,
323) -> bool {
324 env_vars.contains_key("TIDEWAY_LOG_LEVEL")
325 || env_vars.contains_key("RUST_LOG")
326 || env_example_vars.contains_key("TIDEWAY_LOG_LEVEL")
327 || env_example_vars.contains_key("RUST_LOG")
328}
329
330fn has_port_config(
331 env_vars: &BTreeMap<String, String>,
332 env_example_vars: &BTreeMap<String, String>,
333) -> bool {
334 env_vars.contains_key("TIDEWAY_PORT")
335 || env_vars.contains_key("PORT")
336 || env_example_vars.contains_key("TIDEWAY_PORT")
337 || env_example_vars.contains_key("PORT")
338}
339
340fn validate_package_metadata(cargo_toml: &toml::Value) -> Option<String> {
341 let package = cargo_toml.get("package")?.as_table()?;
342 let missing = ["description", "license", "repository"]
343 .iter()
344 .filter(|key| !package.contains_key(**key))
345 .cloned()
346 .collect::<Vec<_>>();
347
348 if missing.is_empty() {
349 return None;
350 }
351
352 Some(format!(
353 "Package metadata missing: {}",
354 missing.join(", ")
355 ))
356}
357
358fn env_example_template(
359 project_name: &str,
360 needs_database: bool,
361 needs_auth: bool,
362) -> Option<Vec<String>> {
363 let mut lines = Vec::new();
364 if needs_database || needs_auth {
365 lines.push("# Server".to_string());
366 lines.push("TIDEWAY_HOST=0.0.0.0".to_string());
367 lines.push("TIDEWAY_PORT=8000".to_string());
368 lines.push(String::new());
369 }
370
371 if needs_database {
372 lines.push("# Database".to_string());
373 lines.push(format!(
374 "DATABASE_URL=postgres://postgres:postgres@localhost:5432/{}",
375 project_name
376 ));
377 lines.push(String::new());
378 }
379
380 if needs_auth {
381 lines.push("# Auth".to_string());
382 lines.push("JWT_SECRET=your-super-secret-jwt-key-change-in-production".to_string());
383 lines.push(String::new());
384 }
385
386 if lines.is_empty() {
387 None
388 } else {
389 Some(lines)
390 }
391}
392
393fn project_name_from_cargo(cargo_toml: &toml::Value, project_dir: &Path) -> String {
394 if let Some(name) = cargo_toml
395 .get("package")
396 .and_then(|pkg| pkg.get("name"))
397 .and_then(|value| value.as_str())
398 {
399 return name.replace('-', "_");
400 }
401
402 project_dir
403 .file_name()
404 .and_then(|n| n.to_str())
405 .unwrap_or("my_app")
406 .replace('-', "_")
407}
408fn write_env_example(path: &Path, lines: &[String]) -> Result<()> {
409 let contents = lines.join("\n");
410 write_file(path, &contents)
411 .with_context(|| format!("Failed to write {}", path.display()))?;
412 Ok(())
413}
414
415fn check_openapi_setup(src_dir: &Path, project_dir: &Path, report: &mut DoctorReport) {
416 let openapi_docs = src_dir.join("openapi_docs.rs");
417 if !openapi_docs.exists() {
418 report.warnings.push(
419 "OpenAPI is enabled but src/openapi_docs.rs is missing (run `tideway add openapi`)"
420 .to_string(),
421 );
422 }
423
424 let main_rs = src_dir.join("main.rs");
425 if let Ok(contents) = fs::read_to_string(&main_rs) {
426 let has_module = contents.contains("mod openapi_docs;");
427 let has_router = contents.contains("openapi_merge_module")
428 || contents.contains("create_openapi_router");
429 if !has_module || !has_router {
430 report.warnings.push(
431 "OpenAPI is enabled but main.rs is not wired (run `tideway add openapi --wire`)"
432 .to_string(),
433 );
434 }
435 } else if project_dir.join("src").join("main.rs").exists() {
436 report.warnings.push("Failed to read src/main.rs for OpenAPI wiring check".to_string());
437 }
438}
439
440fn check_openapi_doc_coverage(src_dir: &Path, report: &mut DoctorReport) {
441 let openapi_docs = src_dir.join("openapi_docs.rs");
442 if !openapi_docs.exists() {
443 return;
444 }
445
446 let Ok(docs_contents) = fs::read_to_string(&openapi_docs) else {
447 report.warnings.push("Failed to read src/openapi_docs.rs".to_string());
448 return;
449 };
450
451 let paths_block = extract_openapi_paths(&docs_contents);
452 if paths_block.is_empty() {
453 report.warnings.push(
454 "OpenAPI docs file has no paths() entries (add routes or run `tideway resource --wire`)"
455 .to_string(),
456 );
457 return;
458 }
459
460 let routes_dir = src_dir.join("routes");
461 if !routes_dir.exists() {
462 return;
463 }
464
465 let mut missing = Vec::new();
466 if let Ok(entries) = fs::read_dir(&routes_dir) {
467 for entry in entries.flatten() {
468 let path = entry.path();
469 if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
470 continue;
471 }
472 let file_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
473 if file_name == "mod" {
474 continue;
475 }
476 if let Ok(contents) = fs::read_to_string(&path) {
477 if !contents.contains("cfg_attr(feature = \"openapi\"") {
478 continue;
479 }
480 }
481
482 let expected_prefix = format!("crate::routes::{}::", file_name);
483 if !paths_block.iter().any(|path| path.starts_with(&expected_prefix)) {
484 missing.push(file_name.to_string());
485 }
486 }
487 }
488
489 if !missing.is_empty() {
490 report.warnings.push(format!(
491 "OpenAPI docs missing routes for: {} (run `tideway resource --wire` to add)",
492 missing.join(", ")
493 ));
494 }
495}
496
497fn extract_openapi_paths(contents: &str) -> Vec<String> {
498 let mut lines = Vec::new();
499 let mut in_paths = false;
500 for line in contents.lines() {
501 let trimmed = line.trim();
502 if trimmed.starts_with("paths(") {
503 in_paths = true;
504 continue;
505 }
506 if in_paths && trimmed.starts_with(')') {
507 break;
508 }
509 if in_paths {
510 let trimmed = trimmed.trim_end_matches(',');
511 if !trimmed.is_empty() {
512 lines.push(trimmed.to_string());
513 }
514 }
515 }
516 lines
517}
518
519fn check_migration_setup(project_dir: &Path, report: &mut DoctorReport) {
520 let migration_lib = project_dir.join("migration").join("src").join("lib.rs");
521 if !migration_lib.exists() {
522 report.warnings.push(
523 "Missing migration/src/lib.rs (run `sea-orm-cli migrate init` or `tideway backend`)"
524 .to_string(),
525 );
526 }
527}
528
529fn check_database_wiring(src_dir: &Path, report: &mut DoctorReport) {
530 let routes_dir = src_dir.join("routes");
531 if !routes_dir.exists() {
532 return;
533 }
534
535 let mut has_db_routes = false;
536 if let Ok(entries) = fs::read_dir(&routes_dir) {
537 for entry in entries.flatten() {
538 let path = entry.path();
539 if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
540 continue;
541 }
542 if let Ok(contents) = fs::read_to_string(&path) {
543 if contents.contains("sea_orm_connection()")
544 || contents.contains("Entity::find")
545 || contents.contains("ActiveModel")
546 {
547 has_db_routes = true;
548 break;
549 }
550 }
551 }
552 }
553
554 if !has_db_routes {
555 return;
556 }
557
558 let main_path = src_dir.join("main.rs");
559 let Ok(contents) = fs::read_to_string(&main_path) else {
560 report.warnings.push("Failed to read src/main.rs for database wiring check".to_string());
561 return;
562 };
563
564 if !contents.contains("with_database(") {
565 report.warnings.push(
566 "DB-backed routes detected but AppContext is not wired (run `tideway add database --wire`)"
567 .to_string(),
568 );
569 }
570}
571
572fn check_migration_execution_hint(
573 project_dir: &Path,
574 src_dir: &Path,
575 env_vars: &BTreeMap<String, String>,
576 env_example_vars: &BTreeMap<String, String>,
577 report: &mut DoctorReport,
578) {
579 let migration_lib = project_dir.join("migration").join("src").join("lib.rs");
580 if !migration_lib.exists() {
581 return;
582 }
583
584 let has_auto_migrate = env_vars.contains_key("DATABASE_AUTO_MIGRATE")
585 || env_example_vars.contains_key("DATABASE_AUTO_MIGRATE");
586 let main_path = src_dir.join("main.rs");
587 let main_contents = fs::read_to_string(&main_path).unwrap_or_default();
588 let has_migration_call = main_contents.contains("run_migrations(")
589 || main_contents.contains("run_migrations_now(");
590
591 if !has_auto_migrate && !has_migration_call {
592 report.info.push(
593 "Migrations detected but not auto-run (set DATABASE_AUTO_MIGRATE=true, call run_migrations, or use `tideway dev`)"
594 .to_string(),
595 );
596 }
597}