"""Project configuration dataclasses and config I/O helpers. Per module_taxonomy_refactor_20260627 Phase 3b, the project context (ProjectContext + 5 sub-dataclasses) and config I/O helpers moved from src/models.py to this module. Per the 4-criteria decision rule: - C1 (cross-system usage >= 3 systems): YES (project_manager, aggregate, api_hooks, app_controller, gui_2, orchestrator_pm, tests) - C2 (state machine / lifecycle): NO (just config; no state transitions) - C3 (test file already exists): YES (test_project_context_20260627.py) - C4 (substantial size): YES (ProjectContext + 5 sub + 3 helpers + 60+ lines) Therefore: DEDICATED FILE = src/project.py """ from __future__ import annotations import re import tomllib from dataclasses import dataclass, field from typing import Any, List from src.paths import get_config_path from src.type_aliases import Metadata # --------------------------------------------------------------------------- ProjectContext @dataclass(frozen=True, slots=True) class ProjectMeta: name: str = "" summary_only: bool = False execution_mode: str = "standard" @dataclass(frozen=True, slots=True) class ProjectOutput: namespace: str = "project" output_dir: str = "" @dataclass(frozen=True, slots=True) class ProjectFiles: base_dir: str = "" paths: tuple[str, ...] = () @dataclass(frozen=True, slots=True) class ProjectScreenshots: base_dir: str = "." paths: tuple[str, ...] = () @dataclass(frozen=True, slots=True) class ProjectDiscussion: roles: tuple[str, ...] = () history: tuple[str, ...] = () @dataclass(frozen=True, slots=True) class ProjectContext: """Typed return type for project_manager.flat_config(). Replaces the dict[str, Any] that flat_config() returned. Per conductor/tracks/cruft_elimination_20260627/SPEC_CORRECTION_phase_2.md.""" project: ProjectMeta = field(default_factory=ProjectMeta) output: ProjectOutput = field(default_factory=ProjectOutput) files: ProjectFiles = field(default_factory=ProjectFiles) screenshots: ProjectScreenshots = field(default_factory=ProjectScreenshots) context_presets: Metadata = field(default_factory=dict) discussion: ProjectDiscussion = field(default_factory=ProjectDiscussion) def to_dict(self) -> Metadata: return { "project": { "name": self.project.name, "summary_only": self.project.summary_only, "execution_mode": self.project.execution_mode, }, "output": { "namespace": self.output.namespace, "output_dir": self.output.output_dir, }, "files": { "base_dir": self.files.base_dir, "paths": list(self.files.paths), }, "screenshots": { "base_dir": self.screenshots.base_dir, "paths": list(self.screenshots.paths), }, "context_presets": dict(self.context_presets), "discussion": { "roles": list(self.discussion.roles), "history": list(self.discussion.history), }, } def __getitem__(self, key: str) -> Any: return self.to_dict()[key] def get(self, key: str, default: Any = None) -> Any: return self.to_dict().get(key, default) EMPTY_PROJECT_CONTEXT: ProjectContext = ProjectContext() # --------------------------------------------------------------------------- Config IO helpers def _clean_nones(data: Any) -> Any: if isinstance(data, dict): return {k: _clean_nones(v) for k, v in data.items() if v is not None} elif isinstance(data, list): return [_clean_nones(v) for v in data if v is not None] return data def load_config_from_disk() -> Metadata: """ Re-read the global config.toml from disk and return the parsed dict. The single source of truth for the in-memory config is the AppController's self.config attribute; this function is the disk I/O primitive that the controller owns. Direct callers in src/ are an architectural smell (bypassing the state owner) and will be flagged by scripts/audit_no_models_config_io.py. [C: src/app_controller.py:AppController.load_config, src/app_controller.py:AppController.__init__] """ with open(get_config_path(), "rb") as f: return tomllib.load(f) def save_config_to_disk(config: Metadata) -> None: # tomli_w is loaded on-demand (sub-track 2 of startup_speedup_20260606). # If it's already in sys.modules (e.g. warmed up or loaded by a prior # call), the import is a fast lookup; otherwise it's a cold load paid # only when the user actually saves config. import tomli_w config = _clean_nones(config) with open(get_config_path(), "wb") as f: tomli_w.dump(config, f) # --------------------------------------------------------------------------- History utilities def parse_history_entries(history_strings: list[str], roles: list[str]) -> list[Metadata]: import re from src import thinking_parser entries = [] for raw in history_strings: ts = "" rest = raw if rest.startswith("@"): nl = rest.find("\n") if nl != -1: ts = rest[1:nl] rest = rest[nl + 1:] known = roles or ["User", "AI", "Vendor API", "System"] role_pat = re.compile(r"^(" + "|".join(re.escape(r) for r in known) + r"):", re.IGNORECASE) match = role_pat.match(rest) role = match.group(1) if match else "User" if match: content = rest[match.end():].strip() else: content = rest entry_obj = {"role": role, "content": content, "collapsed": True, "ts": ts} if role == "AI" and ("" in content or "" in content or "Thinking:" in content): segments, parsed_content = thinking_parser.parse_thinking_trace(content) if segments: entry_obj["content"] = parsed_content entry_obj["thinking_segments"] = [{"content": s.content, "marker": s.marker} for s in segments] entries.append(entry_obj) return entries