diff --git a/lib/tool_kit/config/layers.ex b/lib/tool_kit/config/layers.ex new file mode 100644 index 0000000..21559f3 --- /dev/null +++ b/lib/tool_kit/config/layers.ex @@ -0,0 +1,346 @@ +defmodule ToolKit.Config.Layers do + @moduledoc """ + 設定レイヤの読み込みとマージ(機構のみ)。 + + defaults ⊕ YAML ファイル ⊕ 環境変数 ⊕ CLI オーバーライドの 4 層を + この順の後勝ちでマージする。保持方式(struct / Agent / Application env)と + 設定スキーマはツール側の責務で、本モジュールは純関数だけを提供する。 + + ## 使い方 + + defaults = %{registry_repo: nil, cache: %{enabled: true, ttl_hours: 1}} + + env_spec = %{ + registry_repo: :string, + cache: %{enabled: :boolean, ttl_hours: :integer} + } + + {:ok, config} = + ToolKit.Config.Layers.resolve(defaults, + file: ToolKit.Config.Layers.default_config_path("mytool"), + env: {"MYTOOL", env_spec}, + cli: %{registry_repo: "cli/repo"} + ) + + ## 環境変数 spec + + キー(atom)→ 型の入れ子マップで宣言する。変数名は + `_<キー経路の大文字連結>`(例: `MYTOOL_CACHE_TTL_HOURS`)。 + `{型, "SUFFIX"}` で末端セグメント(キー名由来の部分)だけを差し替えられる + (例: `api: %{timeout_seconds: {:integer, "TIMEOUT"}}` → `MYTOOL_API_TIMEOUT`)。 + + 型は `:string` / `:integer` / `:boolean` / + `:string_list`(カンマ区切り・trim・空要素除去)。 + """ + + @typedoc "環境変数値の変換型" + @type env_type :: :string | :integer | :boolean | :string_list + + @typedoc "環境変数 spec(キー → 型 | {型, 派生名の差し替え} | 入れ子 spec)" + @type env_spec :: %{atom() => env_type() | {env_type(), String.t()} | map()} + + @typedoc "resolve/2 と load_file/1 のエラー理由" + @type error_reason :: {:parse_error, String.t()} | String.t() + + # [^/\s]+ がスラッシュを含まないため「/ をちょうど 1 つ含む」形式のみ許可 + # (owner/repo/extra のような 3 セグメント以上は不一致) + @owner_repo_regex ~r{\A[^/\s]+/[^/\s]+\z} + + @doc """ + defaults ⊕ ファイル ⊕ 環境変数 ⊕ CLI の 4 層を後勝ちでマージする。 + + ## オプション + + - `:file` — YAML 設定ファイルのパス。不存在は無視して defaults に + フォールバック、パース失敗は `{:error, {:parse_error, path}}`。 + 読み込んだ内容は defaults をテンプレートに `normalize_keys/2` で + 正規化される(defaults に無いキーは落ちる) + - `:env` — `{prefix, env_spec}`。変換失敗は `{:error, message}` + - `:cli` — CLI オーバーライドの map(atom キー) + + マージ規則は `merge/1` を参照(nil は上書きしない・入れ子 map は再帰マージ)。 + """ + @spec resolve(map(), keyword()) :: {:ok, map()} | {:error, error_reason()} + def resolve(defaults, opts \\ []) when is_map(defaults) do + with {:ok, file_layer} <- file_layer(Keyword.get(opts, :file), defaults), + {:ok, env_layer} <- env_layer(Keyword.get(opts, :env)) do + {:ok, merge([defaults, file_layer, env_layer, Keyword.get(opts, :cli, %{})])} + end + end + + defp file_layer(nil, _defaults), do: {:ok, %{}} + + defp file_layer(path, defaults) do + case load_file(path) do + {:ok, raw} -> {:ok, normalize_keys(raw, defaults)} + {:error, reason} -> {:error, reason} + end + end + + defp env_layer(nil), do: {:ok, %{}} + defp env_layer({prefix, spec}), do: read_env(prefix, spec) + + @doc """ + YAML 設定ファイルを読み込む。 + + ファイル不存在は `{:ok, %{}}`(defaults へのフォールバック)、 + パース失敗と mapping 以外の内容は `{:error, {:parse_error, path}}`。 + YAML 1.2 は JSON の上位互換なので旧 config.json もそのまま読める。 + """ + @spec load_file(String.t()) :: {:ok, map()} | {:error, {:parse_error, String.t()}} + def load_file(path) do + # 存在チェックせず直接読む(TOCTOU 回避)。不存在はエラー型で判別する + case YamlElixir.read_from_file(path) do + {:ok, config} when is_map(config) -> {:ok, config} + {:error, %YamlElixir.FileNotFoundError{}} -> {:ok, %{}} + _ -> {:error, {:parse_error, path}} + end + end + + @doc """ + テンプレート(通常は defaults)に沿って raw map のキーを atom に正規化する。 + + テンプレートにあるキーだけを拾い(string / atom どちらのキーでも可、 + 両方あれば atom が優先)、テンプレート側が map のキーは再帰する。 + テンプレートに無いキーは落とす(未知キーで atom を無制限に生成しない)。 + """ + @spec normalize_keys(map(), map()) :: map() + def normalize_keys(raw, template) when is_map(raw) and is_map(template) do + Enum.reduce(template, %{}, fn {key, template_value}, acc -> + case fetch_raw(raw, key) do + {:ok, value} -> Map.put(acc, key, normalize_value(value, template_value)) + :error -> acc + end + end) + end + + defp fetch_raw(raw, key) do + case Map.fetch(raw, key) do + {:ok, value} -> {:ok, value} + :error -> Map.fetch(raw, Atom.to_string(key)) + end + end + + defp normalize_value(value, template_value) when is_map(value) and is_map(template_value) do + normalize_keys(value, template_value) + end + + defp normalize_value(value, _template_value), do: value + + @doc """ + レイヤ(map のリスト)を先頭から順に後勝ちでマージする。 + + - 後のレイヤの nil 値は既存値を上書きしない(「未設定」を表す。 + キー自体が無ければ nil のまま入り、defaults の nil キーは保持される)。 + nil で既存値をリセットする手段は意図的に提供しない(無効化が必要な設定は + `false` や `0` など明示的な値で表現するのがツール側の規約) + - 両方が map の値は再帰マージ(env の 1 変数がファイルの入れ子設定を + 丸ごと潰さない) + - それ以外の値は置き換え + + ## Examples + + iex> ToolKit.Config.Layers.merge([%{a: 1, c: %{x: 1, y: 2}}, %{a: 2, c: %{y: 9}}]) + %{a: 2, c: %{x: 1, y: 9}} + + """ + @spec merge([map()]) :: map() + def merge(layers) do + Enum.reduce(layers, %{}, &merge_layer(&2, &1)) + end + + defp merge_layer(base, layer) do + Enum.reduce(layer, base, &merge_entry(&2, &1)) + end + + # nil は既存値を上書きしない(「未設定」)。キー自体が無ければ nil のまま入る + # (defaults 層の csv_path: nil などを保持するため) + defp merge_entry(acc, {key, nil}), do: Map.put_new(acc, key, nil) + + defp merge_entry(acc, {key, value}) do + Map.update(acc, key, value, &merge_value(&1, value)) + end + + defp merge_value(base, value) when is_map(base) and is_map(value) do + merge_layer(base, value) + end + + defp merge_value(_base, value), do: value + + @doc """ + 環境変数を spec に沿って読み込み、設定されたキーだけの map を返す。 + + 変数名の派生と型変換はモジュール doc の「環境変数 spec」を参照。 + 変換失敗(不正な integer / boolean)は `{:error, message}`。 + """ + @spec read_env(String.t(), env_spec()) :: {:ok, map()} | {:error, String.t()} + def read_env(prefix, spec) when is_binary(prefix) and is_map(spec) do + {:ok, read_env_map(prefix, spec)} + catch + :throw, {:invalid_env, message} -> {:error, message} + end + + defp read_env_map(prefix, spec) do + Enum.reduce(spec, %{}, fn {key, entry}, acc -> + put_env_entry(acc, key, entry, prefix) + end) + end + + # 入れ子 spec: プレフィックスにキー名を連ねて再帰。1 変数も無ければキーごと省く + defp put_env_entry(acc, key, nested, prefix) when is_map(nested) do + case read_env_map("#{prefix}_#{env_segment(key)}", nested) do + empty when empty == %{} -> acc + nested_map -> Map.put(acc, key, nested_map) + end + end + + defp put_env_entry(acc, key, {type, suffix}, prefix) do + put_env_value(acc, key, type, "#{prefix}_#{suffix}") + end + + defp put_env_entry(acc, key, type, prefix) when is_atom(type) do + put_env_value(acc, key, type, "#{prefix}_#{env_segment(key)}") + end + + defp env_segment(key), do: key |> Atom.to_string() |> String.upcase() + + defp put_env_value(acc, key, type, var) do + case System.get_env(var) do + nil -> acc + raw -> Map.put(acc, key, convert_env!(type, raw, var)) + end + end + + defp convert_env!(:string, raw, _var), do: raw + + defp convert_env!(:boolean, "true", _var), do: true + defp convert_env!(:boolean, "false", _var), do: false + + defp convert_env!(:boolean, raw, var) do + throw({:invalid_env, "invalid boolean for #{var}: #{raw} (expected true/false)"}) + end + + defp convert_env!(:integer, raw, var) do + case Integer.parse(raw) do + {value, ""} -> value + _ -> throw({:invalid_env, "invalid integer for #{var}: #{raw}"}) + end + end + + defp convert_env!(:string_list, raw, _var) do + raw |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) + end + + @doc """ + `owner/repo` 形式の owner 部を返す。形式外は nil。 + + ## Examples + + iex> ToolKit.Config.Layers.owner_from_repo("acme/registry-data") + "acme" + + iex> ToolKit.Config.Layers.owner_from_repo("acme") + nil + + """ + @spec owner_from_repo(String.t() | nil) :: String.t() | nil + def owner_from_repo(repo) when is_binary(repo) do + case String.split(repo, "/") do + [owner, _repo] when owner != "" -> owner + _ -> nil + end + end + + def owner_from_repo(_repo), do: nil + + @doc """ + github_org が未設定(nil / 空文字列)なら registry_repo の owner を導出する。 + + 明示設定が常に優先。repo も無ければ nil を返し、消費点での明示エラーは + ツール側の責務(他 org への静かな誤対象を防ぐため既定 org は持たない)。 + """ + @spec derive_github_org(String.t() | nil, String.t() | nil) :: String.t() | nil + def derive_github_org(org, repo) when org in [nil, ""], do: owner_from_repo(repo) + def derive_github_org(org, _repo), do: org + + @doc """ + 値が `owner/repo` 形式かどうかを返す。 + + ## Examples + + iex> ToolKit.Config.Layers.valid_owner_repo?("owner/repo") + true + + iex> ToolKit.Config.Layers.valid_owner_repo?("owner/repo/extra") + false + + """ + @spec valid_owner_repo?(String.t()) :: boolean() + def valid_owner_repo?(value) when is_binary(value) do + Regex.match?(@owner_repo_regex, value) + end + + @doc """ + 組織の名簿 CSV の規約パス `~/.config//students.csv` を返す(存在は見ない)。 + """ + @spec conventional_csv_path(String.t(), String.t()) :: String.t() + def conventional_csv_path(github_org, home \\ System.user_home!()) + when is_binary(github_org) and is_binary(home) do + Path.join([home, ".config", github_org, "students.csv"]) + end + + @doc """ + 規約パスの名簿 CSV が存在すればそのパスを、無ければ nil を返す。 + + org / home が使えない環境(未設定・HOME なし)では nil(規約導出をスキップ)。 + """ + @spec find_conventional_csv(String.t() | nil, String.t() | nil) :: String.t() | nil + def find_conventional_csv(github_org, home \\ System.user_home()) + + def find_conventional_csv(github_org, home) + when is_binary(github_org) and github_org != "" and is_binary(home) do + path = conventional_csv_path(github_org, home) + if File.exists?(path), do: path, else: nil + end + + def find_conventional_csv(_github_org, _home), do: nil + + @doc """ + パス先頭のチルダ(`~` / `~/...`)を home に展開する。それ以外はそのまま返す。 + + ## Examples + + iex> ToolKit.Config.Layers.expand_home("~/.cache/tool", "/home/x") + "/home/x/.cache/tool" + + iex> ToolKit.Config.Layers.expand_home("/abs/path", "/home/x") + "/abs/path" + + """ + @spec expand_home(String.t(), String.t() | nil) :: String.t() + def expand_home(path, home \\ System.user_home()) + def expand_home("~", home) when is_binary(home), do: home + def expand_home("~/" <> rest, home) when is_binary(home), do: Path.join(home, rest) + def expand_home(path, _home), do: path + + @doc """ + ツールの既定設定ファイルパス `~/.config//config.yml` を返す。 + """ + @spec default_config_path(String.t(), String.t()) :: String.t() + def default_config_path(tool_name, home \\ System.user_home!()) + when is_binary(tool_name) and is_binary(home) do + Path.join([home, ".config", tool_name, "config.yml"]) + end + + @doc """ + 候補リストから最初に存在するパスを返す(探索順の解決)。 + + nil の候補(未指定の CLI パスなど)はスキップする。どれも存在しなければ nil。 + + 典型例: `first_existing([cli_path, "./config/.yml", default_config_path(tool)])` + """ + @spec first_existing([String.t() | nil]) :: String.t() | nil + def first_existing(candidates) when is_list(candidates) do + Enum.find(candidates, fn path -> is_binary(path) and File.exists?(path) end) + end +end diff --git a/test/tool_kit/config/layers_env_test.exs b/test/tool_kit/config/layers_env_test.exs new file mode 100644 index 0000000..c4ccbc3 --- /dev/null +++ b/test/tool_kit/config/layers_env_test.exs @@ -0,0 +1,189 @@ +defmodule ToolKit.Config.LayersEnvTest do + # System.put_env を使うため async: false + use ExUnit.Case, async: false + + alias ToolKit.Config.Layers + + @prefix "TK_LAYERS_TEST" + # 新しい環境変数を使うテストを追加したら、必ずここにも追加すること + # (setup / on_exit のクリーンアップ対象は本リストで管理している) + @env_vars [ + "#{@prefix}_CSV_PATH", + "#{@prefix}_GITHUB_ORG", + "#{@prefix}_REGISTRY_REPO", + "#{@prefix}_TEST_STUDENT_IDS", + "#{@prefix}_LOG_LEVEL", + "#{@prefix}_CACHE_ENABLED", + "#{@prefix}_CACHE_TTL_HOURS", + "#{@prefix}_API_TIMEOUT" + ] + + setup do + Enum.each(@env_vars, &System.delete_env/1) + on_exit(fn -> Enum.each(@env_vars, &System.delete_env/1) end) + :ok + end + + @env_spec %{ + csv_path: :string, + github_org: :string, + registry_repo: :string, + test_student_ids: :string_list, + log_level: :string, + cache: %{enabled: :boolean, ttl_hours: :integer}, + api: %{timeout_seconds: {:integer, "TIMEOUT"}} + } + + describe "read_env/2" do + test "reads and converts each declared type" do + System.put_env("#{@prefix}_CSV_PATH", "/custom/path.csv") + System.put_env("#{@prefix}_GITHUB_ORG", "custom_org") + System.put_env("#{@prefix}_TEST_STUDENT_IDS", "k99rs001, k99rs002") + System.put_env("#{@prefix}_CACHE_ENABLED", "false") + System.put_env("#{@prefix}_CACHE_TTL_HOURS", "2") + System.put_env("#{@prefix}_API_TIMEOUT", "30") + System.put_env("#{@prefix}_LOG_LEVEL", "debug") + + assert {:ok, config} = Layers.read_env(@prefix, @env_spec) + assert config.csv_path == "/custom/path.csv" + assert config.github_org == "custom_org" + assert config.test_student_ids == ["k99rs001", "k99rs002"] + assert config.cache.enabled == false + assert config.cache.ttl_hours == 2 + assert config.api.timeout_seconds == 30 + assert config.log_level == "debug" + end + + test "returns an empty map when no environment variables are set" do + assert Layers.read_env(@prefix, @env_spec) == {:ok, %{}} + end + + test "omits nested keys when none of their variables are set" do + System.put_env("#{@prefix}_GITHUB_ORG", "solo") + + assert Layers.read_env(@prefix, @env_spec) == {:ok, %{github_org: "solo"}} + end + + test "a string_list value is split on commas, trimmed, and blanks removed" do + System.put_env("#{@prefix}_TEST_STUDENT_IDS", " k99rs001 ,, k99rs002 ,") + + assert {:ok, %{test_student_ids: ["k99rs001", "k99rs002"]}} = + Layers.read_env(@prefix, @env_spec) + end + + test "boolean accepts only true/false" do + System.put_env("#{@prefix}_CACHE_ENABLED", "invalid") + + assert {:error, message} = Layers.read_env(@prefix, @env_spec) + assert message =~ "#{@prefix}_CACHE_ENABLED" + end + + test "integer rejects non-integer values" do + System.put_env("#{@prefix}_CACHE_TTL_HOURS", "2h") + + assert {:error, message} = Layers.read_env(@prefix, @env_spec) + assert message =~ "#{@prefix}_CACHE_TTL_HOURS" + end + + test "a custom name overrides the derived leaf segment" do + System.put_env("#{@prefix}_API_TIMEOUT", "45") + + assert {:ok, %{api: %{timeout_seconds: 45}}} = Layers.read_env(@prefix, @env_spec) + end + end + + describe "resolve/2" do + @defaults %{ + csv_path: nil, + github_org: nil, + registry_repo: nil, + log_level: "info", + cache: %{enabled: true, ttl_hours: 1, max_size_mb: 50}, + api: %{timeout_seconds: 15, max_concurrent: 8} + } + + @tag :tmp_dir + test "merges defaults < file < env < cli", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "config.yml") + + File.write!(path, """ + csv_path: /file/path.csv + registry_repo: file/repo + log_level: warn + """) + + System.put_env("#{@prefix}_REGISTRY_REPO", "env/repo") + + assert {:ok, config} = + Layers.resolve(@defaults, + file: path, + env: {@prefix, @env_spec}, + cli: %{registry_repo: "cli/repo"} + ) + + # CLI > env > file + assert config.registry_repo == "cli/repo" + # file > defaults + assert config.csv_path == "/file/path.csv" + assert config.log_level == "warn" + # defaults のみ + assert config.api.timeout_seconds == 15 + end + + @tag :tmp_dir + test "a single nested env var does not clobber file cache settings", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "config.yml") + + File.write!(path, """ + cache: + enabled: false + max_size_mb: 99 + """) + + System.put_env("#{@prefix}_CACHE_TTL_HOURS", "5") + + assert {:ok, config} = + Layers.resolve(@defaults, file: path, env: {@prefix, @env_spec}) + + assert config.cache.enabled == false + assert config.cache.max_size_mb == 99 + assert config.cache.ttl_hours == 5 + end + + @tag :tmp_dir + test "file keys unknown to the defaults are dropped", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "config.yml") + File.write!(path, "bogus_key: 1\nlog_level: debug\n") + + assert {:ok, config} = Layers.resolve(@defaults, file: path) + assert config.log_level == "debug" + refute Map.has_key?(config, :bogus_key) + refute Map.has_key?(config, "bogus_key") + end + + @tag :tmp_dir + test "a missing file falls back to defaults", %{tmp_dir: tmp_dir} do + assert {:ok, config} = Layers.resolve(@defaults, file: Path.join(tmp_dir, "missing.yml")) + assert config == @defaults + end + + test "file: nil skips the file layer" do + assert Layers.resolve(@defaults) == {:ok, @defaults} + end + + @tag :tmp_dir + test "a parse failure is returned as an error", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "broken.yml") + File.write!(path, "key: [unclosed") + + assert {:error, {:parse_error, ^path}} = Layers.resolve(@defaults, file: path) + end + + test "an invalid env value is returned as an error" do + System.put_env("#{@prefix}_CACHE_ENABLED", "invalid") + + assert {:error, message} = Layers.resolve(@defaults, env: {@prefix, @env_spec}) + assert message =~ "#{@prefix}_CACHE_ENABLED" + end + end +end diff --git a/test/tool_kit/config/layers_test.exs b/test/tool_kit/config/layers_test.exs new file mode 100644 index 0000000..2b7068e --- /dev/null +++ b/test/tool_kit/config/layers_test.exs @@ -0,0 +1,259 @@ +defmodule ToolKit.Config.LayersTest do + use ExUnit.Case, async: true + doctest ToolKit.Config.Layers + + alias ToolKit.Config.Layers + + describe "load_file/1" do + @tag :tmp_dir + test "parses a YAML config file", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "config.yml") + + File.write!(path, """ + # comment line + github_org: yamlorg + registry_repo: yamlorg/thesis-student-registry + cache: + enabled: false + """) + + assert {:ok, config} = Layers.load_file(path) + assert config["github_org"] == "yamlorg" + assert config["registry_repo"] == "yamlorg/thesis-student-registry" + assert config["cache"]["enabled"] == false + end + + @tag :tmp_dir + test "parses legacy JSON content (YAML 1.2 superset)", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "config.json") + File.write!(path, ~s({"github_org": "jsonorg", "cache": {"enabled": false}})) + + assert {:ok, config} = Layers.load_file(path) + assert config["github_org"] == "jsonorg" + assert config["cache"]["enabled"] == false + end + + @tag :tmp_dir + test "returns an empty map when the file does not exist", %{tmp_dir: tmp_dir} do + assert Layers.load_file(Path.join(tmp_dir, "missing.yml")) == {:ok, %{}} + end + + @tag :tmp_dir + test "returns an error when the file cannot be parsed", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "broken.yml") + File.write!(path, "key: [unclosed") + + assert {:error, {:parse_error, ^path}} = Layers.load_file(path) + end + + @tag :tmp_dir + test "returns an error when the file is not a mapping", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "scalar.yml") + File.write!(path, "just a string") + + assert {:error, {:parse_error, ^path}} = Layers.load_file(path) + end + end + + describe "normalize_keys/2" do + @template %{csv_path: nil, log_level: "info", cache: %{enabled: true, ttl_hours: 1}} + + test "converts string keys known to the template into atoms" do + raw = %{"csv_path" => "/a.csv", "log_level" => "debug"} + + assert Layers.normalize_keys(raw, @template) == + %{csv_path: "/a.csv", log_level: "debug"} + end + + test "keeps atom keys as-is" do + assert Layers.normalize_keys(%{log_level: "warn"}, @template) == %{log_level: "warn"} + end + + test "drops keys unknown to the template" do + assert Layers.normalize_keys(%{"unknown" => 1, "log_level" => "debug"}, @template) == + %{log_level: "debug"} + end + + test "recurses into nested maps present in the template" do + raw = %{"cache" => %{"enabled" => false, "bogus" => 9}} + + assert Layers.normalize_keys(raw, @template) == %{cache: %{enabled: false}} + end + + test "an atom key wins when both atom and string keys are present" do + raw = %{:log_level => "warn", "log_level" => "debug"} + + assert Layers.normalize_keys(raw, @template) == %{log_level: "warn"} + end + end + + describe "merge/1" do + test "later layers win" do + assert Layers.merge([%{a: 1}, %{a: 2}, %{a: 3}]) == %{a: 3} + end + + test "keys absent from later layers keep earlier values" do + assert Layers.merge([%{a: 1, b: 2}, %{b: 3}]) == %{a: 1, b: 3} + end + + test "nil values in later layers do not override" do + assert Layers.merge([%{a: 1}, %{a: nil}]) == %{a: 1} + end + + test "nested maps merge instead of clobbering" do + defaults = %{cache: %{enabled: true, ttl_hours: 1, max_size_mb: 50}} + file = %{cache: %{enabled: false, max_size_mb: 99}} + env = %{cache: %{ttl_hours: 5}} + + assert Layers.merge([defaults, file, env]) == + %{cache: %{enabled: false, ttl_hours: 5, max_size_mb: 99}} + end + + test "a non-map value replaces a map value" do + assert Layers.merge([%{a: %{b: 1}}, %{a: "flat"}]) == %{a: "flat"} + end + end + + describe "owner_from_repo/1" do + test "extracts the owner from owner/repo" do + assert Layers.owner_from_repo("acme/registry-data") == "acme" + end + + test "returns nil for shapes without exactly one slash" do + assert Layers.owner_from_repo("acme") == nil + assert Layers.owner_from_repo("a/b/c") == nil + assert Layers.owner_from_repo("/repo") == nil + assert Layers.owner_from_repo(nil) == nil + end + end + + describe "derive_github_org/2" do + test "derives the org from the repo owner when unset" do + assert Layers.derive_github_org(nil, "acme/registry-data") == "acme" + assert Layers.derive_github_org("", "acme/registry-data") == "acme" + end + + test "an explicit org wins over the derived owner" do + assert Layers.derive_github_org("explicit", "acme/registry-data") == "explicit" + end + + test "returns nil when neither org nor repo is set" do + assert Layers.derive_github_org(nil, nil) == nil + end + end + + describe "valid_owner_repo?/1" do + test "accepts owner/repo and rejects other shapes" do + assert Layers.valid_owner_repo?("owner/repo") + refute Layers.valid_owner_repo?("owner") + refute Layers.valid_owner_repo?("owner/repo/extra") + refute Layers.valid_owner_repo?("owner /repo") + refute Layers.valid_owner_repo?("/repo") + refute Layers.valid_owner_repo?("owner/") + end + end + + describe "conventional_csv_path/2" do + test "derives the roster path from the org" do + assert Layers.conventional_csv_path("myorg", "/home/x") == + "/home/x/.config/myorg/students.csv" + end + end + + describe "find_conventional_csv/2" do + @tag :tmp_dir + test "returns the conventional path when the file exists", %{tmp_dir: home} do + path = Path.join([home, ".config", "testorg", "students.csv"]) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, "header\n") + + assert Layers.find_conventional_csv("testorg", home) == path + end + + @tag :tmp_dir + test "returns nil when the file does not exist", %{tmp_dir: home} do + assert Layers.find_conventional_csv("testorg", home) == nil + end + + @tag :tmp_dir + test "returns nil when the org is nil or empty", %{tmp_dir: home} do + assert Layers.find_conventional_csv(nil, home) == nil + assert Layers.find_conventional_csv("", home) == nil + end + + test "returns nil when the home directory is unavailable" do + assert Layers.find_conventional_csv("testorg", nil) == nil + end + end + + describe "expand_home/2" do + test "expands a bare tilde and tilde-prefixed paths" do + assert Layers.expand_home("~", "/home/x") == "/home/x" + assert Layers.expand_home("~/.cache/tool", "/home/x") == "/home/x/.cache/tool" + end + + test "leaves other paths untouched" do + assert Layers.expand_home("/abs/path", "/home/x") == "/abs/path" + assert Layers.expand_home("relative/path", "/home/x") == "relative/path" + assert Layers.expand_home("~user/path", "/home/x") == "~user/path" + end + + test "leaves tilde paths untouched when home is unavailable" do + assert Layers.expand_home("~/.cache/tool", nil) == "~/.cache/tool" + end + end + + describe "default_config_path/2" do + test "returns ~/.config//config.yml" do + assert Layers.default_config_path("thesis-monitor", "/home/x") == + "/home/x/.config/thesis-monitor/config.yml" + end + end + + describe "default home arguments" do + test "conventional_csv_path/1 uses the real home directory" do + assert Layers.conventional_csv_path("myorg") == + Path.join([System.user_home!(), ".config", "myorg", "students.csv"]) + end + + test "default_config_path/1 uses the real home directory" do + assert Layers.default_config_path("mytool") == + Path.join([System.user_home!(), ".config", "mytool", "config.yml"]) + end + + test "expand_home/1 expands against the real home directory" do + assert Layers.expand_home("~/x") == Path.join(System.user_home!(), "x") + end + + test "find_conventional_csv/1 returns nil for a nonexistent org" do + assert Layers.find_conventional_csv("no-such-org-#{System.unique_integer([:positive])}") == + nil + end + end + + describe "first_existing/1" do + @tag :tmp_dir + test "returns the first existing candidate", %{tmp_dir: tmp_dir} do + exists_late = Path.join(tmp_dir, "late.yml") + exists_early = Path.join(tmp_dir, "early.yml") + File.write!(exists_late, "a: 1\n") + File.write!(exists_early, "a: 1\n") + missing = Path.join(tmp_dir, "missing.yml") + + assert Layers.first_existing([missing, exists_early, exists_late]) == exists_early + end + + @tag :tmp_dir + test "skips nil candidates (unset CLI path)", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "config.yml") + File.write!(path, "a: 1\n") + + assert Layers.first_existing([nil, path]) == path + end + + @tag :tmp_dir + test "returns nil when no candidate exists", %{tmp_dir: tmp_dir} do + assert Layers.first_existing([nil, Path.join(tmp_dir, "missing.yml")]) == nil + end + end +end