diff --git a/src/specify_cli/workflows/overlays/_commands.py b/src/specify_cli/workflows/overlays/_commands.py index 06c4dca835..a457dd1179 100644 --- a/src/specify_cli/workflows/overlays/_commands.py +++ b/src/specify_cli/workflows/overlays/_commands.py @@ -112,7 +112,11 @@ def _find_overlay_file(project_root: Path, workflow_id: str, overlay_id: str) -> return None matches: list[Path] = [] for path in entries: - if not path.is_file() or path.suffix not in (".yml", ".yaml"): + # Must match ``ProjectOverlaySource.collect`` exactly. With the loader + # lowercasing the suffix and this helper not, a ``.YML`` overlay was + # ACTIVE during resolution yet reported "not found" by overlay + # enable / disable / remove -- applied, but impossible to manage. + if not path.is_file() or path.suffix.lower() not in (".yml", ".yaml"): continue if path.is_symlink(): continue diff --git a/src/specify_cli/workflows/overlays/layer_sources.py b/src/specify_cli/workflows/overlays/layer_sources.py index a62cef9340..071b45c0eb 100644 --- a/src/specify_cli/workflows/overlays/layer_sources.py +++ b/src/specify_cli/workflows/overlays/layer_sources.py @@ -147,7 +147,12 @@ def collect(self, workflow_id: str, *, include_disabled: bool = False) -> list[L workflow_overlay_dir, [f"Cannot enumerate overlays: {exc}"] ) from exc for path in entries: - if not path.is_file() or path.suffix not in (".yml", ".yaml"): + # Match the extension case-insensitively. A hand-placed overlay + # named ``.YML`` or ``.Yaml`` was skipped here and never + # applied, with nothing reported to say the file had been ignored. + # Every other YAML discovery path in the package already lowercases + # before matching (engine.py:941, _commands.py:1325/1894/2128). + if not path.is_file() or path.suffix.lower() not in (".yml", ".yaml"): continue if path.is_symlink(): raise OverlayLoadError(path, ["Symlinked overlay files are not allowed"]) diff --git a/tests/workflows/test_overlay_commands.py b/tests/workflows/test_overlay_commands.py index c28f53b050..64e0f5282a 100644 --- a/tests/workflows/test_overlay_commands.py +++ b/tests/workflows/test_overlay_commands.py @@ -1002,3 +1002,55 @@ def test_duplicate_manifest_id_is_rejected(self, project_dir, monkeypatch): with pytest.raises(typer.Exit): _find_overlay_file(project_dir, "wf", "lint") + + +class TestOverlayManagementMatchesUppercaseExtension: + """Overlay management finds the same files the resolver loads.""" + + @pytest.mark.parametrize("filename", ["lint.YML", "lint.Yaml"]) + def test_uppercase_extension_overlay_is_manageable( + self, project_dir, monkeypatch, filename + ): + """An overlay the resolver applies must be reachable by enable/disable/remove. + + `ProjectOverlaySource.collect` matches the suffix case-insensitively, so + a `lint.YML` overlay is ACTIVE during resolution. `_find_overlay_file` + matched it case-sensitively, so the same overlay was reported "not + found" by every management command — applied, but impossible to manage. + """ + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + ov_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf" + ov_dir.mkdir(parents=True, exist_ok=True) + overlay = ov_dir / filename + overlay.write_text( + yaml.safe_dump( + { + "id": "lint", + "extends": "wf", + "priority": 10, + "edits": [{"remove": "a"}], + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["workflow", "overlay", "disable", "wf", "lint"]) + assert result.exit_code == 0, result.output + assert yaml.safe_load(overlay.read_text(encoding="utf-8"))["enabled"] is False + + result = runner.invoke(app, ["workflow", "overlay", "enable", "wf", "lint"]) + assert result.exit_code == 0, result.output + assert yaml.safe_load(overlay.read_text(encoding="utf-8"))["enabled"] is True + + result = runner.invoke(app, ["workflow", "overlay", "remove", "wf", "lint"]) + assert result.exit_code == 0, result.output + assert not overlay.exists() diff --git a/tests/workflows/test_overlay_layer_sources.py b/tests/workflows/test_overlay_layer_sources.py index d852cb7622..339d1072d6 100644 --- a/tests/workflows/test_overlay_layer_sources.py +++ b/tests/workflows/test_overlay_layer_sources.py @@ -85,6 +85,50 @@ def test_empty_document_still_reports_missing_fields( ) +class TestProjectOverlaySourceExtensionMatching: + """Overlay file extensions are matched case-insensitively.""" + + @pytest.mark.parametrize( + "filename", ["upper.YML", "mixed.Yaml", "shouty.YAML", "title.Yml"] + ) + def test_uppercase_extension_is_collected( + self, project_dir: Path, filename: str + ) -> None: + """A hand-placed `.YML` must not be silently ignored. + + `collect` matched `path.suffix` verbatim against `(".yml", ".yaml")`, + so an overlay whose extension differed only in case was skipped with + nothing reported — the author's overlay simply never applied. Every + other YAML discovery path in the package lowercases before matching. + """ + ov_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf" + ov_dir.mkdir(parents=True, exist_ok=True) + (ov_dir / filename).write_text( + yaml.safe_dump( + { + "id": "lint", + "extends": "wf", + "priority": 10, + "edits": [{"remove": "a"}], + } + ), + encoding="utf-8", + ) + + layers = ProjectOverlaySource(project_dir).collect("wf") + + assert [layer.content.id for layer in layers] == ["lint"] + + def test_non_yaml_extensions_are_still_skipped(self, project_dir: Path) -> None: + """Broadening case must not broaden which extensions are accepted.""" + ov_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf" + ov_dir.mkdir(parents=True, exist_ok=True) + for name in ("notes.txt", "backup.yml.bak", "README.md", "data.json"): + (ov_dir / name).write_text("id: lint\n", encoding="utf-8") + + assert ProjectOverlaySource(project_dir).collect("wf") == [] + + class TestProjectOverlaySourceFileReadErrors: """File-read errors must be wrapped in OverlayLoadError, not leaked as raw tracebacks."""