Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public void GetCommand_ShouldReturnCommand()
var npxHelper = new Mock<INpxHelper>().Object;
var logger = new Mock<ILogger<TypeSpecInitTool>>().Object;
var outputService = new Mock<IOutputHelper>().Object;
var tool = new TypeSpecInitTool(npxHelper, logger, outputService);
var tool = new TypeSpecInitTool(npxHelper, CreateGitHelper(), logger, outputService);

// Act
var command = tool.GetCommand();
Expand All @@ -34,7 +34,7 @@ public async Task Init_WithInvalidTemplate_ShouldReturnError()
var npxHelper = new Mock<INpxHelper>().Object;
var logger = new Mock<ILogger<TypeSpecInitTool>>().Object;
var outputService = new Mock<IOutputHelper>().Object;
var tool = new TypeSpecInitTool(npxHelper, logger, outputService);
var tool = new TypeSpecInitTool(npxHelper, CreateGitHelper(), logger, outputService);

var result = await tool.InitTypeSpecProjectAsync(outputDirectory: "never-used", template: "invalid-template", serviceNamespace: "MyService", isCli: false);

Expand All @@ -52,7 +52,7 @@ public async Task Init_WithInvalidServiceNamespace_ShouldReturnError()
var npxHelper = new Mock<INpxHelper>().Object;
var logger = new Mock<ILogger<TypeSpecInitTool>>().Object;
var outputService = new Mock<IOutputHelper>().Object;
var tool = new TypeSpecInitTool(npxHelper, logger, outputService);
var tool = new TypeSpecInitTool(npxHelper, CreateGitHelper(), logger, outputService);

var result = await tool.InitTypeSpecProjectAsync(outputDirectory: "never-used", template: "azure-core", serviceNamespace: "", isCli: false);

Expand All @@ -64,20 +64,91 @@ public async Task Init_WithInvalidServiceNamespace_ShouldReturnError()
}

[Test]
public async Task Init_WithNonExistentDirectory_ShouldReturnError()
public async Task Init_WithNonEmptyDirectory_ShouldReturnError()
{
var npxHelper = new Mock<INpxHelper>().Object;
var logger = new Mock<ILogger<TypeSpecInitTool>>().Object;
var outputService = new Mock<IOutputHelper>().Object;
var tool = new TypeSpecInitTool(npxHelper, logger, outputService);
var tool = new TypeSpecInitTool(npxHelper, CreateGitHelper(), logger, outputService);
var tempDir = Path.Combine(Path.GetTempPath(), $"test-nonexistent-{Guid.NewGuid()}");

var result = await tool.InitTypeSpecProjectAsync(outputDirectory: Path.Combine(Path.GetTempPath(), $"test-nonexistent-{Guid.NewGuid()}"), template: "azure-core", serviceNamespace: "MyService", isCli: false);
Directory.CreateDirectory(tempDir);

Assert.Multiple(() =>
try
{
Assert.That(result.IsSuccessful, Is.False);
Assert.That(result.ResponseError, Does.Contain("Invalid --output-directory"));
});
await File.WriteAllTextAsync(Path.Join(tempDir, "somefile.txt"), "some file's contents");

var result = await tool.InitTypeSpecProjectAsync(outputDirectory: tempDir, template: "azure-core", serviceNamespace: "MyService", isCli: false);

Assert.Multiple(() =>
{
Assert.That(result.IsSuccessful, Is.False);
Assert.That(result.ResponseError, Does.Contain("Invalid --output-directory"));
});
}
finally
{
Directory.Delete(tempDir, true);
}
}

[Test]
public async Task Init_IncorrectGitRepo()
{
var npxHelper = new Mock<INpxHelper>().Object;
var logger = new Mock<ILogger<TypeSpecInitTool>>().Object;
var outputService = new Mock<IOutputHelper>().Object;
var tool = new TypeSpecInitTool(npxHelper, CreateGitHelper("azure-sdk-for-php"), logger, outputService);
var tempDir = Path.Combine(Path.GetTempPath(), $"test-nonexistent-{Guid.NewGuid()}");

try
{
var result = await tool.InitTypeSpecProjectAsync(outputDirectory: tempDir, template: "azure-core", serviceNamespace: "MyService", isCli: false);

Assert.Multiple(() =>
{
Assert.That(result.IsSuccessful, Is.False);
Assert.That(result.ResponseError, Does.Contain("Invalid --output-directory"));
Assert.That(result.ResponseError, Does.Contain("must be within the azure-rest-api-specs repo"));
});
}
finally
{
Directory.Delete(tempDir, true);
}
}

[Test]
public async Task Init_NotUnderSpecifications()
{
var npxHelper = new Mock<INpxHelper>().Object;
var logger = new Mock<ILogger<TypeSpecInitTool>>().Object;
var outputService = new Mock<IOutputHelper>().Object;
var tool = new TypeSpecInitTool(npxHelper, CreateGitHelper("azure-rest-api-specs"), logger, outputService);
var tempDir = Path.Combine(Path.GetTempPath(), $"test-nonexistent-{Guid.NewGuid()}");

try
{
var result = await tool.InitTypeSpecProjectAsync(outputDirectory: tempDir, template: "azure-core", serviceNamespace: "MyService", isCli: false);

Assert.Multiple(() =>
{
Assert.That(result.IsSuccessful, Is.False);
Assert.That(result.ResponseError, Does.Contain("Invalid --output-directory"));
Assert.That(result.ResponseError, Does.Contain($"must be under <azure-rest-api-specs>{Path.DirectorySeparatorChar}specification"));
});
}
finally
{
Directory.Delete(tempDir, true);
}
}

