# Task ID: 4

**Title:** Implement ConfigManager

**Status:** pending

**Dependencies:** 2, 3

**Priority:** high

**Description:** Create the ConfigManager infrastructure component for reading/writing TOML configuration and managing tenant profiles.

**Details:**

Create `src/teams_phone/infrastructure/config_manager.py`:
```python
class ConfigManager:
    def __init__(self, config_path: Path | None = None):
        self._config_path = config_path or Path(DEFAULT_CONFIG_PATH).expanduser()
    
    def load_config(self) -> Config: ...
    def save_config(self, config: Config) -> None: ...
    def get_tenant(self, name: str) -> TenantProfile: ...
    def set_tenant(self, name: str, profile: TenantProfile) -> None: ...
    def remove_tenant(self, name: str) -> None: ...
    def get_default_tenant(self) -> str | None: ...
    def set_default_tenant(self, name: str) -> None: ...
    def ensure_config_dir(self) -> None: ...
```

Use tomllib (Python 3.11+) or tomli for reading, tomlkit for writing (preserves formatting).
Handle missing config file gracefully (create default).
Raise ConfigurationError for invalid config.

**Test Strategy:**

Unit tests with tmp_path fixture: test load/save roundtrip, tenant CRUD operations, default tenant management, handling of missing files, validation errors.

---

## Implementation Notes

### Session: 2026-01-28 21:45 - Agent: claude-opus-4-5-20251101

**Status:** Subtask 4.1 Implemented (TOML loading/saving)

#### What Was Done
- Added `tomlkit>=0.12.0` dependency to `pyproject.toml`
- Added conditional `tomli>=2.0.0; python_version < '3.11'` for Python 3.10 support
- Created `src/teams_phone/infrastructure/config_manager.py` with ConfigManager class
  - `__init__`: Accepts optional config_path, defaults to DEFAULT_CONFIG_PATH
  - `ensure_config_dir()`: Creates parent directories for config file
  - `load_config()`: Uses tomllib/tomli to parse TOML, validates with Pydantic, returns default Config if file missing
  - `save_config()`: Uses tomlkit to serialize Config, excludes None values
- Updated `src/teams_phone/infrastructure/__init__.py` to export ConfigManager
- Created `tests/unit/test_config_manager.py` with 16 unit tests

#### Key Decisions Made
- **Conditional tomlib import**: Used `sys.version_info >= (3, 11)` with `tomli` fallback for Python 3.10 support, with `# type: ignore[import-not-found,unused-ignore]` to satisfy mypy running on 3.10 target
- **exclude_none=True in save_config**: tomlkit cannot serialize None values, so we exclude them with Pydantic's `exclude_none=True`
- **mode="json" in model_dump**: Ensures UUID and Enum types serialize to strings for TOML compatibility

#### Patterns Discovered
- `Config.model_dump(mode="json", exclude_none=True)` is the pattern for TOML-safe serialization
- Pydantic ValidationError needs wrapping in ConfigurationError with remediation guidance
- tomllib.TOMLDecodeError is the exception type for parsing errors

#### Open Items for Next Agent
- [x] Subtask 4.2: Implement tenant CRUD methods (get_tenant, set_tenant, remove_tenant)
- [ ] Subtask 4.3: Implement default tenant methods (get_default_tenant, set_default_tenant)

#### Gotchas and Warnings
- tomlkit throws `ConvertError` on None values - always use `exclude_none=True`
- mypy python_version is 3.10 but tomllib is 3.11+ stdlib - need ignore comment on fallback import
- Round-trip test validates that saved config can be loaded back correctly

---

### Session: 2026-01-28 21:06 - Agent: claude-opus-4-5-20251101

**Status:** Subtask 4.2 Implemented (Tenant CRUD operations)

#### What Was Done
- Added `NotFoundError` and `ValidationError` imports to `config_manager.py`
- Added `TenantProfile` import from models
- Implemented `get_tenant(name: str) -> TenantProfile`:
  - Loads config, checks if tenant exists in `config.tenants` dict
  - Raises `NotFoundError` with remediation guidance if not found
