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}
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            // No features listed; keep empty.
192        }
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}