Skip to main content

src/rebar3_otter__cargo.erl

-module(rebar3_otter__cargo).
-moduledoc """
Cargo invocation and cdylib artifact resolution.

This module handles all interaction with the Rust toolchain.
It is independent of the rebar3 provider API.

The build pins cargo's `--target-dir` to `<crate>/target` and computes the
output cdylib path by convention rather than parsing cargo's JSON output.
cdylib final artifacts are not content-hashed, so the name is deterministic
(`lib<name>.so` / `.dylib`, `<name>.dll`), and pinning the target dir makes
its location a guarantee instead of a guess. This also keeps artifact
resolution free of the stdlib `json` module (no JSON to parse).
""".

-export([build/5, clean/1, nif_filename/1]).

-ifdef(TEST).
%% Exposed for unit tests of the cross-platform / cross-target path logic.
-export([artifact_path/4, artifact_filename/2]).
-endif.

%%------------------------------------------------------------------------------

%%%=============================================================================
%%% Public

-spec build(string(), string(), release | debug, [string()], string() | undefined) ->
  {ok, string()} | {error, term()}.
build(CratePath, Name, Mode, Features, Target) ->
  case find_cargo() of
    {error, _} = Err ->
      Err;
    {ok, Cargo} ->
      ManifestPath = filename:join(CratePath, "Cargo.toml"),
      TargetDir = target_dir(CratePath),
      Args = build_args(ManifestPath, Name, Mode, Features, Target, TargetDir),
      case run(Cargo, Args) of
        {0, _Output} ->
          %% We pinned --target-dir, so the cdylib path is fully determined
          %% by the inputs — no need to scrape cargo's output for it.
          Artifact = artifact_path(TargetDir, Name, Mode, Target),
          case filelib:is_file(Artifact) of
            true  -> {ok, Artifact};
            false -> {error, {no_cdylib, Name}}
          end;
        {Code, _Output} ->
          {error, {cargo_failed, Name, Code}}
      end
  end.

-doc """
Remove the crate's build output.

The build pins `--target-dir` to `<crate>/target`, so cleaning is just
removing that directory — exact in scope and independent of cargo (so it
works even without a toolchain installed).
""".
-spec clean(string()) -> ok.
clean(CratePath) ->
  _ = file:del_dir_r(target_dir(CratePath)),
  ok.

%% The directory cargo is told to write into (`--target-dir`) and that the
%% artifact path is computed against. Pinning it removes the workspace
%% target-dir ambiguity that would otherwise make the output location a guess.
-spec target_dir(string()) -> string().
target_dir(CratePath) ->
  filename:join(CratePath, "target").

%%%=============================================================================
%%% Private

%%------------------------------------------------------------------------------
%% Cargo invocation

-spec find_cargo() -> {ok, string()} | {error, cargo_not_found}.
find_cargo() ->
  case os:find_executable("cargo") of
    false -> {error, cargo_not_found};
    Path  -> {ok, Path}
  end.

-spec build_args(string(), string(), release | debug, [string()], string() | undefined, string()) ->
  [string()].
build_args(ManifestPath, Name, Mode, Features, Target, TargetDir) ->
  %% Plain `cargo build` (human message format): compiler diagnostics render
  %% to stderr, which `run/2` lets through to the terminal. `--target-dir`
  %% pins the output location so `artifact_path/4` can compute the cdylib path
  %% without parsing JSON (which would pull in the OTP-27-only `json` module).
  Base = ["build",
          "--manifest-path", ManifestPath,
          "--target-dir", TargetDir,
          "-p", Name],
  ModeArgs = case Mode of
    release -> ["--release"];
    debug   -> []
  end,
  FeatureArgs = case Features of
    [] -> [];
    _  -> ["--features", string:join(Features, ",")]
  end,
  TargetArgs = case Target of
    undefined -> [];
    T         -> ["--target", T]
  end,
  Base ++ ModeArgs ++ FeatureArgs ++ TargetArgs.

