diff --git a/apps/api/alembic/versions/0014_add_profile_resolver_fields.py b/apps/api/alembic/versions/0014_add_profile_resolver_fields.py new file mode 100644 index 0000000..8d923fb --- /dev/null +++ b/apps/api/alembic/versions/0014_add_profile_resolver_fields.py @@ -0,0 +1,119 @@ +"""add profile resolver fields to config profiles and mounts + +Revision ID: 0014_add_profile_resolver_fields +Revises: 0013_add_config_profiles +Create Date: 2026-05-24 14:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0014_add_profile_resolver_fields" +down_revision: Union[str, None] = "0013_add_config_profiles" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add fields to config_profiles + op.add_column( + "config_profiles", + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.add_column( + "config_profiles", + sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.add_column( + "config_profiles", + sa.Column("environment_variables", sa.JSON(), nullable=True), + ) + op.add_column( + "config_profiles", + sa.Column("start_command", sa.Text(), nullable=True), + ) + op.add_column( + "config_profiles", + sa.Column("working_directory", sa.Text(), nullable=True), + ) + op.add_column( + "config_profiles", + sa.Column("port", sa.Integer(), nullable=True), + ) + op.add_column( + "config_profiles", + sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"), + ) + + # Add foreign keys for project and tool_type + op.create_foreign_key( + "fk_config_profiles_project", + "config_profiles", + "projects", + ["project_id"], + ["id"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_config_profiles_tool_type", + "config_profiles", + "tool_types", + ["tool_type_id"], + ["id"], + ondelete="CASCADE", + ) + + # Create indices + op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"]) + op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"]) + + # Alter config_mounts: rename mount_path to target_path, add mode, change content to files JSON + op.alter_column("config_mounts", "mount_path", new_column_name="target_path") + op.add_column( + "config_mounts", + sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"), + ) + op.add_column( + "config_mounts", + sa.Column("files", sa.JSON(), nullable=True), + ) + # Drop the source_profile foreign key if it exists + op.drop_constraint( + "config_mounts_source_profile_id_fkey", + "config_mounts", + type_="foreignkey", + ) + op.drop_column("config_mounts", "content") + op.drop_column("config_mounts", "source_profile_id") + + +def downgrade() -> None: + # Restore config_mounts + op.add_column( + "config_mounts", + sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.add_column( + "config_mounts", + sa.Column("content", sa.Text(), nullable=True), + ) + op.drop_column("config_mounts", "files") + op.drop_column("config_mounts", "mode") + op.alter_column("config_mounts", "target_path", new_column_name="mount_path") + + # Restore config_profiles + op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles") + op.drop_index("idx_config_profiles_project", table_name="config_profiles") + op.drop_constraint("fk_config_profiles_tool_type", "config_profiles", type_="foreignkey") + op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey") + op.drop_column("config_profiles", "is_default") + op.drop_column("config_profiles", "port") + op.drop_column("config_profiles", "working_directory") + op.drop_column("config_profiles", "start_command") + op.drop_column("config_profiles", "environment_variables") + op.drop_column("config_profiles", "tool_type_id") + op.drop_column("config_profiles", "project_id") diff --git a/apps/api/src/models/config_mount.py b/apps/api/src/models/config_mount.py index ab3e1d5..de112a3 100644 --- a/apps/api/src/models/config_mount.py +++ b/apps/api/src/models/config_mount.py @@ -1,7 +1,7 @@ import uuid from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, Integer, String, Text +from sqlalchemy import ForeignKey, Integer, JSON, String from sqlalchemy import Uuid as UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -17,10 +17,10 @@ class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base): profile_id: Mapped[uuid.UUID] = mapped_column( UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False ) - mount_path: Mapped[str] = mapped_column(String(1024), nullable=False) - content: Mapped[str | None] = mapped_column(Text, nullable=True) - source_profile_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True + target_path: Mapped[str] = mapped_column(String(1024), nullable=False) + mode: Mapped[str] = mapped_column(String(10), nullable=False, default="rw") + files: Mapped[dict[str, str] | None] = mapped_column( + JSON, default=dict, nullable=True ) order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) @@ -29,7 +29,3 @@ class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base): foreign_keys=[profile_id], back_populates="mounts", ) - source_profile: Mapped["ConfigProfile | None"] = relationship( - "ConfigProfile", - foreign_keys=[source_profile_id], - ) diff --git a/apps/api/src/models/config_profile.py b/apps/api/src/models/config_profile.py index 65be5d4..df21d9c 100644 --- a/apps/api/src/models/config_profile.py +++ b/apps/api/src/models/config_profile.py @@ -1,13 +1,17 @@ import uuid from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, String, Text, UniqueConstraint +from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint from sqlalchemy import Uuid as UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin if TYPE_CHECKING: + from src.models.config_include import ConfigInclude + from src.models.config_mount import ConfigMount + from src.models.project import Project + from src.models.tool_type import ToolType from src.models.user import User @@ -20,10 +24,25 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base): user_id: Mapped[uuid.UUID] = mapped_column( UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) + project_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True + ) + tool_type_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) + environment_variables: Mapped[dict[str, str] | None] = mapped_column( + JSON, default=dict, nullable=True + ) + start_command: Mapped[str | None] = mapped_column(Text, nullable=True) + working_directory: Mapped[str | None] = mapped_column(Text, nullable=True) + port: Mapped[int | None] = mapped_column(Integer, nullable=True) + is_default: Mapped[bool] = mapped_column(default=False, nullable=False) user: Mapped["User"] = relationship() + project: Mapped["Project | None"] = relationship() + tool_type: Mapped["ToolType | None"] = relationship() includes: Mapped[list["ConfigInclude"]] = relationship( "ConfigInclude", foreign_keys="ConfigInclude.profile_id", diff --git a/apps/api/src/services/profile_resolver.py b/apps/api/src/services/profile_resolver.py new file mode 100644 index 0000000..345497c --- /dev/null +++ b/apps/api/src/services/profile_resolver.py @@ -0,0 +1,251 @@ +"""Profile resolver service for recursive ordered include resolution. + +Provides deterministic merge rules, save-independent cycle protection, +and resolved output structures for env vars, runtime hints, mounts, +file trees, and override metadata. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field + +from src.models.config_include import ConfigInclude +from src.models.config_mount import ConfigMount +from src.models.config_profile import ConfigProfile + + +@dataclass +class ResolvedMount: + """A resolved mount with merged file tree and final mode.""" + + target_path: str + mode: str # "ro" or "rw" + files: dict[str, str] = field(default_factory=dict) + """Relative file paths to UTF-8 text content.""" + overridden_files: dict[str, list[str]] = field(default_factory=dict) + """Map of relative file path to list of profile names that contributed + (latest is the winner).""" + mode_overridden_by: str | None = None + """Name of the profile that set the final mode, if different from first.""" + + +@dataclass +class ResolvedRuntimeHints: + """Resolved runtime hints from profile layers.""" + + start_command: str | None = None + working_directory: str | None = None + port: int | None = None + overridden_hints: dict[str, str] = field(default_factory=dict) + """Map of hint key to profile name that provided the winning value.""" + + +@dataclass +class ResolvedProfileOutput: + """Complete resolved output for a config profile.""" + + profile_id: uuid.UUID + profile_name: str + environment_variables: dict[str, str] = field(default_factory=dict) + """Final merged env vars (later layers win).""" + env_var_sources: dict[str, list[str]] = field(default_factory=dict) + """Map of env var key to ordered list of contributing profile names + (latest is the winner).""" + runtime_hints: ResolvedRuntimeHints = field( + default_factory=lambda: ResolvedRuntimeHints() + ) + mounts: dict[str, ResolvedMount] = field(default_factory=dict) + """Map of target_path to ResolvedMount.""" + resolution_order: list[str] = field(default_factory=list) + """Ordered list of profile names as they were resolved.""" + cycle_detected: bool = False + cycle_path: list[str] | None = None + + +class ProfileResolutionError(Exception): + """Raised when profile resolution fails.""" + + pass + + +class ProfileCycleError(ProfileResolutionError): + """Raised when a cycle is detected during profile resolution.""" + + def __init__(self, cycle_path: list[str]) -> None: + self.cycle_path = cycle_path + path_str = " -> ".join(cycle_path) + super().__init__(f"Profile include cycle detected: {path_str}") + + +def _merge_env_vars( + current: dict[str, str], + sources: dict[str, list[str]], + profile: ConfigProfile, +) -> None: + """Merge a profile's env vars into the current dict, tracking sources.""" + if not profile.environment_variables: + return + for key, value in profile.environment_variables.items(): + current[key] = value + if key not in sources: + sources[key] = [] + sources[key].append(profile.name) + + +def _merge_runtime_hints( + hints: ResolvedRuntimeHints, + profile: ConfigProfile, +) -> None: + """Merge a profile's runtime hints, tracking overrides.""" + if profile.start_command is not None: + hints.start_command = profile.start_command + hints.overridden_hints["start_command"] = profile.name + if profile.working_directory is not None: + hints.working_directory = profile.working_directory + hints.overridden_hints["working_directory"] = profile.name + if profile.port is not None: + hints.port = profile.port + hints.overridden_hints["port"] = profile.name + + +def _merge_mounts( + mounts: dict[str, ResolvedMount], + profile_mounts: list[ConfigMount], + profile: ConfigProfile, +) -> None: + """Merge a profile's mounts into the current mounts dict.""" + for mount in profile_mounts: + target = mount.target_path + if target not in mounts: + mounts[target] = ResolvedMount( + target_path=target, + mode=mount.mode, + files={}, + overridden_files={}, + ) + resolved = mounts[target] + + # Mode override: later wins + if resolved.mode != mount.mode: + resolved.mode = mount.mode + resolved.mode_overridden_by = profile.name + + # File tree merge: later wins for same relative path + if mount.files: + for rel_path, content in mount.files.items(): + if rel_path not in resolved.files: + resolved.overridden_files[rel_path] = [] + else: + if rel_path not in resolved.overridden_files: + resolved.overridden_files[rel_path] = [] + resolved.overridden_files[rel_path].append(profile.name) + resolved.files[rel_path] = content + + +def _resolve_profile_recursive( + profile: ConfigProfile, + visited: set[uuid.UUID], + path: list[str], + resolution_order: list[str], + env_vars: dict[str, str], + env_var_sources: dict[str, list[str]], + runtime_hints: ResolvedRuntimeHints, + mounts: dict[str, ResolvedMount], +) -> None: + """Recursively resolve a profile and its includes. + + Args: + profile: The profile to resolve + visited: Set of already-resolved profile IDs to avoid duplicates + path: Current recursion path for cycle detection + resolution_order: Ordered list of profile names being resolved + env_vars: Accumulated environment variables + env_var_sources: Tracking of which profiles contributed each env var + runtime_hints: Accumulated runtime hints + mounts: Accumulated mounts + + Raises: + ProfileCycleError: If a cycle is detected + """ + if profile.name in path: + # Cycle detected + cycle_start = path.index(profile.name) + cycle_path = path[cycle_start:] + [profile.name] + raise ProfileCycleError(cycle_path) + + if profile.id in visited: + # Already resolved in another branch (diamond graph) + return + + visited.add(profile.id) + path.append(profile.name) + resolution_order.append(profile.name) + + # Resolve includes first (in order) + includes: list[ConfigInclude] = list(profile.includes) + includes.sort(key=lambda inc: inc.order_index) + for include in includes: + included_profile = include.included_profile + if included_profile is not None: + _resolve_profile_recursive( + included_profile, + visited, + path, + resolution_order, + env_vars, + env_var_sources, + runtime_hints, + mounts, + ) + + # Apply this profile's values (later layers win) + _merge_env_vars(env_vars, env_var_sources, profile) + _merge_runtime_hints(runtime_hints, profile) + _merge_mounts(mounts, list(profile.mounts), profile) + + path.pop() + + +def resolve_profile(profile: ConfigProfile) -> ResolvedProfileOutput: + """Resolve a config profile with all its includes. + + Processes included profiles in configured order, then applies the + selected profile itself. Later layers override earlier layers. + + Args: + profile: The root profile to resolve + + Returns: + ResolvedProfileOutput with merged env vars, runtime hints, mounts, + and override metadata + + Raises: + ProfileCycleError: If a cycle is detected in the include graph + """ + env_vars: dict[str, str] = {} + env_var_sources: dict[str, list[str]] = {} + runtime_hints = ResolvedRuntimeHints() + mounts: dict[str, ResolvedMount] = {} + resolution_order: list[str] = [] + + _resolve_profile_recursive( + profile, + set(), + [], + resolution_order, + env_vars, + env_var_sources, + runtime_hints, + mounts, + ) + + return ResolvedProfileOutput( + profile_id=profile.id, + profile_name=profile.name, + environment_variables=env_vars, + env_var_sources=env_var_sources, + runtime_hints=runtime_hints, + mounts=mounts, + resolution_order=resolution_order, + ) diff --git a/apps/api/tests/unit/test_profile_resolver.py b/apps/api/tests/unit/test_profile_resolver.py new file mode 100644 index 0000000..ac065a3 --- /dev/null +++ b/apps/api/tests/unit/test_profile_resolver.py @@ -0,0 +1,463 @@ +"""Unit tests for the profile resolver service.""" + +import uuid +from unittest.mock import MagicMock + +import pytest + +from src.services.profile_resolver import ( + ProfileCycleError, + ResolvedProfileOutput, + resolve_profile, +) + + +def _make_profile( + name: str, + env_vars: dict[str, str] | None = None, + start_command: str | None = None, + working_directory: str | None = None, + port: int | None = None, + mounts: list[MagicMock] | None = None, + includes: list[MagicMock] | None = None, +) -> MagicMock: + """Create a mock ConfigProfile for testing.""" + profile = MagicMock() + profile.id = uuid.uuid4() + profile.name = name + profile.environment_variables = env_vars or {} + profile.start_command = start_command + profile.working_directory = working_directory + profile.port = port + profile.mounts = mounts or [] + profile.includes = includes or [] + return profile + + +def _make_include(included_profile: MagicMock, order_index: int = 0) -> MagicMock: + """Create a mock ConfigInclude for testing.""" + include = MagicMock() + include.included_profile = included_profile + include.order_index = order_index + return include + + +def _make_mount( + target_path: str, + mode: str = "rw", + files: dict[str, str] | None = None, + order_index: int = 0, +) -> MagicMock: + """Create a mock ConfigMount for testing.""" + mount = MagicMock() + mount.target_path = target_path + mount.mode = mode + mount.files = files or {} + mount.order_index = order_index + return mount + + +class TestResolveProfileBasic: + """Tests for basic profile resolution without includes.""" + + def test_empty_profile(self) -> None: + """Resolving an empty profile returns empty output.""" + profile = _make_profile("empty") + result = resolve_profile(profile) + + assert isinstance(result, ResolvedProfileOutput) + assert result.profile_name == "empty" + assert result.environment_variables == {} + assert result.runtime_hints.start_command is None + assert result.runtime_hints.working_directory is None + assert result.runtime_hints.port is None + assert result.mounts == {} + assert result.resolution_order == ["empty"] + + def test_env_vars_only(self) -> None: + """Profile with env vars resolves correctly.""" + profile = _make_profile( + "env-only", + env_vars={"FOO": "bar", "BAZ": "qux"}, + ) + result = resolve_profile(profile) + + assert result.environment_variables == {"FOO": "bar", "BAZ": "qux"} + assert result.env_var_sources == { + "FOO": ["env-only"], + "BAZ": ["env-only"], + } + + def test_runtime_hints_only(self) -> None: + """Profile with runtime hints resolves correctly.""" + profile = _make_profile( + "hints-only", + start_command="python app.py", + working_directory="/app", + port=8080, + ) + result = resolve_profile(profile) + + assert result.runtime_hints.start_command == "python app.py" + assert result.runtime_hints.working_directory == "/app" + assert result.runtime_hints.port == 8080 + assert result.runtime_hints.overridden_hints == { + "start_command": "hints-only", + "working_directory": "hints-only", + "port": "hints-only", + } + + def test_mounts_only(self) -> None: + """Profile with mounts resolves correctly.""" + profile = _make_profile( + "mounts-only", + mounts=[ + _make_mount( + "/config", + mode="ro", + files={"settings.json": '{"key": "value"}'}, + ), + ], + ) + result = resolve_profile(profile) + + assert "/config" in result.mounts + mount = result.mounts["/config"] + assert mount.target_path == "/config" + assert mount.mode == "ro" + assert mount.files == {"settings.json": '{"key": "value"}'} + + +class TestResolveProfileIncludes: + """Tests for profile resolution with includes.""" + + def test_single_include(self) -> None: + """Profile with one include resolves in correct order.""" + base = _make_profile("base", env_vars={"FOO": "base"}) + derived = _make_profile( + "derived", + env_vars={"BAR": "derived"}, + includes=[_make_include(base, order_index=0)], + ) + result = resolve_profile(derived) + + assert result.resolution_order == ["derived", "base"] + assert result.environment_variables == { + "FOO": "base", + "BAR": "derived", + } + + def test_multiple_includes_ordered(self) -> None: + """Multiple includes are resolved in order_index order.""" + first = _make_profile("first", env_vars={"KEY": "first"}) + second = _make_profile("second", env_vars={"KEY": "second"}) + main = _make_profile( + "main", + includes=[ + _make_include(first, order_index=0), + _make_include(second, order_index=1), + ], + ) + result = resolve_profile(main) + + assert result.resolution_order == ["main", "first", "second"] + # second overrides first + assert result.environment_variables == {"KEY": "second"} + assert result.env_var_sources["KEY"] == ["first", "second"] + + def test_include_order_matters(self) -> None: + """Changing include order changes resolution.""" + a = _make_profile("a", env_vars={"KEY": "a"}) + b = _make_profile("b", env_vars={"KEY": "b"}) + main1 = _make_profile( + "main", + includes=[ + _make_include(a, order_index=0), + _make_include(b, order_index=1), + ], + ) + main2 = _make_profile( + "main", + includes=[ + _make_include(b, order_index=0), + _make_include(a, order_index=1), + ], + ) + + result1 = resolve_profile(main1) + result2 = resolve_profile(main2) + + assert result1.environment_variables["KEY"] == "b" + assert result2.environment_variables["KEY"] == "a" + + def test_nested_includes(self) -> None: + """Deeply nested includes resolve recursively.""" + deep = _make_profile("deep", env_vars={"DEEP": "value"}) + mid = _make_profile( + "mid", + env_vars={"MID": "value"}, + includes=[_make_include(deep, order_index=0)], + ) + top = _make_profile( + "top", + env_vars={"TOP": "value"}, + includes=[_make_include(mid, order_index=0)], + ) + result = resolve_profile(top) + + assert result.resolution_order == ["top", "mid", "deep"] + assert result.environment_variables == { + "TOP": "value", + "MID": "value", + "DEEP": "value", + } + + +class TestResolveProfileOverrides: + """Tests for deterministic override rules.""" + + def test_env_var_override(self) -> None: + """Later layers override earlier env vars.""" + base = _make_profile("base", env_vars={"KEY": "base"}) + override = _make_profile("override", env_vars={"KEY": "override"}) + main = _make_profile( + "main", + includes=[ + _make_include(base, order_index=0), + _make_include(override, order_index=1), + ], + ) + result = resolve_profile(main) + + assert result.environment_variables["KEY"] == "override" + assert result.env_var_sources["KEY"] == ["base", "override"] + + def test_main_profile_wins_over_includes(self) -> None: + """The main profile itself wins over all includes.""" + base = _make_profile("base", env_vars={"KEY": "base"}) + main = _make_profile( + "main", + env_vars={"KEY": "main"}, + includes=[_make_include(base, order_index=0)], + ) + result = resolve_profile(main) + + assert result.environment_variables["KEY"] == "main" + assert result.env_var_sources["KEY"] == ["base", "main"] + + def test_runtime_hint_override(self) -> None: + """Later layers override earlier runtime hints.""" + base = _make_profile("base", start_command="python old.py") + override = _make_profile("override", start_command="python new.py") + main = _make_profile( + "main", + includes=[ + _make_include(base, order_index=0), + _make_include(override, order_index=1), + ], + ) + result = resolve_profile(main) + + assert result.runtime_hints.start_command == "python new.py" + assert result.runtime_hints.overridden_hints["start_command"] == "override" + + def test_mount_file_override(self) -> None: + """Later layers override earlier files in the same mount.""" + base = _make_profile( + "base", + mounts=[ + _make_mount( + "/config", + files={"app.json": '{"v": 1}'}, + ), + ], + ) + override = _make_profile( + "override", + mounts=[ + _make_mount( + "/config", + files={"app.json": '{"v": 2}'}, + ), + ], + ) + main = _make_profile( + "main", + includes=[ + _make_include(base, order_index=0), + _make_include(override, order_index=1), + ], + ) + result = resolve_profile(main) + + mount = result.mounts["/config"] + assert mount.files["app.json"] == '{"v": 2}' + assert mount.overridden_files["app.json"] == ["override"] + + def test_mount_mode_override(self) -> None: + """Later layers override mount mode.""" + base = _make_profile( + "base", + mounts=[_make_mount("/data", mode="ro")], + ) + override = _make_profile( + "override", + mounts=[_make_mount("/data", mode="rw")], + ) + main = _make_profile( + "main", + includes=[ + _make_include(base, order_index=0), + _make_include(override, order_index=1), + ], + ) + result = resolve_profile(main) + + assert result.mounts["/data"].mode == "rw" + assert result.mounts["/data"].mode_overridden_by == "override" + + def test_mount_file_merge(self) -> None: + """Different files in the same mount are merged.""" + base = _make_profile( + "base", + mounts=[ + _make_mount( + "/config", + files={"a.json": "1"}, + ), + ], + ) + override = _make_profile( + "override", + mounts=[ + _make_mount( + "/config", + files={"b.json": "2"}, + ), + ], + ) + main = _make_profile( + "main", + includes=[ + _make_include(base, order_index=0), + _make_include(override, order_index=1), + ], + ) + result = resolve_profile(main) + + mount = result.mounts["/config"] + assert mount.files == {"a.json": "1", "b.json": "2"} + + +class TestResolveProfileCycles: + """Tests for cycle detection during resolution.""" + + def test_direct_cycle(self) -> None: + """A -> B -> A is detected.""" + a = _make_profile("a") + b = _make_profile("b", includes=[_make_include(a, order_index=0)]) + a.includes = [_make_include(b, order_index=0)] + + with pytest.raises(ProfileCycleError) as exc_info: + resolve_profile(a) + + assert "a" in exc_info.value.cycle_path + assert "b" in exc_info.value.cycle_path + + def test_indirect_cycle(self) -> None: + """A -> B -> C -> A is detected.""" + a = _make_profile("a") + c = _make_profile("c") + b = _make_profile("b", includes=[_make_include(c, order_index=0)]) + a.includes = [_make_include(b, order_index=0)] + c.includes = [_make_include(a, order_index=0)] + + with pytest.raises(ProfileCycleError) as exc_info: + resolve_profile(a) + + assert "a" in exc_info.value.cycle_path + assert "b" in exc_info.value.cycle_path + assert "c" in exc_info.value.cycle_path + + def test_self_cycle(self) -> None: + """A -> A is detected.""" + a = _make_profile("a") + a.includes = [_make_include(a, order_index=0)] + + with pytest.raises(ProfileCycleError) as exc_info: + resolve_profile(a) + + assert exc_info.value.cycle_path == ["a", "a"] + + def test_cycle_does_not_partially_resolve(self) -> None: + """Cycle detection prevents any partial resolution.""" + a = _make_profile("a", env_vars={"A": "a"}) + b = _make_profile("b", env_vars={"B": "b"}) + a.includes = [_make_include(b, order_index=0)] + b.includes = [_make_include(a, order_index=0)] + + with pytest.raises(ProfileCycleError): + resolve_profile(a) + + +class TestResolveProfileDiamond: + """Tests for diamond-shaped include graphs.""" + + def test_diamond_resolution(self) -> None: + """Diamond graph resolves correctly without duplication issues.""" + base = _make_profile("base", env_vars={"BASE": "base"}) + left = _make_profile( + "left", + env_vars={"LEFT": "left"}, + includes=[_make_include(base, order_index=0)], + ) + right = _make_profile( + "right", + env_vars={"RIGHT": "right"}, + includes=[_make_include(base, order_index=0)], + ) + top = _make_profile( + "top", + env_vars={"TOP": "top"}, + includes=[ + _make_include(left, order_index=0), + _make_include(right, order_index=1), + ], + ) + result = resolve_profile(top) + + # base should appear once (via left, then right skips because visited) + assert result.resolution_order == ["top", "left", "base", "right"] + assert result.environment_variables == { + "TOP": "top", + "LEFT": "left", + "RIGHT": "right", + "BASE": "base", + } + + def test_diamond_override(self) -> None: + """Diamond graph with conflicting overrides resolves correctly.""" + base = _make_profile("base", env_vars={"KEY": "base"}) + left = _make_profile( + "left", + env_vars={"KEY": "left"}, + includes=[_make_include(base, order_index=0)], + ) + right = _make_profile( + "right", + env_vars={"KEY": "right"}, + includes=[_make_include(base, order_index=0)], + ) + top = _make_profile( + "top", + includes=[ + _make_include(left, order_index=0), + _make_include(right, order_index=1), + ], + ) + result = resolve_profile(top) + + # right wins because it's later + assert result.environment_variables["KEY"] == "right" + assert result.env_var_sources["KEY"] == ["base", "left", "right"] + # Note: base appears once because visited set skips duplicate resolution in diamond graphs