- Implemented `set_tenant(name: str, profile: TenantProfile) -> None`:
  - Validates that `profile.name` matches `name` parameter (raises `ValidationError` if mismatch)
  - Loads config, adds/updates tenant, saves config
- Implemented `remove_tenant(name: str) -> None`:
  - Loads config, raises `NotFoundError` if tenant not present
  - Deletes tenant from dict
  - Clears `default_tenant` if it matches the removed tenant
  - Saves config
- Added 12 new unit tests across 4 test classes:
  - `TestGetTenant`: 3 tests (returns existing, raises not found, no config file)
  - `TestSetTenant`: 4 tests (adds new, updates existing, validates name match, preserves others)
  - `TestRemoveTenant`: 5 tests (removes, raises not found, clears default if match, preserves default if different, preserves others)

#### Key Decisions Made
- **ValidationError on name mismatch in set_tenant**: Task details said "ensure profile.name matches key" - interpreted as raising error rather than silently updating the profile name. This enforces data integrity.
- **Remediation text**: Used "Run 'teams-phone tenants list' to see configured tenants" for helpful user guidance.

#### Patterns Discovered
- **Load-modify-save pattern**: `config = self.load_config(); config.tenants[name] = profile; self.save_config(config)` - simple and atomic enough for CLI use case
- **Test pattern for exceptions**: Use `pytest.raises(ExceptionClass) as exc_info`, then assert on `str(exc_info.value)` and `exc_info.value.remediation`

#### Open Items for Next Agent
- [x] Subtask 4.3: Implement default tenant methods (get_default_tenant, set_default_tenant)

#### Gotchas and Warnings
- `load_config()` returns default empty Config if file doesn't exist, so `get_tenant` and `remove_tenant` will naturally fail with NotFoundError for empty configs
- `remove_tenant` must check and clear `default_tenant` before saving - forgetting this leaves config in inconsistent state
- Test file needed ruff format after adding tests - run `ruff format` after editing

---

### Session: 2026-01-28 21:50 - Agent: claude-opus-4-5-20251101

**Status:** Subtask 4.3 Implemented (Default tenant management and permission error handling)

#### What Was Done
- Enhanced `ensure_config_dir()` with permission error handling:
  - Wraps `PermissionError` in `ConfigurationError` with remediation guidance
  - Tells user to check write permissions or create directory manually
- Implemented `get_default_tenant() -> str | None`:
  - Loads config and returns `config.default_tenant`
  - Returns `None` if config doesn't exist or default not set
- Implemented `set_default_tenant(name: str) -> None`:
  - Loads config, validates tenant exists in `config.tenants`
  - Raises `NotFoundError` with remediation guidance if tenant doesn't exist
  - Sets `config.default_tenant = name` and saves
- Added 6 new unit tests:
  - `TestEnsureConfigDir::test_permission_error_raises_configuration_error` - uses mock to simulate permission error
  - `TestGetDefaultTenant::test_returns_none_for_empty_config` - no config file case
  - `TestGetDefaultTenant::test_returns_configured_default` - retrieves existing default
  - `TestSetDefaultTenant::test_updates_default_value` - sets default on existing tenant
  - `TestSetDefaultTenant::test_raises_not_found_for_nonexistent_tenant` - validation case
  - `TestSetDefaultTenant::test_changes_existing_default` - updates from one tenant to another

#### Key Decisions Made
- **Mock for PermissionError**: Used `unittest.mock.patch.object(Path, "mkdir")` to simulate permission errors without OS-level permissions manipulation
- **Chained exception**: Used `raise ConfigurationError(...) from e` to preserve the original PermissionError in the traceback

#### Patterns Discovered
- **Permission error pattern**: `try: path.mkdir(...) except PermissionError as e: raise ConfigurationError(...) from e`
- **Test mocking pattern**: `with patch.object(Path, "mkdir", side_effect=PermissionError("msg")):` for filesystem error simulation

#### Open Items for Next Agent
- Task 4 (ConfigManager) is now complete - all subtasks done
- Next logical task: Task 5 (Implement GraphClient) or tenant CLI commands

#### Gotchas and Warnings
- `set_default_tenant` validates tenant existence before setting - prevents inconsistent config where default_tenant points to non-existent tenant
- All 34 tests pass, mypy and ruff checks are clean

---
