Repository: apple/swift-docc (swift)

Issue:
[SR-15634] Remove need to pass `--fallback-...` CLI arguments in most cases

   |                  |                 |
   |------------------|-----------------|
   |Previous ID       | SR-15634      |
   |Radar             | rdar://problem/86819483         |
   |Original Reporter | @d-ronnqvist      |
   |Type              | Improvement    |

   <details>
  <summary>Additional Detail from JIRA</summary>

   |                  |                 |
   |------------------|-----------------|
   |Votes             | 0         |
   |Component/s       | Swift-DocC    |
   |Labels            | Improvement, StarterBug        |
   |Assignee          | waheedNowLovesSwift (JIRA)      |
   |Priority          | Medium      |

   

   md5: 675dc1ff1939386d0c186c42c940543c

  </details>





**Issue Description:**


Swift-DocC requires some minimal information about the documentation that it's building to name and identify the documentation. This information can be specified in a couple of different ways:

-   If the documentation has a DocC catalog (a folder with a .docc extension) with an Info.plist file in it, then the documentation's name and identifier can be read from the content of that identifier.

-   If the documentation doesn't have a DocC catalog, or the catalog doesn't have an Info.plist file, then the documentation's name and identifier can be provided as two command line arguments (`----``fallback-display-name` and `--``fallback-bundle-identifier`). These arguments are prefixed with "fallback" because they are only used when a value can't be retrieved from the DocC catalog's Info.plist file (for example if that file doesn't exist).

The documentation's identifier doesn't need to follow a specific format. That said, the identifier needs to be unique to the current documentation build. In almost all cases documentation is built separately for each "bundle", so uniqueness isn't an issue. Further, even when multiple "bundles" are built together their display name are most often unique enough.

> A documentation "bundle" is the term for the in-memory representation about a framework or other project. One DocC catalog corresponds to one documentation bundle, but a bundle can also be created from the module information in symbol graph files when there's no DocC catalog.

Because of this, a suitable documentation identifier can almost always be derived from the documentation's display name. When that derived identifier isn't unique, this can be identified and diagnosed and that issue can be resolved by providing a unique identifier either as an Info.plist value or as a custom `--``fallback-bundle-identifier` argument.

Further, a suitable documentation display name can almost always be derived from other information in the documentation "bundle". A documentation bundle consist of either a DocC catalog or a collection of symbol graph files, or both.

Most documentation is about a single module. In this case, when all the bundle's symbol graph files are about the same module, then that module name is a good display name in almost all cases. When it's not, a custom name can be provided as a `--fallback-display-name` argument.

If the bundle contains symbol graph files about more than one module or if the bundle doesn't contain any symbol graph files, then it's less clear what a good display name would be. However, since the developer need to provide a custom display name if none is derived but only needs to provide a custom display name if the derived one is incorrect, I feel that deriving a display name that's good enough in many cases still provide value here. In this situation, I feel that deriving the documentation from the DocC catalogs filename (without the file extension) is likely to be a good enough value in many cases.

Since the documentation's version information is already optional, making the above changes to derive the documentation's identifier and display name would remove most of the cases when developer's need to pass `--fallback...` CLI arguments.

### Summary

If a documentation bundle has no specified identifier, derive the identifier from the bundle's display name.

If a documentation bundle has no specified display name:

-   and it has symbol graph files about a single module, derive the display name from the module's name

-   and it has a DocC catalog, derive the display name from the catalog's filename (without the file extension)

