Skip to content

Commit 0c71170

Browse files
Copilotmackoj
andauthored
Fix file name sanitization for generic types with invalid characters (#31)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mackoj <661647+mackoj@users.noreply.github.com>
1 parent 8168e0f commit 0c71170

File tree

3 files changed

+161
-3
lines changed

3 files changed

+161
-3
lines changed

Sources/SwiftSnapshotCore/PathResolver.swift

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,58 @@ enum PathResolver {
136136
finalFileName = fileName.hasSuffix(".swift") ? fileName : "\(fileName).swift"
137137
} else {
138138
// Default: TypeName+VariableName.swift
139-
finalFileName = "\(typeName)+\(variableName).swift"
139+
// Sanitize the type name to ensure it's a valid file name
140+
let sanitizedTypeName = sanitizeFileName(typeName)
141+
finalFileName = "\(sanitizedTypeName)+\(variableName).swift"
140142
}
141143

142144
return outputDirectory.appendingPathComponent(finalFileName)
143145
}
146+
147+
/// Sanitize a string to be a valid file name component
148+
///
149+
/// Replaces characters that are invalid in file names with underscores.
150+
/// This is particularly important for generic types like `User<T>` or `Dictionary<String, Int>`.
151+
///
152+
/// ## Invalid Characters
153+
///
154+
/// The following characters are replaced with underscores:
155+
/// - Angle brackets: `<` `>`
156+
/// - Slashes: `/` `\`
157+
/// - Colons: `:`
158+
/// - Asterisks: `*`
159+
/// - Question marks: `?`
160+
/// - Quotes: `"` `'`
161+
/// - Pipe: `|`
162+
/// - Other special characters that might be invalid on some filesystems
163+
///
164+
/// ## Examples
165+
///
166+
/// ```swift
167+
/// sanitizeFileName("User<Kakou>")
168+
/// // Returns: "User_Kakou_"
169+
///
170+
/// sanitizeFileName("Dictionary<String, Int>")
171+
/// // Returns: "Dictionary_String__Int_"
172+
///
173+
/// sanitizeFileName("Array<[String: Int]>")
174+
/// // Returns: "Array__String__Int__"
175+
/// ```
176+
///
177+
/// - Parameter name: The string to sanitize for use as a file name
178+
/// - Returns: A sanitized string safe for use in file names
179+
private static func sanitizeFileName(_ name: String) -> String {
180+
// Characters that are typically invalid or problematic in file names across platforms
181+
let invalidChars: Set<Character> = ["<", ">", ":", "\"", "/", "\\", "|", "?", "*", ",", " "]
182+
183+
let sanitized = name.map { char -> Character in
184+
if invalidChars.contains(char) {
185+
return "_"
186+
} else {
187+
return char
188+
}
189+
}
190+
191+
return String(sanitized)
192+
}
144193
}