%% Run cargo and capture stdout. Stderr is inherited by the child
%% process, so compiler diagnostics appear directly in the terminal.
-spec run(string(), [string()]) -> {non_neg_integer(), binary()}.
run(Cargo, Args) ->
  Env = [{"ERTS_INCLUDE_DIR", erts_include_dir()}],
  Port = open_port({spawn_executable, Cargo}, [
    {args, Args},
    {env, Env},
    binary,
    exit_status
  ]),
  collect(Port, []).

-spec collect(port(), [binary()]) -> {non_neg_integer(), binary()}.
collect(Port, Acc) ->
  receive
    {Port, {data, Data}} ->
      collect(Port, [Data | Acc]);
    {Port, {exit_status, Status}} ->
      {Status, iolist_to_binary(lists:reverse(Acc))}
  end.

-spec erts_include_dir() -> string().
erts_include_dir() ->
  filename:join([code:root_dir(),
                 "erts-" ++ erlang:system_info(version),
                 "include"]).

%%------------------------------------------------------------------------------
%% Artifact path (by convention)

%% Absolute path of the cdylib cargo writes for crate Name, given the pinned
%% target dir, the build mode, and the optional --target triple. cargo lays
%% artifacts out as `<target_dir>/[<triple>/]<release|debug>/<file>` and the
%% cdylib final name is not content-hashed, so this is exact.
-spec artifact_path(string(), string(), release | debug, string() | undefined) ->
  string().
artifact_path(TargetDir, Name, Mode, Target) ->
  ProfileDir = case Mode of release -> "release"; debug -> "debug" end,
  File = artifact_filename(normalize_crate_name(Name), Target),
  Parts = case Target of
            undefined -> [TargetDir, ProfileDir, File];
            _         -> [TargetDir, Target, ProfileDir, File]
          end,
  filename:join(Parts).

%% The cdylib filename for the already-normalized crate name. The library
%% prefix/extension follow the *target* platform: derived from the --target
%% triple when one is set (so cross-compiles resolve correctly), otherwise
%% from the build host. Note this is the cargo *source* name (`.dylib` on
%% macOS); `nif_filename/1` gives the `.so` *destination* Erlang expects.
-spec artifact_filename(string(), string() | undefined) -> string().
artifact_filename(Norm, undefined) ->
  case os:type() of
    {win32, _}     -> Norm ++ ".dll";
    {unix, darwin} -> "lib" ++ Norm ++ ".dylib";
    {unix, _}      -> "lib" ++ Norm ++ ".so"
  end;
artifact_filename(Norm, Target) ->
  case classify_triple(Target) of
    windows -> Norm ++ ".dll";
    darwin  -> "lib" ++ Norm ++ ".dylib";
    other   -> "lib" ++ Norm ++ ".so"
  end.

-spec classify_triple(string()) -> windows | darwin | other.
classify_triple(Triple) ->
  IsWindows = string:find(Triple, "windows") =/= nomatch,
  IsDarwin  = (string:find(Triple, "darwin") =/= nomatch)
              orelse (string:find(Triple, "apple") =/= nomatch),
  if
    IsWindows -> windows;
    IsDarwin  -> darwin;
    true      -> other
  end.

%% cargo replaces '-' with '_' in lib target names, so a package named
%% `my-nif` produces `libmy_nif.so`. Normalize the configured name to match.
-spec normalize_crate_name(string()) -> string().
normalize_crate_name(Name) ->
  lists:flatten(string:replace(Name, "-", "_", all)).

%%------------------------------------------------------------------------------
%% Helpers

-doc """
Platform-appropriate filename for a NIF shared library.

`.dll` on Windows, `.so` everywhere else (including macOS, where Erlang
expects `.so` rather than `.dylib`).
""".
-spec nif_filename(string()) -> string().
nif_filename(Name) ->
  case os:type() of
    {win32, _} -> Name ++ ".dll";
    _          -> Name ++ ".so"
  end.