-   otherwise (if the bundle has no DocC catalog and symbol graphs about multiple modules, raise the same error as today that a display is missing name and needs to be provided.

Pull request description:
Bug/issue #, if applicable:  https://github.com/apple/swift-docc/issues/194

## Summary

We can remove the need to pass `--fallback-...` CLI arguments in most cases. 

A suitable documentation identifier can almost always be derived from the documentation's display name and a suitable documentation display name can almost always be derived from other information in the documentation "bundle".

If a documentation bundle has no specified identifier, derive the identifier from the bundle's display name.

If a documentation bundle has no specified display name:
- and it has symbol graph files about a single module, derive the display name from the module's name
- and it has a DocC catalog, derive the display name from the catalog's filename (without the file extension)
otherwise (if the bundle has no DocC catalog and symbol graphs about multiple modules, raise the same error as today that a display is missing name and needs to be provided.

## Testing

To test this, you can create a documentation archive without providing fallbacks for the `display-name` or `bundle-identifier` and check that it succeeds and derives the `display-name` and `bundle-identifier` correctly. 

## Checklist

Make sure you check off the following items. If they cannot be completed, provide a reason.

- [x] Added tests
- [x] Ran the `./bin/test` script and it succeeded
- [x] Updated documentation if necessary

Patch:
diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/DataProviderBundleDiscovery.swift b/Sources/SwiftDocC/Infrastructure/Workspace/DataProviderBundleDiscovery.swift
index db34fbf0d7..fc226b07b5 100644
--- a/Sources/SwiftDocC/Infrastructure/Workspace/DataProviderBundleDiscovery.swift
+++ b/Sources/SwiftDocC/Infrastructure/Workspace/DataProviderBundleDiscovery.swift
@@ -1,7 +1,7 @@
 /*
  This source file is part of the Swift.org open source project
 
- Copyright (c) 2021 Apple Inc. and the Swift project authors
+ Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
  Licensed under Apache License v2.0 with Runtime Library Exception
 
  See https://swift.org/LICENSE.txt for license information
@@ -58,7 +58,11 @@ extension DocumentationWorkspaceDataProvider where Self: FileSystemProvider {
         } else {
             infoPlistData = nil
         }
-        let info = try DocumentationBundle.Info(from: infoPlistData, bundleDiscoveryOptions: options)
+        let info = try DocumentationBundle.Info(
+            from: infoPlistData,
+            bundleDiscoveryOptions: options,
+            derivedDisplayName: directory.url.deletingPathExtension().lastPathComponent
+        )
         
         let markupFiles = findMarkupFiles(bundleChildren, recursive: true).map { $0.url }
         let miscResources = findNonMarkupFiles(bundleChildren, recursive: true).map { $0.url }
diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/DocumentationBundle+Info.swift b/Sources/SwiftDocC/Infrastructure/Workspace/DocumentationBundle+Info.swift
index f07000e91d..1e6b589bf3 100644
--- a/Sources/SwiftDocC/Infrastructure/Workspace/DocumentationBundle+Info.swift
+++ b/Sources/SwiftDocC/Infrastructure/Workspace/DocumentationBundle+Info.swift
@@ -1,7 +1,7 @@
 /*
  This source file is part of the Swift.org open source project
 
- Copyright (c) 2021 Apple Inc. and the Swift project authors
+ Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
  Licensed under Apache License v2.0 with Runtime Library Exception
 
  See https://swift.org/LICENSE.txt for license information
@@ -82,7 +82,8 @@ extension DocumentationBundle {
         /// in the given bundle discovery options if necessary.
         init(
             from infoPlist: Data? = nil,
-            bundleDiscoveryOptions options: BundleDiscoveryOptions? = nil
+            bundleDiscoveryOptions options: BundleDiscoveryOptions? = nil,
+            derivedDisplayName: String? = nil
         ) throws {
             if let infoPlist = infoPlist {
                 let propertyListDecoder = PropertyListDecoder()
@@ -91,6 +92,10 @@ extension DocumentationBundle {
                     propertyListDecoder.userInfo[.bundleDiscoveryOptions] = options
                 }
                 
+                if let derivedDisplayName = derivedDisplayName {
+                    propertyListDecoder.userInfo[.derivedDisplayName] = derivedDisplayName
+                }
+                
                 do {
                     self = try propertyListDecoder.decode(
                         DocumentationBundle.Info.self,
@@ -101,22 +106,29 @@ extension DocumentationBundle {
                 }
                 
             } else {
-                try self.init(with: nil, bundleDiscoveryOptions: options)
+                try self.init(
+                    with: nil,
+                    bundleDiscoveryOptions: options,
+                    derivedDisplayName: derivedDisplayName
+                )
             }
         }
         
         public init(from decoder: Decoder) throws {
             let bundleDiscoveryOptions = decoder.userInfo[.bundleDiscoveryOptions] as? BundleDiscoveryOptions
+            let derivedDisplayName = decoder.userInfo[.derivedDisplayName] as? String
             
             try self.init(
                 with: decoder.container(keyedBy: CodingKeys.self),
-                bundleDiscoveryOptions: bundleDiscoveryOptions
+                bundleDiscoveryOptions: bundleDiscoveryOptions,
+                derivedDisplayName: derivedDisplayName
             )
         }
         
         private init(
             with values: KeyedDecodingContainer<DocumentationBundle.Info.CodingKeys>?,
-            bundleDiscoveryOptions: BundleDiscoveryOptions?
+            bundleDiscoveryOptions: BundleDiscoveryOptions?,
+            derivedDisplayName: String?
         ) throws {
             // Here we define two helper functions that simplify
             // the decoding logic where we'll need to first check if the value
@@ -136,11 +148,12 @@ extension DocumentationBundle {
             /// Helper function that decodes a value of the given type for the given key
             /// in either the Codable container or Info.plist fallbacks.
             func decodeOrFallback<T>(
-                _ expectedType: T.Type, with key: CodingKeys
+                _ expectedType: T.Type,
+                with key: CodingKeys
             ) throws -> T where T : Decodable {
                 if let bundleDiscoveryOptions = bundleDiscoveryOptions {
                     return try values?.decodeIfPresent(T.self, forKey: key)
-                        ?? bundleDiscoveryOptions.infoPlistFallbacks.decode(T.self, forKey: key.rawValue)
+                    ?? bundleDiscoveryOptions.infoPlistFallbacks.decode(T.self, forKey: key.rawValue)
                 } else if let values = values {
                     return try values.decode(T.self, forKey: key)
                 } else {
@@ -154,12 +167,24 @@ extension DocumentationBundle {
             // This allows us to throw a more comprehensive error that includes
             // **all** missing required keys, instead of just the first one hit.
             
-            let givenKeys = Set(values?.allKeys ?? []).union(
+            var givenKeys = Set(values?.allKeys ?? []).union(
                 bundleDiscoveryOptions?.infoPlistFallbacks.keys.compactMap {
                     CodingKeys(stringValue: $0)
                 } ?? []
             )
             
+            // If present, we can use `Info.displayName` as a fallback
+            // for `Info.identifier`.
+            if givenKeys.contains(.displayName) {
+                givenKeys.insert(.identifier)
+            }
+            
+            // If present, we can use the `derivedDisplayName`
+            // as a fallback for the `Info.displayName` and `Info.identifier`.
+            if derivedDisplayName != nil {
+                givenKeys.formUnion([.displayName, .identifier])
+            }
+            
             let missingKeys = Self.requiredKeys.subtracting(givenKeys)
             
             guard missingKeys.isEmpty else {
@@ -170,8 +195,10 @@ extension DocumentationBundle {
             // by decoding the required keys, throwing an error if we fail to
             // decode them for some reason.
             
-            self.displayName = try decodeOrFallback(String.self, with: .displayName)
-            self.identifier = try decodeOrFallback(String.self, with: .identifier)
+            // It's safe to unwrap `derivedDisplayName` because it will only be accessed if neither the decoding container nor the bundle discovery options
+            // contain a display name. If they do but that value fails to decode, that error would be raised before accessing `derivedDisplayName`.
+            self.displayName = try decodeOrFallbackIfPresent(String.self, with: .displayName) ?? derivedDisplayName!
+            self.identifier = try decodeOrFallbackIfPresent(String.self, with: .identifier) ?? self.displayName
             self.version = try decodeOrFallbackIfPresent(String.self, with: .version)
             
             // Finally, decode the optional keys if they're present.
@@ -261,4 +288,6 @@ extension BundleDiscoveryOptions {
 private extension CodingUserInfoKey {
     /// A user info key to store bundle discovery options in the decoder.
     static let bundleDiscoveryOptions = CodingUserInfoKey(rawValue: "bundleDiscoveryOptions")!
+    /// A user info key to store derived display name in the decoder.
+    static let derivedDisplayName = CodingUserInfoKey(rawValue: "derivedDisplayName")!
 }
diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/GeneratedDataProvider.swift b/Sources/SwiftDocC/Infrastructure/Workspace/GeneratedDataProvider.swift
index e7616408d4..86e391c23f 100644
--- a/Sources/SwiftDocC/Infrastructure/Workspace/GeneratedDataProvider.swift
+++ b/Sources/SwiftDocC/Infrastructure/Workspace/GeneratedDataProvider.swift
@@ -1,7 +1,7 @@
 /*
  This source file is part of the Swift.org open source project
 
- Copyright (c) 2021 Apple Inc. and the Swift project authors
+ Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
  Licensed under Apache License v2.0 with Runtime Library Exception
 
  See https://swift.org/LICENSE.txt for license information
@@ -28,17 +28,6 @@ public class GeneratedDataProvider: DocumentationWorkspaceDataProvider {
     }
     
     public func bundles(options: BundleDiscoveryOptions) throws -> [DocumentationBundle] {
-        let info: DocumentationBundle.Info
-        do {
-            info = try DocumentationBundle.Info(bundleDiscoveryOptions: options)
-        } catch {
-            throw Error.notEnoughDataToGenerateBundle(options: options, underlyingError: error)
-        }
-        
-        guard !options.additionalSymbolGraphFiles.isEmpty else {
-            return []
-        }
-        
         // Find all the unique module names from the symbol graph files and generate a top level module page for each of them.
         var moduleNames = Set<String>()
         for url in options.additionalSymbolGraphFiles {
@@ -48,6 +37,25 @@ public class GeneratedDataProvider: DocumentationWorkspaceDataProvider {
             let container = try JSONDecoder().decode(SymbolGraphModuleContainer.self, from: data)
             moduleNames.insert(container.module.name)
         }
+        let info: DocumentationBundle.Info
+        do {
+            let derivedDisplayName: String?
+            if moduleNames.count == 1, let moduleName = moduleNames.first {
+                derivedDisplayName = moduleName
+            } else {
+                derivedDisplayName = nil
+            }
+            info = try DocumentationBundle.Info(
+                bundleDiscoveryOptions: options,
+                derivedDisplayName: derivedDisplayName
+            )
+        } catch {
+            throw Error.notEnoughDataToGenerateBundle(options: options, underlyingError: error)
+        }
+        
+        guard !options.additionalSymbolGraphFiles.isEmpty else {
+            return []
+        }
         
         if moduleNames.count == 1, let moduleName = moduleNames.first, moduleName != info.displayName {
             generatedMarkdownFiles[moduleName] = Data("""


Test patch:
diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationBundleInfoTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationBundleInfoTests.swift
index d77b1dd291..4a930df52e 100644
--- a/Tests/SwiftDocCTests/Infrastructure/DocumentationBundleInfoTests.swift
+++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationBundleInfoTests.swift
@@ -1,7 +1,7 @@
 /*
  This source file is part of the Swift.org open source project
 
- Copyright (c) 2021 Apple Inc. and the Swift project authors
+ Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
  Licensed under Apache License v2.0 with Runtime Library Exception
 
  See https://swift.org/LICENSE.txt for license information
@@ -286,7 +286,7 @@ class DocumentationBundleInfoTests: XCTestCase {
     }
     
     func testDataCorruptedPlist() throws {
-        let valueMissingInvaildPlist = """
+        let valueMissingInvalidPlist = """
         <plist version="1.0">
         <dict>
           <key>CDDefaultCodeListingLanguage</key>
@@ -314,9 +314,9 @@ class DocumentationBundleInfoTests: XCTestCase {
         </plist>
         """
         
-        let valueMissingInvaildPlistData = Data(valueMissingInvaildPlist.utf8)
+        let valueMissingInvalidPlistData = Data(valueMissingInvalidPlist.utf8)
         XCTAssertThrowsError(
-            try DocumentationBundle.Info(from: valueMissingInvaildPlistData),
+            try DocumentationBundle.Info(from: valueMissingInvalidPlistData),
             "Info.plist decode didn't throw as expected"
         ) { error in
             XCTAssertTrue(error is DocumentationBundle.Info.Error)
@@ -330,4 +330,79 @@ class DocumentationBundleInfoTests: XCTestCase {
             XCTAssertEqual(error.localizedDescription, "Unable to decode Info.plist file. Verify that it is correctly formed. Value missing for key inside <dict> at line 24")
         }
     }
+    
+    func testDerivedDisplayNameAsFallback() {
+        let infoPlistWithoutRequiredKeys = """
+        <plist version="1.0">
+        <dict>
+        </dict>
+        </plist>
+        """
+        
+        let infoPlistWithoutRequiredKeysData = Data(infoPlistWithoutRequiredKeys.utf8)
+        
+        XCTAssertEqual(
+            try DocumentationBundle.Info(
+                from: infoPlistWithoutRequiredKeysData,
+                bundleDiscoveryOptions: nil,
+                derivedDisplayName: "Derived Display Name"
+            ),
+            DocumentationBundle.Info(
+                displayName: "Derived Display Name",
+                identifier: "Derived Display Name",
+                version: nil
+            )
+        )
+    }
+    
+    func testDerivedDisplayNameAsFallbackWithIdentifier() {
+        let infoPlistWithoutRequiredKeys = """
+        <plist version="1.0">
+        <dict>
+        <key>CFBundleIdentifier</key>
+        <string>org.swift.docc.example</string>
+        </dict>
+        </plist>
+        """
+        
+        let infoPlistWithoutRequiredKeysData = Data(infoPlistWithoutRequiredKeys.utf8)
+        
+        XCTAssertEqual(
+            try DocumentationBundle.Info(
+                from: infoPlistWithoutRequiredKeysData,
+                bundleDiscoveryOptions: nil,
+                derivedDisplayName: "Derived Display Name"
+            ),
+            DocumentationBundle.Info(
+                displayName: "Derived Display Name",
+                identifier: "org.swift.docc.example",
+                version: nil
+            )
+        )
+    }
+    
+    func testDisplayNameAsIdentifierFallback() {
+        let infoPlistWithoutRequiredKeys = """
+        <plist version="1.0">
+        <dict>
+        <key>CFBundleDisplayName</key>
+        <string>Example</string>
+        </dict>
+        </plist>
+        """
+        
+        let infoPlistWithoutRequiredKeysData = Data(infoPlistWithoutRequiredKeys.utf8)
+        
+        XCTAssertEqual(
+            try DocumentationBundle.Info(
+                from: infoPlistWithoutRequiredKeysData,
+                bundleDiscoveryOptions: nil
+            ),
+            DocumentationBundle.Info(
+                displayName: "Example",
+                identifier: "Example",
+                version: nil
+            )
+        )
+    }
 }
diff --git a/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift b/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift
index d9a126f840..134482bba6 100644
--- a/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift
+++ b/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift
@@ -336,49 +336,6 @@ class ConvertActionTests: XCTestCase {
         let myProtocolNode = try JSONDecoder().decode(RenderNode.self, from: myProtocolNodeData)
         XCTAssertNil(myProtocolNode.abstract)
     }
-    
-    func testConvertWithoutBundleErrorMessage() throws {
-        let myKitSymbolGraph = Bundle.module.url(forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")!
-            .appendingPathComponent("mykit-iOS.symbols.json")
-        
-        XCTAssert(FileManager.default.fileExists(atPath: myKitSymbolGraph.path))
-        let symbolGraphFiles = Folder(name: "Not-a-doc-bundle", content: [
-            CopyOfFile(original: myKitSymbolGraph, newName: "MyKit.symbols.json"),
-        ])
-        
-        let outputLocation = Folder(name: "output", content: [])
-        
-        let testDataProvider = try TestFileSystem(folders: [Folder.emptyHTMLTemplateDirectory, symbolGraphFiles, outputLocation])
-        
-        var infoPlistFallbacks = [String: Any]()
-        infoPlistFallbacks["CFBundleIdentifier"] = "com.example.test"
-        
-        var action = try ConvertAction(
-            documentationBundleURL: nil,
-            outOfProcessResolver: nil,
-            analyze: false,
-            targetDirectory: outputLocation.absoluteURL,
-            htmlTemplateDirectory: Folder.emptyHTMLTemplateDirectory.absoluteURL,
-            emitDigest: false,
-            currentPlatforms: nil,
-            fileManager: testDataProvider,
-                temporaryDirectory: createTemporaryDirectory(),
-            bundleDiscoveryOptions: BundleDiscoveryOptions(
-                infoPlistFallbacks: infoPlistFallbacks,
-                additionalSymbolGraphFiles: [URL(fileURLWithPath: "/Not-a-doc-bundle/MyKit.symbols.json")]
-            )
-        )
-        let logStorage = LogHandle.LogStorage()
-        XCTAssertThrowsError(try action.perform(logHandle: .memory(logStorage))) { error in
-            XCTAssertEqual(error.localizedDescription, """
-            The information provided as command line arguments is not enough to generate a documentation bundle:
-            
-            Missing value for 'CFBundleDisplayName'.
-            Use the '--fallback-display-name' argument or add 'CFBundleDisplayName' to the bundle Info.plist.
-            
-            """)
-        }
-    }
 
     func testMoveOutputCreatesTargetFolderParent() throws {
         // Source folder to test moving
@@ -2637,7 +2594,127 @@ class ConvertActionTests: XCTestCase {
 
     }
 
+    func testConvertWithoutBundleDerivesDisplayNameAndIdentifierFromSingleModuleSymbolGraph() throws {
+        let myKitSymbolGraph = Bundle.module.url(forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")!
+            .appendingPathComponent("mykit-iOS.symbols.json")
+        
+        XCTAssert(FileManager.default.fileExists(atPath: myKitSymbolGraph.path))
+        let symbolGraphFiles = Folder(name: "Not-a-doc-bundle", content: [
+            CopyOfFile(original: myKitSymbolGraph, newName: "MyKit.symbols.json"),
+        ])
+        
+        let outputLocation = Folder(name: "output", content: [])
+        
+        let testDataProvider = try TestFileSystem(folders: [Folder.emptyHTMLTemplateDirectory, symbolGraphFiles, outputLocation])
+        
+        var action = try ConvertAction(
+            documentationBundleURL: nil,
+            outOfProcessResolver: nil,
+            analyze: false,
+            targetDirectory: outputLocation.absoluteURL,
+            htmlTemplateDirectory: Folder.emptyHTMLTemplateDirectory.absoluteURL,
+            emitDigest: false,
+            currentPlatforms: nil,
+            fileManager: testDataProvider,
+                temporaryDirectory: createTemporaryDirectory(),
+            bundleDiscoveryOptions: BundleDiscoveryOptions(
+                additionalSymbolGraphFiles: [URL(fileURLWithPath: "/Not-a-doc-bundle/MyKit.symbols.json")]
+            )
+        )
+        
+        XCTAssert(action.context.registeredBundles.isEmpty)
+        XCTAssertNoThrow(try action.perform(logHandle: .standardOutput))
+
+        XCTAssertEqual(action.context.registeredBundles.count, 1)
+        let bundle = try XCTUnwrap(action.context.registeredBundles.first, "Should have registered the generated test bundle.")
+        XCTAssertEqual(bundle.displayName, "MyKit")
+        XCTAssertEqual(bundle.identifier, "MyKit")
+    }
     
+    func testConvertWithoutBundleErrorsForMultipleModulesSymbolGraph() throws {
+        let testBundle = Bundle.module.url(forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")!
+        let myKitSymbolGraph = testBundle
+            .appendingPathComponent("mykit-iOS.symbols.json")
+        let sideKitSymbolGraph = testBundle
+            .appendingPathComponent("sidekit.symbols.json")
+        
+        XCTAssert(FileManager.default.fileExists(atPath: myKitSymbolGraph.path))
+        XCTAssert(FileManager.default.fileExists(atPath: sideKitSymbolGraph.path))
+        let symbolGraphFiles = Folder(name: "Not-a-doc-bundle", content: [
+            CopyOfFile(original: myKitSymbolGraph, newName: "MyKit.symbols.json"),
+            CopyOfFile(original: sideKitSymbolGraph, newName: "SideKit.symbols.json")
+        ])
+        
+        let outputLocation = Folder(name: "output", content: [])
+        
+        let testDataProvider = try TestFileSystem(
+            folders: [Folder.emptyHTMLTemplateDirectory, symbolGraphFiles, outputLocation]
+        )
+        
+        var infoPlistFallbacks = [String: Any]()
+        infoPlistFallbacks["CFBundleIdentifier"] = "com.example.test"
+        
+        var action = try ConvertAction(
+            documentationBundleURL: nil,
+            outOfProcessResolver: nil,
+            analyze: false,
+            targetDirectory: outputLocation.absoluteURL,
+            htmlTemplateDirectory: Folder.emptyHTMLTemplateDirectory.absoluteURL,
+            emitDigest: false,
+            currentPlatforms: nil,
+            fileManager: testDataProvider,
+                temporaryDirectory: createTemporaryDirectory(),
+            bundleDiscoveryOptions: BundleDiscoveryOptions(
+                infoPlistFallbacks: infoPlistFallbacks,
+                additionalSymbolGraphFiles: [
+                    URL(fileURLWithPath: "/Not-a-doc-bundle/MyKit.symbols.json"),
+                    URL(fileURLWithPath: "/Not-a-doc-bundle/SideKit.symbols.json")
+                ]
+            )
+        )
+        
+        let logStorage = LogHandle.LogStorage()
+        XCTAssertThrowsError(try action.perform(logHandle: .memory(logStorage))) { error in
+            XCTAssertEqual(error.localizedDescription, """
+            The information provided as command line arguments is not enough to generate a documentation bundle:
+            
+            Missing value for 'CFBundleDisplayName'.
+            Use the '--fallback-display-name' argument or add 'CFBundleDisplayName' to the bundle Info.plist.
+            
+            """)
+        }
+    }
+    
+    func testConvertWithBundleDerivesDisplayNameFromBundle() throws {
+        let emptyDoccCatalog = try createTemporaryDirectory(named: "Something.docc")
+        let outputLocation = try createTemporaryDirectory(named: "output")
+
+        var infoPlistFallbacks = [String: Any]()
+        infoPlistFallbacks["CFBundleIdentifier"] = "com.example.test"
+
+        var action = try ConvertAction(
+            documentationBundleURL: emptyDoccCatalog,
+            outOfProcessResolver: nil,
+            analyze: false,
+            targetDirectory: outputLocation.absoluteURL,
+            htmlTemplateDirectory: Folder.emptyHTMLTemplateDirectory.write(inside: createTemporaryDirectory(named: "template")),
+            emitDigest: false,
+            currentPlatforms: nil,
+            temporaryDirectory: createTemporaryDirectory(),
+            bundleDiscoveryOptions: BundleDiscoveryOptions(
+                infoPlistFallbacks: infoPlistFallbacks,
+                additionalSymbolGraphFiles: []
+            )
+        )
+        XCTAssert(action.context.registeredBundles.isEmpty)
+        XCTAssertNoThrow(try action.perform(logHandle: .standardOutput))
+
+        XCTAssertEqual(action.context.registeredBundles.count, 1)
+        let bundle = try XCTUnwrap(action.context.registeredBundles.first, "Should have registered the generated test bundle.")
+        XCTAssertEqual(bundle.displayName, "Something")
+        XCTAssertEqual(bundle.identifier, "com.example.test")
+    }
+
     private func uniformlyPrintDiagnosticMessages(_ problems: [Problem]) -> String {
         return problems.sorted(by: { (lhs, rhs) -> Bool in
             guard lhs.diagnostic.identifier != rhs.diagnostic.identifier else {