"""Unit tests for devx.tools.ansible_checks._shared.""" from pathlib import Path import pytest from devx.tools.ansible_checks._shared import ( DEFAULT_ANSIBLE_DIRS, AnsibleFileFinder, AnsibleYAMLParser, ViolationReporter, ) class TestAnsibleFileFinder: def test_find_task_files_single_yaml(self, tmp_path: Path) -> None: f = tmp_path / "test.yml" f.write_text("tasks: []") assert AnsibleFileFinder.find_task_files(f) == [f] def test_find_task_files_single_non_yaml(self, tmp_path: Path) -> None: f = tmp_path / "test.txt" f.write_text("hello") assert AnsibleFileFinder.find_task_files(f) == [] def test_find_task_files_dir(self, tmp_path: Path) -> None: (tmp_path / "a.yml").write_text("tasks: []") (tmp_path / "b.yaml").write_text("tasks: []") (tmp_path / "c.txt").write_text("hello") result = AnsibleFileFinder.find_task_files(tmp_path) assert len(result) == 2 assert all(f.suffix in (".yml", ".yaml") for f in result) def test_find_task_files_skip_molecule(self, tmp_path: Path) -> None: (tmp_path / "a.yml").write_text("tasks: []") mol = tmp_path / "molecule" / "default" mol.mkdir(parents=True) (mol / "main.yml").write_text("tasks: []") result = AnsibleFileFinder.find_task_files(tmp_path, skip_molecule=True) assert len(result) == 1 assert "molecule" not in result[0].parts def test_find_task_files_include_molecule(self, tmp_path: Path) -> None: (tmp_path / "a.yml").write_text("tasks: []") mol = tmp_path / "molecule" / "default" mol.mkdir(parents=True) (mol / "main.yml").write_text("tasks: []") result = AnsibleFileFinder.find_task_files(tmp_path, skip_molecule=False) assert len(result) == 2 def test_find_task_files_nonexistent(self, tmp_path: Path) -> None: assert AnsibleFileFinder.find_task_files(tmp_path / "nonexistent") == [] def test_find_yaml_files_single_file(self, tmp_path: Path) -> None: f = tmp_path / "test.txt" f.write_text("hello") # find_yaml_files accepts any single file (no suffix check) assert AnsibleFileFinder.find_yaml_files(f) == [f] def test_find_yaml_files_dir(self, tmp_path: Path) -> None: (tmp_path / "a.yml").write_text("tasks: []") (tmp_path / "sub").mkdir() (tmp_path / "sub" / "b.yaml").write_text("tasks: []") result = AnsibleFileFinder.find_yaml_files(tmp_path) assert len(result) == 2 def test_find_yaml_files_skip_molecule(self, tmp_path: Path) -> None: (tmp_path / "a.yml").write_text("tasks: []") mol = tmp_path / "molecule" / "default" mol.mkdir(parents=True) (mol / "main.yml").write_text("tasks: []") result = AnsibleFileFinder.find_yaml_files(tmp_path, skip_molecule=True) assert len(result) == 1 def test_find_yaml_files_include_molecule(self, tmp_path: Path) -> None: (tmp_path / "a.yml").write_text("tasks: []") mol = tmp_path / "molecule" / "default" mol.mkdir(parents=True) (mol / "main.yml").write_text("tasks: []") result = AnsibleFileFinder.find_yaml_files(tmp_path, skip_molecule=False) assert len(result) == 2 def test_find_task_and_playbook_files(self, tmp_path: Path) -> None: role = tmp_path / "roles" / "myrole" (role / "tasks").mkdir(parents=True) (role / "tasks" / "main.yml").write_text("tasks: []") pb = tmp_path / "playbooks" pb.mkdir() (pb / "deploy.yml").write_text("tasks: []") (tmp_path / "random.yml").write_text("tasks: []") result = AnsibleFileFinder.find_task_and_playbook_files(tmp_path) # Should find tasks/main.yml and playbooks/deploy.yml, not random.yml names = [f.name for f in result] assert "main.yml" in names assert "deploy.yml" in names assert "random.yml" not in names def test_find_task_and_playbook_files_skip_molecule(self, tmp_path: Path) -> None: role = tmp_path / "roles" / "myrole" (role / "tasks").mkdir(parents=True) (role / "tasks" / "main.yml").write_text("tasks: []") mol = role / "molecule" / "default" / "tasks" mol.mkdir(parents=True) (mol / "main.yml").write_text("tasks: []") result = AnsibleFileFinder.find_task_and_playbook_files(tmp_path, skip_molecule=True) assert len(result) == 1 assert "molecule" not in result[0].parts class TestAnsibleYAMLParser: def test_parse_file_valid(self) -> None: content = "---\n- name: test\n shell: echo hi\n" docs = AnsibleYAMLParser.parse_file(content) assert len(docs) == 1 assert isinstance(docs[0], list) def test_parse_file_multi_doc(self) -> None: content = "---\n- a\n---\n- b\n" docs = AnsibleYAMLParser.parse_file(content) assert len(docs) == 2 def test_parse_file_empty_docs_filtered(self) -> None: content = "---\n- a\n---\n\n" docs = AnsibleYAMLParser.parse_file(content) assert len(docs) == 1 def test_parse_file_yaml_error(self) -> None: content = "{{ invalid: [" docs = AnsibleYAMLParser.parse_file(content) assert docs == [] def test_iter_tasks_bare_list(self) -> None: doc = [{"name": "task1", "shell": "echo hi"}, {"name": "task2", "shell": "echo bye"}] tasks = list(AnsibleYAMLParser.iter_tasks(doc)) assert len(tasks) == 2 assert tasks[0][0]["name"] == "task1" assert tasks[0][1] == 1 assert tasks[1][1] == 2 def test_iter_tasks_play_dict(self) -> None: doc = {"hosts": "all", "tasks": [{"name": "task1", "shell": "echo hi"}]} tasks = list(AnsibleYAMLParser.iter_tasks(doc)) assert len(tasks) == 1 assert tasks[0][0]["name"] == "task1" def test_iter_tasks_play_with_pre_post_handlers(self) -> None: doc = { "hosts": "all", "pre_tasks": [{"name": "pre", "shell": "echo pre"}], "tasks": [{"name": "main", "shell": "echo main"}], "post_tasks": [{"name": "post", "shell": "echo post"}], "handlers": [{"name": "handler", "shell": "echo handler"}], } tasks = list(AnsibleYAMLParser.iter_tasks(doc)) assert len(tasks) == 4 names = [t[0]["name"] for t in tasks] # Order: tasks, pre_tasks, post_tasks, handlers (as defined in _iter_play_sections) assert names == ["main", "pre", "post", "handler"] def test_iter_tasks_block(self) -> None: doc = [{"name": "outer", "block": [{"name": "inner", "shell": "echo hi"}]}] tasks = list(AnsibleYAMLParser.iter_tasks(doc)) # outer is not a play (no task sections) → yielded as bare task # inner is yielded from block assert len(tasks) == 2 assert tasks[0][0]["name"] == "outer" assert tasks[1][0]["name"] == "inner" def test_iter_tasks_block_in_play_section(self) -> None: """Block tasks within a play's tasks section are yielded.""" doc = { "hosts": "all", "tasks": [ {"name": "outer", "block": [{"name": "inner", "shell": "echo hi"}]}, ], } tasks = list(AnsibleYAMLParser.iter_tasks(doc)) assert len(tasks) == 2 assert tasks[0][0]["name"] == "outer" assert tasks[1][0]["name"] == "inner" def test_iter_tasks_play_list(self) -> None: doc = [{"hosts": "all", "tasks": [{"name": "task1", "shell": "echo hi"}]}] tasks = list(AnsibleYAMLParser.iter_tasks(doc)) assert len(tasks) == 1 assert tasks[0][0]["name"] == "task1" def test_iter_tasks_non_dict_items_skipped(self) -> None: doc = ["string", 42, {"name": "task1", "shell": "echo hi"}] tasks = list(AnsibleYAMLParser.iter_tasks(doc)) assert len(tasks) == 1 class TestViolationReporter: def test_format_violation_with_line(self, tmp_path: Path) -> None: result = ViolationReporter.format_violation(tmp_path / "foo.yml", tmp_path, 42, "bad") assert result == "foo.yml:42 — bad" def test_format_violation_without_line(self, tmp_path: Path) -> None: result = ViolationReporter.format_violation(tmp_path / "foo.yml", tmp_path, None, "bad") assert result == "foo.yml — bad" def test_format_violation_not_relative(self, tmp_path: Path) -> None: other = Path("/other/path") result = ViolationReporter.format_violation(other, tmp_path, 1, "bad") assert str(other) in result assert "bad" in result def test_report_no_violations(self, capsys: pytest.CaptureFixture[str]) -> None: ViolationReporter.report([], "test-tool") captured = capsys.readouterr() assert "OK" in captured.out assert "test-tool" in captured.out def test_report_with_violations(self, capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit) as exc_info: ViolationReporter.report(["v1", "v2"], "test-tool") assert exc_info.value.code == 1 captured = capsys.readouterr() assert "FAIL" in captured.out assert "v1" in captured.out assert "v2" in captured.out class TestDefaultAnsibleDirs: def test_is_tuple(self) -> None: assert isinstance(DEFAULT_ANSIBLE_DIRS, tuple) def test_contains_expected(self) -> None: assert "ansible/roles" in DEFAULT_ANSIBLE_DIRS assert "ansible/playbooks" in DEFAULT_ANSIBLE_DIRS