diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d3a49..ae36df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,43 @@ All notable changes to this project will be documented in this file. --- +## [0.10.0] 2026-04-04 + +- Add JSON configuration file hierarchy + (`$HOME`, project-level, local override, `-ConfigFile`) + with scalar-override and array-append merge rules and optional + upward traversal via `searchParentFolders` + See `docs/configuration.md` for details. + +- Add `-ConfigFile ` to inject an explicit config file (e.g. for CI + pipelines) at highest config priority below command-line parameters. + +- Add `-ShowConfig` to display the effective merged configuration and exit + without scanning or cleaning. Supports `-Json` for machine-readable output. + +- Add freed-space reporting to the clean summary: + `Space freed : 142.3 MB` (text) or `BytesFreed` (JSON). + Also reported in `-Check` mode as `Space to free` and in `-WhatIf` mode + as `Space would free`. + +- Add elapsed time to all summaries (`Duration` in text, `DurationMs` in JSON). + +- Add `Size` (bytes) to every item in the JSON `Items` array, covering both + files (direct length) and directories (recursive sum computed before deletion). + +- Add `Write-Progress` feedback during file scan (updates every 500 files) and + during the deletion phase; suppressed when `-Json` is active. + +- Add cross-platform build artifact patterns to the `standard` level: + `*.o`, `*.a` (Linux/macOS object/static-lib), `*.dylib` (macOS dynamic lib). + +- Add `iOSSimulatorArm64` and `LinuxARM64` output directories to the + `standard` level for Delphi 12+ platform targets. + +- Add `*.mab` (MadExcept/JEDI debug map) to the `deep` level. + +--- + ## [0.9.0] 2026-03-30 - Add `-Check` for simple 0:clean, 1:dirty check diff --git a/README.md b/README.md index fa2566d..9967e42 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,11 @@ and the newer PowerShell 7+ (`pwsh`). - Add extra file patterns with `-IncludeFilePattern` - Exclude directories by wildcard pattern with `-ExcludeDirectoryPattern` - Send items to the recycle bin / trash instead of permanent deletion with `-RecycleBin` -- Use `-OutputLevel` to adjust how much detail is shown. -- Check for cleanup artifacts without modifying files using `-Check`. +- Use `-OutputLevel` to adjust how much detail is shown +- Check for cleanup artifacts without modifying files using `-Check` +- JSON configuration file hierarchy (`$HOME`, project-level, local override, `-ConfigFile`) +- Inspect the effective merged configuration with `-ShowConfig` +- Inject a CI-specific config file via `-ConfigFile` --- @@ -113,19 +116,23 @@ delphi-clean -PassThru ## -Json -Outputs a JSON summary including: +Outputs a single JSON object to standard output. All other output (text, progress) +is suppressed. Key fields include: -- Files found -- Directories found -- Files deleted -- Directories deleted -- Item-level details +- `Level`, `Root`, `Mode` -- invocation metadata +- `FilesFound`, `DirectoriesFound` -- items discovered during scan +- `FilesDeleted`, `DirectoriesDeleted` -- items actually removed +- `BytesFreed` -- total bytes freed (or would-be freed in `-WhatIf`/`-Check`) +- `DurationMs` -- elapsed time in milliseconds - `Disposition` (`Permanent` or `Recycle Bin`) and `RecycleBin` flag +- `Items[]` -- per-item records with `Type`, `Path`, `Deleted`, and `Size` (bytes) ```powershell delphi-clean -Json ``` +See [docs/json-output.md](docs/json-output.md) for the full field reference. + --- ## -IncludeFilePattern @@ -324,6 +331,73 @@ Check vs WhatIf: --- +## -ShowConfig + +Displays the effective merged configuration that would be used for the current +invocation, then exits without scanning or cleaning. No files are modified. + +```powershell +delphi-clean -ShowConfig +delphi-clean -ShowConfig -Json +``` + +The output lists every config file that was found and loaded, plus the final +resolved value for each property (including built-in excluded directories and +any CLI overrides already applied). + +--- + +## -ConfigFile + +Injects an explicit JSON configuration file at the highest config priority +(above project-level and local files, below command-line parameters). Useful +in CI pipelines where the config lives outside the repository tree, or when +testing a config before committing it. + +```powershell +delphi-clean -ConfigFile C:/ci/delphi-clean-ci.json -Level standard +``` + +The file uses the same JSON format as `delphi-clean.json`. See +[docs/configuration.md](docs/configuration.md) for the full key reference. + +--- + +## Configuration Files + +`delphi-clean` supports optional JSON configuration files so you can encode +per-project and per-user preferences without repeating them on the command line. + +Config sources, from lowest to highest priority: + +``` +$HOME/delphi-clean.json user-level defaults + /delphi-clean.json project-level (commit with the repo) + /delphi-clean.local.json local user overrides (add to .gitignore) + -ConfigFile explicit file (e.g. for CI) + command-line parameters highest priority +``` + +**Scalars override** -- the highest-priority source wins. +**Arrays append** -- items from all sources are combined; duplicates are removed. + +Example `delphi-clean.json` (project-level): + +```json +{ + "level": "standard", + "outputLevel": "summary", + "includeFilePattern": ["*.res"], + "excludeDirectoryPattern": ["assets"], + "searchParentFolders": false +} +``` + +See [docs/configuration.md](docs/configuration.md) for the full reference, +including upward traversal (`searchParentFolders`) and monorepo patterns. + +--- + ## Exit Codes ```text diff --git a/docs/cleanup-levels.md b/docs/cleanup-levels.md index 22f193b..2e9ccf0 100644 --- a/docs/cleanup-levels.md +++ b/docs/cleanup-levels.md @@ -44,6 +44,9 @@ Includes everything in `basic, plus the following additional items. - `*.dcp` - `*.bpi` - `*.so` +- `*.o` +- `*.a` +- `*.dylib` - `*.exe` - `*.hpp` - `*.dres` @@ -67,7 +70,9 @@ Includes everything in `basic, plus the following additional items. - `Android` - `Android64` - `iOSDevice64` +- `iOSSimulatorArm64` - `Linux64` +- `LinuxARM64` - `TMSWeb` --- @@ -88,6 +93,7 @@ Includes everything in `standard`, plus the following additional items. - `*.fbl8` - `*.fbpbrk` - `*.fb8lck` +- `*.mab` - `TestInsightSettings.ini` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..f59ec93 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,388 @@ +# delphi-clean configuration files + +`delphi-clean` supports optional JSON configuration files that let you encode +per-project and per-user preferences so you do not have to repeat them on the +command line every time. + +Configuration files are hierarchical in nature: +`$HOME < traversed parents < project < local < -ConfigFile < CLI` + +--- + +## Configuration file names + +| File | Location | Purpose | +|------|----------|---------| +| `delphi-clean.json` | `$HOME` directory | User-level defaults applied to every project | +| `delphi-clean.json` | `-RootPath` directory | Project-level settings committed with the repository | +| `delphi-clean.local.json` | `-RootPath` directory | User-local overrides (typically not committed - add to `.gitignore`) | + +The `-RootPath` directory defaults to the current working directory when the +flag is not supplied on the command line. + +--- + +## Priority order + +Sources are listed from lowest to highest priority: + +``` +$HOME/delphi-clean.json (user-level) + /delphi-clean.json (project-level) + /delphi-clean.local.json (local user overrides) + -ConfigFile (explicit file, e.g. for CI) + command-line parameters (highest priority) +``` + +--- + +## Merge rules + +**Scalars override.** The highest-priority source that specifies a scalar +value wins. Lower-priority sources are ignored for that property. + +**Arrays append.** Every source contributes its array items. All items from +all sources are combined into a single list. Lower-priority items appear before +higher-priority items in the merged result. Duplicate entries are removed; +the first occurrence (lowest-priority source) is kept and later duplicates +are discarded. + +Example: the user-level config adds `*.res` to `includeFilePattern`, the +project config adds `*.mab`, and the command line supplies `*.myext`. The +effective pattern list is `["*.res", "*.mab", "*.myext"]`. If two sources +both list `*.res`, it appears only once at the position contributed by the +lower-priority source. + +This design means project and user configs are additive -- a project can +extend the user's patterns without erasing them, and a local override can +extend the project's patterns without erasing those. + +--- + +## JSON format + +All keys are optional. Omit any key you do not want to set. + +```json +{ + "level": "standard", + "outputLevel": "summary", + "recycleBin": false, + "includeFilePattern": ["*.res", "*.mab"], + "excludeDirectoryPattern": ["vendor*", "assets"], + "searchParentFolders": false +} +``` + +### Supported keys + +| Key | Type | Equivalent CLI flag | Description | +|-----|------|---------------------|-------------| +| `level` | string | `-Level` | Cleanup level: `basic`, `standard`, or `deep` | +| `outputLevel` | string | `-OutputLevel` | Verbosity: `detailed`, `summary`, or `quiet` | +| `recycleBin` | boolean | `-RecycleBin` | Send items to the platform trash instead of deleting permanently | +| `includeFilePattern` | string array | `-IncludeFilePattern` | Additional glob patterns to remove | +| `excludeDirectoryPattern` | string array | `-ExcludeDirectoryPattern` | Directory name patterns to skip | +| `searchParentFolders` | boolean | _(config only)_ | Enable upward traversal for additional project configs (see below) | + +Keys not listed above (such as `-Check`, `-WhatIf`, `-PassThru`, `-Json`, +`-RootPath`) are intentionally excluded from the config format because they +represent invocation-specific behavior rather than persistent preferences. + +--- + +## `-ConfigFile` -- explicit config path + +Pass an explicit JSON config file path on the command line to inject a config +at the highest priority below CLI parameters. This is useful in CI pipelines +where the config lives outside the repository tree, or when testing a config +before committing it: + +```powershell +delphi-clean -RootPath C:/code/myproject -ConfigFile C:/ci/delphi-clean-ci.json +``` + +`-ConfigFile` uses the same JSON format as the project-level file. Its scalars +override everything in the fixed-location hierarchy; its arrays append after +(and deduplicate with) items from all lower-priority sources. + +--- + +## `-ShowConfig` -- inspect the effective configuration + +Pass `-ShowConfig` to display the merged configuration that would be used for +the current invocation and exit without scanning or cleaning. No files are +modified. + +```powershell +delphi-clean -RootPath C:/code/myproject -ShowConfig +``` + +The output lists every config file that was loaded and the final effective +value for each property, including built-in excluded directories and any +CLI overrides already applied. + +Add `-Json` to get machine-readable output: + +```powershell +delphi-clean -RootPath C:/code/myproject -ShowConfig -Json +``` + +--- + +## Upward traversal (`searchParentFolders`) + +By default, `delphi-clean` reads only the three fixed locations listed above. +Upward traversal is triggered only when one of those fixed-location configs +requests it -- typically the project-level `delphi-clean.json` or the local +`delphi-clean.local.json` at `-RootPath`. The `searchParentFolders` key is +ignored in the `$HOME` user-level config; user-level settings are global by +definition and do not participate in project-tree traversal. + +When traversal is enabled, the tool walks from the `-RootPath` directory toward +the filesystem root, collecting a `delphi-clean.json` at each parent level it +finds one. + +Traversal stops when either of these conditions is met: + +- The filesystem root is reached. +- A `delphi-clean.json` is found that contains `"searchParentFolders": false`. + That file acts as a root marker and is included in the merge, but no further + parent directories are searched. + +The priority of traversed files relative to one another follows proximity: +the file nearest to `-RootPath` has higher priority than a file found further +up the directory tree. All traversed files remain lower priority than the +three fixed locations and the command line. + +Extended priority order with traversal enabled (lowest to highest): + +``` +$HOME/delphi-clean.json (user-level, lowest -- searchParentFolders ignored here) + /delphi-clean.json (farthest parent found via traversal) + ... + /delphi-clean.json (nearest parent found via traversal) + /delphi-clean.json (project-level) + /delphi-clean.local.json (local user overrides) + -ConfigFile (explicit file, e.g. for CI) + command-line parameters (highest priority) +``` + +Traversed parent configs slot between the user-level `$HOME` config and the +project-level config at `-RootPath`. A closer ancestor always outranks a +farther one, and all traversed configs outrank the global user defaults. + +### Recommended pattern for monorepos + +Place a `delphi-clean.json` with `"searchParentFolders": false` at the +repository root to act as a stop marker. Sub-projects can then enable +traversal with `"searchParentFolders": true` to inherit the root config +without accidentally walking out of the repository. + +--- + +## Config file examples + +### `$HOME/delphi-clean.json` (user-level defaults) + +```json +{ + "outputLevel": "summary", + "recycleBin": true, + "excludeDirectoryPattern": ["vendor*"] +} +``` + +### `/delphi-clean.json` (project-level, committed to source control) + +```json +{ + "level": "standard", + "includeFilePattern": ["*.res"], + "excludeDirectoryPattern": ["assets"], + "searchParentFolders": false +} +``` + +### `/delphi-clean.local.json` (local user overrides, not committed) + +```json +{ + "level": "deep", + "outputLevel": "detailed" +} +``` + +### Effective configuration after merge + +Given the three files above plus no command-line flags: + +| Property | Resolved value | Winning source | +|----------|---------------|----------------| +| `level` | `deep` | local override | +| `outputLevel` | `detailed` | local override | +| `recycleBin` | `true` | user-level | +| `includeFilePattern` | `["*.res"]` | project-level | +| `excludeDirectoryPattern` | `["vendor*", "assets"]` | user-level + project-level (appended) | +| `searchParentFolders` | `false` | project-level | + +--- + +## Monorepo traversal example + +This example shows upward traversal across a monorepo where two sub-projects +share a common root config but one of them overrides part of it. + +### Directory layout + +``` +C:/code/acme-suite/ + delphi-clean.json <-- repo root (stop marker) + billing/ + delphi-clean.json <-- billing sub-project + payments/ + delphi-clean.json <-- payments sub-project + delphi-clean.local.json <-- developer's local override (not committed) +``` + +### `C:/code/acme-suite/delphi-clean.json` (repo root -- stop marker) + +```json +{ + "level": "standard", + "excludeDirectoryPattern": ["vendor*"], + "searchParentFolders": false +} +``` + +The `searchParentFolders: false` here acts as a boundary. No config above this +directory will ever be read, regardless of what sub-project configs request. + +### `C:/code/acme-suite/billing/delphi-clean.json` + +```json +{ + "searchParentFolders": true +} +``` + +Minimal config. Enables traversal so the billing project inherits the repo root +settings. Adds nothing else of its own. + +### `C:/code/acme-suite/payments/delphi-clean.json` + +```json +{ + "level": "deep", + "includeFilePattern": ["*.res"], + "searchParentFolders": true +} +``` + +Overrides `level` to `deep` and adds a custom pattern. Still inherits +`excludeDirectoryPattern` from the repo root. + +### `C:/code/acme-suite/payments/delphi-clean.local.json` (not committed) + +```json +{ + "outputLevel": "detailed" +} +``` + +One developer's personal preference for verbose output in the payments project. + +--- + +### Effective configuration when run from each sub-project + +Running `delphi-clean -RootPath C:/code/acme-suite/billing`: + +| Property | Resolved value | Source(s) | +|----------|---------------|-----------| +| `level` | `standard` | repo root (traversed) | +| `outputLevel` | `detailed` | built-in default | +| `excludeDirectoryPattern` | `["vendor*"]` | repo root (traversed) | +| `includeFilePattern` | `[]` | (none set) | +| `searchParentFolders` | `true` | billing project | + +Traversal path: billing config (`searchParentFolders: true`) -> walks up -> +finds repo root config (`searchParentFolders: false`) -> stops. + +--- + +Running `delphi-clean -RootPath C:/code/acme-suite/payments`: + +| Property | Resolved value | Source(s) | +|----------|---------------|-----------| +| `level` | `deep` | payments project (overrides repo root) | +| `outputLevel` | `detailed` | local override | +| `excludeDirectoryPattern` | `["vendor*"]` | repo root (traversed, appended) | +| `includeFilePattern` | `["*.res"]` | payments project | +| `searchParentFolders` | `true` | payments project | + +Traversal path: payments local -> payments project (`searchParentFolders: true`) +-> walks up -> finds repo root (`searchParentFolders: false`) -> stops. + +The `level` scalar from the payments project config takes precedence over the +repo root's `standard` because the payments config is closer to RootPath (higher +priority). The `excludeDirectoryPattern` array from the repo root is preserved +because arrays append rather than replace. + +--- + +## Debugging configuration resolution + +Pass `-Verbose` to see exactly which config files were found and what the final +merged values are. This is the primary tool for diagnosing unexpected behavior. + +Example output when running from the `payments` sub-project in the monorepo +example above: + +``` +VERBOSE: [config] loaded user-level: $HOME/delphi-clean.json +VERBOSE: [config] level = (not set) +VERBOSE: [config] outputLevel = (not set) + +VERBOSE: [config] loaded traversed: C:/code/acme-suite/delphi-clean.json +VERBOSE: [config] level = standard +VERBOSE: [config] excludeDirectoryPattern += ["vendor*"] +VERBOSE: [config] searchParentFolders = false (stop marker -- traversal ends here) + +VERBOSE: [config] loaded project-level: C:/code/acme-suite/payments/delphi-clean.json +VERBOSE: [config] level = deep +VERBOSE: [config] includeFilePattern += ["*.res"] +VERBOSE: [config] searchParentFolders = true + +VERBOSE: [config] loaded local override: C:/code/acme-suite/payments/delphi-clean.local.json +VERBOSE: [config] outputLevel = detailed + +VERBOSE: [config] final merged values: +VERBOSE: [config] level = deep (payments/delphi-clean.json) +VERBOSE: [config] outputLevel = detailed (payments/delphi-clean.local.json) +VERBOSE: [config] recycleBin = false (default) +VERBOSE: [config] includeFilePattern = ["*.res"] (payments/delphi-clean.json) +VERBOSE: [config] excludeDirectoryPattern = ["vendor*"] (acme-suite/delphi-clean.json) +VERBOSE: [config] searchParentFolders = true (payments/delphi-clean.json) +``` + +Each `+=` line indicates an array append. Each `=` line for a scalar shows the +value that will take effect; if a higher-priority source later sets the same +scalar, that earlier line is superseded and the winning source is shown in the +final merged values block. + +Config files that were searched but not found are not listed. If a file you +expect to be loaded is absent from the verbose output, it was not found at the +path that was searched. + +--- + +## `.gitignore` recommendation + +The `.local.json` file is intended for personal settings that vary by +developer and should not be committed. Add it to your repository's +`.gitignore`: + +``` +delphi-clean.local.json +``` diff --git a/docs/json-output.md b/docs/json-output.md new file mode 100644 index 0000000..a194d6f --- /dev/null +++ b/docs/json-output.md @@ -0,0 +1,163 @@ +# delphi-clean JSON output reference + +Pass `-Json` to any invocation to receive a single JSON object on standard +output. All plain-text messages and progress output are suppressed when `-Json` +is active. The exit code is still set normally. + +--- + +## Clean / WhatIf / Check output + +This object is returned after a scan, whether or not files are deleted. + +```json +{ + "Level": "standard", + "Root": "C:/code/myproject", + "ExcludeDirectoryPattern": [".git", ".vs", ".claude"], + "IncludeFilePattern": [], + "Mode": "Clean", + "Disposition": "Permanent", + "RecycleBin": false, + "Check": false, + "FilesFound": 14, + "DirectoriesFound": 3, + "FilesDeleted": 14, + "DirectoriesDeleted": 3, + "FilesFailed": 0, + "DirectoriesFailed": 0, + "BytesFreed": 1491763, + "DurationMs": 82, + "Items": [ + { "Type": "File", "Path": "C:/code/myproject/source/Unit1.dcu", "Deleted": true, "Size": 4096 }, + { "Type": "Directory", "Path": "C:/code/myproject/Win32", "Deleted": true, "Size": 51200 } + ] +} +``` + +### Top-level fields + +| Field | Type | Description | +|-------|------|-------------| +| `Level` | string | Cleanup level used: `basic`, `standard`, or `deep` | +| `Root` | string | Absolute path of the scanned root directory | +| `ExcludeDirectoryPattern` | string array | Combined list of built-in and user-supplied exclusion patterns | +| `IncludeFilePattern` | string array | Additional file patterns supplied via config or `-IncludeFilePattern` | +| `Mode` | string | `Clean`, `WhatIf`, or `Check` | +| `Disposition` | string | `Permanent` or `Recycle Bin` | +| `RecycleBin` | boolean | Whether `-RecycleBin` was active | +| `Check` | boolean | Whether `-Check` was active | +| `FilesFound` | integer | Total files matched by the scan | +| `DirectoriesFound` | integer | Total directories matched by the scan | +| `FilesDeleted` | integer | Files actually removed (0 in WhatIf/Check) | +| `DirectoriesDeleted` | integer | Directories actually removed (0 in WhatIf/Check) | +| `FilesFailed` | integer | Files that could not be removed (0 in WhatIf/Check) | +| `DirectoriesFailed` | integer | Directories that could not be removed (0 in WhatIf/Check) | +| `BytesFreed` | integer | Bytes freed (Clean), bytes that would be freed (WhatIf), or bytes that need to be freed (Check) | +| `DurationMs` | integer | Elapsed time from start to end of the operation, in milliseconds | +| `Items` | array | Per-item records (see below) | + +### Item records (`Items[]`) + +Each element in the `Items` array describes one file or directory that was found +during the scan. + +| Field | Type | Description | +|-------|------|-------------| +| `Type` | string | `File` or `Directory` | +| `Path` | string | Absolute path to the item | +| `Deleted` | boolean | `true` if the item was actually removed; `false` in WhatIf/Check or after a failure | +| `Size` | integer | Size in bytes. For files, the file length. For directories, the recursive sum of all contained files computed before deletion. | + +### `Mode` values + +| Value | When set | +|-------|----------| +| `Clean` | Normal run -- items are deleted (or recycled) | +| `WhatIf` | `-WhatIf` was supplied -- scan only, no deletions | +| `Check` | `-Check` was supplied -- scan only, no deletions | + +--- + +## Nothing-to-clean output + +When the scan finds no matching artifacts, the same object shape is returned +with zeroed counters and an empty `Items` array. + +```json +{ + "Level": "standard", + "Root": "C:/code/myproject", + "ExcludeDirectoryPattern": [".git", ".vs", ".claude"], + "IncludeFilePattern": [], + "Mode": "Clean", + "Disposition": "Permanent", + "RecycleBin": false, + "Check": false, + "FilesFound": 0, + "DirectoriesFound": 0, + "FilesDeleted": 0, + "DirectoriesDeleted": 0, + "FilesFailed": 0, + "DirectoriesFailed": 0, + "BytesFreed": 0, + "DurationMs": 11, + "Items": [] +} +``` + +--- + +## -ShowConfig output + +When `-ShowConfig -Json` is used, a different object shape is returned +describing the effective merged configuration. No scan or cleanup is performed. + +```json +{ + "Root": "C:/code/myproject", + "ConfigSources": [ + "C:/Users/darian/delphi-clean.json", + "C:/code/myproject/delphi-clean.json" + ], + "Level": "standard", + "OutputLevel": "detailed", + "RecycleBin": false, + "IncludeFilePattern": ["*.res"], + "ExcludeDirectoryPattern": [".git", ".vs", ".claude", "vendor*"] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `Root` | string | Absolute path that would be used as the scan root | +| `ConfigSources` | string array | Config files that were found and loaded, in priority order (lowest first) | +| `Level` | string | Effective cleanup level | +| `OutputLevel` | string | Effective output verbosity | +| `RecycleBin` | boolean | Whether recycle-bin mode is active | +| `IncludeFilePattern` | string array | Merged additional file patterns | +| `ExcludeDirectoryPattern` | string array | Merged directory exclusion patterns (includes built-in entries) | + +--- + +## -Version output + +When `-Version -Format json` is used, a version envelope is returned. + +```json +{ + "ok": true, + "command": "version", + "tool": { + "name": "delphi-clean", + "version": "0.10.0" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `ok` | boolean | Always `true` | +| `command` | string | Always `"version"` | +| `tool.name` | string | Always `"delphi-clean"` | +| `tool.version` | string | Semantic version of the script | diff --git a/source/delphi-clean.ps1 b/source/delphi-clean.ps1 index 854b710..3801cac 100644 --- a/source/delphi-clean.ps1 +++ b/source/delphi-clean.ps1 @@ -75,6 +75,77 @@ powershell.exe -File .\delphi-clean.ps1 -Level standard -Check -OutputLevel quie .EXAMPLE powershell.exe -File .\delphi-clean.ps1 -Level standard -OutputLevel summary + +.EXAMPLE +powershell.exe -File .\delphi-clean.ps1 -ShowConfig + +.EXAMPLE +powershell.exe -File .\delphi-clean.ps1 -ShowConfig -Json + +.EXAMPLE +powershell.exe -File .\delphi-clean.ps1 -ConfigFile C:/ci/delphi-clean-ci.json -Level standard + +.PARAMETER Level +Cleanup level to apply. One of: basic, standard, deep. Levels are cumulative -- +standard includes everything basic removes, and deep includes everything standard +removes. Defaults to basic. + +.PARAMETER RootPath +Root directory to scan. Defaults to the current working directory when omitted. +All scans and deletions are confined to this directory tree. + +.PARAMETER ExcludeDirectoryPattern +One or more directory-name glob patterns to skip during scanning. Directories +whose names match any pattern (case-insensitive) are not entered and nothing +inside them is deleted. The built-in exclusions (.git, .vs, .claude) are always +applied in addition to any patterns supplied here. + +.PARAMETER IncludeFilePattern +One or more additional file-name glob patterns to delete beyond the patterns +implied by -Level. Useful for project-specific artifacts such as *.res or *.mab. + +.PARAMETER PassThru +Return a structured object to the pipeline containing the list of found items +and deletion results. Intended for scripting scenarios that consume the output +programmatically. May be combined with -WhatIf. + +.PARAMETER Json +Emit a single JSON object to standard output instead of plain-text messages. +Suppresses all other output (Write-Information, Write-Progress). Suitable for +CI pipelines and tooling integrations. + +.PARAMETER RecycleBin +Send items to the platform recycle bin / trash instead of deleting them +permanently. On Windows, uses Microsoft.VisualBasic.FileIO.FileSystem. On +macOS, uses the 'trash' shell command. Not supported on Linux. + +.PARAMETER Check +Audit-only mode. Scans for artifacts but does not delete anything. Exits with +code 0 when nothing is found, or 1 when artifacts are present. Cannot be +combined with -WhatIf. + +.PARAMETER ShowConfig +Display the effective merged configuration (from all config files and CLI +flags) and exit without scanning or cleaning. Add -Json for machine-readable +output. + +.PARAMETER ConfigFile +Path to an explicit JSON configuration file. Loaded at the highest priority +below command-line parameters, above project-level and local config files. +Useful in CI pipelines where the config lives outside the repository tree. + +.PARAMETER OutputLevel +Controls the amount of plain-text output produced during a run. + detailed - header, per-item lines, and summary (default) + summary - header and summary only; per-item lines are suppressed + quiet - no output at all; use the exit code or -Json as the signal +Has no effect when -Json is active. + +.PARAMETER Version +Display the tool version and exit. Cannot be combined with Clean parameters. + +.PARAMETER Format +Output format when -Version is specified. One of: text (default), json. #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Clean')] @@ -94,11 +165,7 @@ param( [string]$RootPath, [Parameter(ParameterSetName = 'Clean')] - [string[]]$ExcludeDirectoryPattern = @( - '.git', - '.vs', - '.claude' - ), + [string[]]$ExcludeDirectoryPattern = @(), [Parameter(ParameterSetName = 'Clean')] [string[]]$IncludeFilePattern = @(), @@ -117,6 +184,15 @@ param( [Parameter(ParameterSetName = 'Clean')] [switch]$Check, + # Show the effective merged configuration and exit. No scan or cleanup is performed. + [Parameter(ParameterSetName = 'Clean')] + [switch]$ShowConfig, + + # Path to an explicit JSON config file. Loaded at highest config priority (above + # project and local files, below command-line parameters). + [Parameter(ParameterSetName = 'Clean')] + [string]$ConfigFile, + # Controls how much output is produced during a clean or check run. # detailed - header, per-item lines, and summary (default) # summary - header and summary only; per-item lines are suppressed @@ -129,9 +205,10 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$script:ToolVersion = '0.9.0' +$script:ToolVersion = '0.10.0' -$script:OutputLevel = $OutputLevel +$script:OutputLevel = $OutputLevel +$script:BuiltInExcludeDirs = @('.git', '.vs', '.claude') if ($Version) { if ($Format -eq 'json') { @@ -194,6 +271,37 @@ function Write-SummarySection { } } +# --------------------------------------------------------------------------- +# Size helpers +# --------------------------------------------------------------------------- + +function Format-Duration { + param([long]$Milliseconds) + if ($Milliseconds -lt 1000) { return "$Milliseconds ms" } + return ('{0:N3} s' -f ($Milliseconds / 1000)) +} + +function Format-ByteSize { + param([long]$Bytes) + if ($Bytes -ge 1GB) { return ('{0:N1} GB' -f ($Bytes / 1GB)) } + if ($Bytes -ge 1MB) { return ('{0:N1} MB' -f ($Bytes / 1MB)) } + if ($Bytes -ge 1KB) { return ('{0:N1} KB' -f ($Bytes / 1KB)) } + return "$Bytes B" +} + +function Get-TreeSize { + param( + [Parameter(Mandatory)] + [string]$Path + ) + if (-not (Test-Path -LiteralPath $Path)) { return [long]0 } + $total = [long]0 + foreach ($f in (Get-ChildItem -Path $Path -Recurse -File -Force -ErrorAction SilentlyContinue)) { + $total += $f.Length + } + return $total +} + # --------------------------------------------------------------------------- # Trash / recycle helpers # --------------------------------------------------------------------------- @@ -407,6 +515,212 @@ function Test-PathUnderExcludedDirectory { return $false } +# --------------------------------------------------------------------------- +# Configuration file helpers +# --------------------------------------------------------------------------- + +function Get-ConfigValue { + param( + [object]$Config, + [Parameter(Mandatory)] + [string]$Key, + $Default = $null + ) + if ($null -eq $Config) { return $Default } + $prop = $Config.PSObject.Properties[$Key] + if ($null -eq $prop) { return $Default } + return $prop.Value +} + +function Read-ConfigFile { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return $null + } + + try { + $content = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 + return $content | ConvertFrom-Json + } + catch { + Write-Warning "[config] Failed to parse '$Path': $($_.Exception.Message)" + return $null + } +} + +function Merge-CleanConfig { + param( + [object[]]$Configs = @() + ) + + $resultLevel = $null + $resultOutputLevel = $null + $resultRecycleBin = $null + $resultSearchParent = $null + + $seenInclude = New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) + $seenExclude = New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) + $resultInclude = New-Object 'System.Collections.Generic.List[string]' + $resultExclude = New-Object 'System.Collections.Generic.List[string]' + + foreach ($config in $Configs) { + if ($null -eq $config) { continue } + + $val = Get-ConfigValue -Config $config -Key 'level' + if ($null -ne $val) { $resultLevel = $val } + + $val = Get-ConfigValue -Config $config -Key 'outputLevel' + if ($null -ne $val) { $resultOutputLevel = $val } + + $val = Get-ConfigValue -Config $config -Key 'recycleBin' + if ($null -ne $val) { $resultRecycleBin = $val } + + $val = Get-ConfigValue -Config $config -Key 'searchParentFolders' + if ($null -ne $val) { $resultSearchParent = $val } + + foreach ($item in @(Get-ConfigValue -Config $config -Key 'includeFilePattern' -Default @())) { + if (-not [string]::IsNullOrEmpty($item) -and $seenInclude.Add($item)) { + $resultInclude.Add($item) + } + } + + foreach ($item in @(Get-ConfigValue -Config $config -Key 'excludeDirectoryPattern' -Default @())) { + if (-not [string]::IsNullOrEmpty($item) -and $seenExclude.Add($item)) { + $resultExclude.Add($item) + } + } + } + + return [PSCustomObject]@{ + level = $resultLevel + outputLevel = $resultOutputLevel + recycleBin = $resultRecycleBin + searchParentFolders = $resultSearchParent + includeFilePattern = @($resultInclude) + excludeDirectoryPattern = @($resultExclude) + } +} + +function Resolve-EffectiveConfig { + param( + [Parameter(Mandatory)] + [string]$RootPath, + + [string]$ConfigFile = '' + ) + + $log = New-Object 'System.Collections.Generic.List[string]' + $anyConfigFound = $false + + # Allow test suites to redirect the home directory lookup. + # Guard against $HOME being null/empty (e.g. minimal CI environments). + $homeDir = if (-not [string]::IsNullOrEmpty($env:DELPHI_CLEAN_HOME_OVERRIDE)) { + $env:DELPHI_CLEAN_HOME_OVERRIDE + } elseif (-not [string]::IsNullOrEmpty($HOME)) { + [string]$HOME + } else { + '' + } + + $projectConfigPath = Join-Path $RootPath 'delphi-clean.json' + $localConfigPath = Join-Path $RootPath 'delphi-clean.local.json' + + $homeConfig = $null + if (-not [string]::IsNullOrEmpty($homeDir)) { + $homeConfigPath = Join-Path $homeDir 'delphi-clean.json' + $homeConfig = Read-ConfigFile -Path $homeConfigPath + Write-Verbose ("[config] user-level: {0}" -f $(if ($null -ne $homeConfig) { $homeConfigPath } else { "$homeConfigPath (not found)" })) + if ($null -ne $homeConfig) { $anyConfigFound = $true; $log.Add("[config] user-level: $homeConfigPath") } + } else { + Write-Verbose '[config] user-level: skipped ($HOME not set)' + } + + $projectConfig = Read-ConfigFile -Path $projectConfigPath + $localConfig = Read-ConfigFile -Path $localConfigPath + + # Write-Verbose always shows all paths; log only tracks found files + Write-Verbose ("[config] project-level: {0}" -f $(if ($null -ne $projectConfig) { $projectConfigPath } else { "$projectConfigPath (not found)" })) + Write-Verbose ("[config] local override: {0}" -f $(if ($null -ne $localConfig) { $localConfigPath } else { "$localConfigPath (not found)" })) + + if ($null -ne $projectConfig) { $anyConfigFound = $true; $log.Add("[config] project-level: $projectConfigPath") } + if ($null -ne $localConfig) { $anyConfigFound = $true; $log.Add("[config] local override: $localConfigPath") } + + # Traversal is triggered only by the project-level or local config. + # searchParentFolders in the $HOME config is intentionally ignored. + $traversalRequested = ((Get-ConfigValue -Config $projectConfig -Key 'searchParentFolders') -eq $true) -or + ((Get-ConfigValue -Config $localConfig -Key 'searchParentFolders') -eq $true) + + $traversedConfigs = @() + + if ($traversalRequested) { + $current = Split-Path -Parent $RootPath + + while (-not [string]::IsNullOrEmpty($current)) { + $parentConfigPath = Join-Path $current 'delphi-clean.json' + $parentConfig = Read-ConfigFile -Path $parentConfigPath + + if ($null -ne $parentConfig) { + # Prepend so farthest ancestor ends up first (lowest priority among traversed) + $traversedConfigs = @($parentConfig) + $traversedConfigs + $anyConfigFound = $true + Write-Verbose "[config] traversed: $parentConfigPath" + $log.Add("[config] traversed: $parentConfigPath") + + if ((Get-ConfigValue -Config $parentConfig -Key 'searchParentFolders') -eq $false) { + Write-Verbose '[config] (stop marker -- traversal ends here)' + $log.Add('[config] (stop marker -- traversal ends here)') + break + } + } + + $parent = Split-Path -Parent $current + if ($parent -eq $current) { break } # filesystem root reached + $current = $parent + } + } + + # Explicit config file (highest config priority, below CLI) + $explicitConfig = $null + if (-not [string]::IsNullOrEmpty($ConfigFile)) { + if (-not (Test-Path -LiteralPath $ConfigFile)) { + Write-Warning "[config] Explicit config file not found: $ConfigFile" + } + else { + $explicitConfig = Read-ConfigFile -Path $ConfigFile + if ($null -ne $explicitConfig) { + $anyConfigFound = $true + Write-Verbose "[config] explicit file: $ConfigFile" + $log.Add("[config] explicit file: $ConfigFile") + } + } + } + + # Merge: lowest priority first + $allConfigs = @($homeConfig) + $traversedConfigs + @($projectConfig) + @($localConfig) + @($explicitConfig) + $merged = Merge-CleanConfig -Configs $allConfigs + + $finalLogLines = @( + '[config] final merged values:' + ("[config] level = {0}" -f $(if ($null -ne $merged.level) { $merged.level } else { '(not set)' })) + ("[config] outputLevel = {0}" -f $(if ($null -ne $merged.outputLevel) { $merged.outputLevel } else { '(not set)' })) + ("[config] recycleBin = {0}" -f $(if ($null -ne $merged.recycleBin) { "$($merged.recycleBin)" } else { '(not set)' })) + ("[config] searchParentFolders = {0}" -f $(if ($null -ne $merged.searchParentFolders) { "$($merged.searchParentFolders)" } else { '(not set)' })) + ("[config] includeFilePattern = [{0}]" -f ($merged.includeFilePattern -join ', ')) + ("[config] excludeDirectoryPattern = [{0}]" -f ($merged.excludeDirectoryPattern -join ', ')) + ) + foreach ($line in $finalLogLines) { Write-Verbose $line } + + return [PSCustomObject]@{ + Merged = $merged + Log = $log.ToArray() + ConfigsFound = $anyConfigFound + } +} + # --------------------------------------------------------------------------- # Level definitions # --------------------------------------------------------------------------- @@ -443,6 +757,9 @@ function Get-LevelDefinition { '*.dcp', '*.bpi', '*.so', + '*.o', + '*.a', + '*.dylib', '*.exe', '*.hpp', '*.dres', @@ -466,7 +783,9 @@ function Get-LevelDefinition { 'Android', 'Android64', 'iOSDevice64', + 'iOSSimulatorArm64', 'Linux64', + 'LinuxARM64', 'TMSWeb' ) @@ -482,6 +801,7 @@ function Get-LevelDefinition { '*.fbl8', '*.fbpbrk', '*.fb8lck', + '*.mab', 'TestInsightSettings.ini' ) @@ -535,7 +855,15 @@ function Get-FilesToDelete { Write-Verbose 'Scanning for matching files.' + $examined = 0 $allFiles = Get-ChildItem -Path $Root -Recurse -File -Force -ErrorAction SilentlyContinue | + ForEach-Object { + $examined++ + if (-not $Json -and $examined % 500 -eq 0) { + Write-Progress -Activity 'delphi-clean' -Status "Scanning: $examined files examined..." + } + $_ + } | Where-Object { -not (Test-PathUnderExcludedDirectory -FullName $_.FullName -Root $Root -ExcludedDirPatterns $ExcludedDirPatterns) } @@ -595,13 +923,16 @@ function ConvertTo-DeletionRecord { [string]$Path, [Parameter(Mandatory)] - [bool]$Deleted + [bool]$Deleted, + + [long]$Size = 0 ) [PSCustomObject]@{ Type = $Type Path = $Path Deleted = $Deleted + Size = $Size } } @@ -645,7 +976,7 @@ function Remove-FileList { Write-Detail "$verb file: $($file.FullName)" if ($ReturnRecords) { - $result.Records.Add((ConvertTo-DeletionRecord -Type File -Path $file.FullName -Deleted $true)) + $result.Records.Add((ConvertTo-DeletionRecord -Type File -Path $file.FullName -Deleted $true -Size $file.Length)) } } catch { @@ -653,14 +984,14 @@ function Remove-FileList { Write-Warning "Failed to $($action.ToLower()): $($file.FullName) - $($_.Exception.Message)" if ($ReturnRecords) { - $result.Records.Add((ConvertTo-DeletionRecord -Type File -Path $file.FullName -Deleted $false)) + $result.Records.Add((ConvertTo-DeletionRecord -Type File -Path $file.FullName -Deleted $false -Size $file.Length)) } } } elseif ($WhatIfPreference) { Write-Detail "Would $($action.ToLower()): $($file.FullName)" if ($ReturnRecords) { - $result.Records.Add((ConvertTo-DeletionRecord -Type File -Path $file.FullName -Deleted $false)) + $result.Records.Add((ConvertTo-DeletionRecord -Type File -Path $file.FullName -Deleted $false -Size $file.Length)) } } } @@ -696,6 +1027,9 @@ function Remove-DirectoryList { Write-Verbose "Evaluating directory: $($dir.FullName)" + # Compute size before any deletion so it is available for the record regardless of outcome + $dirSize = Get-TreeSize -Path $dir.FullName + if ($PSCmdlet.ShouldProcess($dir.FullName, $action)) { try { if ($RecycleBin) { @@ -717,7 +1051,7 @@ function Remove-DirectoryList { Write-Detail "$verb directory: $($dir.FullName)" if ($ReturnRecords) { - $result.Records.Add((ConvertTo-DeletionRecord -Type Directory -Path $dir.FullName -Deleted $true)) + $result.Records.Add((ConvertTo-DeletionRecord -Type Directory -Path $dir.FullName -Deleted $true -Size $dirSize)) } } catch { @@ -725,14 +1059,14 @@ function Remove-DirectoryList { Write-Warning "Failed to $($action.ToLower()): $($dir.FullName) - $($_.Exception.Message)" if ($ReturnRecords) { - $result.Records.Add((ConvertTo-DeletionRecord -Type Directory -Path $dir.FullName -Deleted $false)) + $result.Records.Add((ConvertTo-DeletionRecord -Type Directory -Path $dir.FullName -Deleted $false -Size $dirSize)) } } } elseif ($WhatIfPreference) { Write-Detail "Would $($action.ToLower()): $($dir.FullName)" if ($ReturnRecords) { - $result.Records.Add((ConvertTo-DeletionRecord -Type Directory -Path $dir.FullName -Deleted $false)) + $result.Records.Add((ConvertTo-DeletionRecord -Type Directory -Path $dir.FullName -Deleted $false -Size $dirSize)) } } } @@ -753,9 +1087,106 @@ try { exit 3 } + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $cleanRoot = Resolve-CleanRoot -InputRoot $RootPath Test-SafeCleanRoot -Root $cleanRoot + # --- Load and apply configuration files --- + $configResult = Resolve-EffectiveConfig -RootPath $cleanRoot -ConfigFile $ConfigFile + $effectiveConfig = $configResult.Merged + + # Scalars: config value applies only when the CLI did not explicitly supply the parameter + if ('Level' -notin $PSBoundParameters.Keys) { + $cfgVal = Get-ConfigValue -Config $effectiveConfig -Key 'level' + if ($null -ne $cfgVal) { $Level = $cfgVal } + } + if ('OutputLevel' -notin $PSBoundParameters.Keys) { + $cfgVal = Get-ConfigValue -Config $effectiveConfig -Key 'outputLevel' + if ($null -ne $cfgVal) { + $OutputLevel = $cfgVal + $script:OutputLevel = $OutputLevel + } + } + if ('RecycleBin' -notin $PSBoundParameters.Keys) { + if ((Get-ConfigValue -Config $effectiveConfig -Key 'recycleBin') -eq $true) { + $RecycleBin = [System.Management.Automation.SwitchParameter]::new($true) + } + } + + # Arrays: built-ins + config + CLI, deduplicated (first-seen position preserved) + $seenExcludes = New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) + $mergedExcludes = New-Object 'System.Collections.Generic.List[string]' + foreach ($d in $script:BuiltInExcludeDirs) { + if ($seenExcludes.Add($d)) { $mergedExcludes.Add($d) } + } + foreach ($d in @($effectiveConfig.excludeDirectoryPattern)) { + if (-not [string]::IsNullOrEmpty($d) -and $seenExcludes.Add($d)) { $mergedExcludes.Add($d) } + } + foreach ($d in @($ExcludeDirectoryPattern)) { + if (-not [string]::IsNullOrEmpty($d) -and $seenExcludes.Add($d)) { $mergedExcludes.Add($d) } + } + $ExcludeDirectoryPattern = $mergedExcludes.ToArray() + + $seenIncludes = New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) + $mergedIncludes = New-Object 'System.Collections.Generic.List[string]' + foreach ($p in @($effectiveConfig.includeFilePattern)) { + if (-not [string]::IsNullOrEmpty($p) -and $seenIncludes.Add($p)) { $mergedIncludes.Add($p) } + } + foreach ($p in @($IncludeFilePattern)) { + if (-not [string]::IsNullOrEmpty($p) -and $seenIncludes.Add($p)) { $mergedIncludes.Add($p) } + } + $IncludeFilePattern = $mergedIncludes.ToArray() + + # Show config log now that OutputLevel is finalised (it may itself have come from config). + # Skipped when -Verbose is active because Write-Verbose already emitted the same lines. + if ($configResult.ConfigsFound -and $VerbosePreference -ne 'Continue') { + foreach ($line in $configResult.Log) { Write-Detail $line } + } + + # -ShowConfig: display effective configuration and exit without scanning. + if ($ShowConfig) { + if ($Json) { + [PSCustomObject]@{ + Root = $cleanRoot + ConfigSources = @($configResult.Log) + Level = $Level + OutputLevel = $script:OutputLevel + RecycleBin = $RecycleBin.IsPresent + IncludeFilePattern = @($IncludeFilePattern) + ExcludeDirectoryPattern = @($ExcludeDirectoryPattern) + } | ConvertTo-Json -Depth 5 + } + else { + $nl = [System.Environment]::NewLine + Write-Information "$nl$('=' * 70)" -InformationAction Continue + Write-Information 'Delphi Clean -- Effective Configuration' -InformationAction Continue + Write-Information ('=' * 70) -InformationAction Continue + Write-Information "Root: $cleanRoot" -InformationAction Continue + Write-Information '' -InformationAction Continue + if ($configResult.Log.Count -gt 0) { + Write-Information 'Config sources:' -InformationAction Continue + foreach ($line in $configResult.Log) { + Write-Information " $line" -InformationAction Continue + } + Write-Information '' -InformationAction Continue + } + else { + Write-Information 'No config files found (using defaults and CLI parameters).' -InformationAction Continue + Write-Information '' -InformationAction Continue + } + $includeDisplay = if ($IncludeFilePattern.Count -gt 0) { $IncludeFilePattern -join ', ' } else { '(none)' } + Write-Information 'Effective values:' -InformationAction Continue + Write-Information (' Level : {0}' -f $Level) -InformationAction Continue + Write-Information (' OutputLevel : {0}' -f $script:OutputLevel) -InformationAction Continue + Write-Information (' RecycleBin : {0}' -f $RecycleBin.IsPresent) -InformationAction Continue + Write-Information (' IncludeFilePattern : {0}' -f $includeDisplay) -InformationAction Continue + Write-Information (' ExcludeDirectoryPattern : {0}' -f ($ExcludeDirectoryPattern -join ', ')) -InformationAction Continue + } + Write-Verbose 'Exit code = 0' + exit 0 + } + $definition = Get-LevelDefinition -Name $Level $mode = if ($Check) { 'Check (no changes)' } elseif ($WhatIfPreference) { 'WhatIf (no changes)' } else { 'Execute' } $disposition = if ($RecycleBin) { 'Recycle Bin' } else { 'Permanent' } @@ -775,8 +1206,11 @@ try { Write-Detail ('Disposition : {0}' -f $disposition) } + if (-not $Json) { Write-Progress -Activity 'delphi-clean' -Status 'Scanning for files...' } $filesToDelete = @(Get-FilesToDelete -Root $cleanRoot -Patterns $allFilePatterns -ExcludedDirPatterns $ExcludeDirectoryPattern) + if (-not $Json) { Write-Progress -Activity 'delphi-clean' -Status 'Scanning for directories...' } $dirsToDelete = @(Get-DirectoriesToDelete -Root $cleanRoot -DirectoryNames $definition.DirectoryNames -ExcludedDirPatterns $ExcludeDirectoryPattern) + if (-not $Json) { Write-Progress -Activity 'delphi-clean' -Completed } if (-not $Check) { Write-Detail '' @@ -803,6 +1237,8 @@ try { DirectoriesDeleted = 0 FilesFailed = 0 DirectoriesFailed = 0 + BytesFreed = 0 + DurationMs = $stopwatch.ElapsedMilliseconds Items = @() } | ConvertTo-Json -Depth 5 } @@ -815,6 +1251,16 @@ try { exit 0 } + # Compute total bytes and per-directory sizes (used by both -Check items and normal path) + $totalBytes = [long]0 + $dirSizeMap = @{} + foreach ($f in $filesToDelete) { $totalBytes += $f.Length } + foreach ($dir in $dirsToDelete) { + $s = Get-TreeSize -Path $dir.FullName + $dirSizeMap[$dir.FullName] = $s + $totalBytes += $s + } + # -Check: report what was found and exit without deleting. if ($Check) { if ($Json) { @@ -833,9 +1279,11 @@ try { DirectoriesDeleted = 0 FilesFailed = 0 DirectoriesFailed = 0 + BytesFreed = $totalBytes + DurationMs = $stopwatch.ElapsedMilliseconds Items = @( - @($filesToDelete | ForEach-Object { ConvertTo-DeletionRecord -Type File -Path $_.FullName -Deleted $false }) + - @($dirsToDelete | ForEach-Object { ConvertTo-DeletionRecord -Type Directory -Path $_.FullName -Deleted $false }) + @($filesToDelete | ForEach-Object { ConvertTo-DeletionRecord -Type File -Path $_.FullName -Deleted $false -Size $_.Length }) + + @($dirsToDelete | ForEach-Object { ConvertTo-DeletionRecord -Type Directory -Path $_.FullName -Deleted $false -Size $dirSizeMap[$_.FullName] }) ) } | ConvertTo-Json -Depth 5 } @@ -851,6 +1299,8 @@ try { Write-SummarySection 'Check summary' Write-Summary ('Files found : {0}' -f $filesToDelete.Count) Write-Summary ('Directories found: {0}' -f $dirsToDelete.Count) + Write-Summary ('Space to free : {0}' -f (Format-ByteSize $totalBytes)) + Write-Summary ('Duration : {0}' -f (Format-Duration $stopwatch.ElapsedMilliseconds)) } Write-Verbose 'Exit code = 1' @@ -859,8 +1309,11 @@ try { # Normal clean path Write-Section 'Cleaning' + if (-not $Json) { Write-Progress -Activity 'delphi-clean' -Status "Removing $($filesToDelete.Count) files..." } $fileRemovalResult = Remove-FileList -Files $filesToDelete -ReturnRecords:$returnRecords -RecycleBin:$RecycleBin + if (-not $Json) { Write-Progress -Activity 'delphi-clean' -Status "Removing $($dirsToDelete.Count) directories..." } $dirRemovalResult = Remove-DirectoryList -Directories $dirsToDelete -ReturnRecords:$returnRecords -RecycleBin:$RecycleBin + if (-not $Json) { Write-Progress -Activity 'delphi-clean' -Completed } $allRecords = New-Object System.Collections.Generic.List[object] $allRecords.AddRange([object[]]$fileRemovalResult.Records) @@ -884,6 +1337,8 @@ try { DirectoriesDeleted = $dirRemovalResult.DeletedCount FilesFailed = $fileRemovalResult.FailedCount DirectoriesFailed = $dirRemovalResult.FailedCount + BytesFreed = $totalBytes + DurationMs = $stopwatch.ElapsedMilliseconds Items = $allRecords } | ConvertTo-Json -Depth 5 } @@ -892,12 +1347,16 @@ try { Write-SummarySection 'Summary' if ($WhatIfPreference) { - Write-Summary ('Files would be {0} : {1}' -f $removedLabel, $filesToDelete.Count) - Write-Summary ('Directories would be {0}: {1}' -f $removedLabel, $dirsToDelete.Count) + Write-Summary ('{0}: {1}' -f ('Files would be {0}' -f $removedLabel).PadRight(29), $filesToDelete.Count) + Write-Summary ('{0}: {1}' -f ('Directories would be {0}' -f $removedLabel).PadRight(29), $dirsToDelete.Count) + Write-Summary ('{0}: {1}' -f 'Space would free'.PadRight(29), (Format-ByteSize $totalBytes)) + Write-Summary ('{0}: {1}' -f 'Duration'.PadRight(29), (Format-Duration $stopwatch.ElapsedMilliseconds)) } else { - Write-Summary ('Files {0} : {1}' -f $removedLabel, $fileRemovalResult.DeletedCount) - Write-Summary ('Directories {0} : {1}' -f $removedLabel, $dirRemovalResult.DeletedCount) + Write-Summary ('{0}: {1}' -f ('Files {0}' -f $removedLabel).PadRight(20), $fileRemovalResult.DeletedCount) + Write-Summary ('{0}: {1}' -f ('Directories {0}' -f $removedLabel).PadRight(20), $dirRemovalResult.DeletedCount) + Write-Summary ('{0}: {1}' -f 'Space freed'.PadRight(20), (Format-ByteSize $totalBytes)) + Write-Summary ('{0}: {1}' -f 'Duration'.PadRight(20), (Format-Duration $stopwatch.ElapsedMilliseconds)) if ($totalFailed -gt 0) { Write-Warning ('Items failed to {0}: {1}' -f $removedLabel, $totalFailed) diff --git a/tests/pwsh/delphi-clean.Config.Tests.ps1 b/tests/pwsh/delphi-clean.Config.Tests.ps1 new file mode 100644 index 0000000..47801dd --- /dev/null +++ b/tests/pwsh/delphi-clean.Config.Tests.ps1 @@ -0,0 +1,437 @@ +# tests/pwsh/delphi-clean.Config.Tests.ps1 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Describe 'delphi-clean.ps1 configuration files' { + + BeforeAll { + $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../')).Path + $script:ToolPath = Join-Path $script:RepoRoot 'source' 'delphi-clean.ps1' + $script:FixturesDir = Join-Path $PSScriptRoot 'fixtures' + + if (-not (Test-Path -LiteralPath $script:ToolPath)) { + throw "Tool script not found: $script:ToolPath" + } + } + + BeforeEach { + $script:TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("delphi-clean-cfg-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $script:TempRoot | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:TempRoot 'src') | Out-Null + Set-Content -LiteralPath (Join-Path $script:TempRoot 'src\Unit1.dcu') -Value 'dummy' # basic artifact + Set-Content -LiteralPath (Join-Path $script:TempRoot 'src\App.exe') -Value 'dummy' # standard artifact + Set-Content -LiteralPath (Join-Path $script:TempRoot 'src\Icon.res') -Value 'dummy' # custom-pattern artifact + Set-Content -LiteralPath (Join-Path $script:TempRoot 'src\App.~pas') -Value 'dummy' # deep artifact + + $script:FakeHome = Join-Path ([System.IO.Path]::GetTempPath()) ("delphi-clean-home-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $script:FakeHome | Out-Null + $env:DELPHI_CLEAN_HOME_OVERRIDE = $script:FakeHome + } + + AfterEach { + $env:DELPHI_CLEAN_HOME_OVERRIDE = $null + foreach ($dir in @($script:TempRoot, $script:FakeHome)) { + if ($dir -and (Test-Path -LiteralPath $dir)) { + Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue + } + } + } + + # ------------------------------------------------------------------------- + Context 'no config files present' { + + It 'uses basic level by default' { + & $script:ToolPath -RootPath $script:TempRoot | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\Unit1.dcu') | Should -BeFalse # basic cleans .dcu + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.exe') | Should -BeTrue # standard only + } + + It 'built-in excluded directories are protected with no config files' { + New-Item -ItemType Directory -Path (Join-Path $script:TempRoot '.git') | Out-Null + Set-Content -LiteralPath (Join-Path $script:TempRoot '.git\keep.dcu') -Value 'dummy' + + & $script:ToolPath -RootPath $script:TempRoot | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot '.git\keep.dcu') | Should -BeTrue + } + } + + # ------------------------------------------------------------------------- + Context 'project-level config (delphi-clean.json in RootPath)' { + + It 'applies level from project config' { + @{ level = 'standard' } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:TempRoot | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.exe') | Should -BeFalse + } + + It 'CLI -Level overrides config level' { + @{ level = 'standard' } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:TempRoot -Level basic | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\Unit1.dcu') | Should -BeFalse + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.exe') | Should -BeTrue + } + + It 'applies includeFilePattern from project config' { + @{ includeFilePattern = @('*.res') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:TempRoot -Level basic | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\Icon.res') | Should -BeFalse + } + + It 'CLI -IncludeFilePattern appends to config patterns' { + @{ includeFilePattern = @('*.res') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + Set-Content -LiteralPath (Join-Path $script:TempRoot 'src\App.mab') -Value 'dummy' + + & $script:ToolPath -RootPath $script:TempRoot -Level basic -IncludeFilePattern '*.mab' | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\Icon.res') | Should -BeFalse + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.mab') | Should -BeFalse + } + + It 'applies excludeDirectoryPattern from project config' { + @{ excludeDirectoryPattern = @('vendor*') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + New-Item -ItemType Directory -Path (Join-Path $script:TempRoot 'vendor') | Out-Null + Set-Content -LiteralPath (Join-Path $script:TempRoot 'vendor\Lib.dcu') -Value 'dummy' + + & $script:ToolPath -RootPath $script:TempRoot -Level basic | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'vendor\Lib.dcu') | Should -BeTrue + } + + It 'config excludeDirectoryPattern does not remove built-in excludes' { + @{ excludeDirectoryPattern = @('vendor*') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + New-Item -ItemType Directory -Path (Join-Path $script:TempRoot '.git') | Out-Null + Set-Content -LiteralPath (Join-Path $script:TempRoot '.git\keep.dcu') -Value 'dummy' + + & $script:ToolPath -RootPath $script:TempRoot -Level basic | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot '.git\keep.dcu') | Should -BeTrue + } + } + + # ------------------------------------------------------------------------- + Context 'local override config (delphi-clean.local.json in RootPath)' { + + It 'local override scalar takes priority over project config scalar' { + @{ level = 'standard' } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + @{ level = 'basic' } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.local.json') + + & $script:ToolPath -RootPath $script:TempRoot | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.exe') | Should -BeTrue + } + + It 'local override array appends to project config array' { + @{ includeFilePattern = @('*.res') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + @{ includeFilePattern = @('*.~pas') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.local.json') + + & $script:ToolPath -RootPath $script:TempRoot -Level basic | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\Icon.res') | Should -BeFalse + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.~pas') | Should -BeFalse + } + } + + # ------------------------------------------------------------------------- + Context 'user-level config ($HOME/delphi-clean.json)' { + + It 'user-level config applies when no project config overrides' { + @{ level = 'standard' } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:FakeHome 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:TempRoot | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.exe') | Should -BeFalse + } + + It 'project-level scalar overrides user-level scalar' { + @{ level = 'standard' } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:FakeHome 'delphi-clean.json') + @{ level = 'basic' } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:TempRoot | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.exe') | Should -BeTrue + } + + It 'user-level includeFilePattern is honored' { + @{ includeFilePattern = @('*.res') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:FakeHome 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:TempRoot -Level basic | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\Icon.res') | Should -BeFalse + } + + It 'user-level and project-level includeFilePattern arrays both contribute' { + @{ includeFilePattern = @('*.res') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:FakeHome 'delphi-clean.json') + @{ includeFilePattern = @('*.~pas') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:TempRoot -Level basic | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\Icon.res') | Should -BeFalse + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.~pas') | Should -BeFalse + } + } + + # ------------------------------------------------------------------------- + Context 'array deduplication' { + + It 'duplicate pattern across user and project config appears only once (both files still cleaned)' { + @{ includeFilePattern = @('*.res') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:FakeHome 'delphi-clean.json') + @{ includeFilePattern = @('*.res', '*.~pas') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:TempRoot -Level basic | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\Icon.res') | Should -BeFalse + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.~pas') | Should -BeFalse + } + + It 'duplicate pattern across config and CLI appears only once (file still cleaned)' { + @{ includeFilePattern = @('*.res') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:TempRoot -Level basic -IncludeFilePattern '*.res' | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\Icon.res') | Should -BeFalse + } + } + + # ------------------------------------------------------------------------- + Context 'upward traversal (searchParentFolders)' { + + BeforeEach { + # Three-level structure inside TempRoot: + # TempRoot/outer/ <- has level=deep config (should be blocked by stop marker) + # inner/ <- stop marker: level=standard, searchParentFolders=false + # billing/ <- requests traversal, no level of its own + # src/ + # payments/ <- requests traversal, overrides level to basic, adds *.res + # src/ + + $script:Outer = Join-Path $script:TempRoot 'outer' + $script:Inner = Join-Path $script:Outer 'inner' + $script:Billing = Join-Path $script:Inner 'billing' + $script:Payments = Join-Path $script:Inner 'payments' + + foreach ($sub in @($script:Billing, $script:Payments)) { + New-Item -ItemType Directory -Path (Join-Path $sub 'src') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $sub 'src\Unit1.dcu') -Value 'dummy' + Set-Content -LiteralPath (Join-Path $sub 'src\App.exe') -Value 'dummy' + } + Set-Content -LiteralPath (Join-Path $script:Payments 'src\Icon.res') -Value 'dummy' + Set-Content -LiteralPath (Join-Path $script:Billing 'src\App.~pas') -Value 'dummy' + + # outer: level=deep (must NOT bleed through the stop marker) + @{ level = 'deep' } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:Outer 'delphi-clean.json') + + # inner: stop marker with level=standard + @{ level = 'standard'; searchParentFolders = $false } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:Inner 'delphi-clean.json') + } + + It 'billing inherits level=standard from inner via traversal' { + @{ searchParentFolders = $true } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:Billing 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:Billing | Out-Null + + Test-Path -LiteralPath (Join-Path $script:Billing 'src\Unit1.dcu') | Should -BeFalse # standard cleans .dcu + Test-Path -LiteralPath (Join-Path $script:Billing 'src\App.exe') | Should -BeFalse # standard cleans .exe + } + + It 'billing without traversal does not inherit inner level and stays at basic default' { + @{ searchParentFolders = $false } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:Billing 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:Billing | Out-Null + + Test-Path -LiteralPath (Join-Path $script:Billing 'src\Unit1.dcu') | Should -BeFalse # basic cleans .dcu + Test-Path -LiteralPath (Join-Path $script:Billing 'src\App.exe') | Should -BeTrue # basic does not clean .exe + } + + It 'payments overrides scalar from traversed parent and adds its own include pattern' { + # payments has level=basic (overrides inner's standard) and adds *.res + @{ level = 'basic'; includeFilePattern = @('*.res'); searchParentFolders = $true } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:Payments 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:Payments | Out-Null + + Test-Path -LiteralPath (Join-Path $script:Payments 'src\Unit1.dcu') | Should -BeFalse # basic cleans .dcu + Test-Path -LiteralPath (Join-Path $script:Payments 'src\App.exe') | Should -BeTrue # basic, not standard + Test-Path -LiteralPath (Join-Path $script:Payments 'src\Icon.res') | Should -BeFalse # includeFilePattern + } + + It 'stop marker prevents traversal past inner; outer level=deep is never applied' { + # billing requests traversal; traversal hits inner (stop marker, level=standard) and stops + # outer has level=deep -- if it leaked through, .~pas would be cleaned + @{ searchParentFolders = $true } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:Billing 'delphi-clean.json') + + & $script:ToolPath -RootPath $script:Billing | Out-Null + + # standard (from inner stop marker) cleans .exe + Test-Path -LiteralPath (Join-Path $script:Billing 'src\App.exe') | Should -BeFalse + # deep (from outer) must NOT apply -- .~pas must survive + Test-Path -LiteralPath (Join-Path $script:Billing 'src\App.~pas') | Should -BeTrue + } + + It 'searchParentFolders in HOME config does not trigger traversal' { + # HOME says searchParentFolders=true; this must be ignored + @{ searchParentFolders = $true } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:FakeHome 'delphi-clean.json') + + # payments has no config of its own -> no project/local triggers traversal + & $script:ToolPath -RootPath $script:Payments | Out-Null + + # Without traversal, no inherited level -> basic default -> .exe preserved + Test-Path -LiteralPath (Join-Path $script:Payments 'src\App.exe') | Should -BeTrue + } + } + + # ------------------------------------------------------------------------- + Context '-ShowConfig flag' { + + It '-ShowConfig exits 0 and does not clean files' { + $result = & $script:ToolPath -RootPath $script:TempRoot -ShowConfig + $LASTEXITCODE | Should -Be 0 + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\Unit1.dcu') | Should -BeTrue + } + + It '-ShowConfig output contains effective Level' { + @{ level = 'standard' } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + + # ShowConfig writes to the Information stream (stream 6); redirect to capture it + $output = (& $script:ToolPath -RootPath $script:TempRoot -ShowConfig 6>&1) -join ' ' + + $output | Should -Match 'standard' + } + + It '-ShowConfig -Json returns parseable object with correct fields' { + @{ level = 'standard'; outputLevel = 'summary' } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + + $jsonText = & $script:ToolPath -RootPath $script:TempRoot -ShowConfig -Json + $obj = $jsonText | ConvertFrom-Json + + $obj.Level | Should -Be 'standard' + $obj.OutputLevel | Should -Be 'summary' + $obj.Root | Should -Not -BeNullOrEmpty + $obj.ExcludeDirectoryPattern | Should -Not -BeNullOrEmpty + } + + It '-ShowConfig -Json ConfigSources lists found config files' { + @{ level = 'deep' } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + + $jsonText = & $script:ToolPath -RootPath $script:TempRoot -ShowConfig -Json + $obj = $jsonText | ConvertFrom-Json + + ($obj.ConfigSources | Where-Object { $_ -like '*project-level*' }) | Should -Not -BeNullOrEmpty + } + } + + # ------------------------------------------------------------------------- + Context '-ConfigFile flag' { + + It '-ConfigFile applies level from the explicit file' { + $cfgFile = Join-Path $script:TempRoot 'ci.json' + @{ level = 'standard' } | ConvertTo-Json | Set-Content -LiteralPath $cfgFile + + & $script:ToolPath -RootPath $script:TempRoot -ConfigFile $cfgFile | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.exe') | Should -BeFalse + } + + It '-ConfigFile scalar overrides project-level config' { + @{ level = 'standard' } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + $cfgFile = Join-Path $script:TempRoot 'ci.json' + @{ level = 'basic' } | ConvertTo-Json | Set-Content -LiteralPath $cfgFile + + & $script:ToolPath -RootPath $script:TempRoot -ConfigFile $cfgFile | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\Unit1.dcu') | Should -BeFalse + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.exe') | Should -BeTrue + } + + It 'CLI -Level overrides -ConfigFile level' { + $cfgFile = Join-Path $script:TempRoot 'ci.json' + @{ level = 'standard' } | ConvertTo-Json | Set-Content -LiteralPath $cfgFile + + & $script:ToolPath -RootPath $script:TempRoot -ConfigFile $cfgFile -Level basic | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.exe') | Should -BeTrue + } + + It '-ConfigFile warns when file does not exist' { + $missing = Join-Path $script:TempRoot 'does-not-exist.json' + $warnings = @() + + & $script:ToolPath -RootPath $script:TempRoot -ConfigFile $missing -WarningVariable warnings | Out-Null + + ($warnings -join ' ') | Should -Match 'not found' + } + + It '-ConfigFile array appends to project-level array' { + @{ includeFilePattern = @('*.res') } | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $script:TempRoot 'delphi-clean.json') + $cfgFile = Join-Path $script:TempRoot 'ci.json' + @{ includeFilePattern = @('*.~pas') } | ConvertTo-Json | Set-Content -LiteralPath $cfgFile + + & $script:ToolPath -RootPath $script:TempRoot -Level basic -ConfigFile $cfgFile | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\Icon.res') | Should -BeFalse + Test-Path -LiteralPath (Join-Path $script:TempRoot 'src\App.~pas') | Should -BeFalse + } + } + + # ------------------------------------------------------------------------- + Context 'fixture files' { + + It 'monorepo-root fixture is valid JSON and contains expected keys' { + $fixturePath = Join-Path $script:FixturesDir 'monorepo-root.json' + Test-Path -LiteralPath $fixturePath | Should -BeTrue + $cfg = Get-Content -LiteralPath $fixturePath -Raw | ConvertFrom-Json + $cfg.level | Should -Be 'standard' + $cfg.searchParentFolders | Should -BeFalse + } + + It 'monorepo-billing fixture is valid JSON' { + $fixturePath = Join-Path $script:FixturesDir 'monorepo-billing.json' + { Get-Content -LiteralPath $fixturePath -Raw | ConvertFrom-Json } | Should -Not -Throw + } + + It 'monorepo-payments fixture has level deep and searchParentFolders true' { + $fixturePath = Join-Path $script:FixturesDir 'monorepo-payments.json' + $cfg = Get-Content -LiteralPath $fixturePath -Raw | ConvertFrom-Json + $cfg.level | Should -Be 'deep' + $cfg.searchParentFolders | Should -BeTrue + } + } +} diff --git a/tests/pwsh/delphi-clean.Integration.Tests.ps1 b/tests/pwsh/delphi-clean.Integration.Tests.ps1 index b52522f..6d0b04b 100644 --- a/tests/pwsh/delphi-clean.Integration.Tests.ps1 +++ b/tests/pwsh/delphi-clean.Integration.Tests.ps1 @@ -32,11 +32,19 @@ Describe 'delphi-clean.ps1 integration tests' { Set-Content -LiteralPath (Join-Path $script:TempRoot 'source\Win32\output.txt') -Value 'dummy' Set-Content -LiteralPath (Join-Path $script:TempRoot '.git\keep.dcu') -Value 'dummy' Set-Content -LiteralPath (Join-Path $script:TempRoot '.vs\keep.exe') -Value 'dummy' + + # Redirect home config lookup so a real $HOME/delphi-clean.json does not affect results + $script:FakeHome = Join-Path ([System.IO.Path]::GetTempPath()) ("delphi-clean-home-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $script:FakeHome | Out-Null + $env:DELPHI_CLEAN_HOME_OVERRIDE = $script:FakeHome } AfterEach { - if ($script:TempRoot -and (Test-Path -LiteralPath $script:TempRoot)) { - Remove-Item -LiteralPath $script:TempRoot -Recurse -Force -ErrorAction SilentlyContinue + $env:DELPHI_CLEAN_HOME_OVERRIDE = $null + foreach ($dir in @($script:TempRoot, $script:FakeHome)) { + if ($dir -and (Test-Path -LiteralPath $dir)) { + Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue + } } } @@ -58,6 +66,71 @@ Describe 'delphi-clean.ps1 integration tests' { @($result.Items).Count | Should -BeGreaterThan 0 } + It 'JSON output includes DurationMs as a non-negative integer' { + $jsonText = & $script:ToolPath -RootPath $script:TempRoot -Level standard -Json -WhatIf + $result = $jsonText | ConvertFrom-Json + + $result.PSObject.Properties.Name | Should -Contain 'DurationMs' + $result.DurationMs | Should -BeGreaterOrEqual 0 + } + + It 'JSON output includes DurationMs when nothing to clean' { + # Use a root with no artifacts so the nothing-to-clean path is taken + $emptyRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("delphi-clean-empty-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $emptyRoot | Out-Null + try { + $jsonText = & $script:ToolPath -RootPath $emptyRoot -Level standard -Json + $result = $jsonText | ConvertFrom-Json + + $result.PSObject.Properties.Name | Should -Contain 'DurationMs' + $result.DurationMs | Should -BeGreaterOrEqual 0 + } + finally { + Remove-Item -LiteralPath $emptyRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'JSON output includes DurationMs in -Check mode' { + $jsonText = & $script:ToolPath -RootPath $script:TempRoot -Level standard -Json -Check + $result = $jsonText | ConvertFrom-Json + + $result.PSObject.Properties.Name | Should -Contain 'DurationMs' + $result.DurationMs | Should -BeGreaterOrEqual 0 + } + + It 'JSON Items have a Size property on file records' { + $jsonText = & $script:ToolPath -RootPath $script:TempRoot -Level standard -Json -WhatIf + $result = $jsonText | ConvertFrom-Json + + $fileItem = @($result.Items) | Where-Object { $_.Type -eq 'File' } | Select-Object -First 1 + $fileItem | Should -Not -BeNullOrEmpty + $fileItem.PSObject.Properties.Name | Should -Contain 'Size' + $fileItem.Size | Should -BeGreaterOrEqual 0 + } + + It 'JSON Items have a Size property on directory records' { + $jsonText = & $script:ToolPath -RootPath $script:TempRoot -Level standard -Json -WhatIf + $result = $jsonText | ConvertFrom-Json + + $dirItem = @($result.Items) | Where-Object { $_.Type -eq 'Directory' } | Select-Object -First 1 + $dirItem | Should -Not -BeNullOrEmpty + $dirItem.PSObject.Properties.Name | Should -Contain 'Size' + $dirItem.Size | Should -BeGreaterOrEqual 0 + } + + It 'JSON Items file Size matches the actual file size on disk' { + # Write a file with known byte content so we can verify the reported size + $knownContent = 'A' * 128 + Set-Content -LiteralPath (Join-Path $script:TempRoot 'source\App.map') -Value $knownContent -NoNewline -Encoding ASCII + + $jsonText = & $script:ToolPath -RootPath $script:TempRoot -Level standard -Json -WhatIf + $result = $jsonText | ConvertFrom-Json + + $mapItem = @($result.Items) | Where-Object { $_.Path -like '*App.map' } | Select-Object -First 1 + $mapItem | Should -Not -BeNullOrEmpty + $mapItem.Size | Should -Be 128 + } + It 'removes build artifacts in standard level' { & $script:ToolPath -RootPath $script:TempRoot -Level standard | Out-Null diff --git a/tests/pwsh/delphi-clean.RecycleBin.Tests.ps1 b/tests/pwsh/delphi-clean.RecycleBin.Tests.ps1 index 202bc4c..43d7fbe 100644 --- a/tests/pwsh/delphi-clean.RecycleBin.Tests.ps1 +++ b/tests/pwsh/delphi-clean.RecycleBin.Tests.ps1 @@ -27,11 +27,19 @@ Describe 'delphi-clean.ps1 -RecycleBin tests' { Set-Content -LiteralPath (Join-Path $script:TempRoot 'source\Unit1.identcache') -Value 'dummy' Set-Content -LiteralPath (Join-Path $script:TempRoot 'source\App.exe') -Value 'dummy' Set-Content -LiteralPath (Join-Path $script:TempRoot '.git\keep.dcu') -Value 'dummy' + + # Redirect home config lookup so a real $HOME/delphi-clean.json does not affect results + $script:FakeHome = Join-Path ([System.IO.Path]::GetTempPath()) ("delphi-clean-home-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $script:FakeHome | Out-Null + $env:DELPHI_CLEAN_HOME_OVERRIDE = $script:FakeHome } AfterEach { - if ($script:TempRoot -and (Test-Path -LiteralPath $script:TempRoot)) { - Remove-Item -LiteralPath $script:TempRoot -Recurse -Force -ErrorAction SilentlyContinue + $env:DELPHI_CLEAN_HOME_OVERRIDE = $null + foreach ($dir in @($script:TempRoot, $script:FakeHome)) { + if ($dir -and (Test-Path -LiteralPath $dir)) { + Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue + } } } diff --git a/tests/pwsh/fixtures/monorepo-billing.json b/tests/pwsh/fixtures/monorepo-billing.json new file mode 100644 index 0000000..76d4bad --- /dev/null +++ b/tests/pwsh/fixtures/monorepo-billing.json @@ -0,0 +1,3 @@ +{ + "searchParentFolders": true +} diff --git a/tests/pwsh/fixtures/monorepo-payments.json b/tests/pwsh/fixtures/monorepo-payments.json new file mode 100644 index 0000000..71c8cd9 --- /dev/null +++ b/tests/pwsh/fixtures/monorepo-payments.json @@ -0,0 +1,5 @@ +{ + "level": "deep", + "includeFilePattern": ["*.res"], + "searchParentFolders": true +} diff --git a/tests/pwsh/fixtures/monorepo-root.json b/tests/pwsh/fixtures/monorepo-root.json new file mode 100644 index 0000000..4760ea7 --- /dev/null +++ b/tests/pwsh/fixtures/monorepo-root.json @@ -0,0 +1,5 @@ +{ + "level": "standard", + "excludeDirectoryPattern": ["vendor*"], + "searchParentFolders": false +} diff --git a/tests/pwsh/fixtures/project-example.json b/tests/pwsh/fixtures/project-example.json new file mode 100644 index 0000000..b3a1921 --- /dev/null +++ b/tests/pwsh/fixtures/project-example.json @@ -0,0 +1,4 @@ +{ + "level": "standard", + "searchParentFolders": false +} diff --git a/tests/pwsh/fixtures/user-level-example.json b/tests/pwsh/fixtures/user-level-example.json new file mode 100644 index 0000000..7528c3f --- /dev/null +++ b/tests/pwsh/fixtures/user-level-example.json @@ -0,0 +1,5 @@ +{ + "outputLevel": "summary", + "recycleBin": false, + "excludeDirectoryPattern": ["vendor*"] +}