diff --git a/src/rebar3_nova.erl b/src/rebar3_nova.erl
index 3483f6e..d6e7ce3 100644
--- a/src/rebar3_nova.erl
+++ b/src/rebar3_nova.erl
@@ -22,10 +22,12 @@ init(State) ->
rebar3_nova_gen_resource,
rebar3_nova_gen_test,
rebar3_nova_gen_auth,
+ rebar3_nova_gen_live,
rebar3_nova_middleware,
rebar3_nova_config,
rebar3_nova_audit,
- rebar3_nova_release
+ rebar3_nova_release,
+ rebar3_nova_new
]
);
["git"] ->
@@ -44,10 +46,12 @@ init(State) ->
rebar3_nova_gen_resource,
rebar3_nova_gen_test,
rebar3_nova_gen_auth,
+ rebar3_nova_gen_live,
rebar3_nova_middleware,
rebar3_nova_config,
rebar3_nova_audit,
- rebar3_nova_release
+ rebar3_nova_release,
+ rebar3_nova_new
]
);
SomethingElse ->
diff --git a/src/rebar3_nova_gen_live.erl b/src/rebar3_nova_gen_live.erl
new file mode 100644
index 0000000..9878ea4
--- /dev/null
+++ b/src/rebar3_nova_gen_live.erl
@@ -0,0 +1,730 @@
+-module(rebar3_nova_gen_live).
+
+-export([init/1, do/1, format_error/1]).
+-export([generate/5, generate_optional/5]).
+
+-define(PROVIDER, gen_live).
+-define(DEPS, [{default, compile}]).
+
+-spec init(rebar_state:t()) -> {ok, rebar_state:t()}.
+init(State) ->
+ Provider = providers:create([
+ {name, ?PROVIDER},
+ {module, ?MODULE},
+ {namespace, nova},
+ {bare, true},
+ {deps, ?DEPS},
+ {example,
+ "rebar3 nova gen_live --name users --fields name:string,email:string,active:boolean"},
+ {opts, [
+ {name, $n, "name", string, "Resource name, plural (required)"},
+ {fields, $f, "fields", string, "Comma-separated field:type pairs (required)"},
+ {actions, $a, "actions", {string, "index,show,new,edit"},
+ "Comma-separated view actions"},
+ {no_schema, undefined, "no-schema", boolean, "Skip schema and migration generation"}
+ ]},
+ {short_desc, "Generate Arizona LiveView CRUD views for a resource"},
+ {desc,
+ "Generates Arizona views, Kura schema, migration, and test suite\n"
+ "for a resource. Similar to Phoenix's phx.gen.live.\n\n"
+ "Example:\n"
+ " rebar3 nova gen_live --name users --fields name:string,email:string,active:boolean\n\n"
+ "Supported field types: string, text, integer, float, boolean, date,\n"
+ " utc_datetime, uuid, jsonb"}
+ ]),
+ {ok, rebar_state:add_provider(State, Provider)}.
+
+-spec do(rebar_state:t()) -> {ok, rebar_state:t()} | {error, string()}.
+do(State) ->
+ {Args, _} = rebar_state:command_parsed_args(State),
+ case {proplists:get_value(name, Args), proplists:get_value(fields, Args)} of
+ {undefined, _} ->
+ rebar_api:abort("--name is required", []);
+ {_, undefined} ->
+ rebar_api:abort("--fields is required", []);
+ {Name, FieldsStr} ->
+ AppName = rebar3_nova_utils:get_app_name(State),
+ AppDir = rebar3_nova_utils:get_app_dir(State),
+ ActionsStr = proplists:get_value(actions, Args, "index,show,new,edit"),
+ Actions = rebar3_nova_utils:parse_actions(ActionsStr),
+ Fields = parse_fields(FieldsStr),
+ Opts = #{
+ no_schema => proplists:get_value(no_schema, Args, false)
+ },
+ generate(AppName, AppDir, Name, Fields, Actions),
+ generate_optional(AppName, AppDir, Name, Fields, Opts),
+ print_route_hints(AppName, Name, Actions),
+ {ok, State}
+ end.
+
+-spec format_error(any()) -> iolist().
+format_error(Reason) ->
+ io_lib:format("~p", [Reason]).
+
+-spec generate(atom(), file:filename(), string(), [{string(), string()}], [atom()]) -> ok.
+generate(AppName, AppDir, Name, Fields, Actions) ->
+ App = atom_to_list(AppName),
+ Singular = singularize(Name),
+ lists:foreach(
+ fun(Action) ->
+ generate_view(App, AppDir, Singular, Name, Fields, Action)
+ end,
+ Actions
+ ).
+
+%%======================================================================
+%% Internal: optional generators (schema, migration, test)
+%%======================================================================
+
+generate_optional(AppName, AppDir, Name, Fields, Opts) ->
+ App = atom_to_list(AppName),
+ Singular = singularize(Name),
+ case maps:get(no_schema, Opts, false) of
+ false ->
+ generate_schema(App, AppDir, Singular, Name, Fields),
+ generate_migration(AppDir, Name, Fields);
+ true ->
+ ok
+ end,
+ generate_test(App, AppDir, Singular, Name, Fields).
+
+%%======================================================================
+%% Internal: view generators
+%%======================================================================
+
+generate_view(App, AppDir, Singular, Plural, Fields, index) ->
+ Mod = App ++ "_" ++ Singular ++ "_index_view",
+ FileName = filename:join([AppDir, "src", "views", Mod ++ ".erl"]),
+ Content = index_view_content(Mod, App, Singular, Plural, Fields),
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content);
+generate_view(App, AppDir, Singular, _Plural, Fields, show) ->
+ Mod = App ++ "_" ++ Singular ++ "_show_view",
+ FileName = filename:join([AppDir, "src", "views", Mod ++ ".erl"]),
+ Content = show_view_content(Mod, App, Singular, Fields),
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content);
+generate_view(App, AppDir, Singular, _Plural, Fields, new) ->
+ Mod = App ++ "_" ++ Singular ++ "_new_view",
+ FileName = filename:join([AppDir, "src", "views", Mod ++ ".erl"]),
+ Content = new_view_content(Mod, App, Singular, Fields),
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content);
+generate_view(App, AppDir, Singular, _Plural, Fields, edit) ->
+ Mod = App ++ "_" ++ Singular ++ "_edit_view",
+ FileName = filename:join([AppDir, "src", "views", Mod ++ ".erl"]),
+ Content = edit_view_content(Mod, App, Singular, Fields),
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content);
+generate_view(_App, _AppDir, _Singular, _Plural, _Fields, _Action) ->
+ ok.
+
+%%----------------------------------------------------------------------
+%% Index view
+%%----------------------------------------------------------------------
+
+index_view_content(Mod, App, Singular, Plural, Fields) ->
+ Schema = App ++ "_" ++ Singular,
+ Repo = App ++ "_repo",
+ iolist_to_binary([
+ "-module(",
+ Mod,
+ ").\n"
+ "-compile({parse_transform, arizona_parse_transform}).\n"
+ "-behaviour(arizona_view).\n\n"
+ "-export([mount/2, render/1, handle_event/3]).\n\n"
+ "mount(_MountArg, _Request) ->\n"
+ " {ok, Items} = kura_repo_worker:all(",
+ Repo,
+ ", kura_query:from(",
+ Schema,
+ ")),\n"
+ " arizona_view:new(?MODULE, #{",
+ Plural,
+ " => Items}, none).\n\n"
+ "render(Bindings) ->\n"
+ " arizona_template:from_html(~\"\"\"\"\n"
+ "
\n"
+ "
",
+ capitalize(Plural),
+ "
\n"
+ "
New ",
+ capitalize(Singular),
+ "\n"
+ "
\n"
+ " \n"
+ " \n",
+ table_headers(Fields),
+ " | \n"
+ "
\n"
+ " \n"
+ " \n"
+ " {arizona_template:render_list(\n"
+ " fun(Item) ->\n"
+ " arizona_template:from_html(~\"\"\"\n"
+ " \n",
+ table_cells(Fields),
+ " | \n"
+ " Show\n"
+ " Edit\n"
+ " | \n"
+ "
\n"
+ " \"\"\")\n"
+ " end, arizona_template:get_binding(",
+ Plural,
+ ", Bindings))}\n"
+ " \n"
+ "
\n"
+ "
\n"
+ " \"\"\"\").\n\n"
+ "handle_event(~\"delete\", #{~\"id\" := Id}, View) ->\n"
+ " {ok, Record} = kura_repo_worker:get(",
+ Repo,
+ ", ",
+ Schema,
+ ", Id),\n"
+ " CS = kura_changeset:cast(",
+ Schema,
+ ", Record, #{}, []),\n"
+ " {ok, _} = kura_repo_worker:delete(",
+ Repo,
+ ", CS),\n"
+ " {ok, Items} = kura_repo_worker:all(",
+ Repo,
+ ", kura_query:from(",
+ Schema,
+ ")),\n"
+ " State = arizona_view:get_state(View),\n"
+ " NewState = arizona_stateful:put_binding(",
+ Plural,
+ ", Items, State),\n"
+ " {[], arizona_view:update_state(NewState, View)}.\n"
+ ]).
+
+table_headers(Fields) ->
+ iolist_to_binary([
+ [" ", capitalize(Name), " | \n"]
+ || {Name, _Type} <- Fields
+ ]).
+
+table_cells(Fields) ->
+ iolist_to_binary([
+ [" {maps:get(", Name, ", Item)} | \n"]
+ || {Name, _Type} <- Fields
+ ]).
+
+%%----------------------------------------------------------------------
+%% Show view
+%%----------------------------------------------------------------------
+
+show_view_content(Mod, App, Singular, Fields) ->
+ Schema = App ++ "_" ++ Singular,
+ Repo = App ++ "_repo",
+ iolist_to_binary([
+ "-module(",
+ Mod,
+ ").\n"
+ "-compile({parse_transform, arizona_parse_transform}).\n"
+ "-behaviour(arizona_view).\n\n"
+ "-export([mount/2, render/1]).\n\n"
+ "mount(#{id := Id}, _Request) ->\n"
+ " {ok, Item} = kura_repo_worker:get(",
+ Repo,
+ ", ",
+ Schema,
+ ", Id),\n"
+ " arizona_view:new(?MODULE, #{",
+ Singular,
+ " => Item}, none).\n\n"
+ "render(Bindings) ->\n"
+ " ",
+ capitalize(Singular),
+ " = arizona_template:get_binding(",
+ Singular,
+ ", Bindings),\n"
+ " arizona_template:from_html(~\"\"\"\n"
+ " \n"
+ "
",
+ capitalize(Singular),
+ "
\n"
+ "
\n",
+ show_fields(Singular, Fields),
+ "
\n"
+ "
Back\n"
+ "
Edit\n"
+ "
\n"
+ " \"\"\").\n"
+ ]).
+
+show_fields(_Singular, Fields) ->
+ iolist_to_binary([
+ [
+ " ",
+ capitalize(Name),
+ "\n"
+ " {maps:get(",
+ Name,
+ ", ",
+ capitalize(_Singular),
+ ")}\n"
+ ]
+ || {Name, _Type} <- Fields
+ ]).
+
+%%----------------------------------------------------------------------
+%% New view
+%%----------------------------------------------------------------------
+
+new_view_content(Mod, App, Singular, Fields) ->
+ Schema = App ++ "_" ++ Singular,
+ Repo = App ++ "_repo",
+ CastFields = field_names_list(Fields),
+ iolist_to_binary([
+ "-module(",
+ Mod,
+ ").\n"
+ "-compile({parse_transform, arizona_parse_transform}).\n"
+ "-behaviour(arizona_view).\n\n"
+ "-export([mount/2, render/1, handle_event/3]).\n\n"
+ "mount(_MountArg, _Request) ->\n"
+ " arizona_view:new(?MODULE, #{errors => []}, none).\n\n"
+ "render(Bindings) ->\n"
+ " arizona_template:from_html(~\"\"\"\n"
+ " \n"
+ "
New ",
+ capitalize(Singular),
+ "
\n"
+ "
\n"
+ "
\n"
+ " \"\"\").\n\n"
+ "handle_event(~\"save\", Params, View) ->\n"
+ " CS = kura_changeset:cast(",
+ Schema,
+ ", #{}, Params, ",
+ CastFields,
+ "),\n"
+ " CS1 = kura_changeset:validate_required(CS, ",
+ CastFields,
+ "),\n"
+ " case kura_repo_worker:insert(",
+ Repo,
+ ", CS1) of\n"
+ " {ok, _} ->\n"
+ " {[{redirect, ~\"/",
+ pluralize(Singular),
+ "\", #{}}], View};\n"
+ " {error, Changeset} ->\n"
+ " Errors = maps:get(errors, Changeset, []),\n"
+ " State = arizona_view:get_state(View),\n"
+ " NewState = arizona_stateful:put_binding(errors, Errors, State),\n"
+ " {[], arizona_view:update_state(NewState, View)}\n"
+ " end.\n"
+ ]).
+
+%%----------------------------------------------------------------------
+%% Edit view
+%%----------------------------------------------------------------------
+
+edit_view_content(Mod, App, Singular, Fields) ->
+ Schema = App ++ "_" ++ Singular,
+ Repo = App ++ "_repo",
+ CastFields = field_names_list(Fields),
+ iolist_to_binary([
+ "-module(",
+ Mod,
+ ").\n"
+ "-compile({parse_transform, arizona_parse_transform}).\n"
+ "-behaviour(arizona_view).\n\n"
+ "-export([mount/2, render/1, handle_event/3]).\n\n"
+ "mount(#{id := Id}, _Request) ->\n"
+ " {ok, Item} = kura_repo_worker:get(",
+ Repo,
+ ", ",
+ Schema,
+ ", Id),\n"
+ " arizona_view:new(?MODULE, #{",
+ Singular,
+ " => Item, errors => []}, none).\n\n"
+ "render(Bindings) ->\n"
+ " ",
+ capitalize(Singular),
+ " = arizona_template:get_binding(",
+ Singular,
+ ", Bindings),\n"
+ " arizona_template:from_html(~\"\"\"\n"
+ " \n"
+ "
Edit ",
+ capitalize(Singular),
+ "
\n"
+ "
\n"
+ "
\n"
+ " \"\"\").\n\n"
+ "handle_event(~\"save\", Params, View) ->\n"
+ " State0 = arizona_view:get_state(View),\n"
+ " ",
+ capitalize(Singular),
+ " = arizona_stateful:get_binding(",
+ Singular,
+ ", State0),\n"
+ " Id = maps:get(id, ",
+ capitalize(Singular),
+ "),\n"
+ " {ok, Existing} = kura_repo_worker:get(",
+ Repo,
+ ", ",
+ Schema,
+ ", Id),\n"
+ " CS = kura_changeset:cast(",
+ Schema,
+ ", Existing, Params, ",
+ CastFields,
+ "),\n"
+ " case kura_repo_worker:update(",
+ Repo,
+ ", CS) of\n"
+ " {ok, _} ->\n"
+ " {[{redirect, ~\"/",
+ pluralize(Singular),
+ "\", #{}}], View};\n"
+ " {error, Changeset} ->\n"
+ " Errors = maps:get(errors, Changeset, []),\n"
+ " NewState = arizona_stateful:put_binding(errors, Errors, State0),\n"
+ " {[], arizona_view:update_state(NewState, View)}\n"
+ " end.\n"
+ ]).
+
+%%======================================================================
+%% Internal: form field generation
+%%======================================================================
+
+form_fields(Fields) ->
+ iolist_to_binary([form_field(Name, Type) || {Name, Type} <- Fields]).
+
+form_field(Name, Type) ->
+ Label = capitalize(Name),
+ InputType = field_to_input_type(Type),
+ case InputType of
+ "textarea" ->
+ [
+ " \n"
+ " \n"
+ ];
+ "checkbox" ->
+ [
+ " \n"
+ ];
+ _ ->
+ [
+ " \n"
+ " \n"
+ ]
+ end.
+
+edit_form_fields(Singular, Fields) ->
+ iolist_to_binary([edit_form_field(Singular, Name, Type) || {Name, Type} <- Fields]).
+
+edit_form_field(Singular, Name, Type) ->
+ Label = capitalize(Name),
+ InputType = field_to_input_type(Type),
+ Value = "maps:get(" ++ Name ++ ", " ++ capitalize(Singular) ++ ")",
+ case InputType of
+ "textarea" ->
+ [
+ " \n"
+ " \n"
+ ];
+ "checkbox" ->
+ [
+ " \n"
+ ];
+ _ ->
+ [
+ " \n"
+ " \n"
+ ]
+ end.
+
+field_to_input_type("string") -> "text";
+field_to_input_type("text") -> "textarea";
+field_to_input_type("integer") -> "number";
+field_to_input_type("float") -> "number";
+field_to_input_type("boolean") -> "checkbox";
+field_to_input_type("date") -> "date";
+field_to_input_type("utc_datetime") -> "datetime-local";
+field_to_input_type("uuid") -> "text";
+field_to_input_type("jsonb") -> "textarea";
+field_to_input_type(_) -> "text".
+
+%%======================================================================
+%% Internal: schema generator
+%%======================================================================
+
+generate_schema(App, AppDir, Singular, Plural, Fields) ->
+ Mod = App ++ "_" ++ Singular,
+ FileName = filename:join([AppDir, "src", "schemas", Mod ++ ".erl"]),
+ Content = schema_content(Mod, Plural, Fields),
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
+
+schema_content(Mod, Table, Fields) ->
+ FieldDefs = schema_fields(Fields),
+ iolist_to_binary([
+ "-module(",
+ Mod,
+ ").\n"
+ "-behaviour(kura_schema).\n"
+ "-include_lib(\"kura/include/kura.hrl\").\n\n"
+ "-export([table/0, fields/0]).\n\n"
+ "table() -> <<\"",
+ Table,
+ "\">>.\n\n"
+ "fields() ->\n"
+ " [\n"
+ " #kura_field{name = id, type = id, primary_key = true, nullable = false}",
+ FieldDefs,
+ ",\n"
+ " #kura_field{name = inserted_at, type = utc_datetime},\n"
+ " #kura_field{name = updated_at, type = utc_datetime}\n"
+ " ].\n"
+ ]).
+
+schema_fields(Fields) ->
+ iolist_to_binary([
+ [",\n #kura_field{name = ", Name, ", type = ", Type, "}"]
+ || {Name, Type} <- Fields
+ ]).
+
+%%======================================================================
+%% Internal: migration generator
+%%======================================================================
+
+generate_migration(AppDir, Table, Fields) ->
+ TS = timestamp(),
+ Mod = "m" ++ TS ++ "_create_" ++ Table,
+ FileName = filename:join([AppDir, "src", "migrations", Mod ++ ".erl"]),
+ Content = migration_content(Mod, Table, Fields),
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
+
+migration_content(Mod, Table, Fields) ->
+ Columns = migration_columns(Fields),
+ iolist_to_binary([
+ "-module(",
+ Mod,
+ ").\n"
+ "-behaviour(kura_migration).\n"
+ "-include_lib(\"kura/include/kura.hrl\").\n\n"
+ "-export([up/0, down/0]).\n\n"
+ "up() ->\n"
+ " [{create_table, <<\"",
+ Table,
+ "\">>, [\n"
+ " #kura_column{name = id, type = id, primary_key = true}",
+ Columns,
+ ",\n"
+ " #kura_column{name = inserted_at, type = utc_datetime},\n"
+ " #kura_column{name = updated_at, type = utc_datetime}\n"
+ " ]}].\n\n"
+ "down() ->\n"
+ " [{drop_table, <<\"",
+ Table,
+ "\">>}].\n"
+ ]).
+
+migration_columns(Fields) ->
+ iolist_to_binary([
+ [",\n #kura_column{name = ", Name, ", type = ", Type, "}"]
+ || {Name, Type} <- Fields
+ ]).
+
+%%======================================================================
+%% Internal: test generator
+%%======================================================================
+
+generate_test(App, AppDir, Singular, Plural, Fields) ->
+ Mod = App ++ "_" ++ Singular ++ "_live_SUITE",
+ FileName = filename:join([AppDir, "test", Mod ++ ".erl"]),
+ Content = test_content(Mod, App, Singular, Plural, Fields),
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
+
+test_content(Mod, App, Singular, _Plural, _Fields) ->
+ Schema = App ++ "_" ++ Singular,
+ Repo = App ++ "_repo",
+ iolist_to_binary([
+ "-module(",
+ Mod,
+ ").\n\n"
+ "-include_lib(\"common_test/include/ct.hrl\").\n"
+ "-include_lib(\"stdlib/include/assert.hrl\").\n\n"
+ "-export([all/0, init_per_suite/1, end_per_suite/1]).\n"
+ "-export([list_test/1, get_test/1, create_test/1, update_test/1, delete_test/1]).\n\n"
+ "all() ->\n"
+ " [list_test, get_test, create_test, update_test, delete_test].\n\n"
+ "init_per_suite(Config) ->\n"
+ " {ok, _} = application:ensure_all_started(",
+ App,
+ "),\n"
+ " Config.\n\n"
+ "end_per_suite(_Config) ->\n"
+ " ok.\n\n"
+ "list_test(_Config) ->\n"
+ " {ok, Items} = kura_repo_worker:all(",
+ Repo,
+ ", kura_query:from(",
+ Schema,
+ ")),\n"
+ " ?assert(is_list(Items)).\n\n"
+ "get_test(_Config) ->\n"
+ " %% TODO: Create a record first, then fetch it\n"
+ " ok.\n\n"
+ "create_test(_Config) ->\n"
+ " %% TODO: Insert with kura_changeset:cast/4 + kura_repo_worker:insert/2\n"
+ " ok.\n\n"
+ "update_test(_Config) ->\n"
+ " %% TODO: Create a record, then update it\n"
+ " ok.\n\n"
+ "delete_test(_Config) ->\n"
+ " %% TODO: Create a record, then delete it\n"
+ " ok.\n"
+ ]).
+
+%%======================================================================
+%% Internal: route hints
+%%======================================================================
+
+print_route_hints(AppName, Plural, Actions) ->
+ Singular = singularize(Plural),
+ rebar_api:info("~nAdd these routes to your router:~n", []),
+ rebar_api:info(" %% ~s views", [capitalize(Singular)]),
+ lists:foreach(
+ fun(Action) -> print_route_hint(AppName, Singular, Plural, Action) end,
+ Actions
+ ).
+
+print_route_hint(AppName, Singular, Plural, index) ->
+ rebar_api:info(
+ " {<<\"/~s\">>, {~s_~s_index_view, mount}, #{methods => [get]}}",
+ [Plural, AppName, Singular]
+ );
+print_route_hint(AppName, Singular, Plural, show) ->
+ rebar_api:info(
+ " {<<\"/~s/:id\">>, {~s_~s_show_view, mount}, #{methods => [get]}}",
+ [Plural, AppName, Singular]
+ );
+print_route_hint(AppName, Singular, Plural, new) ->
+ rebar_api:info(
+ " {<<\"/~s/new\">>, {~s_~s_new_view, mount}, #{methods => [get]}}",
+ [Plural, AppName, Singular]
+ );
+print_route_hint(AppName, Singular, Plural, edit) ->
+ rebar_api:info(
+ " {<<\"/~s/:id/edit\">>, {~s_~s_edit_view, mount}, #{methods => [get]}}",
+ [Plural, AppName, Singular]
+ );
+print_route_hint(_, _, _, _) ->
+ ok.
+
+%%======================================================================
+%% Internal: helpers
+%%======================================================================
+
+parse_fields(Str) ->
+ Pairs = string:tokens(Str, ","),
+ [parse_field(string:trim(P)) || P <- Pairs].
+
+parse_field(Pair) ->
+ case string:tokens(Pair, ":") of
+ [Name, Type] -> {string:trim(Name), string:trim(Type)};
+ [Name] -> {string:trim(Name), "string"};
+ _ -> erlang:error({bad_field_spec, Pair})
+ end.
+
+singularize(Name) ->
+ case lists:reverse(Name) of
+ [$s | Rest] -> lists:reverse(Rest);
+ _ -> Name
+ end.
+
+pluralize(Name) ->
+ case lists:last(Name) of
+ $s -> Name;
+ _ -> Name ++ "s"
+ end.
+
+capitalize([H | T]) when H >= $a, H =< $z ->
+ [H - 32 | T];
+capitalize(Other) ->
+ Other.
+
+timestamp() ->
+ {{Y, Mo, D}, {H, Mi, S}} = calendar:universal_time(),
+ lists:flatten(
+ io_lib:format(
+ "~4..0B~2..0B~2..0B~2..0B~2..0B~2..0B",
+ [Y, Mo, D, H, Mi, S]
+ )
+ ).
+
+field_names_list(Fields) ->
+ Names = [Name || {Name, _Type} <- Fields],
+ "[" ++ string:join(Names, ", ") ++ "]".
diff --git a/src/rebar3_nova_new.erl b/src/rebar3_nova_new.erl
new file mode 100644
index 0000000..b178606
--- /dev/null
+++ b/src/rebar3_nova_new.erl
@@ -0,0 +1,1139 @@
+-module(rebar3_nova_new).
+
+-export([init/1, do/1, format_error/1]).
+-export([generate_project/2, validate_flags/1]).
+
+-define(PROVIDER, new).
+-define(DEPS, []).
+
+-spec init(rebar_state:t()) -> {ok, rebar_state:t()}.
+init(State) ->
+ Provider = providers:create([
+ {name, ?PROVIDER},
+ {module, ?MODULE},
+ {namespace, nova},
+ {bare, true},
+ {deps, ?DEPS},
+ {example,
+ "rebar3 nova new myapp [--kura] [--pgo] [--arizona] [--lfe] [--ci] [--docker] [--otel]"},
+ {opts, [
+ {name, $n, "name", string, "Project name"},
+ {kura, undefined, "kura", boolean, "Include Kura database layer"},
+ {pgo, undefined, "pgo", boolean, "Include PGO PostgreSQL client"},
+ {arizona, undefined, "arizona", boolean, "Include Arizona live views"},
+ {lfe, undefined, "lfe", boolean, "Generate LFE source files"},
+ {ci, undefined, "ci", boolean, "Generate GitHub Actions CI workflow"},
+ {docker, undefined, "docker", boolean, "Generate Dockerfile"},
+ {otel, undefined, "otel", boolean, "Include OpenTelemetry instrumentation"}
+ ]},
+ {short_desc, "Create a new Nova project"},
+ {desc, "Create a new Nova project with composable flags"}
+ ]),
+ {ok, rebar_state:add_provider(State, Provider)}.
+
+-spec do(rebar_state:t()) -> {ok, rebar_state:t()} | {error, string()}.
+do(State) ->
+ {Opts, Args} = rebar_state:command_parsed_args(State),
+ Name = resolve_name(Opts, Args),
+ Flags = parse_flags(Opts),
+ case validate_flags(Flags) of
+ ok ->
+ generate_project(Name, Flags),
+ print_summary(Name, Flags),
+ {ok, State};
+ {error, Reason} ->
+ {error, Reason}
+ end.
+
+-spec format_error(any()) -> iolist().
+format_error(Reason) ->
+ io_lib:format("~p", [Reason]).
+
+%%======================================================================
+%% Flag parsing
+%%======================================================================
+
+resolve_name(Opts, Args) ->
+ case proplists:get_value(name, Opts) of
+ undefined ->
+ case Args of
+ [N | _] -> N;
+ _ -> error(missing_project_name)
+ end;
+ N ->
+ N
+ end.
+
+parse_flags(Opts) ->
+ #{
+ kura => proplists:get_value(kura, Opts, false),
+ pgo => proplists:get_value(pgo, Opts, false),
+ arizona => proplists:get_value(arizona, Opts, false),
+ lfe => proplists:get_value(lfe, Opts, false),
+ ci => proplists:get_value(ci, Opts, false),
+ docker => proplists:get_value(docker, Opts, false),
+ otel => proplists:get_value(otel, Opts, false)
+ }.
+
+validate_flags(#{kura := true, pgo := true}) ->
+ {error, "--kura and --pgo are mutually exclusive (kura uses pgo internally)"};
+validate_flags(_) ->
+ ok.
+
+%%======================================================================
+%% Project generation
+%%======================================================================
+
+generate_project(Name, Flags) ->
+ case filelib:is_dir(Name) of
+ true ->
+ error({dir_exists, Name});
+ false ->
+ ok
+ end,
+ generate_rebar_config(Name, Flags),
+ generate_app_src(Name, Flags),
+ generate_app(Name, Flags),
+ generate_sup(Name, Flags),
+ generate_router(Name, Flags),
+ generate_controller(Name, Flags),
+ generate_dev_sys_config(Name, Flags),
+ generate_prod_sys_config(Name, Flags),
+ generate_vm_args(Name),
+ generate_tool_versions(Name),
+ generate_gitignore(Name),
+ copy_favicon(Name),
+ maybe_generate_view(Name, Flags),
+ maybe_generate_kura(Name, Flags),
+ maybe_generate_pgo(Name, Flags),
+ maybe_generate_arizona(Name, Flags),
+ maybe_generate_ci(Name, Flags),
+ maybe_generate_docker(Name, Flags).
+
+%%======================================================================
+%% rebar.config
+%%======================================================================
+
+generate_rebar_config(Name, Flags) ->
+ Path = filename:join(Name, "rebar.config"),
+ Content = [
+ "%% -*- mode: erlang;erlang-indent-level: 4;indent-tabs-mode: nil -*-\n\n",
+ "{erl_opts, [debug_info]}.\n",
+ "{src_dirs, [{\"src\", [{recursive, true}]}]}.\n",
+ "{shell, [{config, \"./config/dev_sys.config.src\"}]}.\n\n",
+ rebar_erlydtl_opts(Flags),
+ rebar_deps(Flags),
+ rebar_relx(Name, Flags),
+ rebar_profiles(Flags),
+ "{dialyzer, [{plt_apps, all_deps}]}.\n\n",
+ rebar_plugins(Flags),
+ rebar_erlfmt(Flags),
+ rebar_provider_hooks(Flags),
+ rebar_xref()
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+rebar_erlydtl_opts(#{arizona := true}) ->
+ [];
+rebar_erlydtl_opts(#{lfe := true}) ->
+ [
+ "{erlydtl_opts, [\n",
+ " {doc_root, \"src/views\"},\n",
+ " {recursive, true},\n",
+ " {libraries, [\n",
+ " {nova_erlydtl_inventory, nova_erlydtl_inventory}\n",
+ " ]},\n",
+ " {default_libraries, [nova_erlydtl_inventory]}\n",
+ "]}.\n\n"
+ ];
+rebar_erlydtl_opts(_) ->
+ [
+ "{erlydtl_opts, [\n",
+ " {doc_root, \"src/views\"},\n",
+ " {recursive, true},\n",
+ " {libraries, [\n",
+ " {nova_erlydtl_inventory, nova_erlydtl_inventory}\n",
+ " ]},\n",
+ " {default_libraries, [nova_erlydtl_inventory]}\n",
+ "]}.\n\n"
+ ].
+
+rebar_deps(Flags) ->
+ BaseDeps =
+ case maps:get(lfe, Flags) of
+ true -> [" nova,\n", " {logjam, \"1.2.4\"}"];
+ false -> [" nova,\n", " {flatlog, \"0.1.2\"}"]
+ end,
+ KuraDep =
+ case maps:get(kura, Flags) of
+ true -> [",\n kura"];
+ false -> []
+ end,
+ PgoDep =
+ case maps:get(pgo, Flags) of
+ true -> [",\n pgo"];
+ false -> []
+ end,
+ ArizonaDeps =
+ case maps:get(arizona, Flags) of
+ true -> [",\n arizona_core,\n arizona_nova"];
+ false -> []
+ end,
+ LfeDep =
+ case maps:get(lfe, Flags) of
+ true ->
+ [",\n {lfe, {git, \"http://github.com/rvirding/lfe\", {branch, \"develop\"}}}"];
+ false ->
+ []
+ end,
+ OtelDeps =
+ case maps:get(otel, Flags) of
+ true ->
+ [
+ ",\n opentelemetry,\n opentelemetry_api,\n opentelemetry_exporter,\n opentelemetry_nova"
+ ];
+ false ->
+ []
+ end,
+ ["{deps, [\n", BaseDeps, KuraDep, PgoDep, ArizonaDeps, LfeDep, OtelDeps, "\n]}.\n\n"].
+
+rebar_relx(Name, _Flags) ->
+ [
+ "{relx, [\n",
+ " {release, {",
+ Name,
+ ", git}, [\n",
+ " ",
+ Name,
+ ",\n",
+ " sasl\n",
+ " ]},\n",
+ " {mode, dev},\n",
+ " {sys_config_src, \"./config/dev_sys.config.src\"},\n",
+ " {vm_args_src, \"./config/vm.args.src\"}\n",
+ "]}.\n\n"
+ ].
+
+rebar_profiles(_Flags) ->
+ [
+ "{profiles, [\n",
+ " {prod, [\n",
+ " {relx, [\n",
+ " {mode, prod},\n",
+ " {sys_config_src, \"./config/prod_sys.config.src\"}\n",
+ " ]}\n",
+ " ]},\n",
+ " {test, [\n",
+ " {erl_opts, [nowarn_export_all, warnings_as_errors, {i, \"test/util\"}]},\n",
+ " {deps, [\n",
+ " {meck, \"1.1.0\"}\n",
+ " ]},\n",
+ " {ct_opts, [{sys_config, \"./config/dev_sys.config.src\"}]},\n",
+ " {extra_src_dirs, [{\"test\", [{recursive, true}]}]},\n",
+ " {xref_extra_paths, [\"test\"]}\n",
+ " ]}\n",
+ "]}.\n\n"
+ ].
+
+rebar_plugins(Flags) ->
+ ErlydtlPlugin =
+ case maps:get(arizona, Flags) of
+ true ->
+ [];
+ false ->
+ [
+ " {rebar3_erlydtl_plugin, \".*\",\n",
+ " {git, \"https://github.com/erlydtl/rebar3_erlydtl_plugin.git\", {branch, \"master\"}}},\n"
+ ]
+ end,
+ LfePlugin =
+ case maps:get(lfe, Flags) of
+ true ->
+ [
+ " {rebar3_lfe, {git, \"http://github.com/lfe-rebar3/rebar3_lfe\", {branch, \"release/0.4.x\"}}},\n"
+ ];
+ false ->
+ []
+ end,
+ [
+ "{project_plugins, [\n",
+ ErlydtlPlugin,
+ LfePlugin,
+ " {rebar3_nova, \".*\",\n",
+ " {git, \"https://github.com/novaframework/rebar3_nova.git\", {branch, \"master\"}}},\n",
+ " {erlfmt, \"~>1.7\"}\n",
+ "]}.\n\n"
+ ].
+
+rebar_erlfmt(_Flags) ->
+ [
+ "{erlfmt, [\n",
+ " write,\n",
+ " {files, [\n",
+ " \"rebar.config\",\n",
+ " \"src/*.app.src\",\n",
+ " \"src/**/{*.erl, *.hrl}\",\n",
+ " \"test/**/{*.erl, *.hrl}\"\n",
+ " ]},\n",
+ " {exclude_files, [\"config/vm.args.src\", \"config/prod_sys.config.src\"]}\n",
+ "]}.\n\n"
+ ].
+
+rebar_provider_hooks(#{lfe := true}) ->
+ [
+ "{provider_hooks, [\n",
+ " {pre, [{compile, {erlydtl, compile}},\n",
+ " {compile, {lfe, compile}}]}\n",
+ "]}.\n\n"
+ ];
+rebar_provider_hooks(#{arizona := true}) ->
+ [];
+rebar_provider_hooks(_) ->
+ [].
+
+rebar_xref() ->
+ [
+ "{xref_checks, [\n",
+ " undefined_function_calls,\n",
+ " undefined_functions,\n",
+ " locals_not_used,\n",
+ " deprecated_function_calls,\n",
+ " deprecated_functions\n",
+ "]}.\n"
+ ].
+
+%%======================================================================
+%% app.src
+%%======================================================================
+
+generate_app_src(Name, Flags) ->
+ Path = filename:join([Name, "src", Name ++ ".app.src"]),
+ Apps = app_src_applications(Flags),
+ Content = [
+ "%% -*- mode: erlang;erlang-indent-level: 4;indent-tabs-mode: nil -*-\n",
+ "{application, ",
+ Name,
+ ", [\n",
+ " {description, \"",
+ Name,
+ " managed by Nova\"},\n",
+ " {vsn, git},\n",
+ " {registered, []},\n",
+ " {mod, {",
+ Name,
+ "_app, []}},\n",
+ " {included_applications, []},\n",
+ " {applications, [\n",
+ " ",
+ Apps,
+ "\n",
+ " ]},\n",
+ " {env, []},\n",
+ " {modules, []},\n",
+ " {maintainers, []},\n",
+ " {licenses, [\"Apache 2.0\"]},\n",
+ " {links, []}\n",
+ "]}.\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+app_src_applications(Flags) ->
+ Base = ["kernel,\n stdlib,\n nova"],
+ Kura =
+ case maps:get(kura, Flags) of
+ true -> [",\n kura"];
+ false -> []
+ end,
+ Pgo =
+ case maps:get(pgo, Flags) of
+ true -> [",\n pgo"];
+ false -> []
+ end,
+ Arizona =
+ case maps:get(arizona, Flags) of
+ true -> [",\n arizona_core,\n arizona_nova"];
+ false -> []
+ end,
+ Otel =
+ case maps:get(otel, Flags) of
+ true -> [",\n opentelemetry,\n opentelemetry_api"];
+ false -> []
+ end,
+ [Base, Kura, Pgo, Arizona, Otel].
+
+%%======================================================================
+%% app.erl / app.lfe
+%%======================================================================
+
+generate_app(Name, #{lfe := true}) ->
+ Path = filename:join([Name, "src", Name ++ "_app.lfe"]),
+ Content = [
+ "(defmodule ",
+ Name,
+ "_app\n",
+ " (behaviour gen_server)\n",
+ " (export\n",
+ " ;; app implementation\n",
+ " (start 2)\n",
+ " (stop 0)))\n\n",
+ "(include-lib \"logjam/include/logjam.hrl\")\n\n",
+ "(defun start (_type _args)\n",
+ " (log-info \"Starting ",
+ Name,
+ " application ...\")\n",
+ " (",
+ Name,
+ ".sup:start_link))\n\n",
+ "(defun stop ()\n",
+ " (",
+ Name,
+ ".sup:stop)\n",
+ " 'ok)\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content);
+generate_app(Name, #{otel := true}) ->
+ Path = filename:join([Name, "src", Name ++ "_app.erl"]),
+ Content = [
+ "-module(",
+ Name,
+ "_app).\n\n",
+ "-behaviour(application).\n\n",
+ "-export([start/2, stop/1]).\n\n",
+ "start(_StartType, _StartArgs) ->\n",
+ " opentelemetry:setup(),\n",
+ " ",
+ Name,
+ "_sup:start_link().\n\n",
+ "stop(_State) ->\n",
+ " ok.\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content);
+generate_app(Name, _Flags) ->
+ Path = filename:join([Name, "src", Name ++ "_app.erl"]),
+ Content = [
+ "-module(",
+ Name,
+ "_app).\n\n",
+ "-behaviour(application).\n\n",
+ "-export([start/2, stop/1]).\n\n",
+ "start(_StartType, _StartArgs) ->\n",
+ " ",
+ Name,
+ "_sup:start_link().\n\n",
+ "stop(_State) ->\n",
+ " ok.\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+%%======================================================================
+%% sup.erl / sup.lfe
+%%======================================================================
+
+generate_sup(Name, #{lfe := true}) ->
+ Path = filename:join([Name, "src", Name ++ ".sup.lfe"]),
+ Content = [
+ "(defmodule ",
+ Name,
+ ".sup\n",
+ " (behaviour supervisor)\n",
+ " (export\n",
+ " (start_link 0)\n",
+ " (stop 0)\n",
+ " (init 1)))\n\n",
+ "(defun SERVER () (MODULE))\n",
+ "(defun supervisor-opts () '())\n",
+ "(defun sup-flags ()\n",
+ " `#M(strategy one_for_all\n",
+ " intensity 0\n",
+ " period 1))\n\n",
+ "(defun start_link ()\n",
+ " (supervisor:start_link `#(local ,(SERVER))\n",
+ " (MODULE)\n",
+ " (supervisor-opts)))\n\n",
+ "(defun stop ()\n",
+ " (gen_server:call (SERVER) 'stop))\n\n",
+ "(defun init (_args)\n",
+ " `#(ok #(,(sup-flags) ())))\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content);
+generate_sup(Name, #{kura := true}) ->
+ Path = filename:join([Name, "src", Name ++ "_sup.erl"]),
+ Content = [
+ "-module(",
+ Name,
+ "_sup).\n\n",
+ "-behaviour(supervisor).\n\n",
+ "-export([start_link/0]).\n",
+ "-export([init/1]).\n\n",
+ "-define(SERVER, ?MODULE).\n\n",
+ "start_link() ->\n",
+ " supervisor:start_link({local, ?SERVER}, ?MODULE, []).\n\n",
+ "init([]) ->\n",
+ " SupFlags = #{strategy => one_for_all},\n",
+ " ChildSpecs = [\n",
+ " #{id => ",
+ Name,
+ "_repo,\n",
+ " start => {",
+ Name,
+ "_repo, start_link, []},\n",
+ " type => worker}\n",
+ " ],\n",
+ " {ok, {SupFlags, ChildSpecs}}.\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content);
+generate_sup(Name, _Flags) ->
+ Path = filename:join([Name, "src", Name ++ "_sup.erl"]),
+ Content = [
+ "-module(",
+ Name,
+ "_sup).\n\n",
+ "-behaviour(supervisor).\n\n",
+ "-export([start_link/0]).\n",
+ "-export([init/1]).\n\n",
+ "-define(SERVER, ?MODULE).\n\n",
+ "start_link() ->\n",
+ " supervisor:start_link({local, ?SERVER}, ?MODULE, []).\n\n",
+ "init([]) ->\n",
+ " SupFlags = #{strategy => one_for_all},\n",
+ " ChildSpecs = [],\n",
+ " {ok, {SupFlags, ChildSpecs}}.\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+%%======================================================================
+%% router
+%%======================================================================
+
+generate_router(Name, #{lfe := true, arizona := true}) ->
+ Path = filename:join([Name, "src", Name ++ "_router.lfe"]),
+ Content = [
+ "(defmodule ",
+ Name,
+ "_router\n",
+ " (export\n",
+ " (routes 1)))\n\n",
+ "(defun routes\n",
+ " (_)\n",
+ " `(#M(prefix \"\"\n",
+ " security false\n",
+ " routes\n",
+ " (\n",
+ " #(\"/heartbeat\" ,(lambda (_) #(status 200)) #M(methods (get)))\n",
+ " #(\"/\" ,(lambda (params) (",
+ Name,
+ "_main_controller:index params))\n",
+ " #M(methods (get)))\n",
+ " ))\n",
+ " #M(prefix \"\"\n",
+ " security false\n",
+ " routes\n",
+ " (\n",
+ " #(\"/ws\" ,(lambda (params) (arizona_nova_adapter:handler params))\n",
+ " #M(protocol ws))\n",
+ " ))))\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content);
+generate_router(Name, #{lfe := true}) ->
+ Path = filename:join([Name, "src", Name ++ "_router.lfe"]),
+ Content = [
+ "(defmodule ",
+ Name,
+ "_router\n",
+ " (export\n",
+ " (routes 1)))\n\n",
+ "(defun routes\n",
+ " (_)\n",
+ " `(#M(prefix \"\"\n",
+ " security false\n",
+ " routes\n",
+ " (\n",
+ " #(\"/heartbeat\" ,(lambda (_) #(status 200)) #M(methods (get)))\n",
+ " #(\"/\" ,(lambda (params) (",
+ Name,
+ "_main_controller:index params))\n",
+ " #M(methods (get)))\n",
+ " ))))\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content);
+generate_router(Name, #{arizona := true}) ->
+ Path = filename:join([Name, "src", Name ++ "_router.erl"]),
+ Content = [
+ "-module(",
+ Name,
+ "_router).\n",
+ "-behaviour(nova_router).\n\n",
+ "-export([routes/1]).\n\n",
+ "routes(_Environment) ->\n",
+ " [\n",
+ " #{\n",
+ " prefix => \"\",\n",
+ " security => false,\n",
+ " routes => [\n",
+ " {\"/\", fun ",
+ Name,
+ "_main_controller:index/1, #{methods => [get]}},\n",
+ " {\"/heartbeat\", fun(_) -> {status, 200} end, #{methods => [get]}}\n",
+ " ]\n",
+ " },\n",
+ " #{\n",
+ " prefix => \"\",\n",
+ " security => false,\n",
+ " routes => [\n",
+ " {\"/ws\", fun arizona_nova_adapter:handler/1, #{protocol => ws}}\n",
+ " ]\n",
+ " }\n",
+ " ].\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content);
+generate_router(Name, _Flags) ->
+ Path = filename:join([Name, "src", Name ++ "_router.erl"]),
+ Content = [
+ "-module(",
+ Name,
+ "_router).\n",
+ "-behaviour(nova_router).\n\n",
+ "-export([routes/1]).\n\n",
+ "routes(_Environment) ->\n",
+ " [\n",
+ " #{\n",
+ " prefix => \"\",\n",
+ " security => false,\n",
+ " routes => [\n",
+ " {\"/\", fun ",
+ Name,
+ "_main_controller:index/1, #{methods => [get]}},\n",
+ " {\"/heartbeat\", fun(_) -> {status, 200} end, #{methods => [get]}}\n",
+ " ]\n",
+ " }\n",
+ " ].\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+%%======================================================================
+%% controller
+%%======================================================================
+
+generate_controller(Name, #{lfe := true}) ->
+ Path = filename:join([Name, "src", "controllers", Name ++ "_main_controller.lfe"]),
+ Content = [
+ "(defmodule ",
+ Name,
+ "_main_controller\n",
+ " (export\n",
+ " (index 1)))\n\n",
+ "(defun index\n",
+ " (_)\n",
+ " `#(status 200 #M() \"nova is running!\"))\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content);
+generate_controller(Name, _Flags) ->
+ Path = filename:join([Name, "src", "controllers", Name ++ "_main_controller.erl"]),
+ Content = [
+ "-module(",
+ Name,
+ "_main_controller).\n\n",
+ "-export([index/1]).\n\n",
+ "index(_Req) ->\n",
+ " {ok, [{message, \"Hello world!\"}]}.\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+%%======================================================================
+%% dev_sys.config.src
+%%======================================================================
+
+generate_dev_sys_config(Name, Flags) ->
+ Path = filename:join([Name, "config", "dev_sys.config.src"]),
+ Content = [
+ "%% -*- mode: erlang;erlang-indent-level: 4;indent-tabs-mode: nil -*-\n\n",
+ "[\n",
+ sys_config_kernel(dev, Flags),
+ sys_config_nova(Name, dev, Flags),
+ sys_config_pgo(Name, dev, Flags),
+ sys_config_arizona(Name, dev, Flags),
+ sys_config_otel(dev, Flags),
+ "].\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+%%======================================================================
+%% prod_sys.config.src
+%%======================================================================
+
+generate_prod_sys_config(Name, Flags) ->
+ Path = filename:join([Name, "config", "prod_sys.config.src"]),
+ Content = [
+ "%% -*- mode: erlang;erlang-indent-level: 4;indent-tabs-mode: nil -*-\n\n",
+ "[\n",
+ sys_config_kernel(prod, Flags),
+ sys_config_nova(Name, prod, Flags),
+ sys_config_pgo(Name, prod, Flags),
+ sys_config_arizona(Name, prod, Flags),
+ sys_config_otel(prod, Flags),
+ "].\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+%%----------------------------------------------------------------------
+%% sys.config section builders
+%%----------------------------------------------------------------------
+
+sys_config_kernel(dev, #{lfe := true}) ->
+ [
+ " {kernel, [\n",
+ " {logger, [\n",
+ " {handler, default, logger_std_h,\n",
+ " #{level => info,\n",
+ " formatter => {logjam,\n",
+ " #{colored => true,\n",
+ " time_designator => $\\s,\n",
+ " time_offset => \"\",\n",
+ " time_unit => second,\n",
+ " strip_tz => true,\n",
+ " level_capitalize => true\n",
+ " }\n",
+ " }\n",
+ " }\n",
+ " }\n",
+ " ]}\n",
+ " ]},\n"
+ ];
+sys_config_kernel(dev, _Flags) ->
+ [
+ " {kernel, [\n",
+ " {logger_level, debug},\n",
+ " #{formatter => {flatlog, #{\n",
+ " map_depth => 3,\n",
+ " term_depth => 50,\n",
+ " colored => true,\n",
+ " template => [colored_start, \"[\\033[1m\", level, \"\\033[0m\", colored_start,\"] [\", time, \"]\",\n",
+ " colored_end, \" \", msg, \" (\", mfa, \")\\n\"]\n",
+ " }}}\n",
+ " ]},\n"
+ ];
+sys_config_kernel(prod, _Flags) ->
+ [
+ " {kernel, [\n",
+ " {logger_level, info},\n",
+ " {logger,\n",
+ " [{handler, default, logger_std_h,\n",
+ " #{level => error,\n",
+ " config => #{file => \"log/erlang.log\"}}}\n",
+ " ]}\n",
+ " ]},\n"
+ ].
+
+sys_config_nova(Name, dev, _Flags) ->
+ [
+ " {nova, [\n",
+ " {use_stacktrace, true},\n",
+ " {environment, dev},\n",
+ " {cowboy_configuration, #{\n",
+ " port => 8080\n",
+ " }},\n",
+ " {dev_mode, true},\n",
+ " {bootstrap_application, ",
+ Name,
+ "},\n",
+ " {plugins, [\n",
+ " {pre_request, nova_request_plugin, #{decode_json_body => true}}\n",
+ " ]}\n",
+ " ]},\n"
+ ];
+sys_config_nova(Name, prod, _Flags) ->
+ [
+ " {nova, [\n",
+ " {use_stacktrace, false},\n",
+ " {environment, prod},\n",
+ " {cowboy_configuration, #{\n",
+ " port => 8080\n",
+ " }},\n",
+ " {dev_mode, false},\n",
+ " {bootstrap_application, ",
+ Name,
+ "},\n",
+ " {plugins, [\n",
+ " {pre_request, nova_request_plugin, #{decode_json_body => true}}\n",
+ " ]}\n",
+ " ]},\n"
+ ].
+
+sys_config_pgo(Name, dev, #{kura := true}) ->
+ [
+ " {pgo, [\n",
+ " {pools, [\n",
+ " {default, #{\n",
+ " pool_size => 10,\n",
+ " host => \"127.0.0.1\",\n",
+ " port => 5432,\n",
+ " database => \"",
+ Name,
+ "_dev\",\n",
+ " user => \"postgres\",\n",
+ " password => \"postgres\"\n",
+ " }}\n",
+ " ]}\n",
+ " ]},\n"
+ ];
+sys_config_pgo(Name, dev, #{pgo := true}) ->
+ [
+ " {pgo, [\n",
+ " {pools, [\n",
+ " {default, #{\n",
+ " pool_size => 10,\n",
+ " host => \"127.0.0.1\",\n",
+ " port => 5432,\n",
+ " database => \"",
+ Name,
+ "_dev\",\n",
+ " user => \"postgres\",\n",
+ " password => \"postgres\"\n",
+ " }}\n",
+ " ]}\n",
+ " ]},\n"
+ ];
+sys_config_pgo(Name, prod, #{kura := true}) ->
+ [
+ " {pgo, [\n",
+ " {pools, [\n",
+ " {default, #{\n",
+ " pool_size => ${PGO_POOL_SIZE},\n",
+ " host => \"${DATABASE_HOST}\",\n",
+ " port => ${DATABASE_PORT},\n",
+ " database => \"",
+ Name,
+ "\",\n",
+ " user => \"${DATABASE_USER}\",\n",
+ " password => \"${DATABASE_PASSWORD}\"\n",
+ " }}\n",
+ " ]}\n",
+ " ]},\n"
+ ];
+sys_config_pgo(Name, prod, #{pgo := true}) ->
+ [
+ " {pgo, [\n",
+ " {pools, [\n",
+ " {default, #{\n",
+ " pool_size => ${PGO_POOL_SIZE},\n",
+ " host => \"${DATABASE_HOST}\",\n",
+ " port => ${DATABASE_PORT},\n",
+ " database => \"",
+ Name,
+ "\",\n",
+ " user => \"${DATABASE_USER}\",\n",
+ " password => \"${DATABASE_PASSWORD}\"\n",
+ " }}\n",
+ " ]}\n",
+ " ]},\n"
+ ];
+sys_config_pgo(_Name, _Env, _Flags) ->
+ [].
+
+sys_config_arizona(Name, _Env, #{arizona := true}) ->
+ [
+ " {arizona_core, [\n",
+ " {otp_app, ",
+ Name,
+ "},\n",
+ " {endpoint, #{\n",
+ " live_reload => true\n",
+ " }}\n",
+ " ]},\n"
+ ];
+sys_config_arizona(_Name, _Env, _Flags) ->
+ [].
+
+sys_config_otel(dev, #{otel := true}) ->
+ [
+ " {opentelemetry, [\n",
+ " {span_processor, simple},\n",
+ " {traces_exporter, {otel_exporter_stdout, []}}\n",
+ " ]},\n"
+ ];
+sys_config_otel(prod, #{otel := true}) ->
+ [
+ " {opentelemetry, [\n",
+ " {span_processor, batch},\n",
+ " {traces_exporter, {opentelemetry_exporter, #{endpoints => [\"${OTEL_ENDPOINT}\"]}}}\n",
+ " ]},\n"
+ ];
+sys_config_otel(_Env, _Flags) ->
+ [].
+
+%%======================================================================
+%% vm.args.src
+%%======================================================================
+
+generate_vm_args(Name) ->
+ Path = filename:join([Name, "config", "vm.args.src"]),
+ Content = [
+ "-sname '",
+ Name,
+ "'\n\n",
+ "-setcookie ",
+ Name,
+ "_cookie\n\n",
+ "+K true\n",
+ "+A30\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+%%======================================================================
+%% .tool-versions
+%%======================================================================
+
+generate_tool_versions(Name) ->
+ Path = filename:join(Name, ".tool-versions"),
+ Content = [
+ "erlang 28.0.4\n",
+ "rebar 3.25.0\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+%%======================================================================
+%% .gitignore
+%%======================================================================
+
+generate_gitignore(Name) ->
+ Path = filename:join(Name, ".gitignore"),
+ Content = [
+ ".rebar3\n",
+ "_*\n",
+ ".eunit\n",
+ "*.o\n",
+ "*.beam\n",
+ "*.plt\n",
+ "*.swp\n",
+ "*.swo\n",
+ ".erlang.cookie\n",
+ "ebin\n",
+ "log\n",
+ "erl_crash.dump\n",
+ ".rebar\n",
+ "logs\n",
+ "_build\n",
+ ".idea\n",
+ "*.iml\n",
+ "rebar3.crashdump\n",
+ "*~\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+%%======================================================================
+%% favicon
+%%======================================================================
+
+copy_favicon(Name) ->
+ DestPath = filename:join([Name, "priv", "assets", "favicon.ico"]),
+ rebar3_nova_utils:copy_priv_file(
+ filename:join(["templates", "nova", "favicon.ico"]),
+ DestPath
+ ).
+
+%%======================================================================
+%% maybe_generate_view (ErlyDTL template, skip for arizona)
+%%======================================================================
+
+maybe_generate_view(_Name, #{arizona := true}) ->
+ ok;
+maybe_generate_view(Name, _Flags) ->
+ DestPath = filename:join([Name, "src", "views", Name ++ "_main.dtl"]),
+ rebar3_nova_utils:copy_priv_file(
+ filename:join(["templates", "nova", "controller.dtl"]),
+ DestPath
+ ).
+
+%%======================================================================
+%% maybe_generate_kura
+%%======================================================================
+
+maybe_generate_kura(Name, #{kura := true}) ->
+ generate_kura_repo(Name),
+ generate_docker_compose(Name);
+maybe_generate_kura(_Name, _Flags) ->
+ ok.
+
+generate_kura_repo(Name) ->
+ Path = filename:join([Name, "src", Name ++ "_repo.erl"]),
+ Content = [
+ "-module(",
+ Name,
+ "_repo).\n\n",
+ "-behaviour(kura_repo).\n\n",
+ "-export([otp_app/0]).\n\n",
+ "otp_app() -> ",
+ Name,
+ ".\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+%%======================================================================
+%% maybe_generate_pgo
+%%======================================================================
+
+maybe_generate_pgo(Name, #{pgo := true}) ->
+ generate_docker_compose(Name);
+maybe_generate_pgo(_Name, _Flags) ->
+ ok.
+
+%%======================================================================
+%% docker-compose.yml (shared by kura and pgo)
+%%======================================================================
+
+generate_docker_compose(Name) ->
+ Path = filename:join(Name, "docker-compose.yml"),
+ case filelib:is_regular(Path) of
+ true ->
+ ok;
+ false ->
+ Content = [
+ "services:\n",
+ " postgres:\n",
+ " image: postgres:17\n",
+ " environment:\n",
+ " POSTGRES_USER: postgres\n",
+ " POSTGRES_PASSWORD: postgres\n",
+ " POSTGRES_DB: ",
+ Name,
+ "_dev\n",
+ " ports:\n",
+ " - \"5432:5432\"\n",
+ " volumes:\n",
+ " - pgdata:/var/lib/postgresql/data\n\n",
+ "volumes:\n",
+ " pgdata:\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content)
+ end.
+
+%%======================================================================
+%% maybe_generate_arizona
+%%======================================================================
+
+maybe_generate_arizona(Name, #{arizona := true}) ->
+ generate_home_view(Name),
+ generate_app_css(Name);
+maybe_generate_arizona(_Name, _Flags) ->
+ ok.
+
+generate_home_view(Name) ->
+ Path = filename:join([Name, "src", "views", Name ++ "_home_view.erl"]),
+ Content = [
+ "-module(",
+ Name,
+ "_home_view).\n",
+ "-compile({parse_transform, arizona_parse_transform}).\n",
+ "-behaviour(arizona_view).\n\n",
+ "-export([mount/2, render/1]).\n\n",
+ "mount(_MountArg, _Request) ->\n",
+ " arizona_view:new(?MODULE, #{message => ~\"Hello from Arizona!\"}, none).\n\n",
+ "render(Bindings) ->\n",
+ " arizona_template:from_html(~\"\"\"\n",
+ " \n",
+ "
{arizona_template:get_binding(message, Bindings)}
\n",
+ " \n",
+ " \"\"\").\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+generate_app_css(Name) ->
+ Path = filename:join([Name, "priv", "assets", "app.css"]),
+ Content = [
+ "* {\n",
+ " margin: 0;\n",
+ " padding: 0;\n",
+ " box-sizing: border-box;\n",
+ "}\n\n",
+ "body {\n",
+ " font-family: system-ui, -apple-system, sans-serif;\n",
+ " background: #1b2735;\n",
+ " color: #f5f6fa;\n",
+ " display: flex;\n",
+ " justify-content: center;\n",
+ " align-items: center;\n",
+ " min-height: 100vh;\n",
+ "}\n\n",
+ "#app h1 {\n",
+ " font-size: 2rem;\n",
+ " font-weight: 300;\n",
+ " letter-spacing: 0.1em;\n",
+ "}\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content).
+
+%%======================================================================
+%% maybe_generate_ci
+%%======================================================================
+
+maybe_generate_ci(Name, #{ci := true}) ->
+ Path = filename:join([Name, ".github", "workflows", "ci.yml"]),
+ Content = [
+ "name: CI\n\n",
+ "on:\n",
+ " push:\n",
+ " branches: [main, master]\n",
+ " pull_request:\n",
+ " branches: [main, master]\n\n",
+ "permissions:\n",
+ " contents: read\n\n",
+ "jobs:\n",
+ " ci:\n",
+ " uses: Taure/erlang-ci/.github/workflows/erlang-ci.yml@v1\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content);
+maybe_generate_ci(_Name, _Flags) ->
+ ok.
+
+%%======================================================================
+%% maybe_generate_docker
+%%======================================================================
+
+maybe_generate_docker(Name, #{docker := true}) ->
+ Path = filename:join(Name, "Dockerfile"),
+ Content = [
+ "FROM erlang:28 AS builder\n\n",
+ "WORKDIR /app\n\n",
+ "COPY rebar.config rebar.lock ./\n",
+ "RUN rebar3 compile\n\n",
+ "COPY . .\n",
+ "RUN rebar3 as prod release\n\n",
+ "FROM debian:bookworm-slim\n\n",
+ "RUN apt-get update && apt-get install -y --no-install-recommends \\\n",
+ " libssl3 libncurses6 libstdc++6 && \\\n",
+ " rm -rf /var/lib/apt/lists/*\n\n",
+ "WORKDIR /app\n",
+ "COPY --from=builder /app/_build/prod/rel/",
+ Name,
+ " ./\n\n",
+ "EXPOSE 8080\n\n",
+ "CMD [\"bin/",
+ Name,
+ "\", \"foreground\"]\n"
+ ],
+ rebar3_nova_utils:write_file(Path, Content);
+maybe_generate_docker(_Name, _Flags) ->
+ ok.
+
+%%======================================================================
+%% Summary
+%%======================================================================
+
+print_summary(Name, Flags) ->
+ rebar_api:info("~n==> Project ~s created successfully!~n", [Name]),
+ rebar_api:info("Next steps:~n", []),
+ rebar_api:info(" cd ~s~n", [Name]),
+ NeedsDb = maps:get(kura, Flags) orelse maps:get(pgo, Flags),
+ case NeedsDb of
+ true ->
+ rebar_api:info(" docker compose up -d~n", []);
+ false ->
+ ok
+ end,
+ case maps:get(kura, Flags) of
+ true ->
+ rebar_api:info(" rebar3 kura setup~n", []);
+ false ->
+ ok
+ end,
+ rebar_api:info(" rebar3 nova serve~n", []).
diff --git a/src/rebar3_nova_utils.erl b/src/rebar3_nova_utils.erl
index fe15f08..03deff6 100644
--- a/src/rebar3_nova_utils.erl
+++ b/src/rebar3_nova_utils.erl
@@ -4,7 +4,9 @@
get_app_name/1,
get_app_dir/1,
ensure_dir/1,
+ write_file/2,
write_file_if_not_exists/2,
+ copy_priv_file/2,
parse_actions/1,
load_sys_config/1
]).
@@ -23,19 +25,32 @@ get_app_dir(State) ->
ensure_dir(Path) ->
filelib:ensure_dir(Path).
+-spec write_file(file:filename(), iodata()) -> ok.
+write_file(Path, Content) ->
+ ok = ensure_dir(Path),
+ ok = file:write_file(Path, Content),
+ log_info("Created ~s", [Path]),
+ ok.
+
-spec write_file_if_not_exists(file:filename(), iodata()) -> ok | skipped.
write_file_if_not_exists(Path, Content) ->
case filelib:is_regular(Path) of
true ->
- rebar_api:warn("File already exists, skipping: ~s", [Path]),
+ log_warn("File already exists, skipping: ~s", [Path]),
skipped;
false ->
- ok = ensure_dir(Path),
- ok = file:write_file(Path, Content),
- rebar_api:info("Created ~s", [Path]),
- ok
+ write_file(Path, Content)
end.
+-spec copy_priv_file(file:filename(), file:filename()) -> ok.
+copy_priv_file(PrivRelPath, DestPath) ->
+ PrivDir = code:priv_dir(rebar3_nova),
+ SrcPath = filename:join(PrivDir, PrivRelPath),
+ ok = ensure_dir(DestPath),
+ {ok, _} = file:copy(SrcPath, DestPath),
+ log_info("Created ~s", [DestPath]),
+ ok.
+
-spec parse_actions(string()) -> [atom()].
parse_actions(Str) ->
Tokens = string:tokens(Str, ","),
@@ -56,3 +71,21 @@ load_sys_config(State) ->
rebar_api:warn("Could not read config from ~s", [ConfigPath]),
[]
end.
+
+%%----------------------------------------------------------------------
+%% Internal: safe logging (rebar_api may not be available in tests)
+%%----------------------------------------------------------------------
+
+log_info(Fmt, Args) ->
+ try
+ rebar_api:info(Fmt, Args)
+ catch
+ error:undef -> io:format(Fmt ++ "~n", Args)
+ end.
+
+log_warn(Fmt, Args) ->
+ try
+ rebar_api:warn(Fmt, Args)
+ catch
+ error:undef -> io:format("Warning: " ++ Fmt ++ "~n", Args)
+ end.
diff --git a/test/rebar3_nova_gen_live_SUITE.erl b/test/rebar3_nova_gen_live_SUITE.erl
new file mode 100644
index 0000000..5a49871
--- /dev/null
+++ b/test/rebar3_nova_gen_live_SUITE.erl
@@ -0,0 +1,328 @@
+-module(rebar3_nova_gen_live_SUITE).
+
+-include_lib("common_test/include/ct.hrl").
+-include_lib("stdlib/include/assert.hrl").
+
+-export([all/0, init_per_testcase/2, end_per_testcase/2]).
+-export([
+ generates_all_views/1,
+ generates_schema/1,
+ generates_migration/1,
+ generates_test_suite/1,
+ index_only/1,
+ no_schema_flag/1,
+ field_type_mapping/1,
+ skips_existing_files/1,
+ views_call_kura_directly/1,
+ uses_correct_state_api/1
+]).
+
+all() ->
+ [
+ generates_all_views,
+ generates_schema,
+ generates_migration,
+ generates_test_suite,
+ index_only,
+ no_schema_flag,
+ field_type_mapping,
+ skips_existing_files,
+ views_call_kura_directly,
+ uses_correct_state_api
+ ].
+
+init_per_testcase(_TC, Config) ->
+ PrivDir = ?config(priv_dir, Config),
+ OldCwd = file:get_cwd(),
+ ok = file:set_cwd(PrivDir),
+ [{old_cwd, OldCwd} | Config].
+
+end_per_testcase(_TC, Config) ->
+ {ok, OldCwd} = ?config(old_cwd, Config),
+ ok = file:set_cwd(OldCwd),
+ Config.
+
+%%======================================================================
+%% Tests
+%%======================================================================
+
+generates_all_views(_Config) ->
+ AppName = myapp,
+ AppDir = "myapp",
+ ok = file:make_dir(AppDir),
+ Fields = [{"name", "string"}, {"email", "string"}, {"active", "boolean"}],
+ Actions = [index, show, new, edit],
+
+ rebar3_nova_gen_live:generate(AppName, AppDir, "users", Fields, Actions),
+
+ assert_file_exists(AppDir, "src/views/myapp_user_index_view.erl"),
+ assert_file_exists(AppDir, "src/views/myapp_user_show_view.erl"),
+ assert_file_exists(AppDir, "src/views/myapp_user_new_view.erl"),
+ assert_file_exists(AppDir, "src/views/myapp_user_edit_view.erl"),
+
+ IndexContent = read_file(AppDir, "src/views/myapp_user_index_view.erl"),
+ assert_contains(IndexContent, "-module(myapp_user_index_view)"),
+ assert_contains(IndexContent, "-behaviour(arizona_view)"),
+ assert_contains(IndexContent, "arizona_parse_transform"),
+ assert_contains(IndexContent, "kura_repo_worker:all(myapp_repo"),
+ assert_contains(IndexContent, "Name | "),
+ assert_contains(IndexContent, "Email | "),
+ assert_contains(IndexContent, "Active | "),
+ assert_contains(IndexContent, "handle_event"),
+ assert_contains(IndexContent, "render_list"),
+
+ ShowContent = read_file(AppDir, "src/views/myapp_user_show_view.erl"),
+ assert_contains(ShowContent, "-module(myapp_user_show_view)"),
+ assert_contains(ShowContent, "kura_repo_worker:get(myapp_repo, myapp_user, Id)"),
+ assert_contains(ShowContent, "Name"),
+
+ NewContent = read_file(AppDir, "src/views/myapp_user_new_view.erl"),
+ assert_contains(NewContent, "-module(myapp_user_new_view)"),
+ assert_contains(NewContent, "kura_changeset:cast(myapp_user"),
+ assert_contains(NewContent, "kura_repo_worker:insert(myapp_repo"),
+ assert_contains(NewContent, "