Skip to main content

tideway_cli/commands/
doctor.rs

1//! Doctor command - diagnose Tideway project setup issues.
2
3use 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            // No features listed; keep empty.
217        }
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}