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