import json import os from pathlib import Path import click import pytest from click.testing import CliRunner, Result import semantica.cli as cli_module @pytest.fixture def runner() -> CliRunner: # Click's CliRunner captures all output in result.output by default; error # messages from ClickException / UsageError are included. If the project # ever moves to a Click version that separates stderr (mix_stderr=False), # update assertions that check error text to use result.stderr instead. return CliRunner() @pytest.fixture(autouse=True) def disable_cli_logging(monkeypatch): monkeypatch.setattr(cli_module, "setup_logging", lambda *args, **kwargs: None) # --------------------------------------------------------------------------- # Help surfaces # --------------------------------------------------------------------------- def test_root_help_shows_expected_groups(runner): result = runner.invoke(cli_module.main, ["--help"]) assert result.exit_code == 0 assert "semantica v" in result.output assert "Knowledge Intelligence Platform" in result.output assert "kg" in result.output assert "pipeline" in result.output assert "Services" in result.output def test_kg_group_help_shows_build_command(runner): result = runner.invoke(cli_module.main, ["kg", "--help"]) assert result.exit_code == 0 assert "Knowledge graph and semantic build commands." in result.output assert "build" in result.output def test_kg_build_help_shows_source_and_config_flags(runner): result = runner.invoke(cli_module.main, ["kg", "build", "--help"]) assert result.exit_code == 0 assert "--source" in result.output assert "-s" in result.output assert "--config" in result.output assert "-c" in result.output # --------------------------------------------------------------------------- # info command # --------------------------------------------------------------------------- def test_info_command_shows_framework_components(runner): result = runner.invoke(cli_module.main, ["info"]) assert result.exit_code == 0 assert "Semantica Framework" in result.output assert "Core Orchestrator" in result.output assert "CLI Log Level" in result.output def test_info_command_shows_config_path_when_supplied(runner): # click.Path(resolve_path=True) turns cfg.yml into an absolute path, and # Rich's table may truncate long paths with '…'. Assert the row is present # and the "(none)" placeholder has been replaced by some path value. with runner.isolated_filesystem(): with open("cfg.yml", "w", encoding="utf-8") as handle: handle.write("logging:\n level: INFO\n") result: Result = runner.invoke( cli_module.main, ["--config", "cfg.yml", "info"] ) assert result.exit_code == 0 assert "CLI Config File" in result.output assert "(none)" not in result.output # --------------------------------------------------------------------------- # Global --log-level override # --------------------------------------------------------------------------- def test_log_level_global_override_stores_in_context(runner, monkeypatch): """--log-level at the root level propagates into CLIContext.""" captured = {} def fake_run_build(cli_ctx, sources): captured["log_level_override"] = cli_ctx.log_level_override captured["log_level"] = cli_ctx.log_level monkeypatch.setattr(cli_module, "_run_build", fake_run_build) result = runner.invoke( cli_module.main, ["--log-level", "DEBUG", "kg", "build", "-s", "src.txt"], ) assert result.exit_code == 0 assert captured["log_level_override"] == "DEBUG" # --------------------------------------------------------------------------- # Command-level config # --------------------------------------------------------------------------- def test_command_config_preserves_global_log_level_override( runner: CliRunner, monkeypatch: pytest.MonkeyPatch ) -> None: """Global --log-level is forwarded into command_ctx.log_level_override.""" captured: dict[str, object] = {} def fake_run_build(cli_ctx: cli_module.CLIContext, _: object) -> None: captured["log_level_override"] = cli_ctx.log_level_override monkeypatch.setattr(cli_module, "_run_build", fake_run_build) with runner.isolated_filesystem(): with open("cfg.yml", "w", encoding="utf-8") as handle: handle.write("logging:\n level: INFO\n") result = runner.invoke( cli_module.main, ["--log-level", "DEBUG", "kg", "build", "-s", "src.txt", "-c", "cfg.yml"], ) assert result.exit_code == 0 assert captured["log_level_override"] == "DEBUG" def test_command_config_keeps_own_logging_without_global_override(runner, monkeypatch): captured = {} def fake_run_build(cli_ctx, sources): captured["logging_level"] = cli_ctx.config.get("logging.level") captured["sources"] = list(sources) monkeypatch.setattr(cli_module, "_run_build", fake_run_build) with runner.isolated_filesystem(): with open("cfg.yml", "w", encoding="utf-8") as handle: handle.write("logging:\n level: DEBUG\n") result = runner.invoke( cli_module.main, ["kg", "build", "-s", "README.md", "-c", "cfg.yml"], ) assert result.exit_code == 0 assert captured["sources"] == ["README.md"] assert captured["logging_level"] == "DEBUG" # --------------------------------------------------------------------------- # Legacy / new command parity # --------------------------------------------------------------------------- @pytest.mark.parametrize( "argv", [ ["kg", "build", "-s", "README.md"], ["build", "-s", "README.md"], ], ) def test_build_paths_invoke_shared_wrapper(runner, monkeypatch, argv): captured = {} def fake_run_build(cli_ctx, sources): captured["config_path"] = cli_ctx.config_path captured["sources"] = list(sources) monkeypatch.setattr(cli_module, "_run_build", fake_run_build) result = runner.invoke(cli_module.main, argv) assert result.exit_code == 0 assert captured["sources"] == ["README.md"] @pytest.mark.parametrize( "argv", [ ["kg", "build", "-s", "README.md", "-c", "cfg.yml"], ["kg", "build", "-s", "README.md", "--config", "cfg.yml"], ["build", "-s", "README.md", "-c", "cfg.yml"], ["build", "-s", "README.md", "--config", "cfg.yml"], ], ) def test_build_config_short_and_long_flags_are_compatible(runner, monkeypatch, argv): captured = {} def fake_run_build(cli_ctx, sources): captured["config_path"] = cli_ctx.config_path captured["sources"] = list(sources) monkeypatch.setattr(cli_module, "_run_build", fake_run_build) with runner.isolated_filesystem(): with open("cfg.yml", "w", encoding="utf-8") as handle: handle.write("logging:\n level: INFO\n") result = runner.invoke(cli_module.main, argv) assert result.exit_code == 0 assert captured["sources"] == ["README.md"] assert captured["config_path"] is not None # --------------------------------------------------------------------------- # build_knowledge_base result handling # --------------------------------------------------------------------------- class _MockFramework: """Lightweight stand-in for Semantica used to test _run_build result paths.""" def __init__(self, return_value): self._return_value = return_value def build_knowledge_base(self, sources): return self._return_value def test_build_result_with_stats_shows_source_count(runner, monkeypatch): """When build returns statistics.sources_processed, that count appears in output.""" monkeypatch.setattr( cli_module, "_get_framework", lambda _: _MockFramework({"statistics": {"sources_processed": 3}}), ) result = runner.invoke( cli_module.main, ["kg", "build", "-s", "a.txt", "-s", "b.txt", "-s", "c.txt"], ) assert result.exit_code == 0 assert "3 source(s)" in result.output def test_build_result_without_stats_shows_generic_success(runner, monkeypatch): """When build result has no statistics key, generic success message is shown.""" monkeypatch.setattr( cli_module, "_get_framework", lambda _: _MockFramework({}), ) result = runner.invoke(cli_module.main, ["kg", "build", "-s", "src.txt"]) assert result.exit_code == 0 assert "Knowledge base build completed" in result.output assert "source(s)" not in result.output def test_build_result_none_shows_generic_success(runner, monkeypatch): """When build_knowledge_base returns None, generic success message is shown.""" monkeypatch.setattr( cli_module, "_get_framework", lambda _: _MockFramework(None), ) result = runner.invoke(cli_module.main, ["kg", "build", "-s", "src.txt"]) assert result.exit_code == 0 assert "Knowledge base build completed" in result.output @pytest.mark.parametrize( "argv", [ ["--dry-run", "kg", "build", "-s", "src.txt"], ["--dry-run", "build", "-s", "src.txt"], ], ) def test_build_global_dry_run_does_not_initialize_framework(runner, monkeypatch, argv): """Global --dry-run previews build without touching the framework.""" def fail_get_framework(_): raise AssertionError("_get_framework should not be called during dry-run") monkeypatch.setattr(cli_module, "_get_framework", fail_get_framework) result = runner.invoke(cli_module.main, argv) assert result.exit_code == 0 assert "Dry run" in result.output assert "build knowledge base" in result.output assert "src.txt" in result.output @pytest.mark.parametrize( "argv", [ ["--json", "--dry-run", "kg", "build", "-s", "src.txt"], ["--json", "--dry-run", "build", "-s", "src.txt"], ], ) def test_build_global_dry_run_json(runner, monkeypatch, argv): """Global --dry-run build emits machine-readable preview JSON.""" def fail_get_framework(_): raise AssertionError("_get_framework should not be called during dry-run") monkeypatch.setattr(cli_module, "_get_framework", fail_get_framework) result = runner.invoke(cli_module.main, argv) assert result.exit_code == 0 payload = json.loads(result.output) assert payload == { "dry_run": True, "action": "build knowledge base", "sources": ["src.txt"], } def _cli_context_with_config(config): return cli_module.CLIContext( config_path=None, config=config, log_level="INFO", ) def _runtime_dir(name: str) -> Path: root = Path(__file__).resolve().parents[1] work_dir = root / "test_data" / "runtime" / f"{name}-{os.getpid()}" work_dir.mkdir(parents=True, exist_ok=True) return work_dir def test_log_directory_probe_does_not_delete_active_log_file(monkeypatch): """doctor probes with a temp file instead of touching semantica.log.""" work_dir = _runtime_dir("doctor-log-probe") monkeypatch.chdir(work_dir) log_path = work_dir / "semantica.log" log_path.write_text("existing log", encoding="utf-8") config = cli_module._build_runtime_config(None, None) note = cli_module._check_log_directory(_cli_context_with_config(config)) assert "writable" in note assert log_path.read_text(encoding="utf-8") == "existing log" def test_log_directory_probe_accepts_console_only_logging(): config = cli_module._build_runtime_config(None, None) config.set("logging.file", None) note = cli_module._check_log_directory(_cli_context_with_config(config)) assert note == "console-only logging" def test_doctor_json_log_directory_check_is_not_false_failure(runner, monkeypatch): work_dir = _runtime_dir("doctor-json") monkeypatch.chdir(work_dir) (work_dir / "semantica.log").write_text("existing log", encoding="utf-8") result = runner.invoke(cli_module.main, ["--json", "doctor"]) assert result.exit_code == 0 checks = json.loads(result.output) log_check = next(item for item in checks if item["check"] == "Log directory") assert log_check["status"] == "ok" # --------------------------------------------------------------------------- # Error handling # --------------------------------------------------------------------------- @pytest.mark.parametrize( "argv", [ ["kg", "build"], ["build"], ], ) def test_missing_input_errors_are_clean_and_click_safe(runner, argv): result = runner.invoke(cli_module.main, argv) assert result.exit_code != 0 assert "At least one source is required" in result.output assert "Traceback" not in result.output def test_invalid_root_config_error_is_clean_and_click_safe(runner): with runner.isolated_filesystem(): with open("bad.md", "w", encoding="utf-8") as handle: handle.write("not-a-config") result = runner.invoke(cli_module.main, ["--config", "bad.md", "info"]) assert result.exit_code != 0 assert "Unsupported configuration file format" in result.output assert "Traceback" not in result.output def test_invalid_command_config_error_is_clean_and_click_safe(runner): with runner.isolated_filesystem(): with open("bad.md", "w", encoding="utf-8") as handle: handle.write("not-a-config") result = runner.invoke( cli_module.main, ["kg", "build", "-s", "README.md", "-c", "bad.md"], ) assert result.exit_code != 0 assert "Unsupported configuration file format" in result.output assert "Traceback" not in result.output def test_empty_yaml_config_is_accepted(runner): with runner.isolated_filesystem(): with open("cfg.yml", "w", encoding="utf-8") as handle: handle.write("") result = runner.invoke(cli_module.main, ["--config", "cfg.yml", "info"]) assert result.exit_code == 0 def test_malformed_logging_section_is_clean_and_click_safe(runner): with runner.isolated_filesystem(): with open("cfg.yml", "w", encoding="utf-8") as handle: handle.write("logging: []\n") result = runner.invoke( cli_module.main, ["--config", "cfg.yml", "--log-level", "INFO", "info"], ) assert result.exit_code != 0 assert ( "Logging configuration section must contain a mapping/object" in result.output ) assert "Traceback" not in result.output @pytest.mark.parametrize( "file_name, config_text, expected_error", [ ("cfg.json", "{not-json", "Failed to parse configuration file"), ( "cfg.yml", "- item\n- item2\n", "Configuration file must contain a mapping/object", ), ], ) def test_command_config_parse_errors_are_clean_and_click_safe( runner, file_name, config_text, expected_error, ): with runner.isolated_filesystem(): with open(file_name, "w", encoding="utf-8") as handle: handle.write(config_text) result = runner.invoke( cli_module.main, ["kg", "build", "-s", "README.md", "-c", file_name], ) assert result.exit_code != 0 assert expected_error in result.output assert "Traceback" not in result.output def test_runtime_errors_are_click_safe_without_traceback(runner, monkeypatch): def boom(_cli_ctx, _sources): raise RuntimeError("boom") monkeypatch.setattr(cli_module, "_run_build", boom) result = runner.invoke(cli_module.main, ["kg", "build", "-s", "README.md"]) assert result.exit_code != 0 assert "RuntimeError" in result.output assert "boom" in result.output assert "Traceback" not in result.output # --------------------------------------------------------------------------- # Lazy initialization # --------------------------------------------------------------------------- @pytest.mark.parametrize( "argv", [ ["--help"], ["kg", "--help"], ["kg", "build", "--help"], ["build", "--help"], ], ) def test_help_calls_do_not_initialize_framework(runner, monkeypatch, argv): def fail_get_framework(_): raise AssertionError("framework initialization must not happen on help") monkeypatch.setattr(cli_module, "_get_framework", fail_get_framework) result = runner.invoke(cli_module.main, argv) assert result.exit_code == 0 # --------------------------------------------------------------------------- # _require_ctx guard # --------------------------------------------------------------------------- def test_require_ctx_raises_click_exception_on_none(): """_require_ctx converts None ctx into a clean ClickException.""" with pytest.raises( click.ClickException, match="CLI context is uninitialized", ): cli_module._require_ctx(None) def test_require_ctx_returns_ctx_unchanged(): """_require_ctx is a pass-through when ctx is valid.""" from semantica.core.config_manager import Config, ConfigManager cfg = ConfigManager().load_from_dict({}, validate=False) ctx = cli_module.CLIContext(config_path=None, config=cfg, log_level="INFO") assert cli_module._require_ctx(ctx) is ctx