Skip to content

Commit 3bdd325

Browse files
committed
feat(config): 首次运行时自动初始化用户配置文件至 ~/.coding-proxy/config.yaml;
新增 _ensure_user_config() 函数,在 load_config() 入口处透明调用, 当无用户配置时自动从 config.default.yaml 拷贝到 ~/.coding-proxy/ 目录, 降低用户上手门槛。幂等、非破坏性、优雅降级(OSError 时回退内嵌默认值)。 附带 9 个单元测试覆盖核心逻辑与集成场景,934/934 全量测试通过。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang<threefish.ai@gmail.com>
1 parent b54b567 commit 3bdd325

2 files changed

Lines changed: 203 additions & 0 deletions

File tree

src/coding/proxy/config/loader.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,42 @@ def _get_default_config_path() -> Path | None:
117117
return None
118118

119119

120+
def _ensure_user_config() -> Path | None:
121+
"""确保 ~/.coding-proxy/config.yaml 存在(不存在则从 default 复制).
122+
123+
首次运行时自动将 config.default.yaml 拷贝到用户目录,
124+
作为用户可编辑的配置基础。幂等、非破坏性、优雅降级。
125+
126+
Returns:
127+
创建/已存在的配置文件路径,失败时返回 None。
128+
"""
129+
import shutil
130+
131+
_home_config = Path("~/.coding-proxy/config.yaml").expanduser()
132+
_cwd_config = Path("config.yaml")
133+
134+
# 已有配置 → 直接返回(不覆盖)
135+
if _cwd_config.exists():
136+
return _cwd_config
137+
if _home_config.exists():
138+
return _home_config
139+
140+
# 无配置 → 从 default 复制
141+
default_path = _get_default_config_path()
142+
if default_path is None:
143+
logger.warning("无法定位 config.default.yaml,跳过用户配置初始化。")
144+
return None
145+
146+
try:
147+
_home_config.parent.mkdir(parents=True, exist_ok=True)
148+
shutil.copy2(default_path, _home_config)
149+
logger.info("已初始化用户配置文件: %s", _home_config)
150+
return _home_config
151+
except OSError as exc:
152+
logger.warning("无法创建用户配置文件 %s: %s", _home_config, exc)
153+
return None
154+
155+
120156
def _log_merge_diagnostics(defaults: dict, user_raw: dict, merged: dict) -> None:
121157
"""记录合并诊断信息,帮助排查配置缺失问题."""
122158
critical_fields = {
@@ -147,6 +183,11 @@ def load_config(path: Path | None = None) -> ProxyConfig:
147183
148184
环境变量展开(${VAR})在深度合并之后执行,确保用户可通过环境变量覆盖任意字段。
149185
"""
186+
# ── 第 0 步:首次运行自动初始化用户配置文件 ─────────────
187+
# 仅在未指定显式路径时触发(用户通过 -c 显式指定时不干预)
188+
if path is None:
189+
_ensure_user_config()
190+
150191
# ── 第 1 步:确定并加载用户配置 ─────────────────────────────
151192
user_raw: dict = {}
152193
if path is None:

tests/test_config_init.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""配置自动初始化单元测试."""
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from coding.proxy.config.loader import _ensure_user_config, load_config
8+
9+
10+
# ── A 组:_ensure_user_config 核心逻辑 ───────────────────────────
11+
12+
13+
class TestEnsureUserConfig:
14+
"""_ensure_user_config 幂等性与安全性测试."""
15+
16+
def test_creates_config_when_none_exists(self, tmp_path: Path, monkeypatch):
17+
"""无任何用户配置时,自动从 default 复制到 ~/.coding-proxy/config.yaml."""
18+
home_dir = tmp_path / "home"
19+
home_dir.mkdir()
20+
monkeypatch.setenv("HOME", str(home_dir))
21+
22+
result = _ensure_user_config()
23+
24+
assert result is not None
25+
expected_path = home_dir / ".coding-proxy" / "config.yaml"
26+
assert result == expected_path
27+
assert expected_path.exists()
28+
# 验证内容非空(确实是从 default 复制的)
29+
content = expected_path.read_text()
30+
assert "server:" in content
31+
assert "vendors:" in content
32+
33+
def test_returns_cwd_config_if_exists(self, tmp_path: Path, monkeypatch):
34+
"""CWD 下已有 config.yaml 时直接返回,不创建新文件."""
35+
cwd_cfg = tmp_path / "config.yaml"
36+
cwd_cfg.write_text("server:\n port: 7777\n")
37+
monkeypatch.chdir(tmp_path)
38+
39+
home_dir = tmp_path / "home"
40+
home_dir.mkdir()
41+
monkeypatch.setenv("HOME", str(home_dir))
42+
43+
result = _ensure_user_config()
44+
45+
# 返回的是相对路径 "config.yaml",解析后应与 cwd_cfg 一致
46+
assert result.resolve() == cwd_cfg.resolve()
47+
# 不应在 home 下创建
48+
assert not (home_dir / ".coding-proxy" / "config.yaml").exists()
49+
50+
def test_returns_home_config_if_exists(self, tmp_path: Path, monkeypatch):
51+
"""~/.coding-proxy/config.yaml 已存在时直接返回,不覆盖."""
52+
home_dir = tmp_path / "home"
53+
cp_dir = home_dir / ".coding-proxy"
54+
cp_dir.mkdir(parents=True)
55+
existing = cp_dir / "config.yaml"
56+
existing.write_text("server:\n port: 8888\n")
57+
monkeypatch.setenv("HOME", str(home_dir))
58+
empty_dir = tmp_path / "empty"
59+
empty_dir.mkdir()
60+
monkeypatch.chdir(empty_dir)
61+
62+
result = _ensure_user_config()
63+
64+
assert result == existing
65+
# 不应覆盖现有内容
66+
assert existing.read_text() == "server:\n port: 8888\n"
67+
68+
def test_idempotent_multiple_calls(self, tmp_path: Path, monkeypatch):
69+
"""多次调用不产生副作用(幂等性)."""
70+
home_dir = tmp_path / "home"
71+
home_dir.mkdir()
72+
monkeypatch.setenv("HOME", str(home_dir))
73+
monkeypatch.chdir(tmp_path)
74+
75+
first = _ensure_user_config()
76+
second = _ensure_user_config()
77+
78+
assert first == second
79+
assert first.exists()
80+
81+
def test_creates_parent_directory(self, tmp_path: Path, monkeypatch):
82+
"""~/.coding-proxy/ 目录不存在时自动创建."""
83+
home_dir = tmp_path / "home"
84+
home_dir.mkdir() # 只创建 home,不创建 .coding-proxy
85+
monkeypatch.setenv("HOME", str(home_dir))
86+
monkeypatch.chdir(tmp_path)
87+
88+
result = _ensure_user_config()
89+
90+
assert result is not None
91+
assert result.parent.exists()
92+
assert result.parent.is_dir()
93+
94+
def test_graceful_when_default_missing(self, tmp_path: Path, monkeypatch):
95+
"""config.default.yaml 缺失时返回 None,不崩溃."""
96+
home_dir = tmp_path / "home"
97+
home_dir.mkdir()
98+
monkeypatch.setenv("HOME", str(home_dir))
99+
monkeypatch.chdir(tmp_path)
100+
101+
import coding.proxy.config.loader as loader_module
102+
103+
original = loader_module._get_default_config_path
104+
monkeypatch.setattr(loader_module, "_get_default_config_path", lambda: None)
105+
106+
result = _ensure_user_config()
107+
108+
assert result is None
109+
# 不应创建任何文件
110+
assert not (home_dir / ".coding-proxy").exists()
111+
112+
113+
# ── B 组:与 load_config 的集成测试 ─────────────────────────────
114+
115+
116+
class TestAutoInitIntegration:
117+
"""验证 _ensure_user_config 与 load_config 的集成行为."""
118+
119+
def test_load_config_auto_creates_home_config(self, tmp_path: Path, monkeypatch):
120+
"""无配置时 load_config 自动创建 ~/.coding-proxy/config.yaml."""
121+
home_dir = tmp_path / "home"
122+
home_dir.mkdir()
123+
monkeypatch.setenv("HOME", str(home_dir))
124+
empty_dir = tmp_path / "empty"
125+
empty_dir.mkdir()
126+
monkeypatch.chdir(empty_dir)
127+
128+
cfg = load_config()
129+
130+
assert cfg.server.port == 8046
131+
created = home_dir / ".coding-proxy" / "config.yaml"
132+
assert created.exists()
133+
134+
def test_load_config_with_explicit_path_no_auto_init(self, tmp_path: Path):
135+
"""指定 -c 路径时不会触发自动初始化."""
136+
home_dir = tmp_path / "home"
137+
home_dir.mkdir()
138+
139+
cfg_file = tmp_path / "my-config.yaml"
140+
cfg_file.write_text("server:\n port: 9999\n")
141+
142+
cfg = load_config(cfg_file)
143+
144+
assert cfg.server.port == 9999
145+
assert not (home_dir / ".coding-proxy").exists()
146+
147+
def test_load_config_cwd_priority_over_auto_init(
148+
self, tmp_path: Path, monkeypatch
149+
):
150+
"""CWD 有 config.yaml 时优先使用,不触发 home 初始化."""
151+
cwd_cfg = tmp_path / "config.yaml"
152+
cwd_cfg.write_text("server:\n port: 7777\n")
153+
monkeypatch.chdir(tmp_path)
154+
155+
home_dir = tmp_path / "home"
156+
home_dir.mkdir()
157+
monkeypatch.setenv("HOME", str(home_dir))
158+
159+
cfg = load_config()
160+
161+
assert cfg.server.port == 7777
162+
assert not (home_dir / ".coding-proxy" / "config.yaml").exists()

0 commit comments

Comments
 (0)