private static IGitHelper CreateGitHelper(string repoName = "azure-rest-api-specs")
{
var mock = new Mock<IGitHelper>();
mock.Setup(m => m.GetRepoName(It.IsAny<string>())).Returns(() => repoName);
return mock.Object;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ public override string ToString()
}
else
{
return $"### TypeSpec Project Path: {TypeSpecProjectPath}";
return string.Join(
Environment.NewLine,
[
$"### TypeSpec Project Path: {TypeSpecProjectPath}",
string.Empty,
..this.NextSteps ?? Enumerable.Empty<string>()
]
);
}
}
}
Expand Down
74 changes: 57 additions & 17 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/TypeSpec/TspInitTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using System.CommandLine;
using System.CommandLine.Invocation;
using System.ComponentModel;
using Azure.Sdk.Tools.Cli.Services;
using Azure.Sdk.Tools.Cli.Contract;
using Azure.Sdk.Tools.Cli.Models.Responses;
using ModelContextProtocol.Server;
Expand All @@ -20,12 +19,14 @@ namespace Azure.Sdk.Tools.Cli.Tools.TypeSpec
public class TypeSpecInitTool : MCPTool
{
private readonly INpxHelper npxHelper;
private readonly IGitHelper gitHelper;
private readonly ILogger<TypeSpecInitTool> logger;
private readonly IOutputHelper output;

public TypeSpecInitTool(INpxHelper npxHelper, ILogger<TypeSpecInitTool> logger, IOutputHelper output)
public TypeSpecInitTool(INpxHelper npxHelper, IGitHelper gitHelper, ILogger<TypeSpecInitTool> logger, IOutputHelper output)
{
this.npxHelper = npxHelper;
this.gitHelper = gitHelper;
this.logger = logger;
this.output = output;
CommandHierarchy = [SharedCommandGroups.TypeSpec];
Expand Down Expand Up @@ -76,11 +77,6 @@ public override Command GetCommand()
}

public override async Task HandleCommand(InvocationContext ctx, CancellationToken ct)
{
await HandleInitCommandAsync(ctx, ct);
}

private async Task HandleInitCommandAsync(InvocationContext ctx, CancellationToken ct)
{
try
{
Expand Down Expand Up @@ -146,18 +142,13 @@ public async Task<TspToolResponse> InitTypeSpecProjectAsync(
};
}

// Validate outputDirectory using FileHelper
var validationResult = FileHelper.ValidateEmptyDirectory(outputDirectory);
if (validationResult != null)
var fullOutputDir = Path.GetFullPath(outputDirectory.Trim());

if (CheckAndCreateDirectory(fullOutputDir) is TspToolResponse resp)
{
SetFailure();
return new TspToolResponse
{
ResponseError = $"Failed: Invalid --output-directory, {validationResult}"
};
return resp;
}

var fullOutputDir = Path.GetFullPath(outputDirectory.Trim());
return await RunTspInitAsync(outputDirectory: fullOutputDir, template: template, serviceNamespace: normalizedServiceName, isCli, ct);
}
catch (Exception ex)
Expand All @@ -171,6 +162,49 @@ public async Task<TspToolResponse> InitTypeSpecProjectAsync(
}
}

/// <summary>
/// Checks the output directory to ensure it's under the azure-rest-api-specs repo, and creates it if necessary.
/// Fails if the output directory is non-empty.
/// </summary>
/// <param name="fullOutputDirectory">A full path to the output directory, as returned by <see cref="Path.GetFullPath"/></param>
/// <returns>For invalid directories, or failures, an appropriate TspToolResponse, otherwise null</returns>
private TspToolResponse CheckAndCreateDirectory(string fullOutputDirectory)
{
if (!Directory.Exists(fullOutputDirectory))
{
Directory.CreateDirectory(fullOutputDirectory);
}

if (FileHelper.ValidateEmptyDirectory(fullOutputDirectory) is string validationResult)
{
SetFailure();
return new TspToolResponse
{
ResponseError = $"Failed: Invalid --output-directory, {validationResult}"
};
}

if (gitHelper.GetRepoName(fullOutputDirectory) is string repoName && repoName != "azure-rest-api-specs")
Comment thread
richardpark-msft marked this conversation as resolved.
Outdated
{
SetFailure();
return new TspToolResponse
{
ResponseError = $"Failed: Invalid --output-directory, must be within the azure-rest-api-specs repo"
};
}

if (!fullOutputDirectory.Contains(Path.DirectorySeparatorChar + "specification" + Path.DirectorySeparatorChar))
{
SetFailure();
return new TspToolResponse
{
ResponseError = $"Failed: Invalid --output-directory, must be under <azure-rest-api-specs>{Path.DirectorySeparatorChar}specification"
};
}

return null;
}

private async Task<TspToolResponse> RunTspInitAsync(string outputDirectory, string template, string serviceNamespace, bool isCli, CancellationToken ct)
{
var npxOptions = new NpxOptions(
Expand Down Expand Up @@ -207,7 +241,13 @@ private async Task<TspToolResponse> RunTspInitAsync(string outputDirectory, stri
return new TspToolResponse
{
IsSuccessful = true,
TypeSpecProjectPath = outputDirectory
TypeSpecProjectPath = outputDirectory,
NextSteps = [
$"Your project has been generated at {outputDirectory}. From here you can build and edit the project using these commands:",
$" 1. cd {outputDirectory}",
" 2. Install dependencies: npx tsp install",
Comment thread
richardpark-msft marked this conversation as resolved.
Outdated
" 3. Compile the TypeSpec for your service: npx tsp compile ."
]
};
}
}
Expand Down
Loading