Repository: microsoft/kiota (csharp)

Issue:
Kiota should sanitize file names and namespace when generating plugins
Today when generating plugins Kiota uses the plugin name as the namespace property in the plugin manifest and to name the generated plugin and sliced OpenAPI descriptions files.
The blank space in the namespace property is making Copilot to fail on finding functions to call the plugin.

Example:

```powershell
kiota plugin add --openapi ./openapi/openapi.yml --plugin-name "Budget Tracker" --type APIPlugin --output ./plugin
```

When using the cmd above, here is what I get:

```
budget tracker-apiplugin.json
budget tracker-openapi.json
```

Also the `budget tracker-apiplugin.json` file has the following info:

```jsonc
"namespace": "Budget Tracker"
```

Expected:
Files names and namespace should be sanitized to be [A-Z][a-z][0-9], no spaces, no special characters.

Pull request description:
Fixes https://github.com/microsoft/kiota/issues/5019

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 30df685762..7fdb78e754 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Fixed a bug where the copilot teams toolkit integration would serialize empty declarative copilots. [#4974](https://github.com/microsoft/kiota/issues/4974)
 - Fixed a bug for the docker image where the volume path would not match the expected configuration for the description.
 - Fixed a bug in Go where certain namespaces were escaped unexpectedly. [#5012](https://github.com/microsoft/kiota/issues/5012)
+- Fixed file name and namespace sanitization when generating plugins. [#5019](https://github.com/microsoft/kiota/issues/5019)
 
 ## [1.16.0] - 2024-07-05
 
diff --git a/src/Kiota.Builder/Plugins/PluginsGenerationService.cs b/src/Kiota.Builder/Plugins/PluginsGenerationService.cs
index 0bb6641c57..48f4132beb 100644
--- a/src/Kiota.Builder/Plugins/PluginsGenerationService.cs
+++ b/src/Kiota.Builder/Plugins/PluginsGenerationService.cs
@@ -4,6 +4,7 @@
 using System.Linq;
 using System.Text.Json;
 using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
 using System.Threading;
 using System.Threading.Tasks;
 using Kiota.Builder.Configuration;
@@ -18,7 +19,7 @@
 using Microsoft.Plugins.Manifest;
 
 namespace Kiota.Builder.Plugins;
-public class PluginsGenerationService
+public partial class PluginsGenerationService
 {
     private readonly OpenApiDocument OAIDocument;
     private readonly OpenApiUrlTreeNode TreeNode;
@@ -43,7 +44,9 @@ public PluginsGenerationService(OpenApiDocument document, OpenApiUrlTreeNode ope
     private const string AppManifestFileName = "manifest.json";
     public async Task GenerateManifestAsync(CancellationToken cancellationToken = default)
     {
-        // 1. write the OpenApi description
+        // 1. cleanup any namings to be used later on.
+        Configuration.ClientClassName = PluginNameCleanupRegex().Replace(Configuration.ClientClassName, string.Empty); //drop any special characters
+        // 2. write the OpenApi description
         var descriptionRelativePath = $"{Configuration.ClientClassName.ToLowerInvariant()}-{DescriptionPathSuffix}";
         var descriptionFullPath = Path.Combine(Configuration.OutputPath, descriptionRelativePath);
         var directory = Path.GetDirectoryName(descriptionFullPath);
@@ -58,7 +61,7 @@ public async Task GenerateManifestAsync(CancellationToken cancellationToken = de
         trimmedPluginDocument.SerializeAsV3(descriptionWriter);
         descriptionWriter.Flush();
 
-        // 2. write the plugins
+        // 3. write the plugins
 
         foreach (var pluginType in Configuration.PluginTypes)
         {
@@ -98,7 +101,7 @@ public async Task GenerateManifestAsync(CancellationToken cancellationToken = de
             await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
         }
 
-        // 3. write the app manifest if its an Api Plugin
+        // 4. write the app manifest if its an Api Plugin
         if (Configuration.PluginTypes.Any(static plugin => plugin == PluginType.APIPlugin))
         {
             var manifestFullPath = Path.Combine(Configuration.OutputPath, AppManifestFileName);
@@ -111,6 +114,9 @@ public async Task GenerateManifestAsync(CancellationToken cancellationToken = de
         }
     }
 
+    [GeneratedRegex(@"[^a-zA-Z0-9_]+", RegexOptions.IgnoreCase | RegexOptions.Singleline, 2000)]
+    private static partial Regex PluginNameCleanupRegex();
+
     private async Task<AppManifestModel> GetAppManifestModelAsync(string pluginFileName, string manifestFullPath, CancellationToken cancellationToken)
     {
         var manifestInfo = ExtractInfoFromDocument(OAIDocument.Info);


Test patch:
diff --git a/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs b/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs
index c04055de9e..bb39cb9396 100644
--- a/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs
+++ b/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs
@@ -33,8 +33,11 @@ public void Dispose()
         _httpClient.Dispose();
     }
 
-    [Fact]
-    public async Task GeneratesManifest()
+    [Theory]
+    [InlineData("client", "client")]
+    [InlineData("Budget Tracker", "BudgetTracker")]//drop the space
+    [InlineData("My-Super complex() %@#$& Name", "MySupercomplexName")]//drop the space and special characters
+    public async Task GeneratesManifest(string inputPluginName, string expectedPluginName)
     {
         var simpleDescriptionContent = @"openapi: 3.0.0
 info:
@@ -76,7 +79,7 @@ public async Task GeneratesManifest()
             OutputPath = outputDirectory,
             OpenAPIFilePath = "openapiPath",
             PluginTypes = [PluginType.APIPlugin, PluginType.APIManifest, PluginType.OpenAI],
-            ClientClassName = "client",
+            ClientClassName = inputPluginName,
             ApiRootUrl = "http://localhost/", //Kiota builder would set this for us
         };
         var (openAPIDocumentStream, _) = await openAPIDocumentDS.LoadStreamAsync(simpleDescriptionPath, generationConfiguration, null, false);
@@ -87,19 +90,20 @@ public async Task GeneratesManifest()
         var pluginsGenerationService = new PluginsGenerationService(openApiDocument, urlTreeNode, generationConfiguration, workingDirectory);
         await pluginsGenerationService.GenerateManifestAsync();
 
-        Assert.True(File.Exists(Path.Combine(outputDirectory, ManifestFileName)));
-        Assert.True(File.Exists(Path.Combine(outputDirectory, "client-apimanifest.json")));
+        Assert.True(File.Exists(Path.Combine(outputDirectory, $"{expectedPluginName.ToLower()}-apiplugin.json")));
+        Assert.True(File.Exists(Path.Combine(outputDirectory, $"{expectedPluginName.ToLower()}-apimanifest.json")));
         Assert.True(File.Exists(Path.Combine(outputDirectory, OpenAIPluginFileName)));
-        Assert.True(File.Exists(Path.Combine(outputDirectory, OpenApiFileName)));
+        Assert.True(File.Exists(Path.Combine(outputDirectory, $"{expectedPluginName.ToLower()}-openapi.yml")));
         Assert.True(File.Exists(Path.Combine(outputDirectory, AppManifestFileName)));
 
         // Validate the v2 plugin
-        var manifestContent = await File.ReadAllTextAsync(Path.Combine(outputDirectory, ManifestFileName));
+        var manifestContent = await File.ReadAllTextAsync(Path.Combine(outputDirectory, $"{expectedPluginName.ToLower()}-apiplugin.json"));
         using var jsonDocument = JsonDocument.Parse(manifestContent);
         var resultingManifest = PluginManifestDocument.Load(jsonDocument.RootElement);
         Assert.NotNull(resultingManifest.Document);
-        Assert.Equal(OpenApiFileName, resultingManifest.Document.Runtimes.OfType<OpenApiRuntime>().First().Spec.Url);
+        Assert.Equal($"{expectedPluginName.ToLower()}-openapi.yml", resultingManifest.Document.Runtimes.OfType<OpenApiRuntime>().First().Spec.Url);
         Assert.Equal(2, resultingManifest.Document.Functions.Count);// all functions are generated despite missing operationIds
+        Assert.Equal(expectedPluginName, resultingManifest.Document.Namespace);// namespace is cleaned up.
         Assert.Empty(resultingManifest.Problems);// no problems are expected with names
 
         // Validate the v1 plugin
@@ -107,20 +111,20 @@ public async Task GeneratesManifest()
         using var v1JsonDocument = JsonDocument.Parse(v1ManifestContent);
         var v1Manifest = PluginManifestDocument.Load(v1JsonDocument.RootElement);
         Assert.NotNull(resultingManifest.Document);
-        Assert.Equal(OpenApiFileName, v1Manifest.Document.Api.URL);
+        Assert.Equal($"{expectedPluginName.ToLower()}-openapi.yml", v1Manifest.Document.Api.URL);
         Assert.Empty(v1Manifest.Problems);
 
         // Validate the manifest file
         var appManifestFile = await File.ReadAllTextAsync(Path.Combine(outputDirectory, AppManifestFileName));
         var appManifestModelObject = JsonSerializer.Deserialize(appManifestFile, PluginsGenerationService.AppManifestModelGenerationContext.AppManifestModel);
-        Assert.Equal("com.microsoft.kiota.plugin.client", appManifestModelObject.PackageName);
-        Assert.Equal("client", appManifestModelObject.Name.ShortName);
+        Assert.Equal($"com.microsoft.kiota.plugin.{expectedPluginName}", appManifestModelObject.PackageName);
+        Assert.Equal(expectedPluginName, appManifestModelObject.Name.ShortName);
         Assert.Equal("Microsoft Kiota.", appManifestModelObject.Developer.Name);
         Assert.Equal("color.png", appManifestModelObject.Icons.Color);
         Assert.NotNull(appManifestModelObject.CopilotExtensions.Plugins);
         Assert.Single(appManifestModelObject.CopilotExtensions.Plugins);
-        Assert.Equal("client", appManifestModelObject.CopilotExtensions.Plugins[0].Id);
-        Assert.Equal(ManifestFileName, appManifestModelObject.CopilotExtensions.Plugins[0].File);
+        Assert.Equal(expectedPluginName, appManifestModelObject.CopilotExtensions.Plugins[0].Id);
+        Assert.Equal($"{expectedPluginName.ToLower()}-apiplugin.json", appManifestModelObject.CopilotExtensions.Plugins[0].File);
     }
     private const string ManifestFileName = "client-apiplugin.json";
     private const string OpenAIPluginFileName = "openai-plugins.json";