feat: add layered config loading (ToolKit.Config.Layers) - #15
Conversation
Extract the config loading/merging mechanism from registry-manager's
Config as pure functions, designed as a superset usable by both
registry-manager and thesis-monitor:
- 4-layer merge: defaults, YAML file, env vars, CLI overrides
(later layers win; nil never overrides; nested maps merge)
- YAML loading via yaml_elixir: missing file falls back to defaults,
parse failure returns {:error, {:parse_error, path}}
- Env var reading with per-tool prefix and typed conversion
(string / integer / boolean / string_list), nested specs, and
leaf-segment name overrides (e.g. PREFIX_API_TIMEOUT)
- Convention helpers: owner_from_repo, derive_github_org,
valid_owner_repo?, conventional_csv_path, find_conventional_csv,
expand_home, default_config_path, first_existing
Retention (struct / Agent / Application env) and the config schema
stay tool-side responsibilities.
Resolves #4
Claude-Session: https://claude.ai/code/session_015FdaeD1BANgCMUcNTUjKVV
| """ | ||
| @spec load_file(String.t()) :: {:ok, map()} | {:error, {:parse_error, String.t()}} | ||
| def load_file(path) do | ||
| if File.exists?(path) do |
There was a problem hiding this comment.
load_file/1 のTOCTOU競合状態
File.exists?(path) でチェックしてから YamlElixir.read_from_file(path) で読み込むまでの間にファイルが削除される可能性があります(Time-of-check to time-of-use)。
以下のように直接読み込んでエラーをハンドリングする方が安全です:
def load_file(path) do
case YamlElixir.read_from_file(path) do
{:ok, config} when is_map(config) -> {:ok, config}
{:ok, _} -> {:error, {:parse_error, path}}
{:error, %YamlElixir.FileNotFoundError{}} -> {:ok, %{}}
{:error, _} -> {:error, {:parse_error, path}}
end
endYamlElixir が返すエラー型を確認した上で適切にパターンマッチしてください。
There was a problem hiding this comment.
commit 35ec43d で修正しました。File.exists? を廃止して直接読み、不存在は {:error, %YamlElixir.FileNotFoundError{}} のパターンマッチで判別するようにしました(deps の yaml_elixir 実装でエラー型を確認済み)。
| """ | ||
| @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)} |
There was a problem hiding this comment.
ℹ️ [LOW] read_env/2 での throw の使用
throw / catch は Elixir では非慣用的なフロー制御です。Enum.reduce_while/3 や Enum.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 系の関数シグネチャも変更する必要があるため、影響範囲が広い点に注意してください。
There was a problem hiding this comment.
据え置きます。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 パターンです(挙動互換の維持も兼ねています)。
|
|
||
| # nil は既存値を上書きしない(「未設定」)。キー自体が無ければ nil のまま入る | ||
| # (defaults 層の csv_path: nil などを保持するため) | ||
| defp merge_entry(acc, {key, nil}), do: Map.put_new(acc, key, nil) |
There was a problem hiding this comment.
ℹ️ [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 で既存値をリセットする手段は提供しない」と明記することを推奨します。将来の利用者が混乱する可能性があります。
There was a problem hiding this comment.
commit 35ec43d で merge/1 の doc に「nil で既存値をリセットする手段は意図的に提供しない(無効化は false / 0 など明示的な値で表現するのがツール側の規約)」と明記しました。
| "#{@prefix}_API_TIMEOUT" | ||
| ] | ||
|
|
||
| setup do |
There was a problem hiding this comment.
ℹ️ [LOW] setup での環境変数クリーンアップの堅牢性
setup ブロックで on_exit を登録していますが、@env_vars に列挙されていない環境変数がテスト中に設定された場合(将来のテスト追加時など)はクリーンアップされません。
現状は @env_vars リストが完全に管理されているため問題ありませんが、テストを追加する際に @env_vars の更新を忘れやすいという保守上のリスクがあります。テストごとに使用する変数を明示するか、コメントで注意を促すことを検討してください。
There was a problem hiding this comment.
commit 35ec43d で @env_vars の直前に「新しい環境変数を使うテストを追加したら必ずここにも追加する」旨の保守コメントを追加しました。
| end) | ||
| end | ||
|
|
||
| defp fetch_raw(raw, key) do |
There was a problem hiding this comment.
✨ [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
endkey は常に atom(テンプレートのキー)なので、まず atom キーで検索し、なければ string キーで検索するという実装は正しいです。ただし、%{"a" => 1, a: 2} のような混在マップでは atom が優先されます。この挙動はテスト("an atom key wins when both atom and string keys are present")でカバーされており問題ありませんが、ドキュメントの「atom が優先」という記述と一致しています。POSITIVE評価です。
There was a problem hiding this comment.
確認ありがとうございます。ご指摘のとおり doc・実装・テスト(atom 優先)の三者は一致しています。
- load_file/1: read the file directly and match on YamlElixir.FileNotFoundError instead of File.exists?, avoiding the TOCTOU window between check and read - merge/1 doc: state explicitly that nil cannot reset an existing value (disabling must use explicit values like false/0) - env test: note that @env_vars must be extended together with new env-using tests Claude-Session: https://claude.ai/code/session_015FdaeD1BANgCMUcNTUjKVV
| """ | ||
| @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)} |
There was a problem hiding this comment.
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)
endThere was a problem hiding this comment.
据え置きます(同趣旨の指摘 #15 (comment) への返信と同じ根拠です)。throw はモジュール内 private に閉じ、公開 API read_env/2 の境界で {:error, message} に変換されます。入れ子 spec の再帰全段への {:ok,}/{:error,} スレッディングは複雑化に見合わず、抽出元 registry-manager の実装とも挙動互換を保っています。
| 組織の名簿 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!()) |
There was a problem hiding this comment.
conventional_csv_path/2 と default_config_path/2 はデフォルト引数に System.user_home!/0 を使っており、HOME 環境変数が未設定の場合に関数定義時ではなく呼び出し時に例外が発生します。一方 find_conventional_csv/2 と expand_home/2 は System.user_home/0(nil を返す)を使っており、挙動が非対称です。conventional_csv_path/1 と default_config_path/1 も nil を返す設計に統一するか、ドキュメントで例外が発生しうることを明記することを推奨します。
There was a problem hiding this comment.
据え置きます。この非対称は意図的な設計です: 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)の分担をそのまま踏襲しています。
|
|
||
| defp file_layer(nil, _defaults), do: {:ok, %{}} | ||
|
|
||
| defp file_layer(path, defaults) do |
There was a problem hiding this comment.
ℹ️ [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
endThere was a problem hiding this comment.
据え置きます。提案の単一節 with は本リポジトリの credo --strict で有効な Credo.Check.Readability.WithSingleClause に抵触するため採用できません(case での明示分岐が credo green を保つ書き方です)。
| case YamlElixir.read_from_file(path) do | ||
| {:ok, config} when is_map(config) -> {:ok, config} | ||
| {:error, %YamlElixir.FileNotFoundError{}} -> {:ok, %{}} | ||
| _ -> {:error, {:parse_error, path}} |
There was a problem hiding this comment.
ℹ️ [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}}
endThere was a problem hiding this comment.
据え置きます。提案の {:ok, _non_map} と {:error, _} はどちらも同一の {:error, {:parse_error, path}} を返すため、分けても挙動は変わりません。将来 YamlElixir が新しいエラー型を追加した場合も「mapping として読めなかった」ことに変わりはなく、parse_error への縮退が正しい扱いです。
| alias ToolKit.Config.Layers | ||
|
|
||
| @prefix "TK_LAYERS_TEST" | ||
| # 新しい環境変数を使うテストを追加したら、必ずここにも追加すること |
There was a problem hiding this comment.
ℹ️ [LOW] テストファイル冒頭のコメント「新しい環境変数を使うテストを追加したら、必ずここにも追加すること」は、手動管理が必要なリストへの依存を示しています。setup 内で @env_spec から動的に変数名を導出してクリーンアップする方法を検討すると、追加漏れのリスクを排除できます。
There was a problem hiding this comment.
据え置きます。@env_spec には末端セグメント差し替え({:integer, "TIMEOUT"})が含まれ、spec から変数名を動的導出するにはテスト対象そのものの派生ロジックを再実装することになり、名前導出のバグをテスト側が隠してしまいます。クリーンアップ対象は実装から独立した明示リストで管理し、保守コメントで追加漏れを防ぐ方針です。
AI レビュー対応まとめ指摘 10 件(MEDIUM 3 / LOW 6 / POSITIVE 1)に対応しました。 修正(commit 35ec43d)
据え置き(各スレッドに根拠を返信)
最新 commit 35ec43d で Elixir CI / AI Code Review とも success です。 |
概要
registry-manager の
Configから設定の読み込み・マージの機構を純関数として抽出し、ToolKit.Config.Layersを追加します。thesis-monitor のConfigも参照し、両ツール(rm#74 / tm#50)が採用できる上位集合として設計しています。保持方式(struct / Agent / Application env)と設定スキーマはツール側の責務のため持ち込んでいません。Resolves #4
親 epic: smkwlab/latex-ecosystem#146
変更内容
resolve/2— defaults ⊕ YAML ファイル ⊕ 環境変数 ⊕ CLI オーバーライドの 4 層を後勝ちでマージ{:error, {:parse_error, path}}normalize_keys/2で正規化(string キー → atom、未知キーは落とす。未知キーからの atom 生成なし)merge/1— nil は既存値を上書きしない(未設定扱い)、入れ子 map は再帰マージ(env の 1 変数がファイルの入れ子設定を潰さない = rm issue #38 の挙動)read_env/2— ツール prefix + 型付き spec(string / integer / boolean / string_list)。入れ子 spec と末端セグメントの派生名差し替え(REGISTRY_MANAGER_API_TIMEOUT型の変数名)に対応。変換失敗は{:error, message}owner_from_repo/1、derive_github_org/2(org 未設定時に repo owner を導出、rm#45 / tm#28 の規約)、valid_owner_repo?/1、conventional_csv_path/2、find_conventional_csv/2(存在チェック込み、rm#16 の規約)、expand_home/2、default_config_path/2first_existing/1— 設定ファイル探索順の解決(tm の「--config → ./config/.yml → ~/.config//config.yml」パターン)確認済み
mix format --check-formatted✔mix credo --strict0 issues ✔mix dialyzerpassed ✔mix test --cover16 doctests + 121 tests, 0 failures / Total 96.63%(Layers 98.55%)✔@tag :tmp_dirで YAML 読み込みを検証。System.put_envを使うテストは async: falsehttps://claude.ai/code/session_015FdaeD1BANgCMUcNTUjKVV