defmodule BDS.CLITest do use ExUnit.Case, async: false import Ecto.Query import ExUnit.CaptureIO alias BDS.CLI alias BDS.CLI.Commands alias BDS.CliSync.Notification alias BDS.Repo setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) Repo.delete_all(Notification) temp_dir = Path.join(System.tmp_dir!(), "bds-cli-test-#{System.unique_integer([:positive])}") File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) {:ok, temp_dir: temp_dir} end defp create_active_project(temp_dir, name \\ "CLI Test") do {:ok, project} = BDS.Projects.create_project(%{name: name, data_path: temp_dir}) {:ok, project} = BDS.Projects.set_active_project(project.id) project end defp notifications do Repo.all(Notification) end describe "argv decoding" do test "splits on the unit separator and drops empties" do assert CLI.env_argv("post" <> <<0x1F>> <> "--title" <> <<0x1F>> <> "Hello" <> <<0x1F>>) == ["post", "--title", "Hello"] assert CLI.env_argv(nil) == [] assert CLI.env_argv("") == [] end end describe "parser" do test "parses every subcommand" do optimus = CLI.optimus() assert {:ok, [:rebuild], %{flags: %{incremental: true}}} = Optimus.parse(optimus, ["rebuild", "--incremental"]) assert {:ok, [:repair], %{args: %{part: "post-links"}}} = Optimus.parse(optimus, ["repair", "post-links"]) assert {:error, [:repair], _errors} = Optimus.parse(optimus, ["repair", "nonsense"]) assert {:ok, [:render], %{flags: %{force: true, incremental: false}}} = Optimus.parse(optimus, ["render", "--force"]) assert {:ok, [:upload], _result} = Optimus.parse(optimus, ["upload"]) assert {:ok, [:push], _result} = Optimus.parse(optimus, ["push"]) assert {:ok, [:pull], _result} = Optimus.parse(optimus, ["pull"]) assert {:ok, [:post], %{options: %{title: "Hi", tags: "a,b"}}} = Optimus.parse(optimus, ["post", "--title", "Hi", "--tags", "a,b"]) assert {:ok, [:media], %{args: %{file: "img.jpg"}, options: %{language: "de"}}} = Optimus.parse(optimus, ["media", "img.jpg", "--language", "de"]) assert {:ok, [:gallery], %{options: %{title: "Trip"}, unknown: ["a.jpg", "b.jpg"]}} = Optimus.parse(optimus, ["gallery", "--title", "Trip", "a.jpg", "b.jpg"]) assert {:ok, [:config, :get], %{args: %{key: "ui.theme"}}} = Optimus.parse(optimus, ["config", "get", "ui.theme"]) assert {:ok, [:config, :set], %{args: %{key: "k", value: "v"}}} = Optimus.parse(optimus, ["config", "set", "k", "v"]) assert {:ok, [:config, :list], _result} = Optimus.parse(optimus, ["config", "list"]) assert {:ok, [:project, :list], _result} = Optimus.parse(optimus, ["project", "list"]) assert {:ok, [:project, :add], %{args: %{path: "/tmp/x"}, options: %{name: "X"}}} = Optimus.parse(optimus, ["project", "add", "/tmp/x", "--name", "X"]) assert {:ok, [:project, :switch], %{args: %{project: "blog"}}} = Optimus.parse(optimus, ["project", "switch", "blog"]) assert {:ok, [:tui], _result} = Optimus.parse(optimus, ["tui"]) assert {:ok, [:lua], %{args: %{script: "cleanup"}, unknown: ["arg1"]}} = Optimus.parse(optimus, ["lua", "cleanup", "arg1"]) end test "help exits 0 and unknown commands exit 1" do {help_code, help_output} = with_io(fn -> CLI.run(["--help"]) end) assert help_code == 0 assert help_output =~ "rebuild" assert help_output =~ "gallery" {code, _output} = with_io(:stderr, fn -> CLI.run(["frobnicate"]) end) assert code == 1 end end describe "config commands" do test "set, get, and list operate on the global settings store", %{temp_dir: _temp_dir} do assert {:ok, "cli.test.key = hello"} = Commands.dispatch([:config, :set], %{args: %{key: "cli.test.key", value: "hello"}}) assert {:ok, "hello"} = Commands.dispatch([:config, :get], %{args: %{key: "cli.test.key"}}) {:ok, output} = with_io(fn -> Commands.dispatch([:config, :list], %{}) end) assert output =~ "cli.test.key=hello" assert {:error, message} = Commands.dispatch([:config, :get], %{args: %{key: "cli.test.missing"}}) assert message =~ "not set" assert Enum.any?( notifications(), &(&1.entity_type == "setting" and &1.entity_id == "cli.test.key" and &1.from_cli == true) ) end end describe "project commands" do test "add, switch, and list manage the project registry", %{temp_dir: temp_dir} do assert {:ok, added_message} = Commands.dispatch([:project, :add], %{ args: %{path: temp_dir}, options: %{name: "CLI Added"} }) assert added_message =~ "Added project" project = Enum.find(BDS.Projects.list_projects(), &(&1.name == "CLI Added")) assert project assert {:ok, "Active project: CLI Added"} = Commands.dispatch([:project, :switch], %{args: %{project: project.slug}}) {:ok, output} = with_io(fn -> Commands.dispatch([:project, :list], %{}) end) assert output =~ "CLI Added" assert output =~ "* " <> project.id assert {:error, message} = Commands.dispatch([:project, :switch], %{args: %{project: "no-such-project"}}) assert message =~ "No project matches" entity_types = Enum.map(notifications(), & &1.entity_type) assert "project" in entity_types end test "add rejects a missing directory" do assert {:error, message} = Commands.dispatch([:project, :add], %{ args: %{path: "/nonexistent/bds-cli"}, options: %{} }) assert message =~ "not a directory" end end describe "post command" do test "creates a post from options and writes a cli notification", %{temp_dir: temp_dir} do create_active_project(temp_dir) {{:ok, message}, output} = with_io(fn -> Commands.dispatch([:post], %{ flags: %{no_translate: true}, options: %{ title: "From the CLI", content: "Hello from the command line", language: "en", tags: "cli,test", categories: "notes" } }) end) assert message =~ "Created post" assert output == "" or output =~ "translation" post = Repo.one!( from post in BDS.Posts.Post, where: post.title == "From the CLI" ) assert post.tags == ["cli", "test"] assert post.categories == ["notes"] assert post.language == "en" assert post.status == :draft assert Enum.any?( notifications(), &(&1.entity_type == "post" and &1.entity_id == post.id and &1.action == :created) ) end test "creates a post from JSON on stdin", %{temp_dir: temp_dir} do create_active_project(temp_dir) json = Jason.encode!(%{ "title" => "Stdin Post", "content" => "Body from stdin", "language" => "en", "tags" => ["json", "cli"] }) {{:ok, message}, _output} = with_io([input: json], fn -> Commands.dispatch([:post], %{flags: %{stdin: true, no_translate: true}, options: %{}}) end) assert message =~ "Created post" post = Repo.one!(from post in BDS.Posts.Post, where: post.title == "Stdin Post") assert post.tags == ["json", "cli"] end test "requires a title", %{temp_dir: temp_dir} do create_active_project(temp_dir) assert {:error, message} = Commands.dispatch([:post], %{flags: %{}, options: %{}}) assert message =~ "--title is required" end end describe "media command" do test "imports a file and reports enrichment best-effort", %{temp_dir: temp_dir} do create_active_project(temp_dir) source = Path.join(temp_dir, "cli-image.png") File.write!(source, "fake png bytes") {result, _output} = with_io(fn -> Commands.dispatch([:media], %{args: %{file: source}, options: %{}}) end) assert {:ok, message} = result assert message =~ "Imported media" assert Enum.any?(notifications(), &(&1.entity_type == "media" and &1.action == :created)) end test "rejects a missing file", %{temp_dir: temp_dir} do create_active_project(temp_dir) assert {:error, message} = Commands.dispatch([:media], %{ args: %{file: Path.join(temp_dir, "missing.png")}, options: %{} }) assert message =~ "No such file" end end describe "rebuild command" do test "incremental rebuild diffs and applies from the filesystem", %{temp_dir: temp_dir} do create_active_project(temp_dir) {result, _output} = with_io(fn -> Commands.dispatch([:rebuild], %{flags: %{incremental: true}}) end) assert {:ok, message} = result assert message =~ "differences" end end describe "gallery and tui guards" do test "gallery without images errors", %{temp_dir: temp_dir} do create_active_project(temp_dir) assert {:error, message} = Commands.dispatch([:gallery], %{ flags: %{}, options: %{title: "Trip"}, unknown: [] }) assert message =~ "image files" end test "tui is delegated to the launcher script" do assert {:error, message} = Commands.dispatch([:tui], %{}) assert message =~ "launcher" end end describe "lua command" do test "rejects unknown and non-utility scripts", %{temp_dir: temp_dir} do project = create_active_project(temp_dir) assert {:error, message} = Commands.dispatch([:lua], %{args: %{script: "missing"}, unknown: []}) assert message =~ "No script" {:ok, script} = BDS.Scripts.create_script(%{ project_id: project.id, title: "Macro Script", kind: :macro, content: "function render() return \"x\" end", entrypoint: "render" }) assert {:error, macro_message} = Commands.dispatch([:lua], %{args: %{script: script.slug}, unknown: []}) assert macro_message =~ "macro" end test "runs a utility script synchronously", %{temp_dir: temp_dir} do project = create_active_project(temp_dir) {:ok, script} = BDS.Scripts.create_script(%{ project_id: project.id, title: "Task Script", kind: :utility, content: "function main() return \"done-42\" end", entrypoint: "main" }) assert {:ok, message} = Commands.dispatch([:lua], %{args: %{script: script.slug}, unknown: []}) assert message =~ "done-42" end end describe "full rebuild step sharing" do test "the CLI and GUI share the canonical full-rebuild sequence" do names = Enum.map(BDS.Maintenance.full_rebuild_steps("project-id"), & &1.name) assert names == [ "Rebuild Posts From Files", "Rebuild Media From Files", "Rebuild Scripts From Files", "Rebuild Templates From Files", "Rebuild Post Links", "Regenerate Missing Thumbnails", "Rebuild Embedding Index" ] end end describe "installer" do test "installs a shim pointing at the release launcher", %{temp_dir: temp_dir} do release_root = Path.join(temp_dir, "release") launcher = Path.join([release_root, "cli", "bin", "bds-cli"]) File.mkdir_p!(Path.dirname(launcher)) File.write!(launcher, "#!/bin/sh\n") bin_dir = Path.join(temp_dir, "local-bin") assert {:ok, installed} = BDS.CLI.Install.install(bin_dir: bin_dir, release_root: release_root) assert installed == Path.join(bin_dir, "bds-cli") content = File.read!(installed) assert content =~ launcher assert content =~ ~s(exec ") assert Bitwise.band(File.stat!(installed).mode, 0o111) != 0 end test "reports no_release outside a packaged release", %{temp_dir: temp_dir} do assert {:error, :no_release} = BDS.CLI.Install.install( bin_dir: Path.join(temp_dir, "bin"), release_root: Path.join(temp_dir, "not-a-release") ) end end describe "settings form action" do test "the data section exposes the install action and run_action handles it" do fields = BDS.UI.SettingsForm.load("data", "nonexistent-project").fields action = Enum.find(fields, &(&1.type == :action)) assert action.key == "install_cli" # Outside a packaged release the action reports the error message. assert {:error, message} = BDS.UI.SettingsForm.run_action("install_cli") assert message =~ "packaged application" assert {:error, _unknown} = BDS.UI.SettingsForm.run_action("nope") end end end