Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
346 changes: 346 additions & 0 deletions lib/tool_kit/config/layers.ex
Original file line number Diff line number Diff line change
@@ -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)→ 型の入れ子マップで宣言する。変数名は
`<prefix>_<キー経路の大文字連結>`(例: `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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ [LOW] file_layer/2 の実装は {:error, reason} -> {:error, reason} というパターンマッチが恒等変換になっています。load_file/1 の結果をそのまま with に渡せるため、以下のように簡略化できます。

defp file_layer(path, defaults) do
  with {:ok, raw} <- load_file(path) do
    {:ok, normalize_keys(raw, defaults)}
  end
end

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

据え置きます。提案の単一節 with は本リポジトリの credo --strict で有効な Credo.Check.Readability.WithSingleClause に抵触するため採用できません(case での明示分岐が credo green を保つ書き方です)。

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}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ [LOW] load_file/1_ ワイルドカード節は、YamlElixir が将来新しいエラー型を追加した場合に、パース失敗と区別できない形で捕捉してしまいます。少なくとも {:error, _}{:ok, _} (非マップ) を明示的に分けることを検討してください。

case YamlElixir.read_from_file(path) do
  {:ok, config} when is_map(config) -> {:ok, config}
  {:ok, _non_map} -> {:error, {:parse_error, path}}
  {:error, %YamlElixir.FileNotFoundError{}} -> {:ok, %{}}
  {:error, _} -> {:error, {:parse_error, path}}
end

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

据え置きます。提案の {:ok, _non_map} と {:error, _} はどちらも同一の {:error, {:parse_error, path}} を返すため、分けても挙動は変わりません。将来 YamlElixir が新しいエラー型を追加した場合も「mapping として読めなかった」ことに変わりはなく、parse_error への縮退が正しい扱いです。

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] fetch_raw/2 でのアトム優先の挙動がドキュメントと実装で一致しているか確認

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

key は常に atom(テンプレートのキー)なので、まず atom キーで検索し、なければ string キーで検索するという実装は正しいです。ただし、%{"a" => 1, a: 2} のような混在マップでは atom が優先されます。この挙動はテスト("an atom key wins when both atom and string keys are present")でカバーされており問題ありませんが、ドキュメントの「atom が優先」という記述と一致しています。POSITIVE評価です。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

確認ありがとうございます。ご指摘のとおり doc・実装・テスト(atom 優先)の三者は一致しています。

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ [LOW] merge_entry/2 における nil の扱いの非対称性

defp merge_entry(acc, {key, nil}), do: Map.put_new(acc, key, nil)

この実装では「nil は既存値を上書きしない」という仕様ですが、defaults 層で %{a: 1} を設定した後に明示的に nil でリセットしたい場合(例:CLIで設定を無効化したい)に手段がありません。

現在の設計判断として意図的であれば、モジュールドキュメントに「nil で既存値をリセットする手段は提供しない」と明記することを推奨します。将来の利用者が混乱する可能性があります。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commit 35ec43d で merge/1 の doc に「nil で既存値をリセットする手段は意図的に提供しない(無効化は false / 0 など明示的な値で表現するのがツール側の規約)」と明記しました。


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)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ [LOW] read_env/2 での throw の使用

throw / catch は Elixir では非慣用的なフロー制御です。Enum.reduce_while/3Enum.reduce + with を使った明示的なエラー伝播の方が、Elixir のイディオムに沿っており可読性も高くなります。

例:

defp read_env_map(prefix, spec) do
  Enum.reduce_while(spec, {:ok, %{}}, fn {key, entry}, {:ok, acc} ->
    case put_env_entry(acc, key, entry, prefix) do
      {:ok, new_acc} -> {:cont, {:ok, new_acc}}
      {:error, _} = err -> {:halt, err}
    end
  end)
end

ただし、この変更は put_env_entry 系の関数シグネチャも変更する必要があるため、影響範囲が広い点に注意してください。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

据え置きます。throw は本モジュール内の private 関数群に閉じており、公開 API の read_env/2 の境界で必ず {:error, message} に変換されます(呼び出し側に漏れません)。入れ子 spec の再帰(read_env_map ⇄ put_env_entry)全段に {:ok, _}/{:error, _} を通す reduce_while 化はシグネチャの複雑化に対して得るものが小さく、抽出元の registry-manager Config.load_env_config も同じ throw/catch パターンです(挙動互換の維持も兼ねています)。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [MEDIUM] エラー伝播に throw/catch を使用していますが、Elixir では with や再帰的な Enum.reduce_while を使った明示的なエラー伝播が慣用的です。throw はスタックを跨ぐ制御フローとして機能しますが、Elixir コードベースでは例外的な用途であり、可読性・保守性の観点から Enum.reduce_while を使って {:error, reason} を明示的に伝播させる方が望ましいです。

defp read_env_map(prefix, spec) do
  Enum.reduce_while(spec, {:ok, %{}}, fn {key, entry}, {:ok, acc} ->
    case put_env_entry(acc, key, entry, prefix) do
      {:ok, new_acc} -> {:cont, {:ok, new_acc}}
      {:error, _} = err -> {:halt, err}
    end
  end)
end

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

据え置きます(同趣旨の指摘 #15 (comment) への返信と同じ根拠です)。throw はモジュール内 private に閉じ、公開 API read_env/2 の境界で {:error, message} に変換されます。入れ子 spec の再帰全段への {:ok,}/{:error,} スレッディングは複雑化に見合わず、抽出元 registry-manager の実装とも挙動互換を保っています。

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/<org>/students.csv` を返す(存在は見ない)。
"""
@spec conventional_csv_path(String.t(), String.t()) :: String.t()
def conventional_csv_path(github_org, home \\ System.user_home!())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [MEDIUM] conventional_csv_path/2default_config_path/2 はデフォルト引数に System.user_home!/0 を使っており、HOME 環境変数が未設定の場合に関数定義時ではなく呼び出し時に例外が発生します。一方 find_conventional_csv/2expand_home/2System.user_home/0(nil を返す)を使っており、挙動が非対称です。conventional_csv_path/1default_config_path/1 も nil を返す設計に統一するか、ドキュメントで例外が発生しうることを明記することを推奨します。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

据え置きます。この非対称は意図的な設計です: conventional_csv_path/2・default_config_path/2 は「必ずパスを返す constructor」なので HOME 不在時は fail-fast(user_home!)にし、nil を返して離れた場所の Path.join で落ちるより原因に近い位置で失敗させます。一方 find_conventional_csv/2・expand_home/2 は「規約解決の resolver」で、HOME 不在時は規約導出をスキップ(nil / 素通し)するのが正しい挙動です。この分担は抽出元 registry-manager の conventional_csv_path(user_home!)と apply_csv_convention(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/<tool>/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/<tool>.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
Loading
Loading