# 配置层实施方案 - mark2pdf.config.toml

## 1. 目标

基于前期调研，为 mark2pdf 项目引入配置层，实现：
1. 代码与数据目录解耦
2. 支持任意目录初始化工作区
3. 支持本地模板定制

验证目录：`../pdfdata`（与本项目平行）

---

## 2. mark2pdf.config.toml 配置文件设计

### 2.1 文件位置
- 工作区根目录下：`<DataRoot>/mark2pdf.config.toml`
- 此文件同时作为"工作区标识文件"

### 2.2 配置项 Schema

```toml
# mark2pdf.config.toml

[project]
name = "My Project"              # 可选，项目名称

[paths]
in = "in"                        # 输入目录，默认 "in"
out = "out"                      # 输出目录，默认 "out"
tmp = "tmp"                      # 临时目录，默认 "tmp"
template = "template"            # 本地模板目录，默认 "template"

[build]
default_template = "nb.typ"      # 默认模板名称
cover = true                     # 默认启用封面
toc_depth = 3                    # 目录深度

[frontmatter]
# 默认 frontmatter 注入
edition = "My Edition"
watermark = ""
```

---

## 3. ConfigManager 模块设计

### 3.1 模块位置
新建 `src/config_manager/` 目录。

### 3.2 文件结构

```
src/config_manager/
├── __init__.py
├── config.py          # 配置数据类定义
├── manager.py         # ConfigManager 实现
└── defaults.py        # 默认值常量
```

### 3.3 核心类设计

#### `config.py` - 配置数据类

```python
from dataclasses import dataclass, field
from pathlib import Path

@dataclass
class PathsConfig:
    input: str = "in"
    output: str = "out"
    tmp: str = "tmp"
    template: str = "template"

@dataclass
class BuildConfig:
    default_template: str = "nb.typ"
    cover: bool = True
    toc_depth: int = 3

@dataclass
class Mark2pdfConfig:
    project_name: str = ""
    paths: PathsConfig = field(default_factory=PathsConfig)
    build: BuildConfig = field(default_factory=BuildConfig)
    frontmatter: dict = field(default_factory=dict)
    
    # 运行时计算
    data_root: Path = None
    code_root: Path = None
```

#### `manager.py` - ConfigManager

```python
class ConfigManager:
    """配置管理器，负责加载和解析配置"""
    
    CONFIG_FILENAME = "mark2pdf.config.toml"
    
    @classmethod
    def load(cls, workdir: Path | None = None) -> Mark2pdfConfig:
        """
        加载配置
        优先级: workdir参数 > MARK2PDF_DATA_DIR > CWD检测 > 默认
        """
        data_root = cls._resolve_data_root(workdir)
        config = cls._load_toml(data_root)
        config.data_root = data_root
        config.code_root = get_project_root()
        return config
    
    @classmethod
    def _resolve_data_root(cls, workdir) -> Path:
        # 1. 显式传入
        if workdir:
            return Path(workdir).resolve()
        
        # 2. 环境变量
        env = os.getenv("MARK2PDF_DATA_DIR")
        if env:
            return Path(env).resolve()
        
        # 3. CWD 检测
        cwd = Path.cwd()
        if (cwd / cls.CONFIG_FILENAME).exists():
            return cwd
        
        # 4. Legacy
        return get_project_root() / "_working"
```

---

## 4. 代码修改计划

### 4.1 helper_workingpath 改造

| 函数 | 改造内容 |
|------|----------|
| `get_project_root()` | 保持不变 |
| `create_working_dirs()` | 改用 ConfigManager 读取路径 |
| `resolve_inout_paths()` | 改用 ConfigManager 获取路径 |
| `resolve_template_path()` | 实现双层查找（本地优先） |

#### `resolve_template_path` 改造示例

```python
def resolve_template_path(template_name: str, config: Mark2pdfConfig = None) -> str:
    if config is None:
        config = ConfigManager.load()
    
    # 1. 本地模板目录
    local_path = config.data_root / config.paths.template / template_name
    if local_path.exists():
        return str(local_path)
    
    # 2. 系统模板目录
    system_path = config.code_root / "template" / template_name
    if system_path.exists():
        return str(system_path)
    
    raise FileNotFoundError(f"找不到模板: {template_name}")
```

### 4.2 markdown2pdf 改造

