From 87695cd7bc7c9937a83ce44e19f3dfb89c2d1d3d Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 9 Jan 2026 02:30:16 +0000 Subject: [PATCH 1/4] Improve validation when initializing from local manifest file --- .../azure.ai.agents/internal/cmd/init.go | 143 ++++++------- .../azure.ai.agents/internal/cmd/init_copy.go | 196 ++++++++++++++++++ .../azure.ai.agents/internal/cmd/init_test.go | 62 ++++++ 3 files changed, 319 insertions(+), 82 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 5345b8d0110..06f723ee157 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -104,11 +104,6 @@ func newInitCommand(rootFlags rootFlagsDefinition) *cobra.Command { return fmt.Errorf("failed to ground into a project context: %w", err) } - // getComposedResourcesResponse, err := azdClient.Compose().ListResources(ctx, &azdext.EmptyRequest{}) - // if err != nil { - // return fmt.Errorf("failed to get composed resources: %w", err) - // } - credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ TenantID: azureContext.Scope.TenantId, AdditionallyAllowedTenants: []string{"*"}, @@ -893,24 +888,32 @@ func (a *InitAction) downloadAgentYaml( name = "" } - // Check if the manifest file is under current directory + "src" - currentDir, _ := os.Getwd() - srcDir := filepath.Join(currentDir, "src", name) - absManifestPath, _ := filepath.Abs(manifestPointer) - - // Check if manifest is under src directory - if strings.HasPrefix(absManifestPath, srcDir) { - confirmResponse, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ - Options: &azdext.ConfirmOptions{ - Message: "This operation will overwrite the provided manifest file. Do you want to continue?", - DefaultValue: to.Ptr(false), - }, - }) + if name != "" { + // Check if the manifest file is under current directory + "src/" + currentDir, err := os.Getwd() if err != nil { - return nil, "", fmt.Errorf("prompting for confirmation: %w", err) + return nil, "", fmt.Errorf("getting current directory: %w", err) } - if !*confirmResponse.Value { - return nil, "", fmt.Errorf("operation cancelled by user") + srcDir := filepath.Join(currentDir, "src", name) + absManifestPath, err := filepath.Abs(manifestPointer) + if err != nil { + return nil, "", fmt.Errorf("getting absolute path for manifest %s: %w", manifestPointer, err) + } + + // Check if manifest is under src directory + if isSubpath(absManifestPath, srcDir) { + confirmResponse, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: "This operation will overwrite the provided manifest file. Do you want to continue?", + DefaultValue: to.Ptr(false), + }, + }) + if err != nil { + return nil, "", fmt.Errorf("prompting for confirmation: %w", err) + } + if !*confirmResponse.Value { + return nil, "", fmt.Errorf("operation cancelled by user") + } } } } else if a.isGitHubUrl(manifestPointer) { @@ -1016,6 +1019,12 @@ func (a *InitAction) downloadAgentYaml( return nil, "", fmt.Errorf("marshaling agent manifest to YAML: %w", err) } content = manifestBytes + } else { + // If we reach here, the manifest pointer didn't match any known type + return nil, "", fmt.Errorf( + "manifest pointer '%s' is not a valid local file path, GitHub URL, or registry URL", + manifestPointer, + ) } // Parse and validate the YAML content against AgentManifest structure @@ -1026,6 +1035,22 @@ func (a *InitAction) downloadAgentYaml( fmt.Println("✓ YAML content successfully validated against AgentManifest format") + agentId := agentManifest.Name + + // Use targetDir if provided, otherwise default to "src/{agentId}" + if targetDir == "" { + targetDir = filepath.Join("src", agentId) + } + + // Safety checks for local container-based agents should happen before prompting for model SKU, etc. + if a.isLocalFilePath(manifestPointer) { + if _, isContainerAgent := agentManifest.Template.(agent_yaml.ContainerAgent); isContainerAgent { + if err := a.validateLocalContainerAgentCopy(ctx, manifestPointer, targetDir); err != nil { + return nil, "", err + } + } + } + agentManifest, err = registry_api.ProcessManifestParameters(ctx, agentManifest, a.azdClient, a.flags.NoPrompt) if err != nil { return nil, "", fmt.Errorf("failed to process manifest parameters: %w", err) @@ -1046,13 +1071,6 @@ func (a *InitAction) downloadAgentYaml( } } - agentId := agentManifest.Name - - // Use targetDir if provided or set to local file pointer, otherwise default to "src/{agentId}" - if targetDir == "" { - targetDir = filepath.Join("src", agentId) - } - // Create target directory if it doesn't exist if err := os.MkdirAll(targetDir, 0755); err != nil { return nil, "", fmt.Errorf("creating target directory %s: %w", targetDir, err) @@ -1063,12 +1081,23 @@ func (a *InitAction) downloadAgentYaml( _, isHostedContainer := agentManifest.Template.(agent_yaml.ContainerAgent) if isHostedContainer { - // For container agents, copy the entire parent directory - fmt.Println("Copying full directory for container agent") + // For container agents, copy the entire parent directory. + // If the manifest already lives in the target directory (re-init), skip the copy. manifestDir := filepath.Dir(manifestPointer) - err := copyDirectory(manifestDir, targetDir) + srcAbs, err := filepath.Abs(manifestDir) + if err != nil { + return nil, "", fmt.Errorf("resolving manifest directory %s: %w", manifestDir, err) + } + dstAbs, err := filepath.Abs(targetDir) if err != nil { - return nil, "", fmt.Errorf("copying parent directory: %w", err) + return nil, "", fmt.Errorf("resolving target directory %s: %w", targetDir, err) + } + if !isSamePath(srcAbs, dstAbs) { + fmt.Println("Copying full directory for container agent") + err := copyDirectory(manifestDir, targetDir) + if err != nil { + return nil, "", fmt.Errorf("copying parent directory: %w", err) + } } } } else if isGitHubUrl { @@ -2019,53 +2048,3 @@ func (a *InitAction) ProcessModels(ctx context.Context, manifest *agent_yaml.Age return updatedManifest, deploymentDetails, nil } - -// copyDirectory recursively copies all files and directories from src to dst -func copyDirectory(src, dst string) error { - return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - - // Calculate the destination path - relPath, err := filepath.Rel(src, path) - if err != nil { - return err - } - dstPath := filepath.Join(dst, relPath) - - if d.IsDir() { - // Create directory and continue processing its contents - return os.MkdirAll(dstPath, 0755) - } else { - // Copy file - return copyFile(path, dstPath) - } - }) -} - -// copyFile copies a single file from src to dst -func copyFile(src, dst string) error { - // Create the destination directory if it doesn't exist - if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { - return err - } - - // Open source file - srcFile, err := os.Open(src) - if err != nil { - return err - } - defer srcFile.Close() - - // Create destination file - dstFile, err := os.Create(dst) - if err != nil { - return err - } - defer dstFile.Close() - - // Copy file contents - _, err = srcFile.WriteTo(dstFile) - return err -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go new file mode 100644 index 00000000000..4df3ba9d72c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +const ( + // copyConfirmThreshold is the max file/folder count before prompting for confirmation. + copyConfirmThreshold = 10 + // previewLimit is the max items shown in the directory preview. + previewLimit = 5 +) + +// validateLocalContainerAgentCopy checks if copying the manifest directory to targetDir is safe, +// prompting for confirmation if the directory contains many files. +func (a *InitAction) validateLocalContainerAgentCopy(ctx context.Context, manifestPointer string, targetDir string) error { + manifestDir := filepath.Dir(manifestPointer) + srcAbs, err := filepath.Abs(manifestDir) + if err != nil { + return fmt.Errorf("resolving manifest directory %s: %w", manifestDir, err) + } + dstAbs, err := filepath.Abs(targetDir) + if err != nil { + return fmt.Errorf("resolving target directory %s: %w", targetDir, err) + } + + // Re-init case: manifest already lives in the destination directory. + // We still overwrite agent.yaml, but we should not attempt to copy the directory into itself. + if isSamePath(dstAbs, srcAbs) { + return nil + } + + if isSubpath(dstAbs, srcAbs) { + return fmt.Errorf( + "destination '%s' is inside the agent manifest directory '%s'. "+ + "Move the manifest to a separate directory to avoid copying into itself", + dstAbs, + srcAbs, + ) + } + + entries, err := os.ReadDir(srcAbs) + if err != nil { + return fmt.Errorf("reading manifest directory %s: %w", srcAbs, err) + } + entryCount := len(entries) + if entryCount <= copyConfirmThreshold { + return nil + } + + if a.flags.NoPrompt { + return nil + } + + preview, err := formatDirectoryPreview(entries, previewLimit) + if err != nil { + return fmt.Errorf("enumerating files and folders in %s: %w", srcAbs, err) + } + + fmt.Printf("%s", output.WithWarningFormat( + "\nThe agent manifest directory '%s' contains %d files and folders that will be copied into '%s': %s\n\n", + srcAbs, + entryCount, + dstAbs, + preview)) + + confirmResponse, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: "Continue?", + DefaultValue: to.Ptr(false), + HelpMessage: "To avoid copying too much, place the manifest in a dedicated folder with only the agent files you want to include.", + }, + }) + if err != nil { + return fmt.Errorf("prompting for confirmation: %w", err) + } + if confirmResponse == nil || confirmResponse.Value == nil || !*confirmResponse.Value { + return fmt.Errorf("operation cancelled by user") + } + + return nil +} + +// formatDirectoryPreview returns a comma-separated preview of directory entries, +// truncating with "(+N more)" if exceeding maxEntries. +func formatDirectoryPreview(entries []os.DirEntry, maxEntries int) (string, error) { + labels := make([]string, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() { + name += "/" + } + labels = append(labels, name) + } + + slices.Sort(labels) + if maxEntries <= 0 || len(labels) <= maxEntries { + return strings.Join(labels, ", "), nil + } + + return fmt.Sprintf("%s, ... (+%d more)", strings.Join(labels[:maxEntries], ", "), len(labels)-maxEntries), nil +} + +// isSubpath returns true if child is inside or equal to parent. +func isSubpath(child, parent string) bool { + rel, err := filepath.Rel(filepath.Clean(parent), filepath.Clean(child)) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func isSamePath(a, b string) bool { + return filepath.Clean(a) == filepath.Clean(b) +} + +// copyDirectory recursively copies all files and directories from src to dst. +func copyDirectory(src, dst string) error { + srcAbs, err := filepath.Abs(src) + if err != nil { + return fmt.Errorf("resolving absolute source path %s: %w", src, err) + } + dstAbs, err := filepath.Abs(dst) + if err != nil { + return fmt.Errorf("resolving absolute destination path %s: %w", dst, err) + } + + // No-op: already in the destination directory (re-init / overwrite scenario). + if isSamePath(dstAbs, srcAbs) { + return nil + } + + if isSubpath(dstAbs, srcAbs) { + return fmt.Errorf("refusing to copy directory '%s' into its own subtree '%s'", srcAbs, dstAbs) + } + + return filepath.WalkDir(srcAbs, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Calculate the destination path + relPath, err := filepath.Rel(srcAbs, path) + if err != nil { + return err + } + dstPath := filepath.Join(dstAbs, relPath) + + if d.IsDir() { + // Create directory and continue processing its contents + return os.MkdirAll(dstPath, 0755) + } + + // Copy file + return copyFile(path, dstPath) + }) +} + +// copyFile copies a single file from src to dst. +func copyFile(src, dst string) error { + // Create the destination directory if it doesn't exist + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + + // Open source file + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + // Create destination file + dstFile, err := os.Create(dst) + if err != nil { + return err + } + defer dstFile.Close() + + // Copy file contents + _, err = srcFile.WriteTo(dstFile) + return err +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go new file mode 100644 index 00000000000..a71b6794295 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestCopyDirectory_RefusesToCopyIntoSubtree(t *testing.T) { + t.Parallel() + + root := t.TempDir() + src := filepath.Join(root, "src") + dst := filepath.Join(src, "child") + + if err := os.MkdirAll(src, 0755); err != nil { + t.Fatalf("mkdir src: %v", err) + } + if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello"), 0644); err != nil { + t.Fatalf("write src file: %v", err) + } + + if err := copyDirectory(src, dst); err == nil { + t.Fatalf("expected error when destination is inside source") + } +} + +func TestCopyDirectory_NoOpWhenSamePath(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("hello"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + if err := copyDirectory(dir, dir); err != nil { + t.Fatalf("expected no error when src==dst: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "file.txt")); err != nil { + t.Fatalf("expected file to still exist: %v", err) + } +} + +func TestValidateLocalContainerAgentCopy_AllowsReinitInPlace(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + manifestPointer := filepath.Join(dir, "agent.yaml") + if err := os.WriteFile(manifestPointer, []byte("name: test"), 0644); err != nil { + t.Fatalf("write agent.yaml: %v", err) + } + + a := &InitAction{} + if err := a.validateLocalContainerAgentCopy(context.Background(), manifestPointer, dir); err != nil { + t.Fatalf("expected no error for re-init in place: %v", err) + } +} From d16ceaa47ebeb816f5d67ff64f6881fdca2ee9ef Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 9 Jan 2026 02:30:33 +0000 Subject: [PATCH 2/4] Update `github.com/azure/azure-dev/cli/azd` --- cli/azd/extensions/azure.ai.agents/go.mod | 2 +- cli/azd/extensions/azure.ai.agents/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index f236dc2e8b3..fce5d30e184 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -10,7 +10,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 - github.com/azure/azure-dev/cli/azd v0.0.0-20251212003342-848978091314 + github.com/azure/azure-dev/cli/azd v0.0.0-20260109002911-7e0ee49fe5ac github.com/braydonk/yaml v0.9.0 github.com/drone/envsubst v1.0.3 github.com/fatih/color v1.18.0 diff --git a/cli/azd/extensions/azure.ai.agents/go.sum b/cli/azd/extensions/azure.ai.agents/go.sum index 5045ad2c862..ab939bdf1d3 100644 --- a/cli/azd/extensions/azure.ai.agents/go.sum +++ b/cli/azd/extensions/azure.ai.agents/go.sum @@ -51,8 +51,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/azure/azure-dev/cli/azd v0.0.0-20251212003342-848978091314 h1:2COt/tcJlZauO+Vd47SGD//isdVqSj2K1DhMfa3J3Vo= -github.com/azure/azure-dev/cli/azd v0.0.0-20251212003342-848978091314/go.mod h1:9+M/plQRg5MGyLdTOm8MMxgKohlUdBF04pzZrIugmPs= +github.com/azure/azure-dev/cli/azd v0.0.0-20260109002911-7e0ee49fe5ac h1:ow5lGEF+3dB4Jps7EVWvQKb46/sXK4GzDexumF1bjuI= +github.com/azure/azure-dev/cli/azd v0.0.0-20260109002911-7e0ee49fe5ac/go.mod h1:j+bdvNwQPdYtSfFe/xbfWqYr8Guw9hiP1JOVpIBERj0= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= From 8084ed809a1c117fbd9ffe6b51ee430ab077c733 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 9 Jan 2026 18:54:40 +0000 Subject: [PATCH 3/4] Update wording --- .../extensions/azure.ai.agents/internal/cmd/init.go | 2 +- .../azure.ai.agents/internal/cmd/init_copy.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 06f723ee157..895f25e01d4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -904,7 +904,7 @@ func (a *InitAction) downloadAgentYaml( if isSubpath(absManifestPath, srcDir) { confirmResponse, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ Options: &azdext.ConfirmOptions{ - Message: "This operation will overwrite the provided manifest file. Do you want to continue?", + Message: "This operation will overwrite the provided manifest file. Continue?", DefaultValue: to.Ptr(false), }, }) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go index 4df3ba9d72c..191ebc4548d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go @@ -45,8 +45,8 @@ func (a *InitAction) validateLocalContainerAgentCopy(ctx context.Context, manife if isSubpath(dstAbs, srcAbs) { return fmt.Errorf( - "destination '%s' is inside the agent manifest directory '%s'. "+ - "Move the manifest to a separate directory to avoid copying into itself", + "cannot copy agent files: target '%s' is inside the manifest directory '%s'.\n"+ + "Move the manifest to a separate directory containing only the agent files.", dstAbs, srcAbs, ) @@ -67,11 +67,11 @@ func (a *InitAction) validateLocalContainerAgentCopy(ctx context.Context, manife preview, err := formatDirectoryPreview(entries, previewLimit) if err != nil { - return fmt.Errorf("enumerating files and folders in %s: %w", srcAbs, err) + return fmt.Errorf("enumerating items in %s: %w", srcAbs, err) } fmt.Printf("%s", output.WithWarningFormat( - "\nThe agent manifest directory '%s' contains %d files and folders that will be copied into '%s': %s\n\n", + "\nThe manifest directory '%s' contains %d items that will be copied into '%s': %s\n\n", srcAbs, entryCount, dstAbs, @@ -81,7 +81,7 @@ func (a *InitAction) validateLocalContainerAgentCopy(ctx context.Context, manife Options: &azdext.ConfirmOptions{ Message: "Continue?", DefaultValue: to.Ptr(false), - HelpMessage: "To avoid copying too much, place the manifest in a dedicated folder with only the agent files you want to include.", + HelpMessage: "To avoid copying too much, move the manifest to a separate directory with only the agent files you want to include.", }, }) if err != nil { From 38ea482b4a7cabed7512994b50ebb7b835da7af7 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 9 Jan 2026 19:24:32 +0000 Subject: [PATCH 4/4] Address comments --- .../azure.ai.agents/internal/cmd/init_copy.go | 2 +- .../azure.ai.agents/internal/cmd/init_test.go | 136 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go index 191ebc4548d..f3c82b77f81 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go @@ -67,7 +67,7 @@ func (a *InitAction) validateLocalContainerAgentCopy(ctx context.Context, manife preview, err := formatDirectoryPreview(entries, previewLimit) if err != nil { - return fmt.Errorf("enumerating items in %s: %w", srcAbs, err) + return fmt.Errorf("formatting directory preview for %s: %w", srcAbs, err) } fmt.Printf("%s", output.WithWarningFormat( diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index a71b6794295..9f27f74cff5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -55,8 +55,144 @@ func TestValidateLocalContainerAgentCopy_AllowsReinitInPlace(t *testing.T) { t.Fatalf("write agent.yaml: %v", err) } + // InitAction with nil azdClient is safe here because isSamePath returns early + // before any prompting code is reached. a := &InitAction{} if err := a.validateLocalContainerAgentCopy(context.Background(), manifestPointer, dir); err != nil { t.Fatalf("expected no error for re-init in place: %v", err) } } + +func TestIsSubpath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + child string + parent string + expected bool + }{ + {"child inside parent", "/a/b/c", "/a/b", true}, + {"child equals parent", "/a/b", "/a/b", true}, + {"child outside parent", "/a/b", "/a/b/c", false}, + {"sibling directories", "/a/b", "/a/c", false}, + {"parent with trailing slash", "/a/b/c", "/a/b/", true}, + {"relative same", ".", ".", true}, + {"relative child", "a/b", "a", true}, + {"relative outside", "a", "a/b", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSubpath(tt.child, tt.parent) + if result != tt.expected { + t.Errorf("isSubpath(%q, %q) = %v, want %v", tt.child, tt.parent, result, tt.expected) + } + }) + } +} + +func TestIsSamePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + a string + b string + expected bool + }{ + {"identical paths", "/a/b/c", "/a/b/c", true}, + {"trailing slash difference", "/a/b/c", "/a/b/c/", true}, + {"with dot segments", "/a/b/../b/c", "/a/b/c", true}, + {"different paths", "/a/b", "/a/c", false}, + {"relative same", "a/b", "a/b", true}, + {"relative with dots", "a/b/../b", "a/b", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSamePath(tt.a, tt.b) + if result != tt.expected { + t.Errorf("isSamePath(%q, %q) = %v, want %v", tt.a, tt.b, result, tt.expected) + } + }) + } +} + +type mockDirEntry struct { + name string + isDir bool +} + +func (m mockDirEntry) Name() string { return m.name } +func (m mockDirEntry) IsDir() bool { return m.isDir } +func (m mockDirEntry) Type() os.FileMode { return 0 } +func (m mockDirEntry) Info() (os.FileInfo, error) { return nil, nil } + +func TestFormatDirectoryPreview(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + entries []os.DirEntry + maxEntries int + expected string + }{ + { + name: "empty entries", + entries: []os.DirEntry{}, + maxEntries: 5, + expected: "", + }, + { + name: "fewer than max", + entries: []os.DirEntry{ + mockDirEntry{name: "file.txt", isDir: false}, + mockDirEntry{name: "dir", isDir: true}, + }, + maxEntries: 5, + expected: "dir/, file.txt", + }, + { + name: "exactly max", + entries: []os.DirEntry{ + mockDirEntry{name: "a.txt", isDir: false}, + mockDirEntry{name: "b.txt", isDir: false}, + }, + maxEntries: 2, + expected: "a.txt, b.txt", + }, + { + name: "more than max", + entries: []os.DirEntry{ + mockDirEntry{name: "c.txt", isDir: false}, + mockDirEntry{name: "a.txt", isDir: false}, + mockDirEntry{name: "b.txt", isDir: false}, + mockDirEntry{name: "d.txt", isDir: false}, + }, + maxEntries: 2, + expected: "a.txt, b.txt, ... (+2 more)", + }, + { + name: "directories get trailing slash", + entries: []os.DirEntry{ + mockDirEntry{name: "mydir", isDir: true}, + mockDirEntry{name: "myfile", isDir: false}, + }, + maxEntries: 5, + expected: "mydir/, myfile", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := formatDirectoryPreview(tt.entries, tt.maxEntries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("formatDirectoryPreview() = %q, want %q", result, tt.expected) + } + }) + } +}