diff --git a/alfred/settings_bootstrap.py b/alfred/settings_bootstrap.py index 6dfbe16..a66564d 100644 --- a/alfred/settings_bootstrap.py +++ b/alfred/settings_bootstrap.py @@ -241,16 +241,18 @@ class SettingsBootstrap: else: raise ValueError(f"{definition.name} is required but not found in .env") - # Convert type + # Convert type (only if value is a string from .env) match definition.type: case SettingType.INTEGER: - return int(value) + return int(value) if not isinstance(value, int) else value case SettingType.FLOAT: - return float(value) + return float(value) if not isinstance(value, float) else value case SettingType.BOOLEAN: - return value.lower() in ("true", "1", "yes") + if isinstance(value, bool): + return value + return str(value).lower() in ("true", "1", "yes") case _: - return value + return str(value) if not isinstance(value, str) else value def _resolve_generated(self, definition: SettingDefinition) -> str: """Resolve generated secret.""" @@ -307,49 +309,83 @@ class SettingsBootstrap: ) def _write_env(self) -> None: - """Write .env file.""" + """ + Write .env file using .env.example as template. + + This preserves the structure, comments, and formatting of .env.example + while updating only the values of variables defined in the schema. + Custom variables from existing .env are appended at the end. + """ print("📝 Writing .env...") - lines = [] - lines.append("# Auto-generated by bootstrap - DO NOT EDIT MANUALLY\n") - lines.append("# Edit pyproject.toml [tool.alfred.settings_schema] instead\n") - lines.append("\n") + # Check if .env.example exists + if not self.source.env_example_path.exists(): + raise FileNotFoundError( + f".env.example not found: {self.source.env_example_path}" + ) - # Group by category - categories = {} - for definition in self.schema: - if definition.category not in categories: - categories[definition.category] = [] - categories[definition.category].append(definition) + # Read .env.example as template + with open(self.source.env_example_path, encoding="utf-8") as f: + template_lines = f.readlines() - # Write by category - for category, definitions in sorted(categories.items()): - lines.append(f"# --- {category.upper()} ---\n") - for definition in definitions: - # Skip computed settings (they're not in .env) - if definition.source == SettingSource.COMPUTED: - continue + # Track which keys we've processed from .env.example + processed_keys = set() - value = self.resolved_settings.get(definition.name, "") - if definition.description: - lines.append(f"# {definition.description}\n") - lines.append(f"{definition.name}={value}\n") - lines.append("\n") + # Process template line by line + output_lines = [] + for line in template_lines: + stripped = line.strip() - # Write computed settings at the end - computed_defs = [d for d in self.schema if d.source == SettingSource.COMPUTED] - if computed_defs: - lines.append("# --- COMPUTED (auto-generated) ---\n") - for definition in computed_defs: - value = self.resolved_settings.get(definition.name, "") - if definition.description: - lines.append(f"# {definition.description}\n") - lines.append(f"{definition.name}={value}\n") + # Keep comments and empty lines as-is + if not stripped or stripped.startswith("#"): + output_lines.append(line) + continue + # Check if line contains a variable assignment + if "=" in line: + key, _ = line.split("=", 1) + key = key.strip() + processed_keys.add(key) + + # Check if this variable is in our schema + definition = self.schema.get(key) + + if definition: + # Update with resolved value (including computed settings) + value = self.resolved_settings.get(key, "") + + # Convert Python booleans to lowercase for .env compatibility + if isinstance(value, bool): + value = "true" if value else "false" + + output_lines.append(f"{key}={value}\n") + # Variable not in schema + # If it exists in current .env, use that value, otherwise keep template + elif key in self.existing_env: + output_lines.append(f"{key}={self.existing_env[key]}\n") + else: + output_lines.append(line) + else: + # Keep any other lines as-is + output_lines.append(line) + + # Append custom variables from existing .env that aren't in .env.example + custom_vars = { + k: v for k, v in self.existing_env.items() if k not in processed_keys + } + if custom_vars: + output_lines.append("\n# --- CUSTOM VARIABLES ---\n") + output_lines.append("# Variables added manually (not in .env.example)\n") + for key, value in sorted(custom_vars.items()): + output_lines.append(f"{key}={value}\n") + + # Write updated .env with open(self.source.env_path, "w", encoding="utf-8") as f: - f.writelines(lines) + f.writelines(output_lines) - print(f"✅ {self.source.env_path.name} written") + print(f"✅ {self.source.env_path.name} written (preserving template structure)") + if custom_vars: + print(f" â„šī¸ Preserved {len(custom_vars)} custom variable(s)") def _write_env_make(self) -> None: """Write .env.make for Makefile.""" diff --git a/tests/test_settings_bootstrap_advanced.py b/tests/test_settings_bootstrap_advanced.py new file mode 100644 index 0000000..747e96b --- /dev/null +++ b/tests/test_settings_bootstrap_advanced.py @@ -0,0 +1,741 @@ +"""Advanced tests for settings bootstrap - template preservation and edge cases.""" + +import pytest + +from alfred.settings_bootstrap import ConfigSource, SettingsBootstrap + + +@pytest.fixture +def test_toml_with_all_types(tmp_path): + """Create test TOML with all setting types.""" + toml_content = """ +[tool.poetry] +name = "test" +version = "1.0.0" + +[tool.poetry.dependencies] +python = "^3.14" + +[tool.alfred.settings_schema.STRING_VAR] +type = "string" +source = "env" +default = "default_string" +description = "String variable" +category = "test" + +[tool.alfred.settings_schema.INT_VAR] +type = "integer" +source = "env" +default = 42 +description = "Integer variable" +category = "test" + +[tool.alfred.settings_schema.FLOAT_VAR] +type = "float" +source = "env" +default = 3.14 +description = "Float variable" +category = "test" + +[tool.alfred.settings_schema.BOOL_VAR] +type = "boolean" +source = "env" +default = true +description = "Boolean variable" +category = "test" + +[tool.alfred.settings_schema.SECRET_VAR] +type = "secret" +source = "generated" +secret_rule = "16:hex" +description = "Secret variable" +category = "security" + +[tool.alfred.settings_schema.COMPUTED_VAR] +type = "computed" +source = "computed" +compute_from = ["STRING_VAR", "INT_VAR"] +compute_template = "{STRING_VAR}_{INT_VAR}" +description = "Computed variable" +category = "test" +""" + toml_path = tmp_path / "pyproject.toml" + toml_path.write_text(toml_content) + return tmp_path + + +@pytest.fixture +def test_env_example(tmp_path): + """Create .env.example template.""" + env_example_content = """# Test configuration +STRING_VAR= +INT_VAR= +FLOAT_VAR= + +# Boolean settings +BOOL_VAR= + +# Security +SECRET_VAR= + +# Computed values +COMPUTED_VAR= + +# Custom section +CUSTOM_VAR=custom_value +""" + env_example_path = tmp_path / ".env.example" + env_example_path.write_text(env_example_content) + return env_example_path + + +class TestTemplatePreservation: + """Test that .env.example template structure is preserved.""" + + def test_preserves_comments(self, test_toml_with_all_types, test_env_example): + """Test that comments from .env.example are preserved.""" + from alfred.settings_schema import load_schema + + schema = load_schema(test_toml_with_all_types) + source = ConfigSource.from_base_dir(test_toml_with_all_types) + + bootstrapper = SettingsBootstrap(source, schema) + bootstrapper.bootstrap() + + env_content = source.env_path.read_text() + + # Check comments are preserved + assert "# Test configuration" in env_content + assert "# Boolean settings" in env_content + assert "# Security" in env_content + assert "# Computed values" in env_content + + def test_preserves_empty_lines(self, test_toml_with_all_types, test_env_example): + """Test that empty lines from .env.example are preserved.""" + from alfred.settings_schema import load_schema + + schema = load_schema(test_toml_with_all_types) + source = ConfigSource.from_base_dir(test_toml_with_all_types) + + bootstrapper = SettingsBootstrap(source, schema) + bootstrapper.bootstrap() + + env_content = source.env_path.read_text() + lines = env_content.split("\n") + + # Check there are empty lines (structure preserved) + assert "" in lines + + def test_preserves_variable_order(self, test_toml_with_all_types, test_env_example): + """Test that variable order from .env.example is preserved.""" + from alfred.settings_schema import load_schema + + schema = load_schema(test_toml_with_all_types) + source = ConfigSource.from_base_dir(test_toml_with_all_types) + + bootstrapper = SettingsBootstrap(source, schema) + bootstrapper.bootstrap() + + env_content = source.env_path.read_text() + + # Check order is preserved + string_pos = env_content.find("STRING_VAR=") + int_pos = env_content.find("INT_VAR=") + float_pos = env_content.find("FLOAT_VAR=") + bool_pos = env_content.find("BOOL_VAR=") + + assert string_pos < int_pos < float_pos < bool_pos + + +class TestSecretPreservation: + """Test that secrets are never overwritten.""" + + def test_preserves_existing_secrets( + self, test_toml_with_all_types, test_env_example + ): + """Test that existing secrets are preserved across multiple bootstraps.""" + from alfred.settings_schema import load_schema + + schema = load_schema(test_toml_with_all_types) + source = ConfigSource.from_base_dir(test_toml_with_all_types) + + # First bootstrap - generates secret + bootstrapper1 = SettingsBootstrap(source, schema) + bootstrapper1.bootstrap() + + env_content_1 = source.env_path.read_text() + secret_1 = [ + line.split("=")[1] + for line in env_content_1.split("\n") + if line.startswith("SECRET_VAR=") + ][0] + + # Second bootstrap - should preserve secret + bootstrapper2 = SettingsBootstrap(source, schema) + bootstrapper2.bootstrap() + + env_content_2 = source.env_path.read_text() + secret_2 = [ + line.split("=")[1] + for line in env_content_2.split("\n") + if line.startswith("SECRET_VAR=") + ][0] + + assert secret_1 == secret_2 + assert len(secret_1) == 32 # 16 hex bytes + + def test_multiple_secrets_preserved(self, tmp_path): + """Test that multiple secrets are all preserved.""" + toml_content = """ +[tool.poetry] +name = "test" +version = "1.0.0" + +[tool.alfred.settings_schema.SECRET_1] +type = "secret" +source = "generated" +secret_rule = "16:hex" +category = "security" + +[tool.alfred.settings_schema.SECRET_2] +type = "secret" +source = "generated" +secret_rule = "32:b64" +category = "security" + +[tool.alfred.settings_schema.SECRET_3] +type = "secret" +source = "generated" +secret_rule = "8:hex" +category = "security" +""" + (tmp_path / "pyproject.toml").write_text(toml_content) + (tmp_path / ".env.example").write_text("SECRET_1=\nSECRET_2=\nSECRET_3=\n") + + from alfred.settings_schema import load_schema + + schema = load_schema(tmp_path) + source = ConfigSource.from_base_dir(tmp_path) + + # First bootstrap + bootstrapper1 = SettingsBootstrap(source, schema) + bootstrapper1.bootstrap() + + env_content_1 = source.env_path.read_text() + + # Second bootstrap + bootstrapper2 = SettingsBootstrap(source, schema) + bootstrapper2.bootstrap() + + env_content_2 = source.env_path.read_text() + + # All secrets should be identical + assert env_content_1 == env_content_2 + + +class TestCustomVariables: + """Test that custom variables (not in schema) are preserved.""" + + def test_preserves_custom_variables_from_env( + self, test_toml_with_all_types, test_env_example + ): + """Test that custom variables added to .env are preserved.""" + from alfred.settings_schema import load_schema + + schema = load_schema(test_toml_with_all_types) + source = ConfigSource.from_base_dir(test_toml_with_all_types) + + # First bootstrap + bootstrapper1 = SettingsBootstrap(source, schema) + bootstrapper1.bootstrap() + + # Add custom variables to .env + with open(source.env_path, "a") as f: + f.write("\nMY_CUSTOM_VAR=custom_value\n") + f.write("ANOTHER_CUSTOM=another_value\n") + + # Second bootstrap + bootstrapper2 = SettingsBootstrap(source, schema) + bootstrapper2.bootstrap() + + env_content = source.env_path.read_text() + + # Custom variables should be preserved + assert "MY_CUSTOM_VAR=custom_value" in env_content + assert "ANOTHER_CUSTOM=another_value" in env_content + + def test_custom_variables_in_dedicated_section( + self, test_toml_with_all_types, test_env_example + ): + """Test that custom variables are placed in a dedicated section.""" + from alfred.settings_schema import load_schema + + schema = load_schema(test_toml_with_all_types) + source = ConfigSource.from_base_dir(test_toml_with_all_types) + + # Create .env with custom variable + source.env_path.write_text("MY_CUSTOM_VAR=test\n") + + bootstrapper = SettingsBootstrap(source, schema) + bootstrapper.bootstrap() + + env_content = source.env_path.read_text() + + # Check custom section exists + assert "# --- CUSTOM VARIABLES ---" in env_content + assert "MY_CUSTOM_VAR=test" in env_content + + def test_preserves_custom_from_env_example( + self, test_toml_with_all_types, test_env_example + ): + """Test that custom variables in .env.example are preserved.""" + from alfred.settings_schema import load_schema + + schema = load_schema(test_toml_with_all_types) + source = ConfigSource.from_base_dir(test_toml_with_all_types) + + bootstrapper = SettingsBootstrap(source, schema) + bootstrapper.bootstrap() + + env_content = source.env_path.read_text() + + # CUSTOM_VAR is in .env.example but not in schema + assert "CUSTOM_VAR=custom_value" in env_content + + +class TestBooleanHandling: + """Test that booleans are handled correctly.""" + + def test_booleans_written_as_lowercase( + self, test_toml_with_all_types, test_env_example + ): + """Test that Python booleans are written as lowercase strings.""" + from alfred.settings_schema import load_schema + + schema = load_schema(test_toml_with_all_types) + source = ConfigSource.from_base_dir(test_toml_with_all_types) + + bootstrapper = SettingsBootstrap(source, schema) + bootstrapper.bootstrap() + + env_content = source.env_path.read_text() + + # Boolean should be lowercase + assert "BOOL_VAR=true" in env_content + assert "BOOL_VAR=True" not in env_content + assert "BOOL_VAR=TRUE" not in env_content + + def test_false_boolean_written_as_lowercase(self, tmp_path): + """Test that False is written as 'false'.""" + toml_content = """ +[tool.poetry] +name = "test" +version = "1.0.0" + +[tool.alfred.settings_schema.BOOL_FALSE] +type = "boolean" +source = "env" +default = false +category = "test" +""" + (tmp_path / "pyproject.toml").write_text(toml_content) + (tmp_path / ".env.example").write_text("BOOL_FALSE=\n") + + from alfred.settings_schema import load_schema + + schema = load_schema(tmp_path) + source = ConfigSource.from_base_dir(tmp_path) + + bootstrapper = SettingsBootstrap(source, schema) + bootstrapper.bootstrap() + + env_content = source.env_path.read_text() + + assert "BOOL_FALSE=false" in env_content + assert "BOOL_FALSE=False" not in env_content + + def test_boolean_parsing_from_env(self, tmp_path): + """Test that various boolean formats are parsed correctly.""" + toml_content = """ +[tool.poetry] +name = "test" +version = "1.0.0" + +[tool.alfred.settings_schema.BOOL_VAR] +type = "boolean" +source = "env" +default = false +category = "test" +""" + (tmp_path / "pyproject.toml").write_text(toml_content) + (tmp_path / ".env.example").write_text("BOOL_VAR=\n") + + from alfred.settings_schema import load_schema + + schema = load_schema(tmp_path) + source = ConfigSource.from_base_dir(tmp_path) + + # Test different boolean formats + test_cases = [ + ("true", True), + ("TRUE", True), + ("True", True), + ("1", True), + ("yes", True), + ("false", False), + ("FALSE", False), + ("False", False), + ("0", False), + ("no", False), + ] + + for input_val, expected in test_cases: + source.env_path.write_text(f"BOOL_VAR={input_val}\n") + + bootstrapper = SettingsBootstrap(source, schema) + bootstrapper._load_sources() + bootstrapper._resolve_settings() + + assert bootstrapper.resolved_settings["BOOL_VAR"] == expected + + +class TestComputedVariables: + """Test that computed variables are calculated correctly.""" + + def test_computed_variables_written_to_env( + self, test_toml_with_all_types, test_env_example + ): + """Test that computed variables are written with their computed values.""" + from alfred.settings_schema import load_schema + + schema = load_schema(test_toml_with_all_types) + source = ConfigSource.from_base_dir(test_toml_with_all_types) + + bootstrapper = SettingsBootstrap(source, schema) + bootstrapper.bootstrap() + + env_content = source.env_path.read_text() + + # Computed variable should have its computed value + assert "COMPUTED_VAR=default_string_42" in env_content + + def test_computed_uri_example(self, tmp_path): + """Test computed URI (like MONGO_URI) is written correctly.""" + toml_content = """ +[tool.poetry] +name = "test" +version = "1.0.0" + +[tool.alfred.settings_schema.DB_HOST] +type = "string" +source = "env" +default = "localhost" +category = "database" + +[tool.alfred.settings_schema.DB_PORT] +type = "integer" +source = "env" +default = 5432 +category = "database" + +[tool.alfred.settings_schema.DB_USER] +type = "string" +source = "env" +default = "user" +category = "database" + +[tool.alfred.settings_schema.DB_PASSWORD] +type = "secret" +source = "generated" +secret_rule = "16:hex" +category = "security" + +[tool.alfred.settings_schema.DB_URI] +type = "computed" +source = "computed" +compute_from = ["DB_USER", "DB_PASSWORD", "DB_HOST", "DB_PORT"] +compute_template = "postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/db" +category = "database" +""" + (tmp_path / "pyproject.toml").write_text(toml_content) + (tmp_path / ".env.example").write_text( + "DB_HOST=\nDB_PORT=\nDB_USER=\nDB_PASSWORD=\nDB_URI=\n" + ) + + from alfred.settings_schema import load_schema + + schema = load_schema(tmp_path) + source = ConfigSource.from_base_dir(tmp_path) + + bootstrapper = SettingsBootstrap(source, schema) + bootstrapper.bootstrap() + + env_content = source.env_path.read_text() + + # Check URI is computed and written + assert "DB_URI=postgresql://user:" in env_content + assert "@localhost:5432/db" in env_content + + # Extract password from URI to verify it's the same as DB_PASSWORD + import re + + uri_match = re.search(r"DB_URI=postgresql://user:([^@]+)@", env_content) + password_match = re.search(r"DB_PASSWORD=([^\n]+)", env_content) + + assert uri_match and password_match + assert uri_match.group(1) == password_match.group(1) + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_missing_env_example(self, tmp_path): + """Test that missing .env.example raises error.""" + toml_content = """ +[tool.poetry] +name = "test" +version = "1.0.0" + +[tool.alfred.settings_schema.TEST_VAR] +type = "string" +source = "env" +default = "test" +category = "test" +""" + (tmp_path / "pyproject.toml").write_text(toml_content) + + from alfred.settings_schema import load_schema + + schema = load_schema(tmp_path) + source = ConfigSource.from_base_dir(tmp_path) + + bootstrapper = SettingsBootstrap(source, schema) + + with pytest.raises(FileNotFoundError, match=".env.example not found"): + bootstrapper.bootstrap() + + def test_empty_env_example(self, tmp_path): + """Test that empty .env.example works.""" + toml_content = """ +[tool.poetry] +name = "test" +version = "1.0.0" + +[tool.alfred.settings_schema.TEST_VAR] +type = "string" +source = "env" +default = "test" +category = "test" +""" + (tmp_path / "pyproject.toml").write_text(toml_content) + (tmp_path / ".env.example").write_text("") + + from alfred.settings_schema import load_schema + + schema = load_schema(tmp_path) + source = ConfigSource.from_base_dir(tmp_path) + + bootstrapper = SettingsBootstrap(source, schema) + bootstrapper.bootstrap() + + # Should create .env even if .env.example is empty + assert source.env_path.exists() + + def test_variable_with_equals_in_value(self, tmp_path): + """Test that variables with '=' in their value are handled correctly.""" + toml_content = """ +[tool.poetry] +name = "test" +version = "1.0.0" + +[tool.alfred.settings_schema.URL_VAR] +type = "string" +source = "env" +default = "http://example.com?key=value" +category = "test" +""" + (tmp_path / "pyproject.toml").write_text(toml_content) + (tmp_path / ".env.example").write_text("URL_VAR=\n") + + from alfred.settings_schema import load_schema + + schema = load_schema(tmp_path) + source = ConfigSource.from_base_dir(tmp_path) + + bootstrapper = SettingsBootstrap(source, schema) + bootstrapper.bootstrap() + + env_content = source.env_path.read_text() + + assert "URL_VAR=http://example.com?key=value" in env_content + + def test_preserves_existing_values_on_update(self, tmp_path): + """Test that existing values are preserved when updating.""" + toml_content = """ +[tool.poetry] +name = "test" +version = "1.0.0" + +[tool.alfred.settings_schema.VAR1] +type = "string" +source = "env" +default = "default1" +category = "test" + +[tool.alfred.settings_schema.VAR2] +type = "string" +source = "env" +default = "default2" +category = "test" +""" + (tmp_path / "pyproject.toml").write_text(toml_content) + (tmp_path / ".env.example").write_text("VAR1=\nVAR2=\n") + + from alfred.settings_schema import load_schema + + schema = load_schema(tmp_path) + source = ConfigSource.from_base_dir(tmp_path) + + # First bootstrap + bootstrapper1 = SettingsBootstrap(source, schema) + bootstrapper1.bootstrap() + + # Modify values + source.env_path.write_text("VAR1=custom1\nVAR2=custom2\n") + + # Second bootstrap + bootstrapper2 = SettingsBootstrap(source, schema) + bootstrapper2.bootstrap() + + env_content = source.env_path.read_text() + + # Custom values should be preserved + assert "VAR1=custom1" in env_content + assert "VAR2=custom2" in env_content + + +class TestIntegration: + """Integration tests with realistic scenarios.""" + + def test_full_workflow_like_alfred(self, tmp_path): + """Test a full workflow similar to Alfred's actual usage.""" + toml_content = """ +[tool.poetry] +name = "alfred" +version = "0.1.7" + +[tool.poetry.dependencies] +python = "^3.14" + +[tool.alfred.settings_schema.ALFRED_VERSION] +type = "string" +source = "toml" +toml_path = "tool.poetry.version" +category = "build" +export_to_env_make = true + +[tool.alfred.settings_schema.HOST] +type = "string" +source = "env" +default = "0.0.0.0" +category = "app" + +[tool.alfred.settings_schema.PORT] +type = "integer" +source = "env" +default = 3080 +category = "app" + +[tool.alfred.settings_schema.JWT_SECRET] +type = "secret" +source = "generated" +secret_rule = "32:hex" +category = "security" + +[tool.alfred.settings_schema.MONGO_HOST] +type = "string" +source = "env" +default = "mongodb" +category = "database" + +[tool.alfred.settings_schema.MONGO_PASSWORD] +type = "secret" +source = "generated" +secret_rule = "16:hex" +category = "security" + +[tool.alfred.settings_schema.MONGO_URI] +type = "computed" +source = "computed" +compute_from = ["MONGO_HOST", "MONGO_PASSWORD"] +compute_template = "mongodb://user:{MONGO_PASSWORD}@{MONGO_HOST}:27017/db" +category = "database" + +[tool.alfred.settings_schema.DEBUG_MODE] +type = "boolean" +source = "env" +default = false +category = "app" +""" + (tmp_path / "pyproject.toml").write_text(toml_content) + + env_example_content = """# Application settings +HOST=0.0.0.0 +PORT=3080 +DEBUG_MODE=false + +# Security +JWT_SECRET= + +# Database +MONGO_HOST=mongodb +MONGO_PASSWORD= +MONGO_URI= + +# Build info +ALFRED_VERSION= +""" + (tmp_path / ".env.example").write_text(env_example_content) + + from alfred.settings_schema import load_schema + + schema = load_schema(tmp_path) + source = ConfigSource.from_base_dir(tmp_path) + + # First bootstrap + bootstrapper1 = SettingsBootstrap(source, schema) + bootstrapper1.bootstrap() + + env_content_1 = source.env_path.read_text() + + # Verify structure + assert "# Application settings" in env_content_1 + assert "HOST=0.0.0.0" in env_content_1 + assert "PORT=3080" in env_content_1 + assert "DEBUG_MODE=false" in env_content_1 # lowercase! + assert "ALFRED_VERSION=0.1.7" in env_content_1 + assert "JWT_SECRET=" in env_content_1 + assert len([l for l in env_content_1.split("\n") if "JWT_SECRET=" in l][0]) > 20 + assert "MONGO_URI=mongodb://user:" in env_content_1 + + # Second bootstrap - should preserve everything + bootstrapper2 = SettingsBootstrap(source, schema) + bootstrapper2.bootstrap() + + env_content_2 = source.env_path.read_text() + + # Everything should be identical + assert env_content_1 == env_content_2 + + # Add custom variable + with open(source.env_path, "a") as f: + f.write("\nMY_CUSTOM_SETTING=test123\n") + + # Third bootstrap - should preserve custom + bootstrapper3 = SettingsBootstrap(source, schema) + bootstrapper3.bootstrap() + + env_content_3 = source.env_path.read_text() + + assert "MY_CUSTOM_SETTING=test123" in env_content_3 + assert "# --- CUSTOM VARIABLES ---" in env_content_3