diff --git a/CHANGELOG.md b/CHANGELOG.md index fa83c93..78042a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] +### Added +- Undo: developer build folders and uninstall remnants go to the freedesktop Trash; `marmot undo` (or the Undo button) restores the last batch. Every action is logged to `~/.local/state/marmot/history.jsonl` (`marmot history`). +- Dry-run for every cleaner item (Preview button, `marmot clean --dry-run`). +- Package backends for dnf (dnf4/dnf5), zypper and apk, plus Flatpak apps in the uninstaller. +- Command line: `marmot status|scan|clean|undo|history`, with `--json` output. +- Cleaner: per-folder `~/.cache` selection, orphaned packages, unused Flatpak runtimes, Docker/Podman unused data, old Snap revisions. +- Disk Analyzer: cached directory sizes (instant back/forward), progress, cancel on navigation, percentage bars, stays on one filesystem. + +- Redesigned TUI: theme-aware cards, CPU/network sparklines, colour-coded bars, top processes, system card; + number keys 1–5 switch tabs; confirmation dialog before cleaning/uninstalling. +- Disk Analyzer: `d` moves the highlighted item to Trash (home folder only, undoable). +- Optimizer: every task shows whether it applies on this machine and why; new "Trim SSDs"; `marmot optimize`. + +### Changed +- Optimizer "Drop page cache" is labelled as benchmarking-only, "Clear failed services" says it does not fix them. +- Uninstaller loads the package list once and filters in memory (no package-manager call per keystroke). +- Scans, cleaning and uninstalling run off the UI thread, so the TUI no longer freezes during a pkexec prompt. + +### Fixed +- **Reclaim swap left zram/systemd swap disabled**: it ran `swapoff -a && swapon -a`, and `swapon -a` only + re-enables fstab entries. Now each device is cycled on its own and restored with its priority. +- File and package names containing `[` were swallowed as markup (e.g. the `[DIR]` prefix in the analyzer). +- Operation logs printed raw `[success]` markup instead of colours. +- Journal size was always reported as 0 when `journalctl` printed sizes like `1.2G`. + ## [1.1.0] ### Added diff --git a/README.md b/README.md index 1629a61..a0752a0 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,33 @@ Upgrade with `uv tool upgrade marmot-tui`, remove with `uv tool uninstall marmot **Supported distributions** -| Family | Package backend | Status | -|---|---|---| -| Debian, Ubuntu, Mint, Pop!_OS, Kali | apt / dpkg | full | -| Arch, Manjaro, EndeavourOS, CachyOS | pacman | full | -| Others (Fedora, openSUSE, Alpine...) | none yet | Monitor, Cleaner (no package cache), Analyzer, Optimizer | +| Family | Package backend | +|---|---| +| Debian, Ubuntu, Mint, Pop!_OS, Kali | apt / dpkg | +| Arch, Manjaro, EndeavourOS, CachyOS | pacman | +| Fedora, RHEL, Rocky, Alma | dnf (dnf4 and dnf5) | +| openSUSE Tumbleweed / Leap | zypper | +| Alpine | apk | +| any distro | Flatpak apps (uninstall, unused runtimes) | + +Docker/Podman and Snap cleanup appear automatically when those tools are installed. Journal cleaning and service reset need systemd. +### Command line + +Every cleaning action also works without the TUI (scripts, cron, SSH): + +```bash +marmot status [--json] # CPU, RAM, disks, battery +marmot scan [--json] # what can be cleaned, with item ids +marmot clean user_cache dev_junk --dry-run +marmot clean user_cache --keep-cache mozilla yay --yes +marmot undo # restore what the last action moved to Trash +marmot history +marmot optimize [ID] [--dry-run] # maintenance tasks and whether they apply +``` + ### Keyboard Navigation & Controls - `TAB` / `Shift+TAB` : Switch between active Tabs (`Monitor`, `Cleaner`, `Uninstaller`, `Disk Analyzer`, `Optimizer`) diff --git a/pyproject.toml b/pyproject.toml index e39d70b..e5bbf4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ Homepage = "https://github.com/aquistusfami/Marmot" Issues = "https://github.com/aquistusfami/Marmot/issues" [project.scripts] -marmot = "marmot.app:main" +marmot = "marmot.cli:main" [dependency-groups] dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6"] diff --git a/src/marmot/__main__.py b/src/marmot/__main__.py index 02ed752..c4b6232 100644 --- a/src/marmot/__main__.py +++ b/src/marmot/__main__.py @@ -1,3 +1,5 @@ -from marmot.app import main +import sys -main() +from marmot.cli import main + +sys.exit(main()) diff --git a/src/marmot/analyzer.py b/src/marmot/analyzer.py index 7a680d4..ca6094d 100644 --- a/src/marmot/analyzer.py +++ b/src/marmot/analyzer.py @@ -1,16 +1,29 @@ import os +import stat +from collections.abc import Callable from pathlib import Path +class ScanCancelled(Exception): + pass + + class DiskAnalyzer: + """ncdu-like sizes. Every directory size computed is cached, so going into a child or back + to the parent after a scan is instant. `invalidate()` drops the cache (Rescan, after cleaning).""" + def __init__(self): self.current_path = Path.home() - + self._cache: dict[str, int] = {} + def set_path(self, path_str: str): path = Path(path_str) if path.exists() and path.is_dir(): self.current_path = path + def invalidate(self) -> None: + self._cache.clear() + def format_size(self, size_bytes: int) -> str: if size_bytes == 0: return "0 B" @@ -21,50 +34,74 @@ def format_size(self, size_bytes: int) -> str: i += 1 return f"{size_bytes:.2f} {size_name[i]}" - def _recursive_size(self, path_str: str) -> int: - import stat - total = 0 + def _recursive_size(self, path_str: str, cancelled: Callable[[], bool] | None = None) -> int: + """Apparent size of a file, or of a directory tree (symlinks count 0, other filesystems skipped).""" + cached = self._cache.get(path_str) + if cached is not None: + return cached try: st = os.lstat(path_str) - if stat.S_ISREG(st.st_mode): - return st.st_size - elif stat.S_ISLNK(st.st_mode): - return 0 - - for root, _dirs, files in os.walk(path_str): - for f in files: + except OSError: + return 0 + if stat.S_ISLNK(st.st_mode): + return 0 + if not stat.S_ISDIR(st.st_mode): + return st.st_size + return self._dir_size(path_str, st.st_dev, cancelled) + + def _dir_size(self, path_str: str, dev: int, cancelled) -> int: + cached = self._cache.get(path_str) + if cached is not None: + return cached + if cancelled and cancelled(): + raise ScanCancelled + total = 0 + try: + with os.scandir(path_str) as it: + for entry in it: try: - fp = os.path.join(root, f) - fst = os.lstat(fp) - if not stat.S_ISLNK(fst.st_mode): - total += fst.st_size + if entry.is_symlink(): + continue + if entry.is_dir(follow_symlinks=False): + # like `du -x`: don't wander into /proc, network mounts, other disks + if entry.stat(follow_symlinks=False).st_dev == dev: + total += self._dir_size(entry.path, dev, cancelled) + else: + total += entry.stat(follow_symlinks=False).st_size except OSError: pass - except Exception: + except OSError: pass + self._cache[path_str] = total return total - def scan_current(self) -> list: + def scan_current(self, progress: Callable[[int, int, str], None] | None = None, + cancelled: Callable[[], bool] | None = None) -> list: + """Sizes of the current directory's entries, biggest first. + `progress(done, total, name)` is called per entry; raises ScanCancelled if `cancelled()` turns true.""" items = [] try: - for entry in os.scandir(self.current_path): - try: - is_directory = entry.is_dir(follow_symlinks=False) - if is_directory: - size = self._recursive_size(entry.path) - else: - size = entry.stat().st_size - - items.append({ - 'name': entry.name, - 'path': entry.path, - 'size': size, - 'size_str': self.format_size(size), - 'is_dir': is_directory - }) - except OSError: - pass - items.sort(key=lambda x: x['size'], reverse=True) + entries = list(os.scandir(self.current_path)) except Exception: - pass + return items + for n, entry in enumerate(entries): + if progress: + progress(n, len(entries), entry.name) + try: + is_directory = entry.is_dir(follow_symlinks=False) + size = self._recursive_size(entry.path, cancelled) if is_directory else entry.stat().st_size + items.append({ + 'name': entry.name, + 'path': entry.path, + 'size': size, + 'size_str': self.format_size(size), + 'is_dir': is_directory + }) + except OSError: + pass + items.sort(key=lambda x: x['size'], reverse=True) + total = sum(i['size'] for i in items) or 1 + for i in items: + i['percent'] = i['size'] * 100 / total + self._cache[str(self.current_path)] = sum(i['size'] for i in items) return items diff --git a/src/marmot/app.py b/src/marmot/app.py index 50cbf8b..3c350d4 100644 --- a/src/marmot/app.py +++ b/src/marmot/app.py @@ -1,189 +1,139 @@ +"""Marmot TUI. Every slow or privileged action runs in a worker thread so the UI never freezes.""" +import platform +from collections import deque +from pathlib import Path + +from rich.markup import escape from textual.app import App, ComposeResult -from textual.containers import Container, Grid, Horizontal, Vertical +from textual.binding import Binding +from textual.containers import Grid, Horizontal, Vertical, VerticalScroll +from textual.screen import ModalScreen from textual.widgets import ( Button, Checkbox, + DataTable, Footer, Header, Input, Label, ListItem, ListView, - ProgressBar, RichLog, SelectionList, + Sparkline, + Static, TabbedContent, TabPane, ) +from textual.worker import get_current_worker -from marmot.analyzer import DiskAnalyzer +from marmot import trash +from marmot.analyzer import DiskAnalyzer, ScanCancelled from marmot.cleaner import SystemCleaner - -# Import Marmot Modules from marmot.monitor import SystemMonitor from marmot.optimizer import SystemOptimizer from marmot.uninstaller import PackageUninstaller +TABS = [("1", "pane-monitor", "Monitor"), ("2", "pane-cleaner", "Cleaner"), ("3", "pane-uninstaller", "Uninstall"), + ("4", "pane-analyzer", "Analyze"), ("5", "pane-optimizer", "Optimize")] +SHOWN_PACKAGES = 150 -class CPUCard(Container): - DEFAULT_CLASSES = "bento-card" - def compose(self) -> ComposeResult: - yield Label("CPU STATUS", classes="card-title") - self.usage_label = Label("Usage: Calculating...") - yield self.usage_label - self.progress = ProgressBar(total=100, show_percentage=False, show_eta=False) - yield self.progress - self.temp_label = Label("Temp: --") - yield self.temp_label - - def update_stats(self, usage: float, temp: float | None): - self.usage_label.update(f"Usage: {usage:.1f}%") - self.progress.update(progress=int(usage)) - if temp is not None: - self.temp_label.update(f"Temp: {temp:.1f}°C") - else: - self.temp_label.update("Temp: N/A") +def human(n: float) -> str: + if n < 0: + return "?" + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024 or unit == "TB": + return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" + n /= 1024 + return "" -class MemoryCard(Container): - DEFAULT_CLASSES = "bento-card" +def bar(pct: float, width: int = 24) -> str: + """Coloured text bar: green < 60% < yellow < 85% < red.""" + pct = max(0.0, min(100.0, pct)) + filled = round(pct / 100 * width) + color = "green" if pct < 60 else "yellow" if pct < 85 else "red" + return f"[{color}]{'━' * filled}[/][dim]{'━' * (width - filled)}[/]" - def compose(self) -> ComposeResult: - yield Label("MEMORY STATUS", classes="card-title") - self.ram_label = Label("RAM: Calculating...") - yield self.ram_label - self.ram_progress = ProgressBar(total=100, show_percentage=False, show_eta=False) - yield self.ram_progress - self.swap_label = Label("Swap: Calculating...") - yield self.swap_label - self.swap_progress = ProgressBar(total=100, show_percentage=False, show_eta=False) - yield self.swap_progress - - def update_stats(self, mem_info: dict): - total_gb = mem_info['total'] / (1024**3) - used_gb = mem_info['used'] / (1024**3) - self.ram_label.update(f"RAM: {used_gb:.2f} GB / {total_gb:.2f} GB ({mem_info['percent']:.1f}%)") - self.ram_progress.update(progress=int(mem_info['percent'])) - - swap_total_gb = mem_info['swap_total'] / (1024**3) - swap_used_gb = mem_info['swap_used'] / (1024**3) - self.swap_label.update(f"Swap: {swap_used_gb:.2f} GB / {swap_total_gb:.2f} GB ({mem_info['swap_percent']:.1f}%)") - self.swap_progress.update(progress=int(mem_info['swap_percent'])) - - -class NetworkCard(Container): - DEFAULT_CLASSES = "bento-card" - def compose(self) -> ComposeResult: - yield Label("NETWORK BANDWIDTH", classes="card-title") - self.net_label = Label("Calculating network statistics...") - yield self.net_label +def os_name() -> str: + try: + return platform.freedesktop_os_release().get("PRETTY_NAME", "Linux") + except OSError: + return "Linux" - def update_stats(self, net_info: dict): - if not net_info: - self.net_label.update("No active network interfaces found.") - return - - lines = [] - for iface, data in net_info.items(): - rx_speed = data['rx_speed'] / 1024.0 # KB/s - tx_speed = data['tx_speed'] / 1024.0 # KB/s - rx_total = data['rx_total'] / (1024**2) # MB - tx_total = data['tx_total'] / (1024**2) # MB - - rx_str = f"{rx_speed:.1f} KB/s" if rx_speed < 1024 else f"{rx_speed/1024:.2f} MB/s" - tx_str = f"{tx_speed:.1f} KB/s" if tx_speed < 1024 else f"{tx_speed/1024:.2f} MB/s" - - lines.append(f"[bold]{iface}:[/bold] Down: [cyan]{rx_str}[/] ([dim]{rx_total:.0f} MB[/]) | Up: [magenta]{tx_str}[/] ([dim]{tx_total:.0f} MB[/])") - - self.net_label.update("\n".join(lines)) - - -class BatteryCard(Container): - DEFAULT_CLASSES = "bento-card" - def compose(self) -> ComposeResult: - yield Label("BATTERY STATUS", classes="card-title") - self.cap_label = Label("Battery: Checking...") - yield self.cap_label - self.progress = ProgressBar(total=100, show_percentage=False, show_eta=False) - yield self.progress - self.status_label = Label("Status: --") - yield self.status_label - - def update_stats(self, bat_info: dict): - if not bat_info['present'] or bat_info['capacity'] is None: - self.cap_label.update("Battery: Not detected / Desktop power") - self.progress.update(progress=0) - self.status_label.update("Status: AC Power Connection") - return - - cap = bat_info['capacity'] - self.cap_label.update(f"Battery Capacity: {cap}%") - self.progress.update(progress=cap) - - status = bat_info['status'] - power = bat_info['power'] - power_str = f" ({power:.2f} W)" if power is not None else "" - self.status_label.update(f"Status: {status}{power_str}") +def uptime(s: float) -> str: + d, rem = divmod(int(s), 86400) + h, m = divmod(rem // 60, 60) + return (f"{d}d " if d else "") + f"{h}h {m:02}m" + + +class Card(Vertical): + """Bordered panel with a title in the border.""" + + def __init__(self, title: str, *children, **kw): + super().__init__(*children, **kw) + self.border_title = title -class DiskCard(Container): - DEFAULT_CLASSES = "bento-card" +class ConfirmScreen(ModalScreen[bool]): + BINDINGS = [("escape", "dismiss(False)", "Cancel"), ("y", "dismiss(True)", "Yes"), ("n", "dismiss(False)", "No")] + + def __init__(self, message: str, confirm: str = "Confirm"): + super().__init__() + self.message, self.confirm = message, confirm def compose(self) -> ComposeResult: - yield Label("DISK VOLUMES", classes="card-title") - self.disk_label = Label("Calculating storage info...") - yield self.disk_label + with Vertical(id="confirm-box"): + yield Static(self.message, id="confirm-msg") + with Horizontal(classes="button-row"): + yield Button(self.confirm, variant="error", id="confirm-yes") + yield Button("Cancel", id="confirm-no") - def update_stats(self, disk_info: list): - if not disk_info: - self.disk_label.update("No disks detected.") - return - - lines = [] - for d in disk_info: - total_gb = d['total'] / (1024**3) - used_gb = d['used'] / (1024**3) - - bar_len = 16 - filled_len = int(bar_len * d['percent'] / 100) - bar = "█" * filled_len + "░" * (bar_len - filled_len) - - lines.append( - f"[bold]{d['mountpoint']}[/bold] [dim]({d['device']})[/dim]\n" - f"Used: {used_gb:.1f} GB / {total_gb:.1f} GB ({d['percent']:.1f}%)\n" - f"[cyan]{bar}[/]" - ) - self.disk_label.update("\n\n".join(lines)) + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(event.button.id == "confirm-yes") class PackageListItem(ListItem): def __init__(self, package_info: dict): super().__init__() self.package_info = package_info - + def compose(self) -> ComposeResult: - yield Label(f"[bold cyan]{self.package_info['name']}[/bold cyan] ([dim]{self.package_info['version']}[/dim])\n[dim]{self.package_info['summary']}[/dim]") + p = self.package_info + tag = " [magenta]flatpak[/]" if p.get('source') == 'flatpak' else "" + yield Label(f"[b]{escape(p['name'])}[/b] [dim]{escape(p['version'])}[/]{tag}") class DiskListItem(ListItem): def __init__(self, item_info: dict): super().__init__() self.item_info = item_info - + def compose(self) -> ComposeResult: - prefix = "[DIR] " if self.item_info['is_dir'] else " " - name_style = "[bold cyan]" if self.item_info['is_dir'] else "[white]" - yield Label(f"{prefix}{name_style}{self.item_info['name']}[/] - [magenta]{self.item_info['size_str']}[/]") + info = self.item_info + pct = info.get('percent', 0) + name = escape(info['name']) + if info['is_dir']: + name = f"[b $accent]{name}/[/]" + yield Label(f"{bar(pct, 16)} {pct:5.1f}% {info['size_str']:>10} {name}") + + +class AnalyzerList(ListView): + BINDINGS = [ + Binding("backspace", "app.analyzer_up", "Up"), + Binding("r", "app.analyzer_rescan", "Rescan"), + Binding("d", "app.analyzer_trash", "Move to Trash"), + ] class MarmotApp(App): CSS_PATH = "marmot.tcss" - BINDINGS = [ - ("q", "quit", "Quit"), - ("tab", "next_tab", "Switch Tab"), + TITLE = "Marmot" + BINDINGS = [Binding(key, f"show_tab('{tab}')", label) for key, tab, label in TABS] + [ + Binding("q", "quit", "Quit"), ] def __init__(self): @@ -193,335 +143,498 @@ def __init__(self): self.optimizer = SystemOptimizer() self.uninstaller = PackageUninstaller() self.analyzer = DiskAnalyzer() - - # Scan status data caching + self.clean_scan_data = {} + self.all_packages = [] self.current_packages = [] + self.cpu_history = deque([0.0] * 60, maxlen=60) + self.net_history = deque([0.0] * 60, maxlen=60) + pm = self.uninstaller.pm + self.sub_title = f"{os_name()} · {pm['label'] if pm else 'no package manager'}" + + # ---- layout ------------------------------------------------------------- def compose(self) -> ComposeResult: - yield Header(show_clock=True) - + yield Header() with TabbedContent(id="tabs-main"): - # 1. Live Monitor with TabPane("Monitor", id="pane-monitor"): - with Vertical(classes="monitor-container"): - with Horizontal(classes="monitor-row"): - self.cpu_card = CPUCard() - self.mem_card = MemoryCard() - yield self.cpu_card - yield self.mem_card - with Horizontal(classes="monitor-row"): - self.net_card = NetworkCard() - self.disk_card = DiskCard() - self.bat_card = BatteryCard() - yield self.net_card - yield self.disk_card - yield self.bat_card - - # 2. Deep Cleaner + with Grid(id="monitor-grid"): + yield Card("CPU", Static(id="cpu-text"), Sparkline(list(self.cpu_history), id="cpu-spark")) + yield Card("Memory", Static(id="mem-text")) + yield Card("System", Static(id="sys-text")) + yield Card("Network", Static(id="net-text"), Sparkline(list(self.net_history), id="net-spark")) + yield Card("Disks", Static(id="disk-text"), id="disk-card") + yield Card("Top processes", DataTable(id="proc-table", cursor_type="none", zebra_stripes=True), + id="proc-card") + with TabPane("Cleaner", id="pane-cleaner"): with Horizontal(): - with Vertical(id="cleaner-options-panel", classes="half-width-pad-right"): - yield Label("[bold]Select areas to clean:[/bold]\n") - yield Checkbox("User Caches (~/.cache)", value=True, id="chk-user-cache") - yield Checkbox("System Trash Bin", value=True, id="chk-trash") - pm = self.cleaner.pm - if pm: - yield Checkbox(f"{pm['label']} Package Cache", value=False, id="chk-pkg-cache") - yield Checkbox("Systemd Journal Logs", value=False, id="chk-systemd-logs") - yield Checkbox("Developer Build Files (node_modules, target...)", value=False, id="chk-dev-junk") - - with Horizontal(classes="margin-top-2"): - yield Button("Scan Storage", variant="primary", id="btn-clean-scan") - yield Button("Clean Selected", variant="error", id="btn-clean-run", classes="margin-left-2") - - with Vertical(classes="half-width"): - yield Label("[bold]Operation Logs:[/bold]") - self.clean_log = RichLog(highlight=True, classes="log-view") - yield self.clean_log - - # 3. Smart Uninstaller + with Vertical(id="clean-left"): + yield Card("Areas to clean", SelectionList(id="list-clean-items"), id="clean-items-card") + yield Card("Cache folders · untick to keep", SelectionList(id="list-clean-cache"), + id="clean-cache-card") + yield Checkbox("Move developer files to Trash (undoable)", value=True, id="chk-use-trash") + with Horizontal(classes="button-row"): + yield Button("Scan", variant="primary", id="btn-clean-scan") + yield Button("Preview", id="btn-clean-preview") + yield Button("Clean", variant="error", id="btn-clean-run") + yield Button("Undo", id="btn-clean-undo") + with Vertical(id="clean-right"): + yield Card("Summary", Static("Press [b]Scan[/b] to measure what can be cleaned.", + id="clean-summary"), id="clean-summary-card") + yield Card("Log", RichLog(markup=True, wrap=True, id="clean-log"), classes="log-card") + with TabPane("Uninstaller", id="pane-uninstaller"): with Horizontal(): - with Vertical(classes="forty-width-pad-right"): - yield Label("[bold]Search installed packages:[/bold]") - self.uninstall_search = Input(placeholder="Type package name...", id="input-uninstall-search") - yield self.uninstall_search - self.uninstall_list = ListView(id="list-packages") - yield self.uninstall_list - - with Vertical(classes="sixty-width"): - yield Label("[bold]Package Information & Remnants:[/bold]") - self.uninstall_info = Label("Select a package from the list to view details.", id="lbl-uninstall-info") - yield self.uninstall_info - # remnant dirs start unchecked: the user opts in to each deletion - self.uninstall_remnants = SelectionList(id="list-uninstall-remnants") - self.uninstall_remnants.display = False - yield self.uninstall_remnants - self.uninstall_btn = Button("Purge Package & Configuration Remnants", variant="error", id="btn-uninstall-purge", disabled=True) - yield self.uninstall_btn - yield Label("[bold]Uninstaller Execution Log:[/bold]", classes="margin-top-1") - self.uninstall_log = RichLog(highlight=True, classes="log-view") - yield self.uninstall_log - - # 4. Disk Analyzer + with Vertical(id="un-left"): + yield Input(placeholder="Search packages…", id="input-uninstall-search") + yield Label("", id="lbl-package-count") + yield ListView(id="list-packages") + with Vertical(id="un-right"): + yield Card("Package", Static("Select a package to see what removing it would do.", + id="lbl-uninstall-info"), id="un-info-card") + yield SelectionList(id="list-uninstall-remnants") + yield Button("Uninstall", variant="error", id="btn-uninstall-purge", disabled=True) + yield Card("Log", RichLog(markup=True, wrap=True, id="uninstall-log"), classes="log-card") + with TabPane("Disk Analyzer", id="pane-analyzer"): - yield Label("[bold]Visual Disk Space Usage (ncdu alternative)[/bold]") - self.analyzer_path_lbl = Label(f"Current Directory: {self.analyzer.current_path}", id="lbl-analyzer-path") - yield self.analyzer_path_lbl - with Horizontal(classes="analyzer-header"): - yield Button("Scan Current Directory", variant="primary", id="btn-analyzer-scan") - yield Button("Go to Parent (..)", variant="default", id="btn-analyzer-up", classes="margin-left-2") - self.analyzer_list = ListView(id="list-analyzer") - yield self.analyzer_list - - # 5. System Optimizer + with Horizontal(id="analyzer-toolbar"): + yield Static("", id="lbl-analyzer-path") + yield Button("Up", id="btn-analyzer-up") + yield Button("Rescan", variant="primary", id="btn-analyzer-scan") + yield AnalyzerList(id="list-analyzer") + yield Static("[dim]Enter opens a folder. [b]d[/b] moves the highlighted item to Trash (only inside " + "your home); Undo in the Cleaner tab or `marmot undo` brings it back.[/]", id="analyzer-hint") + with TabPane("Optimizer", id="pane-optimizer"): - with Horizontal(): - with Vertical(classes="half-width-pad-right"): - yield Label("[bold]Available Optimization Tweak Actions:[/bold]\n") - with Grid(classes="optimize-grid"): - yield Button("Drop RAM Page Cache", id="btn-opt-drop-caches", variant="primary") - yield Button("Flush System DNS", id="btn-opt-flush-dns", variant="primary") - yield Button("Reset Failed Services", id="btn-opt-reset-services", variant="primary") - yield Button("Reclaim Swap Space", id="btn-opt-swap-reset", variant="primary") - with Vertical(classes="half-width"): - yield Label("[bold]Optimizer Execution Log:[/bold]") - self.optimize_log = RichLog(highlight=True, classes="log-view") - yield self.optimize_log - + with VerticalScroll(id="opt-list"): + for t in self.optimizer.get_optimizations(): + with Horizontal(classes="opt-row"): + with Vertical(classes="opt-text"): + yield Static(f"[b]{t['name']}[/b]") + yield Static(f"[dim]{t['desc']}[/]") + yield Static("[dim]checking…[/]", id=f"opt-status-{t['id']}", classes="opt-status") + yield Button("Run", id=f"opt-run-{t['id']}", disabled=True, + variant="warning" if t['id'] == "drop_caches" else "primary") + with Horizontal(classes="button-row"): + yield Button("Re-check", id="btn-opt-refresh") + yield Card("Log", RichLog(markup=True, wrap=True, id="opt-log"), classes="log-card", id="opt-log-card") yield Footer() def on_mount(self) -> None: - # Start update timer for monitor dashboard (1-second interval) - self.set_interval(1.0, self.update_monitor_dashboard) - - # Populate initial packages list - self.refresh_packages_list() - - # Trigger initial monitor fetch + # Widgets are looked up once: timers and worker callbacks can fire while the app is shutting down, + # when a query_one() by id would raise NoMatches. + self.w = {node.id: node for node in self.screen.query("*") if node.id} + if "tokyo-night" in self.available_themes: + self.theme = "tokyo-night" + self.clean_items = self.w["list-clean-items"] + self.clean_cache = self.w["list-clean-cache"] + self.clean_log = self.w["clean-log"] + self.uninstall_search = self.w["input-uninstall-search"] + self.uninstall_list = self.w["list-packages"] + self.uninstall_info = self.w["lbl-uninstall-info"] + self.uninstall_remnants = self.w["list-uninstall-remnants"] + self.uninstall_btn = self.w["btn-uninstall-purge"] + self.uninstall_log = self.w["uninstall-log"] + self.analyzer_list = self.w["list-analyzer"] + self.analyzer_path_lbl = self.w["lbl-analyzer-path"] + self.optimize_log = self.w["opt-log"] + self.w["clean-cache-card"].display = False + self.uninstall_remnants.display = False + self.w["proc-table"].add_columns("PID", "Name", "CPU %", "Memory") + self.update_monitor_dashboard() + self.update_processes() + self.set_interval(1.0, self.update_monitor_dashboard) + self.set_interval(2.0, self.update_processes) + self.run_worker(self._load_packages_worker, thread=True, group="packages") + self.refresh_optimizer_checks() + + def action_show_tab(self, tab: str) -> None: + self.w["tabs-main"].active = tab + + def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None: + if event.pane.id == "pane-analyzer" and not self.analyzer_list.children: + self.refresh_analyzer() + + # ---- monitor ---------------------------------------------------------------- def update_monitor_dashboard(self) -> None: - # Fetch stats cpu = self.monitor.get_cpu_usage() temp = self.monitor.get_temperature() mem = self.monitor.get_memory_info() net = self.monitor.get_network_io() bat = self.monitor.get_battery_info() disks = self.monitor.get_disk_usage() - - # Update widgets - self.cpu_card.update_stats(cpu, temp) - self.mem_card.update_stats(mem) - self.net_card.update_stats(net) - self.disk_card.update_stats(disks) - - if not bat.get("present", False): - self.bat_card.styles.display = "none" - else: - self.bat_card.styles.display = "block" - self.bat_card.update_stats(bat) - - # --- TAB NAVIGATION ACTION --- - def action_next_tab(self) -> None: - tabs = self.query_one("#tabs-main", TabbedContent) - current = tabs.active - # Simple cycling - tab_ids = ["pane-monitor", "pane-cleaner", "pane-uninstaller", "pane-analyzer", "pane-optimizer"] - try: - idx = tab_ids.index(current) - next_idx = (idx + 1) % len(tab_ids) - tabs.active = tab_ids[next_idx] - except ValueError: - pass + sysinfo = self.monitor.get_system_info() + + self.cpu_history.append(cpu) + temp_s = f" [dim]temp[/] {temp:.0f}°C" if temp is not None else "" + self.w["cpu-text"].update(f"[b]{cpu:5.1f}%[/b]{temp_s}\n{bar(cpu)}") + self.w["cpu-spark"].data = list(self.cpu_history) + + swap = (f"\n[dim]Swap[/] {human(mem['swap_used'])} / {human(mem['swap_total'])}\n{bar(mem['swap_percent'])}" + if mem['swap_total'] else "\n[dim]No swap[/]") + self.w["mem-text"].update( + f"[dim]RAM[/] {human(mem['used'])} / {human(mem['total'])} [b]{mem['percent']:.0f}%[/b]\n" + f"{bar(mem['percent'])}{swap}") + + load = " ".join(f"{x:.2f}" for x in sysinfo['load']) + lines = [f"[dim]Uptime[/] {uptime(sysinfo['uptime_s'])}", + f"[dim]Load[/] {load} [dim]({sysinfo['cpus']} CPUs)[/]", + f"[dim]Kernel[/] {platform.release()}"] + if bat.get('present') and bat.get('capacity') is not None: + power = f" · {bat['power']:.1f} W" if bat.get('power') else "" + lines.append(f"[dim]Battery[/] {bat['capacity']}% {bat['status']}{power}\n{bar(100 - bat['capacity'])}") + self.w["sys-text"].update("\n".join(lines)) + + phys = {k: v for k, v in net.items() if self.monitor.is_physical_iface(k)} or net + rx = sum(v['rx_speed'] for v in phys.values()) + tx = sum(v['tx_speed'] for v in phys.values()) + self.net_history.append(rx + tx) + per_if = "\n".join(f"[dim]{escape(k)}[/] ↓{human(v['rx_speed'])}/s ↑{human(v['tx_speed'])}/s" + for k, v in phys.items()) + self.w["net-text"].update(f"[b]↓ {human(rx)}/s ↑ {human(tx)}/s[/b]\n{per_if}") + self.w["net-spark"].data = list(self.net_history) + + self.w["disk-text"].update("\n".join( + f"{escape(d['mountpoint']):<14} {bar(d['percent'], 30)} {d['percent']:5.1f}% " + f"[dim]{human(d['used'])} / {human(d['total'])} · {escape(d['device'])}[/]" for d in disks) + or "No disks detected.") + + def update_processes(self) -> None: + table = self.w["proc-table"] + table.clear() + for p in self.monitor.get_top_processes(8): + table.add_row(str(p['pid']), escape(p['name']), f"{p['cpu']:.1f}", human(p['mem'])) + + # ---- buttons ------------------------------------------------------------------ - # --- CLEANER TAB LOGIC --- def on_button_pressed(self, event: Button.Pressed) -> None: - button_id = event.button.id - - if button_id == "btn-clean-scan": + bid = event.button.id or "" + if bid == "btn-clean-scan": self.run_clean_scan() - elif button_id == "btn-clean-run": + elif bid == "btn-clean-run": self.run_clean_execution() - elif button_id == "btn-opt-drop-caches": - self.run_tweak("drop_caches") - elif button_id == "btn-opt-flush-dns": - self.run_tweak("flush_dns") - elif button_id == "btn-opt-reset-services": - self.run_tweak("reset_services") - elif button_id == "btn-opt-swap-reset": - self.run_tweak("swap_reset") - elif button_id == "btn-uninstall-purge": + elif bid == "btn-clean-preview": + self.run_clean_execution(dry_run=True) + elif bid == "btn-clean-undo": + self.run_undo() + elif bid == "btn-uninstall-purge": self.run_purge_execution() - elif button_id == "btn-analyzer-scan": - self.refresh_analyzer() - elif button_id == "btn-analyzer-up": - # Navigate to parent - parent = self.analyzer.current_path.parent - if parent != self.analyzer.current_path: - self.analyzer.set_path(str(parent)) - self.analyzer_path_lbl.update(f"Current Directory: {self.analyzer.current_path}") - self.refresh_analyzer() + elif bid == "btn-analyzer-scan": + self.action_analyzer_rescan() + elif bid == "btn-analyzer-up": + self.action_analyzer_up() + elif bid == "btn-opt-refresh": + self.refresh_optimizer_checks() + elif bid.startswith("opt-run-"): + self.run_tweak(bid.removeprefix("opt-run-")) + + # ---- cleaner ------------------------------------------------------------------ + + def _set_cleaner_busy(self, busy: bool) -> None: + for bid in ("btn-clean-scan", "btn-clean-preview", "btn-clean-run", "btn-clean-undo"): + self.w[f"{bid}"].disabled = busy def run_clean_scan(self) -> None: - self.clean_log.write("Starting storage scan...") - self.clean_scan_data = self.cleaner.scan() - self.clean_log.write("Scan completed successfully.\n") - - # Display sizes inside checkboxes - for key, val in self.clean_scan_data.items(): - self.clean_log.write(f"- [bold]{val['name']}[/]: {val['size_str']} ({val['desc']})") - - # Update check label with size info dynamically - if key == 'user_cache': - self.query_one("#chk-user-cache", Checkbox).label = f"User Caches: {val['size_str']}" - elif key == 'trash': - self.query_one("#chk-trash", Checkbox).label = f"System Trash Bin: {val['size_str']}" - elif key == 'pkg_cache': - self.query_one("#chk-pkg-cache", Checkbox).label = f"{val['name']}: {val['size_str']}" - elif key == 'systemd_logs': - self.query_one("#chk-systemd-logs", Checkbox).label = f"Systemd Journal Logs: {val['size_str']}" - elif key == 'dev_junk': - self.query_one("#chk-dev-junk", Checkbox).label = f"Developer Build Files: {val['size_str']}" - - def run_clean_execution(self) -> None: + self._set_cleaner_busy(True) + self.w["clean-summary"].update("[yellow]Scanning…[/]") + self.run_worker(self._clean_scan_worker, thread=True, exclusive=True, group="cleaner") + + def _clean_scan_worker(self) -> None: + data = self.cleaner.scan() + self.call_from_thread(self._show_clean_scan, data) + + def _show_clean_scan(self, data: dict) -> None: + previously = set(self.clean_items.selected) if self.clean_scan_data else {"user_cache", "trash"} + self.clean_scan_data = data + self.clean_items.clear_options() + self.clean_items.add_options([ + (f"{escape(v['name'])} [b]{v['size_str']}[/b]" + (" [dim]root[/]" if v['requires_sudo'] else ""), + k, k in previously) for k, v in data.items()]) + children = data.get('user_cache', {}).get('children', []) + self.clean_cache.clear_options() + self.clean_cache.add_options([(f"{escape(c['name'])} [dim]{c['size_str']}[/]", c['path'], True) + for c in children[:40]]) + self.w["clean-cache-card"].display = bool(children) and "user_cache" in self.clean_items.selected + self.clean_log.write("[dim]Scan completed.[/]") + self._update_clean_summary() + self._set_cleaner_busy(False) + + def on_selection_list_selected_changed(self, event: SelectionList.SelectedChanged) -> None: + if event.selection_list.id in ("list-clean-items", "list-clean-cache") and self.clean_scan_data: + children = self.clean_scan_data.get('user_cache', {}).get('children', []) + self.w["clean-cache-card"].display = bool(children) and "user_cache" in self.clean_items.selected + self._update_clean_summary() + + def _update_clean_summary(self) -> None: + sel = [k for k in self.clean_items.selected if k in self.clean_scan_data] + total, unknown, root = 0, 0, 0 + for k in sel: + v = self.clean_scan_data[k] + if k == 'user_cache' and v.get('children'): + picked = set(self._clean_args()['selected']) + total += sum(c['size'] for c in v['children'] if c['path'] in picked) + elif v['size'] < 0: + unknown += 1 + else: + total += v['size'] + root += v['requires_sudo'] + everything = sum(max(v['size'], 0) for v in self.clean_scan_data.values()) + extra = (f"\n[dim]+ {unknown} item(s) of unknown size[/]" if unknown else "") + \ + (f"\n[dim]{root} item(s) will ask for your password[/]" if root else "") + self.w["clean-summary"].update( + f"Selected [b $success]{human(total)}[/] of {human(everything)} found{extra}") + + def _clean_args(self) -> dict: + cache = list(self.clean_cache.selected) + children = self.clean_scan_data.get('user_cache', {}).get('children', []) + # folders beyond the 40 shown are cleaned only when every shown one is ticked + if len(children) > 40 and len(cache) == min(40, len(children)): + cache = [c['path'] for c in children] + return {'selected': cache, 'use_trash': self.w["chk-use-trash"].value} + + def run_clean_execution(self, dry_run: bool = False) -> None: if not self.clean_scan_data: - self.clean_log.write("[warning]Please scan storage first before cleaning.[/]") + self.clean_log.write("[yellow]Please scan first.[/]") + return + items = [i for i in self.clean_items.selected if i in self.clean_scan_data] + if not items: + self.clean_log.write("[yellow]Nothing selected.[/]") return - - chk_mapping = { - 'user_cache': "#chk-user-cache", - 'trash': "#chk-trash", - 'pkg_cache': "#chk-pkg-cache", - 'systemd_logs': "#chk-systemd-logs", - 'dev_junk': "#chk-dev-junk" - } - + args = self._clean_args() + + def start(confirmed: bool | None = True) -> None: + if confirmed: + self._set_cleaner_busy(True) + self.run_worker(lambda: self._clean_worker(items, dry_run, args), thread=True, exclusive=True, + group="cleaner") + + if dry_run: + start() + else: + names = "\n".join(f" • {escape(self.clean_scan_data[i]['name'])}" for i in items) + self.push_screen(ConfirmScreen(f"Clean these items?\n\n{names}", "Clean"), start) + + def _clean_worker(self, items: list, dry_run: bool, args: dict) -> None: cleaned_any = False - for item_id, selector in chk_mapping.items(): - if item_id not in self.clean_scan_data: - continue # e.g. no package cache on an unsupported distro - chk = self.query_one(selector, Checkbox) - if chk.value: - self.clean_log.write(f"Cleaning {self.clean_scan_data[item_id]['name']}...") - success, msg = self.cleaner.clean(item_id, self.clean_scan_data) - if success: - self.clean_log.write(f"[success]✓ {msg}[/]") - cleaned_any = True - else: - self.clean_log.write(f"[danger]✗ {msg}[/]") - + for item_id in items: + name = self.clean_scan_data[item_id]['name'] + self.call_from_thread(self.clean_log.write, f"{'Preview' if dry_run else 'Cleaning'}: {escape(name)}") + success, msg = self.cleaner.clean( + item_id, self.clean_scan_data, dry_run=dry_run, use_trash=args['use_trash'], + selected=args['selected'] if item_id == 'user_cache' else None) + style = "green" if success else "red" + self.call_from_thread(self.clean_log.write, f" [{style}]{'✓' if success else '✗'} {escape(msg)}[/]") + cleaned_any |= success and not dry_run if cleaned_any: - # Refresh Scan data - self.run_clean_scan() + self.analyzer.invalidate() + self._clean_scan_worker() # refresh sizes (still in this worker thread) + else: + self.call_from_thread(self._set_cleaner_busy, False) + + def run_undo(self) -> None: + entry, restored, errors = trash.undo_last() + if entry is None: + self.clean_log.write("[yellow]Nothing to undo (only actions that moved files to Trash can be undone).[/]") + return + self.clean_log.write(f"[green]↶ Undid '{entry['action']}' from {entry['ts']}: restored {len(restored)} item(s)[/]") + for e in errors: + self.clean_log.write(f"[red]✗ {escape(e)}[/]") + self.analyzer.invalidate() + + # ---- uninstaller ---------------------------------------------------------------- + + def _load_packages_worker(self) -> None: + pkgs = self.uninstaller.list_installed_packages(limit=None) + self.call_from_thread(self._packages_loaded, pkgs) + + def _packages_loaded(self, pkgs: list) -> None: + self.all_packages = pkgs + self.refresh_packages_list(self.uninstall_search.value) - # --- UNINSTALLER TAB LOGIC --- def on_input_changed(self, event: Input.Changed) -> None: if event.input.id == "input-uninstall-search": self.refresh_packages_list(event.value) def refresh_packages_list(self, query: str = "") -> None: + q = query.lower() + matches = [p for p in self.all_packages + if not q or q in p['name'].lower() or q in p['summary'].lower()] + self.current_packages = matches[:SHOWN_PACKAGES] self.uninstall_list.clear() - self.current_packages = self.uninstaller.list_installed_packages(query) - for pkg in self.current_packages: - self.uninstall_list.append(PackageListItem(pkg)) + self.uninstall_list.extend(PackageListItem(p) for p in self.current_packages) + more = f" · showing {SHOWN_PACKAGES}, type to narrow" if len(matches) > SHOWN_PACKAGES else "" + self.w["lbl-package-count"].update( + f"[dim]{len(matches)} of {len(self.all_packages)} packages{more}[/]") def on_list_view_selected(self, event: ListView.Selected) -> None: - if event.list_view.id == "list-packages": - item = event.item - if item and hasattr(item, "package_info"): - pkg = item.package_info - - removal = self.uninstaller.preview_removal(pkg['name']) - if removal is None: - removal_text = "\n\n[bold red]Cannot preview removal (package required by others?). Purge disabled.[/]" - else: - extra = [n for n in removal if n != pkg['name']] - removal_text = f"\n\n[bold]Will remove {len(removal)} package(s):[/] {', '.join(removal)}" - if extra: - removal_text += f"\n[bold yellow]Warning: {len(extra)} other package(s) will also be removed.[/]" - - remnants = self.uninstaller.find_remnants(pkg['name']) - self.uninstall_remnants.clear_options() - self.uninstall_remnants.add_options([(f"{r['path']} ({r['size_str']})", r['path']) for r in remnants]) - self.uninstall_remnants.display = bool(remnants) - remnant_text = ("\n\n[bold yellow]Remnant directories found (tick to delete them too):[/]" - if remnants else "\n\nNo config/cache remnants detected.") - - info = ( - f"[bold]{pkg['name']}[/bold]\n" - f"Version: {pkg['version']}\n" - f"Summary: {pkg['summary']}" - f"{removal_text}" - f"{remnant_text}" - ) - self.uninstall_info.update(info) - self.selected_package = pkg['name'] - self.uninstall_btn.disabled = removal is None - - elif event.list_view.id == "list-analyzer": - item = event.item - if item and hasattr(item, "item_info"): - info = item.item_info - if info['is_dir']: - self.analyzer.set_path(info['path']) - self.analyzer_path_lbl.update(f"Current Directory: {self.analyzer.current_path}") - self.refresh_analyzer() + item = event.item + if event.list_view.id == "list-packages" and hasattr(item, "package_info"): + self._show_package(item.package_info) + elif event.list_view.id == "list-analyzer" and hasattr(item, "item_info"): + if item.item_info['is_dir']: + self.analyzer.set_path(item.item_info['path']) + self.refresh_analyzer() + + def _show_package(self, pkg: dict) -> None: + source = pkg.get('source', 'system') + removal = self.uninstaller.preview_removal(pkg['name'], source) + if removal is None: + removal_text = "[b red]Cannot be removed: other packages need it (or the preview failed).[/]" + else: + extra = [n for n in removal if n != pkg['name']] + removal_text = f"[b]Removes {len(removal)} package(s):[/] {escape(', '.join(removal))}" + if extra: + removal_text += f"\n[b yellow]⚠ {len(extra)} other package(s) go with it.[/]" + + remnants = self.uninstaller.find_remnants(pkg['name'], source) + self.uninstall_remnants.clear_options() + self.uninstall_remnants.add_options([(f"{escape(r['path'])} [dim]{r['size_str']}[/]", r['path']) + for r in remnants]) + self.uninstall_remnants.display = bool(remnants) + if source == 'flatpak': + remnant_text = "App data in ~/.var/app is removed with it." + elif remnants: + remnant_text = "[yellow]Leftover folders below: tick the ones to move to Trash.[/]" + else: + remnant_text = "[dim]No leftover config/cache folders.[/]" + self.uninstall_info.update( + f"[b]{escape(pkg['name'])}[/b] [dim]{escape(pkg['version'])}[/]\n{escape(pkg['summary'])}\n\n" + f"{removal_text}\n\n{remnant_text}") + self.selected_package = pkg['name'] + self.selected_source = source + self.uninstall_btn.disabled = removal is None def run_purge_execution(self) -> None: if not hasattr(self, "selected_package"): return - - self.uninstall_log.write(f"Executing purge on {self.selected_package}...") - success, msg = self.uninstaller.uninstall(self.selected_package, list(self.uninstall_remnants.selected)) - - if success: - self.uninstall_log.write(f"[success]✓ {msg}[/]") - self.uninstall_info.update("Select a package from the list to view details.") + args = (self.selected_package, list(self.uninstall_remnants.selected), self.selected_source) + + def go(confirmed: bool | None) -> None: + if not confirmed: + return self.uninstall_btn.disabled = True + self.uninstall_log.write(f"Removing {escape(args[0])}…") + self.run_worker(lambda: self._purge_worker(*args), thread=True, exclusive=True, group="uninstall") + + self.push_screen(ConfirmScreen(f"Uninstall [b]{escape(args[0])}[/b]?", "Uninstall"), go) + + def _purge_worker(self, package: str, remnants: list, source: str) -> None: + success, msg = self.uninstaller.uninstall(package, remnants, source) + if success: + self._load_packages_worker() + self.call_from_thread(self._purge_done, success, msg) + + def _purge_done(self, success: bool, msg: str) -> None: + if success: + self.uninstall_log.write(f"[green]✓ {escape(msg)}[/]") + self.uninstall_info.update("Select a package to see what removing it would do.") self.uninstall_remnants.clear_options() self.uninstall_remnants.display = False - # Refresh package list - self.refresh_packages_list(self.uninstall_search.value) else: - self.uninstall_log.write(f"[danger]✗ {msg}[/]") + self.uninstall_log.write(f"[red]✗ {escape(msg)}[/]") + self.uninstall_btn.disabled = False - # --- OPTIMIZER TAB LOGIC --- - def run_tweak(self, tweak_id: str) -> None: - self.optimize_log.write(f"Running optimization task: {tweak_id}...") - success, msg = self.optimizer.run_optimization(tweak_id) - if success: - self.optimize_log.write(f"[success]✓ {msg}[/]\n") - else: - self.optimize_log.write(f"[danger]✗ {msg}[/]\n") + # ---- optimizer ------------------------------------------------------------------ + + def refresh_optimizer_checks(self) -> None: + self.run_worker(self._optimizer_check_worker, thread=True, exclusive=True, group="optimizer") + + def _optimizer_check_worker(self) -> None: + for t in self.optimizer.get_optimizations(): + st = self.optimizer.check(t['id']) + self.call_from_thread(self._show_check, t['id'], st) + + def _show_check(self, task_id: str, st: dict) -> None: + icon = "[green]●[/]" if st['applicable'] else "[dim]○[/]" + self.w[f"opt-status-{task_id}"].update(f"{icon} {escape(st['status'])}") + self.w[f"opt-run-{task_id}"].disabled = not st['applicable'] + + def run_tweak(self, task_id: str) -> None: + self.w[f"opt-run-{task_id}"].disabled = True + self.optimize_log.write(f"Running {task_id}…") + + def work() -> None: + ok, msg = self.optimizer.run_optimization(task_id) + self.call_from_thread(self.optimize_log.write, f"[{'green' if ok else 'red'}]{'✓' if ok else '✗'} " + f"{escape(msg)}[/]") + self._optimizer_check_worker() + + self.run_worker(work, thread=True, exclusive=True, group="optimizer") + + # ---- analyzer ------------------------------------------------------------------- - # --- ANALYZER TAB LOGIC --- def refresh_analyzer(self) -> None: self.analyzer_list.clear() - self.analyzer_path_lbl.update(f"Current Directory: [bold]{self.analyzer.current_path}[/bold] [yellow](Scanning...)[/yellow]") - - # Insert a visual loading feedback message into the list (wrapped in ListItem so clear() can remove it) - self.analyzer_list.append(ListItem(Label(" Scanning directory contents, please wait...", classes="analyzer-loading-msg"))) - - # Offload the blocking filesystem scan to a background thread worker - self.run_worker(self._scan_analyzer_worker, thread=True, exclusive=True) - + self.analyzer_path_lbl.update(f"[b]{escape(str(self.analyzer.current_path))}[/b] [yellow]scanning…[/]") + # exclusive=True cancels the previous scan when the user navigates before it finishes + self.run_worker(self._scan_analyzer_worker, thread=True, exclusive=True, group="analyzer") + + def action_analyzer_rescan(self) -> None: + self.analyzer.invalidate() + self.refresh_analyzer() + + def action_analyzer_up(self) -> None: + parent = self.analyzer.current_path.parent + if parent != self.analyzer.current_path: + self.analyzer.set_path(str(parent)) + self.refresh_analyzer() + + def action_analyzer_trash(self) -> None: + item = self.analyzer_list.highlighted_child + if not hasattr(item, "item_info"): + return + path = Path(item.item_info['path']) + if Path.home() not in path.parents: + self.notify("Only files inside your home folder can be moved to Trash here.", severity="warning") + return + if path == trash.trash_dir() or trash.trash_dir() in path.parents: + self.notify("That is the Trash itself: empty it from the Cleaner tab.", severity="warning") + return + + def go(confirmed: bool | None) -> None: + if not confirmed: + return + try: + name = trash.move_to_trash(path) + except OSError as e: + self.notify(f"Could not move to Trash: {e}", severity="error") + return + trash.record("analyze:trash", trashed=[{'name': name, 'path': str(path)}]) + self.notify(f"Moved {path.name} to Trash (marmot undo restores it).") + self.analyzer.invalidate() + self.refresh_analyzer() + + self.push_screen(ConfirmScreen(f"Move [b]{escape(str(path))}[/b] ({item.item_info['size_str']}) to Trash?", + "Move to Trash"), go) + def _scan_analyzer_worker(self) -> None: - # Executes in a background thread to prevent UI freezing - items = self.analyzer.scan_current() - - def update_ui(): + worker = get_current_worker() + path = self.analyzer.current_path + + def progress(done: int, total: int, name: str) -> None: + self.call_from_thread(self.analyzer_path_lbl.update, + f"[b]{escape(str(path))}[/b] [yellow]{done}/{total} {escape(name)}[/]") + try: + items = self.analyzer.scan_current(progress=progress, cancelled=lambda: worker.is_cancelled) + except ScanCancelled: + return + + def update_ui() -> None: self.analyzer_list.clear() - self.analyzer_path_lbl.update(f"Current Directory: [bold]{self.analyzer.current_path}[/bold]") + total = human(sum(i['size'] for i in items)) + self.analyzer_path_lbl.update(f"[b]{escape(str(path))}[/b] [dim]{len(items)} items · {total}[/]") if not items: - self.analyzer_list.append(ListItem(Label(" (Empty Directory)", classes="analyzer-loading-label"))) + self.analyzer_list.append(ListItem(Label("[dim](empty)[/]"))) else: - for it in items: - self.analyzer_list.append(DiskListItem(it)) - - # Safely schedule the UI updates back on the main thread - self.call_from_thread(update_ui) + self.analyzer_list.extend(DiskListItem(it) for it in items) + if self.w["tabs-main"].active == "pane-analyzer": + self.analyzer_list.focus() + + if not worker.is_cancelled: + self.call_from_thread(update_ui) def main() -> None: diff --git a/src/marmot/cleaner.py b/src/marmot/cleaner.py index 931cd14..0855e52 100644 --- a/src/marmot/cleaner.py +++ b/src/marmot/cleaner.py @@ -1,10 +1,13 @@ import os +import re +import shlex import shutil import subprocess import sys from pathlib import Path -from marmot import pkgmgr +from marmot import pkgmgr, trash +from marmot.privilege import run_root # the virtualenv Marmot itself runs from (repo .venv, pipx/uv tool env): never offered as junk RUNNING_VENV = Path(sys.prefix).resolve() @@ -19,19 +22,32 @@ "build.gradle", "build.gradle.kts", "package.json"), } +UNKNOWN = -1 # size could not be measured (orphans, flatpak runtimes) +_UNITS = {"": 1, "B": 1, "K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4} + +# remove disabled snap revisions in one pkexec call; names/revisions are passed as argv, never interpolated +_SNAP_REMOVE = 'while [ $# -gt 1 ]; do snap remove "$1" --revision="$2" || exit 1; shift 2; done' + + +def parse_size(text: str) -> int: + """'1.2GB', '120.4M', '3.5 GiB', '0B' -> bytes (binary units); UNKNOWN if unparsable.""" + m = re.match(r"\s*([\d.]+)\s*([KMGT]?)i?B?\b", text, re.I) + return int(float(m[1]) * _UNITS[m[2].upper()]) if m else UNKNOWN + class SystemCleaner: def __init__(self, backend: str | None = None): self.home = Path.home() self.pm = pkgmgr.get(backend) - + # backend pinned (tests) -> do not probe the host for extra tools + self.probe_host = backend is None + def get_dir_size(self, path: Path) -> int: total_size = 0 try: if path.is_file(): return path.stat().st_size for root, _dirs, files in os.walk(path): - # Handle permission errors gracefully for f in files: fp = os.path.join(root, f) try: @@ -44,6 +60,8 @@ def get_dir_size(self, path: Path) -> int: return total_size def format_size(self, size_bytes: int) -> str: + if size_bytes == UNKNOWN: + return "?" if size_bytes == 0: return "0 B" size_name = ("B", "KB", "MB", "GB", "TB") @@ -53,195 +71,287 @@ def format_size(self, size_bytes: int) -> str: i += 1 return f"{size_bytes:.2f} {size_name[i]}" + def _item(self, name: str, path: str, size: int, desc: str, sudo: bool, **extra) -> dict: + return {'name': name, 'path': path, 'size': size, 'size_str': self.format_size(size), + 'desc': desc, 'requires_sudo': sudo, **extra} + + # ---- scan ---------------------------------------------------------------- + def scan(self) -> dict: results = {} - - # 1. User Cache (~/.cache) + + # 1. User cache, per application folder so the user can keep e.g. a browser cache cache_path = self.home / ".cache" - cache_size = self.get_dir_size(cache_path) - results['user_cache'] = { - 'name': "User Caches", - 'path': str(cache_path), - 'size': cache_size, - 'size_str': self.format_size(cache_size), - 'desc': "Temporary application cache files (Chrome, Firefox, Spotify, etc.)", - 'requires_sudo': False - } - - # 2. Trash (~/.local/share/Trash) - trash_path = self.home / ".local" / "share" / "Trash" + children = [] + try: + for entry in cache_path.iterdir(): + if entry.is_symlink(): + continue + sz = self.get_dir_size(entry) + children.append({'name': entry.name, 'path': str(entry), 'size': sz, 'size_str': self.format_size(sz)}) + except OSError: + pass + children.sort(key=lambda c: c['size'], reverse=True) + cache_size = sum(c['size'] for c in children) if children else self.get_dir_size(cache_path) + results['user_cache'] = self._item( + "User Caches", str(cache_path), cache_size, + "Temporary application cache files (Chrome, Firefox, Spotify, etc.)", False, children=children) + + # 2. Trash + trash_path = trash.trash_dir() trash_size = self.get_dir_size(trash_path) - results['trash'] = { - 'name': "System Trash", - 'path': str(trash_path), - 'size': trash_size, - 'size_str': self.format_size(trash_size), - 'desc': "Files in your trash bin", - 'requires_sudo': False - } - - # 3. Package manager cache (apt: .deb archives, pacman: /var/cache/pacman/pkg) - if self.pm: - cache_path = self.pm['cache_dir'] - cache_size = 0 + results['trash'] = self._item("System Trash", str(trash_path), trash_size, + "Files in your trash bin (deleted permanently)", False) + + # 3. Package manager cache + if self.pm and self.pm['cache_dir'] is not None: + cache_dir = self.pm['cache_dir'] + size = 0 try: - for f in cache_path.glob(self.pm['cache_glob']): + for f in cache_dir.glob(self.pm['cache_glob']): try: - cache_size += f.stat().st_size + size += f.stat().st_size except OSError: pass except OSError: pass # missing/unreadable cache dir (Python < 3.13 raises from glob) - results['pkg_cache'] = { - 'name': f"{self.pm['label']} Package Cache", - 'path': str(cache_path), - 'size': cache_size, - 'size_str': self.format_size(cache_size), - 'desc': f"Downloaded installation packages in {cache_path}", - 'requires_sudo': True - } - - # 4. Systemd Journal Logs + results['pkg_cache'] = self._item(f"{self.pm['label']} Package Cache", str(cache_dir), size, + f"Downloaded installation packages in {cache_dir}", True) + + # 4. Orphaned packages (dependencies nothing needs any more) + if self.pm and self.pm.get('orphans_cmd'): + orphans = pkgmgr.query(self.pm['orphans_cmd'], self.pm['parse_orphans']) or [] + if orphans: + results['orphans'] = self._item( + f"Orphaned Packages ({len(orphans)})", "", UNKNOWN, + f"Unneeded dependencies: {', '.join(orphans[:8])}{'...' if len(orphans) > 8 else ''}", + True, packages=orphans) + + # 5. Systemd journal + journal_size = 0 try: out = subprocess.check_output(["journalctl", "--disk-usage"], stderr=subprocess.DEVNULL) - # Example output: "Archived and active journals take up 1.2GB in the file system." - parts = out.decode("utf-8").split() - # Try to find a size part - for part in parts: - if "take" in part or "up" in part: - continue - if "B" in part or "KB" in part or "MB" in part or "GB" in part: - # found size string - results['systemd_logs'] = { - 'name': "Systemd Journal Logs", - 'path': "/var/log/journal", - 'size': 1, # placeholder greater than 0 - 'size_str': part, - 'desc': "System services and boot logs", - 'requires_sudo': True - } - break + # "Archived and active journals take up 1.2G in the file system." + m = re.search(r"take up (\S+)", out.decode("utf-8", errors="ignore")) + journal_size = parse_size(m[1]) if m else 0 + size_str = m[1] if m else "0 B" except Exception: - pass - - if 'systemd_logs' not in results: - results['systemd_logs'] = { - 'name': "Systemd Journal Logs", - 'path': "/var/log/journal", - 'size': 0, - 'size_str': "0 B", - 'desc': "System services and boot logs", - 'requires_sudo': True - } - - # 5. Developer Junk (Scan first level of user folders for node_modules, target, .venv) - dev_size = 0 - dev_paths = [] - # To keep scan fast, we search in standard locations + size_str = "0 B" + results['systemd_logs'] = self._item("Systemd Journal Logs", "/var/log/journal", max(journal_size, 0), + "System services and boot logs (keeps the last day)", True) + results['systemd_logs']['size_str'] = size_str + + # 6. Developer junk + dev_paths = self._find_dev_junk() + dev_size = sum(self.get_dir_size(Path(p)) for p in dev_paths) + results['dev_junk'] = self._item( + "Developer Build Artifacts", f"{len(dev_paths)} folders found", dev_size, + f"Build artifacts (node_modules, target, .venv) in Projects/Home. Detected: " + f"{', '.join([Path(p).parent.name + '/' + Path(p).name for p in dev_paths[:3]])}...", + False, paths_list=dev_paths) + + if self.probe_host: + results.update(self._scan_extras()) + return results + + def _find_dev_junk(self) -> list: search_dirs = [self.home / "Projects", self.home / "workspace", self.home / "Downloads", self.home] - - found_folders = [] + found = [] for sdir in search_dirs: if sdir.exists() and sdir.is_dir(): try: - # Scan depth 2 to keep it responsive for entry in sdir.iterdir(): if entry.is_dir() and not entry.name.startswith("."): - # Check standard build folders for sub, markers in DEV_JUNK_MARKERS.items(): path = entry / sub # "build"/"target" are only junk next to a project file that produced them if path.exists() and path.is_dir() and path.resolve() != RUNNING_VENV and ( markers is None or any((entry / m).exists() for m in markers)): - found_folders.append(path) + if str(path) not in found: + found.append(str(path)) except Exception: pass - - # Scan inside projects too if they are top level - for folder in found_folders: - sz = self.get_dir_size(folder) - dev_size += sz - dev_paths.append(str(folder)) - - results['dev_junk'] = { - 'name': "Developer Build Artifacts", - 'path': f"{len(dev_paths)} folders found", - 'size': dev_size, - 'size_str': self.format_size(dev_size), - 'desc': f"Build artifacts (node_modules, target, .venv) in Projects/Home. Detected: {', '.join([Path(p).parent.name + '/' + Path(p).name for p in dev_paths[:3]])}...", - 'paths_list': dev_paths, - 'requires_sudo': False - } - + return found + + def _scan_extras(self) -> dict: + """Optional sources that exist only on some hosts: flatpak, podman/docker, snap.""" + results = {} + if shutil.which("flatpak"): + results['flatpak_unused'] = self._item( + "Unused Flatpak Runtimes", "", UNKNOWN, "Runtimes and extensions no installed app uses", False) + + for tool in ("podman", "docker"): + if not shutil.which(tool): + continue + try: + res = subprocess.run([tool, "system", "df", "--format", "{{.Type}}\t{{.Reclaimable}}"], + capture_output=True, timeout=30) + except (OSError, subprocess.SubprocessError): + continue + if res.returncode != 0: + continue # e.g. no permission on the docker socket + size = 0 + for line in res.stdout.decode(errors="ignore").splitlines(): + kind, _, rec = line.partition("\t") + if "volume" not in kind.lower(): # prune without --volumes keeps volumes + size += max(parse_size(rec), 0) + results['containers'] = self._item( + f"{tool.capitalize()} Unused Data", tool, size, + "Stopped containers, dangling images, unused networks, build cache (volumes kept). Size is an upper bound.", + False, tool=tool) + break + + if shutil.which("snap"): + revs = self._disabled_snaps() + if revs: + size = sum(r['size'] for r in revs) + results['snap_old'] = self._item( + f"Old Snap Revisions ({len(revs)})", "/var/lib/snapd/snaps", size, + ", ".join(f"{r['name']} r{r['rev']}" for r in revs[:6]), True, revisions=revs) return results - def clean(self, item_id: str, scan_details: dict) -> tuple[bool, str]: - """Cleans specific item, returns (success, message)""" + def _disabled_snaps(self) -> list: + try: + out = subprocess.run(["snap", "list", "--all"], capture_output=True, timeout=30).stdout.decode() + except (OSError, subprocess.SubprocessError): + return [] + return parse_disabled_snaps(out) + + # ---- clean --------------------------------------------------------------- + + def clean(self, item_id: str, scan_details: dict, dry_run: bool = False, selected: list | None = None, + use_trash: bool = True) -> tuple[bool, str]: + """Clean one scanned item. `selected` narrows user_cache to some folders. + `use_trash` moves user-owned data (dev junk) to the Trash so `marmot undo` can restore it.""" details = scan_details.get(item_id) if not details: return False, "Item not found in scan details" - + path_str = details.get('path') - - # 1. User Cache + if item_id == 'user_cache': cache_path = Path(path_str) - if cache_path.exists(): + if not cache_path.exists(): + return False, "Cache directory does not exist" + children = details.get('children') or [] + targets = [c for c in children if selected is None or c['path'] in selected] + if not children: # scan found no folders (or old scan format): wipe the whole cache dir + if dry_run: + return True, f"Would delete everything in {cache_path}" shutil.rmtree(cache_path, ignore_errors=True) os.makedirs(cache_path, exist_ok=True) + trash.record("clean:user_cache", freed=details.get('size', 0)) return True, "User caches cleaned successfully" - return False, "Cache directory does not exist" - - # 2. Trash + freed = sum(c['size'] for c in targets) + if dry_run: + return True, (f"Would delete {len(targets)} cache folder(s), {self.format_size(freed)}: " + f"{', '.join(c['name'] for c in targets)}") + for c in targets: + p = Path(c['path']) + if p.parent != cache_path: # defence in depth: only direct children of ~/.cache + continue + if p.is_dir() and not p.is_symlink(): + shutil.rmtree(p, ignore_errors=True) + else: + p.unlink(missing_ok=True) + trash.record("clean:user_cache", freed=freed, detail=", ".join(c['name'] for c in targets)) + return True, f"User caches cleaned successfully ({len(targets)} folder(s), {self.format_size(freed)})" + elif item_id == 'trash': trash_path = Path(path_str) - if trash_path.exists(): - # delete files under Trash/files and Trash/info - shutil.rmtree(trash_path, ignore_errors=True) - os.makedirs(trash_path / "files", exist_ok=True) - os.makedirs(trash_path / "info", exist_ok=True) - return True, "Trash bin emptied successfully" - return False, "Trash directory does not exist" - - # 3. Package manager cache + if not trash_path.exists(): + return False, "Trash directory does not exist" + if dry_run: + return True, f"Would permanently delete {details.get('size_str', '?')} from {trash_path}" + shutil.rmtree(trash_path, ignore_errors=True) + os.makedirs(trash_path / "files", exist_ok=True) + os.makedirs(trash_path / "info", exist_ok=True) + trash.record("clean:trash", freed=details.get('size', 0)) + return True, "Trash bin emptied successfully" + elif item_id == 'pkg_cache': if not self.pm: - return False, "Unsupported distribution: no apt or pacman found." - label = self.pm['label'] - try: - subprocess.run(["pkexec", *self.pm['clean_cmd']()], check=True, capture_output=True) - return True, f"{label} packages cache cleaned successfully" - except Exception: - try: - subprocess.run(["sudo", "-n", *self.pm['clean_cmd']()], check=True, capture_output=True) - return True, f"{label} packages cache cleaned successfully (via sudo)" - except Exception as e: - return False, f"Failed to execute. Sudo privileges required. Error: {str(e)}" + return False, "Unsupported distribution: no supported package manager found." + return self._root_action(self.pm['clean_cmd'](), dry_run, item_id, details, + f"{self.pm['label']} packages cache cleaned successfully") + + elif item_id == 'orphans': + names = details.get('packages') or [] + return self._root_action(self.pm['remove_orphans_cmd'](names), dry_run, item_id, details, + f"Removed {len(names)} orphaned package(s)") - # 4. Systemd Logs elif item_id == 'systemd_logs': - try: - # Vacuum journalctl to 1 day - cmd = ["pkexec", "journalctl", "--vacuum-time=1d"] - res = subprocess.run(cmd, check=True, capture_output=True) - return True, f"Systemd logs vacuumed: {res.stdout.decode('utf-8').strip()}" - except Exception: - try: - res = subprocess.run(["sudo", "-n", "journalctl", "--vacuum-time=1d"], check=True, capture_output=True) - return True, f"Systemd logs vacuumed (via sudo): {res.stdout.decode('utf-8').strip()}" - except Exception as e: - return False, f"Failed to execute. Sudo privileges required. Error: {str(e)}" - - # 5. Developer Junk + return self._root_action(["journalctl", "--vacuum-time=1d"], dry_run, item_id, details, + "Systemd logs vacuumed", show_output=True) + + elif item_id == 'snap_old': + argv = [x for r in details.get('revisions', []) for x in (r['name'], str(r['rev']))] + return self._root_action(["sh", "-c", _SNAP_REMOVE, "sh", *argv], dry_run, item_id, details, + f"Removed {len(argv) // 2} old snap revision(s)") + + elif item_id == 'flatpak_unused': + return self._user_action(["flatpak", "uninstall", "--unused", "-y"], dry_run, item_id, details, + "Unused Flatpak runtimes removed") + + elif item_id == 'containers': + return self._user_action([details['tool'], "system", "prune", "-f"], dry_run, item_id, details, + f"{details['tool'].capitalize()} unused data pruned") + elif item_id == 'dev_junk': paths = details.get('paths_list', []) - cleaned_count = 0 + if dry_run: + where = "to Trash" if use_trash else "permanently" + return True, f"Would move {len(paths)} build folder(s) {where}: {', '.join(paths)}" + trashed, cleaned_count = [], 0 for p in paths: try: - shutil.rmtree(p, ignore_errors=True) + if use_trash: + trashed.append({'name': trash.move_to_trash(p), 'path': p}) + else: + shutil.rmtree(p, ignore_errors=True) cleaned_count += 1 except Exception: pass + trash.record("clean:dev_junk", freed=0 if use_trash else details.get('size', 0), trashed=trashed) + if use_trash: + return True, f"Moved {cleaned_count} build folder(s) to Trash (marmot undo restores them)" return True, f"Cleaned {cleaned_count} build folder(s)" - + return False, "Unknown item" + + def _root_action(self, cmd: list, dry_run: bool, item_id: str, details: dict, ok_msg: str, + show_output: bool = False) -> tuple[bool, str]: + if dry_run: + return True, f"Would run as root: {shlex.join(cmd)}" + ok, via, out = run_root(cmd) + if not ok: + return False, f"Failed to execute. Sudo privileges required. Error: {out}" + trash.record(f"clean:{item_id}", freed=max(details.get('size', 0), 0)) + return True, f"{ok_msg}{via}" + (f": {out}" if show_output and out else "") + + def _user_action(self, cmd: list, dry_run: bool, item_id: str, details: dict, ok_msg: str) -> tuple[bool, str]: + if dry_run: + return True, f"Would run: {shlex.join(cmd)}" + try: + res = subprocess.run(cmd, capture_output=True, timeout=600) + except (OSError, subprocess.SubprocessError) as e: + return False, f"Failed: {e}" + if res.returncode != 0: + return False, f"Failed: {res.stderr.decode(errors='ignore').strip()[-300:]}" + trash.record(f"clean:{item_id}", freed=max(details.get('size', 0), 0)) + return True, ok_msg + + +def parse_disabled_snaps(out: str) -> list: + """`snap list --all` rows whose Notes column says `disabled` -> [{name, rev, size}].""" + revs = [] + for line in out.splitlines()[1:]: + cols = line.split() + if len(cols) >= 4 and "disabled" in cols[-1].split(","): + name, rev = cols[0], cols[2] + try: + size = Path(f"/var/lib/snapd/snaps/{name}_{rev}.snap").stat().st_size + except OSError: + size = 0 + revs.append({'name': name, 'rev': rev, 'size': size}) + return revs diff --git a/src/marmot/cli.py b/src/marmot/cli.py new file mode 100644 index 0000000..2e848cc --- /dev/null +++ b/src/marmot/cli.py @@ -0,0 +1,175 @@ +"""Command line entry point: `marmot` opens the TUI, subcommands work headless (scripts, cron, SSH).""" +import argparse +import json +import sys +import time + +from marmot import __version__, trash + + +def _fmt(n: int) -> str: + if n < 0: + return "?" + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024 or unit == "TB": + return f"{n:.0f} {unit}" if unit == "B" else f"{n:.2f} {unit}" + n /= 1024 + return "" + + +def cmd_status(args) -> int: + from marmot.monitor import SystemMonitor + mon = SystemMonitor() + mon.get_cpu_usage() # first sample primes the counters + time.sleep(0.3) + data = {"cpu_percent": round(mon.get_cpu_usage(), 1), "temperature_c": mon.get_temperature(), + "memory": mon.get_memory_info(), "disks": mon.get_disk_usage(), "battery": mon.get_battery_info()} + if args.json: + print(json.dumps(data, indent=2)) + return 0 + m = data["memory"] + temp = f" {data['temperature_c']:.0f}°C" if data["temperature_c"] else "" + print(f"CPU {data['cpu_percent']:5.1f}%{temp}") + print(f"RAM {m['percent']:5.1f}% {_fmt(m['used'])} / {_fmt(m['total'])} swap {_fmt(m['swap_used'])}") + for d in data["disks"]: + print(f"Disk {d['percent']:5.1f}% {_fmt(d['used'])} / {_fmt(d['total'])} {d['mountpoint']}") + b = data["battery"] + if b.get("present"): + print(f"Bat {b['capacity']}% {b['status']}") + return 0 + + +def _scan(): + from marmot.cleaner import SystemCleaner + cleaner = SystemCleaner() + return cleaner, cleaner.scan() + + +def cmd_scan(args) -> int: + _, data = _scan() + if args.json: + print(json.dumps(data, indent=2)) + return 0 + width = max(len(k) for k in data) + for key, v in data.items(): + root = " (root)" if v["requires_sudo"] else "" + print(f"{key:<{width}} {v['size_str']:>10} {v['name']}{root}") + print("\nClean with: marmot clean ... [--dry-run]") + return 0 + + +def cmd_clean(args) -> int: + cleaner, data = _scan() + unknown = [i for i in args.items if i not in data] + if unknown: + print(f"Unknown or unavailable item(s): {', '.join(unknown)}. Available: {', '.join(data)}", file=sys.stderr) + return 2 + + selected = None + if args.keep_cache: + selected = [c["path"] for c in data["user_cache"].get("children", []) if c["name"] not in args.keep_cache] + + if not args.dry_run and not args.yes: + if not sys.stdin.isatty(): + print("Refusing to clean without --yes when not interactive (use --dry-run to preview).", file=sys.stderr) + return 2 + for i in args.items: + print(f" {data[i]['name']}: {data[i]['size_str']}") + if input("Clean these? [y/N] ").strip().lower() not in ("y", "yes"): + print("Aborted.") + return 1 + + rc = 0 + for item_id in args.items: + ok, msg = cleaner.clean(item_id, data, dry_run=args.dry_run, use_trash=not args.permanent, + selected=selected if item_id == "user_cache" else None) + print(f"{'✓' if ok else '✗'} {msg}") + rc |= not ok + return rc + + +def cmd_optimize(args) -> int: + from marmot.optimizer import SystemOptimizer + opt = SystemOptimizer() + ids = [t['id'] for t in opt.get_optimizations()] + if not args.task: + for t in opt.get_optimizations(): + st = opt.check(t['id']) + print(f"{'●' if st['applicable'] else '○'} {t['id']:<12} {t['name']:<22} {st['status']}") + print("\nRun with: marmot optimize [--dry-run]") + return 0 + if args.task not in ids: + print(f"Unknown task {args.task!r}. Available: {', '.join(ids)}", file=sys.stderr) + return 2 + ok, msg = opt.run_optimization(args.task, dry_run=args.dry_run) + print(f"{'✓' if ok else '✗'} {msg}") + return 0 if ok else 1 + + +def cmd_undo(args) -> int: + entry, restored, errors = trash.undo_last() + if entry is None: + print("Nothing to undo (only actions that moved files to Trash can be undone).") + return 1 + print(f"Undid '{entry['action']}' from {entry['ts']}:") + for p in restored: + print(f" ↶ {p}") + for e in errors: + print(f" ✗ {e}", file=sys.stderr) + return 1 if errors else 0 + + +def cmd_history(args) -> int: + for e in trash.load_history()[-args.n:]: + extra = f" trashed {len(e['trashed'])}" if e["trashed"] else "" + freed = f" freed {_fmt(e['freed'])}" if e.get("freed") else "" + print(f"{e['ts']} {e['action']:<20}{freed}{extra} {e.get('detail', '')}".rstrip()) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="marmot", description="Run without a command to open the TUI.") + p.add_argument("--version", action="version", version=f"marmot {__version__}") + sub = p.add_subparsers(dest="command") + + s = sub.add_parser("status", help="CPU, memory, disks, battery") + s.add_argument("--json", action="store_true") + s.set_defaults(func=cmd_status) + + s = sub.add_parser("scan", help="measure what can be cleaned") + s.add_argument("--json", action="store_true") + s.set_defaults(func=cmd_scan) + + s = sub.add_parser("clean", help="clean items listed by `marmot scan`") + s.add_argument("items", nargs="+", metavar="ID") + s.add_argument("-n", "--dry-run", action="store_true", help="only show what would happen") + s.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation") + s.add_argument("--permanent", action="store_true", help="delete developer files instead of moving them to Trash") + s.add_argument("--keep-cache", nargs="+", metavar="NAME", default=[], help="~/.cache folders to keep") + s.set_defaults(func=cmd_clean) + + s = sub.add_parser("optimize", help="list maintenance tasks with their status, or run one") + s.add_argument("task", nargs="?", metavar="ID") + s.add_argument("-n", "--dry-run", action="store_true") + s.set_defaults(func=cmd_optimize) + + s = sub.add_parser("undo", help="restore what the last clean/uninstall moved to Trash") + s.set_defaults(func=cmd_undo) + + s = sub.add_parser("history", help="show recent actions") + s.add_argument("-n", type=int, default=20) + s.set_defaults(func=cmd_history) + return p + + +def main(argv: list | None = None) -> int: + args = build_parser().parse_args(argv) + if args.command is None: + from marmot.app import MarmotApp # textual is only imported for the TUI + MarmotApp().run() + return 0 + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/marmot/marmot.tcss b/src/marmot/marmot.tcss index 1935b61..ee5cd91 100644 --- a/src/marmot/marmot.tcss +++ b/src/marmot/marmot.tcss @@ -1,270 +1,248 @@ -/* Style for Marmot App */ +/* Marmot: colours come from the active theme (ctrl+p -> "Change theme"). */ Screen { - background: #0f0f11; - color: #e3e3e6; + background: $background; } -Header { - background: #18181c; - color: #8c8cf0; - text-align: center; - text-style: bold; - height: 1; -} - -Footer { - background: #18181c; - color: #8c8cf0; +TabPane { + padding: 1 2; } -TabbedContent { - height: 1fr; - width: 100%; -} +/* ---- shared ---------------------------------------------------------- */ -ContentSwitcher { - height: 1fr; - width: 100%; +Card { + border: round $panel-lighten-2; + border-title-color: $accent; + border-title-style: bold; + padding: 0 1; + height: auto; + background: $surface; } -TabPane { +.log-card { height: 1fr; - width: 100%; + min-height: 6; } -Tabs { - background: #141417; - border-bottom: double #27272a; +.log-card RichLog { + background: $surface; + scrollbar-size-vertical: 1; } -Tab { - color: #71717a; - text-style: bold; +.button-row { + height: auto; + margin-top: 1; } -Tab:hover { - color: #a1a1aa; - background: #1e1e24; +.button-row Button { + margin-right: 1; + min-width: 12; } -Tab.-active { - color: #8c8cf0; - background: #16161c; +SelectionList { + border: none; + padding: 0; + background: $surface; + height: auto; } -/* Tab contents padding */ -#pane-monitor, #pane-cleaner, #pane-uninstaller, #pane-analyzer, #pane-optimizer { - padding: 1 2; - background: #0f0f11; -} +/* ---- monitor --------------------------------------------------------- */ -/* Bento Box layout for Monitor */ -.monitor-container { +#monitor-grid { + grid-size: 3; + grid-columns: 1fr 1fr 1fr; + grid-rows: auto auto 1fr; + grid-gutter: 1 2; height: 1fr; - width: 100%; - layout: vertical; } -.monitor-row { - height: 1fr; - width: 100%; - layout: horizontal; - margin-bottom: 1; +#monitor-grid Card { + height: 100%; + min-height: 7; } -.monitor-row:last-child { - margin-bottom: 0; +#disk-card { + height: auto; } -/* Bento Card Style */ -.bento-card { - background: #18181c; - border: round #2c2c35; - padding: 0 2; - width: 1fr; - height: 100%; - margin-right: 2; +Sparkline { + height: 3; + margin-top: 1; } -.bento-card:last-child { - margin-right: 0; +#cpu-spark > .sparkline--max-color { + color: $warning; } -.bento-card:focus { - border: round #8c8cf0; +#cpu-spark > .sparkline--min-color { + color: $success; } -.card-title { - color: #8c8cf0; - text-style: bold; - margin-bottom: 1; +#net-spark > .sparkline--max-color { + color: $accent; } -/* Specific elements inside monitor widgets */ -.stat-row { - layout: horizontal; - height: 1; - margin-bottom: 1; +#disk-card { + column-span: 2; } -.stat-label { - width: 15; - color: #a1a1aa; +#proc-card { + column-span: 3; } -.stat-value { - color: #ffffff; - text-style: bold; +#proc-table { + height: 1fr; + background: $surface; } -ProgressBar { - width: 100%; - height: 1; - color: #8c8cf0; - background: #27272a; +/* ---- cleaner --------------------------------------------------------- */ + +#clean-left { + width: 3fr; + padding-right: 2; } -ProgressBar > .bar--bar { - color: #8c8cf0; - background: #8c8cf0; +#clean-right { + width: 2fr; } -ProgressBar.-complete > .bar--bar { - color: #10b981; - background: #10b981; +#clean-items-card SelectionList { + max-height: 14; } -/* Cleaner Tab styling */ -.clean-container { - layout: vertical; - height: 100%; - align: center middle; +#clean-cache-card { + height: 1fr; + min-height: 5; } -.clean-option { - margin: 1 0; - width: 60%; +#clean-cache-card SelectionList { + height: 1fr; } -.btn-action { - background: #8c8cf0; - color: #ffffff; - text-style: bold; - border: none; +#chk-use-trash { margin-top: 1; + background: transparent; + border: none; } -.btn-action:hover { - background: #6d6dd9; +#clean-summary { + padding: 1 0; } -.btn-danger { - background: #ef4444; - color: #ffffff; - text-style: bold; - border: none; - margin-top: 1; +/* ---- uninstaller ----------------------------------------------------- */ + +#un-left { + width: 2fr; + padding-right: 2; } -.btn-danger:hover { - background: #dc2626; +#un-right { + width: 3fr; } -/* Uninstaller Tab styling */ -.uninstall-search-container { - height: auto; - margin-bottom: 1; +#lbl-package-count { + margin: 0 1 1 1; } -.search-input { - width: 100%; - background: #18181c; - border: tall #2c2c35; +#list-packages { + height: 1fr; + background: $surface; + border: round $panel-lighten-2; } -.search-input:focus { - border: tall #8c8cf0; +#lbl-uninstall-info { + padding: 1 0; } -/* Optimizer Tab styling */ -.optimize-grid { - grid-size: 2 2; - grid-columns: 1fr 1fr; - grid-gutter: 2; - height: 80%; +#list-uninstall-remnants { + max-height: 8; margin-top: 1; + border: round $warning 50%; } -/* Terminal Log View */ -.log-view { - background: #09090b; - border: solid #2c2c35; - color: #a1a1aa; - padding: 1 2; - height: 100%; - overflow-y: scroll; +#btn-uninstall-purge { + margin: 1 0; } -/* Width and Padding Utility Classes */ -.half-width-pad-right { - width: 50%; - padding-right: 2; -} +/* ---- analyzer -------------------------------------------------------- */ -.half-width { - width: 50%; +#analyzer-toolbar { + height: auto; + margin-bottom: 1; } -.forty-width-pad-right { - width: 40%; - padding-right: 2; +#lbl-analyzer-path { + width: 1fr; + padding: 1 1 0 1; } -.sixty-width { - width: 60%; +#analyzer-toolbar Button { + margin-left: 1; + min-width: 10; } -/* Margin Utility Classes */ -.margin-top-2 { - margin-top: 2; +#list-analyzer { + height: 1fr; + background: $surface; + border: round $panel-lighten-2; } -.margin-left-2 { - margin-left: 2; +#analyzer-hint { + margin-top: 1; } -.margin-top-1 { - margin-top: 1; +/* ---- optimizer ------------------------------------------------------- */ + +#opt-list { + height: 1fr; } -/* Component ID specific rules */ -#lbl-uninstall-info { - border: solid #2c2c35; - padding: 1 2; - height: 35%; +.opt-row { + height: auto; + padding: 0 2; margin-bottom: 1; + background: $surface; + border-left: thick $accent; } -#list-uninstall-remnants { +.opt-text { + width: 1fr; height: auto; - max-height: 8; - margin-bottom: 1; } -#lbl-analyzer-path { - margin: 1 0; - color: #8c8cf0; +.opt-status { + margin-top: 0; } -.analyzer-header { - height: 3; - margin-bottom: 1; +.opt-row Button { + margin-left: 2; + min-width: 10; } -.analyzer-loading-msg { +#opt-log-card { + height: 8; + min-height: 8; +} + +#pane-optimizer .button-row { + margin: 0 0 1 0; +} + +/* ---- confirm dialog -------------------------------------------------- */ + +ConfirmScreen { + align: center middle; + background: $background 60%; +} + +#confirm-box { + width: 64; + height: auto; padding: 1 2; - color: #eab308; - text-style: italic; + border: thick $error 60%; + background: $surface; } +#confirm-msg { + margin-bottom: 1; +} diff --git a/src/marmot/monitor.py b/src/marmot/monitor.py index 9e25c41..986e05d 100644 --- a/src/marmot/monitor.py +++ b/src/marmot/monitor.py @@ -219,3 +219,48 @@ def get_disk_usage(self) -> list: return partitions except Exception: return [] + + # ---- extras for the dashboard ------------------------------------------- + + @staticmethod + def is_physical_iface(iface: str) -> bool: + """Real NICs have a backing device; docker0, veth*, br-*, virbr*, tun* do not.""" + return os.path.exists(f"/sys/class/net/{iface}/device") + + def get_system_info(self) -> dict: + info = {'uptime_s': 0.0, 'load': (0.0, 0.0, 0.0), 'cpus': os.cpu_count() or 1} + try: + with open('/proc/uptime') as f: + info['uptime_s'] = float(f.read().split()[0]) + info['load'] = os.getloadavg() + except (OSError, ValueError): + pass + return info + + def get_top_processes(self, n: int = 8) -> list: + """Top processes by CPU (then memory) since the previous call, like `top` (100% = one core).""" + hz = os.sysconf('SC_CLK_TCK') + page = os.sysconf('SC_PAGE_SIZE') + now = time.time() + dt = now - getattr(self, '_proc_time', now) + prev = getattr(self, '_proc_ticks', {}) + ticks, procs = {}, [] + for pid in os.listdir('/proc'): + if not pid.isdigit(): + continue + try: + with open(f'/proc/{pid}/stat') as f: + raw = f.read() + except OSError: + continue # process exited while we were reading + name = raw[raw.find('(') + 1:raw.rfind(')')] + rest = raw[raw.rfind(')') + 2:].split() + if len(rest) < 22: + continue + t = int(rest[11]) + int(rest[12]) # utime + stime + ticks[pid] = t + cpu = (t - prev[pid]) / hz / dt * 100 if dt > 0 and pid in prev else 0.0 + procs.append({'pid': int(pid), 'name': name, 'cpu': max(cpu, 0.0), 'mem': int(rest[21]) * page}) + self._proc_ticks, self._proc_time = ticks, now + procs.sort(key=lambda p: (p['cpu'], p['mem']), reverse=True) + return procs[:n] diff --git a/src/marmot/optimizer.py b/src/marmot/optimizer.py index 2c2e7a8..be71f96 100644 --- a/src/marmot/optimizer.py +++ b/src/marmot/optimizer.py @@ -1,127 +1,155 @@ +"""Maintenance tasks. Each task first checks whether it applies on this machine (and says why not), +so the UI never offers a button that does nothing or something harmful.""" +import shutil import subprocess +from marmot.privilege import run_root -class SystemOptimizer: - def __init__(self): +# swapoff + swapon one device at a time, restoring its priority; devices/priorities are argv, never spliced +_SWAP_CYCLE = ('set -e; while [ $# -gt 1 ]; do swapoff "$1"; ' + 'if [ "$2" -ge 0 ] 2>/dev/null; then swapon -p "$2" "$1"; else swapon "$1"; fi; shift 2; done') + +TASKS = [ + {'id': 'trim', 'name': "Trim SSDs", + 'desc': "Tells SSDs which blocks are free (fstrim -av), keeping write speed up. Safe on every filesystem that supports it."}, + {'id': 'swap', 'name': "Reclaim swap", + 'desc': "Moves swapped-out pages back into RAM, one swap device at a time, and re-enables each with the same priority " + "(zram included). Only offered when RAM can hold it."}, + {'id': 'dns', 'name': "Flush DNS cache", + 'desc': "Clears the local resolver cache (systemd-resolved or nscd), e.g. after changing DNS servers or /etc/hosts."}, + {'id': 'failed', 'name': "Clear failed services", + 'desc': "Clears systemd's failed state so the list starts fresh. This does not fix the services: read their logs first."}, + {'id': 'drop_caches', 'name': "Drop page cache", + 'desc': "For benchmarking only. The kernel already frees this cache on demand, so dropping it makes the system " + "slower until the cache refills."}, +] + + +def _fmt(kb: int) -> str: + return f"{kb / 1024**2:.1f} GB" if kb >= 1024**2 else f"{kb / 1024:.0f} MB" + + +def _out(cmd: list) -> tuple[int, str]: + try: + res = subprocess.run(cmd, capture_output=True, timeout=15) + return res.returncode, res.stdout.decode(errors="ignore") + except (OSError, subprocess.SubprocessError): + return 127, "" + + +def read_swaps(text: str | None = None) -> list: + """/proc/swaps -> [{name, used_kb, prio}] (paths with spaces are \\040-escaped by the kernel).""" + if text is None: + try: + with open("/proc/swaps") as f: + text = f.read() + except OSError: + return [] + swaps = [] + for line in text.splitlines()[1:]: + cols = line.split() + if len(cols) >= 5: + swaps.append({'name': cols[0].replace("\\040", " "), 'used_kb': int(cols[3]), 'prio': int(cols[4])}) + return swaps + + +def mem_available_kb() -> int: + try: + with open("/proc/meminfo") as f: + for line in f: + if line.startswith("MemAvailable:"): + return int(line.split()[1]) + except OSError: pass - + return 0 + + +class SystemOptimizer: def get_optimizations(self) -> list: - return [ - { - 'id': 'drop_caches', - 'name': "Drop RAM Memory Cache", - 'desc': "Frees system page cache, dentries, and inodes. Safe to run at any time.", - 'requires_sudo': True - }, - { - 'id': 'flush_dns', - 'name': "Flush DNS Cache", - 'desc': "Flushes resolvectl or nscd local DNS query caches. Useful for resolving connection issues.", - 'requires_sudo': True - }, - { - 'id': 'reset_services', - 'name': "Reset Failed Systemd Services", - 'desc': "Resets failed state of all systemd services that ran into errors.", - 'requires_sudo': True - }, - { - 'id': 'swap_reset', - 'name': "Reclaim Swap space", - 'desc': "Flushes data in swap back into physical RAM. Safe if free memory is sufficient.", - 'requires_sudo': True - } - ] - - def run_optimization(self, opt_id: str) -> tuple[bool, str]: - if opt_id == 'drop_caches': - try: - # Sync first - subprocess.run(["sync"], check=True) - # Drop caches - cmd = ["pkexec", "sh", "-c", "echo 3 > /proc/sys/vm/drop_caches"] - subprocess.run(cmd, check=True) - return True, "RAM page cache dropped successfully" - except Exception: - try: - subprocess.run(["sudo", "-n", "sh", "-c", "echo 3 > /proc/sys/vm/drop_caches"], check=True) - return True, "RAM page cache dropped successfully (via sudo)" - except Exception as e: - return False, f"Failed. Sudo privileges required. Error: {str(e)}" - - elif opt_id == 'flush_dns': - # Check resolved - resolved_active = False - try: - subprocess.run(["systemctl", "is-active", "--quiet", "systemd-resolved"], check=True) - resolved_active = True - except Exception: - pass - - if resolved_active: - try: - subprocess.run(["pkexec", "resolvectl", "flush-caches"], check=True) - return True, "DNS cache flushed via resolvectl" - except Exception: - try: - subprocess.run(["sudo", "-n", "resolvectl", "flush-caches"], check=True) - return True, "DNS cache flushed via resolvectl (via sudo)" - except Exception as e: - return False, f"Failed to run resolvectl. Error: {str(e)}" - else: - # Try nscd - try: - subprocess.run(["pkexec", "systemctl", "restart", "nscd"], check=True) - return True, "DNS cache flushed by restarting nscd service" - except Exception: - try: - subprocess.run(["sudo", "-n", "systemctl", "restart", "nscd"], check=True) - return True, "DNS cache flushed by restarting nscd service (via sudo)" - except Exception as e: - return False, f"Failed to flush DNS cache. Neither systemd-resolved nor nscd is running. Error: {str(e)}" - - elif opt_id == 'reset_services': - try: - subprocess.run(["pkexec", "systemctl", "reset-failed"], check=True) - return True, "Failed Systemd services status reset successfully" - except Exception: - try: - subprocess.run(["sudo", "-n", "systemctl", "reset-failed"], check=True) - return True, "Failed Systemd services status reset successfully (via sudo)" - except Exception as e: - return False, f"Failed. Sudo privileges required. Error: {str(e)}" - - elif opt_id == 'swap_reset': - # Check memory first - try: - # Get free memory and swap used - with open("/proc/meminfo") as f: - meminfo = f.read() - lines = meminfo.split("\n") - mem_free = 0 - swap_used = 0 - for line in lines: - if "MemAvailable:" in line or "MemFree:" in line and mem_free == 0: - mem_free = int(line.split()[1]) * 1024 # B - if "SwapTotal:" in line: - st = int(line.split()[1]) * 1024 - if "SwapFree:" in line: - sf = int(line.split()[1]) * 1024 - swap_used = st - sf - - if mem_free < swap_used: - return False, f"Safety warning: Free RAM ({mem_free // 1024 // 1024}MB) is less than Swap used ({swap_used // 1024 // 1024}MB). Resetting swap might crash your system!" - - # Run swapoff and swapon - subprocess.run(["pkexec", "swapoff", "-a"], check=True) - subprocess.run(["pkexec", "swapon", "-a"], check=True) - return True, "Swap space reclaimed and cycled successfully" - except Exception: - try: - subprocess.run(["sudo", "-n", "swapoff", "-a"], check=True) - subprocess.run(["sudo", "-n", "swapon", "-a"], check=True) - return True, "Swap space reclaimed and cycled successfully (via sudo)" - except Exception as e: - return False, f"Failed. Sudo privileges required. Error: {str(e)}" - - return False, "Unknown optimization ID" + return [dict(t) for t in TASKS] + + # ---- checks: {'applicable': bool, 'status': str} ----------------------- + + def check(self, task_id: str) -> dict: + return getattr(self, f"_check_{task_id}")() + + def _check_trim(self) -> dict: + if not shutil.which("fstrim"): + return {'applicable': False, 'status': "fstrim is not installed (util-linux)."} + rc, _ = _out(["systemctl", "is-enabled", "fstrim.timer"]) + timer = "weekly fstrim.timer is enabled, so this is rarely needed" if rc == 0 else "fstrim.timer is not enabled" + return {'applicable': True, 'status': timer.capitalize() + "."} + + def _check_swap(self) -> dict: + swaps = read_swaps() + if not swaps: + return {'applicable': False, 'status': "No swap is configured."} + used = sum(s['used_kb'] for s in swaps) + if used == 0: + return {'applicable': False, 'status': f"Nothing in swap ({', '.join(s['name'] for s in swaps)})."} + avail = mem_available_kb() + if avail < used * 1.5: # margin so pulling pages back cannot push the system into OOM + return {'applicable': False, + 'status': f"{_fmt(used)} in swap but only {_fmt(avail)} RAM available: not safe."} + return {'applicable': True, 'status': f"{_fmt(used)} in swap, {_fmt(avail)} RAM available."} + + def _dns_backend(self) -> list | None: + if _out(["systemctl", "is-active", "--quiet", "systemd-resolved"])[0] == 0: + return ["resolvectl", "flush-caches"] + if _out(["systemctl", "is-active", "--quiet", "nscd"])[0] == 0: + return ["nscd", "--invalidate=hosts"] + return None + + def _check_dns(self) -> dict: + cmd = self._dns_backend() + if cmd is None: + return {'applicable': False, + 'status': "No local DNS cache running (systemd-resolved/nscd): nothing to flush."} + return {'applicable': True, 'status': f"Uses {cmd[0]}."} + + def failed_units(self) -> list: + _, out = _out(["systemctl", "--failed", "--no-legend", "--plain"]) + return [ln.split()[0] for ln in out.splitlines() if ln.strip()] + + def _check_failed(self) -> dict: + units = self.failed_units() + if not units: + return {'applicable': False, 'status': "No failed services."} + return {'applicable': True, 'status': f"{len(units)} failed: {', '.join(units[:5])}" + f"{'...' if len(units) > 5 else ''}"} + + def _check_drop_caches(self) -> dict: + return {'applicable': True, 'status': "Not recommended for normal use."} + + # ---- run ------------------------------------------------------------------- + + def command(self, task_id: str) -> list | None: + """The root command a task would run (None if not applicable now).""" + if task_id == 'trim': + return ["fstrim", "-av"] + if task_id == 'swap': + argv = [x for s in read_swaps() if s['used_kb'] > 0 for x in (s['name'], str(s['prio']))] + return ["sh", "-c", _SWAP_CYCLE, "sh", *argv] if argv else None + if task_id == 'dns': + return self._dns_backend() + if task_id == 'failed': + return ["systemctl", "reset-failed"] + if task_id == 'drop_caches': + return ["sh", "-c", "sync && echo 3 > /proc/sys/vm/drop_caches"] + raise KeyError(task_id) + + def run_optimization(self, task_id: str, dry_run: bool = False) -> tuple[bool, str]: + state = self.check(task_id) + if not state['applicable']: + return False, f"Skipped: {state['status']}" + cmd = self.command(task_id) + if cmd is None: + return False, "Skipped: nothing to do." + if dry_run: + shown = cmd[:2] + ["…"] if cmd[0] == "sh" else cmd + return True, f"Would run as root: {' '.join(shown)}" + ok, via, out = run_root(cmd) + name = next(t['name'] for t in TASKS if t['id'] == task_id) + if not ok: + return False, f"{name} failed: {out}" + return True, f"{name} done{via}" + (f": {out}" if out else "") diff --git a/src/marmot/pkgmgr.py b/src/marmot/pkgmgr.py index 67b963d..24d4dd3 100644 --- a/src/marmot/pkgmgr.py +++ b/src/marmot/pkgmgr.py @@ -1,9 +1,17 @@ -"""Package manager backends (apt, pacman). Each backend is plain data + small parsers.""" +"""Package manager backends. Each backend is plain data + small output parsers. + +Keys every backend has: + label, list_cmd/parse_list, preview_cmd/parse_preview, remove_cmd, privileged, + cache_dir/cache_glob/clean_cmd (cache_dir None = no package cache to clean), + orphans_cmd/parse_orphans/remove_orphans_cmd (orphans_cmd None = not supported). +""" import shutil +import subprocess from pathlib import Path -def _parse_dpkg(out: str) -> list: +def _parse_tabbed(out: str) -> list: + """`nameversionsummary` lines (dpkg-query, rpm --qf, flatpak --columns).""" pkgs = [] for line in out.splitlines(): parts = line.split("\t") @@ -27,8 +35,19 @@ def _parse_pacman_qi(out: str) -> list: return pkgs +def _parse_apk_info(out: str) -> list: + """`apk info -vv`: `name-1.2.3-r0 - description`.""" + pkgs = [] + for line in out.splitlines(): + nv, sep, desc = line.partition(" - ") + parts = nv.rsplit("-", 2) + if sep and len(parts) == 3: + pkgs.append({'name': parts[0], 'version': f"{parts[1]}-{parts[2]}", 'summary': desc}) + return pkgs + + def _parse_apt_sim(out: str) -> list: - # `apt-get -s purge` prints one "Purg [...]" line per package it would remove + # `apt-get -s purge|autoremove` prints one "Purg [...]" / "Remv " line per removed package return [ln.split()[1] for ln in out.splitlines() if ln.startswith(("Purg ", "Remv "))] @@ -36,17 +55,64 @@ def _parse_lines(out: str) -> list: return [ln.split()[0] for ln in out.splitlines() if ln.strip()] +def _parse_dnf_remove(out: str) -> list: + """`dnf remove --assumeno`: package rows (leading space, >=5 columns) under the "Removing..." headers.""" + names, on = [], False + for ln in out.splitlines(): + if ln.startswith("Transaction Summary"): + break + if ln.startswith("Removing"): + on = True + elif on and ln.startswith(" ") and len(ln.split()) >= 5: + names.append(ln.split()[0]) + return names + + +def _parse_zypper_remove(out: str) -> list: + """`zypper rm --dry-run`: indented, space separated names after "...going to be REMOVED:".""" + names, on = [], False + for ln in out.splitlines(): + if "going to be REMOVED:" in ln: + on = True + elif on: + if not ln.strip(): + break + names.extend(ln.split()) + return names + + +def _parse_zypper_table(out: str) -> list: + """`zypper packages --unneeded`: `S | Repository | Name | Version | Arch` rows.""" + rows = [[c.strip() for c in ln.split("|")] for ln in out.splitlines() if ln.count("|") >= 4] + return [r[2] for r in rows if r[2] and r[2] != "Name"] + + +def _parse_apk_del(out: str) -> list: + # `apk del --simulate`: "(1/9) Purging curl (8.23.0-r0)" + return [ln.split()[2] for ln in out.splitlines() if ln.startswith("(") and len(ln.split()) >= 3] + + +def _first_existing(*paths: str) -> Path: + return next((Path(p) for p in paths if Path(p).exists()), Path(paths[0])) + + +_RPM_LIST = ["rpm", "-qa", "--qf", "%{NAME}\t%{VERSION}-%{RELEASE}\t%{SUMMARY}\n"] + BACKENDS = { 'apt': { 'label': "Apt", 'list_cmd': ["dpkg-query", "-W", "-f=${Package}\t${Version}\t${binary:Summary}\n"], - 'parse_list': _parse_dpkg, + 'parse_list': _parse_tabbed, 'preview_cmd': lambda p: ["apt-get", "-s", "purge", p], 'parse_preview': _parse_apt_sim, 'remove_cmd': lambda p: ["apt-get", "purge", "-y", p], + 'privileged': True, 'cache_dir': Path("/var/cache/apt/archives"), 'cache_glob': "*.deb", 'clean_cmd': lambda: ["apt-get", "clean"], + 'orphans_cmd': ["apt-get", "-s", "autoremove"], + 'parse_orphans': _parse_apt_sim, + 'remove_orphans_cmd': lambda names: ["apt-get", "autoremove", "--purge", "-y"], }, 'pacman': { 'label': "Pacman", @@ -56,19 +122,80 @@ def _parse_lines(out: str) -> list: 'preview_cmd': lambda p: ["pacman", "-Rsp", "--print-format", "%n", p], 'parse_preview': _parse_lines, 'remove_cmd': lambda p: ["pacman", "-Rns", "--noconfirm", p], + 'privileged': True, 'cache_dir': Path("/var/cache/pacman/pkg"), 'cache_glob': "*.pkg.tar.*", # keep the newest version of each package so downgrades stay possible 'clean_cmd': lambda: ["paccache", "-rk1"] if shutil.which("paccache") else ["pacman", "-Sc", "--noconfirm"], + 'orphans_cmd': ["pacman", "-Qdtq"], + 'parse_orphans': _parse_lines, + 'remove_orphans_cmd': lambda names: ["pacman", "-Rns", "--noconfirm", *names], + }, + 'dnf': { + 'label': "DNF", + 'list_cmd': _RPM_LIST, + 'parse_list': _parse_tabbed, + 'preview_cmd': lambda p: ["dnf", "remove", "--assumeno", p], + 'parse_preview': _parse_dnf_remove, + 'remove_cmd': lambda p: ["dnf", "remove", "-y", p], + 'privileged': True, + 'cache_dir': _first_existing("/var/cache/libdnf5", "/var/cache/dnf"), + 'cache_glob': "**/*.rpm", + 'clean_cmd': lambda: ["dnf", "clean", "packages"], + 'orphans_cmd': ["dnf", "repoquery", "--unneeded", "--qf", "%{name}\n"], + 'parse_orphans': _parse_lines, + 'remove_orphans_cmd': lambda names: ["dnf", "autoremove", "-y"], + }, + 'zypper': { + 'label': "Zypper", + 'list_cmd': _RPM_LIST, + 'parse_list': _parse_tabbed, + 'preview_cmd': lambda p: ["zypper", "--non-interactive", "rm", "--dry-run", "--clean-deps", p], + 'parse_preview': _parse_zypper_remove, + 'remove_cmd': lambda p: ["zypper", "--non-interactive", "rm", "--clean-deps", p], + 'privileged': True, + 'cache_dir': Path("/var/cache/zypp/packages"), + 'cache_glob': "**/*.rpm", + 'clean_cmd': lambda: ["zypper", "clean"], + 'orphans_cmd': ["zypper", "--quiet", "--non-interactive", "packages", "--unneeded"], + 'parse_orphans': _parse_zypper_table, + 'remove_orphans_cmd': lambda names: ["zypper", "--non-interactive", "rm", "--clean-deps", *names], + }, + 'apk': { + 'label': "APK", + 'list_cmd': ["apk", "info", "-vv"], + 'parse_list': _parse_apk_info, + 'preview_cmd': lambda p: ["apk", "del", "--simulate", p], + 'parse_preview': _parse_apk_del, + 'remove_cmd': lambda p: ["apk", "del", p], + 'privileged': True, + # apk only keeps a package cache when /etc/apk/cache is configured; apk del already drops orphans + 'cache_dir': Path("/etc/apk/cache") if Path("/etc/apk/cache").is_dir() else None, + 'cache_glob': "*.apk", + 'clean_cmd': lambda: ["apk", "cache", "clean"], + 'orphans_cmd': None, }, } +# Flatpak apps sit beside the distro backend; flatpak asks polkit itself for system installs. +FLATPAK = { + 'label': "Flatpak", + 'list_cmd': ["flatpak", "list", "--app", "--columns=application,version,name"], + 'parse_list': _parse_tabbed, + 'preview_cmd': None, # removing an app never takes other apps with it + 'remove_cmd': lambda app_id: ["flatpak", "uninstall", "-y", "--delete-data", app_id], + 'privileged': False, +} + +# Detection order matters: dnf before zypper (some systems ship both rpm tools). +_DETECT = [('apt', ("dpkg-query", "apt-get")), ('pacman', ("pacman",)), ('dnf', ("dnf", "rpm")), + ('zypper', ("zypper", "rpm")), ('apk', ("apk",))] + def detect() -> str | None: - if shutil.which("dpkg-query") and shutil.which("apt-get"): - return 'apt' - if shutil.which("pacman"): - return 'pacman' + for name, tools in _DETECT: + if all(shutil.which(t) for t in tools): + return name return None @@ -76,3 +203,15 @@ def get(name: str | None = None) -> dict | None: """Backend dict for `name`, or the detected one; None when the distro is unsupported.""" return BACKENDS.get(name or detect() or "") + +def flatpak() -> dict | None: + return FLATPAK if shutil.which("flatpak") else None + + +def query(cmd: list, parser) -> list | None: + """Run a read-only command and parse its stdout; None when it cannot run or yields nothing.""" + try: + res = subprocess.run(cmd, capture_output=True, timeout=120) + except (OSError, subprocess.SubprocessError): + return None + return parser(res.stdout.decode("utf-8", errors="ignore")) or None diff --git a/src/marmot/privilege.py b/src/marmot/privilege.py new file mode 100644 index 0000000..adb424a --- /dev/null +++ b/src/marmot/privilege.py @@ -0,0 +1,16 @@ +import subprocess + + +def run_root(cmd: list) -> tuple[bool, str, str]: + """pkexec first, then passwordless sudo. Returns (ok, " (via sudo)" or "", stdout-or-error). + Output is always captured: a text-mode polkit prompt must never write over the TUI.""" + err = "" + for prefix, suffix in ((["pkexec"], ""), (["sudo", "-n"], " (via sudo)")): + try: + res = subprocess.run([*prefix, *cmd], check=True, capture_output=True) + return True, suffix, res.stdout.decode("utf-8", errors="ignore").strip() + except subprocess.CalledProcessError as e: + err = (e.stderr or b"").decode("utf-8", errors="ignore").strip() or str(e) + except Exception as e: + err = str(e) + return False, "", err diff --git a/src/marmot/trash.py b/src/marmot/trash.py new file mode 100644 index 0000000..726087a --- /dev/null +++ b/src/marmot/trash.py @@ -0,0 +1,121 @@ +"""Undoable deletion: freedesktop.org Trash + an action history for `marmot undo`. + +Trashed items show up in every file manager's Trash and can be restored there too. +""" +import json +import os +import shutil +import time +import uuid +from pathlib import Path +from urllib.parse import quote, unquote + + +def trash_dir() -> Path: + return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "Trash" + + +def history_file() -> Path: + return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state")) / "marmot" / "history.jsonl" + + +def move_to_trash(path: str | Path) -> str: + """Move `path` into the user Trash (spec-compliant .trashinfo). Returns the name inside Trash/files.""" + src = Path(path).absolute() + if not src.exists() and not src.is_symlink(): + raise FileNotFoundError(src) + files, info = trash_dir() / "files", trash_dir() / "info" + files.mkdir(parents=True, exist_ok=True) + info.mkdir(parents=True, exist_ok=True) + + base, n = src.name, 1 + while True: + name = base if n == 1 else f"{base}.{n}" + try: + # O_EXCL reserves the name atomically, as the spec requires + fd = os.open(info / f"{name}.trashinfo", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + n += 1 + continue + if (files / name).exists(): + os.close(fd) + os.unlink(info / f"{name}.trashinfo") + n += 1 + continue + break + with os.fdopen(fd, "w") as f: + f.write(f"[Trash Info]\nPath={quote(str(src))}\nDeletionDate={time.strftime('%Y-%m-%dT%H:%M:%S')}\n") + try: + # shortcut: files on another filesystem are copied into the home Trash instead of $topdir/.Trash-$uid; + # fine for Marmot's targets (all under $HOME), revisit if it ever trashes other mounts. + shutil.move(str(src), str(files / name)) + except BaseException: + os.unlink(info / f"{name}.trashinfo") + raise + return name + + +def restore_from_trash(name: str) -> Path: + """Move a trashed entry back to its original path. Refuses to overwrite.""" + info = trash_dir() / "info" / f"{name}.trashinfo" + original = None + for line in info.read_text().splitlines(): + if line.startswith("Path="): + original = Path(unquote(line[5:])) + if original is None: + raise ValueError(f"{info} has no Path=") + if original.exists() or original.is_symlink(): + raise FileExistsError(f"{original} already exists, not overwriting") + original.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(trash_dir() / "files" / name), str(original)) + info.unlink() + return original + + +# ---- history ------------------------------------------------------------- + +def record(action: str, *, freed: int = 0, trashed: list | None = None, detail: str = "", + undoes: str | None = None) -> dict: + """Append one action. `trashed` = [{"name": , "path": }].""" + entry = {"id": uuid.uuid4().hex[:12], "ts": time.strftime("%Y-%m-%d %H:%M:%S"), "action": action, + "freed": freed, "trashed": trashed or [], "detail": detail, "undoes": undoes} + hf = history_file() + hf.parent.mkdir(parents=True, exist_ok=True) + with hf.open("a") as f: + f.write(json.dumps(entry) + "\n") + return entry + + +def load_history() -> list: + try: + lines = history_file().read_text().splitlines() + except FileNotFoundError: + return [] + out = [] + for ln in lines: + try: + out.append(json.loads(ln)) + except ValueError: + pass # a torn line from a crash must not hide the rest + return out + + +def last_undoable() -> dict | None: + hist = load_history() + undone = {e.get("undoes") for e in hist} + return next((e for e in reversed(hist) if e["trashed"] and e["id"] not in undone), None) + + +def undo_last() -> tuple[dict | None, list, list]: + """Restore everything the last undoable action trashed. Returns (entry, restored, errors).""" + entry = last_undoable() + if entry is None: + return None, [], [] + restored, errors = [], [] + for t in entry["trashed"]: + try: + restored.append(str(restore_from_trash(t["name"]))) + except (OSError, ValueError) as e: + errors.append(f"{t['path']}: {e}") + record("undo", detail=f"undo {entry['action']}", undoes=entry["id"]) + return entry, restored, errors diff --git a/src/marmot/uninstaller.py b/src/marmot/uninstaller.py index 18e4853..c1c2fad 100644 --- a/src/marmot/uninstaller.py +++ b/src/marmot/uninstaller.py @@ -2,7 +2,7 @@ import subprocess from pathlib import Path -from marmot import pkgmgr +from marmot import pkgmgr, trash # Only direct children of these dirs (relative to $HOME) may be deleted as remnants. REMNANT_PARENTS = (".config", ".local/share", ".cache") @@ -12,39 +12,42 @@ class PackageUninstaller: def __init__(self, backend: str | None = None): self.home = Path.home() self.pm = pkgmgr.get(backend) + self.flatpak = pkgmgr.flatpak() if backend is None else None - def list_installed_packages(self, search_query: str = "") -> list: + def _list(self, pm: dict, source: str) -> list: + process = subprocess.Popen(pm['list_cmd'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + out, _ = process.communicate() + return [{**p, 'source': source} for p in pm['parse_list'](out.decode("utf-8", errors="ignore"))] + + def list_installed_packages(self, search_query: str = "", limit: int | None = 60) -> list: packages = [] - if not self.pm: - return packages - try: - process = subprocess.Popen(self.pm['list_cmd'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) - out, _ = process.communicate() - q = search_query.lower() - for pkg in self.pm['parse_list'](out.decode("utf-8", errors="ignore")): - if not q or q in pkg['name'].lower() or q in pkg['summary'].lower(): - packages.append(pkg) - packages.sort(key=lambda x: x['name']) - except Exception: - pass - return packages[:60] # limit for smooth TUI scrolling + q = search_query.lower() + for pm, source in ((self.pm, 'system'), (self.flatpak, 'flatpak')): + if not pm: + continue + try: + packages += [p for p in self._list(pm, source) + if not q or q in p['name'].lower() or q in p['summary'].lower()] + except Exception: + pass + packages.sort(key=lambda x: x['name']) + return packages[:limit] - def preview_removal(self, package_name: str) -> list | None: + def preview_removal(self, package_name: str, source: str = 'system') -> list | None: """Packages the package manager would remove (dependants/orphans included), or None on error.""" + if source == 'flatpak': + return [package_name] if self.flatpak else None if not self.pm: return None - try: - res = subprocess.run(self.pm['preview_cmd'](package_name), capture_output=True) - if res.returncode != 0: - return None - return self.pm['parse_preview'](res.stdout.decode("utf-8", errors="ignore")) - except Exception: - return None + # exit codes are not trusted (dnf --assumeno always "fails"): an empty parse means it would not work + return pkgmgr.query(self.pm['preview_cmd'](package_name), self.pm['parse_preview']) def _remnant_parents(self) -> list: return [self.home / d for d in REMNANT_PARENTS] - def find_remnants(self, package_name: str) -> list: + def find_remnants(self, package_name: str, source: str = 'system') -> list: + if source == 'flatpak': + return [] # `flatpak uninstall --delete-data` already removes ~/.var/app/ # Exact (case-insensitive) name match only: a substring match made "git" hit ~/.config/gitkraken. want = package_name.lower() folders = [] @@ -87,11 +90,18 @@ def format_size(self, size_bytes: int) -> str: i += 1 return f"{size_bytes:.2f} {size_name[i]}" - def uninstall(self, package_name: str, delete_remnants: list = None) -> tuple[bool, str]: + def uninstall(self, package_name: str, delete_remnants: list = None, source: str = 'system') -> tuple[bool, str]: success = False msg = "" - if not self.pm: - return False, "Unsupported distribution: no apt or pacman found." + pm = self.flatpak if source == 'flatpak' else self.pm + if not pm: + return False, "Unsupported distribution: no supported package manager found." + if not pm['privileged']: + res = subprocess.run(pm['remove_cmd'](package_name), capture_output=True) + if res.returncode != 0: + return False, f"Failed to remove: {res.stderr.decode('utf-8', errors='ignore').strip()}" + trash.record("uninstall", detail=f"{source}:{package_name}") + return True, f"{pm['label']} app {package_name} removed (with its data)." try: cmd = ["pkexec", *self.pm['remove_cmd'](package_name)] res = subprocess.run(cmd, capture_output=True) @@ -109,18 +119,18 @@ def uninstall(self, package_name: str, delete_remnants: list = None) -> tuple[bo except Exception as e: msg = f"Error during purge execution: {str(e)}" - if success and delete_remnants: - import shutil - deleted_paths = [] - for rpath in delete_remnants: + if success: + trashed = [] + for rpath in delete_remnants or []: if not self.is_safe_remnant(rpath): continue try: - shutil.rmtree(rpath, ignore_errors=True) - deleted_paths.append(rpath) + trashed.append({'name': trash.move_to_trash(rpath), 'path': rpath}) except Exception: pass - if deleted_paths: - msg += f" Cleared user config remnants: {', '.join([os.path.basename(p) for p in deleted_paths])}." - + if trashed: + msg += (f" Moved remnants to Trash (marmot undo restores them): " + f"{', '.join(os.path.basename(t['path']) for t in trashed)}.") + trash.record("uninstall", trashed=trashed, detail=f"system:{package_name}") + return success, msg diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..66161f2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + + +@pytest.fixture(autouse=True) +def isolated_xdg(tmp_path, monkeypatch): + """Never touch the real Trash or ~/.local/state/marmot from tests.""" + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py index 73dadad..de8bf17 100644 --- a/tests/test_analyzer.py +++ b/tests/test_analyzer.py @@ -21,29 +21,29 @@ def test_set_path_invalid(self, mock_exists): self.analyzer.set_path('/invalid/path') self.assertEqual(self.analyzer.current_path, old_path) - @patch('os.lstat') - @patch('os.walk') - def test_recursive_size(self, mock_walk, mock_lstat): - import stat - # Mock file lstat - file_stat = MagicMock() - file_stat.st_mode = stat.S_IFREG - file_stat.st_size = 500 - - # Mock dir lstat - dir_stat = MagicMock() - dir_stat.st_mode = stat.S_IFDIR - dir_stat.st_size = 4096 - - mock_lstat.side_effect = lambda path: file_stat if 'file' in str(path) else dir_stat - - mock_walk.return_value = [ - ('/tmp/test', (), ('file1.txt', 'file2.txt')), - ] - - size = self.analyzer._recursive_size('/tmp/test') - # 2 files * 500 = 1000 bytes - self.assertEqual(size, 1000) + def test_recursive_size(self): + import tempfile + with tempfile.TemporaryDirectory() as d: + Path(d, "sub").mkdir() + Path(d, "a.bin").write_bytes(b"x" * 600) + Path(d, "sub", "b.bin").write_bytes(b"x" * 400) + Path(d, "link").symlink_to(Path(d, "a.bin")) # symlinks are not counted twice + self.assertEqual(self.analyzer._recursive_size(d), 1000) + # cached: deleting a file does not change the answer until invalidate() + Path(d, "a.bin").unlink() + self.assertEqual(self.analyzer._recursive_size(d), 1000) + self.analyzer.invalidate() + self.assertEqual(self.analyzer._recursive_size(d), 400) + + def test_scan_cancel(self): + import tempfile + + from marmot.analyzer import ScanCancelled + with tempfile.TemporaryDirectory() as d: + Path(d, "sub").mkdir() + self.analyzer.set_path(d) + with self.assertRaises(ScanCancelled): + self.analyzer.scan_current(cancelled=lambda: True) @patch('os.scandir') @patch('marmot.analyzer.DiskAnalyzer._recursive_size', return_value=1200) diff --git a/tests/test_app.py b/tests/test_app.py index 2da0595..bada5d9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,7 @@ -"""Headless smoke test: the TUI mounts and every tab renders (no privileged action is triggered).""" +"""Headless TUI tests (no privileged action and no real cleaning is ever triggered).""" import asyncio import unittest +from unittest.mock import patch from textual.widgets import TabbedContent @@ -8,10 +9,23 @@ TABS = ["pane-monitor", "pane-cleaner", "pane-uninstaller", "pane-analyzer", "pane-optimizer"] +FAKE_SCAN = { + 'user_cache': {'name': "User Caches", 'path': "/nonexistent/.cache", 'size': 300, 'size_str': "300 B", + 'desc': "", 'requires_sudo': False, + 'children': [{'name': "a", 'path': "/nonexistent/.cache/a", 'size': 200, 'size_str': "200 B"}, + {'name': "b", 'path': "/nonexistent/.cache/b", 'size': 100, 'size_str': "100 B"}]}, + 'trash': {'name': "System Trash", 'path': "/nonexistent/Trash", 'size': 0, 'size_str': "0 B", + 'desc': "", 'requires_sudo': False}, +} + + +def run(coro): + return asyncio.run(coro) + class TestAppSmoke(unittest.TestCase): def test_mount_and_switch_tabs(self): - async def run(): + async def go(): app = MarmotApp() async with app.run_test(size=(140, 45)) as pilot: tabs = app.query_one(TabbedContent) @@ -19,11 +33,101 @@ async def run(): tabs.active = tab await pilot.pause() self.assertEqual(tabs.active, tab) - # the package-cache checkbox exists only when a backend was detected - self.assertEqual(bool(app.query("#chk-pkg-cache")), app.cleaner.pm is not None) await pilot.press("q") + run(go()) + + def test_cleaner_scan_then_preview(self): + async def go(): + app = MarmotApp() + calls = [] + + def fake_clean(item_id, data, dry_run=False, selected=None, use_trash=True): + calls.append((item_id, dry_run, selected)) + return True, "would do it" + + with patch.object(app.cleaner, "scan", return_value=FAKE_SCAN), \ + patch.object(app.cleaner, "clean", side_effect=fake_clean): + async with app.run_test(size=(140, 45)) as pilot: + app.query_one(TabbedContent).active = "pane-cleaner" + await pilot.pause() + await pilot.click("#btn-clean-scan") + await app.workers.wait_for_complete() + await pilot.pause() + # defaults: user cache + trash ticked, every cache folder ticked + self.assertEqual(set(app.clean_items.selected), {"user_cache", "trash"}) + self.assertEqual(len(app.clean_cache.selected), 2) + app.clean_cache.deselect("/nonexistent/.cache/b") + await pilot.click("#btn-clean-preview") + await app.workers.wait_for_complete() + await pilot.pause() + self.assertIn(("user_cache", True, ["/nonexistent/.cache/a"]), calls) + self.assertTrue(all(dry for _, dry, _ in calls)) + run(go()) + + +class TestAppSafety(unittest.TestCase): + def test_clean_asks_for_confirmation_and_cancel_does_nothing(self): + async def go(): + app = MarmotApp() + with patch.object(app.cleaner, "scan", return_value=FAKE_SCAN), \ + patch.object(app.cleaner, "clean", return_value=(True, "done")) as clean: + async with app.run_test(size=(140, 45)) as pilot: + await pilot.press("2") + await pilot.click("#btn-clean-scan") + await app.workers.wait_for_complete() + await pilot.pause() + await pilot.click("#btn-clean-run") + await pilot.pause() + self.assertEqual(type(app.screen).__name__, "ConfirmScreen") + await pilot.press("escape") + await app.workers.wait_for_complete() + await pilot.pause() + clean.assert_not_called() + run(go()) + + def test_analyzer_refuses_trash_outside_home(self): + import tempfile + from pathlib import Path - asyncio.run(run()) + async def go(): + app = MarmotApp() + with tempfile.TemporaryDirectory() as outside, \ + patch("marmot.trash.move_to_trash") as mv: + Path(outside, "f").write_text("x") + async with app.run_test(size=(140, 45)) as pilot: + app.analyzer.set_path(outside) + await pilot.press("4") + app.refresh_analyzer() + await app.workers.wait_for_complete() + await pilot.pause() + app.analyzer_list.index = 0 + app.action_analyzer_trash() + await pilot.pause() + self.assertNotEqual(type(app.screen).__name__, "ConfirmScreen") + mv.assert_not_called() + run(go()) + + def test_timers_after_exit_do_not_crash(self): + """Regression: interval/worker callbacks firing during shutdown raised NoMatches (flaky CI).""" + async def go(): + app = MarmotApp() + async with app.run_test() as pilot: + await pilot.pause() + return app + app = run(go()) + app.update_monitor_dashboard() + app.update_processes() + app._show_check("trim", {'applicable': True, 'status': "ok"}) + + def test_number_keys_switch_tabs(self): + async def go(): + app = MarmotApp() + async with app.run_test(size=(140, 45)) as pilot: + for key, tab in (("5", "pane-optimizer"), ("3", "pane-uninstaller"), ("1", "pane-monitor")): + await pilot.press(key) + await pilot.pause() + self.assertEqual(app.query_one(TabbedContent).active, tab) + run(go()) if __name__ == "__main__": diff --git a/tests/test_cleaner.py b/tests/test_cleaner.py index 410bf80..7f6b27f 100644 --- a/tests/test_cleaner.py +++ b/tests/test_cleaner.py @@ -85,7 +85,7 @@ def test_clean_dev_junk(self, mock_rmtree): 'paths_list': ['/home/user/Projects/test/node_modules', '/home/user/Projects/test/.venv'] } } - success, msg = self.cleaner.clean('dev_junk', scan_details) + success, msg = self.cleaner.clean('dev_junk', scan_details, use_trash=False) self.assertTrue(success) self.assertEqual(msg, "Cleaned 2 build folder(s)") self.assertEqual(mock_rmtree.call_count, 2) diff --git a/tests/test_cleaner_extras.py b/tests/test_cleaner_extras.py new file mode 100644 index 0000000..6be8a55 --- /dev/null +++ b/tests/test_cleaner_extras.py @@ -0,0 +1,118 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +from marmot import trash +from marmot.cleaner import SystemCleaner, parse_disabled_snaps, parse_size + + +def test_parse_size(): + assert parse_size("120.4MB") == int(120.4 * 1024**2) + assert parse_size("1.2G") == int(1.2 * 1024**3) + assert parse_size("3.5 GiB") == int(3.5 * 1024**3) + assert parse_size("0B (0%)") == 0 + assert parse_size("n/a") == -1 + + +def test_parse_disabled_snaps(): + out = """Name Version Rev Tracking Publisher Notes +core22 20240111 1122 latest/stable canonical✓ base,disabled +core22 20240408 1380 latest/stable canonical✓ base +firefox 125.0 4173 latest/stable mozilla✓ disabled +""" + assert [(r['name'], r['rev']) for r in parse_disabled_snaps(out)] == [("core22", "1122"), ("firefox", "4173")] + + +def _home(tmp_path): + h = tmp_path / "home" + (h / ".cache" / "big").mkdir(parents=True) + (h / ".cache" / "big" / "f").write_bytes(b"x" * 500) + (h / ".cache" / "keep").mkdir() + (h / ".cache" / "keep" / "f").write_bytes(b"x" * 100) + return h + + +def test_user_cache_per_folder_and_dry_run(tmp_path): + h = _home(tmp_path) + with patch('pathlib.Path.home', return_value=h), patch('subprocess.check_output', side_effect=OSError): + c = SystemCleaner(backend="pacman") + data = c.scan() + kids = data['user_cache']['children'] + assert [k['name'] for k in kids] == ["big", "keep"] and data['user_cache']['size'] == 600 + + ok, msg = c.clean('user_cache', data, dry_run=True, selected=[str(h / ".cache" / "big")]) + assert ok and "Would delete 1" in msg and (h / ".cache" / "big").exists() + + ok, _ = c.clean('user_cache', data, selected=[str(h / ".cache" / "big")]) + assert ok and not (h / ".cache" / "big").exists() and (h / ".cache" / "keep").exists() + + +def test_user_cache_refuses_paths_outside_cache(tmp_path): + h = _home(tmp_path) + precious = tmp_path / "precious" + precious.mkdir() + data = {'user_cache': {'path': str(h / ".cache"), 'size': 1, + 'children': [{'name': "x", 'path': str(precious), 'size': 1}]}} + with patch('pathlib.Path.home', return_value=h): + SystemCleaner(backend="pacman").clean('user_cache', data) + assert precious.exists() + + +def test_dev_junk_goes_to_trash_and_undo_restores(tmp_path): + h = tmp_path / "home" + nm = h / "web" / "node_modules" + nm.mkdir(parents=True) + (h / "web" / "package.json").touch() + with patch('pathlib.Path.home', return_value=h), patch('subprocess.check_output', side_effect=OSError): + c = SystemCleaner(backend="pacman") + data = c.scan() + ok, msg = c.clean('dev_junk', data) + assert ok and "Trash" in msg and not nm.exists() + entry, restored, errors = trash.undo_last() + assert entry['action'] == "clean:dev_junk" and restored == [str(nm)] and nm.exists() and not errors + + +def test_dry_run_never_runs_commands(tmp_path): + c = SystemCleaner(backend="apt") + data = {'pkg_cache': {'size': 1}, 'orphans': {'size': -1, 'packages': ["a"]}, + 'systemd_logs': {'size': 1}, 'containers': {'size': 1, 'tool': "podman"}, + 'flatpak_unused': {'size': -1}, 'snap_old': {'size': 1, 'revisions': [{'name': "x", 'rev': "1", 'size': 1}]}} + with patch('subprocess.run') as run: + for item in data: + ok, msg = c.clean(item, data, dry_run=True) + assert ok and msg.startswith("Would"), (item, msg) + run.assert_not_called() + + +def test_orphans_and_snap_commands(): + c = SystemCleaner(backend="pacman") + with patch('subprocess.run', return_value=MagicMock(stdout=b"")) as run: + c.clean('orphans', {'orphans': {'size': -1, 'packages': ["a", "b"]}}) + assert run.call_args.args[0] == ["pkexec", "pacman", "-Rns", "--noconfirm", "a", "b"] + revs = [{'name': "core22", 'rev': "1122", 'size': 0}, {'name': "evil;rm -rf ~", 'rev': "1", 'size': 0}] + c.clean('snap_old', {'snap_old': {'size': 0, 'revisions': revs}}) + cmd = run.call_args.args[0] + # names are separate argv entries after the script, never spliced into it + assert cmd[:3] == ["pkexec", "sh", "-c"] and cmd[-4:] == ["core22", "1122", "evil;rm -rf ~", "1"] + + +def test_scan_reports_orphans(tmp_path): + with patch('pathlib.Path.home', return_value=tmp_path), patch('subprocess.check_output', side_effect=OSError), \ + patch('marmot.pkgmgr.query', return_value=["libfoo", "libbar"]): + data = SystemCleaner(backend="pacman").scan() + assert data['orphans']['packages'] == ["libfoo", "libbar"] and data['orphans']['size_str'] == "?" + + +def test_containers_scan_skips_volumes(): + df = b"Images\t1GB (50%)\nContainers\t0B (0%)\nLocal Volumes\t5GB (100%)\nBuild Cache\t512MB\n" + c = SystemCleaner(backend="pacman") + with patch('shutil.which', side_effect=lambda t: t == "podman"), \ + patch('subprocess.run', return_value=MagicMock(returncode=0, stdout=df)): + extras = c._scan_extras() + assert extras['containers']['tool'] == "podman" + assert extras['containers']['size'] == 1024**3 + 512 * 1024**2 + + +def test_trash_item_uses_xdg_trash(tmp_path): + with patch('pathlib.Path.home', return_value=tmp_path), patch('subprocess.check_output', side_effect=OSError): + data = SystemCleaner(backend="pacman").scan() + assert Path(data['trash']['path']) == trash.trash_dir() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d93d47e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,65 @@ +import json +from unittest.mock import MagicMock, patch + +from marmot import cli + +FAKE = {'trash': {'name': "System Trash", 'size': 10, 'size_str': "10 B", 'requires_sudo': False, 'desc': ""}} + + +def _cleaner(ok=True): + c = MagicMock() + c.scan.return_value = FAKE + c.clean.return_value = (ok, "Would permanently delete 10 B") + return c + + +def test_scan_json(capsys): + with patch('marmot.cli._scan', return_value=(_cleaner(), FAKE)): + assert cli.main(["scan", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)['trash']['size'] == 10 + + +def test_clean_unknown_item_is_usage_error(capsys): + with patch('marmot.cli._scan', return_value=(_cleaner(), FAKE)): + assert cli.main(["clean", "nope"]) == 2 + assert "Available: trash" in capsys.readouterr().err + + +def test_clean_refuses_non_interactive_without_yes(): + c = _cleaner() + with patch('marmot.cli._scan', return_value=(c, FAKE)), patch('sys.stdin.isatty', return_value=False): + assert cli.main(["clean", "trash"]) == 2 + c.clean.assert_not_called() + + +def test_clean_dry_run_passes_flag(): + c = _cleaner() + with patch('marmot.cli._scan', return_value=(c, FAKE)): + assert cli.main(["clean", "trash", "--dry-run"]) == 0 + assert c.clean.call_args.kwargs['dry_run'] is True + + +def test_clean_failure_exit_code(): + with patch('marmot.cli._scan', return_value=(_cleaner(ok=False), FAKE)): + assert cli.main(["clean", "trash", "--yes"]) == 1 + + +def test_status_json(capsys): + assert cli.main(["status", "--json"]) == 0 + assert "memory" in json.loads(capsys.readouterr().out) + + +def test_undo_nothing(): + assert cli.main(["undo"]) == 1 + + +def test_optimize_list_and_unknown(capsys): + assert cli.main(["optimize"]) == 0 + assert "drop_caches" in capsys.readouterr().out + assert cli.main(["optimize", "nope"]) == 2 + + +def test_optimize_dry_run_never_runs_root(): + with patch('marmot.optimizer.run_root') as root: + cli.main(["optimize", "drop_caches", "--dry-run"]) + root.assert_not_called() diff --git a/tests/test_optimizer.py b/tests/test_optimizer.py index 0c8543b..17130d6 100644 --- a/tests/test_optimizer.py +++ b/tests/test_optimizer.py @@ -1,180 +1,110 @@ -import unittest -from unittest.mock import MagicMock, mock_open, patch - -from marmot.optimizer import SystemOptimizer - - -class TestSystemOptimizer(unittest.TestCase): - def setUp(self): - self.optimizer = SystemOptimizer() - - def test_get_optimizations(self): - opts = self.optimizer.get_optimizations() - self.assertEqual(len(opts), 4) - ids = [o['id'] for o in opts] - self.assertIn('drop_caches', ids) - self.assertIn('flush_dns', ids) - self.assertIn('reset_services', ids) - self.assertIn('swap_reset', ids) - - @patch('subprocess.run') - def test_run_optimization_drop_caches_success(self, mock_run): - mock_res = MagicMock() - mock_res.returncode = 0 - mock_run.return_value = mock_res - - success, msg = self.optimizer.run_optimization("drop_caches") - self.assertTrue(success) - self.assertEqual(msg, "RAM page cache dropped successfully") - self.assertEqual(mock_run.call_count, 2) # sync and pkexec drop_caches - - @patch('subprocess.run') - def test_run_optimization_reset_services_success(self, mock_run): - mock_res = MagicMock() - mock_res.returncode = 0 - mock_run.return_value = mock_res - - success, msg = self.optimizer.run_optimization("reset_services") - self.assertTrue(success) - self.assertEqual(msg, "Failed Systemd services status reset successfully") - - @patch('subprocess.run') - def test_run_optimization_flush_dns_resolved(self, mock_run): - # First call is systemctl is-active -> success - # Second call is resolvectl flush-caches -> success - mock_res = MagicMock() - mock_res.returncode = 0 - mock_run.return_value = mock_res - - success, msg = self.optimizer.run_optimization("flush_dns") - self.assertTrue(success) - self.assertEqual(msg, "DNS cache flushed via resolvectl") - - @patch('subprocess.run') - def test_run_optimization_flush_dns_nscd(self, mock_run): - # First call is systemctl is-active -> fails (resolved inactive) - # Second call is pkexec systemctl restart nscd -> success - import subprocess - mock_res_ok = MagicMock() - mock_res_ok.returncode = 0 - - mock_run.side_effect = [subprocess.CalledProcessError(1, cmd="is-active"), mock_res_ok] - - success, msg = self.optimizer.run_optimization("flush_dns") - self.assertTrue(success) - self.assertEqual(msg, "DNS cache flushed by restarting nscd service") - - @patch('builtins.open', new_callable=mock_open, read_data="MemAvailable: 8000000 kB\nSwapTotal: 2000000 kB\nSwapFree: 1900000 kB\n") - @patch('subprocess.run') - def test_run_optimization_swap_reset_safe(self, mock_run, mock_file): - # Available memory = 8GB, swap used = 100MB (safe) - mock_res = MagicMock() - mock_res.returncode = 0 - mock_run.return_value = mock_res - - success, msg = self.optimizer.run_optimization("swap_reset") - self.assertTrue(success) - self.assertEqual(msg, "Swap space reclaimed and cycled successfully") - - @patch('builtins.open', new_callable=mock_open, read_data="MemAvailable: 100000 kB\nSwapTotal: 2000000 kB\nSwapFree: 1000000 kB\n") - def test_run_optimization_swap_reset_unsafe(self, mock_file): - # Available memory = 100MB, swap used = 1GB (unsafe, should block!) - success, msg = self.optimizer.run_optimization("swap_reset") - self.assertFalse(success) - self.assertIn("Safety warning", msg) - - @patch('subprocess.run') - def test_drop_caches_fallback_sudo_success(self, mock_run): - # first pkexec fails, second sudo succeeds - mock_run.side_effect = [ - None, # sync succeeds - Exception("pkexec error"), # pkexec fails - None # sudo succeeds - ] - success, msg = self.optimizer.run_optimization("drop_caches") - self.assertTrue(success) - self.assertIn("via sudo", msg) - - @patch('subprocess.run') - def test_drop_caches_all_fail(self, mock_run): - mock_run.side_effect = [ - None, # sync succeeds - Exception("pkexec error"), # pkexec fails - Exception("sudo error") # sudo fails - ] - success, msg = self.optimizer.run_optimization("drop_caches") - self.assertFalse(success) - self.assertIn("Failed", msg) - - @patch('subprocess.run') - def test_flush_dns_resolved_fallback_sudo_success(self, mock_run): - mock_res_ok = MagicMock() - mock_res_ok.returncode = 0 - - # resolved active check succeeds, resolvectl pkexec fails, resolvectl sudo succeeds - mock_run.side_effect = [ - mock_res_ok, # resolved active - Exception("pkexec error"), # pkexec fails - mock_res_ok # sudo succeeds - ] - success, msg = self.optimizer.run_optimization("flush_dns") - self.assertTrue(success) - self.assertIn("via sudo", msg) - - @patch('subprocess.run') - def test_flush_dns_resolved_all_fail(self, mock_run): - mock_res_ok = MagicMock() - mock_res_ok.returncode = 0 - - mock_run.side_effect = [ - mock_res_ok, # resolved active - Exception("pkexec error"), # pkexec fails - Exception("sudo error") # sudo fails - ] - success, msg = self.optimizer.run_optimization("flush_dns") - self.assertFalse(success) - self.assertIn("Failed", msg) - - @patch('subprocess.run') - def test_reset_services_fallback_sudo_success(self, mock_run): - mock_run.side_effect = [ - Exception("pkexec error"), - None - ] - success, msg = self.optimizer.run_optimization("reset_services") - self.assertTrue(success) - self.assertIn("via sudo", msg) - - @patch('subprocess.run') - def test_reset_services_all_fail(self, mock_run): - mock_run.side_effect = [ - Exception("pkexec error"), - Exception("sudo error") - ] - success, msg = self.optimizer.run_optimization("reset_services") - self.assertFalse(success) - self.assertIn("Failed", msg) - - @patch('builtins.open', new_callable=mock_open, read_data="MemAvailable: 8000000 kB\nSwapTotal: 2000000 kB\nSwapFree: 1900000 kB\n") - @patch('subprocess.run') - def test_swap_reset_fallback_sudo_success(self, mock_run, mock_file): - # first pkexec swapoff fails, then sudo swapoff/swapon succeeds - mock_run.side_effect = [ - Exception("pkexec error"), # swapoff pkexec fails - None, # swapoff sudo succeeds - None # swapon sudo succeeds - ] - success, msg = self.optimizer.run_optimization("swap_reset") - self.assertTrue(success) - self.assertIn("via sudo", msg) - - @patch('builtins.open', new_callable=mock_open, read_data="MemAvailable: 8000000 kB\nSwapTotal: 2000000 kB\nSwapFree: 1900000 kB\n") - @patch('subprocess.run') - def test_swap_reset_all_fail(self, mock_run, mock_file): - mock_run.side_effect = [ - Exception("pkexec error"), # swapoff pkexec fails - Exception("sudo error") # swapoff sudo fails - ] - success, msg = self.optimizer.run_optimization("swap_reset") - self.assertFalse(success) - self.assertIn("Failed", msg) +import os +import subprocess +from unittest.mock import patch + +from marmot import optimizer +from marmot.optimizer import SystemOptimizer, read_swaps + +SWAPS = """Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority +/dev/zram0 partition\t14180348\t524288\t\t100 +/swap\\040file file\t\t2097148\t\t0\t\t-2 +""" + + +def test_tasks_listed(): + ids = [t['id'] for t in SystemOptimizer().get_optimizations()] + assert ids == ["trim", "swap", "dns", "failed", "drop_caches"] + + +def test_read_swaps(): + assert read_swaps(SWAPS) == [{'name': "/dev/zram0", 'used_kb': 524288, 'prio': 100}, + {'name': "/swap file", 'used_kb': 0, 'prio': -2}] + + +def test_swap_not_offered_when_empty_or_unsafe(): + o = SystemOptimizer() + with patch.object(optimizer, "read_swaps", return_value=[{'name': "/dev/zram0", 'used_kb': 0, 'prio': 100}]): + assert not o.check("swap")['applicable'] + with patch.object(optimizer, "read_swaps", return_value=[{'name': "/dev/zram0", 'used_kb': 4_000_000, 'prio': 100}]), \ + patch.object(optimizer, "mem_available_kb", return_value=5_000_000): + st = o.check("swap") + assert not st['applicable'] and "not safe" in st['status'] + + +def test_swap_command_cycles_only_used_devices(): + with patch.object(optimizer, "read_swaps", return_value=read_swaps(SWAPS)): + cmd = SystemOptimizer().command("swap") + assert cmd[:3] == ["sh", "-c", optimizer._SWAP_CYCLE] and cmd[4:] == ["/dev/zram0", "100"] + + +def test_swap_script_restores_priority(tmp_path): + """Run the real shell script with stub swapoff/swapon: zram must come back with its priority.""" + log = tmp_path / "log" + for tool in ("swapoff", "swapon"): + stub = tmp_path / tool + stub.write_text(f'#!/bin/sh\necho "{tool} $*" >> "{log}"\n') + stub.chmod(0o755) + env = {**os.environ, "PATH": f"{tmp_path}:{os.environ['PATH']}"} + subprocess.run(["sh", "-c", optimizer._SWAP_CYCLE, "sh", "/dev/zram0", "100", "/swap file", "-2"], + check=True, env=env) + assert log.read_text().splitlines() == [ + "swapoff /dev/zram0", "swapon -p 100 /dev/zram0", "swapoff /swap file", "swapon /swap file"] + + +def test_swap_script_stops_on_failure(tmp_path): + for tool, code in (("swapoff", 1), ("swapon", 0)): + stub = tmp_path / tool + stub.write_text(f"#!/bin/sh\nexit {code}\n") + stub.chmod(0o755) + env = {**os.environ, "PATH": f"{tmp_path}:{os.environ['PATH']}"} + assert subprocess.run(["sh", "-c", optimizer._SWAP_CYCLE, "sh", "/dev/zram0", "100"], env=env).returncode != 0 + + +def test_dns_backends(): + o = SystemOptimizer() + with patch.object(optimizer, "_out", side_effect=lambda cmd: (0, "") if "systemd-resolved" in cmd else (3, "")): + assert o.command("dns") == ["resolvectl", "flush-caches"] + with patch.object(optimizer, "_out", return_value=(3, "")): + st = o.check("dns") + assert not st['applicable'] and "nothing to flush" in st['status'] + + +def test_failed_units(): + out = "foo.service loaded failed failed Foo\nbar.mount loaded failed failed Bar\n" + with patch.object(optimizer, "_out", return_value=(0, out)): + assert SystemOptimizer().check("failed") == {'applicable': True, 'status': "2 failed: foo.service, bar.mount"} + with patch.object(optimizer, "_out", return_value=(0, "")): + assert not SystemOptimizer().check("failed")['applicable'] + + +def test_run_skips_with_reason_and_never_calls_root(): + o = SystemOptimizer() + with patch.object(optimizer, "_out", return_value=(0, "")), patch.object(optimizer, "run_root") as root: + ok, msg = o.run_optimization("failed") + assert not ok and msg.startswith("Skipped") and not root.called + + +def test_run_and_dry_run(): + o = SystemOptimizer() + with patch.object(optimizer, "run_root", return_value=(True, "", "/: 1 GiB trimmed")) as root, \ + patch("shutil.which", return_value="/usr/bin/fstrim"), patch.object(optimizer, "_out", return_value=(0, "")): + ok, msg = o.run_optimization("trim", dry_run=True) + assert ok and msg == "Would run as root: fstrim -av" and not root.called + ok, msg = o.run_optimization("trim") + assert ok and "1 GiB trimmed" in msg + root.assert_called_once_with(["fstrim", "-av"]) + + +def test_run_failure_message(): + with patch.object(optimizer, "run_root", return_value=(False, "", "Not authorized")): + ok, msg = SystemOptimizer().run_optimization("drop_caches") + assert not ok and "Not authorized" in msg + + +def test_real_checks_do_not_crash(): + o = SystemOptimizer() + for t in o.get_optimizations(): + st = o.check(t['id']) + assert isinstance(st['applicable'], bool) and st['status'] + + diff --git a/tests/test_pkgmgr.py b/tests/test_pkgmgr.py index 932e47f..dfddf45 100644 --- a/tests/test_pkgmgr.py +++ b/tests/test_pkgmgr.py @@ -98,3 +98,89 @@ def test_skips_marmot_own_venv(self): if __name__ == "__main__": unittest.main() + + +DNF_REMOVE = """Package Arch Version Repository Size +Removing: + python3-requests noarch 0:2.34.2-2.fc45 fedora 679.2 KiB +Removing unused dependencies: + python3-idna noarch 0:3.18-2.fc45 fedora 523.7 KiB + python3-urllib3+socks noarch 0:2.8.0-3.fc45 updates 0.0 B + +Transaction Summary: + Removing: 3 packages +""" + +ZYPPER_REMOVE = """Reading installed packages... +The following 3 packages are going to be REMOVED: + libexpat1 python313-requests + python313-urllib3 + +3 packages to remove. +""" + +ZYPPER_UNNEEDED = """S | Repository | Name | Version | Arch +---+------------+-------------+---------+------- +i | repo-oss | libfoo1 | 1.0-1.1 | x86_64 +i | repo-oss | python3-bar | 2.0-2.1 | noarch +""" + + +class TestMoreBackends(unittest.TestCase): + def test_dnf_remove_preview(self): + self.assertEqual(pkgmgr._parse_dnf_remove(DNF_REMOVE), + ["python3-requests", "python3-idna", "python3-urllib3+socks"]) + + def test_dnf_failed_preview_is_empty(self): + out = "Failed to resolve the transaction:\nProblem 1: ... protected packages: dnf5\n" + self.assertEqual(pkgmgr._parse_dnf_remove(out), []) + + def test_zypper(self): + self.assertEqual(pkgmgr._parse_zypper_remove(ZYPPER_REMOVE), + ["libexpat1", "python313-requests", "python313-urllib3"]) + self.assertEqual(pkgmgr._parse_zypper_table(ZYPPER_UNNEEDED), ["libfoo1", "python3-bar"]) + + def test_apk(self): + info = "alpine-baselayout-3.7.2-r1 - Alpine base dir structure\nca-certificates-bundle-20250911-r0 - Certs\n" + pkgs = pkgmgr._parse_apk_info(info) + self.assertEqual([(p['name'], p['version']) for p in pkgs], + [("alpine-baselayout", "3.7.2-r1"), ("ca-certificates-bundle", "20250911-r0")]) + self.assertEqual(pkgmgr._parse_apk_del("(1/2) Purging curl (8.23.0-r0)\n(2/2) Purging libcurl (8)\nOK: 8 MiB\n"), + ["curl", "libcurl"]) + + def test_detect_order(self): + with patch('shutil.which', side_effect=lambda t: t in ("dnf", "rpm", "zypper")): + self.assertEqual(pkgmgr.detect(), "dnf") + with patch('shutil.which', side_effect=lambda t: t == "apk"): + self.assertEqual(pkgmgr.detect(), "apk") + with patch('shutil.which', return_value=None): + self.assertIsNone(pkgmgr.detect()) + + def test_query_empty_is_none(self): + with patch('subprocess.run', return_value=MagicMock(stdout=b"")): + self.assertIsNone(pkgmgr.query(["x"], pkgmgr._parse_lines)) + with patch('subprocess.run', side_effect=OSError): + self.assertIsNone(pkgmgr.query(["x"], pkgmgr._parse_lines)) + + +class TestFlatpak(unittest.TestCase): + def _un(self): + un = PackageUninstaller(backend="pacman") + un.flatpak = pkgmgr.FLATPAK + return un + + @patch('subprocess.Popen') + def test_lists_both_sources(self, mock_popen): + outs = iter([(PACMAN_QI.encode(), None), (b"org.gimp.GIMP\t3.0.4\tGNU Image Manipulation Program\n", None)]) + mock_popen.return_value.communicate.side_effect = lambda: next(outs) + pkgs = self._un().list_installed_packages() + self.assertEqual({p['name']: p['source'] for p in pkgs}, + {"git": "system", "niri": "system", "org.gimp.GIMP": "flatpak"}) + + @patch('subprocess.run') + def test_flatpak_uninstall_has_no_pkexec(self, mock_run): + mock_run.return_value = MagicMock(returncode=0) + ok, msg = self._un().uninstall("org.gimp.GIMP", source="flatpak") + self.assertTrue(ok) + self.assertEqual(mock_run.call_args.args[0], ["flatpak", "uninstall", "-y", "--delete-data", "org.gimp.GIMP"]) + self.assertEqual(self._un().preview_removal("org.gimp.GIMP", "flatpak"), ["org.gimp.GIMP"]) diff --git a/tests/test_trash.py b/tests/test_trash.py new file mode 100644 index 0000000..577dd74 --- /dev/null +++ b/tests/test_trash.py @@ -0,0 +1,71 @@ +from pathlib import Path + +import pytest + +from marmot import trash + + +def test_trash_and_restore_roundtrip(tmp_path): + victim = tmp_path / "proj" / "node_modules" + victim.mkdir(parents=True) + (victim / "x.js").write_text("hi") + + name = trash.move_to_trash(victim) + assert not victim.exists() + info = (trash.trash_dir() / "info" / f"{name}.trashinfo").read_text() + assert f"Path={victim}" in info and "DeletionDate=" in info + + assert trash.restore_from_trash(name) == victim + assert (victim / "x.js").read_text() == "hi" + assert not (trash.trash_dir() / "info" / f"{name}.trashinfo").exists() + + +def test_name_collisions_get_suffix(tmp_path): + names = [] + for i in range(3): + d = tmp_path / str(i) / ".venv" + d.mkdir(parents=True) + names.append(trash.move_to_trash(d)) + assert names == [".venv", ".venv.2", ".venv.3"] + + +def test_restore_never_overwrites(tmp_path): + f = tmp_path / "a.txt" + f.write_text("old") + name = trash.move_to_trash(f) + f.write_text("new") + with pytest.raises(FileExistsError): + trash.restore_from_trash(name) + assert f.read_text() == "new" + + +def test_undo_last_restores_batch_once(tmp_path): + paths = [tmp_path / "a", tmp_path / "b"] + for p in paths: + p.mkdir() + trashed = [{"name": trash.move_to_trash(p), "path": str(p)} for p in paths] + trash.record("clean:dev_junk", trashed=trashed) + trash.record("clean:trash", freed=10) # not undoable, must be skipped + + entry, restored, errors = trash.undo_last() + assert entry["action"] == "clean:dev_junk" and not errors + assert sorted(restored) == sorted(map(str, paths)) and all(p.exists() for p in paths) + assert trash.undo_last() == (None, [], []) # already undone + + +def test_history_survives_torn_line(): + trash.record("a") + with trash.history_file().open("a") as f: + f.write('{"broken": \n') + trash.record("b") + assert [e["action"] for e in trash.load_history()] == ["a", "b"] + + +def test_missing_path_raises(tmp_path): + with pytest.raises(FileNotFoundError): + trash.move_to_trash(tmp_path / "nope") + assert not list((trash.trash_dir() / "info").glob("*")) if (trash.trash_dir() / "info").exists() else True + + +def test_trash_dir_follows_xdg(tmp_path): + assert trash.trash_dir() == Path(tmp_path / "data" / "Trash") diff --git a/tests/test_uninstaller.py b/tests/test_uninstaller.py index 1b590be..3ef1647 100644 --- a/tests/test_uninstaller.py +++ b/tests/test_uninstaller.py @@ -68,17 +68,18 @@ def custom_is_dir(self_path): Path.is_dir = orig_is_dir @patch('subprocess.run') - @patch('shutil.rmtree') - def test_uninstall_success(self, mock_rmtree, mock_run): + @patch('marmot.trash.move_to_trash', return_value="curl") + def test_uninstall_success(self, mock_trash, mock_run): mock_res = MagicMock() mock_res.returncode = 0 mock_run.return_value = mock_res - - success, msg = self.uninstaller.uninstall("curl", delete_remnants=[str(Path.home() / ".config" / "curl")]) + remnant = str(Path.home() / ".config" / "curl") + + success, msg = self.uninstaller.uninstall("curl", delete_remnants=[remnant]) self.assertTrue(success) self.assertIn("purged successfully", msg) - self.assertIn("Cleared user config remnants", msg) - mock_rmtree.assert_called_once_with(str(Path.home() / ".config" / "curl"), ignore_errors=True) + self.assertIn("Moved remnants to Trash", msg) + mock_trash.assert_called_once_with(remnant) @patch('subprocess.run') def test_uninstall_failure(self, mock_run): @@ -163,12 +164,12 @@ def test_uninstall_exception(self, mock_run): self.assertIn("Error during purge execution", msg) @patch('subprocess.run') - @patch('shutil.rmtree', side_effect=Exception("Delete failed")) - def test_uninstall_remnant_exception(self, mock_rmtree, mock_run): + @patch('marmot.trash.move_to_trash', side_effect=OSError("Delete failed")) + def test_uninstall_remnant_exception(self, mock_trash, mock_run): mock_res = MagicMock() mock_res.returncode = 0 mock_run.return_value = mock_res - + success, msg = self.uninstaller.uninstall("curl", delete_remnants=[str(Path.home() / ".config" / "curl")]) self.assertTrue(success) - self.assertNotIn("Cleared user config remnants", msg) + self.assertNotIn("Moved remnants", msg)