from __future__ import annotations
import re
from dataclasses import dataclass, field
from uuid import UUID
from archledger.errors import ConfigError
from archledger.ids import (
DEFAULT_ID_PREFIX,
DEFAULT_ID_SEGMENT_MODE,
DEFAULT_ID_WIDTH,
LedgerIdFormat,
)
from archledger.model import CURRENT_SOURCE_SCHEMA_VERSION
# --- Public allowed-value constants ---
# Shared by parse.py, render.py, and cli.py.
VALID_BUILD_CONVERTERS: frozenset[str] = frozenset({"auto", "pandoc", "asciidoctor"})
VALID_TRACKING_SCANNERS: frozenset[str] = frozenset({"auto", "git", "filesystem"})
VALID_TRACKING_HASH_ALGORITHMS: frozenset[str] = frozenset({"sha256"})
VALID_DIAGRAM_RENDERERS: frozenset[str] = frozenset(
{"pass-through", "mermaid-cli", "asciidoctor-diagram"}
)
VALID_DIAGRAM_TYPES: frozenset[str] = frozenset(
{"text", "ascii", "unicode", "svgbob", "mermaid"}
)
VALID_DIAGRAM_IMAGE_FORMATS: frozenset[str] = frozenset({"svg", "png"})
DEFAULT_TRACKING_INCLUDE = (
"**/*.py",
"**/*.toml",
"**/*.md",
"**/*.adoc",
"**/*.rst",
"**/*.j2",
"**/*.yaml",
"**/*.yml",
"**/*.json",
)
DEFAULT_TRACKING_EXCLUDE = tuple(
dict.fromkeys(
(
".git/**",
".venv/**",
"**/__pycache__/**",
".mypy_cache/**",
".pytest_cache/**",
".ruff_cache/**",
"dist/**",
"build/**",
)
)
)
DEFAULT_ID_SEGMENT = "content"
DEFAULT_ID_SEGMENT_MAP: dict[str, str] = {
"section": "content",
"requirement": "content",
"stakeholder": "content",
"quality_goal": "quality",
"constraint": "constraint",
"context_interface": "context",
"strategy_item": "strategy",
"white_box": "block",
"black_box": "block",
"interface": "block",
"runtime_scenario": "runtime",
"infrastructure": "deploy",
"concept": "concept",
"adr": "adr",
"quality_requirement": "quality",
"quality_scenario": "quality",
"risk": "risk",
"diagram": "diagram",
"glossary_term": "glossary",
"archive_tombstone": "archive",
}
[docs]
@dataclass(frozen=True, slots=True)
class SourceConfig:
format: str
schema_version: int
front_matter: str
section_extension: str
record_extension: str
[docs]
@dataclass(frozen=True, slots=True)
class BuildOutputConfig:
enabled: bool | None = None
tool: str | None = None
pdf_engine: str = ""
reference_docx: str = ""
[docs]
@dataclass(frozen=True, slots=True)
class BuildConfig:
default_output: str
default_format: str
default_output_dir: str
include_draft: bool
include_superseded: bool
strict: bool
keep_intermediate: bool
converter: str
pdf_engine: str
reference_docx: str
outputs: dict[str, BuildOutputConfig]
[docs]
@dataclass(frozen=True, slots=True)
class Arc42Config:
template_version: str
language: str
title: str
include_help: bool
[docs]
@dataclass(frozen=True, slots=True)
class SkillConfig:
installed: bool
path: str
[docs]
@dataclass(frozen=True, slots=True)
class IdConfig:
prefix: str
width: int
segment_mode: str
default_segment: str
segment_map: dict[str, str]
[docs]
@dataclass(frozen=True, slots=True)
class TrackingConfig:
enabled: bool
state_file: str
scanner: str
include: tuple[str, ...]
exclude: tuple[str, ...]
max_file_bytes: int
hash_algorithm: str
[docs]
@dataclass(frozen=True, slots=True)
class DiagramConfig:
enabled: bool
renderer: str
default_type: str
output_dir: str
image_format: str
kroki_url: str
[docs]
@dataclass(frozen=True, slots=True)
class ProjectConfig:
config_version: int
archledger_dir: str
project_uuid: str
project_name: str
id_prefix: str = DEFAULT_ID_PREFIX
id_width: int = DEFAULT_ID_WIDTH
id_segment_mode: str = DEFAULT_ID_SEGMENT_MODE
id_default_segment: str = DEFAULT_ID_SEGMENT
id_segment_map: dict[str, str] = field(
default_factory=lambda: dict(DEFAULT_ID_SEGMENT_MAP)
)
source_format: str = "markdown"
source_schema_version: int = CURRENT_SOURCE_SCHEMA_VERSION
front_matter: str = "yaml"
section_extension: str = ".md"
record_extension: str = ".md"
build_default_output: str = "architecture.md"
build_default_format: str = "markdown"
build_output_dir: str = "build"
build_include_draft: bool = False
build_include_superseded: bool = False
build_strict: bool = False
build_keep_intermediate: bool = False
build_converter: str = "auto"
build_pdf_engine: str = ""
build_reference_docx: str = ""
build_outputs: dict[str, dict[str, object]] = field(default_factory=dict)
arc42_template_version: str = "9.0-EN"
arc42_language: str = "en"
arc42_title: str = "Architecture Documentation"
arc42_include_help: bool = False
skill_installed: bool = False
skill_path: str = "skills/archledger/SKILL.md"
tracking_enabled: bool = True
tracking_state_file: str = "source-state.json"
tracking_scanner: str = "auto"
tracking_include: tuple[str, ...] = DEFAULT_TRACKING_INCLUDE
tracking_exclude: tuple[str, ...] = DEFAULT_TRACKING_EXCLUDE
tracking_max_file_bytes: int = 1_000_000
tracking_hash_algorithm: str = "sha256"
diagram_enabled: bool = False
diagram_renderer: str = "pass-through"
diagram_default_type: str = "text"
diagram_output_dir: str = "diagrams"
diagram_image_format: str = "svg"
diagram_kroki_url: str = ""
@property
def source(self) -> SourceConfig:
return SourceConfig(
format=self.source_format,
schema_version=self.source_schema_version,
front_matter=self.front_matter,
section_extension=self.section_extension,
record_extension=self.record_extension,
)
@property
def build(self) -> BuildConfig:
return BuildConfig(
default_output=self.build_default_output,
default_format=self.build_default_format,
default_output_dir=self.build_output_dir,
include_draft=self.build_include_draft,
include_superseded=self.build_include_superseded,
strict=self.build_strict,
keep_intermediate=self.build_keep_intermediate,
converter=self.build_converter,
pdf_engine=self.build_pdf_engine,
reference_docx=self.build_reference_docx,
outputs={
name: _build_output_config(value)
for name, value in self.build_outputs.items()
},
)
@property
def arc42(self) -> Arc42Config:
return Arc42Config(
template_version=self.arc42_template_version,
language=self.arc42_language,
title=self.arc42_title,
include_help=self.arc42_include_help,
)
@property
def skill(self) -> SkillConfig:
return SkillConfig(
installed=self.skill_installed,
path=self.skill_path,
)
@property
def ids(self) -> IdConfig:
return IdConfig(
prefix=self.id_prefix,
width=self.id_width,
segment_mode=self.id_segment_mode,
default_segment=self.id_default_segment,
segment_map=dict(self.id_segment_map),
)
@property
def id_format(self) -> LedgerIdFormat:
return LedgerIdFormat(
prefix=self.id_prefix,
width=self.id_width,
segment_mode=self.id_segment_mode,
)
@property
def tracking(self) -> TrackingConfig:
return TrackingConfig(
enabled=self.tracking_enabled,
state_file=self.tracking_state_file,
scanner=self.tracking_scanner,
include=self.tracking_include,
exclude=self.tracking_exclude,
max_file_bytes=self.tracking_max_file_bytes,
hash_algorithm=self.tracking_hash_algorithm,
)
@property
def diagrams(self) -> DiagramConfig:
return DiagramConfig(
enabled=self.diagram_enabled,
renderer=self.diagram_renderer,
default_type=self.diagram_default_type,
output_dir=self.diagram_output_dir,
image_format=self.diagram_image_format,
kroki_url=self.diagram_kroki_url,
)
[docs]
def normalize_project_name(name: str) -> str:
normalized = re.sub(r"[^A-Za-z0-9]+", "-", name.strip().lower()).strip("-")
if not normalized:
raise ConfigError("Project name must contain at least one letter or number.")
return normalized
def _build_output_config(value: dict[str, object]) -> BuildOutputConfig:
enabled_value = value.get("enabled")
tool_value = value.get("tool")
pdf_engine_value = value.get("pdf_engine")
reference_docx_value = value.get("reference_docx")
return BuildOutputConfig(
enabled=enabled_value if isinstance(enabled_value, bool) else None,
tool=tool_value if isinstance(tool_value, str) else None,
pdf_engine=pdf_engine_value if isinstance(pdf_engine_value, str) else "",
reference_docx=(
reference_docx_value if isinstance(reference_docx_value, str) else ""
),
)
def validate_uuid(value: str) -> str:
"""Validate and normalise a UUID string."""
try:
return str(UUID(value))
except ValueError as exc:
raise ConfigError("project_uuid must be a valid UUID.") from exc