Skip to content

Commit c69fab5

Browse files
csharpfritzCopilot
andcommitted
feat(cli): add scaffolding, config transforms, and full pipeline wiring
Add ProjectScaffolder, GlobalUsingsGenerator, ShimGenerator for project scaffold generation (.csproj, Program.cs, _Imports.razor, App.razor, Routes.razor, launchSettings.json, GlobalUsings.cs, shims). Add WebConfigTransformer to parse web.config and generate appsettings.json with appSettings key/values, connectionStrings, and standard Blazor sections. Add DatabaseProviderDetector with 3-pass provider detection: explicit providerName, connection string pattern matching, EntityClient inner provider. Add OutputWriter with dry-run support, UTF-8 no BOM, directory creation, and file tracking for reports. Enhance MigrationReport with JSON serialization, console summary output, and report file writing for --report flag. Wire full pipeline in MigrationPipeline.ExecuteAsync: 1. Scaffold project (if not --skip-scaffold) 2. Transform config (web.config -> appsettings.json) 3. For each source file: markup + code-behind transforms -> write output 4. Generate report Update Program.cs DI to register all new services. Add backward-compatible 2-param constructor on MigrationPipeline for existing tests. All ported from bwfc-migrate.ps1: New-ProjectScaffold, New-AppRazorScaffold, Convert-WebConfigToAppSettings, Find-DatabaseProvider. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent eb42af4 commit c69fab5

9 files changed

