diff --git a/src/openjarvis/cli/config_cmd.py b/src/openjarvis/cli/config_cmd.py index 4d9a9284..8c95ae31 100644 --- a/src/openjarvis/cli/config_cmd.py +++ b/src/openjarvis/cli/config_cmd.py @@ -158,7 +158,7 @@ def _show_toml_config(console: Console, config_path: Path) -> None: console.print(f"[dim]Loading config from: {config_path}[/dim]") if config_path.exists(): - config_content = config_path.read_text() + config_content = config_path.read_text(encoding="utf-8") syntax = Syntax(config_content, "toml", theme="monokai", line_numbers=True) console.print(Panel(syntax, title="Config File", border_style="cyan")) else: @@ -170,7 +170,7 @@ def _show_json_config(console: Console, config_path: Path) -> None: console.print(f"[dim]Loading config from: {config_path}[/dim]") if config_path.exists(): - config_content = config_path.read_text() + config_content = config_path.read_text(encoding="utf-8") try: import tomllib # Python 3.11+ @@ -375,7 +375,7 @@ def set_config(key: str, value: str) -> None: os.environ.get("OPENJARVIS_CONFIG", DEFAULT_CONFIG_DIR / "config.toml") ) if config_path.exists(): - doc = tomlkit.parse(config_path.read_text()) + doc = tomlkit.parse(config_path.read_text(encoding="utf-8")) else: doc = tomlkit.document() config_path.parent.mkdir(parents=True, exist_ok=True) @@ -390,7 +390,7 @@ def set_config(key: str, value: str) -> None: current[parts[-1]] = typed_value # Write back - config_path.write_text(tomlkit.dumps(doc)) + config_path.write_text(tomlkit.dumps(doc), encoding="utf-8") console.print(f"[green]Set[/green] {key} = {value!r}") diff --git a/src/openjarvis/cli/init_cmd.py b/src/openjarvis/cli/init_cmd.py index 869c2746..2866b098 100644 --- a/src/openjarvis/cli/init_cmd.py +++ b/src/openjarvis/cli/init_cmd.py @@ -344,7 +344,9 @@ def init( console.print(f" Looked in: {examples_dir}") raise SystemExit(1) DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True) - DEFAULT_CONFIG_PATH.write_text(preset_path.read_text()) + DEFAULT_CONFIG_PATH.write_text( + preset_path.read_text(encoding="utf-8"), encoding="utf-8" + ) console.print( f"[green]Preset '{preset}' installed to {DEFAULT_CONFIG_PATH}[/green]" ) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 84e2daac..0ec3c12d 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -138,6 +138,38 @@ class TestCLI: content = config_path.read_text() assert "[engine]" in content + def test_init_preset_uses_utf8_for_config_copy(self, tmp_path: Path) -> None: + """Preset installation reads and writes shipped TOML as UTF-8.""" + config_dir = tmp_path / ".openjarvis" + config_path = config_dir / "config.toml" + original_read_text = Path.read_text + original_write_text = Path.write_text + + def read_text(path: Path, *args: object, **kwargs: object) -> str: + if path.name == "chat-simple.toml": + assert kwargs.get("encoding") == "utf-8" + return original_read_text(path, *args, **kwargs) + + def write_text(path: Path, data: str, *args: object, **kwargs: object) -> int: + if path == config_path: + assert kwargs.get("encoding") == "utf-8" + return original_write_text(path, data, *args, **kwargs) + + with ( + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir), + mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path), + mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text), + mock.patch.object( + Path, "write_text", autospec=True, side_effect=write_text + ), + ): + result = CliRunner().invoke(cli, ["init", "--preset", "chat-simple"]) + + assert result.exit_code == 0 + assert "lightweight conversational AI" in config_path.read_text( + encoding="utf-8" + ) + class TestStartupResilience: """Importing the CLI must not force heavy/native deps (#404, #309). diff --git a/tests/cli/test_config_cmd.py b/tests/cli/test_config_cmd.py index d2e9d249..b11fa74a 100644 --- a/tests/cli/test_config_cmd.py +++ b/tests/cli/test_config_cmd.py @@ -4,6 +4,7 @@ from __future__ import annotations import json from pathlib import Path +from unittest import mock import pytest from click.testing import CliRunner @@ -109,18 +110,34 @@ temperature = 0.7 except json.JSONDecodeError: pytest.fail(f"Output is not valid JSON: {result.output}") - def test_config_show_toml_displays_raw_content(self, tmp_path: Path) -> None: - """Test that config show toml displays the raw TOML content.""" + @pytest.mark.parametrize("output_format", ["toml", "json"]) + def test_config_show_uses_utf8_for_config_file( + self, tmp_path: Path, output_format: str + ) -> None: + """Test that config show reads UTF-8 config files explicitly.""" # Create a temporary config file config_file = tmp_path / "test_config.toml" - config_file.write_text('[engine]\ndefault = "ollama"\n') - - result = CliRunner().invoke( - cli, ["config", "show", "toml", "--path", str(config_file)] + config_file.write_text( + '# Preset comment — stored as UTF-8\n[engine]\ndefault = "ollama"\n', + encoding="utf-8", ) + original_read_text = Path.read_text + + def read_text(path: Path, *args: object, **kwargs: object) -> str: + if path == config_file: + assert kwargs.get("encoding") == "utf-8" + return original_read_text(path, *args, **kwargs) + + with mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text): + result = CliRunner().invoke( + cli, ["config", "show", output_format, "--path", str(config_file)] + ) assert result.exit_code == 0 - assert "[engine]" in result.output + if output_format == "toml": + assert "[engine]" in result.output + else: + assert '"engine"' in result.output assert "ollama" in result.output def test_config_show_json_displays_parsed_content(self, tmp_path: Path) -> None: diff --git a/tests/cli/test_config_set.py b/tests/cli/test_config_set.py index 54162256..7fbe47d2 100644 --- a/tests/cli/test_config_set.py +++ b/tests/cli/test_config_set.py @@ -60,6 +60,42 @@ class TestConfigSet: assert "vllm" in content assert "qwen2.5:3b" in content + def test_set_uses_utf8_for_existing_config(self, tmp_path: Path) -> None: + """config set preserves a UTF-8 config regardless of the system locale.""" + config_file = tmp_path / "config.toml" + config_file.write_text( + '# Preset comment — stored as UTF-8\n[engine]\ndefault = "ollama"\n', + encoding="utf-8", + ) + original_read_text = Path.read_text + original_write_text = Path.write_text + + def read_text(path: Path, *args: object, **kwargs: object) -> str: + if path == config_file: + assert kwargs.get("encoding") == "utf-8" + return original_read_text(path, *args, **kwargs) + + def write_text(path: Path, data: str, *args: object, **kwargs: object) -> int: + if path == config_file: + assert kwargs.get("encoding") == "utf-8" + return original_write_text(path, data, *args, **kwargs) + + with ( + mock.patch.dict(os.environ, {"OPENJARVIS_CONFIG": str(config_file)}), + mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text), + mock.patch.object( + Path, "write_text", autospec=True, side_effect=write_text + ), + ): + result = CliRunner().invoke( + cli, ["config", "set", "engine.default", "vllm"] + ) + + assert result.exit_code == 0 + content = config_file.read_text(encoding="utf-8") + assert "Preset comment — stored as UTF-8" in content + assert "vllm" in content + def test_set_invalid_key_rejected(self, tmp_path: Path) -> None: """config set rejects unknown keys.""" config_file = tmp_path / "config.toml"