diff --git a/cli/azd/pkg/environment/azdcontext/azdcontext.go b/cli/azd/pkg/environment/azdcontext/azdcontext.go index 1b8f46a8e97..ebff5bf0e37 100644 --- a/cli/azd/pkg/environment/azdcontext/azdcontext.go +++ b/cli/azd/pkg/environment/azdcontext/azdcontext.go @@ -20,8 +20,12 @@ const DotEnvFileName = ".env" const ConfigFileName = "config.json" const ConfigFileVersion = 1 +// ProjectFileNames lists all valid project file names, in order of preference +var ProjectFileNames = []string{"azure.yaml", "azure.yml"} + type AzdContext struct { projectDirectory string + projectFilePath string } func (c *AzdContext) ProjectDirectory() string { @@ -32,7 +36,13 @@ func (c *AzdContext) SetProjectDirectory(dir string) { c.projectDirectory = dir } +// ProjectPath returns the path to the project file. If the context was created by searching +// for a project file, returns the actual file that was found. Otherwise, returns the default +// project file name joined with the project directory (useful when creating new projects). func (c *AzdContext) ProjectPath() string { + if c.projectFilePath != "" { + return c.projectFilePath + } return filepath.Join(c.ProjectDirectory(), ProjectFileName) } @@ -129,26 +139,41 @@ func NewAzdContextFromWd(wd string) (*AzdContext, error) { return nil, fmt.Errorf("resolving path: %w", err) } + var foundProjectFilePath string for { - projectFilePath := filepath.Join(searchDir, ProjectFileName) - stat, err := os.Stat(projectFilePath) - if os.IsNotExist(err) || (err == nil && stat.IsDir()) { - parent := filepath.Dir(searchDir) - if parent == searchDir { - return nil, ErrNoProject + // Try all valid project file names in order of preference + for _, fileName := range ProjectFileNames { + projectFilePath := filepath.Join(searchDir, fileName) + stat, err := os.Stat(projectFilePath) + if os.IsNotExist(err) || (err == nil && stat.IsDir()) { + // File doesn't exist or is a directory, try next file name + continue + } else if err == nil { + // We found a valid project file + foundProjectFilePath = projectFilePath + break + } else { + // An unexpected error occurred + return nil, fmt.Errorf("searching for project file: %w", err) } - searchDir = parent - } else if err == nil { - // We found our azure.yaml file, and searchDir is the directory - // that contains it. + } + + if foundProjectFilePath != "" { + // We found our project file, and searchDir is the directory that contains it. break - } else { - return nil, fmt.Errorf("searching for project file: %w", err) } + + // No project file found in this directory, move up to parent + parent := filepath.Dir(searchDir) + if parent == searchDir { + return nil, ErrNoProject + } + searchDir = parent } return &AzdContext{ projectDirectory: searchDir, + projectFilePath: foundProjectFilePath, }, nil } diff --git a/cli/azd/pkg/environment/azdcontext/azdcontext_test.go b/cli/azd/pkg/environment/azdcontext/azdcontext_test.go new file mode 100644 index 00000000000..6ca6653e73b --- /dev/null +++ b/cli/azd/pkg/environment/azdcontext/azdcontext_test.go @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdcontext + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewAzdContextFromWd_WithAzureYaml(t *testing.T) { + tempDir := t.TempDir() + + // Create azure.yaml file + azureYamlPath := filepath.Join(tempDir, "azure.yaml") + err := os.WriteFile(azureYamlPath, []byte("name: test\n"), 0600) + require.NoError(t, err) + + // Test from the directory containing azure.yaml + ctx, err := NewAzdContextFromWd(tempDir) + require.NoError(t, err) + require.NotNil(t, ctx) + require.Equal(t, tempDir, ctx.ProjectDirectory()) + require.Equal(t, azureYamlPath, ctx.ProjectPath()) +} + +func TestNewAzdContextFromWd_WithAzureYml(t *testing.T) { + tempDir := t.TempDir() + + // Create azure.yml file + azureYmlPath := filepath.Join(tempDir, "azure.yml") + err := os.WriteFile(azureYmlPath, []byte("name: test\n"), 0600) + require.NoError(t, err) + + // Test from the directory containing azure.yml + ctx, err := NewAzdContextFromWd(tempDir) + require.NoError(t, err) + require.NotNil(t, ctx) + require.Equal(t, tempDir, ctx.ProjectDirectory()) + require.Equal(t, azureYmlPath, ctx.ProjectPath()) +} + +func TestNewAzdContextFromWd_BothFilesExist_YamlTakesPrecedence(t *testing.T) { + tempDir := t.TempDir() + + // Create both azure.yaml and azure.yml + azureYamlPath := filepath.Join(tempDir, "azure.yaml") + azureYmlPath := filepath.Join(tempDir, "azure.yml") + err := os.WriteFile(azureYamlPath, []byte("name: yaml\n"), 0600) + require.NoError(t, err) + err = os.WriteFile(azureYmlPath, []byte("name: yml\n"), 0600) + require.NoError(t, err) + + // Test that azure.yaml takes precedence + ctx, err := NewAzdContextFromWd(tempDir) + require.NoError(t, err) + require.NotNil(t, ctx) + require.Equal(t, tempDir, ctx.ProjectDirectory()) + require.Equal(t, azureYamlPath, ctx.ProjectPath(), "azure.yaml should take precedence over azure.yml") +} + +func TestNewAzdContextFromWd_FromSubdirectory(t *testing.T) { + tempDir := t.TempDir() + subDir := filepath.Join(tempDir, "src", "api") + err := os.MkdirAll(subDir, 0755) + require.NoError(t, err) + + // Create azure.yml in the root + azureYmlPath := filepath.Join(tempDir, "azure.yml") + err = os.WriteFile(azureYmlPath, []byte("name: test\n"), 0600) + require.NoError(t, err) + + // Test from subdirectory - should walk up and find the file + ctx, err := NewAzdContextFromWd(subDir) + require.NoError(t, err) + require.NotNil(t, ctx) + require.Equal(t, tempDir, ctx.ProjectDirectory()) + require.Equal(t, azureYmlPath, ctx.ProjectPath()) +} + +func TestNewAzdContextFromWd_NoProjectFile(t *testing.T) { + tempDir := t.TempDir() + + // No project file exists + ctx, err := NewAzdContextFromWd(tempDir) + require.Error(t, err) + require.Nil(t, ctx) + require.ErrorIs(t, err, ErrNoProject) +} + +func TestNewAzdContextFromWd_DirectoryWithSameName(t *testing.T) { + tempDir := t.TempDir() + + // Create a directory named azure.yaml (edge case) + azureYamlDir := filepath.Join(tempDir, "azure.yaml") + err := os.Mkdir(azureYamlDir, 0755) + require.NoError(t, err) + + // Create actual azure.yml file + azureYmlPath := filepath.Join(tempDir, "azure.yml") + err = os.WriteFile(azureYmlPath, []byte("name: test\n"), 0600) + require.NoError(t, err) + + // Should find azure.yml and skip the directory + ctx, err := NewAzdContextFromWd(tempDir) + require.NoError(t, err) + require.NotNil(t, ctx) + require.Equal(t, tempDir, ctx.ProjectDirectory()) + require.Equal(t, azureYmlPath, ctx.ProjectPath()) +} + +func TestNewAzdContextFromWd_InvalidPath(t *testing.T) { + // Test with a path that doesn't exist + ctx, err := NewAzdContextFromWd("/this/path/does/not/exist/at/all") + require.Error(t, err) + require.Nil(t, ctx) +} + +func TestProjectPath_WithFoundFile(t *testing.T) { + tempDir := t.TempDir() + azureYmlPath := filepath.Join(tempDir, "azure.yml") + err := os.WriteFile(azureYmlPath, []byte("name: test\n"), 0600) + require.NoError(t, err) + + ctx, err := NewAzdContextFromWd(tempDir) + require.NoError(t, err) + + // ProjectPath should return the actual found file + require.Equal(t, azureYmlPath, ctx.ProjectPath()) +} + +func TestProjectPath_WithoutFoundFile(t *testing.T) { + // Create context directly without searching + ctx := NewAzdContextWithDirectory("/some/path") + + // ProjectPath should return default azure.yaml + expected := filepath.Join("/some/path", "azure.yaml") + require.Equal(t, expected, ctx.ProjectPath()) +} + +func TestNewAzdContextWithDirectory(t *testing.T) { + testDir := "/test/directory" + ctx := NewAzdContextWithDirectory(testDir) + + require.NotNil(t, ctx) + require.Equal(t, testDir, ctx.ProjectDirectory()) + require.Equal(t, filepath.Join(testDir, "azure.yaml"), ctx.ProjectPath()) +} + +func TestSetProjectDirectory(t *testing.T) { + ctx := NewAzdContextWithDirectory("/original/path") + require.Equal(t, "/original/path", ctx.ProjectDirectory()) + + ctx.SetProjectDirectory("/new/path") + require.Equal(t, "/new/path", ctx.ProjectDirectory()) +} + +func TestEnvironmentDirectory(t *testing.T) { + ctx := NewAzdContextWithDirectory("/test/path") + expected := filepath.Join("/test/path", ".azure") + require.Equal(t, expected, ctx.EnvironmentDirectory()) +} + +func TestEnvironmentRoot(t *testing.T) { + ctx := NewAzdContextWithDirectory("/test/path") + expected := filepath.Join("/test/path", ".azure", "env1") + require.Equal(t, expected, ctx.EnvironmentRoot("env1")) +} + +func TestGetEnvironmentWorkDirectory(t *testing.T) { + ctx := NewAzdContextWithDirectory("/test/path") + expected := filepath.Join("/test/path", ".azure", "env1", "wd") + require.Equal(t, expected, ctx.GetEnvironmentWorkDirectory("env1")) +} + +func TestProjectFileNames_Order(t *testing.T) { + // Verify the order of preference + require.Len(t, ProjectFileNames, 2) + require.Equal(t, "azure.yaml", ProjectFileNames[0], "azure.yaml should be first (highest priority)") + require.Equal(t, "azure.yml", ProjectFileNames[1], "azure.yml should be second") +} + +func TestProjectName(t *testing.T) { + tests := []struct { + name string + inputDir string + expected string + }{ + { + name: "Simple directory name", + inputDir: "/home/user/my-project", + expected: "my-project", + }, + { + name: "Directory with special characters", + inputDir: "/home/user/My_Project-123", + expected: "my-project-123", + }, + { + name: "Root directory", + inputDir: "/", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ProjectName(tt.inputDir) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/ext/vscode/package.json b/ext/vscode/package.json index ca8f7ff8c25..d8eb573203b 100644 --- a/ext/vscode/package.json +++ b/ext/vscode/package.json @@ -194,7 +194,7 @@ "explorer/context": [ { "submenu": "azure-dev.explorer.submenu", - "when": "resourceFilename =~ /(azure.yaml|pom.xml)/i", + "when": "resourceFilename =~ /(azure.ya?ml|pom.xml)/i", "group": "azure-dev" } ], @@ -205,62 +205,62 @@ "group": "10provision@10" }, { - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /azure.ya?ml/i", "command": "azure-dev.commands.cli.provision", "group": "10provision@10" }, { - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /azure.ya?ml/i", "command": "azure-dev.commands.cli.deploy", "group": "10provision@20" }, { - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /azure.ya?ml/i", "command": "azure-dev.commands.cli.up", "group": "10provision@30" }, { - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /azure.ya?ml/i", "command": "azure-dev.commands.cli.down", "group": "10provision@40" }, { - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /azure.ya?ml/i", "command": "azure-dev.commands.cli.env-new", "group": "20env@10" }, { - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /azure.ya?ml/i", "command": "azure-dev.commands.cli.env-select", "group": "20env@20" }, { - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /azure.ya?ml/i", "command": "azure-dev.commands.cli.env-refresh", "group": "20env@30" }, { - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /azure.ya?ml/i", "command": "azure-dev.commands.cli.env-list", "group": "20env@40" }, { - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /azure.ya?ml/i", "command": "azure-dev.commands.cli.restore", "group": "30develop@10" }, { - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /azure.ya?ml/i", "command": "azure-dev.commands.cli.package", "group": "30develop@20" }, { - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /azure.ya?ml/i", "command": "azure-dev.commands.cli.pipeline-config", "group": "30develop@30" }, { - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /azure.ya?ml/i", "command": "azure-dev.commands.cli.monitor", "group": "40monitor@10" } @@ -500,6 +500,10 @@ { "fileMatch": "azure.yaml", "url": "https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json" + }, + { + "fileMatch": "azure.yml", + "url": "https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json" } ] }, diff --git a/ext/vscode/src/commands/cmdUtil.ts b/ext/vscode/src/commands/cmdUtil.ts index 8d7bc02b474..dc251c28a2e 100644 --- a/ext/vscode/src/commands/cmdUtil.ts +++ b/ext/vscode/src/commands/cmdUtil.ts @@ -10,10 +10,10 @@ import { createAzureDevCli } from '../utils/azureDevCli'; import { execAsync } from '../utils/execAsync'; import { fileExists } from '../utils/fileUtils'; -const AzureYamlGlobPattern: vscode.GlobPattern = '**/[aA][zZ][uU][rR][eE].[yY][aA][mM][lL]'; +const AzureYamlGlobPattern: vscode.GlobPattern = '**/[aA][zZ][uU][rR][eE].{[yY][aA][mM][lL],[yY][mM][lL]}'; // If the command was invoked with a specific file context, use the file context as the working directory for running Azure developer CLI commands. -// Otherwise search the workspace for "azure.yaml" files. If only one is found, use it (i.e. its folder). If more than one is found, ask the user which one to use. +// Otherwise search the workspace for "azure.yaml" or "azure.yml" files. If only one is found, use it (i.e. its folder). If more than one is found, ask the user which one to use. // If at this point we still do not have a working directory, prompt the user to select one. export async function getWorkingFolder(context: IActionContext, selectedFile?: vscode.Uri): Promise { let folderPath = selectedFile ? path.dirname(selectedFile.fsPath) : undefined; @@ -39,10 +39,11 @@ export async function getWorkingFolder(context: IActionContext, selectedFile?: v const folderUri = localFolderUris[0]; const azureYamlUri = vscode.Uri.joinPath(folderUri, 'azure.yaml'); + const azureYmlUri = vscode.Uri.joinPath(folderUri, 'azure.yml'); - if (!await fileExists(azureYamlUri)) { + if (!await fileExists(azureYamlUri) && !await fileExists(azureYmlUri)) { context.errorHandling.suppressReportIssue = true; - throw new Error(vscode.l10n.t("The selected folder does not contain 'azure.yaml' file and cannot be used to run Azure Developer CLI commands")); + throw new Error(vscode.l10n.t("The selected folder does not contain 'azure.yaml' or 'azure.yml' file and cannot be used to run Azure Developer CLI commands")); } folderPath = folderUri.fsPath; @@ -66,7 +67,7 @@ export async function pickAzureYamlFile(context: IActionContext): Promise