`core.py` 和 `cli.py` 需要：
1. 默认参数改为 `None`，运行时通过 `ConfigManager` 获取
2. 新增 `--workdir` 参数支持

---

## 5. init 功能设计

### 5.1 命令入口

新增 CLI 命令：`mark2pdf init`

### 5.2 初始化逻辑

```python
def init_workspace(target_dir: Path = None, copy_templates: bool = False):
    """
    初始化工作区
    
    Args:
        target_dir: 目标目录，默认为 CWD
        copy_templates: 是否复制标准模板到本地
    """
    target = target_dir or Path.cwd()
    
    # 创建目录结构
    dirs = ["in", "out", "tmp", "template"]
    for d in dirs:
        (target / d).mkdir(exist_ok=True)
    
    # 生成默认配置文件
    config_path = target / "mark2pdf.config.toml"
    if not config_path.exists():
        write_default_config(config_path)
    
    # 可选：复制标准模板
    if copy_templates:
        copy_system_templates(target / "template")
```

### 5.3 生成的目录结构

```
pdfdata/                    <- 新工作区
├── mark2pdf.config.toml     <- 配置文件（标识）
├── in/                     <- 输入
├── out/                    <- 输出
├── tmp/                    <- 临时
└── template/               <- 本地模板（可选）
```

---

## 6. 验证计划

### 6.1 单元测试

运行现有测试，确保兼容性：
```bash
uv run pytest src/helper_workingpath/tests/
```

### 6.2 集成测试（在 ../pdfdata 验证）

1. **初始化测试**
   ```bash
   cd ../pdfdata
   uv run mark2pdf init
   ```
   预期：创建目录结构和配置文件

2. **转换测试**
   ```bash
   # 在 pdfdata/in 放入测试 md 文件
   cd ../pdfdata
   uv run md2pdf test.md
   ```
   预期：输出到 `pdfdata/out/`

3. **本地模板测试**
   - 复制 `nb.typ` 到 `pdfdata/template/`
   - 修改样式
   - 运行转换，验证使用本地模板

---

## 7. 实施步骤

| 阶段 | 内容 | 预计工作量 |
|------|------|------------|
| Phase 1 | 新建 `src/config_manager/`，实现 `ConfigManager` | 中 |
| Phase 2 | 改造 `helper_workingpath` 使用配置层 | 中 |
| Phase 3 | 改造 `markdown2pdf` CLI | 小 |
| Phase 4 | 实现 `mark2pdf init` 命令 | 小 |
| Phase 5 | 在 `../pdfdata` 验证 | 小 |

---

## 8. 潜在问题与风险分析 (坑)

### 8.1 向后兼容风险

| 问题 | 影响 | 应对策略 |
|------|------|----------|
| 默认行为变化 | 现有脚本可能因路径解析逻辑变化而失败 | 确保 Legacy 回退路径生效，未配置时完全等同旧行为 |
| API 签名变化 | `resolve_inout_paths(indir, outdir)` 默认值的变化 | 保持函数签名不变，内部通过 ConfigManager 处理 |

### 8.2 路径解析的坑

1. **绝对路径 vs 相对路径混淆**
   - 当前 `resolve_inout_paths` 假设 `indir` 是相对于 `project_root` 的
   - 改造后需区分：配置文件中路径相对于 `data_root`，而非 `code_root`
   - **风险点**: 如果 `data_root = /Users/x/pdfdata`，`paths.in = "in"`，最终应得 `/Users/x/pdfdata/in`

2. **`resolve_template_path` 双层查找的坑**
   - 问题：如果本地模板存在但有语法错误，用户可能不知道加载了哪个版本
   - **建议**: 在 verbose 模式下打印实际使用的模板路径

3. **`convert_directory` 中的 CWD 依赖**
   - 当前代码 (core.py:326-327):
     ```python
     if not dir_path.is_absolute():
         dir_path = Path.cwd() / dir_path
     ```
   - **风险**: 引入 ConfigManager 后，应使用 `config.data_root` 而非 `Path.cwd()`

### 8.3 配置加载的坑

1. **TOML 解析依赖**
   - Python 3.10 及以下版本没有内置 `tomllib`
   - **解决**: 使用 `tomli` 或 `toml` 包作为回退

2. **配置文件缺失字段**
   - 用户可能只配置部分字段
   - **必须**: 使用 `dataclass` 默认值兜底，或使用 `dict.get()` 安全访问

