├── .config
└── dotnet-tools.json
├── .editorconfig
├── .gitattributes
├── .github
├── CODEOWNERS
├── actions
│ ├── install-tools
│ │ └── action.yml
│ └── version-vars
│ │ └── action.yml
├── dependabot.yml
└── workflows
│ ├── CI.yml
│ ├── PR.yml
│ ├── workflow_build.yml
│ └── workflow_release.yml
├── .gitignore
├── .vscode
├── extensions.json
└── settings.json
├── Directory.Build.props
├── Directory.Build.rsp
├── Directory.Packages.props
├── Docs
├── BuildingAndTesting.md
├── README.md
└── Research.md
├── LICENSE.md
├── README.md
├── codecov.yml
├── eng
├── Defaults.props
├── DotNetAnalyzers.props
├── DotNetDefaults.props
└── Packaging.props
├── global.json
├── nuget.config
├── scripts
├── PatchGlobalJson.ps1
└── UpdateVSVersions.ps1
├── slnup.slnx
├── src
├── .editorconfig
├── Directory.Build.props
├── SlnUp.Core
│ ├── ConsoleHelpers.cs
│ ├── Extensions
│ │ ├── RegexExtensions.cs
│ │ └── VersionExtensions.cs
│ ├── ScopedAction.cs
│ ├── SlnUp.Core.csproj
│ ├── VersionManager.cs
│ ├── VersionManager.g.cs
│ ├── VisualStudioProduct.cs
│ └── VisualStudioVersion.cs
├── SlnUp.Json
│ ├── Extensions
│ │ └── AssemblyExtensions.cs
│ ├── SlnUp.Json.csproj
│ ├── VersionManagerJsonHelper.cs
│ └── VisualStudioVersionJsonHelper.cs
├── SlnUp
│ ├── CLI
│ │ ├── ArgumentParser.cs
│ │ └── ProgramOptions.cs
│ ├── Program.cs
│ ├── SlnUp.csproj
│ ├── SlnUpOptions.cs
│ ├── SolutionFile.cs
│ └── SolutionFileHeader.cs
└── VisualStudio.VersionScraper
│ ├── OutputFormat.cs
│ ├── Program.cs
│ ├── ProgramOptions.cs
│ ├── ProgramOptionsBinder.cs
│ ├── Properties
│ └── launchSettings.json
│ ├── VisualStudio.VersionScraper.csproj
│ ├── VisualStudioVersionDocScraper.cs
│ └── Writers
│ ├── CSharp
│ └── CSharpVersionWriter.cs
│ └── CodeWriter.cs
├── tests
├── .editorconfig
├── Directory.Build.props
├── SlnUp.Core.Tests
│ ├── Extensions
│ │ └── VersionExtensionsTests.cs
│ ├── ScopedActionTests.cs
│ ├── SlnUp.Core.Tests.csproj
│ ├── TestVersions.json
│ ├── VersionManagerTests.cs
│ └── VisualStudioVersionTests.cs
├── SlnUp.Json.Tests
│ ├── Extensions
│ │ └── AssemblyExtensionsTests.cs
│ ├── SlnUp.Json.Tests.csproj
│ ├── VersionManagerJsonHelperTests.cs
│ └── VisualStudioVersionJsonHelperTests.cs
├── SlnUp.TestLibrary
│ ├── Extensions
│ │ └── StringExtensions.cs
│ ├── ScopedDirectory.cs
│ ├── ScopedFile.cs
│ ├── SlnUp.TestLibrary.csproj
│ ├── TemporaryDirectory.cs
│ ├── TemporaryFile.cs
│ └── WorkingDirectory.cs
└── SlnUp.Tests
│ ├── CLI
│ ├── ArgumentParserTests.cs
│ └── ProgramOptionsTests.cs
│ ├── ProgramTests.cs
│ ├── SlnUp.Tests.csproj
│ ├── SolutionFileBuilder.cs
│ ├── SolutionFileBuilderTests.cs
│ ├── SolutionFileHeaderTests.cs
│ ├── SolutionFileTests.cs
│ └── Utilities
│ └── TestConsoleExtensions.cs
└── version.json
/.config/dotnet-tools.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "isRoot": true,
4 | "tools": {
5 | "nbgv": {
6 | "version": "3.7.115",
7 | "commands": [
8 | "nbgv"
9 | ]
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: https://EditorConfig.org
2 | # You can learn more about editorconfig here: https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference
3 |
4 | # IMPORTANT
5 | # This file is intended to be a static template. It shouldn't be modified for project specific settings.
6 | # Place project specific settings in subfolders whenever possible.
7 |
8 | # top-most EditorConfig file
9 | root = true
10 |
11 | [*]
12 | charset = utf-8-bom
13 | indent_style = space
14 | insert_final_newline = false
15 | trim_trailing_whitespace = true
16 |
17 | # Add guidelines to editor: https://github.com/pharring/EditorGuidelines#editorconfig-support-vs-2017-or-above
18 | guidelines = 80, 120
19 |
20 | [*.{yml,yaml}]
21 | indent_size = 2
22 | insert_final_newline = true
23 |
24 | [*.{props,csproj,sln,targets}]
25 | indent_size = 2
26 | insert_final_newline = true
27 |
28 | [*.md]
29 | insert_final_newline = true
30 |
31 | [*.{cs,vb}]
32 |
33 | indent_size = 4
34 | tab_width = 4
35 | insert_final_newline = true
36 |
37 | # .NET Analyzer severities
38 |
39 | # Default severity for all analyzer diagnostics
40 | dotnet_analyzer_diagnostic.severity = warning
41 |
42 | # CA1014: Mark assemblies with CLSCompliantAttribute
43 | dotnet_diagnostic.CA1014.severity = none
44 |
45 | # CA1031: Do not catch general exception types
46 | dotnet_diagnostic.CA1031.severity = suggestion
47 |
48 | # IDE0008: Use explicit type instead of var
49 | dotnet_diagnostic.IDE0008.severity = error
50 |
51 | # IDE0021: Use expression body for constructors
52 | dotnet_diagnostic.IDE0021.severity = suggestion
53 |
54 | # IDE0022: Use expression body for methods
55 | dotnet_diagnostic.IDE0022.severity = suggestion
56 |
57 | # IDE0028: Use collection initializers or expressions
58 | dotnet_diagnostic.IDE0028.severity = suggestion
59 |
60 | # IDE0046: Use conditional expression for return
61 | dotnet_diagnostic.IDE0046.severity = suggestion
62 |
63 | # IDE0058: Remove unnecessary expression value
64 | dotnet_diagnostic.IDE0058.severity = suggestion
65 |
66 | # IDE0059: Remove unnecessary value assignment
67 | dotnet_diagnostic.IDE0059.severity = suggestion
68 |
69 | #### Naming styles ####
70 |
71 | # Naming rules
72 |
73 | dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
74 | dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
75 |
76 | dotnet_naming_rule.types_should_be_pascal_case.symbols = types
77 | dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
78 |
79 | dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
80 | dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
81 |
82 | # Symbol specifications
83 |
84 | dotnet_naming_symbols.interface.applicable_kinds = interface
85 | dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
86 | dotnet_naming_symbols.interface.required_modifiers =
87 |
88 | dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
89 | dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
90 | dotnet_naming_symbols.types.required_modifiers =
91 |
92 | dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
93 | dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
94 | dotnet_naming_symbols.non_field_members.required_modifiers =
95 |
96 | # Naming styles
97 |
98 | dotnet_naming_style.begins_with_i.required_prefix = I
99 | dotnet_naming_style.begins_with_i.required_suffix =
100 | dotnet_naming_style.begins_with_i.word_separator =
101 | dotnet_naming_style.begins_with_i.capitalization = pascal_case
102 |
103 | dotnet_naming_style.pascal_case.required_prefix =
104 | dotnet_naming_style.pascal_case.required_suffix =
105 | dotnet_naming_style.pascal_case.word_separator =
106 | dotnet_naming_style.pascal_case.capitalization = pascal_case
107 |
108 | dotnet_naming_style.pascal_case.required_prefix =
109 | dotnet_naming_style.pascal_case.required_suffix =
110 | dotnet_naming_style.pascal_case.word_separator =
111 | dotnet_naming_style.pascal_case.capitalization = pascal_case
112 |
113 | #Style - language keyword and framework type options
114 |
115 | #prefer the language keyword for local variables, method parameters, and class members, instead of the type name, for types that have a keyword to represent them
116 | dotnet_style_predefined_type_for_locals_parameters_members = true
117 |
118 | #Style - qualification options
119 |
120 | #prefer fields to be prefaced with this. in C# or Me. in Visual Basic
121 | dotnet_style_qualification_for_field = true
122 | #prefer methods to be prefaced with this. in C# or Me. in Visual Basic
123 | dotnet_style_qualification_for_method = true
124 | #prefer properties to be prefaced with this. in C# or Me. in Visual Basic
125 | dotnet_style_qualification_for_property = true
126 |
127 | dotnet_style_operator_placement_when_wrapping = beginning_of_line
128 | dotnet_style_coalesce_expression = true
129 | dotnet_style_null_propagation = true
130 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true
131 | dotnet_style_prefer_auto_properties = true
132 | dotnet_style_object_initializer = true
133 | dotnet_style_collection_initializer = true
134 | dotnet_style_prefer_simplified_boolean_expressions = true
135 | dotnet_style_prefer_conditional_expression_over_assignment = true
136 | dotnet_style_prefer_conditional_expression_over_return = true
137 | dotnet_style_explicit_tuple_names = true
138 | dotnet_style_prefer_inferred_tuple_names = true
139 | dotnet_style_prefer_inferred_anonymous_type_member_names = true
140 | dotnet_style_prefer_compound_assignment = true
141 | dotnet_style_prefer_simplified_interpolation = true
142 | dotnet_style_namespace_match_folder = true
143 | dotnet_style_readonly_field = true
144 |
145 | #Formatting - organize using options
146 | # https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/dotnet-formatting-options
147 | dotnet_sort_system_directives_first = true
148 | dotnet_separate_import_directive_groups = true
149 |
150 | [*.cs]
151 |
152 | #Formatting - spacing options
153 |
154 | #require a space before the colon for bases or interfaces in a type declaration
155 | csharp_space_after_colon_in_inheritance_clause = true
156 | #require a space after a keyword in a control flow statement such as a for loop
157 | csharp_space_after_keywords_in_control_flow_statements = true
158 | #require a space before the colon for bases or interfaces in a type declaration
159 | csharp_space_before_colon_in_inheritance_clause = true
160 | #remove space within empty argument list parentheses
161 | csharp_space_between_method_call_empty_parameter_list_parentheses = false
162 | #remove space between method call name and opening parenthesis
163 | csharp_space_between_method_call_name_and_opening_parenthesis = false
164 | #do not place space characters after the opening parenthesis and before the closing parenthesis of a method call
165 | csharp_space_between_method_call_parameter_list_parentheses = false
166 | #remove space within empty parameter list parentheses for a method declaration
167 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
168 | #place a space character after the opening parenthesis and before the closing parenthesis of a method declaration parameter list.
169 | csharp_space_between_method_declaration_parameter_list_parentheses = false
170 |
171 | #Formatting - wrapping options
172 |
173 | #leave code block on single line
174 | csharp_preserve_single_line_blocks = true
175 |
176 | #Style - expression bodied member options
177 |
178 | #prefer block bodies for constructors
179 | csharp_style_expression_bodied_constructors = when_on_single_line
180 |
181 | #prefer expression-bodied members for properties
182 | csharp_style_expression_bodied_properties = when_on_single_line
183 |
184 | #Style - expression level options
185 |
186 | #prefer out variables to be declared before the method call
187 | csharp_style_inlined_variable_declaration = false
188 |
189 | #Style - implicit and explicit types
190 |
191 |
192 | # IDE0007 and IDE0008: 'var' preferences
193 | csharp_style_var_for_built_in_types = false
194 | csharp_style_var_when_type_is_apparent = false
195 | csharp_style_var_elsewhere = false
196 |
197 | # IDE0160 and IDE0161: Namespace declaration preferences
198 | csharp_style_namespace_declarations = file_scoped
199 |
200 | # IDE0022: Use block body for methods
201 | csharp_style_expression_bodied_methods = when_on_single_line
202 |
203 | # IDE0023: Use block body for operators
204 | csharp_style_expression_bodied_operators = when_on_single_line
205 |
206 | # IDE0090: Use 'new(...)'
207 | csharp_style_implicit_object_creation_when_type_is_apparent = true
208 |
209 | # IDE0290: Use Primary Constructors
210 | csharp_style_prefer_primary_constructors = false
211 |
212 | csharp_indent_labels = one_less_than_current
213 | csharp_space_around_binary_operators = before_and_after
214 | csharp_using_directive_placement = inside_namespace
215 | csharp_prefer_simple_using_statement = true
216 | csharp_prefer_braces = true
217 | csharp_style_prefer_method_group_conversion = true
218 | csharp_style_prefer_top_level_statements = false
219 | csharp_style_expression_bodied_indexers = true
220 | csharp_style_expression_bodied_accessors = true
221 | csharp_style_expression_bodied_lambdas = true
222 | csharp_style_expression_bodied_local_functions = false
223 | csharp_style_throw_expression = true
224 | csharp_style_prefer_null_check_over_type_check = true
225 | csharp_prefer_simple_default_expression = true
226 | csharp_style_prefer_local_over_anonymous_function = true
227 | csharp_style_prefer_index_operator = true
228 | csharp_style_prefer_range_operator = true
229 | csharp_style_prefer_tuple_swap = true
230 | csharp_style_prefer_utf8_string_literals = true
231 | csharp_style_deconstructed_variable_declaration = true
232 |
233 | # IDE0058: Remove unnecessary expression value
234 | csharp_style_unused_value_expression_statement_preference = discard_variable
235 |
236 | # IDE0059: Remove unnecessary value assignment
237 | csharp_style_unused_value_assignment_preference = discard_variable
238 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
2 | * @craigktreasure
3 |
--------------------------------------------------------------------------------
/.github/actions/install-tools/action.yml:
--------------------------------------------------------------------------------
1 | name: Install tools
2 |
3 | runs:
4 | using: "composite"
5 | steps:
6 | - name: Setup .NET SDK
7 | uses: actions/setup-dotnet@v4
8 | with:
9 | global-json-file: global.json
10 |
11 | - name: Setup additional .NET SDK versions
12 | uses: actions/setup-dotnet@v4
13 | with:
14 | dotnet-version: |
15 | 8.x
16 |
17 | - name: Install .NET tools
18 | shell: pwsh
19 | run: dotnet tool restore
20 |
--------------------------------------------------------------------------------
/.github/actions/version-vars/action.yml:
--------------------------------------------------------------------------------
1 | name: Set version variables
2 |
3 | outputs:
4 | package_version:
5 | description: The package version.
6 | value: ${{ steps.version.outputs.package_version }}
7 |
8 | runs:
9 | using: "composite"
10 | steps:
11 | - name: Set version
12 | id: version
13 | shell: pwsh
14 | run: |
15 | $packageVersion = dotnet nbgv get-version --variable NuGetPackageVersion
16 | "package_version=$packageVersion" >> $env:GITHUB_OUTPUT
17 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: nuget
4 | directory: "/"
5 | schedule:
6 | interval: weekly
7 | day: 'friday'
8 | time: '17:00'
9 | timezone: 'America/Los_Angeles'
10 | open-pull-requests-limit: 10
11 | reviewers:
12 | - craigktreasure
13 | assignees:
14 | - craigktreasure
15 | groups:
16 | xunit:
17 | patterns:
18 | - "xunit*"
19 | gitversioning:
20 | patterns:
21 | - "Nerdbank.GitVersioning"
22 | - "nbgv"
23 |
24 | - package-ecosystem: 'dotnet-sdk'
25 | directory: "/"
26 | schedule:
27 | interval: 'weekly'
28 | day: 'tuesday'
29 | time: '22:00'
30 |
31 | - package-ecosystem: github-actions
32 | directory: "/"
33 | schedule:
34 | interval: weekly
35 | day: 'friday'
36 | time: '17:00'
37 | timezone: 'America/Los_Angeles'
38 | open-pull-requests-limit: 10
39 | reviewers:
40 | - craigktreasure
41 | assignees:
42 | - craigktreasure
43 |
--------------------------------------------------------------------------------
/.github/workflows/CI.yml:
--------------------------------------------------------------------------------
1 | name: SlnUp-CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths-ignore:
8 | - '.config/**'
9 | - '.github/dependabot.yml'
10 | - '.vscode/**'
11 | - 'docs/**'
12 | - 'README.md'
13 |
14 | jobs:
15 | build_ci:
16 | name: Build SlnUp
17 | if: "!contains(github.event.head_commit.message, 'ci skip')"
18 | uses: ./.github/workflows/workflow_build.yml
19 | secrets: inherit
20 | with:
21 | # don't check format on CI builds due to common breaking changes in the .NET SDK
22 | checkFormat: false
23 |
24 | release_ci:
25 | name: Release SlnUp to NuGet.org
26 | needs: build_ci
27 | if: ${{ !contains(needs.build_ci.outputs.package_version, '-') }}
28 | uses: ./.github/workflows/workflow_release.yml
29 | secrets: inherit
30 | with:
31 | package-version: ${{ needs.build_ci.outputs.package_version }}
32 |
--------------------------------------------------------------------------------
/.github/workflows/PR.yml:
--------------------------------------------------------------------------------
1 | name: SlnUp-PR
2 |
3 | on: pull_request
4 |
5 | jobs:
6 | build_pr:
7 | name: Build SlnUp
8 | strategy:
9 | max-parallel: 3
10 | fail-fast: false
11 | matrix:
12 | platform: [
13 | { os: windows, buildAgent: windows-2025 },
14 | { os: ubuntu, buildAgent: ubuntu-latest },
15 | { os: macos, buildAgent: macos-14 }
16 | ]
17 | uses: ./.github/workflows/workflow_build.yml
18 | secrets: inherit
19 | with:
20 | artifactSuffix: ${{ matrix.platform.os }}
21 | buildAgent: ${{ matrix.platform.buildAgent }}
22 |
--------------------------------------------------------------------------------
/.github/workflows/workflow_build.yml:
--------------------------------------------------------------------------------
1 | name: Build Workflow
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | checkFormat:
7 | type: boolean
8 | default: true
9 | artifactSuffix:
10 | type: string
11 | default: ubuntu
12 | buildAgent:
13 | type: string
14 | default: ubuntu-latest
15 | outputs:
16 | package_version:
17 | description: 'The version of the package that was built.'
18 | value: ${{ jobs.build.outputs.package_version }}
19 |
20 | env:
21 | # Stop wasting time caching packages
22 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
23 |
24 | jobs:
25 | build:
26 | name: Build
27 | runs-on: ${{ inputs.buildAgent }}
28 |
29 | outputs:
30 | package_version: ${{steps.version.outputs.package_version}}
31 |
32 | steps:
33 | - uses: actions/checkout@v4
34 | with:
35 | fetch-depth: 0
36 |
37 | - name: Install tools
38 | uses: ./.github/actions/install-tools
39 |
40 | - name: Set version variables
41 | id: version
42 | uses: ./.github/actions/version-vars
43 |
44 | - name: Restore
45 | run: dotnet restore
46 |
47 | - name: Format validation
48 | if: ${{ inputs.checkFormat }}
49 | run: dotnet format --no-restore --verify-no-changes
50 |
51 | - name: Build
52 | run: dotnet build --configuration Release --no-restore
53 |
54 | - name: Test
55 | run: dotnet test --configuration Release --no-build --verbosity normal /p:CollectCoverage=true
56 |
57 | - name: Upload coverage to Codecov
58 | uses: codecov/codecov-action@v5
59 | with:
60 | name: codecov
61 | directory: __artifacts/test-results
62 | token: ${{ secrets.CODECOV_TOKEN }}
63 | fail_ci_if_error: true
64 | verbose: true
65 |
66 | - name: Upload output artifact
67 | uses: actions/upload-artifact@v4
68 | with:
69 | name: output_${{ inputs.artifactSuffix }}
70 | path: __artifacts/bin
71 |
72 | - name: Upload package artifact
73 | uses: actions/upload-artifact@v4
74 | with:
75 | name: packages_${{ inputs.artifactSuffix }}
76 | path: __artifacts/package
77 |
--------------------------------------------------------------------------------
/.github/workflows/workflow_release.yml:
--------------------------------------------------------------------------------
1 | name: Release Workflow
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | package-version:
7 | type: string
8 | required: true
9 |
10 | jobs:
11 | release:
12 | name: Release
13 | runs-on: ubuntu-latest
14 | environment:
15 | name: NuGet.org
16 | url: https://www.nuget.org/packages/SlnUp/
17 |
18 | steps:
19 | - name: Setup NuGet
20 | uses: NuGet/setup-nuget@v2
21 | with:
22 | nuget-version: latest
23 |
24 | - name: Download packages artifact
25 | uses: actions/download-artifact@v4
26 | with:
27 | name: packages_ubuntu
28 | path: __artifacts/package
29 |
30 | - name: Push ${{ inputs.package-version }} to NuGet.org
31 | run: nuget push __artifacts/package/release/SlnUp.*.nupkg -ApiKey ${{ secrets.NUGET_API_KEY }} -Source https://api.nuget.org/v3/index.json
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __*
2 |
3 | # Created by https://www.toptal.com/developers/gitignore/api/macos,windows,visualstudio,visualstudiocode
4 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,visualstudio,visualstudiocode
5 |
6 | ### macOS ###
7 | # General
8 | .DS_Store
9 | .AppleDouble
10 | .LSOverride
11 |
12 | # Icon must end with two \r
13 | Icon
14 |
15 |
16 | # Thumbnails
17 | ._*
18 |
19 | # Files that might appear in the root of a volume
20 | .DocumentRevisions-V100
21 | .fseventsd
22 | .Spotlight-V100
23 | .TemporaryItems
24 | .Trashes
25 | .VolumeIcon.icns
26 | .com.apple.timemachine.donotpresent
27 |
28 | # Directories potentially created on remote AFP share
29 | .AppleDB
30 | .AppleDesktop
31 | Network Trash Folder
32 | Temporary Items
33 | .apdisk
34 |
35 | ### VisualStudioCode ###
36 | .vscode/*
37 | !.vscode/settings.json
38 | !.vscode/tasks.json
39 | !.vscode/launch.json
40 | !.vscode/extensions.json
41 | *.code-workspace
42 |
43 | # Local History for Visual Studio Code
44 | .history/
45 |
46 | ### VisualStudioCode Patch ###
47 | # Ignore all local history of files
48 | .history
49 | .ionide
50 |
51 | # Support for Project snippet scope
52 | !.vscode/*.code-snippets
53 |
54 | ### Windows ###
55 | # Windows thumbnail cache files
56 | Thumbs.db
57 | Thumbs.db:encryptable
58 | ehthumbs.db
59 | ehthumbs_vista.db
60 |
61 | # Dump file
62 | *.stackdump
63 |
64 | # Folder config file
65 | [Dd]esktop.ini
66 |
67 | # Recycle Bin used on file shares
68 | $RECYCLE.BIN/
69 |
70 | # Windows Installer files
71 | *.cab
72 | *.msi
73 | *.msix
74 | *.msm
75 | *.msp
76 |
77 | # Windows shortcuts
78 | *.lnk
79 |
80 | ### VisualStudio ###
81 | ## Ignore Visual Studio temporary files, build results, and
82 | ## files generated by popular Visual Studio add-ons.
83 | ##
84 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
85 |
86 | # User-specific files
87 | *.rsuser
88 | *.suo
89 | *.user
90 | *.userosscache
91 | *.sln.docstates
92 |
93 | # User-specific files (MonoDevelop/Xamarin Studio)
94 | *.userprefs
95 |
96 | # Mono auto generated files
97 | mono_crash.*
98 |
99 | # Build results
100 | [Dd]ebug/
101 | [Dd]ebugPublic/
102 | [Rr]elease/
103 | [Rr]eleases/
104 | x64/
105 | x86/
106 | [Ww][Ii][Nn]32/
107 | [Aa][Rr][Mm]/
108 | [Aa][Rr][Mm]64/
109 | bld/
110 | [Bb]in/
111 | [Oo]bj/
112 | [Ll]og/
113 | [Ll]ogs/
114 |
115 | # Visual Studio 2015/2017 cache/options directory
116 | .vs/
117 | # Uncomment if you have tasks that create the project's static files in wwwroot
118 | #wwwroot/
119 |
120 | # Visual Studio 2017 auto generated files
121 | Generated\ Files/
122 |
123 | # MSTest test Results
124 | [Tt]est[Rr]esult*/
125 | [Bb]uild[Ll]og.*
126 |
127 | # NUnit
128 | *.VisualState.xml
129 | TestResult.xml
130 | nunit-*.xml
131 |
132 | # Build Results of an ATL Project
133 | [Dd]ebugPS/
134 | [Rr]eleasePS/
135 | dlldata.c
136 |
137 | # Benchmark Results
138 | BenchmarkDotNet.Artifacts/
139 |
140 | # .NET Core
141 | project.lock.json
142 | project.fragment.lock.json
143 | artifacts/
144 |
145 | # ASP.NET Scaffolding
146 | ScaffoldingReadMe.txt
147 |
148 | # StyleCop
149 | StyleCopReport.xml
150 |
151 | # Files built by Visual Studio
152 | *_i.c
153 | *_p.c
154 | *_h.h
155 | *.ilk
156 | *.meta
157 | *.obj
158 | *.iobj
159 | *.pch
160 | *.pdb
161 | *.ipdb
162 | *.pgc
163 | *.pgd
164 | *.rsp
165 | !Directory.Build.rsp
166 | *.sbr
167 | *.tlb
168 | *.tli
169 | *.tlh
170 | *.tmp
171 | *.tmp_proj
172 | *_wpftmp.csproj
173 | *.log
174 | *.tlog
175 | *.vspscc
176 | *.vssscc
177 | .builds
178 | *.pidb
179 | *.svclog
180 | *.scc
181 |
182 | # Chutzpah Test files
183 | _Chutzpah*
184 |
185 | # Visual C++ cache files
186 | ipch/
187 | *.aps
188 | *.ncb
189 | *.opendb
190 | *.opensdf
191 | *.sdf
192 | *.cachefile
193 | *.VC.db
194 | *.VC.VC.opendb
195 |
196 | # Visual Studio profiler
197 | *.psess
198 | *.vsp
199 | *.vspx
200 | *.sap
201 |
202 | # Visual Studio Trace Files
203 | *.e2e
204 |
205 | # TFS 2012 Local Workspace
206 | $tf/
207 |
208 | # Guidance Automation Toolkit
209 | *.gpState
210 |
211 | # ReSharper is a .NET coding add-in
212 | _ReSharper*/
213 | *.[Rr]e[Ss]harper
214 | *.DotSettings.user
215 |
216 | # TeamCity is a build add-in
217 | _TeamCity*
218 |
219 | # DotCover is a Code Coverage Tool
220 | *.dotCover
221 |
222 | # AxoCover is a Code Coverage Tool
223 | .axoCover/*
224 | !.axoCover/settings.json
225 |
226 | # Coverlet is a free, cross platform Code Coverage Tool
227 | coverage*.json
228 | coverage*.xml
229 | coverage*.info
230 |
231 | # Visual Studio code coverage results
232 | *.coverage
233 | *.coveragexml
234 |
235 | # NCrunch
236 | _NCrunch_*
237 | .*crunch*.local.xml
238 | nCrunchTemp_*
239 |
240 | # MightyMoose
241 | *.mm.*
242 | AutoTest.Net/
243 |
244 | # Web workbench (sass)
245 | .sass-cache/
246 |
247 | # Installshield output folder
248 | [Ee]xpress/
249 |
250 | # DocProject is a documentation generator add-in
251 | DocProject/buildhelp/
252 | DocProject/Help/*.HxT
253 | DocProject/Help/*.HxC
254 | DocProject/Help/*.hhc
255 | DocProject/Help/*.hhk
256 | DocProject/Help/*.hhp
257 | DocProject/Help/Html2
258 | DocProject/Help/html
259 |
260 | # Click-Once directory
261 | publish/
262 |
263 | # Publish Web Output
264 | *.[Pp]ublish.xml
265 | *.azurePubxml
266 | # Note: Comment the next line if you want to checkin your web deploy settings,
267 | # but database connection strings (with potential passwords) will be unencrypted
268 | *.pubxml
269 | *.publishproj
270 |
271 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
272 | # checkin your Azure Web App publish settings, but sensitive information contained
273 | # in these scripts will be unencrypted
274 | PublishScripts/
275 |
276 | # NuGet Packages
277 | *.nupkg
278 | # NuGet Symbol Packages
279 | *.snupkg
280 | # The packages folder can be ignored because of Package Restore
281 | **/[Pp]ackages/*
282 | # except build/, which is used as an MSBuild target.
283 | !**/[Pp]ackages/build/
284 | # Uncomment if necessary however generally it will be regenerated when needed
285 | #!**/[Pp]ackages/repositories.config
286 | # NuGet v3's project.json files produces more ignorable files
287 | *.nuget.props
288 | *.nuget.targets
289 |
290 | # Nuget personal access tokens and Credentials
291 | # nuget.config
292 |
293 | # Microsoft Azure Build Output
294 | csx/
295 | *.build.csdef
296 |
297 | # Microsoft Azure Emulator
298 | ecf/
299 | rcf/
300 |
301 | # Windows Store app package directories and files
302 | AppPackages/
303 | BundleArtifacts/
304 | Package.StoreAssociation.xml
305 | _pkginfo.txt
306 | *.appx
307 | *.appxbundle
308 | *.appxupload
309 |
310 | # Visual Studio cache files
311 | # files ending in .cache can be ignored
312 | *.[Cc]ache
313 | # but keep track of directories ending in .cache
314 | !?*.[Cc]ache/
315 |
316 | # Others
317 | ClientBin/
318 | ~$*
319 | *~
320 | *.dbmdl
321 | *.dbproj.schemaview
322 | *.jfm
323 | *.pfx
324 | *.publishsettings
325 | orleans.codegen.cs
326 |
327 | # Including strong name files can present a security risk
328 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
329 | #*.snk
330 |
331 | # Since there are multiple workflows, uncomment next line to ignore bower_components
332 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
333 | #bower_components/
334 |
335 | # RIA/Silverlight projects
336 | Generated_Code/
337 |
338 | # Backup & report files from converting an old project file
339 | # to a newer Visual Studio version. Backup files are not needed,
340 | # because we have git ;-)
341 | _UpgradeReport_Files/
342 | Backup*/
343 | UpgradeLog*.XML
344 | UpgradeLog*.htm
345 | ServiceFabricBackup/
346 | *.rptproj.bak
347 |
348 | # SQL Server files
349 | *.mdf
350 | *.ldf
351 | *.ndf
352 |
353 | # Business Intelligence projects
354 | *.rdl.data
355 | *.bim.layout
356 | *.bim_*.settings
357 | *.rptproj.rsuser
358 | *- [Bb]ackup.rdl
359 | *- [Bb]ackup ([0-9]).rdl
360 | *- [Bb]ackup ([0-9][0-9]).rdl
361 |
362 | # Microsoft Fakes
363 | FakesAssemblies/
364 |
365 | # GhostDoc plugin setting file
366 | *.GhostDoc.xml
367 |
368 | # Node.js Tools for Visual Studio
369 | .ntvs_analysis.dat
370 | node_modules/
371 |
372 | # Visual Studio 6 build log
373 | *.plg
374 |
375 | # Visual Studio 6 workspace options file
376 | *.opt
377 |
378 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
379 | *.vbw
380 |
381 | # Visual Studio LightSwitch build output
382 | **/*.HTMLClient/GeneratedArtifacts
383 | **/*.DesktopClient/GeneratedArtifacts
384 | **/*.DesktopClient/ModelManifest.xml
385 | **/*.Server/GeneratedArtifacts
386 | **/*.Server/ModelManifest.xml
387 | _Pvt_Extensions
388 |
389 | # Paket dependency manager
390 | .paket/paket.exe
391 | paket-files/
392 |
393 | # FAKE - F# Make
394 | .fake/
395 |
396 | # CodeRush personal settings
397 | .cr/personal
398 |
399 | # Python Tools for Visual Studio (PTVS)
400 | __pycache__/
401 | *.pyc
402 |
403 | # Cake - Uncomment if you are using it
404 | # tools/**
405 | # !tools/packages.config
406 |
407 | # Tabs Studio
408 | *.tss
409 |
410 | # Telerik's JustMock configuration file
411 | *.jmconfig
412 |
413 | # BizTalk build output
414 | *.btp.cs
415 | *.btm.cs
416 | *.odx.cs
417 | *.xsd.cs
418 |
419 | # OpenCover UI analysis results
420 | OpenCover/
421 |
422 | # Azure Stream Analytics local run output
423 | ASALocalRun/
424 |
425 | # MSBuild Binary and Structured Log
426 | *.binlog
427 |
428 | # NVidia Nsight GPU debugger configuration file
429 | *.nvuser
430 |
431 | # MFractors (Xamarin productivity tool) working folder
432 | .mfractor/
433 |
434 | # Local History for Visual Studio
435 | .localhistory/
436 |
437 | # BeatPulse healthcheck temp database
438 | healthchecksdb
439 |
440 | # Backup folder for Package Reference Convert tool in Visual Studio 2017
441 | MigrationBackup/
442 |
443 | # Ionide (cross platform F# VS Code tools) working folder
444 | .ionide/
445 |
446 | # Fody - auto-generated XML schema
447 | FodyWeavers.xsd
448 |
449 | # VS Code files for those working on multiple tools
450 |
451 | # Local History for Visual Studio Code
452 |
453 | # Windows Installer files from build outputs
454 |
455 | # JetBrains Rider
456 | .idea/
457 | *.sln.iml
458 |
459 | ### VisualStudio Patch ###
460 | # Additional files built by Visual Studio
461 |
462 | # End of https://www.toptal.com/developers/gitignore/api/macos,windows,visualstudio,visualstudiocode
463 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "ms-dotnettools.csharp",
4 | "hbenl.vscode-test-explorer",
5 | "formulahendry.dotnet-test-explorer"
6 | ]
7 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "dotnet.defaultSolution": "slnup.slnx"
3 | }
--------------------------------------------------------------------------------
/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | $(MSBuildThisFileDirectory)
5 | true
6 | $(RepoRootPath)__artifacts
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Directory.Build.rsp:
--------------------------------------------------------------------------------
1 | /MaxCPUCount
2 | /NodeReuse:false
--------------------------------------------------------------------------------
/Directory.Packages.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 | true
9 |
10 |
11 |
12 | 22.0.14
13 |
14 |
15 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/Docs/BuildingAndTesting.md:
--------------------------------------------------------------------------------
1 | # Building and testing
2 |
3 | ## Requirements
4 |
5 | ### Using Visual Studio
6 |
7 | * [Visual Studio 2022 17.4+][download-vs]
8 | * You'll also need the [.NET 8 SDK][download-dotnet-8-sdk] and [.NET 9 SDK][download-dotnet-9-sdk].
9 |
10 | ### Visual Studio Code
11 |
12 | * [Visual Studio Code][download-vs-code]
13 | * Install recommended extensions.
14 | * [.NET 8 SDK][download-dotnet-8-sdk]
15 | * [.NET 9 SDK][download-dotnet-9-sdk]
16 |
17 | ## Build the tool
18 |
19 | To build the tool, run the following command:
20 |
21 | ``` shell
22 | dotnet build -c Release
23 | ```
24 |
25 | The tool will be packed into a `nupkg` file at `./__packages/NuGet/Release/`.
26 |
27 | ## Run the tests
28 |
29 | To run all the tests, simply run the following command:
30 |
31 | ``` shell
32 | dotnet test
33 | ```
34 |
35 | ## Managing a tool installation from local build output
36 |
37 | ### Install the tool
38 |
39 | These instructions assume you have previously [built](#build-the-tool) the tool.
40 |
41 | To install the tool, run the following command:
42 |
43 | ``` shell
44 | dotnet tool install -g SlnUp --add-source ./__packages/NuGet/Release/ --version
45 | ```
46 |
47 | ### Update the tool
48 |
49 | These instructions assume you have previously [built](#build-the-tool) and [installed](#install-the-tool) the tool.
50 |
51 | For stable release versions, run the following command:
52 |
53 | ``` shell
54 | dotnet tool update -g SlnUp --add-source ./__packages/NuGet/Release/
55 | ```
56 |
57 | For pre-release versions, you need to specify the `--prerelase` argument.
58 |
59 | ### Uninstall the tool
60 |
61 | These instructions assume you have previously [built](#build-the-tool) and [installed](#install-the-tool) the tool.
62 |
63 | To uninstall the tool, run the following command:
64 |
65 | ``` shell
66 | dotnet tool uninstall -g SlnUp
67 | ```
68 |
69 | ## Updating Visual Studio versions
70 |
71 | The VisualStudio.VersionScraper tool is used to retrieve and update the Visual Studio versions bundled with SlnUp.
72 |
73 | Simply run the following PowerShell script:
74 |
75 | ```powershell
76 | ./scripts/UpdateVSVersions.ps1
77 | ```
78 |
79 | [download-dotnet-8-sdk]: https://dotnet.microsoft.com/download/dotnet/8.0 "Download .NET 8.0"
80 | [download-dotnet-9-sdk]: https://dotnet.microsoft.com/download/dotnet/9.0 "Download .NET 9.0"
81 | [download-vs]: https://visualstudio.microsoft.com/downloads/ "Download Visual Studio"
82 | [download-vs-code]: https://code.visualstudio.com/Download "Download Visual Studio Code"
83 |
--------------------------------------------------------------------------------
/Docs/README.md:
--------------------------------------------------------------------------------
1 | # Documentation
2 |
3 | * [Building And Testing](./BuildingAndTesting.md)
4 | * [Research](./Research.md)
5 |
--------------------------------------------------------------------------------
/Docs/Research.md:
--------------------------------------------------------------------------------
1 | # Visual Studio solution updater research
2 |
3 | The goal of this research is to determine what's required to build a tool that
4 | allows you to easily update the Visual Studio version information in a solution
5 | file using a Visual Studio version number.
6 |
7 | ## Why?
8 |
9 | The Visual Studio selector tool, which determines which version of Visual Studio
10 | to open when you double-click a .sln file, uses the solution
11 | [file header](https://docs.microsoft.com/visualstudio/extensibility/internals/solution-dot-sln-file?view=vs-2022#file-header)
12 | to determine which version of Visual Studio to open.
13 |
14 | Updating a solution file with correct version information requires you to
15 | understand the solution file header and the Visual Studio version information.
16 |
17 | ## What to learn
18 |
19 | This research details the shape of solution files generated by various tools in
20 | order to understand what's required to handle updating the various versions of
21 | solution files.
22 |
23 | We'll also seek to understand the attributes of the Visual Studio version so
24 | that we can update solution files with them appropriately.
25 |
26 | ## The Visual Studio solution file header
27 |
28 | According to the [documentation](https://docs.microsoft.com/visualstudio/extensibility/internals/solution-dot-sln-file?view=vs-2022#file-header),
29 | the file header usually looks like this:
30 |
31 | ```
32 | Microsoft Visual Studio Solution File, Format Version 12.00
33 | # Visual Studio Version 16
34 | VisualStudioVersion = 16.0.28701.123
35 | MinimumVisualStudioVersion = 10.0.40219.1
36 | ```
37 |
38 | Standard header that defines the file format version.
39 | > `Microsoft Visual Studio Solution File, Format Version 12.00`
40 |
41 | The major version of Visual Studio that (most recently) saved this solution file.
42 | This information controls the version number in the solution icon.
43 | > `# Visual Studio Version 16`
44 |
45 | The full version of Visual Studio that (most recently) saved the solution file.
46 | If the solution file is saved by a newer version of Visual Studio that has the
47 | same major version, this value is not updated so as to lessen churn in the file.
48 | > `VisualStudioVersion = 16.0.28701.123`
49 |
50 | The minimum (oldest) version of Visual Studio that can open this solution file.
51 | > `MinimumVisualStudioVersion = 10.0.40219.1`
52 |
53 | This is accurate for Visual Studio 2019 and 2022.
54 |
55 | ### Visual Studio 2017
56 |
57 | Visual Studio 2017 has a slight variation of the file header, which is documented
58 | [here](https://docs.microsoft.com/visualstudio/extensibility/internals/solution-dot-sln-file?view=vs-2017#file-header).
59 |
60 | ```
61 | Microsoft Visual Studio Solution File, Format Version 12.00
62 | # Visual Studio 15
63 | VisualStudioVersion = 15.0.26730.15
64 | MinimumVisualStudioVersion = 10.0.40219.1
65 | ```
66 |
67 | Note the difference in the major version most recently saved: `# Visual Studio 15`
68 | vs `# Visual Studio Version 16` for Visual Studio 2019 and above.
69 |
70 | ## Common solution sources
71 |
72 | * Visual Studio templates
73 | * .NET CLI
74 | * [Visual Studio solution generator](https://microsoft.github.io/slngen/)
75 | (a.k.a. slngen)
76 |
77 | ### Generated by Visual Studio 2019
78 |
79 | This was generated by Visual Studio 2019 16.11.6 for a new C# .NET 5 console
80 | application:
81 |
82 | ```
83 |
84 | Microsoft Visual Studio Solution File, Format Version 12.00
85 | # Visual Studio Version 16
86 | VisualStudioVersion = 16.0.31829.152
87 | MinimumVisualStudioVersion = 10.0.40219.1
88 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Generated", "Generated\Generated.csproj", "{0416C64E-01C4-4541-8305-A55FBFA0388C}"
89 | EndProject
90 | Global
91 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
92 | Debug|Any CPU = Debug|Any CPU
93 | Release|Any CPU = Release|Any CPU
94 | EndGlobalSection
95 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
96 | {0416C64E-01C4-4541-8305-A55FBFA0388C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
97 | {0416C64E-01C4-4541-8305-A55FBFA0388C}.Debug|Any CPU.Build.0 = Debug|Any CPU
98 | {0416C64E-01C4-4541-8305-A55FBFA0388C}.Release|Any CPU.ActiveCfg = Release|Any CPU
99 | {0416C64E-01C4-4541-8305-A55FBFA0388C}.Release|Any CPU.Build.0 = Release|Any CPU
100 | EndGlobalSection
101 | GlobalSection(SolutionProperties) = preSolution
102 | HideSolutionNode = FALSE
103 | EndGlobalSection
104 | GlobalSection(ExtensibilityGlobals) = postSolution
105 | SolutionGuid = {554E3E08-EE1C-47D0-B207-103EBACAB529}
106 | EndGlobalSection
107 | EndGlobal
108 |
109 | ```
110 |
111 | The solution file contains all the expected header information.
112 |
113 | ### Generated by Visual Studio 2022
114 |
115 | This was generated by Visual Studio 2022 17.0 for a new C# .NET 6 console
116 | application.
117 |
118 | ```
119 |
120 | Microsoft Visual Studio Solution File, Format Version 12.00
121 | # Visual Studio Version 17
122 | VisualStudioVersion = 17.0.31903.59
123 | MinimumVisualStudioVersion = 10.0.40219.1
124 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Generated", "Generated\Generated.csproj", "{391BD0B8-2B52-46AA-98D1-9D9EDCA9FA19}"
125 | EndProject
126 | Global
127 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
128 | Debug|Any CPU = Debug|Any CPU
129 | Release|Any CPU = Release|Any CPU
130 | EndGlobalSection
131 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
132 | {391BD0B8-2B52-46AA-98D1-9D9EDCA9FA19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
133 | {391BD0B8-2B52-46AA-98D1-9D9EDCA9FA19}.Debug|Any CPU.Build.0 = Debug|Any CPU
134 | {391BD0B8-2B52-46AA-98D1-9D9EDCA9FA19}.Release|Any CPU.ActiveCfg = Release|Any CPU
135 | {391BD0B8-2B52-46AA-98D1-9D9EDCA9FA19}.Release|Any CPU.Build.0 = Release|Any CPU
136 | EndGlobalSection
137 | GlobalSection(SolutionProperties) = preSolution
138 | HideSolutionNode = FALSE
139 | EndGlobalSection
140 | GlobalSection(ExtensibilityGlobals) = postSolution
141 | SolutionGuid = {A2D44F49-9EB7-436B-8C84-67A937526801}
142 | EndGlobalSection
143 | EndGlobal
144 |
145 | ```
146 |
147 | The solution file contains all the expected header information.
148 |
149 | ### Generated by .NET CLI
150 |
151 | This was generated by the .NET 6 SDK 6.0.100.
152 |
153 | ```
154 |
155 | Microsoft Visual Studio Solution File, Format Version 12.00
156 | # Visual Studio Version 16
157 | VisualStudioVersion = 16.0.30114.105
158 | MinimumVisualStudioVersion = 10.0.40219.1
159 | Global
160 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
161 | Debug|Any CPU = Debug|Any CPU
162 | Release|Any CPU = Release|Any CPU
163 | EndGlobalSection
164 | GlobalSection(SolutionProperties) = preSolution
165 | HideSolutionNode = FALSE
166 | EndGlobalSection
167 | EndGlobal
168 |
169 | ```
170 |
171 | The solution file contains all the expected header information.
172 |
173 | ### Generated by Visual Studio solution generator
174 |
175 | This was generated by the Visual Studio solution generator (slngen) 7.2.0.
176 |
177 | ```
178 | Microsoft Visual Studio Solution File, Format Version 12.00
179 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cli", "Cli\Cli.csproj", "{7BB74C71-F9D6-416F-97CB-DB2C5C904FD0}"
180 | EndProject
181 | Global
182 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
183 | Debug|Any CPU = Debug|Any CPU
184 | Release|Any CPU = Release|Any CPU
185 | EndGlobalSection
186 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
187 | {7BB74C71-F9D6-416F-97CB-DB2C5C904FD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
188 | {7BB74C71-F9D6-416F-97CB-DB2C5C904FD0}.Debug|Any CPU.Build.0 = Debug|Any CPU
189 | {7BB74C71-F9D6-416F-97CB-DB2C5C904FD0}.Release|Any CPU.ActiveCfg = Release|Any CPU
190 | {7BB74C71-F9D6-416F-97CB-DB2C5C904FD0}.Release|Any CPU.Build.0 = Release|Any CPU
191 | EndGlobalSection
192 | GlobalSection(SolutionProperties) = preSolution
193 | HideSolutionNode = FALSE
194 | EndGlobalSection
195 | GlobalSection(ExtensibilityGlobals) = postSolution
196 | SolutionGuid = {1BB30EB4-5307-4751-A493-3746A4F75FCE}
197 | EndGlobalSection
198 | EndGlobal
199 |
200 | ```
201 |
202 | The solution file only contains the file format version and is missing:
203 |
204 | * The major version comment that most recently saved the file.
205 | * The `VisualStudioVersion`.
206 | * The `MinimumVisualStudioVersion`.
207 |
208 | ## Visual Studio version information
209 |
210 | The components of a Visual Studio version appear to be a simple 3 part version
211 | and a 4-part build version. There's also a channel which defines the release
212 | channel a version belongs to: Release or Preview.
213 |
214 | For example, [Visual Studio 2022 version information](https://docs.microsoft.com/visualstudio/install/visual-studio-build-numbers-and-release-dates?view=vs-2022)
215 | looks like this:
216 |
217 | | Version | Build Version | Channel |
218 | | --- | --- | --- |
219 | | 17.0.1 | 17.1.31903.286 | Preview 1 |
220 | | 17.0.0 | 17.0.31903.59 | Release |
221 | | 17.0.0 | 17.0.31825.309 | Preview 7 |
222 |
223 | ## Requirements
224 |
225 | * Support solution files with file format version `12.00`.
226 | * Support solution files with all header information.
227 | * Support solution files without all header information by adding the missing
228 | information for the version specified.
229 |
230 | ## Out of scope
231 |
232 | * Generating new solution files.
233 | * Support for anything pre-Visual Studio 2017.
234 |
235 | ## Resources
236 |
237 | * [Visual Studio solution files](https://docs.microsoft.com/visualstudio/extensibility/internals/solution-dot-sln-file?view=vs-2022)
238 |
239 | ### Visual Studio version numbers
240 |
241 | * [Visual Studio 2017 build numbers and release dates](https://docs.microsoft.com/visualstudio/install/visual-studio-build-numbers-and-release-dates?view=vs-2017)
242 | * [Visual Studio 2019 build numbers and release dates](https://docs.microsoft.com/visualstudio/install/visual-studio-build-numbers-and-release-dates?view=vs-2019)
243 | * [Visual Studio 2022 build numbers and release dates](https://docs.microsoft.com/visualstudio/install/visual-studio-build-numbers-and-release-dates?view=vs-2022)
244 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Craig Treasure
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Visual Studio solution updater
2 |
3 | This is a [.NET tool](https://docs.microsoft.com/en-us/dotnet/core/tools/global-tools)
4 | that allows you to easily change the Visual Studio version information in a
5 | solution file using a Visual Studio version number.
6 |
7 | 
8 | [](https://codecov.io/gh/craigktreasure/SlnUp)
9 | [](https://www.nuget.org/packages/SlnUp/)
10 | [](https://www.nuget.org/packages/SlnUp/)
11 |
12 | - [Visual Studio solution updater](#visual-studio-solution-updater)
13 | - [Why?](#why)
14 | - [How to use it](#how-to-use-it)
15 | - [Install the tool](#install-the-tool)
16 | - [Update the tool](#update-the-tool)
17 | - [Run the tool](#run-the-tool)
18 | - [How does it work?](#how-does-it-work)
19 | - [What is supported?](#what-is-supported)
20 |
21 | ## Why?
22 |
23 | The Visual Studio Version Selector tool, which determines which version of
24 | Visual Studio to open when you double-click a .sln file, uses the solution
25 | [file header][vs-sln-file-header] to determine which version of Visual Studio
26 | to open.
27 |
28 | Updating a solution file with correct version information requires you to
29 | understand the solution file header and the Visual Studio version information.
30 |
31 | ## How to use it
32 |
33 | ### Install the tool
34 |
35 | ```shell
36 | dotnet tool install -g SlnUp
37 | ```
38 |
39 | ### Update the tool
40 |
41 | ```shell
42 | dotnet tool update -g SlnUp
43 | ```
44 |
45 | ### Run the tool
46 |
47 | To view all available options, you can run:
48 |
49 | ```shell
50 | slnup --help
51 | ```
52 |
53 | The simplest form is to run the tool (`slnup`) from a directory containing a
54 | single solution (`.sln`) file. This will cause the tool to discover a solution
55 | file in the current directory and update it with the latest version.
56 |
57 | ```shell
58 | slnup
59 | ```
60 |
61 | You can also pass a Visual Studio product year (2017, 2019, or 2022) to update
62 | to the latest version for the specified product year.
63 |
64 | ```shell
65 | slnup 2022
66 | ```
67 |
68 | You can also specify a specific version of Visual Studio using a two-part
69 | (ex. 17.0) or three-part (17.1.0) version number:
70 |
71 | ```shell
72 | slnup 17.0
73 | ```
74 |
75 | A path to a solution file may also be specified using the `-p` or `--path`
76 | parameters. This will be necessary if there is more than one solution file
77 | in the current directory.
78 |
79 | ```shell
80 | slnup 2022 --path ./path/to/solution.sln
81 | ```
82 |
83 | If you want to specify the exact version information to be put into the solution
84 | file header, you can specify the version information like so:
85 |
86 | ```shell
87 | slnup 17.0 --build-version 17.0.31903.59
88 | ```
89 |
90 | ## How does it work?
91 |
92 | Visual Studio solution files have a well known [file header][vs-sln-file-header].
93 | The tool knows how to parse and update the solution file header and save the
94 | file. The tool also knows version information for builds of Visual Studio from
95 | Visual Studio 2017 to 2022. The version information specified, is used to update
96 | the file header.
97 |
98 | Consider a solution file with the following file header:
99 |
100 | ```text
101 | Microsoft Visual Studio Solution File, Format Version 12.00
102 | # Visual Studio Version 16
103 | VisualStudioVersion = 16.0.28701.123
104 | MinimumVisualStudioVersion = 10.0.40219.1
105 | ```
106 |
107 | This solution file header was last updated using Visual Studio 16 (a.k.a.
108 | Visual Studio 2019) with build number `16.0.28701.123`.
109 |
110 | When you double click on a solution file like this, the Visual Studio Version
111 | Selector tool will attempt to locate an installed version of Visual Studio that
112 | most closely matches the version information. If Visual Studio 2019 is installed,
113 | that will likely be used to open the solution file.
114 |
115 | Now, let's say you want to update the solution file to be opened with Visual
116 | Studio 2022. You could run the following to update the solution file with
117 | version information for Visual Studio 2022:
118 |
119 | ```shell
120 | slnup 2022
121 | ```
122 |
123 | In the case of `2022`, the tool will use the latest known version information
124 | it knows for that version. So, this would cause the tool to update the solution
125 | file header to the following:
126 |
127 | ```text
128 | Microsoft Visual Studio Solution File, Format Version 12.00
129 | # Visual Studio Version 17
130 | VisualStudioVersion = 17.0.31903.59
131 | MinimumVisualStudioVersion = 10.0.40219.1
132 | ```
133 |
134 | Now, when you double click on the updated solution file, the Visual Studio
135 | Version Selector tool will attempt to locate and open the solution file using
136 | Visual Studio 2022.
137 |
138 | ## What is supported?
139 |
140 | - The tool supports updating solution files with file format version `12.00`.
141 | - If the solution file does not contain a file format version, you will
142 | receive an error.
143 | - If the solution file header does not contain version information such as
144 | `# Visual Studio Version 17`, `VisualStudioVersion = 17.0.31903.59`, or
145 | `MinimumVisualStudioVersion = 10.0.40219.1`, then those values will be added
146 | to the file header.
147 | - The tool supports Visual Studio 2017, 2019, and 2022.
148 |
149 | [vs-sln-file-header]: https://docs.microsoft.com/visualstudio/extensibility/internals/solution-dot-sln-file?view=vs-2022#file-header "File header"
150 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | status:
3 | project:
4 | default:
5 | target: 95%
6 | threshold: 1%
7 |
--------------------------------------------------------------------------------
/eng/Defaults.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/eng/DotNetAnalyzers.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 | true
8 | AllEnabledByDefault
9 | true
10 | 9.0
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/eng/DotNetDefaults.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | false
6 |
7 | true
8 | true
9 |
10 |
13 | latest
14 |
15 |
18 | enable
19 |
20 |
21 | enable
22 |
23 |
24 | true
25 |
26 |
27 | true
28 |
29 |
30 | embedded
31 |
32 |
33 | true
34 |
35 |
36 | $(IsCIBuild)
37 |
38 |
39 |
40 |
43 | 1701;1702
44 |
45 |
46 |
47 | true
48 | true
49 |
50 |
51 |
52 |
53 | $(ArtifactsPath)/test-results/
54 | $(BaseTestResultsOutputPath)$(MSBuildProjectName)/
55 | $(BaseProjectTestResultsOutputPath)
56 | $(BaseProjectTestResultsOutputPath)
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/eng/Packaging.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 | Craig Treasure
9 | craigktreasure
10 | Copyright © Craig Treasure
11 | https://github.com/craigktreasure/SlnUp
12 | https://github.com/craigktreasure/SlnUp.git
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/global.json:
--------------------------------------------------------------------------------
1 | {
2 | "sdk": {
3 | "version": "9.0.300",
4 | "allowPrerelease": false,
5 | "rollForward": "latestPatch"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/nuget.config:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/scripts/PatchGlobalJson.ps1:
--------------------------------------------------------------------------------
1 | if ($true) {
2 | return
3 | }
4 |
5 | Write-Host 'Disabling rollForward to pin to version in global.json.'
6 | $globalJsonPath = Join-Path $PSScriptRoot '../global.json'
7 | $globalJson = Get-Content $globalJsonPath -Raw | ConvertFrom-Json
8 | $globalJson.sdk.rollForward = 'disable'
9 | $globalJson | ConvertTo-Json | Set-Content -Path $globalJsonPath
10 |
--------------------------------------------------------------------------------
/scripts/UpdateVSVersions.ps1:
--------------------------------------------------------------------------------
1 | #Requires -PSEdition Core
2 |
3 | $repoRoot = Join-Path $PSScriptRoot '..'
4 |
5 | Push-Location $repoRoot
6 |
7 | try {
8 | & dotnet run --project .\src\VisualStudio.VersionScraper\ -- .\src\SlnUp.Core\VersionManager.g.cs --format CSharp
9 | }
10 | finally {
11 | Pop-Location
12 | }
13 |
--------------------------------------------------------------------------------
/slnup.slnx:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/src/.editorconfig:
--------------------------------------------------------------------------------
1 | # Project specific settings
2 |
3 | [*.{cs,vb}]
4 |
5 | # CA1303: Do not pass literals as localized parameters
6 | dotnet_diagnostic.CA1303.severity = none
7 |
8 | # SYSLIB1045: Use GeneratedRegexAttribute to generate the regular expression implementation at compile time.
9 | # The GeneratedRegexAttribute is only available in .NET 7.
10 | dotnet_diagnostic.SYSLIB1045.severity = none
11 |
--------------------------------------------------------------------------------
/src/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | true
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/SlnUp.Core/ConsoleHelpers.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Core;
2 |
3 | using System;
4 |
5 | ///
6 | /// Class ConsoleHelpers.
7 | ///
8 | public static class ConsoleHelpers
9 | {
10 | ///
11 | /// Configures the console foreground color for errors for the lifetime of the .
12 | ///
13 | /// .
14 | public static IDisposable WithError()
15 | => WithForegroundColor(ConsoleColor.Red);
16 |
17 | ///
18 | /// Configures the console foreground color for the lifetime of the .
19 | ///
20 | /// The color.
21 | /// .
22 | public static IDisposable WithForegroundColor(ConsoleColor color)
23 | {
24 | ConsoleColor originalColor = Console.ForegroundColor;
25 | Console.ForegroundColor = color;
26 |
27 | return new ScopedAction(() => Console.ForegroundColor = originalColor);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/SlnUp.Core/Extensions/RegexExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Core.Extensions;
2 |
3 | using System.Diagnostics.CodeAnalysis;
4 | using System.Text.RegularExpressions;
5 |
6 | ///
7 | /// Class RegexExtensions.
8 | ///
9 | public static class RegexExtensions
10 | {
11 | ///
12 | /// Tries to match the specified .
13 | ///
14 | /// The regex.
15 | /// The input.
16 | /// The match.
17 | /// true if the match was successful, false otherwise.
18 | public static bool TryMatch(this Regex regex, string input, [NotNullWhen(true)] out Match? match)
19 | {
20 | ArgumentNullException.ThrowIfNull(regex);
21 |
22 | match = null;
23 | Match myMatch = regex.Match(input);
24 |
25 | if (myMatch.Success)
26 | {
27 | match = myMatch;
28 | }
29 |
30 | return match is not null;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/SlnUp.Core/Extensions/VersionExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Core.Extensions;
2 |
3 | using System;
4 |
5 | ///
6 | /// Class VersionExtensions.
7 | ///
8 | public static class VersionExtensions
9 | {
10 | ///
11 | /// Determines whether the versions have the same major and minor version numbers.
12 | ///
13 | /// The version.
14 | /// The other.
15 | /// true if the major and minor versions are the same; otherwise, false.
16 | public static bool HasSameMajorMinor(this Version version, Version other)
17 | {
18 | ArgumentNullException.ThrowIfNull(version);
19 | ArgumentNullException.ThrowIfNull(other);
20 |
21 | return version.Major == other.Major && version.Minor == other.Minor;
22 | }
23 |
24 | ///
25 | /// Determines if the version declares a 2-part version number.
26 | ///
27 | /// The version.
28 | /// true if the version declares a 2-part version number, false otherwise.
29 | public static bool Is2PartVersion(this Version version)
30 | {
31 | ArgumentNullException.ThrowIfNull(version);
32 | return version is { Major: >= 0, Minor: >= 0, Build: -1, Revision: -1 };
33 | }
34 |
35 | ///
36 | /// Determines if the version declares a 3-part version number.
37 | ///
38 | /// The version.
39 | /// true if the version declares a 3-part version number, false otherwise.
40 | public static bool Is3PartVersion(this Version version)
41 | {
42 | ArgumentNullException.ThrowIfNull(version);
43 | return version is { Major: >= 0, Minor: >= 0, Build: >= 0, Revision: -1 };
44 | }
45 |
46 | ///
47 | /// Determines if the version declares a 4-part version number.
48 | ///
49 | /// The version.
50 | /// true if the version declares a 4-part version number, false otherwise.
51 | public static bool Is4PartVersion(this Version version)
52 | {
53 | ArgumentNullException.ThrowIfNull(version);
54 | return version is { Major: >= 0, Minor: >= 0, Build: >= 0, Revision: >= 0 };
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/SlnUp.Core/ScopedAction.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Core;
2 |
3 | ///
4 | /// Class ScopedAction.
5 | /// Implements the
6 | ///
7 | ///
8 | public class ScopedAction : IDisposable
9 | {
10 | private readonly Action action;
11 |
12 | private bool disposedValue;
13 |
14 | ///
15 | /// Initializes a new instance of the class.
16 | ///
17 | /// The action.
18 | public ScopedAction(Action action)
19 | => this.action = action;
20 |
21 | ///
22 | /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
23 | ///
24 | public void Dispose()
25 | {
26 | this.Dispose(disposing: true);
27 | GC.SuppressFinalize(this);
28 | }
29 |
30 | ///
31 | /// Releases unmanaged and - optionally - managed resources.
32 | ///
33 | ///
34 | /// true to release both managed and unmanaged resources; false to release only unmanaged resources.
35 | ///
36 | protected virtual void Dispose(bool disposing)
37 | {
38 | if (!this.disposedValue)
39 | {
40 | if (disposing)
41 | {
42 | this.action?.Invoke();
43 | }
44 |
45 | this.disposedValue = true;
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/SlnUp.Core/SlnUp.Core.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net9.0;net8.0
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/SlnUp.Core/VersionManager.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Core;
2 |
3 | using System.Diagnostics.CodeAnalysis;
4 |
5 | using SlnUp.Core.Extensions;
6 |
7 | ///
8 | /// Manages versions of Visual Studio.
9 | ///
10 | public partial class VersionManager
11 | {
12 | private readonly IReadOnlyList versions;
13 |
14 | ///
15 | /// Initializes a new instance of the class.
16 | ///
17 | public VersionManager()
18 | : this(GetDefaultVersions())
19 | {
20 | }
21 |
22 | ///
23 | /// Initializes a new instance of the class.
24 | ///
25 | /// The versions.
26 | public VersionManager(IReadOnlyList versions)
27 | => this.versions = versions;
28 |
29 | ///
30 | /// Tries to parse a Visual Studio version (x.x[.x]) from the specified input.
31 | ///
32 | /// The input.
33 | /// The version.
34 | /// true if a valid Visual Studio version was parsed, false otherwise.
35 | public static bool TryParseVisualStudioVersion(string? input, [NotNullWhen(true)] out Version? version)
36 | {
37 | version = null;
38 |
39 | if (string.IsNullOrWhiteSpace(input))
40 | {
41 | return false;
42 | }
43 |
44 | if (Version.TryParse(input, out Version? potentialVersion)
45 | && (potentialVersion.Is2PartVersion() || potentialVersion.Is3PartVersion()))
46 | {
47 | version = potentialVersion;
48 | }
49 |
50 | return version is not null;
51 | }
52 |
53 | ///
54 | /// Gets a Visual Studio version from the specified input.
55 | /// Supported inputs are a 2 or 3-part version number or a valid product year.
56 | ///
57 | /// The version input.
58 | /// .
59 | public VisualStudioVersion? FromVersionParameter(string? versionInput)
60 | {
61 | if (versionInput is null)
62 | {
63 | return null;
64 | }
65 |
66 | if (TryParseVisualStudioVersion(versionInput, out Version? parsedVersion))
67 | {
68 | return this.FindLatestMatchingVersion(parsedVersion);
69 | }
70 | else if (TryParseVisualStudioProduct(versionInput, out VisualStudioProduct product))
71 | {
72 | return this.FindLatestMatchingVersion(product);
73 | }
74 |
75 | return null;
76 | }
77 |
78 | private static VisualStudioProduct GetVersionFromProductYear(int productYear)
79 | => productYear switch
80 | {
81 | 2017 => VisualStudioProduct.VisualStudio2017,
82 | 2019 => VisualStudioProduct.VisualStudio2019,
83 | 2022 => VisualStudioProduct.VisualStudio2022,
84 | _ => VisualStudioProduct.Unknown,
85 | };
86 |
87 | private static bool TryParseVisualStudioProduct(string input, out VisualStudioProduct product)
88 | {
89 | product = VisualStudioProduct.Unknown;
90 |
91 | if (int.TryParse(input, out int productYear))
92 | {
93 | product = GetVersionFromProductYear(productYear);
94 | }
95 |
96 | return product is not VisualStudioProduct.Unknown;
97 | }
98 |
99 | private VisualStudioVersion? FindLatestMatchingVersion(Version version)
100 | {
101 | VisualStudioVersion? versionResolved = null;
102 |
103 | if (version.Is3PartVersion())
104 | {
105 | // Expect an exact match.
106 | versionResolved = this.versions.FirstOrDefault(v => v.Version == version);
107 | }
108 |
109 | if (version.Is2PartVersion())
110 | {
111 | // Expect a major.minor match.
112 | versionResolved = this.versions.Where(v => v.Version.HasSameMajorMinor(version)).MaxBy(v => v.BuildVersion);
113 | }
114 |
115 | return versionResolved;
116 | }
117 |
118 | private VisualStudioVersion? FindLatestMatchingVersion(VisualStudioProduct product)
119 | => this.versions.Where(v => v.Product == product).MaxBy(v => v.BuildVersion);
120 | }
121 |
--------------------------------------------------------------------------------
/src/SlnUp.Core/VisualStudioProduct.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Core;
2 |
3 | ///
4 | /// Represents a Visual Studio product
5 | ///
6 | public enum VisualStudioProduct
7 | {
8 | ///
9 | /// The product is unknown.
10 | ///
11 | Unknown = 0,
12 |
13 | ///
14 | /// The Visual Studio 2017 product.
15 | ///
16 | VisualStudio2017 = 2017,
17 |
18 | ///
19 | /// The Visual Studio 2019 product.
20 | ///
21 | VisualStudio2019 = 2019,
22 |
23 | ///
24 | /// The Visual Studio 2022 product.
25 | ///
26 | VisualStudio2022 = 2022,
27 | }
28 |
--------------------------------------------------------------------------------
/src/SlnUp.Core/VisualStudioVersion.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Core;
2 |
3 | using System.Text;
4 |
5 | using Humanizer;
6 |
7 | ///
8 | /// Represents Visual Studio version details.
9 | ///
10 | public class VisualStudioVersion : IEquatable
11 | {
12 | ///
13 | /// Gets the Visual Studio build version.
14 | ///
15 | public Version BuildVersion { get; init; }
16 |
17 | ///
18 | /// Gets the Visual Studio channel.
19 | ///
20 | public string Channel { get; init; }
21 |
22 | ///
23 | /// Gets the Visual Studio full product title.
24 | ///
25 | public string FullProductTitle => this.ToString();
26 |
27 | ///
28 | /// Gets a value indicating whether the Visual Studio version is a preview.
29 | ///
30 | public bool IsPreview { get; init; }
31 |
32 | ///
33 | /// Gets the Visual Studio product.
34 | ///
35 | public VisualStudioProduct Product { get; init; }
36 |
37 | ///
38 | /// Gets the Visual Studio product title.
39 | ///
40 | public string ProductTitle => this.Product.Humanize().Transform(To.TitleCase);
41 |
42 | ///
43 | /// Gets the Visual Studio version.
44 | ///
45 | public Version Version { get; init; }
46 |
47 | ///
48 | /// Initializes a new instance of the class.
49 | ///
50 | /// The Visual Studio product.
51 | /// The Visual Studio version.
52 | /// The Visual Studio build version.
53 | /// The Visual Studio channel.
54 | /// if set to true, the Visual Studio version is a preview.
55 | public VisualStudioVersion(
56 | VisualStudioProduct product,
57 | Version version,
58 | Version buildVersion,
59 | string channel = "",
60 | bool isPreview = false)
61 | {
62 | this.Product = product;
63 | this.Version = version;
64 | this.BuildVersion = buildVersion;
65 | this.Channel = channel;
66 | this.IsPreview = isPreview;
67 | }
68 |
69 | ///
70 | /// Indicates whether the current object is equal to another object of the same type.
71 | ///
72 | /// An object to compare with this object.
73 | /// if the current object is equal to the parameter; otherwise, .
74 | public bool Equals(VisualStudioVersion? other)
75 | => other is not null && this.BuildVersion == other.BuildVersion;
76 |
77 | ///
78 | /// Determines whether the specified is equal to this instance.
79 | ///
80 | /// The object to compare with the current object.
81 | /// true if the specified is equal to this instance; otherwise, false.
82 | public override bool Equals(object? obj)
83 | => obj is VisualStudioVersion visualStudioVersion && this.Equals(visualStudioVersion);
84 |
85 | ///
86 | /// Returns a hash code for this instance.
87 | ///
88 | /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table.
89 | public override int GetHashCode() => this.BuildVersion.GetHashCode();
90 |
91 | ///
92 | /// Returns a that represents this instance.
93 | ///
94 | /// A that represents this instance.
95 | public override string ToString()
96 | {
97 | StringBuilder sb = new(this.ProductTitle);
98 |
99 | if (this.IsPreview)
100 | {
101 | sb.Append(' ').Append(this.Version);
102 |
103 | if (!string.IsNullOrWhiteSpace(this.Channel))
104 | {
105 | sb.Append(' ').Append(this.Channel);
106 | }
107 | }
108 | else
109 | {
110 | sb.Append(' ')
111 | .Append(this.Version.Major)
112 | .Append('.')
113 | .Append(this.Version.Minor);
114 | }
115 |
116 | return sb.ToString();
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/SlnUp.Json/Extensions/AssemblyExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Json.Extensions;
2 |
3 | using System;
4 | using System.Reflection;
5 |
6 | internal static class AssemblyExtensions
7 | {
8 | ///
9 | /// Gets the content of the embedded file resource.
10 | ///
11 | /// The assembly.
12 | /// The file path.
13 | /// .
14 | /// Resource not found in assembly - filePath
15 | public static string GetEmbeddedFileResourceContent(this Assembly assembly, string filePath)
16 | {
17 | using Stream stream = assembly.GetManifestResourceStream(filePath)
18 | ?? throw new ArgumentException("Resource not found in assembly", nameof(filePath));
19 | using StreamReader streamReader = new(stream);
20 | return streamReader.ReadToEnd();
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/SlnUp.Json/SlnUp.Json.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net9.0;net8.0
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/SlnUp.Json/VersionManagerJsonHelper.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Json;
2 |
3 | using System.Reflection;
4 |
5 | using SlnUp.Core;
6 | using SlnUp.Json.Extensions;
7 |
8 | ///
9 | /// A helper when loading a from json.
10 | ///
11 | public static class VersionManagerJsonHelper
12 | {
13 | ///
14 | /// Loads a from an embedded file resource.
15 | ///
16 | /// The assembly.
17 | /// The file path.
18 | /// .
19 | public static VersionManager LoadFromEmbeddedResource(Assembly assembly, string filePath)
20 | {
21 | ArgumentNullException.ThrowIfNull(assembly);
22 |
23 | string jsonContent = assembly.GetEmbeddedFileResourceContent(filePath);
24 |
25 | IReadOnlyList versions = VisualStudioVersionJsonHelper.FromJson(jsonContent);
26 |
27 | return new VersionManager(versions);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/SlnUp.Json/VisualStudioVersionJsonHelper.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Json;
2 |
3 | using System.IO.Abstractions;
4 | using System.Text.Json;
5 | using System.Text.Json.Serialization;
6 |
7 | using SlnUp.Core;
8 |
9 | ///
10 | /// A helper when converting data to and from json.
11 | ///
12 | public static class VisualStudioVersionJsonHelper
13 | {
14 | private static readonly JsonSerializerOptions serializerOptions = new()
15 | {
16 | IgnoreReadOnlyProperties = true,
17 | WriteIndented = true,
18 | Converters =
19 | {
20 | new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
21 | }
22 | };
23 |
24 | ///
25 | /// Deserializes version details from json content.
26 | ///
27 | /// The json.
28 | /// .
29 | /// The file did not contain valid json.
30 | public static IReadOnlyList FromJson(string json)
31 | => JsonSerializer.Deserialize>(json, serializerOptions)
32 | ?? throw new InvalidDataException("The file did not contain valid json for version details.");
33 |
34 | ///
35 | /// Loads from json.
36 | ///
37 | /// The file system.
38 | /// The file path.
39 | /// .
40 | public static IReadOnlyList FromJsonFile(IFileSystem fileSystem, string filePath)
41 | {
42 | ArgumentNullException.ThrowIfNull(fileSystem);
43 |
44 | return FromJson(fileSystem.File.ReadAllText(filePath));
45 | }
46 |
47 | ///
48 | /// Serializes to json content.
49 | ///
50 | /// The versions.
51 | public static string ToJson(IEnumerable versions)
52 | => JsonSerializer.Serialize(versions, serializerOptions);
53 |
54 | ///
55 | /// Saves to json.
56 | ///
57 | /// The file system.
58 | /// The versions.
59 | /// The file path.
60 | public static void ToJsonFile(IFileSystem fileSystem, IEnumerable versions, string filePath)
61 | {
62 | ArgumentNullException.ThrowIfNull(fileSystem);
63 |
64 | fileSystem.File.WriteAllText(filePath, ToJson(versions));
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/SlnUp/CLI/ArgumentParser.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.CLI;
2 |
3 | using System.CommandLine.Parsing;
4 | using System.Globalization;
5 |
6 | internal static class ArgumentParser
7 | {
8 | private const string CannotParseArgumentOption = "Cannot parse argument '{0}' for option '{1}' as expected type '{2}'.";
9 |
10 | #if NET8_0_OR_GREATER
11 | private static readonly System.Text.CompositeFormat CannotParseArgumentOptionFormat = System.Text.CompositeFormat.Parse(CannotParseArgumentOption);
12 | #endif
13 |
14 | public static Version? ParseVersion(ArgumentResult result)
15 | {
16 | string tokenValue = result.Tokens.Single().Value;
17 | if (Version.TryParse(tokenValue, out Version? version))
18 | {
19 | return version;
20 | }
21 |
22 | result.ErrorMessage = string.Format(
23 | CultureInfo.InvariantCulture,
24 | #if NET8_0_OR_GREATER
25 | CannotParseArgumentOptionFormat,
26 | #else
27 | CannotParseArgumentOption,
28 | #endif
29 | tokenValue,
30 | result.Argument.Name,
31 | result.Argument.ValueType);
32 | return null;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/SlnUp/CLI/ProgramOptions.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.CLI;
2 |
3 | using System.CommandLine;
4 | using System.CommandLine.NamingConventionBinder;
5 | using System.Diagnostics.CodeAnalysis;
6 | using System.IO.Abstractions;
7 |
8 | using SlnUp;
9 | using SlnUp.Core;
10 | using SlnUp.Core.Extensions;
11 |
12 | internal class ProgramOptions
13 | {
14 | public static string DefaultVersionArgument => VersionManager.LatestProductValue;
15 |
16 | public Version? BuildVersion { get; set; }
17 |
18 | public string? Path { get; set; }
19 |
20 | public string? Version { get; set; }
21 |
22 | public static RootCommand Configure(Func invokeAction)
23 | {
24 | Option pathOption = new(
25 | name: "--path",
26 | description: "The path to the solution file.");
27 | pathOption.AddAlias("-p");
28 |
29 | Argument versionArgument = new(
30 | name: "version",
31 | getDefaultValue: () => DefaultVersionArgument,
32 | description: "The Visual Studio version to update the solution file with. Can be either a product year (ex. 2017, 2019, or 2022) or a 2 or 3-part version number (ex. 16.9 or 17.0.1).");
33 |
34 | Option buildVersionOption = new(
35 | name: "--build-version",
36 | description: "Uses version information as specified with this build version number.",
37 | parseArgument: ArgumentParser.ParseVersion);
38 |
39 | const string version = ThisAssembly.IsPrerelease
40 | ? ThisAssembly.AssemblyInformationalVersion
41 | : ThisAssembly.AssemblyFileVersion;
42 | RootCommand rootCommand = new($"SlnUp {version}")
43 | {
44 | pathOption,
45 | buildVersionOption,
46 | versionArgument
47 | };
48 |
49 | rootCommand.Handler = CommandHandler.Create((ProgramOptions options) =>
50 | {
51 | return Task.FromResult(invokeAction(options));
52 | });
53 |
54 | return rootCommand;
55 | }
56 |
57 | ///
58 | /// Tries to get .
59 | ///
60 | /// The file system.
61 | /// The options.
62 | /// true if the options were retrieved, false otherwise.
63 | public bool TryGetSlnUpOptions(IFileSystem fileSystem, [NotNullWhen(true)] out SlnUpOptions? options)
64 | {
65 | options = null;
66 |
67 | if (!TryResolveSolutionFilePath(fileSystem, this.Path, out string? solutionFilePath))
68 | {
69 | return false;
70 | }
71 |
72 | if (!fileSystem.File.Exists(solutionFilePath))
73 | {
74 | using IDisposable _ = ConsoleHelpers.WithError();
75 | Console.WriteLine($"The solution file could not be found: '{solutionFilePath}'.");
76 | return false;
77 | }
78 |
79 | if (this.BuildVersion is not null)
80 | {
81 | // Use version information as provided.
82 | if (!this.BuildVersion.Is4PartVersion())
83 | {
84 | using IDisposable _ = ConsoleHelpers.WithError();
85 | Console.WriteLine("The build version must be a full 4-part version number.");
86 | return false;
87 | }
88 |
89 | if (!VersionManager.TryParseVisualStudioVersion(this.Version, out Version? version))
90 | {
91 | using IDisposable _ = ConsoleHelpers.WithError();
92 | Console.WriteLine("The version specified was not a valid 2 or 3-part version number.");
93 | return false;
94 | }
95 |
96 | options = new SlnUpOptions(solutionFilePath, version, this.BuildVersion);
97 | }
98 | else
99 | {
100 | // Try to lookup the version specified.
101 | VersionManager versionManager = new();
102 |
103 | VisualStudioVersion? version = versionManager.FromVersionParameter(this.Version);
104 |
105 | if (version is null)
106 | {
107 | using IDisposable _ = ConsoleHelpers.WithError();
108 | Console.WriteLine($"The version specified could not be resolved to a supported Visual Studio product version: '{this.Version}'.");
109 | return false;
110 | }
111 |
112 | options = new SlnUpOptions(solutionFilePath, version.Version, version.BuildVersion);
113 | }
114 |
115 | return true;
116 | }
117 |
118 | private static bool TryResolveSolutionFilePath(IFileSystem fileSystem, string? input, [NotNullWhen(true)] out string? solutionFilePath)
119 | {
120 | solutionFilePath = null;
121 | string currentDirectory = fileSystem.Directory.GetCurrentDirectory();
122 |
123 | if (input is null)
124 | {
125 | string[] solutionFiles = [.. fileSystem.Directory.EnumerateFiles(
126 | currentDirectory,
127 | "*.sln",
128 | SearchOption.TopDirectoryOnly)];
129 |
130 | if (solutionFiles.Length is 0)
131 | {
132 | using IDisposable _ = ConsoleHelpers.WithError();
133 |
134 | Console.WriteLine("No solution (.sln) files could be found in the current directory.");
135 |
136 | return false;
137 | }
138 |
139 | if (solutionFiles.Length > 1)
140 | {
141 | using IDisposable _ = ConsoleHelpers.WithError();
142 |
143 | Console.WriteLine("More than one solution (.sln) files were found in the current directory. Use the -p or --path paramter to specify one:");
144 | foreach (string solutionFile in solutionFiles)
145 | {
146 | Console.WriteLine($" - '{solutionFile}'");
147 | }
148 |
149 | return false;
150 | }
151 |
152 | solutionFilePath = solutionFiles[0];
153 | }
154 | else
155 | {
156 | if (fileSystem.Path.IsPathFullyQualified(input))
157 | {
158 | solutionFilePath = input;
159 | }
160 | else
161 | {
162 | // Assume it is a relative path to the current directory.
163 | solutionFilePath = fileSystem.Path.GetFullPath(input, currentDirectory);
164 | }
165 | }
166 |
167 | return solutionFilePath is not null;
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/src/SlnUp/Program.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp;
2 |
3 | using System.CommandLine;
4 | using System.Diagnostics.CodeAnalysis;
5 | using System.IO.Abstractions;
6 |
7 | using SlnUp.CLI;
8 | using SlnUp.Core;
9 |
10 | internal static class Program
11 | {
12 | public static int Main(string[] args)
13 | {
14 | Console.Title = ThisAssembly.AssemblyTitle;
15 |
16 | return ProgramOptions.Configure(Run).Invoke(args);
17 | }
18 |
19 | private static int Run(ProgramOptions options)
20 | => Run(new FileSystem(), options);
21 |
22 | private static int Run(IFileSystem fileSystem, ProgramOptions programOptions)
23 | {
24 | if (!programOptions.TryGetSlnUpOptions(fileSystem, out SlnUpOptions? options))
25 | {
26 | return 1;
27 | }
28 |
29 | return Run(fileSystem, options);
30 | }
31 |
32 | [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "By design.")]
33 | private static int Run(IFileSystem fileSystem, SlnUpOptions options)
34 | {
35 | try
36 | {
37 | SolutionFile solutionFile = new(fileSystem, options.SolutionFilePath);
38 | solutionFile.UpdateFileHeader(options.BuildVersion);
39 | }
40 | catch (Exception ex)
41 | {
42 | using IDisposable _ = ConsoleHelpers.WithError();
43 | Console.WriteLine(ex.Message);
44 | return 1;
45 | }
46 |
47 | return 0;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/SlnUp/SlnUp.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net9.0;net8.0
6 |
7 | true
8 | slnup
9 | true
10 | A tool for updating Visual Studio version information in solution files.
11 | README.md
12 | MIT
13 | false
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/SlnUp/SlnUpOptions.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp;
2 |
3 | using System;
4 | using System.Diagnostics.CodeAnalysis;
5 |
6 | ///
7 | /// Class SlnUpOptions.
8 | /// Implements the
9 | ///
10 | ///
11 | [ExcludeFromCodeCoverage]
12 | internal record SlnUpOptions(string SolutionFilePath, Version Version, Version BuildVersion);
13 |
--------------------------------------------------------------------------------
/src/SlnUp/SolutionFile.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp;
2 |
3 | using System.Diagnostics.CodeAnalysis;
4 | using System.IO.Abstractions;
5 | using System.Text.RegularExpressions;
6 |
7 | using SlnUp.Core.Extensions;
8 |
9 | internal class SolutionFile
10 | {
11 | private static readonly Regex fileFormatVersionRegex = new(
12 | @"^Microsoft Visual Studio Solution File, Format Version (\d+\.\d+)$",
13 | RegexOptions.Compiled);
14 |
15 | private static readonly Regex lastVisualStudioMajorVersionRegex = new(
16 | @"^# Visual Studio(?: Version)? (\d+)$",
17 | RegexOptions.Compiled);
18 |
19 | private static readonly Regex lastVisualStudioVersionRegex = new(
20 | @"^VisualStudioVersion = (\d+\.\d+\.\d+\.\d+)$",
21 | RegexOptions.Compiled);
22 |
23 | private static readonly Regex minimumVisualStudioVersionRegex = new(
24 | @"^MinimumVisualStudioVersion = (\d+\.\d+\.\d+\.\d+)$",
25 | RegexOptions.Compiled);
26 |
27 | private readonly string filePath;
28 |
29 | private readonly IFileSystem fileSystem;
30 |
31 | private int fileFormatLineNumber = -1;
32 |
33 | ///
34 | /// Gets the file header.
35 | ///
36 | public SolutionFileHeader FileHeader { get; private set; }
37 |
38 | ///
39 | /// Initializes a new instance of the class.
40 | ///
41 | /// The file system.
42 | /// The file path.
43 | /// The solution file could not be found.
44 | public SolutionFile(IFileSystem fileSystem, string filePath)
45 | {
46 | if (!fileSystem.File.Exists(filePath))
47 | {
48 | throw new FileNotFoundException("The solution file could not be found.", filePath);
49 | }
50 |
51 | this.fileSystem = fileSystem;
52 | this.filePath = filePath;
53 |
54 | this.FileHeader = this.LoadFileHeader();
55 | }
56 |
57 | ///
58 | /// Reads the file content.
59 | ///
60 | /// .
61 | public string ReadContent() => this.fileSystem.File.ReadAllText(this.filePath);
62 |
63 | ///
64 | /// Reload.
65 | ///
66 | public void Reload()
67 | => this.FileHeader = this.LoadFileHeader();
68 |
69 | ///
70 | /// Updates the file header.
71 | ///
72 | /// The new Visual Studio version.
73 | public void UpdateFileHeader(Version newVisualStudioVersion)
74 | {
75 | SolutionFileHeader newHeader = this.FileHeader.DuplicateAndUpdate(newVisualStudioVersion);
76 | this.UpdateFileHeader(newHeader);
77 | }
78 |
79 | internal void UpdateFileHeader(SolutionFileHeader fileHeader)
80 | {
81 | if (fileHeader.LastVisualStudioMajorVersion is null)
82 | {
83 | throw new InvalidDataException($"The {nameof(SolutionFileHeader.LastVisualStudioMajorVersion)} cannot be null.");
84 | }
85 |
86 | if (fileHeader.LastVisualStudioVersion is null)
87 | {
88 | throw new InvalidDataException($"The {nameof(SolutionFileHeader.LastVisualStudioVersion)} cannot be null.");
89 | }
90 |
91 | if (fileHeader.MinimumVisualStudioVersion is null)
92 | {
93 | // Inject a default minimum Visual Studio version.
94 | fileHeader = fileHeader with
95 | {
96 | MinimumVisualStudioVersion = Version.Parse(SolutionFileHeader.DefaultMinimumVisualStudioVersion)
97 | };
98 | }
99 |
100 | List lines = [.. this.fileSystem.File.ReadAllLines(this.filePath)];
101 |
102 | if (this.FileHeader.LastVisualStudioMajorVersion is null)
103 | {
104 | lines.Insert(this.fileFormatLineNumber + 1, string.Empty);
105 | }
106 |
107 | if (this.FileHeader.LastVisualStudioVersion is null)
108 | {
109 | lines.Insert(this.fileFormatLineNumber + 2, string.Empty);
110 | }
111 |
112 | if (this.FileHeader.MinimumVisualStudioVersion is null)
113 | {
114 | lines.Insert(this.fileFormatLineNumber + 3, string.Empty);
115 | }
116 |
117 | string fileFormatVersionLine = $"Microsoft Visual Studio Solution File, Format Version {fileHeader.FileFormatVersion}";
118 | lines[this.fileFormatLineNumber] = fileFormatVersionLine;
119 |
120 | string lastVisualStudioMajorVersionLine = fileHeader.LastVisualStudioMajorVersion >= 16
121 | ? $"# Visual Studio Version {fileHeader.LastVisualStudioMajorVersion}"
122 | : $"# Visual Studio {fileHeader.LastVisualStudioMajorVersion}";
123 |
124 | lines[this.fileFormatLineNumber + 1] = lastVisualStudioMajorVersionLine;
125 |
126 | string lastVisualStudioVersionLine = $"VisualStudioVersion = {fileHeader.LastVisualStudioVersion}";
127 | lines[this.fileFormatLineNumber + 2] = lastVisualStudioVersionLine;
128 |
129 | string minimumVisualStudioVersionLine = $"MinimumVisualStudioVersion = {fileHeader.MinimumVisualStudioVersion}";
130 | lines[this.fileFormatLineNumber + 3] = minimumVisualStudioVersionLine;
131 |
132 | this.fileSystem.File.WriteAllLines(this.filePath, lines);
133 |
134 | this.FileHeader = fileHeader;
135 | }
136 |
137 | private static bool TryGetFileFormat(string line, [NotNullWhen(true)] out string? fileFormatVersion)
138 | {
139 | fileFormatVersion = null;
140 |
141 | if (fileFormatVersionRegex.TryMatch(line, out Match? fileFormatVersionMatch))
142 | {
143 | fileFormatVersion = fileFormatVersionMatch.Groups[1].Value;
144 | }
145 |
146 | return fileFormatVersion is not null;
147 | }
148 |
149 | private static bool TryGetLastVisualStudioMajorVersion(string line, [NotNullWhen(true)] out int? lastMajorVersion)
150 | {
151 | lastMajorVersion = null;
152 |
153 | if (lastVisualStudioMajorVersionRegex.TryMatch(line, out Match? majorVersionMatch)
154 | && int.TryParse(majorVersionMatch.Groups[1].Value, out int parsedLastMajorVersion))
155 | {
156 | lastMajorVersion = parsedLastMajorVersion;
157 | }
158 |
159 | return lastMajorVersion is not null;
160 | }
161 |
162 | private static bool TryGetLastVisualStudioVersion(string line, [NotNullWhen(true)] out Version? lastVersion)
163 | {
164 | lastVersion = null;
165 |
166 | if (lastVisualStudioVersionRegex.TryMatch(line, out Match? majorVersionMatch)
167 | && Version.TryParse(majorVersionMatch.Groups[1].Value, out Version? parsedLastVersion))
168 | {
169 | lastVersion = parsedLastVersion;
170 | }
171 |
172 | return lastVersion is not null;
173 | }
174 |
175 | private static bool TryGetMinimumVisualStudioVersion(string line, [NotNullWhen(true)] out Version? minimumVersion)
176 | {
177 | minimumVersion = null;
178 |
179 | if (minimumVisualStudioVersionRegex.TryMatch(line, out Match? minimumVersionMatch)
180 | && Version.TryParse(minimumVersionMatch.Groups[1].Value, out Version? parsedMinimumVersion))
181 | {
182 | minimumVersion = parsedMinimumVersion;
183 | }
184 |
185 | return minimumVersion is not null;
186 | }
187 |
188 | private static bool TryLocateFileFormatLine(
189 | string[] lines,
190 | out int fileFormatLineNumber,
191 | [NotNullWhen(true)] out string? fileFormatVersion)
192 | {
193 | fileFormatLineNumber = -1;
194 | fileFormatVersion = null;
195 |
196 | for (int i = 0; i < lines.Length; ++i)
197 | {
198 | if (TryGetFileFormat(lines[i], out fileFormatVersion))
199 | {
200 | fileFormatLineNumber = i;
201 | break;
202 | }
203 | }
204 |
205 | return fileFormatLineNumber != -1;
206 | }
207 |
208 | private SolutionFileHeader LoadFileHeader()
209 | {
210 | string[] lines = this.fileSystem.File.ReadAllLines(this.filePath);
211 |
212 | if (!TryLocateFileFormatLine(lines, out this.fileFormatLineNumber, out string? fileFormatVersion))
213 | {
214 | throw new InvalidDataException($"The file does not contain a valid file format: '{this.filePath}'.");
215 | }
216 |
217 | SolutionFileHeader fileHeader = new(fileFormatVersion);
218 |
219 | int nextLine = this.fileFormatLineNumber + 1;
220 | if (lines.Length > nextLine + 1
221 | && TryGetLastVisualStudioMajorVersion(lines[nextLine], out int? lastMajorVersion))
222 | {
223 | fileHeader = fileHeader with
224 | {
225 | LastVisualStudioMajorVersion = lastMajorVersion,
226 | };
227 | ++nextLine;
228 | }
229 |
230 | if (lines.Length > nextLine + 1
231 | && TryGetLastVisualStudioVersion(lines[nextLine], out Version? lastVersion))
232 | {
233 | fileHeader = fileHeader with
234 | {
235 | LastVisualStudioVersion = lastVersion,
236 | };
237 | ++nextLine;
238 | }
239 |
240 | if (lines.Length > nextLine + 1
241 | && TryGetMinimumVisualStudioVersion(lines[nextLine], out Version? minimumVersion))
242 | {
243 | fileHeader = fileHeader with
244 | {
245 | MinimumVisualStudioVersion = minimumVersion,
246 | };
247 | }
248 |
249 | return fileHeader;
250 | }
251 | }
252 |
--------------------------------------------------------------------------------
/src/SlnUp/SolutionFileHeader.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp;
2 |
3 | internal record SolutionFileHeader
4 | {
5 | public const string DefaultMinimumVisualStudioVersion = "10.0.40219.1";
6 |
7 | public const string SupportedFileFormatVersion = "12.00";
8 |
9 | ///
10 | /// Gets or sets the file format version.
11 | ///
12 | public string FileFormatVersion { get; init; }
13 |
14 | ///
15 | /// Gets or sets the last Visual Studio major version.
16 | ///
17 | public int? LastVisualStudioMajorVersion { get; init; }
18 |
19 | ///
20 | /// Gets or sets the last Visual Studio version.
21 | ///
22 | public Version? LastVisualStudioVersion { get; init; }
23 |
24 | ///
25 | /// Gets or sets the minimum Visual Studio version.
26 | ///
27 | public Version? MinimumVisualStudioVersion { get; init; }
28 |
29 | ///
30 | /// Initializes a new instance of the class.
31 | ///
32 | /// The file format version.
33 | /// The last Visual Studio major version.
34 | /// The last Visual Studio version.
35 | /// The minimum Visual Studio version.
36 | /// Only file format version {SupportedFileFormatVersion} is supported. - fileFormatVersion
37 | public SolutionFileHeader(
38 | string fileFormatVersion,
39 | int? lastVisualStudioMajorVersion = null,
40 | Version? lastVisualStudioVersion = null,
41 | Version? minimumVisualStudioVersion = null)
42 | {
43 | if (fileFormatVersion != SupportedFileFormatVersion)
44 | {
45 | throw new ArgumentException($"Only file format version {SupportedFileFormatVersion} is supported.", nameof(fileFormatVersion));
46 | }
47 |
48 | this.FileFormatVersion = fileFormatVersion;
49 | this.LastVisualStudioMajorVersion = lastVisualStudioMajorVersion;
50 | this.LastVisualStudioVersion = lastVisualStudioVersion;
51 | this.MinimumVisualStudioVersion = minimumVisualStudioVersion;
52 | }
53 |
54 | ///
55 | /// Duplicates this instance and updates with the specified version information.
56 | ///
57 | /// The new visual studio version.
58 | /// .
59 | public SolutionFileHeader DuplicateAndUpdate(Version newVisualStudioVersion) => this with
60 | {
61 | LastVisualStudioMajorVersion = newVisualStudioVersion.Major,
62 | LastVisualStudioVersion = newVisualStudioVersion,
63 | };
64 | }
65 |
--------------------------------------------------------------------------------
/src/VisualStudio.VersionScraper/OutputFormat.cs:
--------------------------------------------------------------------------------
1 | namespace VisualStudio.VersionScraper;
2 |
3 | internal enum OutputFormat
4 | {
5 | Json,
6 |
7 | CSharp,
8 | }
9 |
--------------------------------------------------------------------------------
/src/VisualStudio.VersionScraper/Program.cs:
--------------------------------------------------------------------------------
1 | namespace VisualStudio.VersionScraper;
2 |
3 | using System.CommandLine;
4 | using System.IO.Abstractions;
5 |
6 | using SlnUp.Core;
7 | using SlnUp.Json;
8 |
9 | using VisualStudio.VersionScraper.Writers.CSharp;
10 |
11 | internal static class Program
12 | {
13 | public static int Main(string[] args)
14 | {
15 | Console.Title = "Visual Studio Version Scraper";
16 |
17 | return ProgramOptions.Configure(Run).Invoke(args);
18 | }
19 |
20 | private static int Run(ProgramOptions options)
21 | {
22 | if (string.IsNullOrWhiteSpace(options.OutputFilePath))
23 | {
24 | Console.WriteLine("Invalid file path.");
25 | return 1;
26 | }
27 |
28 | VisualStudioVersionDocScraper docScraper = new(useCache: !options.NoCache);
29 |
30 | IEnumerable versions = docScraper.ScrapeVisualStudioVersions()
31 | .Where(v => !v.IsPreview)
32 | .OrderByDescending(x => x.BuildVersion);
33 |
34 | IFileSystem fileSystem = new FileSystem();
35 |
36 | switch (options.Format)
37 | {
38 | case OutputFormat.Json:
39 | VisualStudioVersionJsonHelper.ToJsonFile(fileSystem, versions, options.OutputFilePath);
40 | break;
41 |
42 | case OutputFormat.CSharp:
43 | CSharpVersionWriter csharpWriter = new(fileSystem);
44 | csharpWriter.WriteClassToFile(versions, options.OutputFilePath);
45 | break;
46 |
47 | default:
48 | throw new NotSupportedException($"The format is not supported: '{options.Format}'.");
49 | }
50 |
51 | using (ConsoleHelpers.WithForegroundColor(ConsoleColor.Green))
52 | {
53 | Console.WriteLine($"Done writing {options.Format} to '{options.OutputFilePath}'.");
54 | }
55 |
56 | return 0;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/VisualStudio.VersionScraper/ProgramOptions.cs:
--------------------------------------------------------------------------------
1 | namespace VisualStudio.VersionScraper;
2 |
3 | using System.CommandLine;
4 | using System.Diagnostics.CodeAnalysis;
5 |
6 | [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Created at runtime.")]
7 | internal sealed class ProgramOptions
8 | {
9 | public OutputFormat Format { get; set; }
10 |
11 | public bool NoCache { get; set; }
12 |
13 | public string? OutputFilePath { get; set; }
14 |
15 | public static RootCommand Configure(Func invokeAction)
16 | {
17 | Argument outputArgument = new(
18 | name: "output",
19 | description: "The output file path.");
20 |
21 | Option formatOption = new(
22 | name: "--format",
23 | description: "The output file format.");
24 | formatOption.AddAlias("-f");
25 |
26 | Option noCacheOption = new(
27 | name: "--no-cache",
28 | description: "Skip the cache when making requests.");
29 |
30 | RootCommand rootCommand = new("Visual Studio Version Scraper")
31 | {
32 | outputArgument,
33 | formatOption,
34 | noCacheOption,
35 | };
36 |
37 | rootCommand.SetHandler(options =>
38 | {
39 | return Task.FromResult(invokeAction(options));
40 | }, new ProgramOptionsBinder(outputArgument, formatOption, noCacheOption));
41 |
42 | return rootCommand;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/VisualStudio.VersionScraper/ProgramOptionsBinder.cs:
--------------------------------------------------------------------------------
1 | namespace VisualStudio.VersionScraper;
2 |
3 | using System.CommandLine;
4 | using System.CommandLine.Binding;
5 |
6 | internal sealed class ProgramOptionsBinder : BinderBase
7 | {
8 | private readonly Option formatOption;
9 |
10 | private readonly Option noCacheOption;
11 |
12 | private readonly Argument outputArgument;
13 |
14 | public ProgramOptionsBinder(
15 | Argument outputArgument,
16 | Option formatOption,
17 | Option noCacheOption)
18 | {
19 | ArgumentNullException.ThrowIfNull(outputArgument);
20 | ArgumentNullException.ThrowIfNull(formatOption);
21 | ArgumentNullException.ThrowIfNull(noCacheOption);
22 |
23 | this.outputArgument = outputArgument;
24 | this.formatOption = formatOption;
25 | this.noCacheOption = noCacheOption;
26 | }
27 |
28 | protected override ProgramOptions GetBoundValue(BindingContext bindingContext) => new()
29 | {
30 | OutputFilePath = bindingContext.ParseResult.GetValueForArgument(this.outputArgument),
31 | Format = bindingContext.ParseResult.GetValueForOption(this.formatOption),
32 | NoCache = bindingContext.ParseResult.GetValueForOption(this.noCacheOption),
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/src/VisualStudio.VersionScraper/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "profiles": {
3 | "VisualStudioVersionScraper": {
4 | "commandName": "Project"
5 | },
6 | "Generate": {
7 | "commandName": "Project",
8 | "commandLineArgs": "../../../../../../src/SlnUp/Versions.json"
9 | },
10 | "Generate (no-cache)": {
11 | "commandName": "Project",
12 | "commandLineArgs": "../../../../../../src/SlnUp/Versions.json --no-cache"
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/src/VisualStudio.VersionScraper/VisualStudio.VersionScraper.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net9.0
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/VisualStudio.VersionScraper/VisualStudioVersionDocScraper.cs:
--------------------------------------------------------------------------------
1 | namespace VisualStudio.VersionScraper;
2 |
3 | using System.Diagnostics.CodeAnalysis;
4 | using System.Globalization;
5 | using System.Text.RegularExpressions;
6 |
7 | using HtmlAgilityPack;
8 |
9 | using SlnUp.Core;
10 | using SlnUp.Core.Extensions;
11 |
12 | internal sealed class VisualStudioVersionDocScraper
13 | {
14 | private const string buildNumberColumnName = "Build Number";
15 |
16 | private const string buildVersionColumnName = "Build version";
17 |
18 | private const string channelColumnName = "Channel";
19 |
20 | private const string releaseDateColumnName = "Release Date";
21 |
22 | private const string versionColumnName = "Version";
23 |
24 | private const string vs2017VersionsDocUrl = "https://docs.microsoft.com/en-us/previous-versions/visualstudio/visual-studio-2017/install/visual-studio-build-numbers-and-release-dates";
25 |
26 | private const string vs2019VersionsDocUrl = "https://learn.microsoft.com/en-us/visualstudio/releases/2019/history";
27 |
28 | private const string vsCurrentVersionsDocUrl = "https://docs.microsoft.com/en-us/visualstudio/install/visual-studio-build-numbers-and-release-dates";
29 |
30 | private static readonly Regex vs15PreviewVersionMatcher = new(
31 | @"(?\d+\.\d+\.?\d*) (?Preview \d\.?\d*)",
32 | RegexOptions.Compiled);
33 |
34 | private readonly bool useCache;
35 |
36 | ///
37 | /// Initializes a new instance of the class.
38 | ///
39 | /// if set to true, use a cache.
40 | public VisualStudioVersionDocScraper(bool useCache = true)
41 | => this.useCache = useCache;
42 |
43 | ///
44 | /// Scrapes the visual studio versions from the documentation.
45 | ///
46 | /// .
47 | public IEnumerable ScrapeVisualStudioVersions()
48 | {
49 | HashSet versions =
50 | [
51 | .. this.ScrapeVisualStudioVersions(vsCurrentVersionsDocUrl, "VSCurrentVersionCache"),
52 | .. this.ScrapeVisualStudioVersions(vs2019VersionsDocUrl, "VS2019VersionCache"),
53 | .. this.ScrapeVisualStudioVersions(vs2017VersionsDocUrl, "VS2017VersionCache"),
54 | ];
55 | return versions;
56 | }
57 |
58 | private static VisualStudioVersion GetVersionDetailFromRow(RowData row)
59 | {
60 | string versionInput = row.Version;
61 | string channel = row.Channel ?? string.Empty;
62 |
63 | if (vs15PreviewVersionMatcher.TryMatch(versionInput, out Match? match)
64 | && Version.TryParse(match.Groups["version"].Value, out Version? version))
65 | {
66 | if (version.Build == -1)
67 | {
68 | // Ensure a 3-part build number.
69 | version = new Version(version.Major, version.Minor, 0);
70 | }
71 |
72 | // The VS 2017 version and channel can be specified differently for preview versions.
73 | channel = match.Groups["channel"].Value;
74 | }
75 | else if (!Version.TryParse(versionInput, out version))
76 | {
77 | throw new InvalidDataException($"First column did not contain a valid version value: '{versionInput}'.");
78 | }
79 |
80 | if (!Version.TryParse(row.BuildNumber, out Version? buildVersion))
81 | {
82 | throw new InvalidDataException($"Third column did not contain a valid build version value: '{row.BuildNumber}'.");
83 | }
84 |
85 | VisualStudioProduct vsVersion = GetVisualStudioVersion(version);
86 | bool isPreview = channel.StartsWith("Preview", StringComparison.OrdinalIgnoreCase);
87 |
88 | return new VisualStudioVersion(vsVersion, version, buildVersion, channel, isPreview);
89 | }
90 |
91 | private static VisualStudioProduct GetVisualStudioVersion(Version version)
92 | => GetVisualStudioVersion(version.Major);
93 |
94 | private static VisualStudioProduct GetVisualStudioVersion(int majorVersion) => majorVersion switch
95 | {
96 | 15 => VisualStudioProduct.VisualStudio2017,
97 | 16 => VisualStudioProduct.VisualStudio2019,
98 | 17 => VisualStudioProduct.VisualStudio2022,
99 | _ => VisualStudioProduct.Unknown
100 | };
101 |
102 | private static bool TryGetColumnIndices(HtmlNode table, out int versionIndex, out int releaseDateIndex, out int buildNumberIndex, out int? channelIndex)
103 | {
104 | const int notSet = -1;
105 | versionIndex = notSet;
106 | releaseDateIndex = notSet;
107 | buildNumberIndex = notSet;
108 | channelIndex = null;
109 |
110 | HtmlNodeCollection headings = table.SelectNodes("thead//th")
111 | ?? throw new InvalidOperationException("Unable to locate table headings.");
112 |
113 | string[] columnNames = [.. headings.Select(x => x.InnerText.Trim())];
114 |
115 | for (int i = 0; i < columnNames.Length; i++)
116 | {
117 | string columnName = columnNames[i];
118 |
119 | if (versionIndex is notSet
120 | && columnName.Equals(versionColumnName, StringComparison.OrdinalIgnoreCase))
121 | {
122 | versionIndex = i;
123 | }
124 | else if (releaseDateIndex is notSet
125 | && columnName.Equals(releaseDateColumnName, StringComparison.OrdinalIgnoreCase))
126 | {
127 | releaseDateIndex = i;
128 | }
129 | else if (buildNumberIndex is notSet
130 | && (columnName.Equals(buildNumberColumnName, StringComparison.OrdinalIgnoreCase)
131 | || columnName.Equals(buildVersionColumnName, StringComparison.OrdinalIgnoreCase)))
132 | {
133 | buildNumberIndex = i;
134 | }
135 | else if (!channelIndex.HasValue
136 | && columnName.Equals(channelColumnName, StringComparison.OrdinalIgnoreCase))
137 | {
138 | channelIndex = i;
139 | }
140 | }
141 |
142 | return versionIndex is not notSet
143 | && releaseDateIndex is not notSet
144 | && buildNumberIndex is not notSet;
145 | }
146 |
147 | private static bool TryGetTableData(
148 | HtmlNode table,
149 | [NotNullWhen(true)]
150 | out IReadOnlyCollection? result)
151 | {
152 | result = null;
153 |
154 | if (table is null)
155 | {
156 | return false;
157 | }
158 |
159 | if (!TryGetColumnIndices(table, out int versionIndex, out int releaseDateIndex, out int buildNumberIndex, out int? channelIndex))
160 | {
161 | return false;
162 | }
163 |
164 | HtmlNodeCollection? rows = table.SelectNodes("tbody/tr");
165 |
166 | if (rows is null || rows.Count is 0)
167 | {
168 | return false;
169 | }
170 |
171 | List data = [];
172 |
173 | foreach (HtmlNode row in rows)
174 | {
175 | HtmlNodeCollection tds = row.SelectNodes("td")
176 | ?? throw new InvalidOperationException("Unable to locate table data.");
177 |
178 | string version = tds[versionIndex].InnerText.Trim();
179 | string releaseDate = tds[releaseDateIndex].InnerText.Trim();
180 | string buildNumber = tds[buildNumberIndex].InnerText.Trim();
181 | string? channel = channelIndex.HasValue
182 | ? tds[channelIndex.Value].InnerText.Trim()
183 | : null;
184 |
185 | data.Add(new RowData(version, releaseDate, buildNumber, channel));
186 | }
187 |
188 | result = data;
189 | return true;
190 | }
191 |
192 | private HtmlDocument LoadVisualStudioVersionDocument(string url, string cacheFolderName)
193 | {
194 | #pragma warning disable IO0006 // Replace Path class with IFileSystem.Path for improved testability
195 | string cachePath = Path.Join(Path.GetTempPath(), cacheFolderName);
196 | #pragma warning restore IO0006 // Replace Path class with IFileSystem.Path for improved testability
197 | HtmlWeb web = new()
198 | {
199 | CachePath = cachePath,
200 | UsingCache = this.useCache,
201 | };
202 |
203 | UriBuilder uriBuilder = new(url);
204 | if (this.useCache)
205 | {
206 | // Add a cache query parameter.
207 | uriBuilder.Query = $"?cache={DateTime.Now.Date.ToString("MM-dd-yyyy", CultureInfo.CurrentCulture)}";
208 | }
209 |
210 | HtmlDocument doc = web.Load(uriBuilder.Uri);
211 |
212 | return doc;
213 | }
214 |
215 | private IEnumerable ScrapeVisualStudioVersions(string url, string cacheFolderName)
216 | {
217 | HtmlDocument doc = this.LoadVisualStudioVersionDocument(url, cacheFolderName);
218 |
219 | HtmlNodeCollection tables = doc.DocumentNode.SelectNodes("//table")
220 | ?? throw new InvalidOperationException("Unable to locate any tables.");
221 |
222 | if (tables.Count == 0)
223 | {
224 | throw new InvalidDataException("No tables were found.");
225 | }
226 |
227 | IReadOnlyCollection? tableData = null;
228 |
229 | if (tables.Count > 1)
230 | {
231 | // Too many tables were found. We need to see if we can determine which one.
232 | foreach (HtmlNode table in tables)
233 | {
234 | if (TryGetTableData(table, out tableData))
235 | {
236 | break;
237 | }
238 | }
239 | }
240 | else
241 | {
242 | TryGetTableData(tables.Single(), out tableData);
243 | }
244 |
245 | if (tableData is null)
246 | {
247 | throw new InvalidDataException("Unable to locate a suitable table or unable to retrieve table data.");
248 | }
249 |
250 | foreach (RowData rowData in tableData)
251 | {
252 | yield return GetVersionDetailFromRow(rowData);
253 | }
254 | }
255 |
256 | private sealed record RowData(string Version, string ReleaseDate, string BuildNumber, string? Channel);
257 | }
258 |
--------------------------------------------------------------------------------
/src/VisualStudio.VersionScraper/Writers/CSharp/CSharpVersionWriter.cs:
--------------------------------------------------------------------------------
1 | namespace VisualStudio.VersionScraper.Writers.CSharp;
2 |
3 | using System.IO.Abstractions;
4 |
5 | using SlnUp.Core;
6 |
7 | internal sealed class CSharpVersionWriter
8 | {
9 | private const string ClassName = "VersionManager";
10 |
11 | private const string GetVersionsMethodName = "GetDefaultVersions";
12 |
13 | private const string NamespaceName = "SlnUp.Core";
14 |
15 | private readonly IFileSystem fileSystem;
16 |
17 | public CSharpVersionWriter(IFileSystem fileSystem)
18 | {
19 | ArgumentNullException.ThrowIfNull(fileSystem);
20 |
21 | this.fileSystem = fileSystem;
22 | }
23 |
24 | public void WriteClassToFile(IEnumerable versions, string filePath)
25 | {
26 | using StreamWriter streamWriter = this.fileSystem.File.CreateText(filePath);
27 | CodeWriter writer = new(streamWriter);
28 | WriteHeader(writer);
29 | writer.WriteLine();
30 | WriteClass(writer, versions);
31 | }
32 |
33 | private static void WriteClass(CodeWriter writer, IEnumerable versions)
34 | {
35 | writer.WriteLine($"namespace {NamespaceName};");
36 | writer.WriteLine();
37 | writer.WriteLine($"[System.CodeDom.Compiler.GeneratedCodeAttribute(\"{ThisAssembly.AssemblyName}\", \"{ThisAssembly.AssemblyVersion}\")]");
38 | writer.WriteLine($"public partial class {ClassName}");
39 | using (writer.WithBrackets())
40 | {
41 | WriteLatestVersions(writer);
42 | writer.WriteLine();
43 | WriteGetVersionsMethod(writer, versions);
44 | }
45 | }
46 |
47 | private static void WriteGetVersionsMethod(CodeWriter writer, IEnumerable versions)
48 | {
49 | writer.WriteLines(@"
50 | ///
51 | /// Gets the default versions.
52 | ///
53 | /// .");
54 | writer.WriteLine($"public static IReadOnlyList<{nameof(VisualStudioVersion)}> {GetVersionsMethodName}()");
55 | using (writer.WithBrackets())
56 | {
57 | writer.WriteLine("return new []");
58 |
59 | using (writer.WithBrackets(closingSemicolon: true))
60 | {
61 | foreach (VisualStudioVersion version in versions)
62 | {
63 | string isPreview = version.IsPreview ? "true" : "false";
64 | writer.WriteLine($"new {nameof(VisualStudioVersion)}({nameof(VisualStudioProduct)}.{version.Product}, Version.Parse(\"{version.Version}\"), Version.Parse(\"{version.BuildVersion}\"), \"{version.Channel}\", {isPreview}),");
65 | }
66 | }
67 | }
68 | }
69 |
70 | private static void WriteHeader(CodeWriter writer)
71 | {
72 | writer.WriteLines($@"
73 | //------------------------------------------------------------------------------
74 | //
75 | // This code was generated by a tool on {ThisAssembly.GitCommitDate:d}.
76 | //
77 | // Changes to this file may cause incorrect behavior and will be lost if
78 | // the code is regenerated.
79 | //
80 | //------------------------------------------------------------------------------");
81 | }
82 |
83 | private static void WriteLatestVersions(CodeWriter writer)
84 | {
85 | VisualStudioProduct latestProduct = Enum.GetValues().Max();
86 | writer.WriteLines(@"
87 | ///
88 | /// The latest product.
89 | /// ");
90 | writer.WriteLine($"public const {nameof(VisualStudioProduct)} LatestProduct = {nameof(VisualStudioProduct)}.{latestProduct};");
91 | writer.WriteLine();
92 | writer.WriteLines(@"
93 | ///
94 | /// The latest product value.
95 | /// ");
96 | writer.WriteLine($"public const string LatestProductValue = \"{(int)latestProduct}\";");
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/VisualStudio.VersionScraper/Writers/CodeWriter.cs:
--------------------------------------------------------------------------------
1 | namespace VisualStudio.VersionScraper.Writers;
2 |
3 | using System;
4 | using System.Diagnostics.CodeAnalysis;
5 |
6 | using SlnUp.Core;
7 |
8 | internal sealed class CodeWriter
9 | {
10 | private readonly int indentSpaces = 4;
11 |
12 | private readonly StreamWriter writer;
13 |
14 | private int currentIndentationLevel;
15 |
16 | ///
17 | /// Initializes a new instance of the class.
18 | ///
19 | /// The writer.
20 | public CodeWriter(StreamWriter writer)
21 | {
22 | ArgumentNullException.ThrowIfNull(writer);
23 |
24 | this.writer = writer;
25 | }
26 |
27 | ///
28 | /// Indents the code.
29 | ///
30 | public void Indent() => ++this.currentIndentationLevel;
31 |
32 | ///
33 | /// Unindents the code.
34 | ///
35 | public void Unindent() => --this.currentIndentationLevel;
36 |
37 | ///
38 | /// Creates a scope with brackets.
39 | ///
40 | /// if set to true, the closing bracket will include a semicolon.
41 | /// .
42 | public IDisposable WithBrackets(bool closingSemicolon = false)
43 | {
44 | this.WriteLine("{");
45 | this.Indent();
46 | return new ScopedAction(() =>
47 | {
48 | this.Unindent();
49 |
50 | if (closingSemicolon)
51 | {
52 | this.WriteLine("};");
53 | }
54 | else
55 | {
56 | this.WriteLine("}");
57 | }
58 | });
59 | }
60 |
61 | ///
62 | /// Creates a scope with indentation.
63 | ///
64 | /// .
65 | public IDisposable WithIndent()
66 | {
67 | this.Indent();
68 | return new ScopedAction(this.Unindent);
69 | }
70 |
71 | ///
72 | /// Writes the line.
73 | ///
74 | /// The value.
75 | /// .
76 | [SuppressMessage("Globalization", "CA1307:Specify StringComparison for clarity")]
77 | public CodeWriter WriteLine(string? value = null)
78 | {
79 | if (value is not null)
80 | {
81 | if (value.Contains(Environment.NewLine))
82 | {
83 | throw new InvalidOperationException("Use WriteLines to write content that contains multiple lines.");
84 | }
85 |
86 | this.WriteIndentation();
87 | }
88 |
89 | this.writer.WriteLine(value);
90 |
91 | return this;
92 | }
93 |
94 | ///
95 | /// Writes the lines.
96 | ///
97 | /// The value.
98 | /// .
99 | public CodeWriter WriteLines(string value)
100 | {
101 | foreach (string line in value.Split(Environment.NewLine, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
102 | {
103 | this.WriteLine(line);
104 | }
105 |
106 | return this;
107 | }
108 |
109 | private string GetIndentationWhiteSpace() => new(' ', this.indentSpaces * this.currentIndentationLevel);
110 |
111 | private void WriteIndentation() => this.writer.Write(this.GetIndentationWhiteSpace());
112 | }
113 |
--------------------------------------------------------------------------------
/tests/.editorconfig:
--------------------------------------------------------------------------------
1 | root = false
2 |
3 | # Project specific settings
4 |
5 | [*.cs]
6 |
7 | # CS1591: Missing XML comment for publicly visible type or member
8 | dotnet_diagnostic.CS1591.severity = none
9 |
10 | # CA1515: Consider making public types internal
11 | dotnet_diagnostic.CA1515.severity = none
12 |
13 | # CA1707: Identifiers should not contain underscores
14 | dotnet_diagnostic.CA1707.severity = none
15 |
--------------------------------------------------------------------------------
/tests/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | true
5 |
6 |
7 | false
8 |
9 |
10 | false
11 |
12 |
13 | true
14 |
15 |
16 | GeneratedCodeAttribute,CompilerGeneratedAttribute
17 | cobertura
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/tests/SlnUp.Core.Tests/Extensions/VersionExtensionsTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Core.Tests.Extensions;
2 |
3 | using SlnUp.Core.Extensions;
4 |
5 | public class VersionExtensionsTests
6 | {
7 | [Theory]
8 | [InlineData("0.0", "0.0", true)]
9 | [InlineData("0.0", "0.1", false)]
10 | [InlineData("0.0.0", "0.0.0", true)]
11 | [InlineData("0.0.0", "0.1.0", false)]
12 | [InlineData("0.0.0", "0.0.1", true)]
13 | [InlineData("0.0.0.0", "0.0.0.0", true)]
14 | [InlineData("0.0.0.0", "0.1.0.0", false)]
15 | [InlineData("0.0.0.0", "0.0.0.1", true)]
16 | public void HasSameMajorMinor(string versionAInput, string versionBInput, bool expectedResult)
17 | {
18 | // Arrange
19 | Version versionA = Version.Parse(versionAInput);
20 | Version versionB = Version.Parse(versionBInput);
21 |
22 | // Act
23 | bool result = versionA.HasSameMajorMinor(versionB);
24 |
25 | // Assert
26 | Assert.Equal(expectedResult, result);
27 | }
28 |
29 | [Theory]
30 | [InlineData("0.0", true)]
31 | [InlineData("0.0.0", false)]
32 | [InlineData("0.0.0.0", false)]
33 | public void Is2PartVersion(string versionInput, bool expectedResult)
34 | {
35 | // Arrange
36 | Version version = Version.Parse(versionInput);
37 |
38 | // Act
39 | bool result = version.Is2PartVersion();
40 |
41 | // Assert
42 | Assert.Equal(expectedResult, result);
43 | }
44 |
45 | [Theory]
46 | [InlineData("0.0", false)]
47 | [InlineData("0.0.0", true)]
48 | [InlineData("0.0.0.0", false)]
49 | public void Is3PartVersion(string versionInput, bool expectedResult)
50 | {
51 | // Arrange
52 | Version version = Version.Parse(versionInput);
53 |
54 | // Act
55 | bool result = version.Is3PartVersion();
56 |
57 | // Assert
58 | Assert.Equal(expectedResult, result);
59 | }
60 |
61 | [Theory]
62 | [InlineData("0.0", false)]
63 | [InlineData("0.0.0", false)]
64 | [InlineData("0.0.0.0", true)]
65 | public void Is4PartVersion(string versionInput, bool expectedResult)
66 | {
67 | // Arrange
68 | Version version = Version.Parse(versionInput);
69 |
70 | // Act
71 | bool result = version.Is4PartVersion();
72 |
73 | // Assert
74 | Assert.Equal(expectedResult, result);
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/tests/SlnUp.Core.Tests/ScopedActionTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Core.Tests;
2 |
3 | public class ScopedActionTests
4 | {
5 | [Fact]
6 | public void ScopedAction_Invoked()
7 | {
8 | // Arrange
9 | int actionCalled = 0;
10 | ScopedAction action = new(() => ++actionCalled);
11 |
12 | // Act
13 | action.Dispose();
14 | action.Dispose();
15 |
16 | // Assert
17 | Assert.Equal(1, actionCalled);
18 | }
19 |
20 | [Fact]
21 | public void ScopedAction_InvokedFromUsing()
22 | {
23 | // Arrange
24 | bool actionCalled = false;
25 |
26 | // Act
27 | {
28 | using ScopedAction action = new(() => actionCalled = true);
29 | }
30 |
31 | // Assert
32 | Assert.True(actionCalled);
33 | }
34 |
35 | [Fact]
36 | public void ScopedAction_Null()
37 | {
38 | // Arrange
39 | ScopedAction action = new(null!);
40 |
41 | // Act and assert
42 | action.Dispose();
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/tests/SlnUp.Core.Tests/SlnUp.Core.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net9.0;net8.0
5 | Exe
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | %(Identity)
16 |
17 |
18 |
19 |
20 |
21 | all
22 | runtime; build; native; contentfiles; analyzers; buildtransitive
23 |
24 |
25 | all
26 | runtime; build; native; contentfiles; analyzers
27 |
28 |
29 |
30 |
31 |
32 |
33 | all
34 | runtime; build; native; contentfiles; analyzers; buildtransitive
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/tests/SlnUp.Core.Tests/VersionManagerTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Core.Tests;
2 |
3 | using System.Diagnostics.CodeAnalysis;
4 |
5 | using SlnUp.Json;
6 |
7 | public class VersionManagerTests
8 | {
9 | [Fact]
10 | [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider")]
11 | public void Construct()
12 | {
13 | // Act
14 | VersionManager versionManager = new();
15 |
16 | // Assert
17 | foreach (VisualStudioProduct product in Enum.GetValues())
18 | {
19 | if (product is VisualStudioProduct.Unknown)
20 | {
21 | continue;
22 | }
23 |
24 | VisualStudioVersion? version = versionManager.FromVersionParameter(((int)product).ToString());
25 | Assert.NotNull(version);
26 | }
27 | }
28 |
29 | [Fact]
30 | [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider")]
31 | public void Construct_Versions()
32 | {
33 | // Arrange
34 | IReadOnlyList versions =
35 | [
36 | new VisualStudioVersion(VisualStudioProduct.VisualStudio2022, Version.Parse("17.2.5"), Version.Parse("17.2.32616.157"), "Release", false),
37 | ];
38 |
39 | // Act
40 | VersionManager versionManager = new(versions);
41 |
42 | // Assert
43 | Assert.NotNull(versionManager.FromVersionParameter(((int)VisualStudioProduct.VisualStudio2022).ToString()));
44 | Assert.Null(versionManager.FromVersionParameter(((int)VisualStudioProduct.VisualStudio2017).ToString()));
45 | }
46 |
47 | [Theory]
48 | [InlineData(null, false, null)]
49 | [InlineData("", false, null)]
50 | [InlineData(" ", false, null)]
51 | [InlineData("0", false, null)]
52 | [InlineData("0.0", false, null)]
53 | [InlineData("0.0.0", false, null)]
54 | [InlineData("0.0.0.0", false, null)]
55 | [InlineData("15.2", true, "15.0.26430.16")]
56 | [InlineData("15.2.5", true, "15.0.26430.15")]
57 | [InlineData("15.99", false, null)]
58 | [InlineData("15.2.99", false, null)]
59 | [InlineData("2017", true, "15.9.28307.1778")]
60 | [InlineData("16.9", true, "16.9.32106.192")]
61 | [InlineData("16.7.21", true, "16.7.31828.227")]
62 | [InlineData("16.99", false, null)]
63 | [InlineData("16.7.99", false, null)]
64 | [InlineData("2019", true, "16.11.32106.194")]
65 | [InlineData("17.0", true, "17.0.32112.339")]
66 | [InlineData("17.0.0", true, "17.0.31903.59")]
67 | [InlineData("17.99", false, null)]
68 | [InlineData("17.0.99", false, null)]
69 | [InlineData("2022", true, "17.0.32112.339")]
70 | public void FromVersionParameter(string? input, bool expectFound, string? expectedBuildVersion)
71 | {
72 | // Arrange
73 | if (expectFound && expectedBuildVersion is null)
74 | {
75 | throw new ArgumentNullException(nameof(expectedBuildVersion), "We expect a value to be found but an expected build version wasn't provided.");
76 | }
77 |
78 | VersionManager versionManager = VersionManagerJsonHelper.LoadFromEmbeddedResource(typeof(VersionManagerTests).Assembly, "TestVersions.json");
79 |
80 | // Act
81 | VisualStudioVersion? version = versionManager.FromVersionParameter(input);
82 |
83 | // Assert
84 | if (!expectFound)
85 | {
86 | Assert.Null(version);
87 | }
88 | else
89 | {
90 | Assert.NotNull(version);
91 | Assert.Equal(Version.Parse(expectedBuildVersion!), version.BuildVersion);
92 | }
93 | }
94 |
95 | [Theory]
96 | [InlineData(null, false)]
97 | [InlineData("", false)]
98 | [InlineData(" ", false)]
99 | [InlineData("0", false)]
100 | [InlineData("0.0", true)]
101 | [InlineData("0.0.0", true)]
102 | [InlineData("0.0.0.0", false)]
103 | [InlineData("2022", false)]
104 | public void TryParseVisualStudioVersion(string? input, bool expectedResult)
105 | {
106 | // Act
107 | bool result = VersionManager.TryParseVisualStudioVersion(input, out Version? version);
108 |
109 | // Assert
110 | Assert.Equal(expectedResult, result);
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/tests/SlnUp.Core.Tests/VisualStudioVersionTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Core.Tests;
2 |
3 | public class VisualStudioVersionTests
4 | {
5 | private static readonly IReadOnlyList commonVersions =
6 | [
7 | new VisualStudioVersion(
8 | VisualStudioProduct.VisualStudio2019,
9 | Version.Parse("16.11.6"),
10 | Version.Parse("16.11.31829.152"),
11 | "Release"),
12 | new VisualStudioVersion(
13 | VisualStudioProduct.VisualStudio2022,
14 | Version.Parse("17.0.0"),
15 | Version.Parse("17.0.31903.59"),
16 | "Release")
17 | ];
18 |
19 | [Fact]
20 | public void Constructor()
21 | {
22 | // Arrange
23 | const string expectedChannel = "Release";
24 | const string expectedFullVersionTitle = "Visual Studio 2022 17.0";
25 | const string expectedVersionTitle = "Visual Studio 2022";
26 | const string expectedVersion = "17.0.0";
27 | const string expectedBuildVersion = "17.0.31903.59";
28 |
29 | // Act
30 | VisualStudioVersion version = new(
31 | VisualStudioProduct.VisualStudio2022,
32 | Version.Parse(expectedVersion),
33 | Version.Parse(expectedBuildVersion),
34 | expectedChannel);
35 |
36 | // Assert
37 | Assert.Equal(expectedChannel, version.Channel);
38 | Assert.Equal(expectedVersion, version.Version.ToString());
39 | Assert.Equal(expectedBuildVersion, version.BuildVersion.ToString());
40 | Assert.Equal(expectedVersionTitle, version.ProductTitle);
41 | Assert.Equal(expectedFullVersionTitle, version.FullProductTitle);
42 | }
43 |
44 | [Fact]
45 | public void Constructor_WithPreview()
46 | {
47 | // Arrange
48 | const string expectedChannel = "Preview 1";
49 | const string expectedFullVersionTitle = "Visual Studio 2022 17.0.1 Preview 1";
50 | const string expectedVersionTitle = "Visual Studio 2022";
51 | const string expectedVersion = "17.0.1";
52 | const string expectedBuildVersion = "17.1.31903.286";
53 |
54 | // Act
55 | VisualStudioVersion version = new(
56 | VisualStudioProduct.VisualStudio2022,
57 | Version.Parse(expectedVersion),
58 | Version.Parse(expectedBuildVersion),
59 | expectedChannel,
60 | isPreview: true);
61 |
62 | // Assert
63 | Assert.Equal(expectedChannel, version.Channel);
64 | Assert.Equal(expectedVersion, version.Version.ToString());
65 | Assert.Equal(expectedBuildVersion, version.BuildVersion.ToString());
66 | Assert.Equal(expectedVersionTitle, version.ProductTitle);
67 | Assert.Equal(expectedFullVersionTitle, version.FullProductTitle);
68 | }
69 |
70 | [Fact]
71 | public void Equals_NullParameter_ReturnsFalse()
72 | {
73 | // Arrange
74 | VisualStudioVersion version = commonVersions[0];
75 | VisualStudioVersion? other = null;
76 |
77 | // Act
78 | bool result = version.Equals(other);
79 |
80 | // Assert
81 | Assert.False(result);
82 | }
83 |
84 | [Fact]
85 | public void Equals_SameReference_ReturnsTrue()
86 | {
87 | // Arrange
88 | VisualStudioVersion version = commonVersions[0];
89 | VisualStudioVersion? other = commonVersions[0];
90 |
91 | // Act
92 | bool result = version.Equals(other);
93 |
94 | // Assert
95 | Assert.True(result);
96 | }
97 |
98 | [Fact]
99 | public void Equals_SameValue_ReturnsTrue()
100 | {
101 | // Arrange
102 | VisualStudioVersion version = new(
103 | VisualStudioProduct.VisualStudio2022,
104 | Version.Parse("17.0.0"),
105 | Version.Parse("17.0.31903.59"),
106 | "Release");
107 | VisualStudioVersion? other = new(
108 | VisualStudioProduct.VisualStudio2022,
109 | Version.Parse("17.0.0"),
110 | Version.Parse("17.0.31903.59"),
111 | "Release");
112 |
113 | // Act
114 | bool result = version.Equals(other);
115 |
116 | // Assert
117 | Assert.True(result);
118 | }
119 |
120 | [Fact]
121 | public void Equals_WithObject_NullParameter_ReturnsFalse()
122 | {
123 | // Arrange
124 | VisualStudioVersion version = commonVersions[0];
125 | object? other = null;
126 |
127 | // Act
128 | bool result = version.Equals(other);
129 |
130 | // Assert
131 | Assert.False(result);
132 | }
133 |
134 | [Fact]
135 | public void Equals_WithObject_SameValue_ReturnsTrue()
136 | {
137 | // Arrange
138 | VisualStudioVersion version = new(
139 | VisualStudioProduct.VisualStudio2022,
140 | Version.Parse("17.0.0"),
141 | Version.Parse("17.0.31903.59"),
142 | "Release");
143 | object? other = new VisualStudioVersion(
144 | VisualStudioProduct.VisualStudio2022,
145 | Version.Parse("17.0.0"),
146 | Version.Parse("17.0.31903.59"),
147 | "Release");
148 |
149 | // Act
150 | bool result = version.Equals(other);
151 |
152 | // Assert
153 | Assert.True(result);
154 | }
155 |
156 | [Fact]
157 | public void GetHashCodeMethod()
158 | {
159 | // Arrange
160 | VisualStudioVersion version = commonVersions[0];
161 | int expectedHashCode = version.BuildVersion.GetHashCode();
162 |
163 | // Act
164 | int hashCode = version.GetHashCode();
165 |
166 | // Assert
167 | Assert.Equal(expectedHashCode, hashCode);
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/tests/SlnUp.Json.Tests/Extensions/AssemblyExtensionsTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Json.Tests.Extensions;
2 |
3 | using SlnUp.Json.Extensions;
4 |
5 | public class AssemblyExtensionsTests
6 | {
7 | [Fact]
8 | public void GetEmbeddedFileResourceContent()
9 | {
10 | // Arrange
11 | string resourceName = "TestVersions.json";
12 |
13 | // Act
14 | string content = AssemblyExtensions.GetEmbeddedFileResourceContent(
15 | typeof(AssemblyExtensionsTests).Assembly,
16 | resourceName);
17 |
18 | // Assert
19 | Assert.NotNull(content);
20 | }
21 |
22 | [Fact]
23 | public void GetEmbeddedFileResourceContent_ResourceNotFound()
24 | {
25 | // Arrange, act, and assert
26 | Assert.Throws(() => AssemblyExtensions.GetEmbeddedFileResourceContent(
27 | typeof(AssemblyExtensionsTests).Assembly,
28 | "nothing"));
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/tests/SlnUp.Json.Tests/SlnUp.Json.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net9.0;net8.0
5 | Exe
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | %(Filename)%(Extension)
16 |
17 |
18 |
19 |
20 |
21 | all
22 | runtime; build; native; contentfiles; analyzers; buildtransitive
23 |
24 |
25 | all
26 | runtime; build; native; contentfiles; analyzers
27 |
28 |
29 |
30 |
31 |
32 |
33 | all
34 | runtime; build; native; contentfiles; analyzers; buildtransitive
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/tests/SlnUp.Json.Tests/VersionManagerJsonHelperTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Json.Tests;
2 |
3 | using SlnUp.Core;
4 |
5 | public class VersionManagerJsonHelperTests
6 | {
7 | [Fact]
8 | public void LoadFromEmbeddedResource()
9 | {
10 | // Arrange
11 | string resourceName = "TestVersions.json";
12 |
13 | // Act
14 | VersionManager versionManager = VersionManagerJsonHelper.LoadFromEmbeddedResource(
15 | typeof(VersionManagerJsonHelperTests).Assembly,
16 | resourceName);
17 |
18 | // Assert
19 | Assert.NotNull(versionManager);
20 | }
21 |
22 | [Fact]
23 | public void LoadFromEmbeddedResource_ResourceNotFound()
24 | {
25 | // Arrange, act, and assert
26 | Assert.Throws(() => VersionManagerJsonHelper.LoadFromEmbeddedResource(
27 | typeof(VersionManagerJsonHelperTests).Assembly,
28 | "nothing"));
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/tests/SlnUp.Json.Tests/VisualStudioVersionJsonHelperTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Json.Tests;
2 |
3 | using System.IO.Abstractions;
4 | using System.IO.Abstractions.TestingHelpers;
5 | using System.Text.Json;
6 |
7 | using SlnUp.Core;
8 | using SlnUp.TestLibrary.Extensions;
9 |
10 | public class VisualStudioVersionJsonHelperTests
11 | {
12 | private const string commonVersionsJson = /*lang=json,strict*/ @"[
13 | {
14 | ""BuildVersion"": ""16.11.31829.152"",
15 | ""Channel"": ""Release"",
16 | ""IsPreview"": false,
17 | ""Product"": ""visualStudio2019"",
18 | ""Version"": ""16.11.6""
19 | },
20 | {
21 | ""BuildVersion"": ""17.0.31903.59"",
22 | ""Channel"": ""Release"",
23 | ""IsPreview"": false,
24 | ""Product"": ""visualStudio2022"",
25 | ""Version"": ""17.0.0""
26 | }
27 | ]";
28 |
29 | private static readonly IReadOnlyList commonVersions =
30 | [
31 | new VisualStudioVersion(
32 | VisualStudioProduct.VisualStudio2019,
33 | Version.Parse("16.11.6"),
34 | Version.Parse("16.11.31829.152"),
35 | "Release"),
36 | new VisualStudioVersion(
37 | VisualStudioProduct.VisualStudio2022,
38 | Version.Parse("17.0.0"),
39 | Version.Parse("17.0.31903.59"),
40 | "Release"),
41 | ];
42 |
43 | [Fact]
44 | public void FromJson()
45 | {
46 | // Act
47 | IReadOnlyList versions = VisualStudioVersionJsonHelper.FromJson(commonVersionsJson);
48 |
49 | // Assert
50 | Assert.Equal(commonVersions, versions);
51 | }
52 |
53 | [Fact]
54 | public void FromJson_Empty()
55 | {
56 | // Act
57 | IReadOnlyList versions = VisualStudioVersionJsonHelper.FromJson("[]");
58 |
59 | // Assert
60 | Assert.Empty(versions);
61 | }
62 |
63 | [Fact]
64 | public void FromJson_EmptyString()
65 | {
66 | // Act
67 | Assert.Throws(() => VisualStudioVersionJsonHelper.FromJson(string.Empty));
68 | }
69 |
70 | [Fact]
71 | public void FromJson_Null()
72 | {
73 | // Act
74 | Assert.Throws(() => VisualStudioVersionJsonHelper.FromJson("null"));
75 | }
76 |
77 | [Fact]
78 | public void FromJsonFile()
79 | {
80 | // Arrange
81 | string filePath = "C:/foo.json".ToCrossPlatformPath();
82 | IFileSystem fileSystem = new MockFileSystem(new Dictionary
83 | {
84 | [filePath] = new MockFileData(commonVersionsJson),
85 | });
86 |
87 | // Act
88 | IReadOnlyList versions = VisualStudioVersionJsonHelper.FromJsonFile(fileSystem, filePath);
89 |
90 | // Assert
91 | Assert.Equal(commonVersions, versions);
92 | }
93 |
94 | [Fact]
95 | public void ToJson()
96 | {
97 | // Act
98 | string json = VisualStudioVersionJsonHelper.ToJson(commonVersions);
99 |
100 | // Assert
101 | Assert.Equal(commonVersionsJson, json);
102 | }
103 |
104 | [Fact]
105 | public void ToJson_Empty()
106 | {
107 | // Arrange
108 | const string expectedJson = "[]";
109 |
110 | // Act
111 | string json = VisualStudioVersionJsonHelper.ToJson([]);
112 |
113 | // Assert
114 | Assert.Equal(expectedJson, json);
115 | }
116 |
117 | [Fact]
118 | public void ToJson_Null()
119 | {
120 | // Arrange
121 | const string expectedJson = "null";
122 |
123 | // Act
124 | string json = VisualStudioVersionJsonHelper.ToJson(null!);
125 |
126 | // Assert
127 | Assert.Equal(expectedJson, json);
128 | }
129 |
130 | [Fact]
131 | public void ToJsonFile()
132 | {
133 | // Arrange
134 | MockFileSystem fileSystem = new();
135 | string filePath = "C:/foo.json".ToCrossPlatformPath();
136 |
137 | // Act
138 | VisualStudioVersionJsonHelper.ToJsonFile(fileSystem, commonVersions, filePath);
139 |
140 | // Assert
141 | Assert.True(fileSystem.FileExists(filePath));
142 | Assert.Equal(commonVersionsJson, fileSystem.GetFile(filePath).TextContents);
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/tests/SlnUp.TestLibrary/Extensions/StringExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.TestLibrary.Extensions;
2 |
3 | using System.IO.Abstractions.TestingHelpers;
4 |
5 | ///
6 | /// Class StringExtensions.
7 | ///
8 | public static class StringExtensions
9 | {
10 | ///
11 | /// Converts to a cross-platform path from a Windows path.
12 | ///
13 | /// The path.
14 | /// .
15 | public static string ToCrossPlatformPath(this string path)
16 | => MockUnixSupport.Path(path);
17 |
18 | ///
19 | /// Converts to cross-platform paths from Windows paths.
20 | ///
21 | /// The paths.
22 | /// .
23 | public static IEnumerable ToCrossPlatformPath(this IEnumerable paths)
24 | => paths.Select(MockUnixSupport.Path);
25 | }
26 |
--------------------------------------------------------------------------------
/tests/SlnUp.TestLibrary/ScopedDirectory.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.TestLibrary;
2 |
3 | using System.IO.Abstractions;
4 |
5 | using SlnUp.Core;
6 |
7 | ///
8 | /// Class ScopedDirectory.
9 | /// Implements the
10 | ///
11 | ///
12 | public class ScopedDirectory : IDisposable
13 | {
14 | private readonly ScopedAction cleanupAction;
15 |
16 | private bool disposedValue;
17 |
18 | ///
19 | /// Gets the file system.
20 | ///
21 | public IFileSystem FileSystem { get; }
22 |
23 | ///
24 | /// Gets the directory path.
25 | ///
26 | public string Path { get; }
27 |
28 | ///
29 | /// Initializes a new instance of the class.
30 | ///
31 | /// The directory path.
32 | public ScopedDirectory(string path)
33 | : this(new FileSystem(), path)
34 | {
35 | }
36 |
37 | ///
38 | /// Initializes a new instance of the class.
39 | ///
40 | /// The file system.
41 | /// The directory path.
42 | public ScopedDirectory(IFileSystem fileSystem, string path)
43 | {
44 | ArgumentNullException.ThrowIfNull(fileSystem);
45 | ArgumentException.ThrowIfNullOrWhiteSpace(path);
46 |
47 | this.FileSystem = fileSystem;
48 | this.Path = path;
49 |
50 | if (!fileSystem.Directory.Exists(path))
51 | {
52 | fileSystem.Directory.CreateDirectory(path);
53 | }
54 |
55 | this.cleanupAction = new ScopedAction(this.Cleanup);
56 | }
57 |
58 | ///
59 | /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
60 | ///
61 | public void Dispose()
62 | {
63 | this.Dispose(disposing: true);
64 | GC.SuppressFinalize(this);
65 | }
66 |
67 | ///
68 | /// Gets the path to a temporary file with the specified file name.
69 | ///
70 | /// The name of the file.
71 | /// A path to a temporary file.
72 | public string GetPathWithFileName(string fileName) => this.FileSystem.Path.Combine(this.Path, fileName);
73 |
74 | ///
75 | /// Gets a path to a random temporary file. The file is not created.
76 | ///
77 | /// A path to a random temporary file.
78 | public string GetRandomFilePath()
79 | => this.GetPathWithFileName(TemporaryFile.GetRandomFileName());
80 |
81 | ///
82 | /// Gets a path to a random temporary file. The file is not created.
83 | ///
84 | /// The file extension. No '.'.
85 | /// A path to a random temporary file.
86 | public string GetRandomFilePathWithExtension(string extension)
87 | => this.GetPathWithFileName(TemporaryFile.GetRandomFileNameWithExtension(extension));
88 |
89 | ///
90 | /// Sets the directory as the working directory using scoped behavior.
91 | ///
92 | /// .
93 | public IDisposable SetAsScopedWorkingDirectory()
94 | => WorkingDirectory.SetScoped(this.FileSystem, this.Path);
95 |
96 | ///
97 | /// Releases unmanaged and - optionally - managed resources.
98 | ///
99 | ///
100 | /// true to release both managed and unmanaged resources; false to release only unmanaged resources.
101 | ///
102 | protected virtual void Dispose(bool disposing)
103 | {
104 | if (!this.disposedValue)
105 | {
106 | if (disposing)
107 | {
108 | this.cleanupAction.Dispose();
109 | }
110 |
111 | this.disposedValue = true;
112 | }
113 | }
114 |
115 | private void Cleanup()
116 | {
117 | if (this.FileSystem.Directory.Exists(this.Path))
118 | {
119 | this.FileSystem.Directory.Delete(this.Path, recursive: true);
120 | }
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/tests/SlnUp.TestLibrary/ScopedFile.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.TestLibrary;
2 |
3 | using System.IO.Abstractions;
4 |
5 | using SlnUp.Core;
6 |
7 | ///
8 | /// Class ScopedFile.
9 | /// Implements the
10 | ///
11 | ///
12 | public class ScopedFile : IDisposable
13 | {
14 | private readonly ScopedAction cleanupAction;
15 |
16 | private bool disposedValue;
17 |
18 | ///
19 | /// Gets the file system.
20 | ///
21 | public IFileSystem FileSystem { get; }
22 |
23 | ///
24 | /// Gets the file path.
25 | ///
26 | public string Path { get; }
27 |
28 | ///
29 | /// Initializes a new instance of the class.
30 | ///
31 | /// The file path.
32 | public ScopedFile(string filePath)
33 | : this(new FileSystem(), filePath)
34 | {
35 | }
36 |
37 | ///
38 | /// Initializes a new instance of the class.
39 | ///
40 | /// The file system.
41 | /// The file path.
42 | public ScopedFile(IFileSystem fileSystem, string filePath)
43 | {
44 | ArgumentNullException.ThrowIfNull(fileSystem);
45 | ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
46 |
47 | this.FileSystem = fileSystem;
48 | this.Path = filePath;
49 |
50 | if (!fileSystem.File.Exists(filePath))
51 | {
52 | fileSystem.File.WriteAllText(this.Path, string.Empty);
53 | }
54 |
55 | this.cleanupAction = new ScopedAction(this.Cleanup);
56 | }
57 |
58 | ///
59 | /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
60 | ///
61 | public void Dispose()
62 | {
63 | this.Dispose(disposing: true);
64 | GC.SuppressFinalize(this);
65 | }
66 |
67 | ///
68 | /// Releases unmanaged and - optionally - managed resources.
69 | ///
70 | ///
71 | /// true to release both managed and unmanaged resources; false to release only unmanaged resources.
72 | ///
73 | protected virtual void Dispose(bool disposing)
74 | {
75 | if (!this.disposedValue)
76 | {
77 | if (disposing)
78 | {
79 | this.cleanupAction.Dispose();
80 | }
81 |
82 | this.disposedValue = true;
83 | }
84 | }
85 |
86 | private void Cleanup()
87 | {
88 | if (this.FileSystem.File.Exists(this.Path))
89 | {
90 | this.FileSystem.File.Delete(this.Path);
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/tests/SlnUp.TestLibrary/SlnUp.TestLibrary.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net9.0;net8.0
5 |
6 | false
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/tests/SlnUp.TestLibrary/TemporaryDirectory.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.TestLibrary;
2 |
3 | using System.IO.Abstractions;
4 |
5 | ///
6 | /// Class TemporaryDirectory.
7 | ///
8 | public static class TemporaryDirectory
9 | {
10 | ///
11 | /// Creates a temporary directory.
12 | ///
13 | /// The name of the directory.
14 | /// .
15 | public static ScopedDirectory Create(string directoryName)
16 | => Create(new FileSystem(), directoryName);
17 |
18 | ///
19 | /// Creates a temporary directory.
20 | ///
21 | /// The file system.
22 | /// The name of the directory.
23 | /// .
24 | public static ScopedDirectory Create(IFileSystem fileSystem, string directoryName)
25 | {
26 | ArgumentNullException.ThrowIfNull(fileSystem);
27 |
28 | string directoryPath = GetPathWithName(fileSystem, directoryName);
29 |
30 | return new(fileSystem, directoryPath);
31 | }
32 |
33 | ///
34 | /// Creates a random temporary directory.
35 | ///
36 | /// .
37 | public static ScopedDirectory CreateRandom()
38 | => CreateRandom(new FileSystem());
39 |
40 | ///
41 | /// Creates a random temporary directory.
42 | ///
43 | /// The file system.
44 | /// .
45 | public static ScopedDirectory CreateRandom(IFileSystem fileSystem)
46 | => Create(fileSystem, GetRandomDirectoryName());
47 |
48 | ///
49 | /// Gets the path to the temporary directory.
50 | ///
51 | /// The file system.
52 | /// .
53 | public static string GetPath(IFileSystem fileSystem)
54 | {
55 | ArgumentNullException.ThrowIfNull(fileSystem);
56 |
57 | return fileSystem.Path.GetTempPath();
58 | }
59 |
60 | ///
61 | /// Gets the path to a temporary directory with the specified name.
62 | ///
63 | /// The file system.
64 | /// Name of the directory.
65 | /// .
66 | public static string GetPathWithName(IFileSystem fileSystem, string directoryName)
67 | {
68 | ArgumentNullException.ThrowIfNull(fileSystem);
69 | ArgumentException.ThrowIfNullOrWhiteSpace(directoryName);
70 |
71 | return fileSystem.Path.Combine(GetPath(fileSystem), directoryName);
72 | }
73 |
74 | ///
75 | /// Gets a random directory name.
76 | ///
77 | /// A random directory name.
78 | public static string GetRandomDirectoryName() => Guid.NewGuid().ToString();
79 | }
80 |
--------------------------------------------------------------------------------
/tests/SlnUp.TestLibrary/TemporaryFile.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.TestLibrary;
2 |
3 | using System.IO.Abstractions;
4 |
5 | ///
6 | /// Class TemporaryFile.
7 | ///
8 | public static class TemporaryFile
9 | {
10 | ///
11 | /// Creates a temporary file.
12 | ///
13 | /// The name of the file.
14 | /// .
15 | public static ScopedFile Create(string fileName)
16 | => Create(new FileSystem(), fileName);
17 |
18 | ///
19 | /// Creates a temporary file.
20 | ///
21 | /// The file system.
22 | /// The name of the file.
23 | /// .
24 | public static ScopedFile Create(IFileSystem fileSystem, string fileName)
25 | {
26 | ArgumentNullException.ThrowIfNull(fileSystem);
27 |
28 | string filePath = GetPathWithFileName(fileSystem, fileName);
29 | return new(fileSystem, filePath);
30 | }
31 |
32 | ///
33 | /// Creates a random temporary file.
34 | ///
35 | /// .
36 | public static ScopedFile CreateRandom()
37 | => CreateRandom(new FileSystem());
38 |
39 | ///
40 | /// Creates a random temporary file.
41 | ///
42 | /// The file system.
43 | /// .
44 | public static ScopedFile CreateRandom(IFileSystem fileSystem)
45 | => Create(fileSystem, GetRandomFileName());
46 |
47 | ///
48 | /// Creates a random temporary file with the specified extension.
49 | ///
50 | /// The file extension. No '.'.
51 | /// .
52 | public static ScopedFile CreateRandomWithExtension(string extension)
53 | => Create(new FileSystem(), GetRandomFileNameWithExtension(extension));
54 |
55 | ///
56 | /// Creates a random temporary file.
57 | ///
58 | /// The file system.
59 | /// The file extension. No '.'.
60 | /// .
61 | public static ScopedFile CreateRandomWithExtension(IFileSystem fileSystem, string extension)
62 | => Create(fileSystem, GetRandomFileNameWithExtension(extension));
63 |
64 | ///
65 | /// Gets the path to a temporary file with the specified file name.
66 | ///
67 | /// The file system.
68 | /// The name of the file.
69 | /// A path to a temporary file.
70 | public static string GetPathWithFileName(IFileSystem fileSystem, string fileName)
71 | {
72 | ArgumentNullException.ThrowIfNull(fileSystem);
73 |
74 | return fileSystem.Path.Combine(TemporaryDirectory.GetPath(fileSystem), fileName);
75 | }
76 |
77 | ///
78 | /// Gets a random file name.
79 | ///
80 | /// A random file name.
81 | public static string GetRandomFileName() => Guid.NewGuid().ToString();
82 |
83 | ///
84 | /// Gets a random file name with the specified extension.
85 | ///
86 | /// The file extension. No '.'.
87 | /// A random file name with the specified extension.
88 | public static string GetRandomFileNameWithExtension(string extension)
89 | => $"{GetRandomFileName()}.{extension}";
90 |
91 | ///
92 | /// Gets a path to a random temporary file. The file is not created.
93 | ///
94 | /// The file system.
95 | /// A path to a random temporary file.
96 | public static string GetRandomFilePath(IFileSystem fileSystem)
97 | => GetPathWithFileName(fileSystem, GetRandomFileName());
98 |
99 | ///
100 | /// Gets a path to a random temporary file. The file is not created.
101 | ///
102 | /// The file system.
103 | /// The file extension. No '.'.
104 | /// A path to a random temporary file.
105 | public static string GetRandomFilePathWithExtension(IFileSystem fileSystem, string extension)
106 | => GetPathWithFileName(fileSystem, GetRandomFileNameWithExtension(extension));
107 | }
108 |
--------------------------------------------------------------------------------
/tests/SlnUp.TestLibrary/WorkingDirectory.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.TestLibrary;
2 |
3 | using System.IO.Abstractions;
4 |
5 | using SlnUp.Core;
6 |
7 | internal static class WorkingDirectory
8 | {
9 | ///
10 | /// Gets the current working directory.
11 | ///
12 | /// The file system.
13 | /// The path to the current working directory.
14 | public static string Get(IFileSystem fileSystem) => fileSystem.Directory.GetCurrentDirectory();
15 |
16 | ///
17 | /// Sets the working directory to the specified path.
18 | ///
19 | /// The file system.
20 | /// The path.
21 | public static void Set(IFileSystem fileSystem, string path)
22 | => fileSystem.Directory.SetCurrentDirectory(path);
23 |
24 | ///
25 | /// Sets the working directory using a to enable reset upon disposal.
26 | ///
27 | /// The file system.
28 | /// The path.
29 | /// .
30 | public static IDisposable SetScoped(IFileSystem fileSystem, string path)
31 | {
32 | string currentPath = Get(fileSystem);
33 | Set(fileSystem, path);
34 |
35 | return new ScopedAction(() => Set(fileSystem, currentPath));
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/tests/SlnUp.Tests/CLI/ArgumentParserTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Tests.CLI;
2 |
3 | using System;
4 | using System.CommandLine;
5 | using System.CommandLine.Parsing;
6 |
7 | using SlnUp.CLI;
8 |
9 | public class ArgumentParserTests
10 | {
11 | [Theory]
12 | [InlineData("1.2")]
13 | [InlineData("1.2.3")]
14 | [InlineData("1.2.3.4")]
15 | public void ParseVersion(string expectedVersionValue)
16 | {
17 | // Arrange
18 | Option versionOption = new("--version", ArgumentParser.ParseVersion);
19 | Parser parser = new(new RootCommand()
20 | {
21 | versionOption,
22 | });
23 | Version expectedVersion = Version.Parse(expectedVersionValue);
24 |
25 | // Act
26 | ParseResult result = parser.Parse($"--version {expectedVersionValue}");
27 |
28 | // Assert
29 | Assert.Empty(result.Errors);
30 | Version? version = result.RootCommandResult.GetValueForOption(versionOption);
31 | Assert.NotNull(version);
32 | Assert.Equal(expectedVersion, version);
33 | }
34 |
35 | [Fact]
36 | public void ParseVersion_Invalid()
37 | {
38 | // Arrange
39 | Option versionOption = new("--version", ArgumentParser.ParseVersion);
40 | Parser parser = new(new RootCommand()
41 | {
42 | versionOption,
43 | });
44 |
45 | // Act
46 | ParseResult result = parser.Parse("--version not-valid");
47 |
48 | // Assert
49 | ParseError error = Assert.Single(result.Errors);
50 | Assert.Equal("version", error.SymbolResult?.Symbol.Name);
51 | Assert.StartsWith("Cannot parse argument 'not-valid'", error.Message, StringComparison.Ordinal);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/tests/SlnUp.Tests/CLI/ProgramOptionsTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Tests.CLI;
2 |
3 | using System.CommandLine;
4 | using System.CommandLine.IO;
5 | using System.IO.Abstractions.TestingHelpers;
6 |
7 | using SlnUp.CLI;
8 | using SlnUp.TestLibrary.Extensions;
9 | using SlnUp.Tests.Utilities;
10 |
11 | public class ProgramOptionsTests
12 | {
13 | private readonly TestConsole testConsole = new();
14 |
15 | [Theory]
16 | [InlineData("2022")]
17 | [InlineData("17.0")]
18 | public void Configure(string version)
19 | {
20 | // Arrange
21 | string[] args = [version];
22 |
23 | // Act
24 | ProgramOptions? result = this.ConfigureAndInvoke(args, out int exitCode);
25 |
26 | // Assert
27 | Assert.Equal(0, exitCode);
28 | Assert.NotNull(result);
29 | Assert.Equal(version, result.Version);
30 | }
31 |
32 | [Fact]
33 | public void Configure_NoParameters()
34 | {
35 | // Arrange
36 | string[] args = [];
37 |
38 | // Act
39 | ProgramOptions? result = this.ConfigureAndInvoke(args, out int exitCode);
40 |
41 | // Assert
42 | Assert.Equal(0, exitCode);
43 | Assert.NotNull(result);
44 | Assert.Equal(ProgramOptions.DefaultVersionArgument, result.Version);
45 | }
46 |
47 | [Theory]
48 | [InlineData("2022", "--build-version", "17.0.31903.59")]
49 | [InlineData("--build-version", "17.0.31903.59", "2022")]
50 | public void Configure_WithBuildVersion(params string[] args)
51 | {
52 | // Arrange
53 | const string expectedVersion = "2022";
54 | const string expectedBuildVersion = "17.0.31903.59";
55 |
56 | // Act
57 | ProgramOptions? result = this.ConfigureAndInvoke(args, out int exitCode);
58 |
59 | // Assert
60 | Assert.Equal(0, exitCode);
61 | Assert.NotNull(result);
62 | Assert.Equal(expectedVersion, result.Version);
63 | Assert.Equal(Version.Parse(expectedBuildVersion), result.BuildVersion);
64 | }
65 |
66 | [Theory]
67 | [InlineData("--help")]
68 | [InlineData("-h")]
69 | [InlineData("-?")]
70 | public void Configure_WithHelp(params string[] args)
71 | {
72 | // Act
73 | ProgramOptions? result = this.ConfigureAndInvoke(args, out int exitCode);
74 |
75 | // Assert
76 | Assert.Equal(0, exitCode);
77 | Assert.Null(result);
78 | Assert.StartsWith("Description:", this.testConsole.GetOutput(), StringComparison.Ordinal);
79 | Assert.True(this.testConsole.HasNoErrorOutput());
80 | }
81 |
82 | [Fact]
83 | public void Configure_WithInvalidBuildVersion()
84 | {
85 | // Arrange
86 | string[] args =
87 | [
88 | "2022",
89 | "--build-version",
90 | "invalid-version"
91 | ];
92 | const string expectedErrorOutput = "Cannot parse argument 'invalid-version' for option 'build-version' as expected type 'System.Version'.";
93 |
94 | // Act
95 | ProgramOptions? result = this.ConfigureAndInvoke(args, out int exitCode);
96 |
97 | // Assert
98 | Assert.Equal(1, exitCode);
99 | Assert.Null(result);
100 | Assert.True(this.testConsole.HasOutput());
101 | Assert.True(this.testConsole.HasErrorOutput());
102 | Assert.Equal(expectedErrorOutput, this.testConsole.GetErrorOutput().TrimEnd());
103 | }
104 |
105 | [Theory]
106 | [InlineData("2022", "--path", "C:/solution.sln")]
107 | [InlineData("2022", "-p", "C:/solution.sln")]
108 | [InlineData("-p", "C:/solution.sln", "2022")]
109 | public void Configure_WithPath(params string[] args)
110 | {
111 | // Arrange
112 | const string expectedVersion = "2022";
113 | string expectedFilePath = "C:/solution.sln".ToCrossPlatformPath();
114 |
115 | // Act
116 | ProgramOptions? result = this.ConfigureAndInvoke([.. args.ToCrossPlatformPath()], out int exitCode);
117 |
118 | // Assert
119 | Assert.Equal(0, exitCode);
120 | Assert.NotNull(result);
121 | Assert.Equal(expectedVersion, result.Version);
122 | Assert.Equal(expectedFilePath, result.Path);
123 | }
124 |
125 | [Fact]
126 | public void Configure_WithVersion()
127 | {
128 | // Arrange
129 | string[] args = ["--version"];
130 |
131 | // Act
132 | ProgramOptions? result = this.ConfigureAndInvoke(args, out int exitCode);
133 |
134 | // Assert
135 | Assert.Equal(0, exitCode);
136 | Assert.Null(result);
137 | Assert.True(this.testConsole.HasOutput());
138 | }
139 |
140 | ///
141 | /// Minimal invocation: > app 16.8
142 | ///
143 | [Fact]
144 | public void TryGetSlnUpOptions()
145 | {
146 | // Arrange
147 | string expectedSolutionFilePath = "C:\\MyProject.sln".ToCrossPlatformPath();
148 | ProgramOptions programOptions = new()
149 | {
150 | Version = "16.8"
151 | };
152 | MockFileSystem fileSystem = new(new Dictionary
153 | {
154 | [expectedSolutionFilePath] = new MockFileData(string.Empty),
155 | }, "C:\\".ToCrossPlatformPath());
156 |
157 | // Act
158 | bool result = programOptions.TryGetSlnUpOptions(fileSystem, out SlnUpOptions? options);
159 |
160 | // Assert
161 | Assert.True(result);
162 | Assert.NotNull(options);
163 | Assert.Equal(expectedSolutionFilePath, options.SolutionFilePath);
164 | Assert.Equal(Version.Parse("16.8.7"), options.Version);
165 | Assert.Equal(Version.Parse("16.8.31025.109"), options.BuildVersion);
166 | }
167 |
168 | ///
169 | /// app 16.8 --path C:\MyProject.sln
170 | ///
171 | [Fact]
172 | public void TryGetSlnUpOptions_WithAbsoluteSolutionPath()
173 | {
174 | // Arrange
175 | string expectedSolutionFilePath = "C:\\MyProject.sln".ToCrossPlatformPath();
176 | ProgramOptions programOptions = new()
177 | {
178 | Version = "16.8",
179 | Path = expectedSolutionFilePath
180 | };
181 | MockFileSystem fileSystem = new(new Dictionary
182 | {
183 | [expectedSolutionFilePath] = new MockFileData(string.Empty),
184 | }, "C:\\".ToCrossPlatformPath());
185 |
186 | // Act
187 | bool result = programOptions.TryGetSlnUpOptions(fileSystem, out SlnUpOptions? options);
188 |
189 | // Assert
190 | Assert.True(result);
191 | Assert.NotNull(options);
192 | Assert.Equal(expectedSolutionFilePath, options.SolutionFilePath);
193 | }
194 |
195 | ///
196 | /// Build version: > app 0.0 --build-version 0.0.0.0
197 | ///
198 | [Fact]
199 | public void TryGetSlnUpOptions_WithBuildVersion()
200 | {
201 | // Arrange
202 | string expectedSolutionFilePath = "C:\\MyProject.sln".ToCrossPlatformPath();
203 | ProgramOptions programOptions = new()
204 | {
205 | Version = "0.0",
206 | BuildVersion = Version.Parse("0.0.0.0")
207 | };
208 | MockFileSystem fileSystem = new(new Dictionary
209 | {
210 | [expectedSolutionFilePath] = new MockFileData(string.Empty),
211 | }, "C:\\".ToCrossPlatformPath());
212 |
213 | // Act
214 | bool result = programOptions.TryGetSlnUpOptions(fileSystem, out SlnUpOptions? options);
215 |
216 | // Assert
217 | Assert.True(result);
218 | Assert.NotNull(options);
219 | Assert.Equal(expectedSolutionFilePath, options.SolutionFilePath);
220 | Assert.Equal(Version.Parse("0.0"), options.Version);
221 | Assert.Equal(Version.Parse("0.0.0.0"), options.BuildVersion);
222 | }
223 |
224 | ///
225 | /// Build version with invalid version: > app 0 --build-version 0.0.0
226 | ///
227 | [Fact]
228 | public void TryGetSlnUpOptions_WithBuildVersionAndInvalidVersion()
229 | {
230 | // Arrange
231 | string expectedSolutionFilePath = "C:\\MyProject.sln".ToCrossPlatformPath();
232 | ProgramOptions programOptions = new()
233 | {
234 | Version = "0",
235 | BuildVersion = Version.Parse("0.0.0.0")
236 | };
237 | MockFileSystem fileSystem = new(new Dictionary
238 | {
239 | [expectedSolutionFilePath] = new MockFileData(string.Empty),
240 | }, "C:\\".ToCrossPlatformPath());
241 |
242 | // Act
243 | bool result = programOptions.TryGetSlnUpOptions(fileSystem, out SlnUpOptions? options);
244 |
245 | // Assert
246 | Assert.False(result);
247 | Assert.Null(options);
248 | }
249 |
250 | ///
251 | /// Invalid build version: > app 0.0 --build-version 0.0.0
252 | ///
253 | [Fact]
254 | public void TryGetSlnUpOptions_WithInvalidBuildVersion()
255 | {
256 | // Arrange
257 | string expectedSolutionFilePath = "C:\\MyProject.sln".ToCrossPlatformPath();
258 | ProgramOptions programOptions = new()
259 | {
260 | Version = "0.0",
261 | BuildVersion = Version.Parse("0.0.0")
262 | };
263 | MockFileSystem fileSystem = new(new Dictionary
264 | {
265 | [expectedSolutionFilePath] = new MockFileData(string.Empty),
266 | }, "C:\\".ToCrossPlatformPath());
267 |
268 | // Act
269 | bool result = programOptions.TryGetSlnUpOptions(fileSystem, out SlnUpOptions? options);
270 |
271 | // Assert
272 | Assert.False(result);
273 | Assert.Null(options);
274 | }
275 |
276 | ///
277 | /// Minimal invocation: > app 0.0
278 | ///
279 | [Fact]
280 | public void TryGetSlnUpOptions_WithInvalidVersion()
281 | {
282 | // Arrange
283 | string expectedSolutionFilePath = "C:\\MyProject.sln".ToCrossPlatformPath();
284 | ProgramOptions programOptions = new()
285 | {
286 | Version = "0.0"
287 | };
288 | MockFileSystem fileSystem = new(new Dictionary
289 | {
290 | [expectedSolutionFilePath] = new MockFileData(string.Empty),
291 | }, "C:\\".ToCrossPlatformPath());
292 |
293 | // Act
294 | bool result = programOptions.TryGetSlnUpOptions(fileSystem, out SlnUpOptions? options);
295 |
296 | // Assert
297 | Assert.False(result);
298 | Assert.Null(options);
299 | }
300 |
301 | ///
302 | /// Multiple solution files available: > app 16.8
303 | ///
304 | [Fact]
305 | public void TryGetSlnUpOptions_WithMultipleSolutions()
306 | {
307 | // Arrange
308 | ProgramOptions programOptions = new()
309 | {
310 | Version = "16.8"
311 | };
312 | MockFileSystem fileSystem = new(new Dictionary
313 | {
314 | ["C:\\MyProject.sln".ToCrossPlatformPath()] = new MockFileData(string.Empty),
315 | ["C:\\MyProject2.sln".ToCrossPlatformPath()] = new MockFileData(string.Empty),
316 | }, "C:\\".ToCrossPlatformPath());
317 |
318 | // Act
319 | bool result = programOptions.TryGetSlnUpOptions(fileSystem, out SlnUpOptions? options);
320 |
321 | // Assert
322 | Assert.False(result);
323 | Assert.Null(options);
324 | }
325 |
326 | ///
327 | /// app 16.8 --path C:\Missing.sln
328 | ///
329 | [Fact]
330 | public void TryGetSlnUpOptions_WithNonExistentSolutionPath()
331 | {
332 | // Arrange
333 | ProgramOptions programOptions = new()
334 | {
335 | Version = "16.8",
336 | Path = "C:\\Missing.sln".ToCrossPlatformPath(),
337 | };
338 | MockFileSystem fileSystem = new(new Dictionary
339 | {
340 | ["C:\\MyProject.sln".ToCrossPlatformPath()] = new MockFileData(string.Empty),
341 | }, "C:\\".ToCrossPlatformPath());
342 |
343 | // Act
344 | bool result = programOptions.TryGetSlnUpOptions(fileSystem, out SlnUpOptions? options);
345 |
346 | // Assert
347 | Assert.False(result);
348 | Assert.Null(options);
349 | }
350 |
351 | ///
352 | /// Multiple solution files available: > app 16.8
353 | ///
354 | [Fact]
355 | public void TryGetSlnUpOptions_WithNoSolutions()
356 | {
357 | // Arrange
358 | ProgramOptions programOptions = new()
359 | {
360 | Version = "16.8"
361 | };
362 | MockFileSystem fileSystem = new();
363 |
364 | // Act
365 | bool result = programOptions.TryGetSlnUpOptions(fileSystem, out SlnUpOptions? options);
366 |
367 | // Assert
368 | Assert.False(result);
369 | Assert.Null(options);
370 | }
371 |
372 | ///
373 | /// app 16.8 --path .\MyProject.sln
374 | ///
375 | [Fact]
376 | public void TryGetSlnUpOptions_WithRelativeSolutionPath()
377 | {
378 | // Arrange
379 | string expectedSolutionFilePath = "C:\\MyProject.sln".ToCrossPlatformPath();
380 | ProgramOptions programOptions = new()
381 | {
382 | Version = "16.8",
383 | Path = ".\\MyProject.sln".ToCrossPlatformPath(),
384 | };
385 | MockFileSystem fileSystem = new(new Dictionary
386 | {
387 | [expectedSolutionFilePath] = new MockFileData(string.Empty),
388 | }, "C:\\".ToCrossPlatformPath());
389 |
390 | // Act
391 | bool result = programOptions.TryGetSlnUpOptions(fileSystem, out SlnUpOptions? options);
392 |
393 | // Assert
394 | Assert.True(result);
395 | Assert.NotNull(options);
396 | Assert.Equal(expectedSolutionFilePath, options.SolutionFilePath);
397 | }
398 |
399 | private ProgramOptions? ConfigureAndInvoke(string[] args, out int exitCode)
400 | {
401 | ProgramOptions? options = null;
402 |
403 | exitCode = ProgramOptions.Configure(opts =>
404 | {
405 | options = opts;
406 | return 0;
407 | }).Invoke(args, this.testConsole);
408 |
409 | return options;
410 | }
411 | }
412 |
--------------------------------------------------------------------------------
/tests/SlnUp.Tests/ProgramTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Tests;
2 |
3 | using SlnUp;
4 | using SlnUp.TestLibrary;
5 |
6 | public class ProgramTests
7 | {
8 | private const int FailedExitCode = 1;
9 |
10 | private const int NormalExitCode = 0;
11 |
12 | [Fact]
13 | public void Main_NoLocalFile()
14 | {
15 | // Arrange
16 | using ScopedDirectory directory = TemporaryDirectory.CreateRandom();
17 | string[] args = ["2022"];
18 | using IDisposable _ = directory.SetAsScopedWorkingDirectory();
19 |
20 | // Act
21 | int exitCode = Program.Main(args);
22 |
23 | // Assert
24 | Assert.Equal(FailedExitCode, exitCode);
25 | }
26 |
27 | [Fact]
28 | public void Main_WithFilePath()
29 | {
30 | // Arrange
31 | using ScopedFile file = TemporaryFile.CreateRandomWithExtension("sln");
32 | SolutionFileBuilder solutionFileBuilder = new(Version.Parse("16.0.30114.105"));
33 | SolutionFile solutionFile = solutionFileBuilder.BuildToSolutionFile(file.FileSystem, file.Path);
34 | string[] args =
35 | [
36 | "2022",
37 | "--path",
38 | file.Path,
39 | ];
40 |
41 | // Act
42 | int exitCode = Program.Main(args);
43 |
44 | // Assert
45 | Assert.Equal(NormalExitCode, exitCode);
46 | solutionFile.Reload();
47 | Assert.Equal(17, solutionFile.FileHeader.LastVisualStudioMajorVersion);
48 | }
49 |
50 | [Fact]
51 | public void Main_WithInvalidFile()
52 | {
53 | // Arrange
54 | using ScopedFile file = TemporaryFile.CreateRandomWithExtension("sln");
55 | string[] args =
56 | [
57 | "2022",
58 | "--path",
59 | file.Path,
60 | ];
61 |
62 | // Act
63 | int exitCode = Program.Main(args);
64 |
65 | // Assert
66 | Assert.Equal(FailedExitCode, exitCode);
67 | }
68 |
69 | [Fact]
70 | public void Main_WithLocalFile()
71 | {
72 | // Arrange
73 | using ScopedDirectory directory = TemporaryDirectory.CreateRandom();
74 | string solutionFilePath = directory.GetRandomFilePathWithExtension("sln");
75 | SolutionFileBuilder solutionFileBuilder = new(Version.Parse("16.0.30114.105"));
76 | SolutionFile solutionFile = solutionFileBuilder.BuildToSolutionFile(directory.FileSystem, solutionFilePath);
77 | string[] args = ["2022"];
78 | using IDisposable _ = directory.SetAsScopedWorkingDirectory();
79 |
80 | // Act
81 | int exitCode = Program.Main(args);
82 |
83 | // Assert
84 | Assert.Equal(NormalExitCode, exitCode);
85 | solutionFile.Reload();
86 | Assert.Equal(17, solutionFile.FileHeader.LastVisualStudioMajorVersion);
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/tests/SlnUp.Tests/SlnUp.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net9.0;net8.0
5 | Exe
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | all
16 | runtime; build; native; contentfiles; analyzers; buildtransitive
17 |
18 |
19 | all
20 | runtime; build; native; contentfiles; analyzers
21 |
22 |
23 |
24 |
25 |
26 |
27 | all
28 | runtime; build; native; contentfiles; analyzers; buildtransitive
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/tests/SlnUp.Tests/SolutionFileBuilder.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Tests;
2 |
3 | using System.IO.Abstractions;
4 | using System.IO.Abstractions.TestingHelpers;
5 | using System.Text;
6 |
7 | using SlnUp.TestLibrary;
8 |
9 | internal sealed class SolutionFileBuilder
10 | {
11 | public static readonly Version DefaultVisualStudioFullVersion = Version.Parse("16.0.28701.123");
12 |
13 | public static readonly Version DefaultVisualStudioMinimumVersion = Version.Parse(SolutionFileHeader.DefaultMinimumVisualStudioVersion);
14 |
15 | private readonly string fileFormatVersion;
16 |
17 | private readonly int iconMajorVersion;
18 |
19 | private readonly Version visualStudioFullVersion;
20 |
21 | private readonly Version visualStudioMinimumVersion;
22 |
23 | private bool includeBody = true;
24 |
25 | private bool includeFileFormatVersion = true;
26 |
27 | private bool includeSolutionIconVersion = true;
28 |
29 | private bool includeVisualStudioFullVersion = true;
30 |
31 | private bool includeVisualStudioMinimumVersion = true;
32 |
33 | ///
34 | /// Initializes a new instance of the class using all default values.
35 | ///
36 | public SolutionFileBuilder()
37 | : this(DefaultVisualStudioFullVersion) { }
38 |
39 | ///
40 | /// Initializes a new instance of the class from a full Visual Studio version
41 | /// and optional file format version and minimum Visual Studio version.
42 | ///
43 | /// The full Visual Studio version.
44 | /// The file format version.
45 | /// The minimum Visual Studio version.
46 | public SolutionFileBuilder(Version visualStudioFullVersion, string fileFormatVersion = SolutionFileHeader.SupportedFileFormatVersion, Version? visualStudioMinimumVersion = null)
47 | : this(fileFormatVersion, visualStudioFullVersion.Major, visualStudioFullVersion, visualStudioMinimumVersion ?? DefaultVisualStudioMinimumVersion) { }
48 |
49 | ///
50 | /// Initializes a new instance of the class.
51 | ///
52 | /// The file format version.
53 | /// The icon major version.
54 | /// The full Visual Studio version.
55 | /// The minimum Visual Studio version.
56 | public SolutionFileBuilder(string fileFormatVersion, int iconMajorVersion, Version visualStudioFullVersion, Version visualStudioMinimumVersion)
57 | {
58 | this.fileFormatVersion = fileFormatVersion;
59 | this.iconMajorVersion = iconMajorVersion;
60 | this.visualStudioFullVersion = visualStudioFullVersion;
61 | this.visualStudioMinimumVersion = visualStudioMinimumVersion;
62 | }
63 |
64 | ///
65 | /// Builds the content of the solution file.
66 | ///
67 | /// .
68 | public string Build()
69 | {
70 | StringBuilder builder = new();
71 |
72 | builder.AppendLine();
73 |
74 | if (this.includeFileFormatVersion)
75 | {
76 | builder.Append("Microsoft Visual Studio Solution File, Format Version ")
77 | .AppendLine(this.fileFormatVersion);
78 | }
79 |
80 | if (this.includeSolutionIconVersion)
81 | {
82 | if (this.iconMajorVersion >= 16)
83 | {
84 | builder.Append("# Visual Studio Version ").Append(this.iconMajorVersion).AppendLine();
85 | }
86 | else
87 | {
88 | builder.Append("# Visual Studio ").Append(this.iconMajorVersion).AppendLine();
89 | }
90 | }
91 |
92 | if (this.includeVisualStudioFullVersion)
93 | {
94 | builder.Append("VisualStudioVersion = ").Append(this.visualStudioFullVersion).AppendLine();
95 | }
96 |
97 | if (this.includeVisualStudioMinimumVersion)
98 | {
99 | builder.Append("MinimumVisualStudioVersion = ").Append(this.visualStudioMinimumVersion).AppendLine();
100 | }
101 |
102 | if (this.includeBody)
103 | {
104 | builder.AppendLine(@"
105 | Global
106 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
107 | Debug|Any CPU = Debug|Any CPU
108 | Release|Any CPU = Release|Any CPU
109 | EndGlobalSection
110 | GlobalSection(SolutionProperties) = preSolution
111 | HideSolutionNode = FALSE
112 | EndGlobalSection
113 | EndGlobal
114 | ".Trim());
115 | }
116 |
117 | return builder.ToString();
118 | }
119 |
120 | ///
121 | /// Builds a solution file and write it to the specified file path in the specified file system.
122 | ///
123 | /// The file system.
124 | /// The file path.
125 | public void BuildToFile(IFileSystem fileSystem, string filePath)
126 | => fileSystem.File.WriteAllText(filePath, this.Build());
127 |
128 | ///
129 | /// Builds the content of the solution file and puts it into a .
130 | ///
131 | /// The file path.
132 | /// .
133 | public MockFileSystem BuildToMockFileSystem(out string filePath)
134 | {
135 | MockFileSystem fileSystem = new();
136 | filePath = TemporaryFile.GetRandomFilePathWithExtension(fileSystem, "sln");
137 | this.BuildToFile(fileSystem, filePath);
138 |
139 | return fileSystem;
140 | }
141 |
142 | ///
143 | /// Builds the content to a .
144 | ///
145 | /// .
146 | public SolutionFile BuildToMockSolutionFile()
147 | {
148 | IFileSystem fileSystem = this.BuildToMockFileSystem(out string filePath);
149 | SolutionFile solutionFile = new(fileSystem, filePath);
150 |
151 | return solutionFile;
152 | }
153 |
154 | ///
155 | /// Builds the content to a .
156 | ///
157 | /// The file system.
158 | /// The file path.
159 | /// .
160 | public SolutionFile BuildToSolutionFile(IFileSystem fileSystem, string filePath)
161 | {
162 | this.BuildToFile(fileSystem, filePath);
163 | SolutionFile solutionFile = new(fileSystem, filePath);
164 |
165 | return solutionFile;
166 | }
167 |
168 | ///
169 | /// Configures the minimum file header lacking the solution icon version and full and minimum Visual Studio versions.
170 | ///
171 | /// .
172 | public SolutionFileBuilder ConfigureMinimumHeader()
173 | {
174 | this.includeSolutionIconVersion = false;
175 | this.includeVisualStudioFullVersion = false;
176 | this.includeVisualStudioMinimumVersion = false;
177 |
178 | return this;
179 | }
180 |
181 | ///
182 | /// Excludes the body from the generated file content.
183 | ///
184 | /// .
185 | public SolutionFileBuilder ExcludeBody()
186 | {
187 | this.includeBody = false;
188 |
189 | return this;
190 | }
191 |
192 | ///
193 | /// Excludes the file format version from the generated file content.
194 | ///
195 | /// .
196 | public SolutionFileBuilder ExcludeFileFormatVersion()
197 | {
198 | this.includeFileFormatVersion = false;
199 |
200 | return this;
201 | }
202 |
203 | ///
204 | /// Excludes the solution icon version from the generated file content.
205 | ///
206 | /// .
207 | public SolutionFileBuilder ExcludeSolutionIconVersion()
208 | {
209 | this.includeSolutionIconVersion = false;
210 |
211 | return this;
212 | }
213 |
214 | ///
215 | /// Excludes the full Visual Studio version from the generated file content.
216 | ///
217 | /// .
218 | public SolutionFileBuilder ExcludeVisualStudioFullVersion()
219 | {
220 | this.includeVisualStudioFullVersion = false;
221 |
222 | return this;
223 | }
224 |
225 | ///
226 | /// Excludes the minimum Visual Studio version from the generated file content.
227 | ///
228 | /// .
229 | public SolutionFileBuilder ExcludeVisualStudioMinimumVersion()
230 | {
231 | this.includeVisualStudioMinimumVersion = false;
232 |
233 | return this;
234 | }
235 | }
236 |
--------------------------------------------------------------------------------
/tests/SlnUp.Tests/SolutionFileBuilderTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Tests;
2 |
3 | public class SolutionFileBuilderTests
4 | {
5 | [Fact]
6 | public void Build()
7 | {
8 | // Arrange
9 | SolutionFileBuilder fileBuilder = new();
10 | const string expectedContent = @"
11 | Microsoft Visual Studio Solution File, Format Version 12.00
12 | # Visual Studio Version 16
13 | VisualStudioVersion = 16.0.28701.123
14 | MinimumVisualStudioVersion = 10.0.40219.1
15 | Global
16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
17 | Debug|Any CPU = Debug|Any CPU
18 | Release|Any CPU = Release|Any CPU
19 | EndGlobalSection
20 | GlobalSection(SolutionProperties) = preSolution
21 | HideSolutionNode = FALSE
22 | EndGlobalSection
23 | EndGlobal
24 | ";
25 |
26 | // Act
27 | string actualContent = fileBuilder.Build();
28 |
29 | // Assert
30 | Assert.Equal(expectedContent, actualContent);
31 | }
32 |
33 | [Fact]
34 | public void Build_ForVS15()
35 | {
36 | // Arrange
37 | SolutionFileBuilder fileBuilder = new(Version.Parse("15.0.28701.123"));
38 | const string expectedContent = @"
39 | Microsoft Visual Studio Solution File, Format Version 12.00
40 | # Visual Studio 15
41 | VisualStudioVersion = 15.0.28701.123
42 | MinimumVisualStudioVersion = 10.0.40219.1
43 | Global
44 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
45 | Debug|Any CPU = Debug|Any CPU
46 | Release|Any CPU = Release|Any CPU
47 | EndGlobalSection
48 | GlobalSection(SolutionProperties) = preSolution
49 | HideSolutionNode = FALSE
50 | EndGlobalSection
51 | EndGlobal
52 | ";
53 |
54 | // Act
55 | string actualContent = fileBuilder.Build();
56 |
57 | // Assert
58 | Assert.Equal(expectedContent, actualContent);
59 | }
60 |
61 | [Fact]
62 | public void Build_WithMinimumHeader()
63 | {
64 | // Arrange
65 | SolutionFileBuilder fileBuilder = new SolutionFileBuilder()
66 | .ConfigureMinimumHeader();
67 | const string expectedContent = @"
68 | Microsoft Visual Studio Solution File, Format Version 12.00
69 | Global
70 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
71 | Debug|Any CPU = Debug|Any CPU
72 | Release|Any CPU = Release|Any CPU
73 | EndGlobalSection
74 | GlobalSection(SolutionProperties) = preSolution
75 | HideSolutionNode = FALSE
76 | EndGlobalSection
77 | EndGlobal
78 | ";
79 |
80 | // Act
81 | string actualContent = fileBuilder.Build();
82 |
83 | // Assert
84 | Assert.Equal(expectedContent, actualContent);
85 | }
86 |
87 | [Fact]
88 | public void Build_WithoutBody()
89 | {
90 | // Arrange
91 | SolutionFileBuilder fileBuilder = new SolutionFileBuilder()
92 | .ExcludeBody();
93 | const string expectedContent = @"
94 | Microsoft Visual Studio Solution File, Format Version 12.00
95 | # Visual Studio Version 16
96 | VisualStudioVersion = 16.0.28701.123
97 | MinimumVisualStudioVersion = 10.0.40219.1
98 | ";
99 |
100 | // Act
101 | string actualContent = fileBuilder.Build();
102 |
103 | // Assert
104 | Assert.Equal(expectedContent, actualContent);
105 | }
106 |
107 | [Fact]
108 | public void Build_WithoutFileFormatVersion()
109 | {
110 | // Arrange
111 | SolutionFileBuilder fileBuilder = new SolutionFileBuilder()
112 | .ExcludeFileFormatVersion();
113 | const string expectedContent = @"
114 | # Visual Studio Version 16
115 | VisualStudioVersion = 16.0.28701.123
116 | MinimumVisualStudioVersion = 10.0.40219.1
117 | Global
118 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
119 | Debug|Any CPU = Debug|Any CPU
120 | Release|Any CPU = Release|Any CPU
121 | EndGlobalSection
122 | GlobalSection(SolutionProperties) = preSolution
123 | HideSolutionNode = FALSE
124 | EndGlobalSection
125 | EndGlobal
126 | ";
127 |
128 | // Act
129 | string actualContent = fileBuilder.Build();
130 |
131 | // Assert
132 | Assert.Equal(expectedContent, actualContent);
133 | }
134 |
135 | [Fact]
136 | public void Build_WithoutSolutionIconVersion()
137 | {
138 | // Arrange
139 | SolutionFileBuilder fileBuilder = new SolutionFileBuilder()
140 | .ExcludeSolutionIconVersion();
141 | const string expectedContent = @"
142 | Microsoft Visual Studio Solution File, Format Version 12.00
143 | VisualStudioVersion = 16.0.28701.123
144 | MinimumVisualStudioVersion = 10.0.40219.1
145 | Global
146 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
147 | Debug|Any CPU = Debug|Any CPU
148 | Release|Any CPU = Release|Any CPU
149 | EndGlobalSection
150 | GlobalSection(SolutionProperties) = preSolution
151 | HideSolutionNode = FALSE
152 | EndGlobalSection
153 | EndGlobal
154 | ";
155 |
156 | // Act
157 | string actualContent = fileBuilder.Build();
158 |
159 | // Assert
160 | Assert.Equal(expectedContent, actualContent);
161 | }
162 |
163 | [Fact]
164 | public void Build_WithoutVisualStudioFullVersion()
165 | {
166 | // Arrange
167 | SolutionFileBuilder fileBuilder = new SolutionFileBuilder()
168 | .ExcludeVisualStudioFullVersion();
169 | const string expectedContent = @"
170 | Microsoft Visual Studio Solution File, Format Version 12.00
171 | # Visual Studio Version 16
172 | MinimumVisualStudioVersion = 10.0.40219.1
173 | Global
174 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
175 | Debug|Any CPU = Debug|Any CPU
176 | Release|Any CPU = Release|Any CPU
177 | EndGlobalSection
178 | GlobalSection(SolutionProperties) = preSolution
179 | HideSolutionNode = FALSE
180 | EndGlobalSection
181 | EndGlobal
182 | ";
183 |
184 | // Act
185 | string actualContent = fileBuilder.Build();
186 |
187 | // Assert
188 | Assert.Equal(expectedContent, actualContent);
189 | }
190 |
191 | [Fact]
192 | public void Build_WithoutVisualStudioMinimumVersion()
193 | {
194 | // Arrange
195 | SolutionFileBuilder fileBuilder = new SolutionFileBuilder()
196 | .ExcludeVisualStudioMinimumVersion();
197 | const string expectedContent = @"
198 | Microsoft Visual Studio Solution File, Format Version 12.00
199 | # Visual Studio Version 16
200 | VisualStudioVersion = 16.0.28701.123
201 | Global
202 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
203 | Debug|Any CPU = Debug|Any CPU
204 | Release|Any CPU = Release|Any CPU
205 | EndGlobalSection
206 | GlobalSection(SolutionProperties) = preSolution
207 | HideSolutionNode = FALSE
208 | EndGlobalSection
209 | EndGlobal
210 | ";
211 |
212 | // Act
213 | string actualContent = fileBuilder.Build();
214 |
215 | // Assert
216 | Assert.Equal(expectedContent, actualContent);
217 | }
218 | }
219 |
--------------------------------------------------------------------------------
/tests/SlnUp.Tests/SolutionFileHeaderTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Tests;
2 |
3 | public class SolutionFileHeaderTests
4 | {
5 | [Fact]
6 | public void Construct()
7 | {
8 | // Arrange
9 | Version version = Version.Parse("17.0.31903.59");
10 |
11 | // Act
12 | SolutionFileHeader fileHeader = new(
13 | SolutionFileHeader.SupportedFileFormatVersion,
14 | version.Major,
15 | version,
16 | Version.Parse(SolutionFileHeader.DefaultMinimumVisualStudioVersion));
17 |
18 | // Assert
19 | Assert.Equal(SolutionFileHeader.SupportedFileFormatVersion, fileHeader.FileFormatVersion);
20 | Assert.Equal(version.Major, fileHeader.LastVisualStudioMajorVersion);
21 | Assert.Equal(version, fileHeader.LastVisualStudioVersion);
22 | Assert.Equal(Version.Parse(SolutionFileHeader.DefaultMinimumVisualStudioVersion), fileHeader.MinimumVisualStudioVersion);
23 | }
24 |
25 | [Fact]
26 | public void Construct_WithUnsupportedFileFormatVersion()
27 | {
28 | // Arrange and act
29 | ArgumentException ex = Assert.Throws(() => new SolutionFileHeader("13.00"));
30 |
31 | // Assert
32 | Assert.Equal("fileFormatVersion", ex.ParamName);
33 | }
34 |
35 | [Fact]
36 | public void DuplicateAndUpdate()
37 | {
38 | // Arrange
39 | Version originalVersion = Version.Parse("17.0.31903.59");
40 | SolutionFileHeader originalFileHeader = new(
41 | SolutionFileHeader.SupportedFileFormatVersion,
42 | originalVersion.Major,
43 | originalVersion,
44 | originalVersion);
45 | Version updatedVersion = Version.Parse("17.1.32210.238");
46 |
47 | // Act
48 | SolutionFileHeader updatedFileHeader = originalFileHeader.DuplicateAndUpdate(updatedVersion);
49 |
50 | // Assert
51 | Assert.NotSame(originalFileHeader, updatedFileHeader);
52 | Assert.Equal(SolutionFileHeader.SupportedFileFormatVersion, updatedFileHeader.FileFormatVersion);
53 | Assert.Equal(updatedVersion.Major, updatedFileHeader.LastVisualStudioMajorVersion);
54 | Assert.Equal(updatedVersion, updatedFileHeader.LastVisualStudioVersion);
55 | Assert.Equal(originalVersion, updatedFileHeader.MinimumVisualStudioVersion);
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/tests/SlnUp.Tests/SolutionFileTests.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Tests;
2 |
3 | using System.IO.Abstractions.TestingHelpers;
4 |
5 | using SlnUp.TestLibrary;
6 |
7 | public class SolutionFileTests
8 | {
9 | [Fact]
10 | public void Construct_WithEmptyFile()
11 | {
12 | // Arrange
13 | const string filePath = "C:\\MyProject.sln";
14 | MockFileSystem fileSystem = new(new Dictionary
15 | {
16 | [filePath] = new MockFileData(string.Empty)
17 | });
18 |
19 | // Act and assert
20 | Assert.Throws(() => new SolutionFile(fileSystem, filePath));
21 | }
22 |
23 | [Fact]
24 | public void Construct_WithFullHeader()
25 | {
26 | // Arrange
27 | Version expectedVersion = Version.Parse("16.0.30114.105");
28 | MockFileSystem fileSystem = new SolutionFileBuilder(expectedVersion)
29 | .BuildToMockFileSystem(out string filePath);
30 |
31 | // Act
32 | SolutionFile solutionFile = new(fileSystem, filePath);
33 |
34 | // Assert
35 | SolutionFileHeader fileHeader = solutionFile.FileHeader;
36 | Assert.Equal(SolutionFileHeader.SupportedFileFormatVersion, fileHeader.FileFormatVersion);
37 | Assert.Equal(16, fileHeader.LastVisualStudioMajorVersion);
38 | Assert.Equal(expectedVersion, fileHeader.LastVisualStudioVersion);
39 | Assert.Equal(SolutionFileBuilder.DefaultVisualStudioMinimumVersion, fileHeader.MinimumVisualStudioVersion);
40 | }
41 |
42 | [Fact]
43 | public void Construct_WithFullV15Header()
44 | {
45 | // Arrange
46 | Version expectedVersion = Version.Parse("15.0.26124.0");
47 | MockFileSystem fileSystem = new SolutionFileBuilder(expectedVersion, visualStudioMinimumVersion: expectedVersion)
48 | .BuildToMockFileSystem(out string filePath);
49 |
50 | // Act
51 | SolutionFile solutionFile = new(fileSystem, filePath);
52 |
53 | // Assert
54 | SolutionFileHeader fileHeader = solutionFile.FileHeader;
55 | Assert.Equal(SolutionFileHeader.SupportedFileFormatVersion, fileHeader.FileFormatVersion);
56 | Assert.Equal(15, fileHeader.LastVisualStudioMajorVersion);
57 | Assert.Equal(expectedVersion, fileHeader.LastVisualStudioVersion);
58 | Assert.Equal(expectedVersion, fileHeader.MinimumVisualStudioVersion);
59 | }
60 |
61 | [Fact]
62 | public void Construct_WithMinimalHeader()
63 | {
64 | // Arrange
65 | MockFileSystem fileSystem = new SolutionFileBuilder()
66 | .ConfigureMinimumHeader()
67 | .BuildToMockFileSystem(out string filePath);
68 |
69 | // Act
70 | SolutionFile solutionFile = new(fileSystem, filePath);
71 |
72 | // Assert
73 | SolutionFileHeader fileHeader = solutionFile.FileHeader;
74 | Assert.Equal(SolutionFileHeader.SupportedFileFormatVersion, fileHeader.FileFormatVersion);
75 | Assert.Null(fileHeader.LastVisualStudioMajorVersion);
76 | Assert.Null(fileHeader.LastVisualStudioVersion);
77 | Assert.Null(fileHeader.MinimumVisualStudioVersion);
78 | }
79 |
80 | [Fact]
81 | public void Construct_WithMinimalHeaderNoBody()
82 | {
83 | // Arrange
84 | MockFileSystem fileSystem = new SolutionFileBuilder()
85 | .ConfigureMinimumHeader()
86 | .ExcludeBody()
87 | .BuildToMockFileSystem(out string filePath);
88 |
89 | // Act
90 | SolutionFile solutionFile = new(fileSystem, filePath);
91 |
92 | // Assert
93 | SolutionFileHeader fileHeader = solutionFile.FileHeader;
94 | Assert.Equal(SolutionFileHeader.SupportedFileFormatVersion, fileHeader.FileFormatVersion);
95 | Assert.Null(fileHeader.LastVisualStudioMajorVersion);
96 | Assert.Null(fileHeader.LastVisualStudioVersion);
97 | Assert.Null(fileHeader.MinimumVisualStudioVersion);
98 | }
99 |
100 | [Fact]
101 | public void Construct_WithMissingFile()
102 | {
103 | // Arrange
104 | MockFileSystem fileSystem = new();
105 | string filePath = TemporaryFile.GetRandomFilePathWithExtension(fileSystem, "sln");
106 |
107 | // Act and assert
108 | FileNotFoundException exception = Assert.Throws(() => new SolutionFile(fileSystem, filePath));
109 |
110 | // Assert
111 | Assert.Equal(filePath, exception.FileName);
112 | }
113 |
114 | [Fact]
115 | public void Construct_WithMissingFileFormatVersion()
116 | {
117 | // Arrange
118 | MockFileSystem fileSystem = new SolutionFileBuilder()
119 | .ExcludeFileFormatVersion()
120 | .BuildToMockFileSystem(out string filePath);
121 |
122 | // Act
123 | Assert.Throws(() => new SolutionFile(fileSystem, filePath));
124 | }
125 |
126 | [Fact]
127 | public void UpdateFileHeader_WithFullHeader()
128 | {
129 | // Arrange
130 | SolutionFile solutionFile = new SolutionFileBuilder(Version.Parse("16.0.30114.105"))
131 | .BuildToMockSolutionFile();
132 | Version expectedVersion = Version.Parse("17.0.31903.59");
133 | string expectedContent = new SolutionFileBuilder(expectedVersion).Build();
134 |
135 | // Act
136 | solutionFile.UpdateFileHeader(expectedVersion);
137 |
138 | // Assert
139 | SolutionFileHeader fileHeader = solutionFile.FileHeader;
140 | Assert.Equal(SolutionFileHeader.SupportedFileFormatVersion, fileHeader.FileFormatVersion);
141 | Assert.Equal(expectedVersion.Major, fileHeader.LastVisualStudioMajorVersion);
142 | Assert.Equal(expectedVersion, fileHeader.LastVisualStudioVersion);
143 | Assert.Equal(SolutionFileBuilder.DefaultVisualStudioMinimumVersion, fileHeader.MinimumVisualStudioVersion);
144 | Assert.Equal(expectedContent, solutionFile.ReadContent());
145 | }
146 |
147 | [Fact]
148 | public void UpdateFileHeader_WithFullV15Header()
149 | {
150 | // Arrange
151 | SolutionFile solutionFile = new SolutionFileBuilder(Version.Parse("15.0.26124.0"))
152 | .BuildToMockSolutionFile();
153 | Version expectedVersion = Version.Parse("15.0.27000.0");
154 | string expectedContent = new SolutionFileBuilder(expectedVersion).Build();
155 |
156 | // Act
157 | solutionFile.UpdateFileHeader(expectedVersion);
158 |
159 | // Assert
160 | SolutionFileHeader fileHeader = solutionFile.FileHeader;
161 | Assert.Equal(SolutionFileHeader.SupportedFileFormatVersion, fileHeader.FileFormatVersion);
162 | Assert.Equal(expectedVersion.Major, fileHeader.LastVisualStudioMajorVersion);
163 | Assert.Equal(expectedVersion, fileHeader.LastVisualStudioVersion);
164 | Assert.Equal(SolutionFileBuilder.DefaultVisualStudioMinimumVersion, fileHeader.MinimumVisualStudioVersion);
165 | Assert.Equal(expectedContent, solutionFile.ReadContent());
166 | }
167 |
168 | [Fact]
169 | public void UpdateFileHeader_WithMinimalHeader()
170 | {
171 | // Arrange
172 | Version expectedVersion = Version.Parse("17.0.31903.59");
173 | string expectedFileContent = new SolutionFileBuilder(expectedVersion).Build();
174 | SolutionFile solutionFile = new SolutionFileBuilder()
175 | .ConfigureMinimumHeader()
176 | .BuildToMockSolutionFile();
177 |
178 | // Act
179 | solutionFile.UpdateFileHeader(expectedVersion);
180 |
181 | // Assert
182 | SolutionFileHeader fileHeader = solutionFile.FileHeader;
183 | Assert.Equal(SolutionFileHeader.SupportedFileFormatVersion, fileHeader.FileFormatVersion);
184 | Assert.Equal(expectedVersion.Major, fileHeader.LastVisualStudioMajorVersion);
185 | Assert.Equal(expectedVersion, fileHeader.LastVisualStudioVersion);
186 | Assert.Equal(Version.Parse(SolutionFileHeader.DefaultMinimumVisualStudioVersion), fileHeader.MinimumVisualStudioVersion);
187 | Assert.Equal(expectedFileContent, solutionFile.ReadContent());
188 | }
189 |
190 | [Fact]
191 | public void UpdateFileHeader_WithMissingMajorVersion()
192 | {
193 | // Arrange
194 | Version expectedVersion = Version.Parse("17.0.31903.59");
195 | string expectedFileContent = new SolutionFileBuilder(expectedVersion).Build();
196 | SolutionFile solutionFile = new SolutionFileBuilder(expectedVersion)
197 | .ExcludeSolutionIconVersion()
198 | .BuildToMockSolutionFile();
199 |
200 | // Act
201 | solutionFile.UpdateFileHeader(expectedVersion);
202 |
203 | // Assert
204 | SolutionFileHeader fileHeader = solutionFile.FileHeader;
205 | Assert.Equal(SolutionFileHeader.SupportedFileFormatVersion, fileHeader.FileFormatVersion);
206 | Assert.Equal(expectedVersion.Major, fileHeader.LastVisualStudioMajorVersion);
207 | Assert.Equal(expectedVersion, fileHeader.LastVisualStudioVersion);
208 | Assert.Equal(Version.Parse(SolutionFileHeader.DefaultMinimumVisualStudioVersion), fileHeader.MinimumVisualStudioVersion);
209 | Assert.Equal(expectedFileContent, solutionFile.ReadContent());
210 | }
211 |
212 | [Fact]
213 | public void UpdateFileHeader_WithMissingMinimumVersion()
214 | {
215 | // Arrange
216 | Version expectedVersion = Version.Parse("17.0.31903.59");
217 | string expectedFileContent = new SolutionFileBuilder(expectedVersion).Build();
218 | SolutionFile solutionFile = new SolutionFileBuilder(expectedVersion)
219 | .ExcludeVisualStudioMinimumVersion()
220 | .BuildToMockSolutionFile();
221 |
222 | // Act
223 | solutionFile.UpdateFileHeader(expectedVersion);
224 |
225 | // Assert
226 | SolutionFileHeader fileHeader = solutionFile.FileHeader;
227 | Assert.Equal(SolutionFileHeader.SupportedFileFormatVersion, fileHeader.FileFormatVersion);
228 | Assert.Equal(expectedVersion.Major, fileHeader.LastVisualStudioMajorVersion);
229 | Assert.Equal(expectedVersion, fileHeader.LastVisualStudioVersion);
230 | Assert.Equal(Version.Parse(SolutionFileHeader.DefaultMinimumVisualStudioVersion), fileHeader.MinimumVisualStudioVersion);
231 | Assert.Equal(expectedFileContent, solutionFile.ReadContent());
232 | }
233 |
234 | [Fact]
235 | public void UpdateFileHeader_WithMissingVersion()
236 | {
237 | // Arrange
238 | Version expectedVersion = Version.Parse("17.0.31903.59");
239 | string expectedFileContent = new SolutionFileBuilder(expectedVersion).Build();
240 | SolutionFile solutionFile = new SolutionFileBuilder(expectedVersion)
241 | .ExcludeVisualStudioFullVersion()
242 | .BuildToMockSolutionFile();
243 |
244 | // Act
245 | solutionFile.UpdateFileHeader(expectedVersion);
246 |
247 | // Assert
248 | SolutionFileHeader fileHeader = solutionFile.FileHeader;
249 | Assert.Equal(SolutionFileHeader.SupportedFileFormatVersion, fileHeader.FileFormatVersion);
250 | Assert.Equal(expectedVersion.Major, fileHeader.LastVisualStudioMajorVersion);
251 | Assert.Equal(expectedVersion, fileHeader.LastVisualStudioVersion);
252 | Assert.Equal(Version.Parse(SolutionFileHeader.DefaultMinimumVisualStudioVersion), fileHeader.MinimumVisualStudioVersion);
253 | Assert.Equal(expectedFileContent, solutionFile.ReadContent());
254 | }
255 |
256 | [Fact]
257 | public void UpdateFileHeader_WithNullLastVisualStudioMajorVersion()
258 | {
259 | // Arrange
260 | Version expectedVersion = Version.Parse("17.0.31903.59");
261 | SolutionFile solutionFile = new SolutionFileBuilder(expectedVersion).BuildToMockSolutionFile();
262 | SolutionFileHeader fileHeader = new(
263 | fileFormatVersion: SolutionFileHeader.SupportedFileFormatVersion,
264 | lastVisualStudioMajorVersion: null,
265 | lastVisualStudioVersion: expectedVersion,
266 | minimumVisualStudioVersion: expectedVersion);
267 |
268 | // Act and assert
269 | Assert.Throws(() => solutionFile.UpdateFileHeader(fileHeader));
270 | }
271 |
272 | [Fact]
273 | public void UpdateFileHeader_WithNullLastVisualStudioVersion()
274 | {
275 | // Arrange
276 | Version expectedVersion = Version.Parse("17.0.31903.59");
277 | SolutionFile solutionFile = new SolutionFileBuilder(expectedVersion).BuildToMockSolutionFile();
278 | SolutionFileHeader fileHeader = new(
279 | fileFormatVersion: SolutionFileHeader.SupportedFileFormatVersion,
280 | lastVisualStudioMajorVersion: expectedVersion.Major,
281 | lastVisualStudioVersion: null,
282 | minimumVisualStudioVersion: expectedVersion);
283 |
284 | // Act and assert
285 | Assert.Throws(() => solutionFile.UpdateFileHeader(fileHeader));
286 | }
287 | }
288 |
--------------------------------------------------------------------------------
/tests/SlnUp.Tests/Utilities/TestConsoleExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace SlnUp.Tests.Utilities;
2 |
3 | using System.CommandLine;
4 |
5 | internal static class TestConsoleExtensions
6 | {
7 | public static string GetErrorOutput(this IConsole console) => console.Error.ToString() ?? string.Empty;
8 |
9 | public static string GetOutput(this IConsole console) => console.Out.ToString() ?? string.Empty;
10 |
11 | public static bool HasErrorOutput(this IConsole console) => !string.IsNullOrEmpty(console.GetErrorOutput());
12 |
13 | public static bool HasNoErrorOutput(this IConsole console) => !console.HasErrorOutput();
14 |
15 | public static bool HasNoOutput(this IConsole console) => !console.HasOutput();
16 |
17 | public static bool HasOutput(this IConsole console) => !string.IsNullOrEmpty(console.GetOutput());
18 | }
19 |
--------------------------------------------------------------------------------
/version.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://raw.githubusercontent.com/AArnott/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
3 | "version": "3.0.1-beta.{height}",
4 | "buildNumberOffset": -1,
5 | "nugetPackageVersion": {
6 | "semVer": 2
7 | },
8 | "publicReleaseRefSpec": [
9 | "^refs/heads/main$" // we release out of main
10 | ]
11 | }
--------------------------------------------------------------------------------