Lines changed: 1065 additions & 65 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
using System.Xml.Linq;
2+
3+
namespace BlazorWebFormsComponents.Cli.Config;
4+
5+
/// <summary>
6+
/// Detects EF database provider from Web.config connectionStrings.
7+
/// Ported from Find-DatabaseProvider in bwfc-migrate.ps1.
8+
/// </summary>
9+
public class DatabaseProviderDetector
10+
{
11+
private static readonly Dictionary<string, (string PackageName, string ProviderMethod)> ProviderMap = new(StringComparer.OrdinalIgnoreCase)
12+
{
13+
["System.Data.SqlClient"] = ("Microsoft.EntityFrameworkCore.SqlServer", "UseSqlServer"),
14+
["System.Data.SQLite"] = ("Microsoft.EntityFrameworkCore.Sqlite", "UseSqlite"),
15+
["Npgsql"] = ("Npgsql.EntityFrameworkCore.PostgreSQL", "UseNpgsql"),
16+
["MySql.Data.MySqlClient"] = ("Pomelo.EntityFrameworkCore.MySql", "UseMySql")
17+
};
18+
19+
public DatabaseProviderInfo Detect(string sourcePath)
20+
{
21+
var defaultResult = new DatabaseProviderInfo
22+
{
23+
PackageName = "Microsoft.EntityFrameworkCore.SqlServer",
24+
ProviderMethod = "UseSqlServer",
25+
DetectedFrom = "Default — no Web.config connectionStrings found",
26+
ConnectionString = ""
27+
};
28+
29+
if (string.IsNullOrEmpty(sourcePath))
30+
return defaultResult;
31+
32+
// Look for Web.config in source path and parent directory
33+
var candidates = new[]
34+
{
35+
Path.Combine(sourcePath, "Web.config"),
36+
Path.Combine(Path.GetDirectoryName(sourcePath) ?? sourcePath, "Web.config")
37+
};
38+
39+
string? webConfigPath = null;
40+
foreach (var candidate in candidates)
41+
{
42+
if (File.Exists(candidate))
43+
{
44+
webConfigPath = candidate;
45+
break;
46+
}
47+
}
48+
49+
if (webConfigPath == null)
50+
return defaultResult;
51+
52+
XDocument doc;
53+
try
54+
{
55+
doc = XDocument.Load(webConfigPath);
56+
}
57+
catch
58+
{
59+
return defaultResult;
60+
}
61+
62+
var connStringsElement = doc.Root?.Element("connectionStrings");
63+
if (connStringsElement == null)
64+
return defaultResult;
65+
66+
var adds = connStringsElement.Elements("add").ToList();
67+
if (adds.Count == 0)
68+
return defaultResult;
69+
70+
// Pass 1: Non-EntityClient entries with explicit providerName
71+
foreach (var entry in adds)
72+
{
73+
var providerName = entry.Attribute("providerName")?.Value;
74+
if (string.IsNullOrEmpty(providerName) || providerName == "System.Data.EntityClient")
75+
continue;
76+
77+
if (ProviderMap.TryGetValue(providerName, out var mapped))
78+
{
79+
return new DatabaseProviderInfo
80+
{
81+
PackageName = mapped.PackageName,
82+
ProviderMethod = mapped.ProviderMethod,
83+
DetectedFrom = $"Web.config providerName={providerName}",
84+
ConnectionString = entry.Attribute("connectionString")?.Value ?? ""
85+
};
86+
}
87+
}
88+
89+
// Pass 2: Entries without providerName — detect from connection string content
90+
foreach (var entry in adds)
91+
{
92+
var connString = entry.Attribute("connectionString")?.Value;
93+
if (string.IsNullOrEmpty(connString) || connString.StartsWith("metadata=", StringComparison.OrdinalIgnoreCase))
94+
continue;
95+
if (entry.Attribute("providerName") != null)
96+
continue;
97+
98+
if (System.Text.RegularExpressions.Regex.IsMatch(connString, @"(?i)\(LocalDB\)|Server="))
99+
{
100+
return new DatabaseProviderInfo
101+
{
102+
PackageName = "Microsoft.EntityFrameworkCore.SqlServer",
103+
ProviderMethod = "UseSqlServer",
104+
DetectedFrom = "Web.config connection string pattern (SQL Server)",
105+
ConnectionString = connString
106+
};
107+
}
108+
109+
if (System.Text.RegularExpressions.Regex.IsMatch(connString, @"(?i)Data Source=.*\.db"))
110+
{
111+
return new DatabaseProviderInfo
112+
{
113+
PackageName = "Microsoft.EntityFrameworkCore.Sqlite",
114+
ProviderMethod = "UseSqlite",
115+
DetectedFrom = "Web.config connection string pattern (SQLite)",
116+
ConnectionString = connString
117+
};
118+
}
119+
}
120+
121+
// Pass 3: EntityClient entries — extract inner provider (EF6 pattern)
122+
foreach (var entry in adds)
123+
{
124+
if (entry.Attribute("providerName")?.Value != "System.Data.EntityClient")
125+
continue;
126+
127+
var connString = entry.Attribute("connectionString")?.Value ?? "";
128+
var match = System.Text.RegularExpressions.Regex.Match(connString, @"provider=([^;""]+)");
129+
if (match.Success)
130+
{
131+
var innerProvider = match.Groups[1].Value.Trim();
132+
if (ProviderMap.TryGetValue(innerProvider, out var mapped))
133+
{
134+
return new DatabaseProviderInfo
135+
{
136+
PackageName = mapped.PackageName,
137+
ProviderMethod = mapped.ProviderMethod,
138+
DetectedFrom = $"Web.config EntityClient provider={innerProvider}",
139+
ConnectionString = ""
140+
};
141+
}
142+
}
143+
}
144+
145+
return defaultResult;
146+
}
147+
}
148+
149+
public class DatabaseProviderInfo
150+
{
151+
public required string PackageName { get; init; }
152+
public required string ProviderMethod { get; init; }
153+
public required string DetectedFrom { get; init; }
154+
public required string ConnectionString { get; init; }
155+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
using System.Text.Json;
2+
using System.Xml.Linq;
3+
using BlazorWebFormsComponents.Cli.Io;
4+
5+
namespace BlazorWebFormsComponents.Cli.Config;
6+
7+
/// <summary>
8+
/// Parses Web.config and generates appsettings.json.
9+
/// Ported from Convert-WebConfigToAppSettings in bwfc-migrate.ps1 (line ~892).
10+
/// </summary>
11+
public class WebConfigTransformer
12+
{
13+
private static readonly HashSet<string> BuiltInConnectionNames = new(StringComparer.OrdinalIgnoreCase)
14+
{
15+
"LocalSqlServer",
16+
"LocalMySqlServer"
17+
};
18+
19+
/// <summary>
20+
/// Transforms Web.config appSettings and connectionStrings into appsettings.json content.
21+
/// Returns null if no Web.config found or no settings to extract.
22+
/// </summary>
23+
public WebConfigResult? Transform(string sourcePath)
24+
{
25+
// Find Web.config (case-insensitive search)
26+
var webConfigPath = FindWebConfig(sourcePath);
27+
if (webConfigPath == null)
28+
return null;
29+
30+
XDocument doc;
31+
try
32+
{
33+
doc = XDocument.Load(webConfigPath);
34+
}
35+
catch (Exception ex)
36+
{
37+
return new WebConfigResult
38+
{
39+
JsonContent = null,
40+
AppSettingsCount = 0,
41+
ConnectionStringsCount = 0,
42+
Error = $"Could not parse Web.config: {ex.Message}"
43+
};
44+
}
45+
46+
var appSettings = new Dictionary<string, string>();
47+
var connectionStrings = new Dictionary<string, string>();
48+
49+
// Parse <appSettings>
50+
var appSettingsNodes = doc.Descendants("appSettings").Elements("add");
51+
foreach (var node in appSettingsNodes)
52+
{
53+
var key = node.Attribute("key")?.Value;
54+
var value = node.Attribute("value")?.Value ?? "";
55+
if (!string.IsNullOrEmpty(key))
56+
{
57+
appSettings[key] = value;
58+
}
59+
}
60+
61+
// Parse <connectionStrings>
62+
var connStrNodes = doc.Descendants("connectionStrings").Elements("add");
63+
foreach (var node in connStrNodes)
64+
{
65+
var name = node.Attribute("name")?.Value;
66+
var connStr = node.Attribute("connectionString")?.Value;
67+
if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(connStr))
68+
{
69+
if (!BuiltInConnectionNames.Contains(name))
70+
{
71+
connectionStrings[name] = connStr;
72+
}
73+
}
74+
}
75+
76+
if (appSettings.Count == 0 && connectionStrings.Count == 0)
77+
return null;
78+
79+
// Build JSON structure
80+
var jsonObj = new Dictionary<string, object>();
81+
82+
if (connectionStrings.Count > 0)
83+
{
84+
jsonObj["ConnectionStrings"] = connectionStrings;
85+
}
86+
87+
foreach (var entry in appSettings)
88+
{
89+
jsonObj[entry.Key] = entry.Value;
90+
}
91+
92+
// Add standard Blazor sections
93+
if (!jsonObj.ContainsKey("Logging"))
94+
{
95+
jsonObj["Logging"] = new Dictionary<string, object>
96+
{
97+
["LogLevel"] = new Dictionary<string, string>
98+
{
99+
["Default"] = "Information",
100+
["Microsoft.AspNetCore"] = "Warning"
101+
}
102+
};
103+
}
104+
105+
if (!jsonObj.ContainsKey("AllowedHosts"))
106+
{
107+
jsonObj["AllowedHosts"] = "*";
108+
}
109+
110+
var options = new JsonSerializerOptions
111+
{
112+
WriteIndented = true
113+
};
114+
var jsonContent = JsonSerializer.Serialize(jsonObj, options);
115+
116+
return new WebConfigResult
117+
{
118+
JsonContent = jsonContent,
119+
AppSettingsCount = appSettings.Count,
120+
ConnectionStringsCount = connectionStrings.Count,
121+
AppSettingsKeys = [.. appSettings.Keys],
122+
ConnectionStringNames = [.. connectionStrings.Keys]
123+
};
124+
}
125+
126+
private static string? FindWebConfig(string sourcePath)
127+
{
128+
var path1 = Path.Combine(sourcePath, "Web.config");
129+
if (File.Exists(path1)) return path1;
130+
131+
var path2 = Path.Combine(sourcePath, "web.config");
132+
if (File.Exists(path2)) return path2;
133+
134+
return null;
135+
}
136+
}
137+
138+
public class WebConfigResult
139+
{
140+
public string? JsonContent { get; init; }
141+
public int AppSettingsCount { get; init; }
142+
public int ConnectionStringsCount { get; init; }
143+
public List<string> AppSettingsKeys { get; init; } = [];
144+
public List<string> ConnectionStringNames { get; init; } = [];
145+
public string? Error { get; init; }
146+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System.Text;
2+
3+
namespace BlazorWebFormsComponents.Cli.Io;
4+
5+
/// <summary>
6+
/// Centralized output writer that respects --dry-run and tracks all written files.
7+
/// UTF-8 encoding without BOM, creates directories as needed.
8+
/// </summary>
9+
public class OutputWriter
10+
{
11+
private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
12+
private readonly List<string> _writtenFiles = [];
13+
14+
public bool DryRun { get; set; }
15+
public bool Verbose { get; set; }
16+
17+
public IReadOnlyList<string> WrittenFiles => _writtenFiles;
18+
19+
/// <summary>
20+
/// Write content to a file, respecting dry-run mode.
21+
/// </summary>
22+
public async Task WriteFileAsync(string path, string content, string description)
23+
{
24+
if (DryRun)
25+
{
26+
Console.WriteLine($" [dry-run] Would write: {path}");
27+
if (Verbose)
28+
Console.WriteLine($" ({description})");
29+
return;
30+
}
31+
32+
var directory = Path.GetDirectoryName(path);
33+
if (!string.IsNullOrEmpty(directory))
34+
Directory.CreateDirectory(directory);
35+
36+
await File.WriteAllTextAsync(path, content, Utf8NoBom);
37+
_writtenFiles.Add(path);
38+
39+
if (Verbose)
40+
Console.WriteLine($" ✓ {path} ({description})");
41+
}
42+
43+
/// <summary>
44+
/// Copy a file to the output, respecting dry-run mode.
45+
/// </summary>
46+
public void CopyFile(string source, string destination, string description)
47+
{
48+
if (DryRun)
49+
{
50+
Console.WriteLine($" [dry-run] Would copy: {source}{destination}");
51+
return;
52+
}
53+
54+
var directory = Path.GetDirectoryName(destination);
55+
if (!string.IsNullOrEmpty(directory))
56+
Directory.CreateDirectory(directory);
57+
58+
File.Copy(source, destination, overwrite: true);
59+
_writtenFiles.Add(destination);
60+
61+
if (Verbose)
62+
Console.WriteLine($" ✓ {destination} ({description})");
63+
}
64+
65+
public void Reset()
66+
{
67+
_writtenFiles.Clear();
68+
}
69+
}

0 commit comments

Comments
 (0)