3. **配置优先级冲突**
   - CLI 参数、环境变量、配置文件三者可能冲突
   - **建议**: 明确优先级 `CLI > ENV > 配置文件 > 默认值`

### 8.4 init 命令的坑

1. **非空目录初始化**
   - 用户可能在已有项目目录执行 init
   - **建议**: 检查目录是否为空，非空时发出 warning（而非阻止）

2. **配置文件覆盖**
   - 如果配置文件已存在，init 不应覆盖
   - 当前设计已处理 (`if not config_path.exists()`)

### 8.5 现有测试覆盖分析

当前测试文件: `src/helper_workingpath/tests/test_working_path_helper.py`

| 已覆盖 | 未覆盖 (需新增) |
|--------|----------------|
| `get_project_root` | `get_data_root` (新函数) |
| `create_working_dirs` | ConfigManager 集成 |
| `resolve_inout_paths` | 双层模板查找 |
| `safesave_path` | CWD 检测逻辑 |
| `create_root_identifier` | TOML 解析 |

---

## 9. 单元测试计划

### 9.1 新增测试文件

```
src/config_manager/tests/
├── __init__.py
├── test_config.py         # 配置数据类测试
├── test_manager.py        # ConfigManager 测试
└── fixtures/              # 测试用配置文件
    ├── valid_config.toml
    └── partial_config.toml
```

### 9.2 测试用例设计

#### `test_manager.py`

```python
class TestConfigManager:
    """ConfigManager 测试"""
    
    def test_load_from_cwd(self, tmp_path):
        """CWD 检测配置文件"""
        # 创建配置文件
        # 模拟 CWD
        # 验证加载结果
    
    def test_load_from_env(self, tmp_path, monkeypatch):
        """环境变量优先于 CWD"""
        # 设置 MARK2PDF_DATA_DIR
        # 验证使用环境变量路径
    
    def test_load_legacy_fallback(self, monkeypatch):
        """无配置时回退到 _working"""
        # 不设置任何配置
        # 验证返回 project_root/_working
    
    def test_missing_fields_use_defaults(self, tmp_path):
        """配置文件缺少字段时使用默认值"""
        # 创建只有 [project] 的配置
        # 验证 paths 使用默认值
    
    def test_invalid_toml(self, tmp_path):
        """无效 TOML 抛出异常"""
        # 创建损坏的 toml
        # 验证抛出合适的错误
```

#### `test_resolve_template_path_v2.py` (升级现有测试)

```python
def test_local_template_priority(tmp_path, monkeypatch):
    """本地模板优先于系统模板"""
    # 创建 data_root/template/nb.typ
    # 创建 code_root/template/nb.typ (不同内容)
    # 验证返回 data_root 版本

def test_fallback_to_system_template(tmp_path, monkeypatch):
    """本地不存在时回退系统模板"""
    # 只在 code_root 创建模板
    # 验证返回 code_root 版本

def test_template_not_found_error(tmp_path, monkeypatch):
    """两处都不存在时报错"""
    # 都不创建
    # 验证抛出 FileNotFoundError
```

### 9.3 运行测试命令

```bash
# 运行所有 config_manager 测试
uv run pytest src/config_manager/tests/ -v

# 运行 helper_workingpath 测试 (含新增)
uv run pytest src/helper_workingpath/tests/ -v

# 运行全部测试
uv run pytest src/ -v
```

### 9.4 集成测试 (手动验证)

在 `../pdfdata` 中执行：

1. **初始化测试**
   ```bash
   mkdir -p ../pdfdata && cd ../pdfdata
   uv run --directory /Users/fangjun/python/mark2pdf mark2pdf init
   ls -la  # 预期: in/ out/ tmp/ template/ mark2pdf.config.toml
   ```

2. **配置优先级测试**
   ```bash
   # 设置环境变量指向其他目录
   export MARK2PDF_DATA_DIR=/tmp/mark2pdf_test
   uv run md2pdf test.md --verbose
   # 预期: 输出显示使用 /tmp/mark2pdf_test 目录
   ```

3. **本地模板测试**
   ```bash
   cp /Users/fangjun/python/mark2pdf/template/nb.typ ./template/
   # 修改本地 nb.typ 添加标识注释
   uv run md2pdf test.md --verbose
   # 预期: 输出显示使用本地模板路径
   ```
