Add DEVELOPMENT.md and SECURITY.md, add test suite
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from filedust import junk
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
monkeypatch.setattr(junk, "HOME", home.resolve())
|
||||
|
||||
return home
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from filedust import cli
|
||||
from filedust.junk import Finding
|
||||
|
||||
|
||||
def test_human_size_formats_units():
|
||||
assert cli.human_size(0) == "0.0 B"
|
||||
assert cli.human_size(1023) == "1023.0 B"
|
||||
assert cli.human_size(1024) == "1.0 KB"
|
||||
assert cli.human_size(1024 * 1024) == "1.0 MB"
|
||||
|
||||
|
||||
def test_compute_total_size_counts_file_and_directory_findings(tmp_path):
|
||||
file_path = tmp_path / "file.tmp"
|
||||
file_path.write_bytes(b"1234")
|
||||
dir_path = tmp_path / "cache"
|
||||
dir_path.mkdir()
|
||||
(dir_path / "nested.tmp").write_bytes(b"12345")
|
||||
|
||||
findings = [
|
||||
Finding(file_path, "file", "junk_file"),
|
||||
Finding(dir_path, "dir", "junk_dir"),
|
||||
]
|
||||
|
||||
assert cli.compute_total_size(findings) == 9
|
||||
|
||||
|
||||
def test_delete_all_deletes_files_before_directories(tmp_path):
|
||||
directory = tmp_path / "__pycache__"
|
||||
directory.mkdir()
|
||||
nested_file = directory / "module.pyc"
|
||||
nested_file.write_text("x")
|
||||
file_path = tmp_path / "notes.tmp"
|
||||
file_path.write_text("x")
|
||||
|
||||
findings = [
|
||||
Finding(directory, "dir", "junk_dir"),
|
||||
Finding(file_path, "file", "junk_file"),
|
||||
]
|
||||
|
||||
assert cli.delete_all(findings) == 0
|
||||
assert not file_path.exists()
|
||||
assert not directory.exists()
|
||||
|
||||
|
||||
def test_delete_all_reports_directory_failures(tmp_path):
|
||||
missing_directory = tmp_path / "missing"
|
||||
|
||||
failures = cli.delete_all([Finding(missing_directory, "dir", "junk_dir")])
|
||||
|
||||
assert failures == 1
|
||||
|
||||
|
||||
def test_main_refuses_paths_outside_home(fake_home, tmp_path):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
|
||||
assert cli.main([str(outside), "--dry-run"]) == 1
|
||||
|
||||
|
||||
def test_main_reports_missing_path_inside_home(fake_home):
|
||||
missing = fake_home / "missing"
|
||||
|
||||
assert cli.main([str(missing), "--dry-run"]) == 1
|
||||
|
||||
|
||||
def test_main_returns_zero_when_no_junk_found(fake_home):
|
||||
project = fake_home / "project"
|
||||
project.mkdir()
|
||||
(project / "keep.txt").write_text("x")
|
||||
|
||||
assert cli.main([str(project), "--dry-run"]) == 0
|
||||
|
||||
|
||||
def test_main_dry_run_reports_but_does_not_delete(fake_home):
|
||||
project = fake_home / "project"
|
||||
project.mkdir()
|
||||
junk_file = project / "notes.tmp"
|
||||
junk_file.write_text("x")
|
||||
|
||||
assert cli.main([str(project), "--dry-run"]) == 0
|
||||
assert junk_file.exists()
|
||||
|
||||
|
||||
def test_main_yes_deletes_without_prompt(fake_home, monkeypatch):
|
||||
project = fake_home / "project"
|
||||
project.mkdir()
|
||||
junk_file = project / "notes.tmp"
|
||||
junk_file.write_text("x")
|
||||
junk_dir = project / "__pycache__"
|
||||
junk_dir.mkdir()
|
||||
(junk_dir / "module.pyc").write_text("x")
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli.Confirm,
|
||||
"ask",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("confirmation should not be shown")
|
||||
),
|
||||
)
|
||||
|
||||
assert cli.main([str(project), "--yes"]) == 0
|
||||
assert not junk_file.exists()
|
||||
assert not junk_dir.exists()
|
||||
|
||||
|
||||
def test_main_confirmation_decline_does_not_delete(fake_home, monkeypatch):
|
||||
project = fake_home / "project"
|
||||
project.mkdir()
|
||||
junk_file = project / "notes.tmp"
|
||||
junk_file.write_text("x")
|
||||
|
||||
monkeypatch.setattr(cli.Confirm, "ask", lambda *args, **kwargs: False)
|
||||
|
||||
assert cli.main([str(project)]) == 0
|
||||
assert junk_file.exists()
|
||||
|
||||
|
||||
def test_main_confirmation_accept_deletes(fake_home, monkeypatch):
|
||||
project = fake_home / "project"
|
||||
project.mkdir()
|
||||
junk_file = project / "notes.tmp"
|
||||
junk_file.write_text("x")
|
||||
|
||||
monkeypatch.setattr(cli.Confirm, "ask", lambda *args, **kwargs: True)
|
||||
|
||||
assert cli.main([str(project)]) == 0
|
||||
assert not junk_file.exists()
|
||||
|
||||
|
||||
def test_build_parser_defaults_to_current_directory():
|
||||
args = cli.build_parser().parse_args([])
|
||||
|
||||
assert args.path == "."
|
||||
assert args.dry_run is False
|
||||
assert args.yes is False
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from filedust.junk import (
|
||||
UserRules,
|
||||
iter_junk,
|
||||
load_user_rules,
|
||||
matches_any,
|
||||
)
|
||||
|
||||
|
||||
def finding_map(findings, home: Path) -> dict[str, tuple[str, str]]:
|
||||
return {
|
||||
str(f.path.relative_to(home)): (f.kind, f.reason) for f in findings
|
||||
}
|
||||
|
||||
|
||||
def test_matches_any_supports_exact_star_globstar_and_stripped_slashes():
|
||||
assert matches_any(["Projects/App"], Path("Projects/App"))
|
||||
assert matches_any(["Projects/*"], Path("Projects/App"))
|
||||
assert not matches_any(["Projects/*"], Path("Projects/App/file.tmp"))
|
||||
assert matches_any(["Projects/**"], Path("Projects/App/file.tmp"))
|
||||
assert matches_any(["/Projects/**/"], Path("Projects/App/file.tmp"))
|
||||
assert matches_any(["Projects/**"], Path("Projects"))
|
||||
|
||||
|
||||
def test_matches_any_is_case_sensitive():
|
||||
assert matches_any(["Projects/App"], Path("Projects/App"))
|
||||
assert not matches_any(["projects/app"], Path("Projects/App"))
|
||||
|
||||
|
||||
def test_matches_any_returns_false_for_empty_patterns():
|
||||
assert not matches_any([], Path("anything"))
|
||||
|
||||
|
||||
def test_load_user_rules_reads_case_preserved_include_and_exclude(fake_home):
|
||||
(fake_home / ".filedust.conf").write_text(
|
||||
"[include]\n"
|
||||
"CaseSensitive.TMP\n"
|
||||
"Projects/**/Build\n"
|
||||
"\n"
|
||||
"[exclude]\n"
|
||||
"Projects/Important\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
rules = load_user_rules()
|
||||
|
||||
assert rules.include == ["CaseSensitive.TMP", "Projects/**/Build"]
|
||||
assert rules.exclude == ["Projects/Important"]
|
||||
|
||||
|
||||
def test_load_user_rules_returns_empty_rules_without_config(fake_home):
|
||||
rules = load_user_rules()
|
||||
|
||||
assert rules.include == []
|
||||
assert rules.exclude == []
|
||||
|
||||
|
||||
def test_iter_junk_detects_builtin_junk_directories_and_files(fake_home):
|
||||
project = fake_home / "project"
|
||||
(project / "__pycache__").mkdir(parents=True)
|
||||
(project / "__pycache__" / "module.pyc").write_text("x")
|
||||
(project / ".pytest_cache").mkdir()
|
||||
(project / "notes.tmp").write_text("x")
|
||||
(project / "keep.txt").write_text("x")
|
||||
|
||||
findings = finding_map(iter_junk(project), fake_home)
|
||||
|
||||
assert findings == {
|
||||
"project/__pycache__": ("dir", "junk_dir"),
|
||||
"project/.pytest_cache": ("dir", "junk_dir"),
|
||||
"project/notes.tmp": ("file", "junk_file"),
|
||||
}
|
||||
|
||||
|
||||
def test_iter_junk_does_not_descend_into_builtin_junk_directories(fake_home):
|
||||
project = fake_home / "project"
|
||||
(project / "__pycache__" / "nested").mkdir(parents=True)
|
||||
(project / "__pycache__" / "nested" / "extra.tmp").write_text("x")
|
||||
|
||||
findings = finding_map(iter_junk(project), fake_home)
|
||||
|
||||
assert findings == {"project/__pycache__": ("dir", "junk_dir")}
|
||||
|
||||
|
||||
def test_iter_junk_exclude_rules_win_over_builtin_and_include_rules(fake_home):
|
||||
project = fake_home / "project"
|
||||
(project / "__pycache__").mkdir(parents=True)
|
||||
(project / "__pycache__" / "module.pyc").write_text("x")
|
||||
(project / "delete.tmp").write_text("x")
|
||||
(project / "keep.tmp").write_text("x")
|
||||
|
||||
rules = UserRules()
|
||||
rules.include = ["project/**"]
|
||||
rules.exclude = ["project/__pycache__", "project/keep.tmp"]
|
||||
|
||||
findings = finding_map(iter_junk(project, rules=rules), fake_home)
|
||||
|
||||
assert findings == {"project/delete.tmp": ("file", "user_include")}
|
||||
|
||||
|
||||
def test_iter_junk_user_include_can_select_custom_files_and_dirs(fake_home):
|
||||
project = fake_home / "project"
|
||||
(project / "custom-dir").mkdir(parents=True)
|
||||
(project / "custom-dir" / "nested.tmp").write_text("x")
|
||||
(project / "custom.file").write_text("x")
|
||||
|
||||
rules = UserRules()
|
||||
rules.include = ["project/custom-dir", "project/custom.file"]
|
||||
|
||||
findings = finding_map(iter_junk(project, rules=rules), fake_home)
|
||||
|
||||
assert findings == {
|
||||
"project/custom-dir": ("dir", "user_include"),
|
||||
"project/custom.file": ("file", "user_include"),
|
||||
}
|
||||
|
||||
|
||||
def test_iter_junk_skips_protected_directories_by_default(fake_home):
|
||||
project = fake_home / "project"
|
||||
(project / ".git" / "objects").mkdir(parents=True)
|
||||
(project / ".git" / "objects" / "junk.tmp").write_text("x")
|
||||
|
||||
assert list(iter_junk(project)) == []
|
||||
|
||||
|
||||
def test_iter_junk_reports_explicitly_included_protected_directory(fake_home):
|
||||
project = fake_home / "project"
|
||||
(project / ".git" / "objects").mkdir(parents=True)
|
||||
(project / ".git" / "objects" / "junk.tmp").write_text("x")
|
||||
|
||||
rules = UserRules()
|
||||
rules.include = ["project/.git"]
|
||||
|
||||
findings = finding_map(iter_junk(project, rules=rules), fake_home)
|
||||
assert findings == {"project/.git": ("dir", "user_include")}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks unsupported")
|
||||
def test_iter_junk_does_not_follow_valid_symlink_files(fake_home):
|
||||
project = fake_home / "project"
|
||||
project.mkdir()
|
||||
target = project / "real.txt"
|
||||
target.write_text("important")
|
||||
(project / "link.tmp").symlink_to(target)
|
||||
|
||||
assert list(iter_junk(project)) == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks unsupported")
|
||||
def test_iter_junk_allows_user_include_for_valid_symlink_files(fake_home):
|
||||
project = fake_home / "project"
|
||||
project.mkdir()
|
||||
target = project / "real.txt"
|
||||
target.write_text("important")
|
||||
(project / "link.txt").symlink_to(target)
|
||||
|
||||
rules = UserRules()
|
||||
rules.include = ["project/link.txt"]
|
||||
|
||||
findings = finding_map(iter_junk(project, rules=rules), fake_home)
|
||||
assert findings == {"project/link.txt": ("file", "user_include")}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks unsupported")
|
||||
def test_iter_junk_reports_broken_symlink_matching_junk_file_pattern(
|
||||
fake_home,
|
||||
):
|
||||
project = fake_home / "project"
|
||||
project.mkdir()
|
||||
(project / "broken.tmp").symlink_to(project / "missing-target")
|
||||
|
||||
findings = finding_map(iter_junk(project), fake_home)
|
||||
assert findings == {"project/broken.tmp": ("file", "broken_symlink")}
|
||||
|
||||
|
||||
def test_iter_junk_skips_roots_outside_home(fake_home, tmp_path):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "junk.tmp").write_text("x")
|
||||
|
||||
assert list(iter_junk(outside)) == []
|
||||
Reference in New Issue
Block a user