diff --git a/CHANGES.md b/CHANGES.md index 0d9e95b..f753198 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.0 - 2026-05-21 + +- Add `doctor check --json` for machine-readable diagnostics. +- Tighten opam environment diagnostic tests and small parser helpers. +- Fix the README license badge URL. + ## 0.1.0 - 2026-05-06 - Initial opam release. diff --git a/README.md b/README.md index a839f33..7f81c1f 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,16 @@ [![CI](https://github.com/funwithcthulhu/doctor/actions/workflows/ci.yml/badge.svg)](https://github.com/funwithcthulhu/doctor/actions/workflows/ci.yml) [![opam](https://badgen.net/opam/v/doctor)](https://opam.ocaml.org/packages/doctor/) -[![license](https://img.shields.io/github/license/funwithcthulhu/doctor.svg)](LICENSE) +[![license](https://img.shields.io/github/license/funwithcthulhu/doctor)](LICENSE) -`doctor` is a read-only CLI for checking whether a local OCaml development -environment looks usable. +`doctor` checks a local OCaml development environment. It reports missing tools, +suspicious opam state, and editor setup issues; it does not modify switches, +shell files, or editor settings. -It reports missing core tools, opam initialization and switch state, likely shell -environment mismatches, selected opam packages, and the VS Code OCaml Platform -extension when the `code` command is available. +It currently checks platform details, core tool versions, opam initialization +state, active and available switches, whether the resolved `ocaml` appears to +match the active switch, selected opam packages, and the VS Code OCaml Platform +extension when `code` is available. ## Installation @@ -85,7 +87,7 @@ $ doctor check --json `doctor version` prints: ```console -doctor 0.1.0 +doctor 0.2.0 ``` ## Exit Codes diff --git a/RELEASE.md b/RELEASE.md index 241530e..8a7ea8c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -3,7 +3,7 @@ Notes for publishing `doctor` to opam-repository. This is a release checklist, not release automation. -Run from a clean checkout. Replace `0.1.0` with the version being released. +Run from a clean checkout. Replace `0.2.0` with the version being released. ## Prepare @@ -35,7 +35,7 @@ Push the branch and tag after checking the final diff. ```console git status --short -git tag -a 0.1.0 -m "Release 0.1.0" +git tag -a 0.2.0 -m "Release 0.2.0" ``` ## Publish diff --git a/dune-project b/dune-project index da1e201..3ffed61 100644 --- a/dune-project +++ b/dune-project @@ -1,7 +1,7 @@ (lang dune 3.11) (name doctor) -(version 0.1.0) +(version 0.2.0) (source (github funwithcthulhu/doctor)) diff --git a/lib/check.ml b/lib/check.ml index 98e93f0..1ddccad 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -68,13 +68,14 @@ let title_with_version label version = Printf.sprintf "%s found: %s" label version | _ -> Printf.sprintf "%s found" label +let command_version result parser = + result |> first_output_line |> Option.map parser + let command_diagnostic ~(run : Process.runner) spec = let result = run spec.command spec.args in match result.status with | Process.Exited 0 -> - let version = - result |> first_output_line |> Option.map spec.version_parser - in + let version = command_version result spec.version_parser in let title = title_with_version spec.label version in make ~id:("command." ^ spec.command) ~title Ok | Process.Spawn_error _ -> @@ -96,18 +97,14 @@ let lsp_command_diagnostic ~(run : Process.runner) = let primary = run "ocaml-lsp-server" [ "--version" ] in match primary.status with | Process.Exited 0 -> - let version = - primary |> first_output_line |> Option.map String.trim - in + let version = command_version primary String.trim in let title = title_with_version "OCaml LSP" version in make ~id:"command.ocaml-lsp-server" ~title Ok | Process.Spawn_error _ -> ( let fallback = run "ocamllsp" [ "--version" ] in match fallback.status with | Process.Exited 0 -> - let version = - fallback |> first_output_line |> Option.map String.trim - in + let version = command_version fallback String.trim in let title = match version with | Some version when version <> "" -> @@ -140,7 +137,7 @@ let lsp_command_diagnostic ~(run : Process.runner) = reinstall it with opam." Warn -let command_diagnostics ~run = +let core_command_specs = [ { command = "opam"; @@ -170,18 +167,20 @@ let command_diagnostics ~run = version_parser = String.trim; }; ] - |> List.map (command_diagnostic ~run) - |> fun diagnostics -> - diagnostics + +let ocamlformat_spec = + { + command = "ocamlformat"; + args = [ "--version" ]; + label = "ocamlformat"; + missing_severity = Warn; + missing_suggestion = "opam install ocamlformat"; + version_parser = String.trim; + } + +let command_diagnostics ~run = + List.map (command_diagnostic ~run) core_command_specs @ [ lsp_command_diagnostic ~run; - command_diagnostic ~run - { - command = "ocamlformat"; - args = [ "--version" ]; - label = "ocamlformat"; - missing_severity = Warn; - missing_suggestion = "opam install ocamlformat"; - version_parser = String.trim; - }; + command_diagnostic ~run ocamlformat_spec; ] diff --git a/lib/opam.ml b/lib/opam.ml index 6453960..a8a5ed8 100644 --- a/lib/opam.ml +++ b/lib/opam.ml @@ -4,22 +4,33 @@ let non_empty_lines output = |> List.map String.trim |> List.filter (fun line -> line <> "") +let first_stdout_line result = + match result.Process.status with + | Process.Exited 0 -> ( + match non_empty_lines result.stdout with + | line :: _ -> Some line + | [] -> None) + | _ -> None + let parse_active_switch output = match non_empty_lines output with | [] -> None | line :: _ -> let lower = String.lowercase_ascii line in - if String.length lower >= 7 && String.sub lower 0 7 = "[error]" - then None + if String.starts_with ~prefix:"[error]" lower then None else Some line +let trim_switch_marker line = + let line = String.trim line in + match line with + | "" -> "" + | _ when line.[0] = '*' -> + String.trim (String.sub line 1 (String.length line - 1)) + | _ -> line + let parse_switch_list output = non_empty_lines output - |> List.map (fun line -> - let line = String.trim line in - if line <> "" && line.[0] = '*' then - String.trim (String.sub line 1 (String.length line - 1)) - else line) + |> List.map trim_switch_marker |> List.filter (fun line -> line <> "") let words line = @@ -41,16 +52,21 @@ let opam_available ~(run : Process.runner) = | Process.Exited 0 -> true | _ -> false +let switch_suggestion os switches = + match switches with + | [] -> "opam switch create 5.2.0" + | _ :: _ -> Platform.environment_sync_suggestion os + let initialized_diagnostic ~(run : Process.runner) = let result = run "opam" [ "var"; "root" ] in match result.status with | Process.Exited 0 -> ( - match non_empty_lines result.stdout with - | root :: _ -> + match first_stdout_line result with + | Some root -> Check.make ~id:"opam.initialized" ~title:"opam initialized" ~detail:(Printf.sprintf "Root: %s" root) Check.Ok - | [] -> + | None -> Check.make ~id:"opam.initialized" ~title:"opam root could not be read" ~detail:(Process.summary result) @@ -78,19 +94,13 @@ let switch_diagnostics ~(run : Process.runner) os = ~title:(Printf.sprintf "active switch: %s" active) Check.Ok | None -> - let suggestion = - if switch_list = [] then "opam switch create 5.2.0" - else Platform.environment_sync_suggestion os - in + let suggestion = switch_suggestion os switch_list in Check.make ~id:"opam.switch.active" ~title:"opam switch not active" ~detail:"opam did not report an active switch." ~suggestion Check.Error) | _ -> - let suggestion = - if switch_list = [] then "opam switch create 5.2.0" - else Platform.environment_sync_suggestion os - in + let suggestion = switch_suggestion os switch_list in Check.make ~id:"opam.switch.active" ~title:"opam switch not active" ~detail:(Process.summary show) ~suggestion Check.Error @@ -116,47 +126,36 @@ let switch_diagnostics ~(run : Process.runner) os = in [ show_diagnostic; list_diagnostic ] +let locate_ocaml ~(run : Process.runner) os = + let locator, args_for = Platform.command_locator os in + first_stdout_line (run locator (args_for "ocaml")) + let switch_bin_diagnostic ~(run : Process.runner) os = let bin = run "opam" [ "var"; "bin" ] in - match bin.status with - | Process.Exited 0 -> ( - match non_empty_lines bin.stdout with - | [] -> [] - | switch_bin :: _ -> ( - let locator, args_for = Platform.command_locator os in - let located = run locator (args_for "ocaml") in - let first_path = - match located.status with - | Process.Exited 0 -> ( - match non_empty_lines located.stdout with - | path :: _ -> Some path - | [] -> None) - | _ -> None - in - match first_path with - | Some path - when Platform.is_path_under ~parent:switch_bin path -> - [ - Check.make ~id:"opam.env.sync" - ~title:"shell environment appears synced with opam" - ~detail:(Printf.sprintf "ocaml resolves to %s" path) - Check.Ok; - ] - | Some path -> - [ - Check.make ~id:"opam.env.sync" - ~title: - "shell environment may be out of sync with opam" - ~detail: - (Printf.sprintf - "ocaml resolves to %s, but the active switch \ - bin is %s." - path switch_bin) - ~suggestion:(Platform.environment_sync_suggestion os) - Check.Warn; - ] - | None -> [])) - | _ -> [] + match first_stdout_line bin with + | None -> [] + | Some switch_bin -> ( + match locate_ocaml ~run os with + | Some path when Platform.is_path_under ~parent:switch_bin path -> + [ + Check.make ~id:"opam.env.sync" + ~title:"shell environment appears synced with opam" + ~detail:(Printf.sprintf "ocaml resolves to %s" path) + Check.Ok; + ] + | Some path -> + [ + Check.make ~id:"opam.env.sync" + ~title:"shell environment may be out of sync with opam" + ~detail: + (Printf.sprintf + "ocaml resolves to %s, but the active switch bin is \ + %s." + path switch_bin) + ~suggestion:(Platform.environment_sync_suggestion os) + Check.Warn; + ] + | None -> []) let package_diagnostic packages package ~optional = if has_package packages package then diff --git a/lib/platform.ml b/lib/platform.ml index 37db098..2620237 100644 --- a/lib/platform.ml +++ b/lib/platform.ml @@ -1,6 +1,6 @@ type os = Windows | Macos | Linux | Wsl | Cygwin | Other of string -let env name = try Some (Sys.getenv name) with Not_found -> None +let env = Sys.getenv_opt let contains_substring haystack needle = let haystack_length = String.length haystack in @@ -84,9 +84,7 @@ let is_path_under ~parent path = let parent = normalize_path parent in let path = normalize_path path in let parent = - if parent <> "" && parent.[String.length parent - 1] = '/' then - parent - else parent ^ "/" + if String.ends_with ~suffix:"/" parent then parent else parent ^ "/" in String.length path >= String.length parent && String.sub path 0 (String.length parent) = parent diff --git a/lib/process.ml b/lib/process.ml index 15e1c08..940f042 100644 --- a/lib/process.ml +++ b/lib/process.ml @@ -84,17 +84,7 @@ let run command args = close_noerr stdout_fd; close_noerr stderr_fd; let _pid, status = Unix.waitpid [] pid in - let stdout = read_file stdout_path in - let stderr = read_file stderr_path in - remove_if_exists stdout_path; - remove_if_exists stderr_path; - { - command; - args; - status = unix_status_to_status status; - stdout; - stderr; - } + finish (unix_status_to_status status) with Unix.Unix_error (error, function_name, argument) -> let message = Printf.sprintf "%s: %s %s" diff --git a/lib/version.ml b/lib/version.ml index e598de7..f3f3af0 100644 --- a/lib/version.ml +++ b/lib/version.ml @@ -1,2 +1,2 @@ -let current = "0.1.0" +let current = "0.2.0" let display = "doctor " ^ current diff --git a/test/test_cli.ml b/test/test_cli.ml index d93797c..23ef1ea 100644 --- a/test/test_cli.ml +++ b/test/test_cli.ml @@ -4,4 +4,4 @@ let expect_equal label expected actual = (Printf.sprintf "%s: expected %S, got %S" label expected actual) let () = - expect_equal "version display" "doctor 0.1.0" Doctor.Version.display + expect_equal "version display" "doctor 0.2.0" Doctor.Version.display diff --git a/test/test_diagnostics.ml b/test/test_diagnostics.ml index 8139064..aa344af 100644 --- a/test/test_diagnostics.ml +++ b/test/test_diagnostics.ml @@ -7,13 +7,13 @@ module Process = Doctor.Process let result ?(stdout = "") ?(stderr = "") status command args = { Process.command; args; status; stdout; stderr } -let run responses command args = +let fake_runner responses command args = match List.assoc_opt (command, args) responses with | Some (status, stdout, stderr) -> result ~stdout ~stderr status command args | None -> result (Process.Spawn_error "not found") command args -let expect_equal label expected actual = +let expect_string label expected actual = if not (String.equal expected actual) then failwith (Printf.sprintf "%s: expected %S, got %S" label expected actual) @@ -32,11 +32,11 @@ let expect_some label = function | None -> failwith (Printf.sprintf "%s: expected Some _" label) let expect_suggestion label expected diagnostic = - expect_equal label expected + expect_string label expected (expect_some (label ^ " suggestion") diagnostic.Check.suggestion) let expect_detail label expected diagnostic = - expect_equal label expected + expect_string label expected (expect_some (label ^ " detail") diagnostic.Check.detail) let find_diagnostic id diagnostics = @@ -45,8 +45,8 @@ let find_diagnostic id diagnostics = String.equal diagnostic.Check.id id) |> expect_some ("diagnostic " ^ id) -let () = - let command_responses = +let test_command_checks_use_ocamllsp_fallback () = + let responses = [ (("opam", [ "--version" ]), (Process.Exited 0, "2.2.1\n", "")); ( ("ocaml", [ "-version" ]), @@ -60,50 +60,35 @@ let () = ] in let diagnostics = - Check.command_diagnostics ~run:(run command_responses) + Check.command_diagnostics ~run:(fake_runner responses) in let lsp = find_diagnostic "command.ocaml-lsp-server" diagnostics in expect_severity "lsp fallback" Check.Ok lsp.severity; - expect_equal "lsp fallback title" "OCaml LSP found: 1.26.0 (ocamllsp)" - lsp.title; - let missing_ocamlformat = - Check.command_diagnostic ~run:(run []) - { - command = "ocamlformat"; - args = [ "--version" ]; - label = "ocamlformat"; - missing_severity = Check.Warn; - missing_suggestion = "opam install ocamlformat"; - version_parser = String.trim; - } + expect_string "lsp fallback title" + "OCaml LSP found: 1.26.0 (ocamllsp)" lsp.title + +let test_missing_ocamlformat_is_a_warning () = + let responses = + [ + (("opam", [ "--version" ]), (Process.Exited 0, "2.2.1\n", "")); + ( ("ocaml", [ "-version" ]), + (Process.Exited 0, "The OCaml toplevel, version 5.2.0\n", "") ); + (("dune", [ "--version" ]), (Process.Exited 0, "3.17.0\n", "")); + ( ("ocaml-lsp-server", [ "--version" ]), + (Process.Exited 0, "1.26.0\n", "") ); + ] in - expect_severity "missing command is warning" Check.Warn - missing_ocamlformat.severity; - expect_equal "missing command suggestion" "opam install ocamlformat" - (expect_some "missing command suggestion" - missing_ocamlformat.suggestion); - let failing_opam = - Check.command_diagnostic - ~run: - (run - [ - ( ("opam", [ "--version" ]), - (Process.Exited 2, "", "opam failed\n") ); - ]) - { - command = "opam"; - args = [ "--version" ]; - label = "opam"; - missing_severity = Check.Error; - missing_suggestion = - "Install opam from https://opam.ocaml.org/doc/Install.html"; - version_parser = String.trim; - } + let diagnostics = + Check.command_diagnostics ~run:(fake_runner responses) in - expect_severity "nonzero command is diagnostic" Check.Error - failing_opam.severity; + let diagnostic = find_diagnostic "command.ocamlformat" diagnostics in + expect_severity "missing command is warning" Check.Warn + diagnostic.severity; + expect_string "missing command suggestion" "opam install ocamlformat" + (expect_some "missing command suggestion" diagnostic.suggestion) - let missing_tool_responses = +let test_missing_development_tools_are_warnings () = + let responses = [ (("opam", [ "--version" ]), (Process.Exited 0, "2.2.1\n", "")); ( ("ocaml", [ "-version" ]), @@ -119,17 +104,15 @@ let () = ] in let diagnostics = - Check.command_diagnostics ~run:(run missing_tool_responses) + Check.command_diagnostics ~run:(fake_runner responses) in let dune = find_diagnostic "command.dune" diagnostics in - expect_severity "missing dune is warning when opam exists" Check.Warn - dune.severity; - expect_equal "missing dune title" "dune not found" dune.title; + expect_severity "missing dune is warning" Check.Warn dune.severity; + expect_string "missing dune title" "dune not found" dune.title; expect_suggestion "missing dune suggestion" "opam install dune" dune; let lsp = find_diagnostic "command.ocaml-lsp-server" diagnostics in - expect_severity "missing lsp is warning when opam exists" Check.Warn - lsp.severity; - expect_equal "missing lsp title" "OCaml LSP command not found" + expect_severity "missing lsp is warning" Check.Warn lsp.severity; + expect_string "missing lsp title" "OCaml LSP command not found" lsp.title; expect_detail "missing lsp detail" "Checked `ocaml-lsp-server` and `ocamllsp`; neither command is \ @@ -138,26 +121,51 @@ let () = expect_suggestion "missing lsp suggestion" "opam install ocaml-lsp-server" lsp; let ocamlformat = find_diagnostic "command.ocamlformat" diagnostics in - expect_severity "missing ocamlformat is warning when opam exists" - Check.Warn ocamlformat.severity; - expect_equal "missing ocamlformat title" "ocamlformat not found" + expect_severity "missing ocamlformat is warning" Check.Warn + ocamlformat.severity; + expect_string "missing ocamlformat title" "ocamlformat not found" ocamlformat.title; expect_suggestion "missing ocamlformat suggestion" "opam install ocamlformat" ocamlformat; - expect_int "missing optional tools exit code" 1 - (Check.exit_code diagnostics); - - let diagnostics = Opam.diagnostics ~run:(run []) Platform.Linux in - let skipped = find_diagnostic "opam.initialized" diagnostics in - expect_severity "missing opam skips opam checks as error" Check.Error - skipped.severity; - expect_equal "missing opam title" - "opam checks skipped because opam is missing" skipped.title; + expect_int "missing tools exit code" 1 (Check.exit_code diagnostics) + +let test_failed_opam_version_check_is_an_error () = + let responses = + [ + ( ("opam", [ "--version" ]), + (Process.Exited 2, "", "opam failed\n") ); + ( ("ocaml", [ "-version" ]), + (Process.Exited 0, "The OCaml toplevel, version 5.2.0\n", "") ); + (("dune", [ "--version" ]), (Process.Exited 0, "3.17.0\n", "")); + ( ("ocaml-lsp-server", [ "--version" ]), + (Process.Exited 0, "1.26.0\n", "") ); + ( ("ocamlformat", [ "--version" ]), + (Process.Exited 0, "0.27.0\n", "") ); + ] + in + let diagnostics = + Check.command_diagnostics ~run:(fake_runner responses) + in + let diagnostic = find_diagnostic "command.opam" diagnostics in + expect_severity "nonzero command is diagnostic" Check.Error + diagnostic.severity + +let test_missing_opam_skips_opam_checks_as_error () = + let diagnostics = + Opam.diagnostics ~run:(fake_runner []) Platform.Linux + in + let diagnostic = find_diagnostic "opam.initialized" diagnostics in + expect_severity "missing opam is error" Check.Error + diagnostic.severity; + expect_string "missing opam title" + "opam checks skipped because opam is missing" diagnostic.title; expect_suggestion "missing opam suggestion" - "Install opam from https://opam.ocaml.org/doc/Install.html" skipped; - expect_int "missing opam exit code" 2 (Check.exit_code diagnostics); + "Install opam from https://opam.ocaml.org/doc/Install.html" + diagnostic; + expect_int "missing opam exit code" 2 (Check.exit_code diagnostics) - let opam_responses = +let test_opam_env_warns_when_ocaml_resolves_outside_active_switch () = + let responses = [ (("opam", [ "--version" ]), (Process.Exited 0, "2.2.1\n", "")); ( ("opam", [ "var"; "root" ]), @@ -174,11 +182,11 @@ let () = ] in let diagnostics = - Opam.diagnostics ~run:(run opam_responses) Platform.Linux + Opam.diagnostics ~run:(fake_runner responses) Platform.Linux in let env = find_diagnostic "opam.env.sync" diagnostics in expect_severity "env sync warning" Check.Warn env.severity; - expect_equal "env sync title" + expect_string "env sync title" "shell environment may be out of sync with opam" env.title; expect_detail "env sync detail" "ocaml resolves to /usr/bin/ocaml, but the active switch bin is \ @@ -189,9 +197,10 @@ let () = find_diagnostic "opam.package.ocamlformat" diagnostics in expect_severity "missing ocamlformat package" Check.Warn - ocamlformat.severity; + ocamlformat.severity - let windows_responses = +let test_windows_opam_env_suggestion_matches_shell_wording () = + let responses = [ (("opam", [ "--version" ]), (Process.Exited 0, "2.2.1\n", "")); (("opam", [ "var"; "root" ]), (Process.Exited 0, "C:\\opam\n", "")); @@ -210,15 +219,30 @@ let () = ] in let diagnostics = - Opam.diagnostics ~run:(run windows_responses) Platform.Windows + Opam.diagnostics ~run:(fake_runner responses) Platform.Windows in let env = find_diagnostic "opam.env.sync" diagnostics in expect_severity "windows env sync warning" Check.Warn env.severity; expect_suggestion "windows env sync suggestion" "Run `opam env` and apply the environment changes in your current \ shell, then restart the terminal if needed." - env; + env - let editor = Editor.diagnostics ~run:(run []) in - let code = find_diagnostic "editor.vscode.command" editor in +let test_missing_code_command_skips_vscode_extension_check () = + let diagnostics = Editor.diagnostics ~run:(fake_runner []) in + let code = find_diagnostic "editor.vscode.command" diagnostics in expect_severity "missing code is ok" Check.Ok code.severity + +let () = + List.iter + (fun test -> test ()) + [ + test_command_checks_use_ocamllsp_fallback; + test_missing_ocamlformat_is_a_warning; + test_missing_development_tools_are_warnings; + test_failed_opam_version_check_is_an_error; + test_missing_opam_skips_opam_checks_as_error; + test_opam_env_warns_when_ocaml_resolves_outside_active_switch; + test_windows_opam_env_suggestion_matches_shell_wording; + test_missing_code_command_skips_vscode_extension_check; + ] diff --git a/test/test_process.ml b/test/test_process.ml index a4e9218..4ba5876 100644 --- a/test/test_process.ml +++ b/test/test_process.ml @@ -6,6 +6,9 @@ let expect_equal label expected actual = let expect_bool label value = if not value then failwith (Printf.sprintf "%s: expected true" label) +let expect_false label value = + if value then failwith (Printf.sprintf "%s: expected false" label) + let expect_some label = function | Some value -> value | None -> failwith (Printf.sprintf "%s: expected Some _" label) @@ -28,7 +31,16 @@ let () = expect_equal "switch list parser" "default, 5.2.0" (Doctor.Opam.parse_switch_list "default\n5.2.0\n" |> String.concat ", "); + expect_equal "active switch marker is trimmed" "default, 5.2.0" + (Doctor.Opam.parse_switch_list "* default\n 5.2.0\n" + |> String.concat ", "); expect_equal "package parser trims whitespace" "dune, ocamlformat" (Doctor.Opam.parse_installed_packages " dune 3.17.0\n\tocamlformat\t0.27.0\n" - |> String.concat ", ") + |> String.concat ", "); + expect_bool "path below switch bin" + (Doctor.Platform.is_path_under ~parent:"/home/me/.opam/5.2.0/bin" + "/home/me/.opam/5.2.0/bin/ocaml"); + expect_false "path with shared prefix is not below switch bin" + (Doctor.Platform.is_path_under ~parent:"/home/me/.opam/5.2.0/bin" + "/home/me/.opam/5.2.0/bin-old/ocaml") diff --git a/test/test_report.ml b/test/test_report.ml index 5b38b90..8a00883 100644 --- a/test/test_report.ml +++ b/test/test_report.ml @@ -1,11 +1,16 @@ let diagnostic ?detail ?suggestion severity title = Doctor.Check.make ?detail ?suggestion ~id:title ~title severity -let expect_equal label expected actual = +let expect_int label expected actual = if expected <> actual then failwith (Printf.sprintf "%s: expected %d, got %d" label expected actual) +let expect_string label expected actual = + if not (String.equal expected actual) then + failwith + (Printf.sprintf "%s: expected %S, got %S" label expected actual) + let expect_line label needle haystack = if not @@ -28,37 +33,44 @@ let expect_contains label needle haystack = if not (contains_substring haystack needle) then failwith (Printf.sprintf "%s: missing substring %S" label needle) -let () = - let ok = diagnostic Doctor.Check.Ok "opam found: 2.2.1" in - let warn = - diagnostic Doctor.Check.Warn "ocamlformat not installed" - ~suggestion:"opam install ocamlformat" - in - let error = diagnostic Doctor.Check.Error "opam switch not active" in - expect_equal "ok exit code" 0 (Doctor.Report.exit_code [ ok ]); - expect_equal "warning exit code" 1 +let ok = diagnostic Doctor.Check.Ok "opam found: 2.2.1" + +let warn = + diagnostic Doctor.Check.Warn "ocamlformat not installed" + ~suggestion:"opam install ocamlformat" + +let error = diagnostic Doctor.Check.Error "opam switch not active" + +let test_exit_codes_and_counts () = + expect_int "ok exit code" 0 (Doctor.Report.exit_code [ ok ]); + expect_int "warning exit code" 1 (Doctor.Report.exit_code [ ok; warn ]); - expect_equal "error exit code" 2 + expect_int "error exit code" 2 (Doctor.Report.exit_code [ ok; warn; error ]); - expect_equal "summary ok count" 1 + expect_int "summary ok count" 1 (let ok_count, _, _ = Doctor.Report.counts [ ok; warn; error ] in - ok_count); + ok_count) + +let test_text_report_includes_suggestions () = let rendered = Doctor.Report.render [ warn ] in expect_line "warning line" "[WARN] ocamlformat not installed" rendered; expect_line "suggestion line" " Suggested fix: opam install ocamlformat" rendered; - expect_line "summary line" "Summary: 0 OK, 1 WARN, 0 ERROR" rendered; - let multiline = - Doctor.Check.make ~id:"multi" ~title:"multi-line detail" + expect_line "summary line" "Summary: 0 OK, 1 WARN, 0 ERROR" rendered + +let test_multiline_detail_and_suggestion_are_indented () = + let diagnostic = + diagnostic Doctor.Check.Warn "multi-line detail" ~detail:"first line\nsecond line" ~suggestion:"fix it\ntry again" - Doctor.Check.Warn in - let rendered = Doctor.Report.render [ multiline ] in + let rendered = Doctor.Report.render [ diagnostic ] in expect_line "multiline detail first" " first line" rendered; expect_line "multiline detail second" " second line" rendered; expect_line "multiline suggestion first" " Suggested fix: fix it" rendered; - expect_line "multiline suggestion second" " try again" rendered; + expect_line "multiline suggestion second" " try again" rendered + +let test_json_report_contains_diagnostics_summary_and_exit_code () = let json = Doctor.Report.render_json [ @@ -67,22 +79,53 @@ let () = ~title:"ocamlformat not installed" ~detail:"The `ocamlformat` command is not available on PATH." ~suggestion:"opam install ocamlformat" Doctor.Check.Warn; + error; ] in - expect_line "json status" " \"status\": \"warn\"," json; - expect_line "json exit code" " \"exit_code\": 1" json; + expect_line "json status" " \"status\": \"error\"," json; + expect_line "json exit code" " \"exit_code\": 2" json; expect_contains "json diagnostic name" "\"name\": \"command.ocamlformat\"" json; expect_contains "json diagnostic details" "\"details\": [\"The `ocamlformat` command is not available on \ PATH.\", \"Suggested fix: opam install ocamlformat\"]" - json; - let escaped = - Doctor.Report.render_json + json + +let test_json_escapes_strings () = + let diagnostic = + diagnostic Doctor.Check.Warn "quoted \"message\"" + ~detail:"first line\nsecond line" + in + let json = Doctor.Report.render_json [ diagnostic ] in + expect_contains "json quotes" + "\"message\": \"quoted \\\"message\\\"\"" json; + expect_contains "json newline" + "\"details\": [\"first line\", \"second line\"]" json + +let test_empty_json_report () = + let expected = + String.concat "\n" [ - Doctor.Check.make ~id:"quote" ~title:"quoted \"message\"" - Doctor.Check.Warn; + "{"; + " \"summary\": {"; + " \"status\": \"ok\","; + " \"exit_code\": 0"; + " },"; + " \"diagnostics\": []"; + "}"; ] + ^ "\n" in - expect_contains "json escapes title" - "\"message\": \"quoted \\\"message\\\"\"" escaped + expect_string "empty json" expected (Doctor.Report.render_json []) + +let () = + List.iter + (fun test -> test ()) + [ + test_exit_codes_and_counts; + test_text_report_includes_suggestions; + test_multiline_detail_and_suggestion_are_indented; + test_json_report_contains_diagnostics_summary_and_exit_code; + test_json_escapes_strings; + test_empty_json_report; + ]