using Sdt.Config;
using Xunit;
namespace DevTool.Tests;
public sealed class ConfigBootstrapperTests
{
[Fact]
public void Scan_DetectsDotnetAndNode()
{
var root = CreateTempDir();
File.WriteAllText(Path.Combine(root, "sample.sln"), "");
File.WriteAllText(Path.Combine(root, "package.json"), """{ "name": "demo", "scripts": { "build": "echo build" } }""");
var scan = ConfigBootstrapper.Scan(root);
Assert.Contains("dotnet", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
Assert.Contains("node", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
Assert.Contains("npm", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public void Scan_DetectsPythonFromScriptsDirectory()
{
var root = CreateTempDir();
var scripts = Path.Combine(root, "scripts");
Directory.CreateDirectory(scripts);
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('ok')");
var scan = ConfigBootstrapper.Scan(root);
Assert.Contains("python", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public void Scan_DoesNotDetectNode_ForDependencyOnlyPackageJson()
{
var root = CreateTempDir();
File.WriteAllText(Path.Combine(root, "package.json"), """{ "dependencies": { "leftpad": "1.0.0" } }""");
var scan = ConfigBootstrapper.Scan(root);
Assert.DoesNotContain("node", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
Assert.DoesNotContain("npm", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public void BuildDefaultConfig_ProducesWorkflowsAndDebugSection()
{
var root = CreateTempDir();
var scan = new BootstrapScanResult(
ProjectRoot: root,
ProjectName: "demo",
ProjectType: "dotnet",
ToolFamilies: ["dotnet", "git"],
DotnetWorkingDir: ".",
NodeWorkingDir: null,
PythonRequirementsFile: null,
HasDockerCompose: false,
RootHints: ["*.sln"]);
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
Assert.NotNull(cfg.Debug);
Assert.Contains(cfg.Workflows, w => w.Id == "build");
Assert.Contains(cfg.Workflows, w => w.Id == "repo-health");
Assert.False(cfg.Debug!.Diagnostics.IncludeAllEnv);
Assert.Contains("SDT_LOG_LEVEL", cfg.Debug.Diagnostics.CaptureEnvKeys);
Assert.NotNull(cfg.EnvProfiles);
Assert.Contains(cfg.EnvProfiles!.Profiles, p => p.Id == "dev");
Assert.Contains(cfg.EnvProfiles.Profiles, p => p.Id == "ci");
Assert.Contains(cfg.EnvProfiles.Profiles, p => p.Id == "release");
}
[Fact]
public void BuildDefaultConfig_IncludesScriptDrivenWorkflow_WhenHelpersExist()
{
var root = CreateTempDir();
var scripts = Path.Combine(root, "scripts");
Directory.CreateDirectory(scripts);
Directory.CreateDirectory(Path.Combine(root, "ui", "src-tauri"));
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('ok')");
File.WriteAllText(Path.Combine(scripts, "publish-sidecar.py"), "print('ok')");
File.WriteAllText(Path.Combine(root, "SidecarService.csproj"), "");
File.WriteAllText(Path.Combine(root, "ui", "package.json"), """{ "name": "demo-ui", "scripts": { "build": "echo build", "tauri": "echo tauri" } }""");
File.WriteAllText(Path.Combine(root, "ui", "src-tauri", "tauri.conf.json"), "{}");
var scan = ConfigBootstrapper.Scan(root);
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
Assert.Contains(cfg.Workflows, w => w.Id == "web");
Assert.Contains(cfg.Workflows, w => w.Id == "sidecar");
Assert.Contains(cfg.Workflows, w => w.Id == "tauri");
}
[Fact]
public void BuildDefaultConfig_DoesNotReferenceMissingSidecarDependency()
{
var root = CreateTempDir();
var scripts = Path.Combine(root, "scripts");
Directory.CreateDirectory(scripts);
Directory.CreateDirectory(Path.Combine(root, "ui", "src-tauri"));
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('ok')");
File.WriteAllText(Path.Combine(scripts, "publish-sidecar.py"), "print('ok')");
File.WriteAllText(Path.Combine(root, "ui", "package.json"), """{ "name": "demo-ui", "scripts": { "build": "echo build", "tauri": "echo tauri" } }""");
File.WriteAllText(Path.Combine(root, "ui", "src-tauri", "tauri.conf.json"), "{}");
var scan = ConfigBootstrapper.Scan(root);
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
Assert.DoesNotContain(cfg.Workflows, w => w.Id == "sidecar");
var tauri = Assert.Single(cfg.Workflows, w => w.Id == "tauri");
Assert.Empty(tauri.DependsOn);
}
[Fact]
public void BuildDefaultConfig_DoesNotGenerateTauriWorkflow_WithoutTauriConfig()
{
var root = CreateTempDir();
var scripts = Path.Combine(root, "scripts");
Directory.CreateDirectory(scripts);
Directory.CreateDirectory(Path.Combine(root, "ui"));
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('ok')");
File.WriteAllText(Path.Combine(root, "ui", "package.json"), """{ "name": "demo-ui", "scripts": { "build": "echo build" } }""");
var scan = ConfigBootstrapper.Scan(root);
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
Assert.Contains(cfg.Workflows, w => w.Id == "web");
Assert.DoesNotContain(cfg.Workflows, w => w.Id == "tauri");
}
[Fact]
public void BuildDefaultConfig_AddsPrimaryDotnetPublishWorkflow_ForRootExecutableProject()
{
var root = CreateTempDir();
File.WriteAllText(Path.Combine(root, "demo.csproj"), """
Exe
net10.0
sdt
""");
var scan = ConfigBootstrapper.Scan(root);
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
var publish = Assert.Single(cfg.Workflows, w => w.Id == "publish-primary-dotnet");
Assert.Equal("Publish sdt", publish.Label, ignoreCase: true);
Assert.Contains("output/", publish.Description, StringComparison.OrdinalIgnoreCase);
Assert.Equal("publish cli", publish.GuidedName, ignoreCase: true);
Assert.Contains("publish tool", publish.Aliases, StringComparer.OrdinalIgnoreCase);
Assert.Equal("dotnet", Assert.Single(publish.Steps[0].Requires).Tool, ignoreCase: true);
}
[Fact]
public void BuildDefaultConfig_ClarifiesStageOutputScope()
{
var root = CreateTempDir();
var scripts = Path.Combine(root, "scripts");
Directory.CreateDirectory(scripts);
File.WriteAllText(Path.Combine(scripts, "publish-output.py"), "print('ok')");
var scan = ConfigBootstrapper.Scan(root);
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
var stage = Assert.Single(cfg.Workflows, w => w.Id == "stage-output");
Assert.Equal("Stage All Detected Outputs", stage.Label, ignoreCase: true);
Assert.Contains("every detected ship target", stage.Description, StringComparison.OrdinalIgnoreCase);
Assert.Equal("stage all outputs", stage.GuidedName, ignoreCase: true);
}
[Fact]
public void WriteDefaultConfig_WritesDevtoolJson()
{
var root = CreateTempDir();
var scan = new BootstrapScanResult(
ProjectRoot: root,
ProjectName: "demo",
ProjectType: "dotnet",
ToolFamilies: ["dotnet"],
DotnetWorkingDir: ".",
NodeWorkingDir: null,
PythonRequirementsFile: null,
HasDockerCompose: false,
RootHints: ["*.sln"]);
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
var path = ConfigBootstrapper.WriteDefaultConfig(root, cfg);
Assert.True(File.Exists(path));
}
[Fact]
public void Scan_IgnoresExcludedDirectories_ForToolDetection()
{
var root = CreateTempDir();
var nodeModules = Path.Combine(root, "node_modules", "nested");
Directory.CreateDirectory(nodeModules);
File.WriteAllText(Path.Combine(nodeModules, "package.json"), "{}");
var scan = ConfigBootstrapper.Scan(root);
Assert.DoesNotContain("node", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public void Scan_DetectsGoAndSetsProjectType()
{
var root = CreateTempDir();
File.WriteAllText(Path.Combine(root, "go.mod"), "module example.com/demo");
var scan = ConfigBootstrapper.Scan(root);
Assert.Contains("go", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
Assert.Equal("go", scan.ProjectType, ignoreCase: true);
}
[Fact]
public void Scan_DetectsMavenAndGradleAsJavaProject()
{
var root = CreateTempDir();
File.WriteAllText(Path.Combine(root, "pom.xml"), "");
File.WriteAllText(Path.Combine(root, "build.gradle"), "plugins {}");
var scan = ConfigBootstrapper.Scan(root);
Assert.Contains("maven", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
Assert.Contains("gradle", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
Assert.Contains("java", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
Assert.Equal("java", scan.ProjectType, ignoreCase: true);
}
[Fact]
public void Scan_ResolvesDotnetWorkingDir_FromNearestLaunchPath()
{
var workspace = CreateTempDir();
File.WriteAllText(Path.Combine(workspace, "package.json"), """{ "name": "workspace-root" }""");
var appA = Path.Combine(workspace, "AppA");
var appB = Path.Combine(workspace, "AppB");
Directory.CreateDirectory(appA);
Directory.CreateDirectory(appB);
File.WriteAllText(Path.Combine(appA, "AppA.csproj"), "");
File.WriteAllText(Path.Combine(appB, "AppB.csproj"), "");
var scan = ConfigBootstrapper.Scan(appB);
Assert.Equal("AppB", scan.DotnetWorkingDir, ignoreCase: true);
}
[Fact]
public void Scan_PrefersTauriPackageRoot_ForNodeWorkingDir()
{
var root = CreateTempDir();
var workspacePkg = Path.Combine(root, "journal");
var appPkg = Path.Combine(workspacePkg, "Journal.App");
Directory.CreateDirectory(appPkg);
Directory.CreateDirectory(Path.Combine(appPkg, "src-tauri"));
File.WriteAllText(Path.Combine(workspacePkg, "package.json"), """{ "name": "workspace", "scripts": { "build": "echo build" } }""");
File.WriteAllText(Path.Combine(appPkg, "package.json"), """{ "name": "journal-app", "scripts": { "build": "echo build", "tauri": "echo tauri" } }""");
File.WriteAllText(Path.Combine(appPkg, "src-tauri", "tauri.conf.json"), "{}");
var scan = ConfigBootstrapper.Scan(root);
Assert.Equal(Path.Combine("journal", "Journal.App"), scan.NodeWorkingDir, ignoreCase: true);
}
private static string CreateTempDir()
{
var path = Path.Combine(Path.GetTempPath(), "sdt-bootstrap-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
}