Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions src/apm_cli/config.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
"""Configuration management for APM."""

import os
import json
import os
from typing import Optional


CONFIG_DIR = os.path.expanduser("~/.apm")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")

Expand All @@ -15,17 +14,17 @@ def ensure_config_exists():
"""Ensure the configuration directory and file exist."""
if not os.path.exists(CONFIG_DIR):
os.makedirs(CONFIG_DIR)

if not os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "w") as f:
json.dump({"default_client": "vscode"}, f)


def get_config():
"""Get the current configuration.

Results are cached for the lifetime of the process.

Returns:
dict: Current configuration.
"""
Expand All @@ -46,22 +45,22 @@ def _invalidate_config_cache():

def update_config(updates):
"""Update the configuration with new values.

Args:
updates (dict): Dictionary of configuration values to update.
"""
_invalidate_config_cache()
config = get_config()
config.update(updates)

with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2)
_invalidate_config_cache()


def get_default_client():
"""Get the default MCP client.

Returns:
str: Default MCP client type.
"""
Expand All @@ -70,8 +69,26 @@ def get_default_client():

def set_default_client(client_type):
"""Set the default MCP client.

Args:
client_type (str): Type of client to set as default.
"""
update_config({"default_client": client_type})


def get_auto_integrate() -> bool:
"""Get the auto-integrate setting.

Returns:
bool: Whether auto-integration is enabled (default: True).
"""
return get_config().get("auto_integrate", True)


def set_auto_integrate(enabled: bool) -> None:
"""Set the auto-integrate setting.

Args:
enabled: Whether to enable auto-integration.
"""
update_config({"auto_integrate": enabled})
297 changes: 297 additions & 0 deletions tests/unit/test_config_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
"""Tests for the apm config command."""

import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from click.testing import CliRunner

from apm_cli.commands.config import config


class TestConfigShow:
"""Tests for `apm config` (show current configuration)."""

def setup_method(self):
self.runner = CliRunner()
self.original_dir = os.getcwd()

def teardown_method(self):
try:
os.chdir(self.original_dir)
except (FileNotFoundError, OSError):
pass

def test_config_show_outside_project(self):
"""Show config when not in an APM project directory."""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)
with patch("apm_cli.commands.config.get_version", return_value="1.2.3"):
result = self.runner.invoke(config, [])
assert result.exit_code == 0

def test_config_show_inside_project(self):
"""Show config when apm.yml is present."""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)
apm_yml = Path(tmp_dir) / "apm.yml"
apm_yml.write_text("name: myproject\nversion: '0.1'\n")
with (
patch("apm_cli.commands.config.get_version", return_value="1.2.3"),
patch(
"apm_cli.commands.config._load_apm_config",
return_value={
"name": "myproject",
"version": "0.1",
"entrypoint": "main.md",
},
),
):
result = self.runner.invoke(config, [])
assert result.exit_code == 0

def test_config_show_inside_project_with_compilation(self):
"""Show config when apm.yml has compilation settings."""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)
apm_yml = Path(tmp_dir) / "apm.yml"
apm_yml.write_text("name: myproject\ncompilation:\n output: AGENTS.md\n")
apm_config = {
"name": "myproject",
"version": "0.1",
"compilation": {
"output": "AGENTS.md",
"chatmode": "copilot",
"resolve_links": False,
},
"dependencies": {"mcp": ["server1", "server2"]},
}
with (
patch("apm_cli.commands.config.get_version", return_value="1.2.3"),
patch(
"apm_cli.commands.config._load_apm_config", return_value=apm_config
),
):
result = self.runner.invoke(config, [])
assert result.exit_code == 0

def test_config_show_rich_import_error_fallback(self):
"""Fallback plain-text display when Rich (rich.table.Table) is unavailable."""
import rich.table

mock_table_cls = MagicMock(side_effect=ImportError("no rich"))
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)
with (
patch("apm_cli.commands.config.get_version", return_value="0.9.0"),
patch.object(rich.table, "Table", side_effect=ImportError("no rich")),
):
result = self.runner.invoke(config, [])
assert result.exit_code == 0

def test_config_show_fallback_inside_project(self):
"""Fallback display inside a project directory when console/table unavailable."""
import rich.table

with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)
apm_yml = Path(tmp_dir) / "apm.yml"
apm_yml.write_text("name: proj\n")
apm_config = {
"name": "proj",
"version": "1.0",
"entrypoint": None,
"dependencies": {"mcp": []},
}
with (
patch("apm_cli.commands.config.get_version", return_value="0.9.0"),
patch(
"apm_cli.commands.config._load_apm_config", return_value=apm_config
),
patch.object(rich.table, "Table", side_effect=ImportError("no rich")),
):
result = self.runner.invoke(config, [])
assert result.exit_code == 0


class TestConfigSet:
"""Tests for `apm config set <key> <value>`."""

def setup_method(self):
self.runner = CliRunner()

def test_set_auto_integrate_true(self):
"""Enable auto-integration."""
with (
patch("apm_cli.config.set_auto_integrate") as mock_set,
patch("apm_cli.commands.config._rich_success"),
):
result = self.runner.invoke(config, ["set", "auto-integrate", "true"])
assert result.exit_code == 0
mock_set.assert_called_once_with(True)

def test_set_auto_integrate_yes(self):
"""Enable auto-integration with 'yes' alias."""
with (
patch("apm_cli.config.set_auto_integrate") as mock_set,
patch("apm_cli.commands.config._rich_success"),
):
result = self.runner.invoke(config, ["set", "auto-integrate", "yes"])
assert result.exit_code == 0
mock_set.assert_called_once_with(True)