Tests/SwiftSnapshotTests/IntegrationTests.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,5 +382,64 @@ extension SnapshotTests {
382382
"""#
383383
}
384384
}
385+
386+
// MARK: - Generic Types Test
387+
388+
@Test func genericTypesWithValidFileName() throws {
389+
struct Kakou: Codable {
390+
let toto: String
391+
let tata: Val
392+
enum Val: Codable {
393+
case a, b, c
394+
}
395+
}
396+
397+
struct User<T: Codable>: Codable {
398+
let id: Int
399+
let name: String
400+
let some: [T]
401+
}
402+
403+
let mockData = [
404+
Kakou(toto: "hello", tata: .b),
405+
Kakou(toto: "world", tata: .c),
406+
]
407+
408+
let user = User(id: 42, name: "Alice", some: mockData)
409+
410+
// Test that the file export works and creates a valid file name
411+
let tempDir = FileManager.default.temporaryDirectory
412+
.appendingPathComponent("SwiftSnapshotTests")
413+
.appendingPathComponent(UUID().uuidString)
414+
415+
let url = try SwiftSnapshotRuntime.export(
416+
instance: user,
417+
variableName: "mock",
418+
outputBasePath: tempDir.path,
419+
header: "/// Test HEADER",
420+
context: "This is for testing."
421+
)
422+
423+
// Cleanup
424+
defer { try? FileManager.default.removeItem(at: tempDir) }
425+
426+
// Verify file exists
427+
#expect(FileManager.default.fileExists(atPath: url.path))
428+
429+
// Verify file name is sanitized (no < or > characters)
430+
let fileName = url.lastPathComponent
431+
#expect(!fileName.contains("<"))
432+
#expect(!fileName.contains(">"))
433+
434+
// The file name should be sanitized to User_Kakou_+mock.swift
435+
#expect(fileName == "User_Kakou_+mock.swift")
436+
437+
// Verify content is valid Swift code
438+
let content = try String(contentsOf: url, encoding: .utf8)
439+
#expect(content.contains("/// Test HEADER"))
440+
#expect(content.contains("This is for testing."))
441+
#expect(content.contains("static let mock"))
442+
#expect(content.contains("id: 42"))
443+
}
385444
}
386445
}

Tests/SwiftSnapshotTests/PathResolverTests.swift

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ extension SnapshotTests {
135135
outputDirectory: outputDir
136136
)
137137

138-
#expect(result1.lastPathComponent == "Array<String>+myArray.swift")
138+
// Angle brackets should be sanitized
139+
#expect(result1.lastPathComponent == "Array_String_+myArray.swift")
139140

140141
let result2 = PathResolver.resolveFilePath(
141142
typeName: "Dictionary<String, Int>",
@@ -144,7 +145,56 @@ extension SnapshotTests {
144145
outputDirectory: outputDir
145146
)
146147

147-
#expect(result2.lastPathComponent == "Dictionary<String, Int>+myDict.swift")
148+
// Angle brackets and commas should be sanitized
149+
#expect(result2.lastPathComponent == "Dictionary_String__Int_+myDict.swift")
150+
}
151+
152+
/// Test that generic type names are properly sanitized
153+
@Test func resolveFilePathWithGenericTypes() {
154+
let outputDir = URL(fileURLWithPath: "/tmp/snapshots")
155+
156+
// Test User<Kakou>
157+
let result1 = PathResolver.resolveFilePath(
158+
typeName: "User<Kakou>",
159+
variableName: "mock",
160+
fileName: nil,
161+
outputDirectory: outputDir
162+
)
163+
#expect(result1.lastPathComponent == "User_Kakou_+mock.swift")
164+
165+
// Test nested generics
166+
let result2 = PathResolver.resolveFilePath(
167+
typeName: "Array<Dictionary<String, Int>>",
168+
variableName: "data",
169+
fileName: nil,
170+
outputDirectory: outputDir
171+
)
172+
#expect(result2.lastPathComponent == "Array_Dictionary_String__Int__+data.swift")
173+
174+
// Test with spaces
175+
let result3 = PathResolver.resolveFilePath(
176+
typeName: "Optional<User Model>",
177+
variableName: "user",
178+
fileName: nil,
179+
outputDirectory: outputDir
180+
)
181+
#expect(result3.lastPathComponent == "Optional_User_Model_+user.swift")
182+
}
183+
184+
/// Test that custom fileName is not sanitized (user provided)
185+
@Test func customFileNameNotSanitized() {
186+
let outputDir = URL(fileURLWithPath: "/tmp/snapshots")
187+
188+
// When user provides custom fileName, it should be used as-is
189+
let result = PathResolver.resolveFilePath(
190+
typeName: "User<Kakou>",
191+
variableName: "mock",
192+
fileName: "CustomFixture",
193+
outputDirectory: outputDir
194+
)
195+
196+
// Custom file name should not trigger sanitization
197+
#expect(result.lastPathComponent == "CustomFixture.swift")
148198
}
149199

150200
/// Test priority order: explicit > global > env > default

0 commit comments

Comments
 (0)