diff --git a/README.md b/README.md index b3c97ad..3249a0e 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,24 @@ Filter by age: docklean --images --older-than 7d ``` +Clean top 10 largest resources: + +```bash +docklean --images --top 10 +``` + +Clean until 5GB is reclaimed: + +```bash +docklean --images --limit-space 5GB +``` + +Combine filters: + +```bash +docklean --images --older-than 30d --top 5 +``` + ## Flags - `--containers` Clean stopped/exited containers @@ -69,6 +87,8 @@ docklean --images --older-than 7d - `--dangling` Dangling images + stopped containers + unused volumes - `--all` All unused resources - `--older-than ` Only clean items older than `m/h/d/w` +- `--limit-space ` Clean until specified space is reclaimed (e.g., `5GB`, `500MB`) +- `--top ` Select top N largest resources - `--dry-run` Print what would be removed - `--force` Skip confirmation prompt - `--yes` Alias for `--force` @@ -77,6 +97,10 @@ docklean --images --older-than 7d - `--verbose` More verbose output - `--no-color` Disable colored output +**Note:** `--limit-space` and `--top` cannot be used together, and they only apply to containers and images. Docker volumes and networks do not report size information, so they are not affected by these flags. + +When using `--top` or `--limit-space` with multiple resource types (e.g., `--containers --images --top 10`), the filter applies independently to each resource type. For example, `--top 10` with both containers and images would select up to 10 containers AND up to 10 images (20 items total), not 10 items across all types. + ## Exit Codes - `0` Success diff --git a/package-lock.json b/package-lock.json index 4908e31..42e6a44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "docklean", - "version": "0.1.2", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docklean", - "version": "0.1.2", + "version": "0.1.4", "license": "MIT", "dependencies": { "@clack/prompts": "^0.7.0", diff --git a/src/args.ts b/src/args.ts index 4c6f986..498da38 100644 --- a/src/args.ts +++ b/src/args.ts @@ -5,12 +5,37 @@ import { CliOptions } from "./types"; export interface ParsedArgs { options: CliOptions; olderThanMs?: number; + limitSpaceBytes?: number; selectedResources: Array>; } const resourceFlags = ["containers", "images", "volumes", "networks", "cache"] as const; const mutableResourceFlags = [...resourceFlags]; +export function parseSizeString(sizeStr: string): number { + const trimmed = sizeStr.trim(); + const match = trimmed.match(/^([0-9.]+)\s*(B|KB|MB|GB|TB)?$/i); + if (!match) { + throw new Error("Invalid size format. Use formats like: 5GB, 500MB, 1.5GB"); + } + + const value = Number(match[1]); + if (isNaN(value) || value < 0) { + throw new Error("Invalid size value: must be a non-negative number"); + } + + const unit = (match[2] || "B").toLowerCase(); + const multipliers: Record = { + b: 1, + kb: 1000, + mb: 1000 ** 2, + gb: 1000 ** 3, + tb: 1000 ** 4 + }; + + return Math.round(value * (multipliers[unit] ?? 1)); +} + export function parseArgs(argv: string[]): ParsedArgs { const program = new Command(); @@ -25,6 +50,8 @@ export function parseArgs(argv: string[]): ParsedArgs { .option("--dangling", "Clean dangling images, stopped containers, unused volumes") .option("--all", "Clean all unused resources") .option("--older-than ", "Only clean resources older than m/h/d/w") + .option("--limit-space ", "Clean until specified space is reclaimed (e.g., 5GB, 500MB)") + .option("--top ", "Select top N largest resources", parseInt) .option("-f, --force", "Skip confirmation prompt") .option("-y, --yes", "Alias for --force") .option("--dry-run", "Print what would be removed") @@ -41,6 +68,10 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new Error("Use either --quiet or --verbose, not both."); } + if (raw.limitSpace && raw.top) { + throw new Error("Use either --limit-space or --top, not both."); + } + const options: CliOptions = { containers: Boolean(raw.containers), images: Boolean(raw.images), @@ -52,6 +83,8 @@ export function parseArgs(argv: string[]): ParsedArgs { force: Boolean(raw.force || raw.yes), dryRun: Boolean(raw.dryRun), olderThan: raw.olderThan, + limitSpace: raw.limitSpace, + top: raw.top, verbose: Boolean(raw.verbose), quiet: Boolean(raw.quiet), json: Boolean(raw.json), @@ -71,6 +104,26 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new Error("Invalid --older-than value. Use m/h/d/w like 7d or 12h."); } + // Validate and parse limitSpace + let limitSpaceBytes: number | undefined = undefined; + if (options.limitSpace) { + try { + limitSpaceBytes = parseSizeString(options.limitSpace); + if (limitSpaceBytes <= 0) { + throw new Error("Size must be greater than 0"); + } + } catch (error) { + throw new Error(`Invalid --limit-space value: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Validate top flag + if (options.top !== undefined) { + if (isNaN(options.top) || options.top <= 0 || !Number.isInteger(options.top)) { + throw new Error("Invalid --top value. Must be a positive integer."); + } + } + let selectedFlags = mutableResourceFlags.filter((flag) => options[flag]); if (options.all) { selectedFlags = mutableResourceFlags; @@ -79,6 +132,7 @@ export function parseArgs(argv: string[]): ParsedArgs { return { options, olderThanMs, + limitSpaceBytes, selectedResources: selectedFlags }; } diff --git a/src/clean.ts b/src/clean.ts index b7c1243..c8e09b9 100644 --- a/src/clean.ts +++ b/src/clean.ts @@ -1,5 +1,13 @@ -import { dockerPrune, filterUntilTimestamp, parseDockerSize } from "./docker"; -import { CleanResult, ResourceType } from "./types"; +import { + dockerPrune, + dockerRemoveContainers, + dockerRemoveImages, + dockerRemoveVolumes, + dockerRemoveNetworks, + filterUntilTimestamp, + parseDockerSize +} from "./docker"; +import { CleanResult, ResourceType, SelectedResources } from "./types"; interface CleanOptions { resources: ResourceType[]; @@ -7,6 +15,8 @@ interface CleanOptions { dryRun: boolean; includeAllImages: boolean; expectedCounts: Record; + selectedIds?: SelectedResources; + estimatedBytes?: Record; } export async function cleanResources(options: CleanOptions): Promise { @@ -32,15 +42,58 @@ export async function cleanResources(options: CleanOptions): Promise 0) { + switch (type) { + case "containers": + await dockerRemoveContainers(ids); + removed[type] = ids.length; + break; + case "images": + await dockerRemoveImages(ids); + removed[type] = ids.length; + break; + case "volumes": + await dockerRemoveVolumes(ids); + removed[type] = ids.length; + break; + case "networks": + await dockerRemoveNetworks(ids); + removed[type] = ids.length; + break; + } + // For ID-based deletion, use the estimated bytes from scan phase + // since docker rm doesn't report space + if (options.estimatedBytes) { + reclaimedBytes += options.estimatedBytes[type] || 0; + } + } + } + } } catch (error) { failures[type].push(error instanceof Error ? error.message : String(error)); } diff --git a/src/docker.ts b/src/docker.ts index 029d38e..d11735c 100644 --- a/src/docker.ts +++ b/src/docker.ts @@ -214,3 +214,27 @@ export function toExitCode(code?: number): ExitCode { if (!code || code === 0) return 0; return 4; } + +export async function dockerRemoveContainers(containerIds: string[]): Promise { + if (containerIds.length === 0) return ""; + const { stdout } = await execDocker(["container", "rm", "-f", ...containerIds]); + return stdout; +} + +export async function dockerRemoveImages(imageIds: string[]): Promise { + if (imageIds.length === 0) return ""; + const { stdout } = await execDocker(["image", "rm", "-f", ...imageIds]); + return stdout; +} + +export async function dockerRemoveVolumes(volumeNames: string[]): Promise { + if (volumeNames.length === 0) return ""; + const { stdout } = await execDocker(["volume", "rm", "-f", ...volumeNames]); + return stdout; +} + +export async function dockerRemoveNetworks(networkIds: string[]): Promise { + if (networkIds.length === 0) return ""; + const { stdout } = await execDocker(["network", "rm", ...networkIds]); + return stdout; +} diff --git a/src/index.ts b/src/index.ts index 03d0db7..8eac961 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,7 +45,7 @@ function summarizeCounts(resources: ResourceType[], totalItems: number): string } async function run(): Promise { - const { options, olderThanMs } = parseArgs(process.argv); + const { options, olderThanMs, limitSpaceBytes } = parseArgs(process.argv); setColorEnabled(!options.noColor); const dockerCheck = await checkDocker(); @@ -64,7 +64,9 @@ async function run(): Promise { includeNetworks: resources.includes("networks"), includeCache: resources.includes("cache"), includeAllImages, - olderThanMs + olderThanMs, + top: options.top, + limitSpaceBytes }); scanSpinner?.succeed("Scan complete"); @@ -140,12 +142,45 @@ async function run(): Promise { cache: 0 } as Record); + // Extract selected IDs when size filters are active + const hasSizeFilters = options.top !== undefined || limitSpaceBytes !== undefined; + let selectedIds = undefined; + let estimatedBytes = undefined; + + if (hasSizeFilters) { + selectedIds = scanResult.summaries.reduce((acc, summary) => { + // Only collect IDs for resource types that have them (not cache) + if (summary.type !== "cache") { + acc[summary.type] = summary.items.map(item => item.id); + } + return acc; + }, { + containers: [], + images: [], + volumes: [], + networks: [] + } as import("./types").SelectedResources); + + estimatedBytes = scanResult.summaries.reduce((acc, summary) => { + acc[summary.type] = summary.reclaimableBytes || 0; + return acc; + }, { + containers: 0, + images: 0, + volumes: 0, + networks: 0, + cache: 0 + } as Record); + } + const cleanResult = await cleanResources({ resources, olderThanMs, dryRun: options.dryRun, includeAllImages, - expectedCounts + expectedCounts, + selectedIds, + estimatedBytes }); cleanSpinner?.succeed("Cleanup completed"); diff --git a/src/scan.ts b/src/scan.ts index 6624ffd..7bb25dc 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -20,6 +20,8 @@ interface ScanOptions { includeCache: boolean; olderThanMs?: number; includeAllImages?: boolean; + top?: number; + limitSpaceBytes?: number; } function parseCreatedAt(raw?: string): string | undefined { @@ -37,6 +39,56 @@ function applyOlderThan(items: ResourceItem[], olderThanMs?: number): ResourceIt }); } +function sortBySize(items: ResourceItem[]): ResourceItem[] { + return [...items].sort((a, b) => { + const sizeA = parseDockerSize(a.size); + const sizeB = parseDockerSize(b.size); + return sizeB - sizeA; // Descending order (largest first) + }); +} + +function selectTopN(items: ResourceItem[], n: number): ResourceItem[] { + return items.slice(0, n); +} + +function selectUntilSpaceLimit(items: ResourceItem[], limitBytes: number): ResourceItem[] { + const selected: ResourceItem[] = []; + let totalBytes = 0; + + for (const item of items) { + selected.push(item); + totalBytes += parseDockerSize(item.size); + if (totalBytes >= limitBytes) break; + } + + return selected; +} + +export function applySizeFilters( + items: ResourceItem[], + top?: number, + limitSpaceBytes?: number +): ResourceItem[] { + if (!top && !limitSpaceBytes) { + return items; + } + + // Sort items by size (largest first) + const sorted = sortBySize(items); + + // Apply top N filter + if (top !== undefined) { + return selectTopN(sorted, top); + } + + // Apply space limit filter + if (limitSpaceBytes !== undefined) { + return selectUntilSpaceLimit(sorted, limitSpaceBytes); + } + + return sorted; +} + export async function scanResources(options: ScanOptions): Promise { const summaries: ResourceSummary[] = []; let totalReclaimableBytes = 0; @@ -56,7 +108,8 @@ export async function scanResources(options: ScanOptions): Promise { raw: container })); - const filtered = applyOlderThan(containers, options.olderThanMs); + let filtered = applyOlderThan(containers, options.olderThanMs); + filtered = applySizeFilters(filtered, options.top, options.limitSpaceBytes); const reclaimableBytes = filtered.reduce((sum, item) => sum + parseDockerSize(item.size), 0); summaries.push({ @@ -84,7 +137,8 @@ export async function scanResources(options: ScanOptions): Promise { raw: image })); - const filtered = applyOlderThan(images, options.olderThanMs); + let filtered = applyOlderThan(images, options.olderThanMs); + filtered = applySizeFilters(filtered, options.top, options.limitSpaceBytes); const reclaimableBytes = filtered.reduce((sum, item) => sum + parseDockerSize(item.size), 0); summaries.push({ diff --git a/src/types.ts b/src/types.ts index 9dde8c1..0692597 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,8 @@ export interface CliOptions { force: boolean; dryRun: boolean; olderThan?: string; + limitSpace?: string; + top?: number; verbose: boolean; quiet: boolean; json: boolean; @@ -46,6 +48,13 @@ export interface CleanResult { reclaimedBytes: number; } +export interface SelectedResources { + containers: string[]; + images: string[]; + volumes: string[]; + networks: string[]; +} + export interface DockerCheck { ok: boolean; code: ExitCode; diff --git a/tests/args.test.ts b/tests/args.test.ts index 3713a65..d06342b 100644 --- a/tests/args.test.ts +++ b/tests/args.test.ts @@ -1,8 +1,57 @@ import { describe, expect, it } from "vitest"; -import { parseArgs } from "../src/args"; +import { parseArgs, parseSizeString } from "../src/args"; const baseArgv = ["node", "docklean"]; +describe("parseSizeString", () => { + it("parses bytes correctly", () => { + expect(parseSizeString("1024B")).toBe(1024); + expect(parseSizeString("1024 B")).toBe(1024); + }); + + it("parses kilobytes correctly", () => { + expect(parseSizeString("5KB")).toBe(5000); + expect(parseSizeString("5 KB")).toBe(5000); + }); + + it("parses megabytes correctly", () => { + expect(parseSizeString("100MB")).toBe(100 * 1000 * 1000); + expect(parseSizeString("100 MB")).toBe(100 * 1000 * 1000); + }); + + it("parses gigabytes correctly", () => { + expect(parseSizeString("5GB")).toBe(5 * 1000 * 1000 * 1000); + expect(parseSizeString("5 GB")).toBe(5 * 1000 * 1000 * 1000); + }); + + it("parses terabytes correctly", () => { + expect(parseSizeString("2TB")).toBe(2 * 1000 * 1000 * 1000 * 1000); + }); + + it("parses decimal values", () => { + expect(parseSizeString("1.5GB")).toBe(Math.round(1.5 * 1000 * 1000 * 1000)); + }); + + it("defaults to bytes when no unit specified", () => { + expect(parseSizeString("1024")).toBe(1024); + }); + + it("is case insensitive for units", () => { + expect(parseSizeString("5gb")).toBe(5 * 1000 * 1000 * 1000); + expect(parseSizeString("5Gb")).toBe(5 * 1000 * 1000 * 1000); + expect(parseSizeString("5gB")).toBe(5 * 1000 * 1000 * 1000); + }); + + it("throws error for invalid format", () => { + expect(() => parseSizeString("invalid")).toThrowError(/Invalid size format/); + expect(() => parseSizeString("abc GB")).toThrowError(/Invalid size format/); + }); + + it("throws error for negative values", () => { + expect(() => parseSizeString("-5GB")).toThrowError(/Invalid size format/); + }); +}); + describe("parseArgs", () => { it("defaults to no flags", () => { const { options } = parseArgs(baseArgv); @@ -238,4 +287,118 @@ describe("parseArgs", () => { expect(result1.selectedResources).toEqual(result2.selectedResources); }); }); + + describe("limit-space flag", () => { + it("parses --limit-space with GB", () => { + const { options, limitSpaceBytes } = parseArgs([...baseArgv, "--limit-space", "5GB"]); + expect(options.limitSpace).toBe("5GB"); + expect(limitSpaceBytes).toBe(5 * 1000 * 1000 * 1000); + }); + + it("parses --limit-space with MB", () => { + const { limitSpaceBytes } = parseArgs([...baseArgv, "--limit-space", "500MB"]); + expect(limitSpaceBytes).toBe(500 * 1000 * 1000); + }); + + it("parses --limit-space with decimal values", () => { + const { limitSpaceBytes } = parseArgs([...baseArgv, "--limit-space", "1.5GB"]); + expect(limitSpaceBytes).toBe(Math.round(1.5 * 1000 * 1000 * 1000)); + }); + + it("parses --limit-space with KB", () => { + const { limitSpaceBytes } = parseArgs([...baseArgv, "--limit-space", "100KB"]); + expect(limitSpaceBytes).toBe(100 * 1000); + }); + + it("parses --limit-space with TB", () => { + const { limitSpaceBytes } = parseArgs([...baseArgv, "--limit-space", "2TB"]); + expect(limitSpaceBytes).toBe(2 * 1000 * 1000 * 1000 * 1000); + }); + + it("parses --limit-space with bytes", () => { + const { limitSpaceBytes } = parseArgs([...baseArgv, "--limit-space", "1024B"]); + expect(limitSpaceBytes).toBe(1024); + }); + + it("parses --limit-space with spaces", () => { + const { limitSpaceBytes } = parseArgs([...baseArgv, "--limit-space", "5 GB"]); + expect(limitSpaceBytes).toBe(5 * 1000 * 1000 * 1000); + }); + + it("rejects invalid --limit-space format", () => { + expect(() => parseArgs([...baseArgv, "--limit-space", "invalid"])) + .toThrowError(/Invalid --limit-space value/); + }); + + it("rejects negative --limit-space value", () => { + expect(() => parseArgs([...baseArgv, "--limit-space", "-5GB"])) + .toThrowError(/Invalid --limit-space value/); + }); + + it("rejects zero --limit-space value", () => { + expect(() => parseArgs([...baseArgv, "--limit-space", "0GB"])) + .toThrowError(/Invalid --limit-space value/); + }); + + it("can combine --limit-space with other flags", () => { + const { options, limitSpaceBytes } = parseArgs([ + ...baseArgv, + "--limit-space", + "5GB", + "--images", + "--containers" + ]); + expect(limitSpaceBytes).toBe(5 * 1000 * 1000 * 1000); + expect(options.images).toBe(true); + expect(options.containers).toBe(true); + }); + }); + + describe("top flag", () => { + it("parses --top flag", () => { + const { options } = parseArgs([...baseArgv, "--top", "10"]); + expect(options.top).toBe(10); + }); + + it("parses --top with large number", () => { + const { options } = parseArgs([...baseArgv, "--top", "1000"]); + expect(options.top).toBe(1000); + }); + + it("rejects negative --top value", () => { + expect(() => parseArgs([...baseArgv, "--top", "-5"])) + .toThrowError(/Invalid --top value/); + }); + + it("rejects zero --top value", () => { + expect(() => parseArgs([...baseArgv, "--top", "0"])) + .toThrowError(/Invalid --top value/); + }); + + it("rejects non-numeric --top value", () => { + expect(() => parseArgs([...baseArgv, "--top", "abc"])) + .toThrowError(/Invalid --top value/); + }); + + it("can combine --top with other flags", () => { + const { options } = parseArgs([ + ...baseArgv, + "--top", + "5", + "--images", + "--dry-run" + ]); + expect(options.top).toBe(5); + expect(options.images).toBe(true); + expect(options.dryRun).toBe(true); + }); + }); + + describe("--limit-space and --top mutual exclusivity", () => { + it("rejects using both --limit-space and --top together", () => { + expect(() => + parseArgs([...baseArgv, "--limit-space", "5GB", "--top", "10"]) + ).toThrowError(/Use either --limit-space or --top, not both/); + }); + }); }); diff --git a/tests/scan.test.ts b/tests/scan.test.ts index be7cc88..94e4cc3 100644 --- a/tests/scan.test.ts +++ b/tests/scan.test.ts @@ -575,3 +575,339 @@ describe("summarizeScan", () => { expect(summary).toContain("0 B"); }); }); + +describe("size filtering", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("top N filter", () => { + it("selects top N largest containers", async () => { + const mockPsAll = dockerPsAll as any; + mockPsAll.mockResolvedValue([ + { + ID: "1", + Names: "large", + State: "exited", + Size: "500 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "2", + Names: "medium", + State: "exited", + Size: "300 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "3", + Names: "small", + State: "exited", + Size: "100 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + } + ]); + + const result = await scanResources({ + includeContainers: true, + includeImages: false, + includeVolumes: false, + includeNetworks: false, + includeCache: false, + top: 2 + }); + + expect(result.summaries[0].items).toHaveLength(2); + expect(result.summaries[0].items[0].name).toBe("large"); + expect(result.summaries[0].items[1].name).toBe("medium"); + }); + + it("selects top N largest images", async () => { + const mockImages = dockerImages as any; + mockImages.mockResolvedValue([ + { + ID: "1", + Repository: "", + Tag: "", + Size: "1 GB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "2", + Repository: "", + Tag: "", + Size: "500 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "3", + Repository: "", + Tag: "", + Size: "200 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "4", + Repository: "", + Tag: "", + Size: "100 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + } + ]); + + const result = await scanResources({ + includeContainers: false, + includeImages: true, + includeVolumes: false, + includeNetworks: false, + includeCache: false, + includeAllImages: false, + top: 3 + }); + + expect(result.summaries[0].items).toHaveLength(3); + expect(result.summaries[0].items[0].size).toBe("1 GB"); + expect(result.summaries[0].items[1].size).toBe("500 MB"); + expect(result.summaries[0].items[2].size).toBe("200 MB"); + }); + + it("returns all items when top N is greater than available items", async () => { + const mockPsAll = dockerPsAll as any; + mockPsAll.mockResolvedValue([ + { + ID: "1", + Names: "container1", + State: "exited", + Size: "100 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "2", + Names: "container2", + State: "exited", + Size: "50 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + } + ]); + + const result = await scanResources({ + includeContainers: true, + includeImages: false, + includeVolumes: false, + includeNetworks: false, + includeCache: false, + top: 10 + }); + + expect(result.summaries[0].items).toHaveLength(2); + }); + }); + + describe("limit-space filter", () => { + it("selects containers until space limit is reached", async () => { + const mockPsAll = dockerPsAll as any; + mockPsAll.mockResolvedValue([ + { + ID: "1", + Names: "large", + State: "exited", + Size: "500 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "2", + Names: "medium", + State: "exited", + Size: "300 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "3", + Names: "small", + State: "exited", + Size: "100 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + } + ]); + + // Limit to 600 MB - selects items in descending size order until limit is reached. + // Selects "large" (500 MB, total=500 MB), then "medium" (300 MB, total=800 MB >= limit), stops. + const result = await scanResources({ + includeContainers: true, + includeImages: false, + includeVolumes: false, + includeNetworks: false, + includeCache: false, + limitSpaceBytes: 600 * 1000 * 1000 + }); + + expect(result.summaries[0].items).toHaveLength(2); + expect(result.summaries[0].items[0].name).toBe("large"); + expect(result.summaries[0].items[1].name).toBe("medium"); + }); + + it("selects images until space limit is reached", async () => { + const mockImages = dockerImages as any; + mockImages.mockResolvedValue([ + { + ID: "1", + Repository: "", + Tag: "", + Size: "1 GB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "2", + Repository: "", + Tag: "", + Size: "500 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "3", + Repository: "", + Tag: "", + Size: "200 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + } + ]); + + const result = await scanResources({ + includeContainers: false, + includeImages: true, + includeVolumes: false, + includeNetworks: false, + includeCache: false, + includeAllImages: false, + limitSpaceBytes: 1.2 * 1000 * 1000 * 1000 + }); + + expect(result.summaries[0].items).toHaveLength(2); + expect(result.summaries[0].reclaimableBytes).toBe(1.5 * 1000 * 1000 * 1000); + }); + + it("selects single item when it exceeds limit", async () => { + const mockPsAll = dockerPsAll as any; + mockPsAll.mockResolvedValue([ + { + ID: "1", + Names: "huge", + State: "exited", + Size: "5 GB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "2", + Names: "small", + State: "exited", + Size: "100 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + } + ]); + + const result = await scanResources({ + includeContainers: true, + includeImages: false, + includeVolumes: false, + includeNetworks: false, + includeCache: false, + limitSpaceBytes: 1 * 1000 * 1000 * 1000 // 1 GB limit + }); + + // Should select at least the first (largest) item + expect(result.summaries[0].items).toHaveLength(1); + expect(result.summaries[0].items[0].name).toBe("huge"); + }); + }); + + describe("sorting by size", () => { + it("sorts containers by size in descending order", async () => { + const mockPsAll = dockerPsAll as any; + mockPsAll.mockResolvedValue([ + { + ID: "1", + Names: "small", + State: "exited", + Size: "100 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "2", + Names: "large", + State: "exited", + Size: "500 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "3", + Names: "medium", + State: "exited", + Size: "300 MB", + CreatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() + } + ]); + + const result = await scanResources({ + includeContainers: true, + includeImages: false, + includeVolumes: false, + includeNetworks: false, + includeCache: false, + top: 10 // Get all but sorted + }); + + expect(result.summaries[0].items).toHaveLength(3); + expect(result.summaries[0].items[0].name).toBe("large"); + expect(result.summaries[0].items[1].name).toBe("medium"); + expect(result.summaries[0].items[2].name).toBe("small"); + }); + }); + + describe("combined with older-than filter", () => { + it("applies older-than filter before size filter", async () => { + const mockPsAll = dockerPsAll as any; + mockPsAll.mockResolvedValue([ + { + ID: "1", + Names: "old-large", + State: "exited", + Size: "500 MB", + CreatedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "2", + Names: "recent-huge", + State: "exited", + Size: "1 GB", + CreatedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString() + }, + { + ID: "3", + Names: "old-small", + State: "exited", + Size: "100 MB", + CreatedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() + } + ]); + + const result = await scanResources({ + includeContainers: true, + includeImages: false, + includeVolumes: false, + includeNetworks: false, + includeCache: false, + olderThanMs: 14 * 24 * 60 * 60 * 1000, // 14 days + top: 1 + }); + + // Should filter to keep only items younger than 14 days (created within last 14 days) + // Then select the single largest item by size from the remaining items (recent-huge) + expect(result.summaries[0].items).toHaveLength(1); + expect(result.summaries[0].items[0].name).toBe("recent-huge"); + }); + }); +});