├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── contents.xcworkspacedata
│ └── xcshareddata
│ └── xcschemes
│ ├── CoreTests.xcscheme
│ ├── Run.xcscheme
│ ├── SwiftPackageInfo.xcscheme
│ ├── swift-package-info-Package.xcscheme
│ └── swift-package-info.xcscheme
├── LICENSE
├── MeasurementApp.xcodeproj
├── project.pbxproj
└── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── MeasurementApp
├── Assets.xcassets
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── 100.png
│ │ ├── 1024.png
│ │ ├── 114.png
│ │ ├── 120.png
│ │ ├── 128.png
│ │ ├── 144.png
│ │ ├── 152.png
│ │ ├── 16.png
│ │ ├── 167.png
│ │ ├── 180.png
│ │ ├── 20.png
│ │ ├── 256.png
│ │ ├── 29.png
│ │ ├── 32.png
│ │ ├── 40.png
│ │ ├── 50.png
│ │ ├── 512.png
│ │ ├── 57.png
│ │ ├── 58.png
│ │ ├── 60.png
│ │ ├── 64.png
│ │ ├── 72.png
│ │ ├── 76.png
│ │ ├── 80.png
│ │ ├── 87.png
│ │ └── Contents.json
│ └── Contents.json
├── ContentView.swift
├── Info.plist
├── MeasurementApp.swift
└── Preview Content
│ └── Preview Assets.xcassets
│ └── Contents.json
├── Package.swift
├── README.md
├── Sources
├── App
│ ├── Providers
│ │ ├── BinarySizeProvider
│ │ │ ├── AppManager.swift
│ │ │ ├── BinarySizeProvider.swift
│ │ │ └── SizeMeasurer.swift
│ │ ├── DependenciesProvider
│ │ │ └── DependenciesProvider.swift
│ │ └── PlatformsProvider
│ │ │ └── PlatformsProvider.swift
│ └── Services
│ │ ├── SwiftPackageService.swift
│ │ └── SwiftPackageValidator.swift
├── Core
│ ├── Console.swift
│ ├── Extensions
│ │ ├── AbsolutePath+Core.swift
│ │ ├── Array+Core.swift
│ │ ├── JSONEncoder+Core.swift
│ │ ├── KeyPath+Core.swift
│ │ ├── Result+Core.swift
│ │ └── URL+Core.swift
│ ├── InfoProvider.swift
│ ├── Models
│ │ ├── PackageDefinition.swift
│ │ ├── PackageModel.swift
│ │ ├── ResurceState.swift
│ │ └── SizeOnDisk.swift
│ ├── PackageLoader.swift
│ └── Shell.swift
├── CoreTestSupport
│ ├── Doubles
│ │ ├── Fixtures
│ │ │ ├── Fixture+ProvidedInfo.swift
│ │ │ ├── Fixture+SwiftPackage.swift
│ │ │ └── Fixture.swift
│ │ └── Mocks
│ │ │ ├── ProgressAnimationMock.swift
│ │ │ └── TerminalControllerMock.swift
│ └── Extensions
│ │ └── XCTest+TestSupport.swift
├── Reports
│ ├── Generators
│ │ ├── ConsoleReportGenerator.swift
│ │ ├── JSONDumpReportGenerator.swift
│ │ └── ReportGenerating.swift
│ ├── Report.swift
│ ├── ReportCell.swift
│ ├── ReportFormat.swift
│ └── Reporting.swift
├── Run
│ ├── ExpressibleByArgument+Run.swift
│ ├── Subcommands
│ │ ├── BinarySize.swift
│ │ ├── Dependencies.swift
│ │ ├── FullAnalyzes.swift
│ │ └── Platforms.swift
│ └── SwiftPackageInfo.swift
└── SwiftPackageInfo
│ └── SwiftPackageInfoLibrary.swift
└── Tests
├── AppTests
├── Fixtures
│ └── Fixture+PackageContent.swift
└── Providers
│ ├── BinarySizeProvider
│ ├── BinarySizeProviderErrorTests.swift
│ └── BinarySizeProviderTests.swift
│ ├── DependenciesProvider
│ └── DependenciesProviderTests.swift
│ └── PlatformsProvider
│ └── PlatformsProviderTests.swift
├── CoreTests
├── Helpers
│ └── FileManager+CoreTests.swift
├── Models
│ ├── PackageDefinitionTests.swift
│ └── SizeOnDiskTests.swift
├── ProvidedInfoTests.swift
└── URLExtensionTests.swift
├── ReportsTests
└── Generators
│ ├── ConsoleReportGeneratorTests.swift
│ └── JSONDumpReportGeneratorTests.swift
├── RunTests
└── RunTests.swift
└── SwiftPackageInfoTests
└── LibraryTests.swift
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | workflow_dispatch:
6 |
7 | env:
8 | DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer
9 |
10 | concurrency:
11 | group: '${{ github.workflow }}-${{ github.head_ref }}'
12 | cancel-in-progress: true
13 |
14 | jobs:
15 | test:
16 | name: "Test"
17 | runs-on: macos-latest
18 | steps:
19 | - uses: actions/checkout@v4
20 |
21 | - name: Check cache for Swift dependencies
22 | uses: actions/cache@v4
23 | with:
24 | path: .build
25 | key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
26 | restore-keys: |
27 | ${{ runner.os }}-spm-
28 |
29 | - name: Build
30 | run: swift build
31 |
32 | - name: Run tests
33 | run: swift test --build-path PROJECT_DIR
34 |
35 | - name: Report code coverage
36 | uses: codecov/codecov-action@v4
37 | continue-on-error: true
38 | timeout-minutes: 10
39 | with:
40 | fail_ci_if_error: true
41 | token: ${{ secrets.CODECOV_TOKEN }}
42 | exclude: Tests/**/*
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## macOS
2 | *.DS_Store
3 |
4 | ## User settings
5 | xcuserdata/
6 |
7 | ## App packaging
8 | *.ipa
9 | *.dSYM.zip
10 | *.dSYM
11 |
12 | # Swift Package Manager
13 | .build/
14 | Packages
15 | Package.pins
16 | Package.resolved
17 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/CoreTests.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
14 |
15 |
17 |
23 |
24 |
25 |
26 |
27 |
37 |
38 |
44 |
45 |
47 |
48 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/Run.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
43 |
49 |
50 |
51 |
57 |
63 |
64 |
65 |
71 |
77 |
78 |
79 |
80 |
81 |
86 |
87 |
89 |
95 |
96 |
97 |
99 |
105 |
106 |
107 |
109 |
115 |
116 |
117 |
119 |
125 |
126 |
127 |
128 |
129 |
139 |
141 |
147 |
148 |
149 |
150 |
156 |
158 |
164 |
165 |
166 |
167 |
169 |
170 |
173 |
174 |
175 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/SwiftPackageInfo.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
33 |
39 |
40 |
41 |
42 |
43 |
53 |
55 |
61 |
62 |
63 |
64 |
70 |
72 |
78 |
79 |
80 |
81 |
83 |
84 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Felipe Lefèvre Marino
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MeasurementApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/MeasurementApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/100.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/100.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/1024.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/114.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/120.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/128.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/144.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/152.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/16.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/167.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/167.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/180.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/20.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/20.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/256.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/29.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/29.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/32.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/40.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/40.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/50.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/50.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/512.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/57.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/58.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/58.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/60.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/64.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/72.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/76.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/80.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/80.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/87.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/87.png
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "40.png",
5 | "idiom" : "iphone",
6 | "scale" : "2x",
7 | "size" : "20x20"
8 | },
9 | {
10 | "filename" : "60.png",
11 | "idiom" : "iphone",
12 | "scale" : "3x",
13 | "size" : "20x20"
14 | },
15 | {
16 | "filename" : "29.png",
17 | "idiom" : "iphone",
18 | "scale" : "1x",
19 | "size" : "29x29"
20 | },
21 | {
22 | "filename" : "58.png",
23 | "idiom" : "iphone",
24 | "scale" : "2x",
25 | "size" : "29x29"
26 | },
27 | {
28 | "filename" : "87.png",
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "29x29"
32 | },
33 | {
34 | "filename" : "80.png",
35 | "idiom" : "iphone",
36 | "scale" : "2x",
37 | "size" : "40x40"
38 | },
39 | {
40 | "filename" : "120.png",
41 | "idiom" : "iphone",
42 | "scale" : "3x",
43 | "size" : "40x40"
44 | },
45 | {
46 | "filename" : "57.png",
47 | "idiom" : "iphone",
48 | "scale" : "1x",
49 | "size" : "57x57"
50 | },
51 | {
52 | "filename" : "114.png",
53 | "idiom" : "iphone",
54 | "scale" : "2x",
55 | "size" : "57x57"
56 | },
57 | {
58 | "filename" : "120.png",
59 | "idiom" : "iphone",
60 | "scale" : "2x",
61 | "size" : "60x60"
62 | },
63 | {
64 | "filename" : "180.png",
65 | "idiom" : "iphone",
66 | "scale" : "3x",
67 | "size" : "60x60"
68 | },
69 | {
70 | "filename" : "20.png",
71 | "idiom" : "ipad",
72 | "scale" : "1x",
73 | "size" : "20x20"
74 | },
75 | {
76 | "filename" : "40.png",
77 | "idiom" : "ipad",
78 | "scale" : "2x",
79 | "size" : "20x20"
80 | },
81 | {
82 | "filename" : "29.png",
83 | "idiom" : "ipad",
84 | "scale" : "1x",
85 | "size" : "29x29"
86 | },
87 | {
88 | "filename" : "58.png",
89 | "idiom" : "ipad",
90 | "scale" : "2x",
91 | "size" : "29x29"
92 | },
93 | {
94 | "filename" : "40.png",
95 | "idiom" : "ipad",
96 | "scale" : "1x",
97 | "size" : "40x40"
98 | },
99 | {
100 | "filename" : "80.png",
101 | "idiom" : "ipad",
102 | "scale" : "2x",
103 | "size" : "40x40"
104 | },
105 | {
106 | "filename" : "50.png",
107 | "idiom" : "ipad",
108 | "scale" : "1x",
109 | "size" : "50x50"
110 | },
111 | {
112 | "filename" : "100.png",
113 | "idiom" : "ipad",
114 | "scale" : "2x",
115 | "size" : "50x50"
116 | },
117 | {
118 | "filename" : "72.png",
119 | "idiom" : "ipad",
120 | "scale" : "1x",
121 | "size" : "72x72"
122 | },
123 | {
124 | "filename" : "144.png",
125 | "idiom" : "ipad",
126 | "scale" : "2x",
127 | "size" : "72x72"
128 | },
129 | {
130 | "filename" : "76.png",
131 | "idiom" : "ipad",
132 | "scale" : "1x",
133 | "size" : "76x76"
134 | },
135 | {
136 | "filename" : "152.png",
137 | "idiom" : "ipad",
138 | "scale" : "2x",
139 | "size" : "76x76"
140 | },
141 | {
142 | "filename" : "167.png",
143 | "idiom" : "ipad",
144 | "scale" : "2x",
145 | "size" : "83.5x83.5"
146 | },
147 | {
148 | "filename" : "1024.png",
149 | "idiom" : "ios-marketing",
150 | "scale" : "1x",
151 | "size" : "1024x1024"
152 | },
153 | {
154 | "filename" : "16.png",
155 | "idiom" : "mac",
156 | "scale" : "1x",
157 | "size" : "16x16"
158 | },
159 | {
160 | "filename" : "32.png",
161 | "idiom" : "mac",
162 | "scale" : "2x",
163 | "size" : "16x16"
164 | },
165 | {
166 | "filename" : "32.png",
167 | "idiom" : "mac",
168 | "scale" : "1x",
169 | "size" : "32x32"
170 | },
171 | {
172 | "filename" : "64.png",
173 | "idiom" : "mac",
174 | "scale" : "2x",
175 | "size" : "32x32"
176 | },
177 | {
178 | "filename" : "128.png",
179 | "idiom" : "mac",
180 | "scale" : "1x",
181 | "size" : "128x128"
182 | },
183 | {
184 | "filename" : "256.png",
185 | "idiom" : "mac",
186 | "scale" : "2x",
187 | "size" : "128x128"
188 | },
189 | {
190 | "filename" : "256.png",
191 | "idiom" : "mac",
192 | "scale" : "1x",
193 | "size" : "256x256"
194 | },
195 | {
196 | "filename" : "512.png",
197 | "idiom" : "mac",
198 | "scale" : "2x",
199 | "size" : "256x256"
200 | },
201 | {
202 | "filename" : "512.png",
203 | "idiom" : "mac",
204 | "scale" : "1x",
205 | "size" : "512x512"
206 | },
207 | {
208 | "filename" : "1024.png",
209 | "idiom" : "mac",
210 | "scale" : "2x",
211 | "size" : "512x512"
212 | }
213 | ],
214 | "info" : {
215 | "author" : "xcode",
216 | "version" : 1
217 | }
218 | }
219 |
--------------------------------------------------------------------------------
/MeasurementApp/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/MeasurementApp/ContentView.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import SwiftUI
22 |
23 | struct ContentView: View {
24 | var body: some View {
25 | Text("Hello, world!")
26 | .padding()
27 | }
28 | }
29 |
30 | struct ContentView_Previews: PreviewProvider {
31 | static var previews: some View {
32 | ContentView()
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/MeasurementApp/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | $(CURRENT_PROJECT_VERSION)
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 |
28 | UIApplicationSupportsIndirectInputEvents
29 |
30 | UILaunchScreen
31 |
32 | UIRequiredDeviceCapabilities
33 |
34 | armv7
35 |
36 | UISupportedInterfaceOrientations
37 |
38 | UIInterfaceOrientationPortrait
39 | UIInterfaceOrientationLandscapeLeft
40 | UIInterfaceOrientationLandscapeRight
41 |
42 | UISupportedInterfaceOrientations~ipad
43 |
44 | UIInterfaceOrientationPortrait
45 | UIInterfaceOrientationPortraitUpsideDown
46 | UIInterfaceOrientationLandscapeLeft
47 | UIInterfaceOrientationLandscapeRight
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/MeasurementApp/MeasurementApp.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import SwiftUI
22 |
23 | @main
24 | struct MeasurementAppApp: App {
25 | var body: some Scene {
26 | WindowGroup {
27 | ContentView()
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/MeasurementApp/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "swift-package-info",
8 | platforms: [
9 | .macOS(.v13)
10 | ],
11 | products: [
12 | .executable(
13 | name: "swift-package-info",
14 | targets: [
15 | "Run"
16 | ]
17 | ),
18 | .library(
19 | name: "SwiftPackageInfo",
20 | targets: ["SwiftPackageInfo"]
21 | )
22 | ],
23 | dependencies: [
24 | // Can't update and benefit from latest Swift 6 warnings fixes because the latest
25 | // swift-package-manager release still relies on older versions of the swift-argument-parser
26 | .package(
27 | url: "https://github.com/apple/swift-argument-parser",
28 | .upToNextMinor(from: "1.2.1")
29 | ),
30 | .package(
31 | url: "https://github.com/tuist/XcodeProj.git",
32 | .upToNextMinor(from: "8.7.1")
33 | ),
34 | .package(
35 | url: "https://github.com/marinofelipe/http_client",
36 | .upToNextMinor(from: "0.0.4")
37 | ),
38 | // - Pinned to the the Swift 6.0.3 release / Xcode 16.2
39 | // It auto exports SwiftToolsSupport, so no need to directly depend it 🙏
40 | .package(
41 | url: "https://github.com/apple/swift-package-manager",
42 | revision: "swift-6.0.3-RELEASE"
43 | ),
44 | ],
45 | targets: [
46 | .executableTarget(
47 | name: "Run",
48 | dependencies: [
49 | .target(name: "App"),
50 | .target(name: "Reports"),
51 | .target(name: "Core"),
52 | .product(
53 | name: "ArgumentParser",
54 | package: "swift-argument-parser"
55 | ),
56 | ],
57 | swiftSettings: [
58 | .enableExperimentalFeature("StrictConcurrency"),
59 | .enableExperimentalFeature("InferSendableFromCaptures"),
60 | ]
61 | ),
62 | .testTarget(
63 | name: "RunTests",
64 | dependencies: [
65 | .target(name: "Run")
66 | ],
67 | swiftSettings: [
68 | .enableExperimentalFeature("StrictConcurrency"),
69 | .enableExperimentalFeature("InferSendableFromCaptures"),
70 | ]
71 | ),
72 | .target(
73 | name: "SwiftPackageInfo", // Library
74 | dependencies: [
75 | .target(name: "App"),
76 | .target(name: "Core")
77 | ],
78 | swiftSettings: [
79 | .enableExperimentalFeature("StrictConcurrency"),
80 | .enableExperimentalFeature("InferSendableFromCaptures"),
81 | ]
82 | ),
83 | .testTarget(
84 | name: "SwiftPackageInfoTests",
85 | dependencies: [
86 | .target(name: "SwiftPackageInfo"),
87 | .target(name: "CoreTestSupport")
88 | ],
89 | swiftSettings: [
90 | .enableExperimentalFeature("StrictConcurrency"),
91 | .enableExperimentalFeature("InferSendableFromCaptures"),
92 | ]
93 | ),
94 | .target(
95 | name: "App",
96 | dependencies: [
97 | .product(
98 | name: "XcodeProj",
99 | package: "XcodeProj"
100 | ),
101 | .product(
102 | name: "CombineHTTPClient",
103 | package: "http_client"
104 | ),
105 | .target(name: "Core")
106 | ],
107 | swiftSettings: [
108 | .enableExperimentalFeature("StrictConcurrency"),
109 | .enableExperimentalFeature("InferSendableFromCaptures"),
110 | ]
111 | ),
112 | .testTarget(
113 | name: "AppTests",
114 | dependencies: [
115 | .target(name: "App"),
116 | .target(name: "CoreTestSupport")
117 | ],
118 | swiftSettings: [
119 | .enableExperimentalFeature("StrictConcurrency"),
120 | .enableExperimentalFeature("InferSendableFromCaptures"),
121 | ]
122 | ),
123 | .target(
124 | name: "Reports",
125 | dependencies: [
126 | .target(name: "Core")
127 | ],
128 | swiftSettings: [
129 | .enableExperimentalFeature("StrictConcurrency"),
130 | .enableExperimentalFeature("InferSendableFromCaptures"),
131 | ]
132 | ),
133 | .testTarget(
134 | name: "ReportsTests",
135 | dependencies: [
136 | .target(name: "Reports"),
137 | .target(name: "CoreTestSupport")
138 | ],
139 | swiftSettings: [
140 | .enableExperimentalFeature("StrictConcurrency"),
141 | .enableExperimentalFeature("InferSendableFromCaptures"),
142 | ]
143 | ),
144 | .target(
145 | name: "Core",
146 | dependencies: [
147 | .product(
148 | name: "SwiftPM",
149 | package: "swift-package-manager"
150 | ),
151 | ],
152 | swiftSettings: [
153 | .enableExperimentalFeature("StrictConcurrency"),
154 | .enableExperimentalFeature("InferSendableFromCaptures"),
155 | ]
156 | ),
157 | .testTarget(
158 | name: "CoreTests",
159 | dependencies: [
160 | .target(name: "Core"),
161 | .target(name: "CoreTestSupport")
162 | ],
163 | swiftSettings: [
164 | .enableExperimentalFeature("StrictConcurrency"),
165 | .enableExperimentalFeature("InferSendableFromCaptures"),
166 | ]
167 | ),
168 | .target(
169 | name: "CoreTestSupport",
170 | dependencies: [
171 | .target(name: "Core")
172 | ],
173 | swiftSettings: [
174 | .enableExperimentalFeature("StrictConcurrency"),
175 | .enableExperimentalFeature("InferSendableFromCaptures"),
176 | ]
177 | )
178 | ],
179 | swiftLanguageVersions: [
180 | .v4,
181 | .v5
182 | ]
183 | )
184 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | [](https://swift.org/package-manager/)
3 | [](https://twitter.com/_marinofelipe)
4 |
5 | # Swift Package Info
6 | CLI tool that provides information about a *given Swift Package product*, such as a *measurament of its binary size impact*.
7 | It's built on top of [Swift Argument Parser](https://github.com/apple/swift-argument-parser).
8 |
9 | ## Usage
10 | ```
11 | OVERVIEW: A tool for analyzing Swift Packages
12 |
13 | Provides valuable information about a given Swift Package,
14 | that can be used in your favor when deciding whether to
15 | adopt or not a Swift Package as a dependency on your app.
16 |
17 | USAGE: swift-package-info
18 |
19 | OPTIONS:
20 | --version Show the version.
21 | -h, --help Show help information.
22 |
23 | SUBCOMMANDS:
24 | binary-size Estimated binary size of a Swift Package product.
25 | platforms Shows platforms supported b a Package product.
26 | dependencies List dependencies of a Package product.
27 | full-analyzes (default) All available information about a Swift Package product.
28 |
29 | See 'swift-package-info help ' for detailed help.
30 | ```
31 |
32 | ### Examples
33 | - Run a full analyzes
34 | ```
35 | swift-package-info --for https://github.com/ReactiveX/RxSwift -v 6.0.0 --product RxSwift
36 | ```
37 |
38 | - Check supported platforms (sub command)
39 | ```
40 | swift-package-info platforms --for https://github.com/krzyzanowskim/CryptoSwift -v 1.3.8 --product CryptoSwift
41 | ```
42 |
43 | - See binary size of a local pacakge (e.g. under development framework)
44 | ```
45 | swift-package-info binary-size --path ../project/my-framework
46 | ```
47 |
48 | ### Report
49 | ```
50 | swift-package-info --for https://github.com/ReactiveX/RxSwift -v 6.0.0 --product RxSwift
51 | ```
52 | ```
53 | +------------------------------------------------+
54 | | Swift Package Info |
55 | | |
56 | | RxSwift, 6.0.0 |
57 | +--------------+---------------------------------+
58 | | Provider | Results |
59 | +--------------+---------------------------------+
60 | | Binary Size | Binary size increases by 963 KB |
61 | | Platforms | System default |
62 | | Dependencies | No third-party dependencies :) |
63 | +--------------+---------------------------------+
64 | > Total of 3 providers used.
65 | ```
66 |
67 | A custom report strategy can be passed via the `report` argument _(check --help for supported values)_
68 | ```
69 | swift-package-info --for https://github.com/ReactiveX/RxSwift -v 6.0.0 --product RxSwift --report jsonDump
70 | ```
71 | ```JSON
72 | {
73 | "binarySize" : {
74 | "amount" : 962560,
75 | "formatted" : "963 KB"
76 | },
77 | "dependencies" : [
78 |
79 | ],
80 | "platforms" : {
81 |
82 | }
83 | }
84 | ```
85 |
86 | ## Installation
87 | * Install [mint](https://github.com/yonaskolb/Mint)
88 | * _Optionally_ [add mint to your $PATH](https://github.com/yonaskolb/Mint?tab=readme-ov-file#linking)
89 | * Run: `mint install marinofelipe/swift-package-info` to install the latest version
90 |
91 | ## Running it
92 | * `mint run swift-package-info`
93 | * or simply, `swift-package-info` in case it was symlinked
94 |
95 | ## Building
96 | Build from Swift Package Manager
97 |
98 | * `swift build` in the top level directory
99 | * The built utility can be found in `.build/debug/swift-package-info`
100 | * Run with `swift run`
101 |
102 | ## Running tests
103 | Run from Xcode
104 |
105 | * Add the project directory to `swift-package-info` scheme customWorkingDirectory
106 | * Run the tests
107 |
108 | Run from command line
109 |
110 | * `swift test --build-path PROJECT_DIR`
111 |
112 | ## Dependencies
113 | * [CombineHTTPClient from HTTPClient](https://github.com/marinofelipe/http_client/blob/main/Package.swift)
114 | * [Swift Argument Parser](https://github.com/apple/swift-argument-parser)
115 | * [swift-tools-support-core from SwiftToolsSupport-auto](https://github.com/apple/swift-tools-support-core/blob/main/Package.swift)
116 | * [XcodeProj](https://github.com/tuist/XcodeProj.git)
117 |
118 | ## Binary size report
119 | Its methodology is inspired by [cocoapods-size](https://github.com/google/cocoapods-size), and thus works by comparing archives with no bitcode and ARM64 arch.
120 | Such strategy has proven to be consistent with the size added to iOS apps downloaded and installed via TestFlight.
121 |
122 | ## Thanks
123 | Special thanks to [@unnamedd](https://github.com/unnamedd) for sharing his experience with [swift-tools-support-core](https://github.com/apple/swift-tools-support-core) and on how to build a pretty 👌 report.
124 |
125 | ## Contributions
126 | *Swift Package Info* is fully open and your contributions are more than welcome.
127 |
--------------------------------------------------------------------------------
/Sources/App/Providers/BinarySizeProvider/BinarySizeProvider.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public import Core
22 | public import Foundation
23 |
24 | // MARK: - Types
25 |
26 | enum BinarySizeProviderError: LocalizedError, Equatable {
27 | case unableToGenerateArchive(errorMessage: String)
28 | case unableToCloneEmptyApp(errorMessage: String)
29 | case unableToGetBinarySizeOnDisk(underlyingError: NSError)
30 | case unableToRetrieveAppProject(atPath: String)
31 | case unexpectedError(underlyingError: NSError, isVerbose: Bool)
32 |
33 | var errorDescription: String? {
34 | let step: String
35 | let message: String
36 | switch self {
37 | case let .unableToGenerateArchive(errorMessage):
38 | step = "Archiving"
39 | message = errorMessage
40 | case let .unableToCloneEmptyApp(errorMessage):
41 | step = "Cloning empty app"
42 | message = errorMessage
43 | case let .unableToGetBinarySizeOnDisk(underlyingError):
44 | step = "Reading binary size"
45 | message = "Failed to read binary size from archive. Details: \(underlyingError.localizedDescription)"
46 | case let .unableToRetrieveAppProject(path):
47 | step = "Read measurement app project"
48 | message = "Failed to get MeasurementApp project from XcodeProj at path: \(path)"
49 | case let .unexpectedError(underlyingError, isVerboseOn):
50 | step = "Undefined"
51 | message = """
52 | Unexpected failure. \(underlyingError.description).
53 | \(isVerboseOn ? "" : "Please run with --verbose enabled for more details.")
54 | """
55 | }
56 |
57 | return """
58 | Failed to measure binary size
59 | Step: \(step)
60 | Error: \(message)
61 | """
62 | }
63 | }
64 |
65 | struct BinarySizeInformation: Equatable, Encodable, CustomConsoleMessagesConvertible {
66 | private let amount: Int
67 | private let formatted: String
68 |
69 | var messages: [ConsoleMessage] { buildConsoleMessages() }
70 |
71 | init(binarySize: SizeOnDisk) {
72 | self.amount = binarySize.amount
73 | self.formatted = binarySize.formatted
74 | }
75 |
76 | private enum CodingKeys: String, CodingKey {
77 | case amount
78 | case formatted
79 | }
80 |
81 | private func buildConsoleMessages() -> [ConsoleMessage] {
82 | [
83 | .init(
84 | text: "Binary size increases by ",
85 | color: .noColor,
86 | isBold: false,
87 | hasLineBreakAfter: false
88 | ),
89 | .init(
90 | text: formatted,
91 | color: .yellow,
92 | isBold: true,
93 | hasLineBreakAfter: false
94 | )
95 | ]
96 | }
97 | }
98 |
99 | // MARK: - Provider
100 |
101 | public struct BinarySizeProvider {
102 | @Sendable
103 | public static func binarySize(
104 | for packageDefinition: PackageDefinition,
105 | resolvedPackage: PackageWrapper,
106 | xcconfig: URL?,
107 | verbose: Bool
108 | ) async throws -> ProvidedInfo { // throws(InfoProviderError): typed throws only supported from macOS 15 runtime
109 | let sizeMeasurer = await defaultSizeMeasurer(xcconfig, verbose)
110 | var binarySize: SizeOnDisk = .zero
111 |
112 | let isProductDynamicLibrary = resolvedPackage.products
113 | .first{ $0.name == packageDefinition.product }?
114 | .isDynamicLibrary ?? false
115 |
116 | do {
117 | binarySize = try await sizeMeasurer(
118 | packageDefinition,
119 | isProductDynamicLibrary
120 | )
121 | } catch let error as LocalizedError {
122 | throw InfoProviderError(localizedError: error)
123 | } catch {
124 | throw InfoProviderError(
125 | localizedError: BinarySizeProviderError.unexpectedError(
126 | underlyingError: error as NSError,
127 | isVerbose: verbose
128 | )
129 | )
130 | }
131 |
132 | return ProvidedInfo(
133 | providerName: "Binary Size",
134 | providerKind: .binarySize,
135 | information: BinarySizeInformation(
136 | binarySize: binarySize
137 | )
138 | )
139 | }
140 | }
141 |
142 | // MARK: - Provider - library
143 |
144 | public extension BinarySizeProvider {
145 | struct Result: Sendable {
146 | public let amount: Int
147 | public let formatted: String
148 | }
149 |
150 | @Sendable
151 | static func binarySize(
152 | for packageDefinition: PackageDefinition,
153 | resolvedPackage: PackageWrapper,
154 | xcConfig: URL?
155 | ) async throws(InfoProviderError) -> Result {
156 | let sizeMeasurer = await defaultSizeMeasurer(xcConfig, false)
157 | var binarySize: SizeOnDisk = .zero
158 |
159 | let isProductDynamicLibrary = resolvedPackage.products
160 | .first{ $0.name == packageDefinition.product }?
161 | .isDynamicLibrary ?? false
162 |
163 | do {
164 | binarySize = try await sizeMeasurer(
165 | packageDefinition,
166 | isProductDynamicLibrary
167 | )
168 | } catch let error as LocalizedError {
169 | throw InfoProviderError(localizedError: error)
170 | } catch {
171 | throw InfoProviderError(
172 | localizedError: BinarySizeProviderError.unexpectedError(
173 | underlyingError: error as NSError,
174 | isVerbose: false
175 | )
176 | )
177 | }
178 |
179 | return Result(
180 | amount: binarySize.amount,
181 | formatted: binarySize.formatted
182 | )
183 | }
184 | }
185 |
186 | #if DEBUG
187 | // debug only
188 | nonisolated(unsafe) var defaultSizeMeasurer: (URL?, Bool) async -> SizeMeasuring = { xcconfig, verbose in
189 | await SizeMeasurer(verbose: verbose, xcconfig: xcconfig).binarySize
190 | }
191 | #else
192 | let defaultSizeMeasurer: (URL?, Bool) async -> SizeMeasuring = { xcconfig, verbose in
193 | await SizeMeasurer(verbose: verbose, xcconfig: xcconfig).binarySize
194 | }
195 | #endif
196 |
--------------------------------------------------------------------------------
/Sources/App/Providers/BinarySizeProvider/SizeMeasurer.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | internal import Foundation
22 | internal import Core
23 |
24 | typealias SizeMeasuring = (
25 | _ swiftPackage: PackageDefinition,
26 | _ isDynamic: Bool
27 | ) async throws -> SizeOnDisk
28 |
29 | final class SizeMeasurer {
30 | private var appManager: AppManager
31 | private let console: Console
32 | private let verbose: Bool
33 |
34 | public convenience init(verbose: Bool, xcconfig: URL?) async {
35 | await self.init(
36 | appManager: .init(
37 | console: .default,
38 | xcconfig: xcconfig,
39 | verbose: verbose
40 | ),
41 | console: .default,
42 | verbose: verbose
43 | )
44 | }
45 |
46 | init(
47 | appManager: AppManager,
48 | console: Console,
49 | verbose: Bool
50 | ) async {
51 | self.appManager = appManager
52 | self.console = console
53 | self.verbose = verbose
54 | }
55 |
56 | private static let stepsCount = 7
57 | private var currentStep = 1
58 | private static let second: Double = 1_000_000
59 |
60 | deinit {
61 | try? appManager.cleanUp()
62 | }
63 |
64 | public func binarySize(
65 | for swiftPackage: PackageDefinition,
66 | isDynamic: Bool
67 | ) async throws -> SizeOnDisk {
68 | await console.lineBreak()
69 |
70 | let emptyAppSize = try await measureEmptyAppSize()
71 | let appSizeWithDependencyAdded = try await measureAppSize(
72 | with: swiftPackage,
73 | isDynamic: isDynamic
74 | )
75 |
76 | await completeLoading()
77 | let increasedSize = appSizeWithDependencyAdded - emptyAppSize
78 |
79 | return increasedSize
80 | }
81 | }
82 |
83 | // MARK: - Private
84 |
85 | private extension SizeMeasurer {
86 | func measureEmptyAppSize() async throws -> SizeOnDisk {
87 | if verbose == false {
88 | await showOrUpdateLoading(withText: "Cleaning up empty app directory...")
89 | }
90 | try appManager.cleanUp()
91 |
92 | if verbose {
93 | await console.lineBreakAndWrite("Cloning empty app")
94 | } else {
95 | await showOrUpdateLoading(withText: "Cloning empty app...")
96 | }
97 | try await appManager.cloneEmptyApp()
98 |
99 | if verbose {
100 | await console.lineBreakAndWrite(
101 | .init(
102 | text: "Measuring empty app size",
103 | color: .green,
104 | isBold: true
105 | )
106 | )
107 | }
108 |
109 | if verbose == false {
110 | await showOrUpdateLoading(withText: "Generating archive for empty app...")
111 | }
112 | try await appManager.generateArchive()
113 |
114 | if verbose == false {
115 | await showOrUpdateLoading(withText: "Calculating binary size...")
116 | }
117 | return try await appManager.calculateBinarySize()
118 | }
119 |
120 | func measureAppSize(
121 | with swiftPackage: PackageDefinition,
122 | isDynamic: Bool
123 | ) async throws -> SizeOnDisk {
124 | if verbose {
125 | await console.lineBreakAndWrite(
126 | .init(
127 | text: "Measuring app size with \(swiftPackage.product) added as dependency",
128 | color: .green,
129 | isBold: true
130 | )
131 | )
132 | }
133 |
134 | if verbose == false {
135 | await showOrUpdateLoading(withText: "Adding \(swiftPackage.product) as dependency...")
136 | }
137 | try appManager.add(
138 | asDependency: swiftPackage,
139 | isDynamic: isDynamic
140 | )
141 |
142 | if verbose == false {
143 | await showOrUpdateLoading(withText: "Generating archive for updated app...")
144 | }
145 | try await appManager.generateArchive()
146 |
147 | if verbose == false {
148 | await showOrUpdateLoading(withText: "Calculating updated binary size...")
149 | }
150 | return try await appManager.calculateBinarySize()
151 | }
152 |
153 | func showOrUpdateLoading(withText text: String) async {
154 | usleep(UInt32(Self.second * 0.5))
155 | await console.showLoading(step: currentStep, total: Self.stepsCount, text: text)
156 | currentStep += 1
157 | }
158 |
159 | func completeLoading() async {
160 | await console.completeLoading(success: true)
161 | currentStep = 0
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/Sources/App/Providers/DependenciesProvider/DependenciesProvider.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public import Core
22 | public import Foundation
23 |
24 | enum DependenciesProviderError: LocalizedError, Equatable {
25 | case failedToMatchProduct
26 |
27 | var errorDescription: String? {
28 | switch self {
29 | case .failedToMatchProduct:
30 | return "Failed to match product when evaluating dependencies"
31 | }
32 | }
33 | }
34 |
35 | public struct DependenciesProvider {
36 | @Sendable
37 | public static func dependencies(
38 | for packageDefinition: PackageDefinition,
39 | resolvedPackage: PackageWrapper,
40 | xcconfig: URL?,
41 | verbose: Bool
42 | ) async throws -> ProvidedInfo {
43 | guard
44 | let product = resolvedPackage.products.first(where: { $0.name == packageDefinition.product })
45 | else {
46 | throw DependenciesProviderError.failedToMatchProduct
47 | }
48 |
49 | let productTargets = product.targets
50 | let externalDependencies = getExternalDependencies(
51 | forTargets: productTargets,
52 | package: resolvedPackage
53 | )
54 |
55 | return ProvidedInfo(
56 | providerName: "Dependencies",
57 | providerKind: .dependencies,
58 | information: DependenciesInformation(dependencies: externalDependencies)
59 | )
60 | }
61 |
62 | private static func getExternalDependencies(
63 | forTargets targets: [PackageWrapper.Target],
64 | package: PackageWrapper
65 | ) -> [DependenciesInformation.Dependency] {
66 | let externalDependencies = targets
67 | .map(\.productDependencies)
68 | .reduce(
69 | [],
70 | +
71 | )
72 |
73 | let transitiveExternalDependencies = targets
74 | .map(\.allTransitiveProductDependencies)
75 | .reduce([], +)
76 |
77 | let allExternalDependencies = externalDependencies + transitiveExternalDependencies
78 |
79 | let dependencies = allExternalDependencies.map(DependenciesInformation.Dependency.init)
80 |
81 | return dependencies.sorted(by: { $0.product < $1.product })
82 | }
83 | }
84 |
85 | private extension PackageWrapper.Target {
86 | var productDependencies: [PackageWrapper.Product] {
87 | dependencies.compactMap(\.product)
88 | }
89 |
90 | var targetDependencies: [PackageWrapper.Target] {
91 | dependencies.compactMap(\.target)
92 | }
93 |
94 | var allTransitiveProductDependencies: [PackageWrapper.Product] {
95 | mapTransitiveProducts(targets: targetDependencies)
96 | }
97 |
98 | private func mapTransitiveProducts(targets: [PackageWrapper.Target]) -> [PackageWrapper.Product] {
99 | let productDependencies = targets.map(\.dependencies)
100 | .reduce([], +)
101 | .compactMap(\.product)
102 |
103 | let targetDependencies = targets.map(\.targetDependencies)
104 | .reduce([], +)
105 |
106 | if targetDependencies.isEmpty == false {
107 | return mapTransitiveProducts(targets: targetDependencies)
108 | } else {
109 | return productDependencies
110 | }
111 | }
112 | }
113 |
114 | struct DependenciesInformation: Equatable, CustomConsoleMessagesConvertible {
115 | struct Dependency: Equatable, Encodable {
116 | let product: String
117 | let package: String
118 | }
119 |
120 | let dependencies: [Dependency]
121 | private let consoleDependencies: [Dependency]
122 |
123 | init(dependencies: [Dependency]) {
124 | self.dependencies = dependencies
125 | self.consoleDependencies = Array(dependencies.prefix(3))
126 | }
127 |
128 | var messages: [ConsoleMessage] { buildConsoleMessages() }
129 |
130 | private func buildConsoleMessages() -> [ConsoleMessage] {
131 | if dependencies.isEmpty {
132 | [
133 | .init(
134 | text: "No third-party dependencies :)",
135 | hasLineBreakAfter: false
136 | )
137 | ]
138 | } else {
139 | consoleDependencies.enumerated().map { index, dependency -> [ConsoleMessage] in
140 | var messages: [ConsoleMessage] = [
141 | .init(
142 | text: "\(dependency.product)",
143 | hasLineBreakAfter: false
144 | ),
145 | ]
146 |
147 | let isLast = index == consoleDependencies.count - 1
148 | if isLast == false {
149 | messages.append(
150 | .init(
151 | text: " | ",
152 | hasLineBreakAfter: false
153 | )
154 | )
155 | }
156 |
157 | if isLast && dependencies.count > 3 {
158 | messages.append(
159 | .init(
160 | text: " | ",
161 | hasLineBreakAfter: false
162 | )
163 | )
164 | messages.append(
165 | .init(
166 | text: "Use `--report jsonDump` to see all..",
167 | hasLineBreakAfter: false
168 | )
169 | )
170 | }
171 |
172 | return messages
173 | }
174 | .reduce(
175 | [],
176 | +
177 | )
178 | }
179 | }
180 | }
181 |
182 | extension DependenciesInformation: Encodable {
183 | func encode(to encoder: Encoder) throws {
184 | var container = encoder.singleValueContainer()
185 | try container.encode(dependencies)
186 | }
187 | }
188 |
189 | extension DependenciesInformation.Dependency {
190 | init(
191 | from dependency: PackageWrapper.Product
192 | ) {
193 | self.init(
194 | product: dependency.name,
195 | package: dependency.package ?? ""
196 | )
197 | }
198 | }
199 |
--------------------------------------------------------------------------------
/Sources/App/Providers/PlatformsProvider/PlatformsProvider.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public import Core
22 | public import Foundation
23 |
24 | public struct PlatformsProvider {
25 | @Sendable
26 | public static func platforms(
27 | for packageDefinition: PackageDefinition,
28 | resolvedPackage: PackageWrapper,
29 | xcconfig: URL?,
30 | verbose: Bool
31 | ) async throws -> ProvidedInfo {
32 | .init(
33 | providerName: "Platforms",
34 | providerKind: .platforms,
35 | information: PlatformsInformation(
36 | platforms: resolvedPackage.platforms
37 | )
38 | )
39 | }
40 | }
41 |
42 | struct PlatformsInformation: Equatable, Encodable, CustomConsoleMessagesConvertible {
43 | let platforms: [PackageWrapper.Platform]
44 | let iOS: String?
45 | let macOS: String?
46 | let tvOS: String?
47 | let watchOS: String?
48 |
49 | var messages: [ConsoleMessage] { buildConsoleMessages() }
50 |
51 | init(platforms: [PackageWrapper.Platform]) {
52 | self.platforms = platforms
53 | self.iOS = platforms.iOSVersion
54 | self.macOS = platforms.macOSVersion
55 | self.tvOS = platforms.tvOSVersion
56 | self.watchOS = platforms.watchOSVersion
57 | }
58 |
59 | private enum CodingKeys: String, CodingKey {
60 | case iOS, macOS, tvOS, watchOS
61 | }
62 |
63 | private func buildConsoleMessages() -> [ConsoleMessage] {
64 | if platforms.isEmpty {
65 | [
66 | .init(
67 | text: "System default",
68 | hasLineBreakAfter: false
69 | )
70 | ]
71 | } else {
72 | platforms
73 | .map { platform -> [ConsoleMessage] in
74 | var messages = [
75 | ConsoleMessage(
76 | text: "\(platform.platformName) from v. \(platform.version)",
77 | isBold: true,
78 | hasLineBreakAfter: false
79 | )
80 | ]
81 |
82 | let isLastPlatform = platform == platforms.last
83 | if isLastPlatform == false {
84 | messages.append(
85 | .init(
86 | text: " | ",
87 | hasLineBreakAfter: false
88 | )
89 | )
90 | }
91 |
92 | return messages
93 | }
94 | .reduce(
95 | [],
96 | +
97 | )
98 | }
99 | }
100 | }
101 |
102 | extension Array where Element == PackageWrapper.Platform {
103 | var iOSVersion: String? {
104 | first(where: \.platformName == "ios")?.version
105 | }
106 |
107 | var macOSVersion: String? {
108 | first(where: \.platformName == "macos")?.version
109 | }
110 |
111 | var tvOSVersion: String? {
112 | first(where: \.platformName == "tvos")?.version
113 | }
114 |
115 | var watchOSVersion: String? {
116 | first(where: \.platformName == "watchos")?.version
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/Sources/App/Services/SwiftPackageValidator.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public import Foundation
22 | internal import PackageModel
23 |
24 | public import Core
25 |
26 | public enum SwiftPackageValidationError: Error, Equatable {
27 | case invalidURL
28 | case failedToLoadPackage
29 | case noProductFound(packageURL: URL)
30 | }
31 |
32 | public protocol SwiftPackageValidating {
33 | func validate(
34 | packageDefinition: inout PackageDefinition,
35 | isVerbose: Bool
36 | ) async throws(SwiftPackageValidationError) -> PackageWrapper
37 | }
38 |
39 | /// Uses the `SPM` library to load the package from its local or remote source, and then validates and adjusts
40 | /// its properties.
41 | ///
42 | /// Depending on the result, it can mutate the ``SwiftPackage`` with:
43 | /// - a valid first `Product`, if no product is passed or invalid
44 | /// - the latest `tag` as `resolution`, in case the passed tag is invalid
45 | public struct SwiftPackageValidator: SwiftPackageValidating {
46 | private let swiftPackageService: SwiftPackageService
47 | private let console: Console?
48 |
49 | public init(console: Console? = nil) {
50 | self.init(
51 | swiftPackageService: .init(),
52 | console: console
53 | )
54 | }
55 |
56 | init(
57 | swiftPackageService: SwiftPackageService = .init(),
58 | console: Console? = nil
59 | ) {
60 | self.swiftPackageService = swiftPackageService
61 | self.console = console
62 | }
63 |
64 | public func validate(
65 | packageDefinition: inout PackageDefinition,
66 | isVerbose: Bool = false
67 | ) async throws(SwiftPackageValidationError) -> PackageWrapper {
68 | let packageResponse: SwiftPackageValidationResult
69 | do {
70 | packageResponse = try await swiftPackageService.validate(
71 | swiftPackage: packageDefinition,
72 | verbose: isVerbose
73 | )
74 | } catch {
75 | throw .failedToLoadPackage
76 | }
77 |
78 | switch packageResponse.sourceInformation {
79 | case let .remote(isRepositoryValid, tagState, latestTag):
80 | guard isRepositoryValid else {
81 | throw SwiftPackageValidationError.invalidURL
82 | }
83 |
84 | switch packageDefinition.source.remoteResolution {
85 | case let .revision(revision):
86 | await console?.lineBreakAndWrite("Resolved revision: \(revision)")
87 | case .version:
88 | switch tagState {
89 | case .undefined, .invalid:
90 | await console?.lineBreakAndWrite("Package version was \(tagState.description)")
91 |
92 | if let latestTag {
93 | await console?.lineBreakAndWrite("Defaulting to latest found semver tag: \(latestTag)")
94 | packageDefinition.source = .remote(
95 | url: packageDefinition.url,
96 | resolution: .version(latestTag)
97 | )
98 | }
99 | case .valid:
100 | break
101 | }
102 | case .none:
103 | break
104 | }
105 | case .local:
106 | break
107 | }
108 |
109 | guard let firstProduct = packageResponse.availableProducts.first else {
110 | throw .noProductFound(packageURL: packageDefinition.url)
111 | }
112 |
113 | if packageResponse.isProductValid == false {
114 | await console?.lineBreakAndWrite("Invalid product: \(packageDefinition.product)")
115 | await console?.lineBreakAndWrite("Using first found product instead: \(packageDefinition)")
116 |
117 | packageDefinition.product = firstProduct
118 | }
119 |
120 | return packageResponse.package
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/Sources/Core/Console.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Console.swift
3 | //
4 | // Acknowledgement: This piece of code is inspired by Raycast's script-commands repository:
5 | // https://github.com/raycast/script-commands/blob/master/Tools/Toolkit/Sources/ToolkitLibrary/Core/Console.swift
6 | //
7 | // Created by Marino Felipe on 28.12.20.
8 | //
9 |
10 | @preconcurrency import TSCBasic
11 | import TSCUtility
12 |
13 | // MARK: - ConsoleColor - Wrapper
14 |
15 | public enum ConsoleColor: Sendable {
16 | case noColor
17 | case red
18 | case green
19 | case yellow
20 | case cyan
21 | case white
22 | case black
23 | case gray
24 | }
25 |
26 | private extension ConsoleColor {
27 | var terminalColor: TerminalController.Color {
28 | switch self {
29 | case .black: return .black
30 | case .cyan: return .cyan
31 | case .green: return .green
32 | case .gray: return .gray
33 | case .noColor: return .noColor
34 | case .red: return .red
35 | case .white: return .white
36 | case .yellow: return .yellow
37 | }
38 | }
39 | }
40 |
41 | // MARK: - TerminalControlling
42 |
43 | public protocol TerminalControlling {
44 | func endLine()
45 | func write(
46 | _ string: String,
47 | inColor _: ConsoleColor,
48 | bold: Bool
49 | )
50 | }
51 |
52 | extension TerminalController: TerminalControlling {
53 | public func write(
54 | _ string: String,
55 | inColor color: ConsoleColor,
56 | bold: Bool
57 | ) {
58 | write(
59 | string,
60 | inColor: color.terminalColor,
61 | bold: bold
62 | )
63 | }
64 | }
65 |
66 | final class PrintController: TerminalControlling {
67 | func endLine() {
68 | print()
69 | }
70 |
71 | func write(_ string: String, inColor _: ConsoleColor, bold: Bool) {
72 | print(string)
73 | }
74 |
75 | }
76 |
77 | // MARK: - ConsoleMessage
78 |
79 | public struct ConsoleMessage: Equatable, ExpressibleByStringLiteral, ExpressibleByStringInterpolation, Sendable {
80 | public let text: String
81 | let color: ConsoleColor
82 | let isBold: Bool
83 | let hasLineBreakAfter: Bool
84 |
85 | public init(
86 | text: String,
87 | color: ConsoleColor,
88 | isBold: Bool = false,
89 | hasLineBreakAfter: Bool = true
90 | ) {
91 | self.text = text
92 | self.color = color
93 | self.isBold = isBold
94 | self.hasLineBreakAfter = hasLineBreakAfter
95 | }
96 |
97 | public init(
98 | text: String,
99 | isBold: Bool = false,
100 | hasLineBreakAfter: Bool = true
101 | ) {
102 | self.text = text
103 | self.color = .noColor
104 | self.isBold = isBold
105 | self.hasLineBreakAfter = hasLineBreakAfter
106 | }
107 |
108 | public init(stringLiteral value: String) {
109 | self.init(text: value, color: .noColor, isBold: false, hasLineBreakAfter: true)
110 | }
111 | }
112 |
113 | // MARK: - CustomConsoleMessageConvertible
114 |
115 | public protocol CustomConsoleMessageConvertible {
116 | /// A console message representation.
117 | var message: ConsoleMessage { get }
118 | }
119 |
120 | public protocol CustomConsoleMessagesConvertible: Sendable {
121 | /// A console message representation that is split into and composed by multiple messages.
122 | var messages: [ConsoleMessage] { get }
123 | }
124 |
125 | // MARK: - Console
126 |
127 | @MainActor
128 | public final class Console {
129 | private var isOutputColored: Bool
130 | private let terminalController: TerminalControlling
131 | private let progressAnimation: ProgressAnimationProtocol
132 |
133 | public init(
134 | isOutputColored: Bool,
135 | terminalController: TerminalControlling = TerminalControllerFactory.make(),
136 | progressAnimation: ProgressAnimationProtocol = NinjaProgressAnimation(stream: stdoutStream)
137 | ) {
138 | self.isOutputColored = isOutputColored
139 | self.terminalController = terminalController
140 | self.progressAnimation = progressAnimation
141 | }
142 |
143 | public func write(_ message: ConsoleMessage) {
144 | write(
145 | message: message.text,
146 | color: isOutputColored ? message.color : .noColor,
147 | bold: message.isBold,
148 | addLineBreakAfter: message.hasLineBreakAfter
149 | )
150 | }
151 |
152 | public func lineBreakAndWrite(_ message: ConsoleMessage) {
153 | lineBreak()
154 | write(
155 | message: message.text,
156 | color: isOutputColored ? message.color : .noColor,
157 | bold: message.isBold,
158 | addLineBreakAfter: message.hasLineBreakAfter
159 | )
160 | }
161 |
162 | public func lineBreak() {
163 | terminalController.endLine()
164 | }
165 | }
166 |
167 | public final class TerminalControllerFactory {
168 | public static func make(stream: WritableByteStream = stdoutStream) -> TerminalControlling {
169 | if let controller = TerminalController(stream: stream) {
170 | return controller
171 | } else {
172 | return PrintController()
173 | }
174 | }
175 | }
176 |
177 | // MARK: - Loading
178 |
179 | public extension Console {
180 | func showLoading(
181 | step: Int ,
182 | total: Int = 10,
183 | text: String
184 | ) {
185 | progressAnimation.update(
186 | step: step,
187 | total: total,
188 | text: text
189 | )
190 | }
191 |
192 | func completeLoading(success: Bool) {
193 | progressAnimation.complete(success: success)
194 | }
195 | }
196 |
197 | // MARK: - Private
198 |
199 | private extension Console {
200 | func write(
201 | message: String,
202 | color: ConsoleColor,
203 | bold: Bool,
204 | addLineBreakAfter: Bool
205 | ) {
206 | terminalController.write(message, inColor: color, bold: bold)
207 | if addLineBreakAfter { self.lineBreak() }
208 | }
209 | }
210 |
211 | #if DEBUG
212 | extension Console {
213 | public static var `default` = Console(isOutputColored: false)
214 | }
215 | #else
216 | extension Console {
217 | public static let `default` = Console(isOutputColored: true)
218 | }
219 | #endif
220 |
--------------------------------------------------------------------------------
/Sources/Core/Extensions/AbsolutePath+Core.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2024 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public import Basics
22 | internal import Foundation
23 |
24 | public extension AbsolutePath {
25 | /// The path to the program’s current directory.
26 | static let currentDir: Self = {
27 | guard let currentDir = localFileSystem.currentWorkingDirectory else {
28 | do {
29 | return try AbsolutePath(validating: currentDirPath)
30 | } catch {
31 | preconditionFailure(
32 | "Unable to make AbsolutePath from currentDir, error: \(error.localizedDescription)"
33 | )
34 | }
35 | }
36 |
37 | return currentDir
38 | }()
39 | }
40 |
41 | #if DEBUG
42 | //debug only
43 | nonisolated(unsafe) var currentDirPath = FileManager.default.currentDirectoryPath
44 | #else
45 | let currentDirPath = FileManager.default.currentDirectoryPath
46 | #endif
47 |
--------------------------------------------------------------------------------
/Sources/Core/Extensions/Array+Core.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | internal import Foundation
22 |
23 | public extension Array {
24 | subscript(safeIndex index: Int) -> Element? {
25 | guard index >= 0, index < endIndex else { return nil }
26 |
27 | return self[index]
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/Core/Extensions/JSONEncoder+Core.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public import Foundation
22 |
23 | public extension JSONEncoder {
24 | static let sortedAndPrettyPrinted: JSONEncoder = {
25 | let encoder = JSONEncoder()
26 | encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
27 | return encoder
28 | }()
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/Core/Extensions/KeyPath+Core.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public func ==(lhs: KeyPath, rhs: V) -> (T) -> Bool {
22 | return { $0[keyPath: lhs] == rhs }
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/Core/Extensions/Result+Core.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public extension Result {
22 | var value: Success? {
23 | guard case let .success(value) = self else { return nil }
24 | return value
25 | }
26 |
27 | var error: Failure? {
28 | guard case let .failure(error) = self else { return nil }
29 | return error
30 | }
31 |
32 | @discardableResult
33 | func onSuccess(_ action: (Success) -> Void) -> Result {
34 | guard case let .success(value) = self else { return self }
35 | action(value)
36 | return self
37 | }
38 |
39 | @discardableResult
40 | func onFailure(_ action: (Failure) -> Void) -> Result {
41 | guard case let .failure(error) = self else { return self }
42 | action(error)
43 | return self
44 | }
45 |
46 | @discardableResult
47 | func onSuccess(_ action: (Success) throws -> Void) rethrows -> Result {
48 | guard case let .success(value) = self else { return self }
49 | try action(value)
50 | return self
51 | }
52 |
53 | @discardableResult
54 | func onFailure(_ action: (Failure) throws -> Void) rethrows -> Result {
55 | guard case let .failure(error) = self else { return self }
56 | try action(error)
57 | return self
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Sources/Core/Extensions/URL+Core.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URL+Core.swift
3 | //
4 | // Acknowledgement: This piece of code is inspired by StackOverflow post's top-voted answer on how to get directory size on OS X:
5 | // https://stackoverflow.com/questions/32814535/how-to-get-directory-size-with-swift-on-os-x
6 | //
7 | // Created by Marino Felipe on 28.12.20.
8 | //
9 |
10 | public import Foundation
11 |
12 | // MARK: - Size on disk
13 |
14 | extension URL {
15 | // MARK: Public
16 |
17 | nonisolated(unsafe) public static let fileByteCountFormatter: ByteCountFormatter = {
18 | let byteCountFormatter = ByteCountFormatter()
19 | byteCountFormatter.countStyle = .file
20 | return byteCountFormatter
21 | }()
22 |
23 | @MainActor
24 | public func sizeOnDisk() throws -> SizeOnDisk {
25 | let size = try isDirectory()
26 | ? try directoryTotalAllocatedSize(includingSubfolders: true)
27 | : try totalFileAllocatedSize()
28 |
29 | guard let formattedByteCount = URL.fileByteCountFormatter.string(for: size) else {
30 | throw URLSizeReadingError.unableToCountCountBytes(filePath: self.path)
31 | }
32 | return .init(amount: size, formatted: formattedByteCount)
33 | }
34 |
35 | // MARK: Internal
36 |
37 | func totalFileAllocatedSize() throws -> Int {
38 | try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize ?? 0
39 | }
40 |
41 | func directoryTotalAllocatedSize(
42 | fileManager: FileManager = .default,
43 | includingSubfolders: Bool = false
44 | ) throws -> Int {
45 | let allocatedSizeWithoutSubfolders = {
46 | return try fileManager.contentsOfDirectory(at: self, includingPropertiesForKeys: nil).totalAllocatedSize()
47 | }
48 |
49 | if includingSubfolders {
50 | guard let urls = fileManager.enumerator(at: self, includingPropertiesForKeys: nil)?.allObjects as? [URL] else {
51 | return try allocatedSizeWithoutSubfolders()
52 | }
53 |
54 | return try urls.totalAllocatedSize()
55 | }
56 |
57 | return try allocatedSizeWithoutSubfolders()
58 | }
59 |
60 | func isDirectory() throws -> Bool {
61 | try resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true
62 | }
63 | }
64 |
65 | enum URLSizeReadingError: LocalizedError {
66 | case unableToCountCountBytes(filePath: String)
67 |
68 | var errorDescription: String? {
69 | switch self {
70 | case let .unableToCountCountBytes(filePath):
71 | return "Unable to count bytes for file at: \(filePath)"
72 | }
73 | }
74 | }
75 |
76 | extension Array where Element == URL {
77 | func totalAllocatedSize() throws -> Int {
78 | try lazy.reduce(0) { try $1.totalFileAllocatedSize() + $0 }
79 | }
80 | }
81 |
82 | // MARK: - Extension - Local & remote
83 |
84 | public extension URL {
85 | static let isValidURLRegex = "^(https?://)?(www\\.)?([-a-z0-9]{1,63}\\.)*?[a-z0-9][-a-z0-9]{0,61}[a-z0-9]\\.[a-z]{2,6}(/[-\\w@\\+\\.~#\\?&/=%]*)?$"
86 |
87 | var isValidRemote: Bool {
88 | NSPredicate(format:"SELF MATCHES %@", Self.isValidURLRegex)
89 | .evaluate(with: absoluteString)
90 | }
91 |
92 | func isLocalDirectoryContainingPackageDotSwift(
93 | fileManager: FileManager = .default
94 | ) throws -> Bool {
95 | try fileManager.contentsOfDirectory(atPath: path).contains("Package.swift")
96 | }
97 |
98 | func isLocalXCConfigFileValid(
99 | fileManager: FileManager = .default
100 | ) -> Bool {
101 | fileManager.isReadableFile(atPath: path) && self.pathExtension == "xcconfig"
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/Sources/Core/InfoProvider.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public import Foundation
22 |
23 | public struct InfoProviderError: LocalizedError, Equatable, CustomConsoleMessageConvertible {
24 | public let message: ConsoleMessage
25 |
26 | public private(set) var errorDescription: String?
27 |
28 | public init(
29 | localizedError: LocalizedError,
30 | customConsoleMessage: ConsoleMessage? = nil
31 | ) {
32 | self.errorDescription = localizedError.errorDescription
33 | self.message = customConsoleMessage ?? .init(
34 | text: localizedError.errorDescription ?? "",
35 | color: .red,
36 | isBold: true,
37 | hasLineBreakAfter: true
38 | )
39 | }
40 | }
41 |
42 | public enum ProviderKind: String, CodingKey {
43 | case binarySize
44 | case dependencies
45 | case platforms
46 | }
47 |
48 | public typealias InfoProvider = @Sendable (
49 | _ packageDefinition: PackageDefinition,
50 | _ resolvedPackage: PackageWrapper,
51 | _ xcconfig: URL?,
52 | _ verbose: Bool
53 | ) async throws -> ProvidedInfo
54 | //throws(InfoProviderError) , typed throws only supported from macOS 15.0 runtime
55 |
56 | public struct ProvidedInfo: Encodable, CustomConsoleMessagesConvertible, Sendable {
57 | public let providerName: String
58 | public let providerKind: ProviderKind
59 | public var messages: [ConsoleMessage] {
60 | informationMessagesConvertible.messages
61 | }
62 |
63 | private let informationEncoder: @Sendable (Encoder) throws -> Void
64 | private let informationMessagesConvertible: CustomConsoleMessagesConvertible
65 |
66 | public init(
67 | providerName: String,
68 | providerKind: ProviderKind,
69 | information: T
70 | ) where T: Encodable, T: CustomConsoleMessagesConvertible {
71 | self.providerName = providerName
72 | self.providerKind = providerKind
73 | self.informationMessagesConvertible = information
74 | self.informationEncoder = { encoder in
75 | var container = encoder.singleValueContainer()
76 | try container.encode(information)
77 | }
78 | }
79 |
80 | public func encode(to encoder: Encoder) throws {
81 | try informationEncoder(encoder)
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/Sources/Core/Models/PackageDefinition.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public import struct Foundation.URL
22 | public import Basics
23 |
24 | /// Defines a Swift Package product.
25 | /// The initial input needed to resolve the package graph and provide the required information.
26 | @dynamicMemberLookup
27 | public struct PackageDefinition: Equatable, CustomStringConvertible, Sendable {
28 | /// The remote repository resolution, either a git tag or revision.
29 | public enum RemoteResolution: Equatable, CustomStringConvertible, Sendable {
30 | /// Semantic version of the Swift Package. If not valid, the latest semver tag is used
31 | case version(String)
32 | /// A single git commit, SHA-1 hash, or branch name
33 | case revision(String)
34 |
35 | public var description: String {
36 | switch self {
37 | case let .revision(revision):
38 | return "Revision: \(revision)"
39 | case let .version(tag):
40 | return "Version: \(tag)"
41 | }
42 | }
43 |
44 | var version: String? {
45 | switch self {
46 | case .revision: nil
47 | case let .version(tag): tag
48 | }
49 | }
50 |
51 | var revision: String? {
52 | switch self {
53 | case let .revision(revision): revision
54 | case .version: nil
55 | }
56 | }
57 | }
58 |
59 | /// The source reference for the Package repository.
60 | public enum Source: Equatable, Sendable, CustomStringConvertible {
61 | /// A relative local directory path that contains a `Package.swift`. **Full paths not supported**.
62 | case local(AbsolutePath)
63 | /// A valid git repository URL that contains a `Package.swift` and it's resolution method, either the git version or revision.
64 | case remote(url: URL, resolution: RemoteResolution)
65 |
66 | public var description: String {
67 | switch self {
68 | case let .local(path):
69 | "Local path: \(path)"
70 | case let .remote(url, resolution):
71 | """
72 | Repository URL: \(url)
73 | \(resolution.description)
74 | """
75 | }
76 | }
77 |
78 | public var remoteResolution: RemoteResolution? {
79 | switch self {
80 | case .local: nil
81 | case let .remote(_, resolution): resolution
82 | }
83 | }
84 |
85 | public var url: URL {
86 | switch self {
87 | case let .local(path): path.asURL
88 | case let .remote(remoteURL, _): remoteURL
89 | }
90 | }
91 |
92 | public var version: String? {
93 | switch self {
94 | case .local: nil
95 | case let .remote(_, resolution): resolution.version
96 | }
97 | }
98 |
99 | public var revision: String? {
100 | switch self {
101 | case .local: nil
102 | case let .remote(_, resolution): resolution.revision
103 | }
104 | }
105 | }
106 |
107 | /// A ``PackageDefinition`` initialization error.
108 | public enum Error: Swift.Error {
109 | case invalidURL
110 | }
111 |
112 | public var source: Source
113 | public var product: String
114 |
115 | /// Initializes a ``PackageDefinition``
116 | /// - Parameters:
117 | /// - source: The source reference for the Package repository.
118 | /// - product: Name of the product to be checked. If not passed in the first available product is used.
119 | public init(
120 | source: Source,
121 | product: String?
122 | ) throws(PackageDefinition.Error) {
123 | switch source {
124 | case let .local(absolutePath):
125 | let isValidLocalDirectory = try? absolutePath.asURL.isLocalDirectoryContainingPackageDotSwift()
126 | guard isValidLocalDirectory ?? false else {
127 | throw Error.invalidURL
128 | }
129 | case let .remote(url, _):
130 | guard url.isValidRemote else {
131 | throw Error.invalidURL
132 | }
133 | }
134 |
135 | self.source = source
136 | // N.B. Set as `undefined` which is used later on for replacing it for a valid Product
137 | self.product = product ?? ResourceState.undefined.description
138 | }
139 |
140 | /// Initializes a ``PackageDefinition`` from CLI arguments
141 | /// - Parameters:
142 | /// - url: Either a valid git repository URL or a relative local directory path that contains a `Package.swift`. For local packages **full paths are discouraged and unsupported**.
143 | /// - version: Semantic version of the Swift Package. If not passed and `revision` is not set, the latest semver tag is used.
144 | /// - revision: A single git commit, SHA-1 hash, or branch name. Applied when `packageVersion` is not set.
145 | /// - product: Name of the product to be checked. If not passed in the first available product is used.
146 | @_disfavoredOverload
147 | public init(
148 | url: URL,
149 | version: String?,
150 | revision: String?,
151 | product: String?
152 | ) throws(PackageDefinition.Error) {
153 | let isValidRemoteURL = url.isValidRemote
154 | let isValidLocalDirectory = (try? url.isLocalDirectoryContainingPackageDotSwift()) ?? false
155 |
156 | guard isValidRemoteURL || isValidLocalDirectory else {
157 | throw Error.invalidURL
158 | }
159 |
160 | if isValidLocalDirectory {
161 | let path: AbsolutePath
162 | do {
163 | path = try AbsolutePath(
164 | validating: url.absoluteString,
165 | relativeTo: .currentDir
166 | )
167 | } catch {
168 | throw PackageDefinition.Error.invalidURL
169 | }
170 | self.source = .local(path)
171 | } else {
172 | let resolvedVersion = version ?? ResourceState.undefined.description
173 | if let revision = revision, resolvedVersion == ResourceState.undefined.description {
174 | self.source = .remote(url: url, resolution: .revision(revision))
175 | } else {
176 | self.source = .remote(url: url, resolution: .version(resolvedVersion))
177 | }
178 | }
179 |
180 | // N.B. Set as `undefined` which is used later on for replacing it for a valid Product
181 | self.product = product ?? ResourceState.undefined.description
182 | }
183 |
184 | public subscript(dynamicMember keyPath: KeyPath) -> T {
185 | source[keyPath: keyPath]
186 | }
187 |
188 | public var description: String {
189 | """
190 | \(source.description)
191 | Product: \(product)
192 | """
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/Sources/Core/Models/PackageModel.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2024 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import PackageModel
22 |
23 | /// A wrapper over SwiftPM library Package type.
24 | /// `Equatable` and `easily testable`
25 | public struct PackageWrapper: Equatable, Sendable {
26 | public struct Product: Equatable, Sendable {
27 | public let name: String
28 | public let package: String?
29 | public let isDynamicLibrary: Bool?
30 | public let targets: [Target]
31 | }
32 |
33 | public struct Target: Equatable, Sendable {
34 | public enum Dependency: Equatable, Sendable {
35 | case target(Target)
36 | case product(Product)
37 |
38 | public var target: Target? {
39 | switch self {
40 | case let .target(target):
41 | return target
42 | case .product:
43 | return nil
44 | }
45 | }
46 |
47 | public var product: Product? {
48 | switch self {
49 | case .target:
50 | return nil
51 | case let .product(product):
52 | return product
53 | }
54 | }
55 | }
56 |
57 | public let name: String
58 | public let dependencies: [Dependency]
59 | }
60 |
61 | public struct Platform: Equatable, Sendable {
62 | public let platformName: String
63 | public let version: String
64 | }
65 |
66 | public let products: [Product]
67 | public let platforms: [Platform]
68 | public let targets: [Target]
69 | }
70 |
71 | extension PackageWrapper {
72 | public init(from package: Package) {
73 | products = package.products.map(Product.init(from:))
74 | platforms = package.manifest.platforms.map(Platform.init(from:))
75 | targets = package.modules.map(Target.init(from:))
76 | }
77 | }
78 |
79 | // MARK: - Mappers
80 |
81 | extension PackageWrapper.Target {
82 | init(from module: PackageModel.Module) {
83 | name = module.name
84 | dependencies = module.dependencies.map(Dependency.init(from:))
85 | }
86 | }
87 |
88 | extension PackageWrapper.Target.Dependency {
89 | init(from dependency: PackageModel.Module.Dependency) {
90 | switch dependency {
91 | case let .module(target, _):
92 | self = .target(PackageWrapper.Target(from: target))
93 | case let .product(product, _):
94 | self = .product(PackageWrapper.Product(from: product))
95 | }
96 | }
97 | }
98 |
99 | extension PackageWrapper.Product {
100 | init(from product: PackageModel.Product) {
101 | name = product.name
102 | package = nil
103 | isDynamicLibrary = product.isDynamicLibrary
104 | targets = product.modules.map(PackageWrapper.Target.init(from:))
105 | }
106 | }
107 |
108 | extension PackageWrapper.Product {
109 | init(from product: PackageModel.Module.ProductReference) {
110 | name = product.name
111 | package = product.package
112 | isDynamicLibrary = nil
113 | targets = []
114 | }
115 | }
116 |
117 | extension PackageWrapper.Platform {
118 | init(from platformDescription: PackageModel.PlatformDescription) {
119 | platformName = platformDescription.platformName
120 | version = platformDescription.version
121 | }
122 | }
123 |
124 | // MARK: - PackageModel Extensions
125 |
126 | private extension Product {
127 | var isDynamicLibrary: Bool {
128 | switch type {
129 | case .library(.dynamic):
130 | return true
131 | default:
132 | return false
133 | }
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/Sources/Core/Models/ResurceState.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public enum ResourceState: Equatable, CustomStringConvertible, Sendable {
22 | case undefined
23 | case valid
24 | case invalid
25 |
26 | public var description: String {
27 | switch self {
28 | case .undefined:
29 | return "undefined"
30 | case .valid:
31 | return "valid"
32 | case .invalid:
33 | return "invalid"
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Sources/Core/Models/SizeOnDisk.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | internal import Foundation
22 |
23 | public struct SizeOnDisk: Equatable, Sendable {
24 | /// Literal size quantity, in `Kilobytes`
25 | public let amount: Int
26 | public let formatted: String
27 |
28 | public init(
29 | amount: Int,
30 | formatted: String
31 | ) {
32 | self.amount = amount
33 | self.formatted = formatted
34 | }
35 |
36 | public static let empty: SizeOnDisk = .init(
37 | amount: 0,
38 | formatted: "0.0"
39 | )
40 | }
41 |
42 | extension SizeOnDisk: CustomStringConvertible {
43 | public var description: String {
44 | "Size on disk: \(formatted)"
45 | }
46 | }
47 |
48 | extension SizeOnDisk: CustomConsoleMessageConvertible {
49 | public var message: ConsoleMessage {
50 | .init(
51 | text: description,
52 | color: .yellow,
53 | isBold: false
54 | )
55 | }
56 | }
57 |
58 | extension SizeOnDisk: AdditiveArithmetic {
59 | public static let zero: SizeOnDisk = .empty
60 |
61 | public static func - (lhs: SizeOnDisk, rhs: SizeOnDisk) -> SizeOnDisk {
62 | let finalAmount = lhs.amount - rhs.amount
63 |
64 | return .init(
65 | amount: finalAmount,
66 | formatted: URL.fileByteCountFormatter.string(
67 | for: finalAmount
68 | ) ?? ""
69 | )
70 | }
71 |
72 | public static func + (lhs: SizeOnDisk, rhs: SizeOnDisk) -> SizeOnDisk {
73 | let finalAmount = lhs.amount + rhs.amount
74 |
75 | return .init(
76 | amount: finalAmount,
77 | formatted: URL.fileByteCountFormatter.string(
78 | for: finalAmount
79 | ) ?? ""
80 | )
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/Sources/Core/PackageLoader.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2024 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public import Basics
22 | public import PackageModel
23 | internal import Workspace
24 |
25 | /// Loads the content of a Package.swift, the dependency graph included
26 | ///
27 | /// The ``PackageLoader`` uses the SPM library to load the package representation
28 | public struct PackageLoader: Sendable {
29 | /// Loads a Package.swift at a given `packagePath`
30 | public var load: @Sendable (AbsolutePath) async throws -> Package
31 | }
32 |
33 | extension PackageLoader {
34 | /// Makes a **Live** ``PackageLoader`` instance
35 | public static let live: Self = {
36 | .init(
37 | load: { packagePath in
38 | let observability = ObservabilitySystem { print("\($0): \($1)") }
39 |
40 | let workspace = try Workspace(forRootPackage: packagePath)
41 |
42 | return try await workspace.loadRootPackage(
43 | at: packagePath,
44 | observabilityScope: observability.topScope
45 | )
46 | }
47 | )
48 | }()
49 | }
50 |
--------------------------------------------------------------------------------
/Sources/Core/Shell.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public import Foundation
22 |
23 | public enum Shell {
24 | public struct Output: Equatable {
25 | public let succeeded: Bool
26 | public let data: Data
27 | public let errorData: Data
28 | }
29 |
30 | @discardableResult
31 | public static func run(
32 | workingDirectory: String? = FileManager.default.currentDirectoryPath,
33 | outputPipe: Pipe = .init(),
34 | errorPipe: Pipe = .init(),
35 | arguments: String...,
36 | verbose: Bool,
37 | timeout: TimeInterval? = 30
38 | ) async throws -> Output {
39 | try await runProcess(
40 | workingDirectory: workingDirectory,
41 | outputPipe: outputPipe,
42 | errorPipe: errorPipe,
43 | arguments: arguments,
44 | verbose: verbose,
45 | timeout: timeout
46 | )
47 | }
48 |
49 | @discardableResult
50 | public static func run(
51 | _ command: String,
52 | outputPipe: Pipe = .init(),
53 | errorPipe: Pipe = .init(),
54 | workingDirectory: String? = FileManager.default.currentDirectoryPath,
55 | verbose: Bool,
56 | timeout: TimeInterval? = 30
57 | ) async throws -> Output {
58 | let commands = command.split(whereSeparator: \.isWhitespace)
59 |
60 | let arguments: [String]
61 | if commands.count > 1 {
62 | arguments = commands.map { String($0) }
63 | } else {
64 | arguments = command
65 | .split { [" -", " --"].contains(String($0)) }
66 | .map { String($0) }
67 | }
68 |
69 | return try await runProcess(
70 | workingDirectory: workingDirectory,
71 | outputPipe: outputPipe,
72 | errorPipe: errorPipe,
73 | arguments: arguments,
74 | verbose: verbose,
75 | timeout: timeout
76 | )
77 | }
78 | }
79 |
80 | public extension Shell {
81 | @discardableResult
82 | static func performShallowGitClone(
83 | workingDirectory: String? = FileManager.default.currentDirectoryPath,
84 | repositoryURLString: String,
85 | branchOrTag: String,
86 | verbose: Bool,
87 | timeout: TimeInterval? = 15
88 | ) async throws -> Output {
89 | try await Shell.run(
90 | "git clone --branch \(branchOrTag) --depth 1 \(repositoryURLString)",
91 | workingDirectory: workingDirectory,
92 | verbose: verbose,
93 | timeout: timeout
94 | )
95 | }
96 | }
97 |
98 | // MARK: - Private
99 |
100 | private extension Shell {
101 | static func runProcess(
102 | workingDirectory: String?,
103 | outputPipe: Pipe,
104 | errorPipe: Pipe,
105 | arguments: [String],
106 | verbose: Bool,
107 | timeout: TimeInterval?
108 | ) async throws -> Output {
109 | let process = Process.make(
110 | workingDirectory: workingDirectory,
111 | outputPipe: outputPipe,
112 | errorPipe: errorPipe,
113 | arguments: arguments,
114 | verbose: verbose
115 | )
116 |
117 | if verbose {
118 | await Console.default.lineBreakAndWrite(
119 | .init(
120 | text: "Running Shell command",
121 | color: .yellow
122 | )
123 | )
124 | }
125 |
126 | var outputData = Data()
127 | var errorData = Data()
128 |
129 | try process.run()
130 |
131 | await withTaskGroup(of: Void.self) { taskGroup in
132 | outputData = await readData(
133 | from: outputPipe,
134 | isError: false,
135 | verbose: verbose
136 | )
137 | errorData = await readData(
138 | from: errorPipe,
139 | isError: true,
140 | verbose: verbose
141 | )
142 | }
143 |
144 | process.waitUntilExit()
145 |
146 | if verbose {
147 | await Console.default.lineBreakAndWrite(
148 | .init(
149 | text: "Finished Shell command",
150 | color: .yellow
151 | )
152 | )
153 | }
154 |
155 | return .init(
156 | succeeded: process.terminationStatus == 0,
157 | data: outputData,
158 | errorData: errorData
159 | )
160 | }
161 |
162 | static func readData(
163 | from pipe: Pipe,
164 | isError: Bool,
165 | verbose: Bool
166 | ) async -> Data {
167 | await withCheckedContinuation { continuation in
168 | pipe.fileHandleForReading.readabilityHandler = { fileHandle in
169 | // readData(ofLength:) is used here to avoid unwanted performance side effects,
170 | // as described in the findings from:
171 | // https://stackoverflow.com/questions/49184623/nstask-race-condition-with-readabilityhandler-block
172 | let data = fileHandle.readData(ofLength: .max)
173 |
174 | let cleanAndResume = {
175 | pipe.fileHandleForReading.readabilityHandler = nil
176 | continuation.resume(returning: data)
177 | }
178 |
179 | if data.isEmpty == false {
180 | guard verbose else {
181 | cleanAndResume()
182 | return
183 | }
184 | String(data: data, encoding: .utf8).map {
185 | let text = $0
186 | if isError {
187 | Task { @MainActor in
188 | Console.default.lineBreakAndWrite(
189 | .init(
190 | text: text,
191 | color: .red
192 | )
193 | )
194 | }
195 | } else {
196 | Task { @MainActor in
197 | Console.default.write(
198 | .init(
199 | text: text,
200 | hasLineBreakAfter: false
201 | )
202 | )
203 | }
204 | }
205 | }
206 | cleanAndResume()
207 | } else {
208 | cleanAndResume()
209 | }
210 | }
211 | }
212 | }
213 | }
214 |
215 | extension Process {
216 | static func make(
217 | launchPath: String = "/usr/bin/env",
218 | workingDirectory: String? = FileManager.default.currentDirectoryPath,
219 | outputPipe: Pipe,
220 | errorPipe: Pipe,
221 | arguments: [String],
222 | verbose: Bool
223 | ) -> Process {
224 | let process = Process()
225 | process.launchPath = launchPath
226 | process.arguments = arguments
227 | process.standardError = errorPipe
228 | process.standardOutput = outputPipe
229 | process.qualityOfService = .userInteractive
230 | if let workingDirectory = workingDirectory {
231 | process.currentDirectoryPath = workingDirectory
232 | }
233 | return process
234 | }
235 | }
236 |
237 | struct SendableData: Equatable, Sendable {
238 | private(set) var _data: Data = .init()
239 | var data: Data {
240 | get {
241 | _data
242 | }
243 | set {
244 | NSLock().withLock {
245 | self._data.append(newValue)
246 | }
247 | }
248 | }
249 | }
250 |
--------------------------------------------------------------------------------
/Sources/CoreTestSupport/Doubles/Fixtures/Fixture+ProvidedInfo.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import Core
22 |
23 | public extension Fixture {
24 | struct ProvidedInformation: Encodable, CustomConsoleMessagesConvertible {
25 | let name: String
26 | let value: Int
27 |
28 | public var messages: [ConsoleMessage] {
29 | [
30 | .init(
31 | text: name,
32 | color: .green,
33 | isBold: true,
34 | hasLineBreakAfter: false
35 | ),
36 | .init(
37 | text: "Value is \(value)",
38 | color: .noColor,
39 | isBold: false,
40 | hasLineBreakAfter: true
41 | ),
42 | ]
43 | }
44 | }
45 |
46 | static func makeProvidedInfoInformation(
47 | name: String = "name",
48 | value: Int = 10
49 | ) -> ProvidedInformation {
50 | .init(
51 | name: name,
52 | value: value
53 | )
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Sources/CoreTestSupport/Doubles/Fixtures/Fixture+SwiftPackage.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import Foundation
22 |
23 | import Core
24 |
25 | public extension Fixture {
26 | static func makePackageDefinition(
27 | url: URL = URL(string: "https://www.apple.com")!,
28 | version: String = "1.0.0",
29 | revision: String? = nil,
30 | product: String = "Some"
31 | ) throws -> PackageDefinition {
32 | try PackageDefinition(
33 | url: url,
34 | version: version,
35 | revision: revision,
36 | product: product
37 | )
38 | }
39 |
40 | static func makePackageDefinition(
41 | source: PackageDefinition.Source = .remote(
42 | url: URL(string: "https://www.apple.com")!,
43 | resolution: .version("1.0.0")
44 | ),
45 | product: String = "Some"
46 | ) throws -> PackageDefinition {
47 | try PackageDefinition(
48 | source: source,
49 | product: product
50 | )
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Sources/CoreTestSupport/Doubles/Fixtures/Fixture.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public enum Fixture {}
22 |
--------------------------------------------------------------------------------
/Sources/CoreTestSupport/Doubles/Mocks/ProgressAnimationMock.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import TSCUtility
22 |
23 | public final class ProgressAnimationMock: ProgressAnimationProtocol {
24 | public private(set) var updateCallsCount: Int = 0
25 | public private(set) var completeCallsCount: Int = 0
26 | public private(set) var clearCallsCount: Int = 0
27 | public private(set) var lastUpdateStep: Int?
28 | public private(set) var lastUpdateTotal: Int?
29 | public private(set) var lastUpdateText: String?
30 | public private(set) var lastCompleteSuccess: Bool?
31 |
32 | public init() { }
33 |
34 | public func update(
35 | step: Int,
36 | total: Int,
37 | text: String
38 | ) {
39 | updateCallsCount += 1
40 | lastUpdateStep = step
41 | lastUpdateTotal = total
42 | lastUpdateText = text
43 | }
44 |
45 | public func complete(success: Bool) {
46 | completeCallsCount += 1
47 | lastCompleteSuccess = success
48 | }
49 |
50 | public func clear() {
51 | clearCallsCount += 1
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Sources/CoreTestSupport/Doubles/Mocks/TerminalControllerMock.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import Core
22 |
23 | public final class TerminalControllerMock: TerminalControlling {
24 | public private(set) var endLineCallsCount: Int = 0
25 | public private(set) var writeCallsCount: Int = 0
26 | public private(set) var writeStrings: [String] = []
27 | public private(set) var writeColors: [ConsoleColor] = []
28 | public private(set) var writeBolds: [Bool] = []
29 |
30 | public init() { }
31 |
32 | public func endLine() {
33 | endLineCallsCount += 1
34 | }
35 |
36 | public func write(
37 | _ string: String,
38 | inColor color: ConsoleColor,
39 | bold: Bool
40 | ) {
41 | writeCallsCount += 1
42 | writeStrings.append(string)
43 | writeColors.append(color)
44 | writeBolds.append(bold)
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Sources/CoreTestSupport/Extensions/XCTest+TestSupport.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import XCTest
22 |
23 | public extension XCTestCase {
24 | func dataFromJSON(
25 | named name: String,
26 | bundle: Bundle,
27 | file: StaticString = #filePath,
28 | line: UInt = #line
29 | ) throws -> Data {
30 | let jsonData = try bundle.url(forResource: name, withExtension: "json")
31 | .flatMap { try Data(contentsOf: $0) }
32 | return try XCTUnwrap(
33 | jsonData,
34 | file: file,
35 | line: line
36 | )
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/Reports/Generators/JSONDumpReportGenerator.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import Core
22 | import Foundation
23 |
24 | struct JSONDumpReportGenerator: Sendable {
25 | private let console: Console
26 | private let encoder: JSONEncoder
27 |
28 | init(
29 | console: Console,
30 | encoder: JSONEncoder = .sortedAndPrettyPrinted
31 | ) {
32 | self.console = console
33 | self.encoder = encoder
34 | }
35 |
36 | @Sendable
37 | func renderDump(
38 | for swiftPackage: PackageDefinition,
39 | providedInfos: [ProvidedInfo]
40 | ) throws {
41 | let reportData = ReportData(
42 | providedInfos: providedInfos
43 | )
44 |
45 | let data = try encoder.encode(reportData)
46 | let stringData = String(data: data, encoding: .utf8) ?? ""
47 |
48 | Task { @MainActor in
49 | console.write(
50 | .init(text: stringData)
51 | )
52 | }
53 | }
54 | }
55 |
56 | struct ReportData: Encodable {
57 | let providedInfos: [ProvidedInfo]
58 |
59 | func encode(to encoder: Encoder) throws {
60 | var container = encoder.container(keyedBy: ProviderKind.self)
61 |
62 | if let binarySizeInfo = providedInfos.first(where: \.providerKind == .binarySize) {
63 | try container.encode(binarySizeInfo, forKey: .binarySize)
64 | }
65 | if let dependenciesInfo = providedInfos.first(where: \.providerKind == .dependencies) {
66 | try container.encode(dependenciesInfo, forKey: .dependencies)
67 | }
68 | if let platformsInfo = providedInfos.first(where: \.providerKind == .platforms) {
69 | try container.encode(platformsInfo, forKey: .platforms)
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Sources/Reports/Generators/ReportGenerating.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import Core
22 |
23 | typealias ReportGenerating = (
24 | _ swiftPackage: PackageDefinition,
25 | _ providedInfos: [ProvidedInfo]
26 | ) async throws -> Void
27 |
--------------------------------------------------------------------------------
/Sources/Reports/Report.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import TSCBasic
22 | import Core
23 |
24 | public final class Report: Reporting {
25 | let packageDefinition: PackageDefinition
26 | private let console: Console
27 |
28 | public init(
29 | packageDefinition: PackageDefinition,
30 | console: Console
31 | ) {
32 | self.packageDefinition = packageDefinition
33 | self.console = console
34 | }
35 |
36 | public func generate(
37 | for providedInfo: ProvidedInfo,
38 | format: ReportFormat
39 | ) async throws {
40 | try await generate(
41 | for: [providedInfo],
42 | format: format
43 | )
44 | }
45 |
46 | public func generate(
47 | for providedInfos: [ProvidedInfo],
48 | format: ReportFormat
49 | ) async throws {
50 | let reportGenerator = format.makeReportGenerator(console: console)
51 | try await reportGenerator(
52 | packageDefinition,
53 | providedInfos
54 | )
55 | }
56 | }
57 |
58 | // MARK: - SwiftPackage: CustomConsoleMessageConvertible
59 |
60 | extension PackageDefinition: CustomConsoleMessageConvertible {
61 | public var message: ConsoleMessage {
62 | .init(
63 | text: "\(product), \(versionOrRevision)",
64 | color: .cyan,
65 | isBold: false,
66 | hasLineBreakAfter: false
67 | )
68 | }
69 |
70 | private var versionOrRevision: String {
71 | switch source.remoteResolution {
72 | case let .revision(revision):
73 | revision
74 | case let .version(tag):
75 | tag
76 | case .none:
77 | "local package"
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/Sources/Reports/ReportCell.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import Core
22 |
23 | struct ReportCell: Equatable {
24 | let messages: [ConsoleMessage]
25 | let size: Int
26 | let textSize: Int
27 |
28 | init(messages: [ConsoleMessage], customSize: Int? = nil) {
29 | self.messages = messages
30 |
31 | let textSize = messages
32 | .map(\.text.count)
33 | .reduce(0, +)
34 | self.size = customSize ?? textSize
35 | self.textSize = textSize
36 | }
37 |
38 | static func makeColumnHeaderCell(title: String, size: Int) -> Self {
39 | .init(
40 | messages: [
41 | .init(
42 | text: title,
43 | color: .noColor,
44 | isBold: false,
45 | hasLineBreakAfter: false
46 | )
47 | ],
48 | customSize: size
49 | )
50 | }
51 |
52 | static func makeProviderTitleCell(named name: String, size: Int) -> Self {
53 | .init(
54 | messages: [
55 | .init(
56 | text: name,
57 | color: .yellow,
58 | isBold: true,
59 | hasLineBreakAfter: false
60 | )
61 | ],
62 | customSize: size
63 | )
64 | }
65 |
66 | static func makeForProvidedInfo(providedInfo: ProvidedInfo, size: Int) -> Self {
67 | .init(
68 | messages: providedInfo.messages,
69 | customSize: size
70 | )
71 | }
72 |
73 | static func makeTitleCell(text: String) -> Self {
74 | .init(
75 | messages: [
76 | .init(
77 | text: text,
78 | color: .cyan,
79 | isBold: true,
80 | hasLineBreakAfter: false
81 | )
82 | ]
83 | )
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/Sources/Reports/ReportFormat.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import Core
22 |
23 | public enum ReportFormat: String, CaseIterable, Sendable {
24 | case consoleMessage
25 | case jsonDump
26 | }
27 |
28 | extension ReportFormat {
29 | func makeReportGenerator(console: Console) -> ReportGenerating {
30 | switch self {
31 | case .consoleMessage:
32 | ConsoleReportGenerator(console: console).renderReport
33 | case .jsonDump:
34 | JSONDumpReportGenerator(console: console).renderDump
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/Reports/Reporting.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import Core
22 |
23 | protocol Reporting {
24 | var packageDefinition: PackageDefinition { get }
25 |
26 | func generate(for _: ProvidedInfo, format: ReportFormat) async throws
27 | func generate(for _: [ProvidedInfo], format: ReportFormat) async throws
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/Run/ExpressibleByArgument+Run.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | public import ArgumentParser
22 | public import Foundation
23 | public import Reports
24 | public import TSCUtility
25 |
26 | extension Foundation.URL: @retroactive ExpressibleByArgument {
27 | public init?(argument: String) {
28 | self.init(string: argument)
29 | }
30 | }
31 |
32 | extension TSCUtility.Version: @retroactive ExpressibleByArgument {
33 | public init?(argument: String) {
34 | self.init(argument)
35 | }
36 |
37 | public var defaultValueDescription: String { "1.2.12" }
38 | }
39 |
40 | extension ReportFormat: ExpressibleByArgument {
41 | public init?(argument: String) {
42 | self.init(rawValue: argument)
43 | }
44 |
45 | public var defaultValueDescription: String {
46 | ReportFormat.consoleMessage.rawValue
47 | }
48 |
49 | static public var allValueStrings: [String] {
50 | ReportFormat.allCases.map(\.rawValue)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Sources/Run/Subcommands/BinarySize.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import ArgumentParser
22 | import Core
23 | import App
24 | import Reports
25 |
26 | extension SwiftPackageInfo {
27 | public struct BinarySize: AsyncParsableCommand {
28 | static let estimatedSizeNote = """
29 | * Note: The estimated size may not reflect the exact amount since it doesn't account optimizations such as app thinning.
30 | Its methodology is inspired by [cocoapods-size](https://github.com/google/cocoapods-size),
31 | and thus works by comparing archives with no bitcode and ARM64 arch.
32 | Such a strategy has proven to be very consistent with the size added to iOS apps downloaded and installed via TestFlight.
33 | """
34 |
35 | public static let configuration = CommandConfiguration(
36 | abstract: "Estimated binary size of a Swift Package product.",
37 | discussion: """
38 | Measures the estimated binary size impact of a Swift Package product,
39 | such as "ArgumentParser" declared on `swift-argument-parser`.
40 |
41 | \(estimatedSizeNote)
42 | """,
43 | version: SwiftPackageInfo.configuration.version
44 | )
45 |
46 | @OptionGroup var allArguments: AllArguments
47 |
48 | public init() {}
49 |
50 | public func run() async throws {
51 | try runArgumentsValidation(arguments: allArguments)
52 | var packageDefinition = try makePackageDefinition(from: allArguments)
53 |
54 | Task { @MainActor in
55 | try packageDefinition.messages.forEach(Console.default.lineBreakAndWrite)
56 | }
57 |
58 | let validator = await SwiftPackageValidator(console: .default)
59 | let package: PackageWrapper
60 | do {
61 | package = try await validator.validate(packageDefinition: &packageDefinition)
62 | } catch {
63 | throw CleanExit.make(from: error)
64 | }
65 |
66 | let finalPackageDefinition = packageDefinition
67 |
68 | let providedInfo: ProvidedInfo?
69 | do {
70 | providedInfo = try await BinarySizeProvider.binarySize(
71 | for: packageDefinition,
72 | resolvedPackage: package,
73 | xcconfig: allArguments.xcconfig,
74 | verbose: allArguments.verbose
75 | )
76 | } catch {
77 | providedInfo = nil
78 | if let providerError = error as? InfoProviderError {
79 | Task { @MainActor in
80 | Console.default.write(providerError.message)
81 | }
82 | }
83 | }
84 |
85 | if let providedInfo {
86 | let report = await Report(packageDefinition: finalPackageDefinition, console: .default)
87 | try await report.generate(
88 | for: providedInfo,
89 | format: allArguments.report
90 | )
91 | }
92 | }
93 | }
94 | }
95 |
96 | extension PackageDefinition: CustomConsoleMessagesConvertible {
97 | public var messages: [ConsoleMessage] {
98 | [
99 | .init(
100 | text: "Identified Swift Package:",
101 | color: .green,
102 | isBold: true,
103 | hasLineBreakAfter: false
104 | ),
105 | .init(
106 | text: description,
107 | color: .noColor,
108 | isBold: false
109 | )
110 | ]
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/Sources/Run/Subcommands/Dependencies.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import ArgumentParser
22 | import Core
23 | import App
24 | import Reports
25 |
26 | extension SwiftPackageInfo {
27 | public struct Dependencies: AsyncParsableCommand {
28 | public static let configuration = CommandConfiguration(
29 | abstract: "List dependencies of a Package product.",
30 | discussion: """
31 | Show direct and indirect dependencies of a product, listing
32 | all dependencies that are linked to its binary.
33 | """,
34 | version: SwiftPackageInfo.configuration.version
35 | )
36 |
37 | @OptionGroup var allArguments: AllArguments
38 |
39 | public init() {}
40 |
41 | public func run() async throws {
42 | try runArgumentsValidation(arguments: allArguments)
43 |
44 | var packageDefinition = try makePackageDefinition(from: allArguments)
45 | Task { @MainActor in
46 | try packageDefinition.messages.forEach(Console.default.lineBreakAndWrite)
47 | }
48 |
49 | let validator = await SwiftPackageValidator(console: .default)
50 | let package: PackageWrapper
51 | do {
52 | package = try await validator.validate(packageDefinition: &packageDefinition)
53 | } catch {
54 | throw CleanExit.make(from: error)
55 | }
56 |
57 | do {
58 | let providedInfo = try await DependenciesProvider.dependencies(
59 | for: packageDefinition,
60 | resolvedPackage: package,
61 | xcconfig: allArguments.xcconfig,
62 | verbose: allArguments.verbose
63 | )
64 |
65 | let report = await Report(packageDefinition: packageDefinition, console: .default)
66 | try await report.generate(
67 | for: providedInfo,
68 | format: allArguments.report
69 | )
70 | } catch {
71 | if let providerError = error as? InfoProviderError {
72 | Task { @MainActor in
73 | Console.default.write(providerError.message)
74 | }
75 | }
76 | }
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/Sources/Run/Subcommands/FullAnalyzes.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import ArgumentParser
22 | import App
23 | import Core
24 | import Foundation
25 | import Reports
26 |
27 | extension SwiftPackageInfo {
28 | public struct FullAnalyzes: AsyncParsableCommand {
29 | public static let configuration = CommandConfiguration(
30 | abstract: "All available information about a Swift Package product.",
31 | discussion: """
32 | Runs all available providers (each one available via a subcommand, e.g. BinarySize),
33 | and generates a full report of a given Swift Package product for a specific version.
34 | """,
35 | version: SwiftPackageInfo.configuration.version
36 | )
37 |
38 | @OptionGroup var allArguments: AllArguments
39 |
40 | public init() {}
41 |
42 | public func run() async throws {
43 | try runArgumentsValidation(arguments: allArguments)
44 | var packageDefinition = try makePackageDefinition(from: allArguments)
45 | packageDefinition.messages.forEach {
46 | let message = $0
47 | Task { @MainActor in
48 | Console.default.lineBreakAndWrite(message)
49 | }
50 | }
51 |
52 | let validator = await SwiftPackageValidator(console: .default)
53 | let package = try await validator.validate(packageDefinition: &packageDefinition)
54 |
55 | let finalPackageDefinition = packageDefinition
56 |
57 | // All copies to silence Swift 6 concurrency `sending` warnings
58 | let xcconfig = allArguments.xcconfig
59 | let isVerbose = allArguments.verbose
60 | let providedInfos: [ProvidedInfo] = try await withThrowingTaskGroup(
61 | of: ProvidedInfo.self,
62 | returning: [ProvidedInfo].self
63 | ) { taskGroup in
64 | SwiftPackageInfo.subcommandsProviders.forEach { subcommandProvider in
65 | taskGroup.addTask {
66 | try await subcommandProvider(
67 | finalPackageDefinition,
68 | package,
69 | xcconfig,
70 | isVerbose
71 | )
72 | }
73 | }
74 |
75 | var providedInfos: [ProvidedInfo] = []
76 | for try await result in taskGroup {
77 | providedInfos.append(result)
78 | }
79 | return providedInfos
80 | }
81 |
82 | let report = await Report(packageDefinition: finalPackageDefinition, console: .default)
83 | try await report.generate(
84 | for: providedInfos,
85 | format: allArguments.report
86 | )
87 | }
88 | }
89 | }
90 |
91 | extension CommandConfiguration: @retroactive @unchecked Sendable {}
92 |
93 |
94 | // 1. validates and updates the package input
95 | // 2. calls info provider -> provides info
96 | // 3. calls reporter -> provides report
97 |
98 | // Executable
99 | // Needs both validation, provider and reporter
100 |
101 | // Library
102 | // Needs (input) -> output
103 | // with validation included, and ideally internally resolved
104 |
105 | // Library target
106 | // - has wrapper around providers
107 | // - for that we need the validation to be moved out
108 | // - the validation should be safer - no inout
109 |
110 | // Action items
111 | // - Make validation safer and improve executable
112 | // - then build library target
113 |
--------------------------------------------------------------------------------
/Sources/Run/Subcommands/Platforms.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import ArgumentParser
22 | import Core
23 | import App
24 | import Reports
25 |
26 | extension SwiftPackageInfo {
27 | public struct Platforms: AsyncParsableCommand {
28 | public static let configuration = CommandConfiguration(
29 | abstract: "Shows platforms supported b a Package product.",
30 | discussion: """
31 | Informs supported platforms by a given Package.swift and its products,
32 | e.g 'iOS with 9.0 minimum deployment target'.
33 | """,
34 | version: SwiftPackageInfo.configuration.version
35 | )
36 |
37 | @OptionGroup var allArguments: AllArguments
38 |
39 | public init() {}
40 |
41 | public func run() async throws {
42 | try runArgumentsValidation(arguments: allArguments)
43 |
44 | var packageDefinition = try makePackageDefinition(from: allArguments)
45 | Task { @MainActor in
46 | try packageDefinition.messages.forEach(Console.default.lineBreakAndWrite)
47 | }
48 |
49 | let validator = await SwiftPackageValidator(console: .default)
50 | let package: PackageWrapper
51 | do {
52 | package = try await validator.validate(packageDefinition: &packageDefinition)
53 | } catch {
54 | throw CleanExit.make(from: error)
55 | }
56 |
57 | do {
58 | let providedInfo = try await PlatformsProvider.platforms(
59 | for: packageDefinition,
60 | resolvedPackage: package,
61 | xcconfig: allArguments.xcconfig,
62 | verbose: allArguments.verbose
63 | )
64 |
65 | let report = await Report(packageDefinition: packageDefinition, console: .default)
66 | try await report.generate(
67 | for: providedInfo,
68 | format: allArguments.report
69 | )
70 | } catch {
71 | if let providerError = error as? InfoProviderError {
72 | Task { @MainActor in
73 | Console.default.write(providerError.message)
74 | }
75 | }
76 | }
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/Sources/Run/SwiftPackageInfo.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import ArgumentParser
22 | import struct Foundation.URL
23 | import Core
24 | import App
25 | import Reports
26 |
27 | import PackageModel
28 |
29 | // MARK: - Main parsable command
30 |
31 | @main
32 | public struct SwiftPackageInfo: AsyncParsableCommand {
33 | public static let configuration = CommandConfiguration(
34 | abstract: "A tool for analyzing Swift Packages",
35 | discussion: """
36 | Provides valuable information about a given Swift Package,
37 | that can be used in your favor when deciding whether to
38 | adopt or not a Swift Package as a dependency on your app.
39 | """,
40 | version: "1.6.0",
41 | subcommands: [
42 | BinarySize.self,
43 | Platforms.self,
44 | Dependencies.self,
45 | FullAnalyzes.self
46 | ],
47 | defaultSubcommand: FullAnalyzes.self
48 | )
49 |
50 | static let subcommandsProviders: [InfoProvider] = [
51 | BinarySizeProvider.binarySize(for:resolvedPackage:xcconfig:verbose:),
52 | PlatformsProvider.platforms(for:resolvedPackage:xcconfig:verbose:),
53 | DependenciesProvider.dependencies(for:resolvedPackage:xcconfig:verbose:)
54 | ]
55 |
56 | public init() {}
57 | }
58 |
59 | // MARK: - Available arguments
60 |
61 | struct AllArguments: ParsableArguments {
62 | @Option(
63 | name: [
64 | .long,
65 | .customLong("for"),
66 | .customLong("package"),
67 | .customLong("repo-url"),
68 | .customLong("path"),
69 | .customLong("local-path"),
70 | ],
71 | help: """
72 | Either a valid git repository or the relative or absolute path to a local directory that contains a `Package.swift`.
73 | """
74 | )
75 | var url: URL
76 |
77 | @Option(
78 | name: [
79 | .long,
80 | .customShort("v")
81 | ],
82 | help: "Semantic version of the Swift Package. If not passed and `revision` is not set, the latest semver tag is used"
83 | )
84 | var packageVersion: String?
85 |
86 | @Option(
87 | name: [
88 | .long,
89 | .customShort("r")
90 | ],
91 | help: "A single git commit, SHA-1 hash, or branch name. Applied when `packageVersion` is not set"
92 | )
93 | var revision: String?
94 |
95 | @Option(
96 | name: [
97 | .long,
98 | .customLong("product-named"),
99 | .customLong("product-name")
100 | ],
101 | help: "Name of the product to be checked. If not passed in the first available product is used"
102 | )
103 | var product: String?
104 |
105 | @Option(
106 | name: [
107 | .long,
108 | .customLong("report-format"),
109 | .customLong("output"),
110 | .customLong("output-format")
111 | ],
112 | help: """
113 | Define the report output format/strategy. Supported values are:
114 | - \(
115 | ReportFormat.allCases.map(\.rawValue)
116 | .joined(separator: "\n- ")
117 | )
118 | """
119 | )
120 | var report: ReportFormat = .consoleMessage
121 |
122 | @Option(
123 | name: [
124 | .customLong("xcconfig"),
125 | ],
126 | help: """
127 | A valid relative local directory path that point to a file of type `.xcconfig`
128 | """
129 | )
130 | var xcconfig: URL? = nil
131 |
132 | @Flag(
133 | name: .long,
134 | help: "Increase verbosity of informational output"
135 | )
136 | var verbose = false
137 | }
138 |
139 | // MARK: - Common ParsableCommand extension
140 |
141 | extension ParsableCommand {
142 | func runArgumentsValidation(arguments: AllArguments) throws {
143 | guard CommandLine.argc > 0 else { throw CleanExit.helpRequest() }
144 |
145 | let isValidRemoteURL = arguments.url.isValidRemote
146 | let isValidLocalDirectory = try? arguments.url.isLocalDirectoryContainingPackageDotSwift()
147 | let isValidLocalCustomFile = arguments.xcconfig?.isLocalXCConfigFileValid()
148 |
149 | guard isValidRemoteURL || isValidLocalDirectory == true else {
150 | throw CleanExit.message(
151 | """
152 | Error: Invalid argument '--url '
153 | Usage: The URL must be either:
154 | - A valid git repository URL that contains a `Package.swift`, e.g `https://github.com/Alamofire/Alamofire`; or
155 | - A relative or absolute path to a local directory that has a `Package.swift`, e.g. `../other-dir/my-project`
156 | """
157 | )
158 | }
159 |
160 | if isValidLocalCustomFile == false {
161 | throw CleanExit.message(
162 | """
163 | Error: Invalid argument '--xcconfig '
164 | Usage: The URL must be a relative local file path that has point to a `.xcconfig` file, e.g. `../other-dir/CustomConfiguration.xcconfig`
165 | """
166 | )
167 | }
168 | }
169 |
170 | func makePackageDefinition(from arguments: AllArguments) throws -> PackageDefinition {
171 | try PackageDefinition(
172 | url: arguments.url,
173 | version: arguments.packageVersion,
174 | revision: arguments.revision,
175 | product: arguments.product
176 | )
177 | }
178 | }
179 |
180 | extension CleanExit {
181 | static func make(from validationError: SwiftPackageValidationError) -> Self {
182 | switch validationError {
183 | case .invalidURL:
184 | CleanExit.message(
185 | """
186 | Error: Invalid argument '--url '
187 | Usage: The URL must be a valid git repository URL that contains
188 | a `Package.swift`, e.g `https://github.com/Alamofire/Alamofire`
189 | """
190 | )
191 | case .failedToLoadPackage:
192 | CleanExit.message("")
193 | case let .noProductFound(packageURL):
194 | CleanExit.message(
195 | "Error: \(packageURL) doesn't contain any product declared on Package.swift"
196 | )
197 | }
198 | }
199 | }
200 |
--------------------------------------------------------------------------------
/Sources/SwiftPackageInfo/SwiftPackageInfoLibrary.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | internal import App
22 | public import Core
23 | public import Foundation
24 |
25 | public enum BinarySize {}
26 |
27 | public extension BinarySize {
28 | protocol Providing {
29 | func getBinarySize(
30 | for packageDefinition: PackageDefinition,
31 | xcConfig: URL?
32 | ) async throws -> Result
33 | }
34 | }
35 |
36 | public extension BinarySize {
37 | /// A type that provides the binary size of a given Swift Package Product.
38 | final class Provider: Providing {
39 | private let validator: SwiftPackageValidating
40 |
41 | public convenience init() {
42 | self.init(validator: SwiftPackageValidator())
43 | }
44 |
45 | init(validator: SwiftPackageValidating = SwiftPackageValidator()) {
46 | self.validator = validator
47 | }
48 |
49 | public func getBinarySize(
50 | for packageDefinition: PackageDefinition,
51 | xcConfig: URL? //// A valid relative local directory path that point to a file of type `.xcconfig`
52 | ) async throws(BinarySize.Error) -> Result {
53 | if
54 | let xcConfig = xcConfig,
55 | !xcConfig.isLocalXCConfigFileValid()
56 | {
57 | throw Error.invalidXcConfigFile(xcConfig)
58 | }
59 |
60 | var finalPackageDefinition = packageDefinition
61 |
62 | let package: PackageWrapper
63 | do {
64 | package = try await validator.validate(
65 | packageDefinition: &finalPackageDefinition,
66 | isVerbose: false
67 | )
68 | } catch {
69 | throw BinarySize.Error.invalidPackageDefinition(
70 | ValidationError.make(from: error)
71 | )
72 | }
73 |
74 | let result: App.BinarySizeProvider.Result
75 | do {
76 | result = try await App.BinarySizeProvider.binarySize(
77 | for: packageDefinition,
78 | resolvedPackage: package,
79 | xcConfig: xcConfig
80 | )
81 | } catch {
82 | throw BinarySize.Error.failedToProvideInfo(error)
83 | }
84 |
85 | return Result(amount: result.amount, formatted: result.formatted)
86 | }
87 | }
88 | }
89 |
90 | extension BinarySize {
91 | public struct Result: Sendable {
92 | let amount: Int
93 | let formatted: String
94 | }
95 |
96 | public enum Error: Swift.Error, Sendable, Equatable {
97 | case invalidPackageDefinition(ValidationError)
98 | case invalidXcConfigFile(URL)
99 | case failedToProvideInfo(InfoProviderError)
100 | }
101 |
102 | // Allows for `App` to be internally imported
103 | public enum ValidationError: Swift.Error, Equatable, Sendable {
104 | case invalidURL
105 | case failedToLoadPackage
106 | case noProductFound(packageURL: URL)
107 |
108 | static func make(from error: SwiftPackageValidationError) -> Self {
109 | switch error {
110 | case .invalidURL:
111 | .invalidURL
112 | case .failedToLoadPackage:
113 | .failedToLoadPackage
114 | case let .noProductFound(packageURL):
115 | .noProductFound(packageURL: packageURL)
116 | }
117 | }
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/Tests/AppTests/Fixtures/Fixture+PackageContent.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 | // Copyright (c) 2025 Felipe Marino
21 | //
22 | // Permission is hereby granted, free of charge, to any person obtaining a copy
23 | // of this software and associated documentation files (the "Software"), to deal
24 | // in the Software without restriction, including without limitation the rights
25 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
26 | // copies of the Software, and to permit persons to whom the Software is
27 | // furnished to do so, subject to the following conditions:
28 | //
29 | // The above copyright notice and this permission notice shall be included in all
30 | // copies or substantial portions of the Software.
31 | //
32 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
33 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
34 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
35 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
36 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
37 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
38 | // SOFTWARE.
39 |
40 | import Foundation
41 | import CoreTestSupport
42 |
43 | @testable import Core
44 |
45 | public extension Fixture {
46 | static func makePackageWrapper(
47 | products: [PackageWrapper.Product] = [],
48 | platforms: [PackageWrapper.Platform] = [],
49 | targets: [PackageWrapper.Target] = [],
50 | swiftLanguageVersions: [String]? = []
51 | ) -> PackageWrapper {
52 | PackageWrapper(
53 | products: products,
54 | platforms: platforms,
55 | targets: targets
56 | )
57 | }
58 |
59 | static func makePackageContentPlatform(
60 | platformName: String = "ios",
61 | version: String = "13.5"
62 | ) -> PackageWrapper.Platform {
63 | .init(
64 | platformName: platformName,
65 | version: version
66 | )
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/Tests/AppTests/Providers/BinarySizeProvider/BinarySizeProviderErrorTests.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import XCTest
22 | @testable import App
23 |
24 | final class BinarySizeProviderErrorTests: XCTestCase {
25 | func testUnableToGenerateArchiveLocalizedMessage() {
26 | let error = BinarySizeProviderError.unableToGenerateArchive(errorMessage: "some")
27 | XCTAssertEqual(
28 | error.localizedDescription,
29 | """
30 | Failed to measure binary size
31 | Step: Archiving
32 | Error: some
33 | """
34 | )
35 | }
36 |
37 | func testUnableToCloneEmptyAppLocalizedMessage() {
38 | let error = BinarySizeProviderError.unableToCloneEmptyApp(errorMessage: "some")
39 | XCTAssertEqual(
40 | error.localizedDescription,
41 | """
42 | Failed to measure binary size
43 | Step: Cloning empty app
44 | Error: some
45 | """
46 | )
47 | }
48 |
49 | func testUnableToGetBinarySizeOnDiskLocalizedMessage() {
50 | let error = BinarySizeProviderError.unableToGetBinarySizeOnDisk(underlyingError: FakeError() as NSError)
51 | XCTAssertEqual(
52 | error.localizedDescription,
53 | """
54 | Failed to measure binary size
55 | Step: Reading binary size
56 | Error: Failed to read binary size from archive. Details: some
57 | """
58 | )
59 | }
60 |
61 | func testUnableToRetrieveAppProjectLocalizedMessage() {
62 | let error = BinarySizeProviderError.unableToRetrieveAppProject(atPath: "path")
63 | XCTAssertEqual(
64 | error.localizedDescription,
65 | """
66 | Failed to measure binary size
67 | Step: Read measurement app project
68 | Error: Failed to get MeasurementApp project from XcodeProj at path: path
69 | """
70 | )
71 | }
72 |
73 | func testUnexpectedErrorLocalizedMessageWhenVerboseTrue() {
74 | let error = BinarySizeProviderError.unexpectedError(
75 | underlyingError: URLError(.badURL) as NSError,
76 | isVerbose: true
77 | )
78 | XCTAssertEqual(
79 | error.localizedDescription,
80 | """
81 | Failed to measure binary size
82 | Step: Undefined
83 | Error: Unexpected failure. Error Domain=NSURLErrorDomain Code=-1000 "(null)".
84 |
85 | """
86 | )
87 | }
88 |
89 | func testUnexpectedErrorLocalizedMessageWhenVerboseFalse() {
90 | let error = BinarySizeProviderError.unexpectedError(
91 | underlyingError: URLError(.badURL) as NSError,
92 | isVerbose: false
93 | )
94 | XCTAssertEqual(
95 | error.localizedDescription,
96 | """
97 | Failed to measure binary size
98 | Step: Undefined
99 | Error: Unexpected failure. Error Domain=NSURLErrorDomain Code=-1000 "(null)".
100 | Please run with --verbose enabled for more details.
101 | """
102 | )
103 | }
104 | }
105 |
106 | private struct FakeError: LocalizedError {
107 | let errorDescription: String? = "some"
108 | }
109 |
--------------------------------------------------------------------------------
/Tests/AppTests/Providers/BinarySizeProvider/BinarySizeProviderTests.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import XCTest
22 | import CoreTestSupport
23 |
24 | @testable import App
25 | @testable import Core
26 |
27 | final class BinarySizeProviderTests: XCTestCase {
28 | func testFetchInformation() async throws {
29 | var defaultSizeMeasurerCallsCount = 0
30 | var lastVerbose: Bool?
31 |
32 | var sizeMeasurerCallsCount = 0
33 | var lastSwiftPackage: PackageDefinition?
34 | var lastIsDynamic: Bool?
35 |
36 | defaultSizeMeasurer = { xcconfig, verbose in
37 | lastVerbose = verbose
38 | defaultSizeMeasurerCallsCount += 1
39 |
40 | return { swiftPackage, isDynamic in
41 | lastSwiftPackage = swiftPackage
42 | lastIsDynamic = isDynamic
43 | sizeMeasurerCallsCount += 1
44 |
45 | return .init(
46 | amount: 908,
47 | formatted: "908 kb"
48 | )
49 | }
50 | }
51 |
52 | let productName = "Product"
53 | let swiftPackage = try Fixture.makePackageDefinition(
54 | product: productName
55 | )
56 | let providedInfo = try await BinarySizeProvider.binarySize(
57 | for: swiftPackage,
58 | resolvedPackage: Fixture.makePackageWrapper(
59 | products: [
60 | .init(
61 | name: productName,
62 | package: nil,
63 | isDynamicLibrary: true,
64 | targets: [
65 | .init(
66 | name: "Target",
67 | dependencies: []
68 | )
69 | ]
70 | )
71 | ]
72 | ),
73 | xcconfig: nil,
74 | verbose: true
75 | )
76 |
77 | XCTAssertEqual(
78 | providedInfo.providerName,
79 | "Binary Size"
80 | )
81 | XCTAssertEqual(
82 | providedInfo.providerKind,
83 | .binarySize
84 | )
85 |
86 | XCTAssertEqual(
87 | defaultSizeMeasurerCallsCount,
88 | 1
89 | )
90 | XCTAssertEqual(
91 | lastVerbose,
92 | true
93 | )
94 | XCTAssertEqual(
95 | sizeMeasurerCallsCount,
96 | 1
97 | )
98 | XCTAssertEqual(
99 | lastSwiftPackage,
100 | swiftPackage
101 | )
102 | XCTAssertEqual(
103 | lastIsDynamic,
104 | true
105 | )
106 |
107 | XCTAssertEqual(
108 | providedInfo.messages,
109 | [
110 | ConsoleMessage(
111 | text: "Binary size increases by ",
112 | color: .noColor,
113 | isBold: false,
114 | hasLineBreakAfter: false
115 | ),
116 | ConsoleMessage(
117 | text: "908 kb",
118 | color: .yellow,
119 | isBold: true,
120 | hasLineBreakAfter: false
121 | )
122 | ]
123 | )
124 |
125 | let encodedProvidedInfo = try JSONEncoder.sortedAndPrettyPrinted.encode(providedInfo)
126 | let encodedProvidedInfoString = String(
127 | data: encodedProvidedInfo,
128 | encoding: .utf8
129 | )
130 |
131 | XCTAssertEqual(
132 | encodedProvidedInfoString,
133 | #"""
134 | {
135 | "amount" : 908,
136 | "formatted" : "908 kb"
137 | }
138 | """#
139 | )
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/Tests/AppTests/Providers/PlatformsProvider/PlatformsProviderTests.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import XCTest
22 | import CoreTestSupport
23 |
24 | @testable import App
25 | @testable import Core
26 |
27 | final class PlatformsProviderTests: XCTestCase {
28 | func testFetchInformation() async throws {
29 | let providedInfo = try await PlatformsProvider.platforms(
30 | for: Fixture.makePackageDefinition(),
31 | resolvedPackage: Fixture.makePackageWrapper(
32 | platforms: [
33 | Fixture.makePackageContentPlatform(),
34 | Fixture.makePackageContentPlatform(
35 | platformName: "macos",
36 | version: "10.15"
37 | ),
38 | Fixture.makePackageContentPlatform(
39 | platformName: "watchos",
40 | version: "7.3.2"
41 | ),
42 | Fixture.makePackageContentPlatform(
43 | platformName: "tvos",
44 | version: "14.0"
45 | ),
46 | ]
47 | ),
48 | xcconfig: nil,
49 | verbose: true
50 | )
51 |
52 | XCTAssertEqual(
53 | providedInfo.providerName,
54 | "Platforms"
55 | )
56 | XCTAssertEqual(
57 | providedInfo.providerKind,
58 | .platforms
59 | )
60 |
61 | let expectedPlatformMessage: (String) -> ConsoleMessage = { contentText in
62 | ConsoleMessage(
63 | text: contentText,
64 | color: .noColor,
65 | isBold: true,
66 | hasLineBreakAfter: false
67 | )
68 | }
69 | let expectedSeparatorMessage = ConsoleMessage(
70 | text: " | ",
71 | hasLineBreakAfter: false
72 | )
73 | XCTAssertEqual(
74 | providedInfo.messages,
75 | [
76 | expectedPlatformMessage("ios from v. 13.5"),
77 | expectedSeparatorMessage,
78 | expectedPlatformMessage("macos from v. 10.15"),
79 | expectedSeparatorMessage,
80 | expectedPlatformMessage("watchos from v. 7.3.2"),
81 | expectedSeparatorMessage,
82 | expectedPlatformMessage("tvos from v. 14.0")
83 | ]
84 | )
85 |
86 | let encodedProvidedInfo = try JSONEncoder.sortedAndPrettyPrinted.encode(providedInfo)
87 | let encodedProvidedInfoString = String(
88 | data: encodedProvidedInfo,
89 | encoding: .utf8
90 | )
91 |
92 | XCTAssertEqual(
93 | encodedProvidedInfoString,
94 | #"""
95 | {
96 | "iOS" : "13.5",
97 | "macOS" : "10.15",
98 | "tvOS" : "14.0",
99 | "watchOS" : "7.3.2"
100 | }
101 | """#
102 | )
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/Tests/CoreTests/Helpers/FileManager+CoreTests.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import Foundation
22 |
23 | enum FileManagerError: LocalizedError {
24 | case fileAlreadyExists(atPath: String)
25 | case directoryAlreadyExists(atPath: String)
26 |
27 | var errorDescription: String? {
28 | switch self {
29 | case let .fileAlreadyExists(path):
30 | return "Unable to create file since file already exists at \(path)"
31 | case let .directoryAlreadyExists(path):
32 | return "Unable to directory since directory already exists at \(path)"
33 | }
34 | }
35 | }
36 |
37 | extension FileManager {
38 | func createFile(
39 | atPath path: String,
40 | content data: Data
41 | ) throws {
42 | if fileExists(atPath: path) == false {
43 | createFile(atPath: path, contents: data, attributes: nil)
44 | } else {
45 | throw FileManagerError.fileAlreadyExists(atPath: path)
46 | }
47 | }
48 |
49 | func createDirectory(atPath path: String) throws {
50 | if fileExists(atPath: path) == false {
51 | try createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil)
52 | } else {
53 | throw FileManagerError.directoryAlreadyExists(atPath: path)
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Tests/CoreTests/Models/PackageDefinitionTests.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | internal import Basics
22 | import XCTest
23 | import CoreTestSupport
24 |
25 | @testable import Core
26 |
27 | final class PackageDefinitionTests: XCTestCase {
28 | // MARK: - Init
29 |
30 | func testSourcePermutations() throws {
31 | struct Permutation: Equatable {
32 | let version: String
33 | let revision: String?
34 | let expectedSource: PackageDefinition.Source
35 | }
36 |
37 | let url = try Fixture.makePackageDefinition().url
38 |
39 | let permutations = [
40 | Permutation(
41 | version: "1.2.3",
42 | revision: "3fag5v0",
43 | expectedSource: .remote(url: url, resolution: .version("1.2.3"))
44 | ),
45 | Permutation(
46 | version: ResourceState.undefined.description,
47 | revision: nil,
48 | expectedSource: .remote(
49 | url: url,
50 | resolution: .version(ResourceState.undefined.description)
51 | )
52 | ),
53 | Permutation(
54 | version: ResourceState.undefined.description,
55 | revision: "3fag5v0",
56 | expectedSource: .remote(
57 | url: url,
58 | resolution: .revision("3fag5v0")
59 | )
60 | ),
61 | Permutation(
62 | version: ResourceState.invalid.description,
63 | revision: "3fag5v0",
64 | expectedSource: .remote(
65 | url: url,
66 | resolution: .version(ResourceState.invalid.description)
67 | )
68 | ),
69 | Permutation(
70 | version: ResourceState.undefined.description,
71 | revision: nil,
72 | expectedSource: .remote(
73 | url: url,
74 | resolution: .version(ResourceState.undefined.description)
75 | )
76 | ),
77 | Permutation(
78 | version: "",
79 | revision: nil,
80 | expectedSource: .local(try localFileSystem.tempDirectory)
81 | ),
82 | ]
83 |
84 | try permutations.forEach { permutation in
85 | let sut: PackageDefinition
86 | if case let .local(path) = permutation.expectedSource {
87 | sut = try Fixture.makePackageDefinition(source: .local(path))
88 | } else {
89 | sut = try Fixture.makePackageDefinition(
90 | version: permutation.version,
91 | revision: permutation.revision
92 | )
93 | }
94 |
95 | XCTAssertEqual(
96 | sut.source,
97 | permutation.expectedSource
98 | )
99 | }
100 | }
101 |
102 | func testInvalidRemoteAndLocal() throws {
103 | XCTAssertThrowsError(
104 | try Fixture.makePackageDefinition(
105 | url: URL(string: "../directory")!
106 | )
107 | )
108 |
109 | do {
110 | _ = try Fixture.makePackageDefinition(
111 | url: URL(string: "../directory")!
112 | )
113 | } catch {
114 | XCTAssertEqual(error as? PackageDefinition.Error, .invalidURL)
115 | }
116 | }
117 |
118 | // MARK: - Description
119 |
120 | func testDescriptionWhenLocal() throws {
121 | let temporaryDir = try createTemporaryValidLocalDir()
122 | let sut = try Fixture.makePackageDefinition(source: .local(temporaryDir))
123 | XCTAssertEqual(
124 | sut.description,
125 | """
126 | Local path: \(temporaryDir)
127 | Product: Some
128 | """
129 | )
130 | }
131 |
132 | func testDescriptionWhenRemote() throws {
133 | let sut = try Fixture.makePackageDefinition()
134 | XCTAssertEqual(
135 | sut.description,
136 | """
137 | Repository URL: https://www.apple.com
138 | Version: 1.0.0
139 | Product: Some
140 | """
141 | )
142 | }
143 |
144 | func testDescriptionWhenRevision() throws {
145 | let revision = "f46ab7s"
146 | let sut = try Fixture.makePackageDefinition(
147 | version: ResourceState.undefined.description,
148 | revision: revision
149 | )
150 | XCTAssertEqual(
151 | sut.description,
152 | """
153 | Repository URL: https://www.apple.com
154 | Revision: \(revision)
155 | Product: Some
156 | """
157 | )
158 | }
159 | }
160 |
161 | private extension PackageDefinitionTests {
162 | func createTemporaryValidLocalDir() throws -> AbsolutePath {
163 | let temporaryDir = URL.temporaryDirectory
164 | let temporaryFilename = "Package.swift"
165 | let temporaryFileURL = temporaryDir.appendingPathComponent(temporaryFilename)
166 |
167 | try? FileManager.default.createFile(
168 | atPath: temporaryFileURL.path(),
169 | content: Data("Test".utf8)
170 | )
171 |
172 | return try AbsolutePath(validating: temporaryDir.path())
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/Tests/CoreTests/Models/SizeOnDiskTests.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import XCTest
22 | @testable import Core
23 |
24 | final class SizeOnDiskTests: XCTestCase {
25 | func testEmpty() {
26 | XCTAssertEqual(
27 | SizeOnDisk.empty,
28 | .init(amount: 0, formatted: "0.0")
29 | )
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Tests/CoreTests/ProvidedInfoTests.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import XCTest
22 | @testable import Core
23 |
24 | final class ProvidedInfoTests: XCTestCase {
25 | private struct ProvidedContent: CustomConsoleMessagesConvertible, Encodable {
26 | let binarySize: Float
27 | var messages: [ConsoleMessage] {
28 | [
29 | .init(stringLiteral: "something")
30 | ]
31 | }
32 | }
33 |
34 | func testConsoleMessages() {
35 | let sut = ProvidedInfo(
36 | providerName: "name",
37 | providerKind: .binarySize,
38 | information: ProvidedContent(binarySize: 300)
39 | )
40 |
41 | XCTAssertEqual(
42 | sut.messages,
43 | [
44 | .init(stringLiteral: "something")
45 | ]
46 | )
47 | }
48 |
49 | func testProviderName() {
50 | let sut = ProvidedInfo(
51 | providerName: "name",
52 | providerKind: .binarySize,
53 | information: ProvidedContent(binarySize: 300)
54 | )
55 |
56 | XCTAssertEqual(
57 | sut.providerName,
58 | "name"
59 | )
60 | }
61 |
62 | func testEncodedValue() throws {
63 | let sut = ProvidedInfo(
64 | providerName: "name",
65 | providerKind: .binarySize,
66 | information: ProvidedContent(binarySize: 300)
67 | )
68 |
69 | let encoded = try JSONEncoder().encode(sut)
70 | let encodedString = String(data: encoded, encoding: .utf8)
71 |
72 | XCTAssertEqual(
73 | encodedString,
74 | #"{"binarySize":300}"#
75 | )
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/Tests/ReportsTests/Generators/ConsoleReportGeneratorTests.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import XCTest
22 | import CoreTestSupport
23 |
24 | @testable import Core
25 | @testable import Reports
26 |
27 | @MainActor
28 | final class ConsoleReportGeneratorTests: XCTestCase {
29 | func testRenderReport() throws {
30 | let terminalControllerMock = TerminalControllerMock()
31 | let progressAnimationMock = ProgressAnimationMock()
32 |
33 | Console.default = Console(
34 | isOutputColored: false,
35 | terminalController: terminalControllerMock,
36 | progressAnimation: progressAnimationMock
37 | )
38 |
39 | let sut = ConsoleReportGenerator(
40 | console: .default
41 | )
42 |
43 | sut.renderReport(
44 | for: try Fixture.makePackageDefinition(),
45 | providedInfos: [
46 | ProvidedInfo.init(
47 | providerName: "Name",
48 | providerKind: .binarySize,
49 | information: Fixture.makeProvidedInfoInformation()
50 | )
51 | ]
52 | )
53 |
54 | XCTAssertEqual(progressAnimationMock.completeCallsCount, 0)
55 | XCTAssertEqual(progressAnimationMock.clearCallsCount, 0)
56 | XCTAssertEqual(progressAnimationMock.updateCallsCount, 0)
57 |
58 | XCTAssertEqual(terminalControllerMock.writeCallsCount, 39)
59 | XCTAssertEqual(terminalControllerMock.endLineCallsCount, 13)
60 |
61 | XCTAssertEqual(
62 | terminalControllerMock.writeStrings,
63 | [
64 | "+----------------------------+",
65 | "|",
66 | " ",
67 | "Swift Package Info",
68 | " ",
69 | "|",
70 | "|",
71 | " ",
72 | "|",
73 | "|",
74 | " ",
75 | "Some, 1.0.0",
76 | " ",
77 | "|",
78 | "+----------+-----------------+",
79 | "|",
80 | " ",
81 | "Provider",
82 | " ",
83 | "|",
84 | " ",
85 | "Results",
86 | " ",
87 | "|",
88 | "+----------+-----------------+",
89 | "|",
90 | " ",
91 | "Name",
92 | " ",
93 | "|",
94 | " ",
95 | "name",
96 | "Value is 10",
97 | " ",
98 | "|",
99 | "+----------+-----------------+",
100 | "> Total of ",
101 | "1",
102 | " provider used."
103 | ]
104 | )
105 |
106 | XCTAssertEqual(
107 | terminalControllerMock.writeColors.count,
108 | 39
109 | )
110 | XCTAssertEqual(
111 | Set(terminalControllerMock.writeColors),
112 | Set([.noColor])
113 | )
114 | XCTAssertEqual(
115 | terminalControllerMock.writeBolds,
116 | [
117 | false,
118 | false,
119 | false,
120 | true,
121 | false,
122 | false,
123 | false,
124 | false,
125 | false,
126 | false,
127 | false,
128 | false,
129 | false,
130 | false,
131 | false,
132 | false,
133 | false,
134 | false,
135 | false,
136 | false,
137 | false,
138 | false,
139 | false,
140 | false,
141 | false,
142 | false,
143 | false,
144 | true,
145 | false,
146 | false,
147 | false,
148 | true,
149 | false,
150 | false,
151 | false,
152 | false,
153 | false,
154 | true,
155 | false
156 | ]
157 | )
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/Tests/ReportsTests/Generators/JSONDumpReportGeneratorTests.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import XCTest
22 | import CoreTestSupport
23 |
24 | @testable import Core
25 | @testable import Reports
26 |
27 | @MainActor
28 | final class JSONDumpReportGeneratorTests: XCTestCase {
29 | func testRenderDumpWithOneKindOfProvidedInfoOnly() async throws {
30 | let terminalControllerMock = TerminalControllerMock()
31 | let progressAnimationMock = ProgressAnimationMock()
32 |
33 | Console.default = Console(
34 | isOutputColored: false,
35 | terminalController: terminalControllerMock,
36 | progressAnimation: progressAnimationMock
37 | )
38 |
39 | let sut = JSONDumpReportGenerator(console: .default)
40 |
41 | try sut.renderDump(
42 | for: Fixture.makePackageDefinition(),
43 | providedInfos: [
44 | ProvidedInfo.init(
45 | providerName: "Name",
46 | providerKind: .binarySize,
47 | information: Fixture.makeProvidedInfoInformation()
48 | )
49 | ]
50 | )
51 |
52 | await Task.yield()
53 | await Task.yield()
54 |
55 | XCTAssertEqual(progressAnimationMock.completeCallsCount, 0)
56 | XCTAssertEqual(progressAnimationMock.clearCallsCount, 0)
57 | XCTAssertEqual(progressAnimationMock.updateCallsCount, 0)
58 |
59 | XCTAssertEqual(terminalControllerMock.writeCallsCount, 1)
60 | XCTAssertEqual(terminalControllerMock.endLineCallsCount, 1)
61 |
62 | XCTAssertEqual(
63 | terminalControllerMock.writeStrings,
64 | [
65 | #"""
66 | {
67 | "binarySize" : {
68 | "name" : "name",
69 | "value" : 10
70 | }
71 | }
72 | """#
73 | ]
74 | )
75 |
76 | XCTAssertEqual(
77 | terminalControllerMock.writeColors,
78 | [
79 | .noColor
80 | ]
81 | )
82 | XCTAssertEqual(
83 | terminalControllerMock.writeBolds,
84 | [
85 | false
86 | ]
87 | )
88 | }
89 |
90 | func testRenderDumpWithAllProvidedInfoKinds() async throws {
91 | let terminalControllerMock = TerminalControllerMock()
92 | let progressAnimationMock = ProgressAnimationMock()
93 |
94 | Console.default = Console(
95 | isOutputColored: false,
96 | terminalController: terminalControllerMock,
97 | progressAnimation: progressAnimationMock
98 | )
99 |
100 | let sut = JSONDumpReportGenerator(console: .default)
101 |
102 | try sut.renderDump(
103 | for: Fixture.makePackageDefinition(),
104 | providedInfos: [
105 | ProvidedInfo.init(
106 | providerName: "Name",
107 | providerKind: .binarySize,
108 | information: Fixture.makeProvidedInfoInformation()
109 | ),
110 | ProvidedInfo.init(
111 | providerName: "Other",
112 | providerKind: .dependencies,
113 | information: Fixture.makeProvidedInfoInformation(
114 | name: "other",
115 | value: 9999
116 | )
117 | ),
118 | ProvidedInfo.init(
119 | providerName: "Yet another",
120 | providerKind: .platforms,
121 | information: Fixture.makeProvidedInfoInformation(
122 | name: "yet another",
123 | value: 17
124 | )
125 | )
126 | ]
127 | )
128 |
129 | await Task.yield()
130 | await Task.yield()
131 |
132 | XCTAssertEqual(progressAnimationMock.completeCallsCount, 0)
133 | XCTAssertEqual(progressAnimationMock.clearCallsCount, 0)
134 | XCTAssertEqual(progressAnimationMock.updateCallsCount, 0)
135 |
136 | XCTAssertEqual(terminalControllerMock.writeCallsCount, 1)
137 | XCTAssertEqual(terminalControllerMock.endLineCallsCount, 1)
138 |
139 | XCTAssertEqual(
140 | terminalControllerMock.writeStrings,
141 | [
142 | #"""
143 | {
144 | "binarySize" : {
145 | "name" : "name",
146 | "value" : 10
147 | },
148 | "dependencies" : {
149 | "name" : "other",
150 | "value" : 9999
151 | },
152 | "platforms" : {
153 | "name" : "yet another",
154 | "value" : 17
155 | }
156 | }
157 | """#
158 | ]
159 | )
160 |
161 | XCTAssertEqual(
162 | terminalControllerMock.writeColors,
163 | [
164 | .noColor
165 | ]
166 | )
167 | XCTAssertEqual(
168 | terminalControllerMock.writeBolds,
169 | [
170 | false
171 | ]
172 | )
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/Tests/RunTests/RunTests.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | import XCTest
22 | import Foundation
23 |
24 | @available(macOS 10.13, *)
25 | final class RunTests: XCTestCase {
26 | func testWithInvalidRemoteURL() throws {
27 | try runToolProcessAndAssert(
28 | command: "--url somethingElse --package-version 6.0.0 --product RxSwift",
29 | expectedOutput: """
30 | Error: Invalid argument '--url '
31 | Usage: The URL must be either:
32 | - A valid git repository URL that contains a `Package.swift`, e.g `https://github.com/Alamofire/Alamofire`; or
33 | - A relative or absolute path to a local directory that has a `Package.swift`, e.g. `../other-dir/my-project`
34 |
35 | """,
36 | expectedError: ""
37 | )
38 | }
39 |
40 | func testLocalURLWithoutPackage() throws {
41 | try runToolProcessAndAssert(
42 | command: "--url ../path",
43 | expectedOutput: """
44 | Error: Invalid argument '--url '
45 | Usage: The URL must be either:
46 | - A valid git repository URL that contains a `Package.swift`, e.g `https://github.com/Alamofire/Alamofire`; or
47 | - A relative or absolute path to a local directory that has a `Package.swift`, e.g. `../other-dir/my-project`
48 |
49 | """,
50 | expectedError: ""
51 | )
52 | }
53 |
54 | func testHelp() throws {
55 | let expectedOutput = """
56 | OVERVIEW: A tool for analyzing Swift Packages
57 |
58 | Provides valuable information about a given Swift Package,\nthat can be used in your favor when deciding whether to\nadopt or not a Swift Package as a dependency on your app.
59 |
60 | USAGE: swift-package-info
61 |
62 | OPTIONS:
63 | --version Show the version.
64 | -h, --help Show help information.\n\nSUBCOMMANDS:
65 | binary-size Estimated binary size of a Swift Package product.
66 | platforms Shows platforms supported b a Package product.
67 | dependencies List dependencies of a Package product.
68 | full-analyzes (default) All available information about a Swift Package\n product.
69 |
70 | See \'swift-package-info help \' for detailed help.
71 |
72 | """
73 |
74 | try runToolProcessAndAssert(
75 | command: "--help",
76 | expectedOutput: expectedOutput,
77 | expectedError: ""
78 | )
79 | }
80 |
81 | /// Returns path to the built products directory.
82 | var productsDirectory: URL {
83 | #if os(macOS)
84 | for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
85 | return bundle.bundleURL.deletingLastPathComponent()
86 | }
87 | fatalError("couldn't find the products directory")
88 | #else
89 | return Bundle.main.bundleURL
90 | #endif
91 | }
92 |
93 | // MARK: - Helpers
94 |
95 | private func runToolProcessAndAssert(
96 | _ file: StaticString = #filePath,
97 | _ function: StaticString = #function,
98 | _ line: UInt = #line,
99 | command: String,
100 | expectedOutput: String?,
101 | expectedError: String?
102 | ) throws {
103 | let commands = command.split(whereSeparator: \.isWhitespace)
104 |
105 | let arguments: [String]
106 | if commands.count > 1 {
107 | arguments = commands.map { String($0) }
108 | } else {
109 | arguments = command
110 | .split { [" -", " --"].contains(String($0)) }
111 | .map { String($0) }
112 | }
113 |
114 | let executableURL = productsDirectory.appendingPathComponent("swift-package-info")
115 |
116 | let process = Process()
117 | process.executableURL = executableURL
118 | process.arguments = arguments
119 |
120 | let outputPipe = Pipe()
121 | process.standardOutput = outputPipe
122 |
123 | let errorPipe = Pipe()
124 | process.standardError = errorPipe
125 |
126 | try process.run()
127 | process.waitUntilExit()
128 |
129 | let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
130 | let outputContent = String(data: outputData, encoding: .utf8)
131 | let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
132 | let errorContent = String(data: errorData, encoding: .utf8)
133 |
134 | XCTAssertEqual(outputContent, expectedOutput, file: file, line: line)
135 | XCTAssertEqual(errorContent, expectedError, file: file, line: line)
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/Tests/SwiftPackageInfoTests/LibraryTests.swift:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Felipe Marino
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 |
--------------------------------------------------------------------------------