def test_set_auto_integrate_one(self):
"""Enable auto-integration with '1' alias."""
with (
patch("apm_cli.config.set_auto_integrate") as mock_set,
patch("apm_cli.commands.config._rich_success"),
):
result = self.runner.invoke(config, ["set", "auto-integrate", "1"])
assert result.exit_code == 0
mock_set.assert_called_once_with(True)

def test_set_auto_integrate_false(self):
"""Disable auto-integration."""
with (
patch("apm_cli.config.set_auto_integrate") as mock_set,
patch("apm_cli.commands.config._rich_success"),
):
result = self.runner.invoke(config, ["set", "auto-integrate", "false"])
assert result.exit_code == 0
mock_set.assert_called_once_with(False)

def test_set_auto_integrate_no(self):
"""Disable auto-integration with 'no' alias."""
with (
patch("apm_cli.config.set_auto_integrate") as mock_set,
patch("apm_cli.commands.config._rich_success"),
):
result = self.runner.invoke(config, ["set", "auto-integrate", "no"])
assert result.exit_code == 0
mock_set.assert_called_once_with(False)

def test_set_auto_integrate_zero(self):
"""Disable auto-integration with '0' alias."""
with (
patch("apm_cli.config.set_auto_integrate") as mock_set,
patch("apm_cli.commands.config._rich_success"),
):
result = self.runner.invoke(config, ["set", "auto-integrate", "0"])
assert result.exit_code == 0
mock_set.assert_called_once_with(False)

def test_set_auto_integrate_invalid_value(self):
"""Reject an invalid value for auto-integrate."""
with patch("apm_cli.commands.config._rich_error"):
result = self.runner.invoke(config, ["set", "auto-integrate", "maybe"])
assert result.exit_code == 1

def test_set_unknown_key(self):
"""Reject an unknown configuration key."""
with (
patch("apm_cli.commands.config._rich_error"),
patch("apm_cli.commands.config._rich_info"),
):
result = self.runner.invoke(config, ["set", "nonexistent", "value"])
assert result.exit_code == 1

def test_set_auto_integrate_case_insensitive(self):
"""Value comparison is case-insensitive."""
with (
patch("apm_cli.config.set_auto_integrate") as mock_set,
patch("apm_cli.commands.config._rich_success"),
):
result = self.runner.invoke(config, ["set", "auto-integrate", "TRUE"])
assert result.exit_code == 0
mock_set.assert_called_once_with(True)


class TestConfigGet:
"""Tests for `apm config get [key]`."""

def setup_method(self):
self.runner = CliRunner()

def test_get_auto_integrate(self):
"""Get the auto-integrate setting."""
with patch("apm_cli.config.get_auto_integrate", return_value=True):
result = self.runner.invoke(config, ["get", "auto-integrate"])
assert result.exit_code == 0
assert "auto-integrate: True" in result.output

def test_get_auto_integrate_disabled(self):
"""Get auto-integrate when disabled."""
with patch("apm_cli.config.get_auto_integrate", return_value=False):
result = self.runner.invoke(config, ["get", "auto-integrate"])
assert result.exit_code == 0
assert "auto-integrate: False" in result.output

def test_get_unknown_key(self):
"""Reject an unknown key."""
with (
patch("apm_cli.commands.config._rich_error"),
patch("apm_cli.commands.config._rich_info"),
):
result = self.runner.invoke(config, ["get", "nonexistent"])
assert result.exit_code == 1

def test_get_all_config(self):
"""Show all config when no key is provided."""
fake_config = {"auto_integrate": True, "default_client": "vscode"}
with (
patch("apm_cli.config.get_config", return_value=fake_config),
patch("apm_cli.commands.config._rich_info"),
):
result = self.runner.invoke(config, ["get"])
assert result.exit_code == 0
assert "auto-integrate: True" in result.output

def test_get_all_config_unknown_key_passthrough(self):
"""Unknown config keys are shown as-is."""
fake_config = {"some_other_key": "value"}
with (
patch("apm_cli.config.get_config", return_value=fake_config),
patch("apm_cli.commands.config._rich_info"),
):
result = self.runner.invoke(config, ["get"])
assert result.exit_code == 0
assert "some_other_key: value" in result.output


class TestAutoIntegrateFunctions:
"""Tests for get_auto_integrate and set_auto_integrate in apm_cli.config."""

def test_get_auto_integrate_default(self):
"""Default value is True when not set."""
import apm_cli.config as cfg_module

with patch.object(cfg_module, "get_config", return_value={}):
assert cfg_module.get_auto_integrate() is True

def test_get_auto_integrate_false(self):
"""Returns False when set to False."""
import apm_cli.config as cfg_module

with patch.object(
cfg_module, "get_config", return_value={"auto_integrate": False}
):
assert cfg_module.get_auto_integrate() is False

def test_set_auto_integrate_calls_update_config(self):
"""set_auto_integrate delegates to update_config."""
import apm_cli.config as cfg_module

with patch.object(cfg_module, "update_config") as mock_update:
cfg_module.set_auto_integrate(True)
mock_update.assert_called_once_with({"auto_integrate": True})

def test_set_auto_integrate_false_calls_update_config(self):
"""set_auto_integrate(False) passes False to update_config."""
import apm_cli.config as cfg_module

with patch.object(cfg_module, "update_config") as mock_update:
cfg_module.set_auto_integrate(False)
mock_update.assert_called_once_with({"auto_integrate": False})
Loading