From 62aa185b35cc4d4f376986ae3cf592f15eaead50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:01:34 +0000 Subject: [PATCH 1/4] feat(cache): add library-overwrite-enabled flag and ALZ ref support to cache create Agent-Logs-Url: https://github.com/Azure/alzlib/sessions/edcee89c-eb65-4920-964a-6d9121e6e3c8 Co-authored-by: matt-FFFFFF <16320656+matt-FFFFFF@users.noreply.github.com> --- cmd/alzlibtool/command/cache/create.go | 71 +++++++++++++--- cmd/alzlibtool/command/cache/create_test.go | 94 +++++++++++++++++++++ 2 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 cmd/alzlibtool/command/cache/create_test.go diff --git a/cmd/alzlibtool/command/cache/create.go b/cmd/alzlibtool/command/cache/create.go index 07700f4..c02e9e0 100644 --- a/cmd/alzlibtool/command/cache/create.go +++ b/cmd/alzlibtool/command/cache/create.go @@ -6,6 +6,7 @@ package cache import ( "log/slog" "os" + "strings" alzlib "github.com/Azure/alzlib" "github.com/Azure/alzlib/cache" @@ -36,6 +37,14 @@ When --library and --architecture are specified, only the definitions referenced architecture are included, producing a smaller, use-case-specific cache. This is useful for embedding the minimal set of definitions needed for a given deployment workflow. +A --library value may be either a local path (e.g. ./mylib) or an ALZ Library reference in +the form @, e.g. platform/alz@2026.01.3. The flag may be specified multiple +times to combine several libraries; their dependencies are fetched recursively. + +Use --library-overwrite-enabled to allow later libraries to overwrite definitions, policy +assignments, role definitions, archetypes and architectures already provided by earlier +libraries. This is useful when layering a custom library on top of a base ALZ library. + Use --from-cache to seed from an existing cache file (requires --library and --architecture). Definitions already present in the seed cache are used directly and not re-fetched from Azure, reducing the number of API calls. The same file may be used for both --from-cache and --output @@ -44,12 +53,13 @@ to update a cache in-place.`, Run: func(cmd *cobra.Command, _ []string) { outFile, _ := cmd.Flags().GetString("output") verbose, _ := cmd.Flags().GetBool("verbose") - libraryPath, _ := cmd.Flags().GetString("library") + libraryRefs, _ := cmd.Flags().GetStringArray("library") architectureName, _ := cmd.Flags().GetString("architecture") fromCacheFile, _ := cmd.Flags().GetString("from-cache") + libraryOverwriteEnabled, _ := cmd.Flags().GetBool("library-overwrite-enabled") // --library and --architecture must be specified together. - if (libraryPath == "") != (architectureName == "") { + if (len(libraryRefs) == 0) != (architectureName == "") { cmd.PrintErrf( "%s --library and --architecture must be specified together\n", cmd.ErrPrefix(), @@ -61,7 +71,7 @@ to update a cache in-place.`, // lazy cache-first lookup can skip individual Azure API calls. In full-scan // mode the bulk listing APIs fetch everything regardless, so a seed cache // cannot reduce work. - if fromCacheFile != "" && libraryPath == "" { + if fromCacheFile != "" && len(libraryRefs) == 0 { cmd.PrintErrf( "%s --from-cache requires --library and --architecture\n", cmd.ErrPrefix(), @@ -69,6 +79,16 @@ to update a cache in-place.`, os.Exit(1) } + // --library-overwrite-enabled is only meaningful in architecture-scoped mode + // where multiple libraries are loaded into an AlzLib instance. + if libraryOverwriteEnabled && len(libraryRefs) == 0 { + cmd.PrintErrf( + "%s --library-overwrite-enabled requires --library and --architecture\n", + cmd.ErrPrefix(), + ) + os.Exit(1) + } + // Read the seed cache BEFORE opening the output file, because --from-cache // and --output may point to the same path. var seedCache *cache.Cache @@ -106,12 +126,15 @@ to update a cache in-place.`, var resultCache *cache.Cache - if libraryPath != "" { + if len(libraryRefs) > 0 { // Architecture-scoped mode: process the library + architecture and export // only the built-in definitions that were actually referenced. - thisLib := alzlib.NewCustomLibraryReference(libraryPath) + refs := make(alzlib.LibraryReferences, 0, len(libraryRefs)) + for _, r := range libraryRefs { + refs = append(refs, parseLibraryReference(r)) + } - allLibs, err := thisLib.FetchWithDependencies(cmd.Context()) + allLibs, err := refs.FetchWithDependencies(cmd.Context()) if err != nil { cmd.PrintErrf( "%s could not fetch libraries with dependencies: %v\n", @@ -121,6 +144,7 @@ to update a cache in-place.`, } az := alzlib.NewAlzLib(nil) + az.Options.AllowOverwrite = libraryOverwriteEnabled if seedCache != nil { az.AddCache(seedCache) @@ -221,10 +245,13 @@ func init() { createCmd.Flags(). BoolP("verbose", "v", false, "Display detailed progress during cache creation.") createCmd.Flags(). - StringP( - "library", "L", "", - "Path to a library. When set together with --architecture, creates a minimal cache "+ - "containing only the definitions referenced by the specified architecture.") + StringArrayP( + "library", "L", nil, + "Path or reference to a library. May be specified multiple times. Each value is "+ + "either a local filesystem path (e.g. ./mylib) or an ALZ Library reference of "+ + "the form @ (e.g. platform/alz@2026.01.3). When set together with "+ + "--architecture, creates a minimal cache containing only the definitions "+ + "referenced by the specified architecture.") createCmd.Flags(). StringP( "architecture", "a", "", @@ -235,4 +262,28 @@ func init() { "Path to an existing cache file to use as a seed (requires --library and --architecture). "+ "Definitions found in the seed cache are not re-fetched from Azure, reducing API calls. "+ "The same path may be used for both --from-cache and --output to update a cache in-place.") + createCmd.Flags(). + Bool( + "library-overwrite-enabled", false, + "Allow later libraries to overwrite definitions, policy assignments, role definitions, "+ + "archetypes and architectures already provided by earlier libraries. Useful when "+ + "layering a custom library on top of a base ALZ library. Requires --library and "+ + "--architecture.") +} + +// parseLibraryReference converts a CLI library argument into a LibraryReference. +// Values containing an "@" separator and not starting with a filesystem path +// indicator (".", "/" or "\") are treated as ALZ Library references of the form +// @. Everything else is treated as a local custom library path. +func parseLibraryReference(s string) alzlib.LibraryReference { + if idx := strings.LastIndex(s, "@"); idx > 0 { + path := s[:idx] + ref := s[idx+1:] + if ref != "" && !strings.HasPrefix(path, ".") && + !strings.HasPrefix(path, "/") && !strings.HasPrefix(path, `\`) { + return alzlib.NewAlzLibraryReference(path, ref) + } + } + + return alzlib.NewCustomLibraryReference(s) } diff --git a/cmd/alzlibtool/command/cache/create_test.go b/cmd/alzlibtool/command/cache/create_test.go new file mode 100644 index 0000000..ba4d46e --- /dev/null +++ b/cmd/alzlibtool/command/cache/create_test.go @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cache + +import ( + "testing" + + "github.com/Azure/alzlib" + "github.com/stretchr/testify/assert" +) + +func TestParseLibraryReference(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + wantAlz bool + wantPath string + wantRef string // only for ALZ refs + wantCustS string // only for custom refs (String()) + }{ + { + name: "alz reference", + input: "platform/alz@2026.01.3", + wantAlz: true, + wantPath: "platform/alz", + wantRef: "2026.01.3", + }, + { + name: "alz reference single segment", + input: "alz@2024.07.01", + wantAlz: true, + wantPath: "alz", + wantRef: "2024.07.01", + }, + { + name: "local relative path", + input: "./mylib", + wantAlz: false, + wantCustS: "./mylib", + }, + { + name: "local relative path with at sign", + input: "./mylib@dev", + wantAlz: false, + wantCustS: "./mylib@dev", + }, + { + name: "absolute path", + input: "/tmp/lib", + wantAlz: false, + wantCustS: "/tmp/lib", + }, + { + name: "windows-style path", + input: `\\share\lib`, + wantAlz: false, + wantCustS: `\\share\lib`, + }, + { + name: "no separator", + input: "platform/alz", + wantAlz: false, + wantCustS: "platform/alz", + }, + { + name: "trailing at sign with empty ref", + input: "platform/alz@", + wantAlz: false, + wantCustS: "platform/alz@", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := parseLibraryReference(tc.input) + if tc.wantAlz { + ref, ok := got.(*alzlib.AlzLibraryReference) + if assert.True(t, ok, "expected AlzLibraryReference, got %T", got) { + assert.Equal(t, tc.wantPath, ref.Path()) + assert.Equal(t, tc.wantRef, ref.Ref()) + } + } else { + _, ok := got.(*alzlib.CustomLibraryReference) + assert.True(t, ok, "expected CustomLibraryReference, got %T", got) + assert.Equal(t, tc.wantCustS, got.String()) + } + }) + } +} From 626b043cb2bdddac22bad02e4f8cf24ba14ed6ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:03:21 +0000 Subject: [PATCH 2/4] fix: also treat Windows drive-letter paths as local in parseLibraryReference Agent-Logs-Url: https://github.com/Azure/alzlib/sessions/edcee89c-eb65-4920-964a-6d9121e6e3c8 Co-authored-by: matt-FFFFFF <16320656+matt-FFFFFF@users.noreply.github.com> --- cmd/alzlibtool/command/cache/create.go | 25 +++++++++++++++++---- cmd/alzlibtool/command/cache/create_test.go | 12 ++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/cmd/alzlibtool/command/cache/create.go b/cmd/alzlibtool/command/cache/create.go index c02e9e0..e992181 100644 --- a/cmd/alzlibtool/command/cache/create.go +++ b/cmd/alzlibtool/command/cache/create.go @@ -273,17 +273,34 @@ func init() { // parseLibraryReference converts a CLI library argument into a LibraryReference. // Values containing an "@" separator and not starting with a filesystem path -// indicator (".", "/" or "\") are treated as ALZ Library references of the form -// @. Everything else is treated as a local custom library path. +// indicator (".", "/", "\" or a Windows drive letter such as "C:") are treated +// as ALZ Library references of the form @. Everything else is +// treated as a local custom library path. func parseLibraryReference(s string) alzlib.LibraryReference { if idx := strings.LastIndex(s, "@"); idx > 0 { path := s[:idx] ref := s[idx+1:] - if ref != "" && !strings.HasPrefix(path, ".") && - !strings.HasPrefix(path, "/") && !strings.HasPrefix(path, `\`) { + if ref != "" && !looksLikeLocalPath(path) { return alzlib.NewAlzLibraryReference(path, ref) } } return alzlib.NewCustomLibraryReference(s) } + +// looksLikeLocalPath reports whether p has a prefix that indicates a local +// filesystem path rather than an ALZ Library member path. +func looksLikeLocalPath(p string) bool { + if strings.HasPrefix(p, ".") || strings.HasPrefix(p, "/") || strings.HasPrefix(p, `\`) { + return true + } + // Windows drive letter, e.g. "C:" or "c:". + if len(p) >= 2 && p[1] == ':' { + c := p[0] + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') { + return true + } + } + + return false +} diff --git a/cmd/alzlibtool/command/cache/create_test.go b/cmd/alzlibtool/command/cache/create_test.go index ba4d46e..73eeb86 100644 --- a/cmd/alzlibtool/command/cache/create_test.go +++ b/cmd/alzlibtool/command/cache/create_test.go @@ -59,6 +59,18 @@ func TestParseLibraryReference(t *testing.T) { wantAlz: false, wantCustS: `\\share\lib`, }, + { + name: "windows drive path with at sign", + input: `C:\libs\mylib@dev`, + wantAlz: false, + wantCustS: `C:\libs\mylib@dev`, + }, + { + name: "windows drive path lowercase with at sign", + input: `d:/libs/mylib@dev`, + wantAlz: false, + wantCustS: `d:/libs/mylib@dev`, + }, { name: "no separator", input: "platform/alz", From c525cdb88966444ff65a81552f281a2d261b7e41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:19:57 +0000 Subject: [PATCH 3/4] style: fix wsl_v5 lint by separating decls from if in parseLibraryReference Agent-Logs-Url: https://github.com/Azure/alzlib/sessions/2d6ab067-3b9c-4968-a532-e07bb61d5e03 Co-authored-by: matt-FFFFFF <16320656+matt-FFFFFF@users.noreply.github.com> --- cmd/alzlibtool/command/cache/create.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/alzlibtool/command/cache/create.go b/cmd/alzlibtool/command/cache/create.go index e992181..9b8879f 100644 --- a/cmd/alzlibtool/command/cache/create.go +++ b/cmd/alzlibtool/command/cache/create.go @@ -280,6 +280,7 @@ func parseLibraryReference(s string) alzlib.LibraryReference { if idx := strings.LastIndex(s, "@"); idx > 0 { path := s[:idx] ref := s[idx+1:] + if ref != "" && !looksLikeLocalPath(path) { return alzlib.NewAlzLibraryReference(path, ref) } From e3a34a9d944f429eeb3f856a61749b99c3eebf8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:31:42 +0000 Subject: [PATCH 4/4] refactor(alzlib): add public NewLibraryReference constructors with detection Agent-Logs-Url: https://github.com/Azure/alzlib/sessions/6bceed06-ced3-40bc-8b0b-78635d478262 Co-authored-by: matt-FFFFFF <16320656+matt-FFFFFF@users.noreply.github.com> --- cmd/alzlibtool/command/cache/create.go | 38 +----- cmd/alzlibtool/command/cache/create_test.go | 106 --------------- metadata.go | 65 +++++++++ metadata_test.go | 140 ++++++++++++++++++++ 4 files changed, 206 insertions(+), 143 deletions(-) delete mode 100644 cmd/alzlibtool/command/cache/create_test.go diff --git a/cmd/alzlibtool/command/cache/create.go b/cmd/alzlibtool/command/cache/create.go index 9b8879f..eeb9f98 100644 --- a/cmd/alzlibtool/command/cache/create.go +++ b/cmd/alzlibtool/command/cache/create.go @@ -6,7 +6,6 @@ package cache import ( "log/slog" "os" - "strings" alzlib "github.com/Azure/alzlib" "github.com/Azure/alzlib/cache" @@ -131,7 +130,7 @@ to update a cache in-place.`, // only the built-in definitions that were actually referenced. refs := make(alzlib.LibraryReferences, 0, len(libraryRefs)) for _, r := range libraryRefs { - refs = append(refs, parseLibraryReference(r)) + refs = append(refs, alzlib.NewLibraryReference(r)) } allLibs, err := refs.FetchWithDependencies(cmd.Context()) @@ -270,38 +269,3 @@ func init() { "layering a custom library on top of a base ALZ library. Requires --library and "+ "--architecture.") } - -// parseLibraryReference converts a CLI library argument into a LibraryReference. -// Values containing an "@" separator and not starting with a filesystem path -// indicator (".", "/", "\" or a Windows drive letter such as "C:") are treated -// as ALZ Library references of the form @. Everything else is -// treated as a local custom library path. -func parseLibraryReference(s string) alzlib.LibraryReference { - if idx := strings.LastIndex(s, "@"); idx > 0 { - path := s[:idx] - ref := s[idx+1:] - - if ref != "" && !looksLikeLocalPath(path) { - return alzlib.NewAlzLibraryReference(path, ref) - } - } - - return alzlib.NewCustomLibraryReference(s) -} - -// looksLikeLocalPath reports whether p has a prefix that indicates a local -// filesystem path rather than an ALZ Library member path. -func looksLikeLocalPath(p string) bool { - if strings.HasPrefix(p, ".") || strings.HasPrefix(p, "/") || strings.HasPrefix(p, `\`) { - return true - } - // Windows drive letter, e.g. "C:" or "c:". - if len(p) >= 2 && p[1] == ':' { - c := p[0] - if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') { - return true - } - } - - return false -} diff --git a/cmd/alzlibtool/command/cache/create_test.go b/cmd/alzlibtool/command/cache/create_test.go deleted file mode 100644 index 73eeb86..0000000 --- a/cmd/alzlibtool/command/cache/create_test.go +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cache - -import ( - "testing" - - "github.com/Azure/alzlib" - "github.com/stretchr/testify/assert" -) - -func TestParseLibraryReference(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - input string - wantAlz bool - wantPath string - wantRef string // only for ALZ refs - wantCustS string // only for custom refs (String()) - }{ - { - name: "alz reference", - input: "platform/alz@2026.01.3", - wantAlz: true, - wantPath: "platform/alz", - wantRef: "2026.01.3", - }, - { - name: "alz reference single segment", - input: "alz@2024.07.01", - wantAlz: true, - wantPath: "alz", - wantRef: "2024.07.01", - }, - { - name: "local relative path", - input: "./mylib", - wantAlz: false, - wantCustS: "./mylib", - }, - { - name: "local relative path with at sign", - input: "./mylib@dev", - wantAlz: false, - wantCustS: "./mylib@dev", - }, - { - name: "absolute path", - input: "/tmp/lib", - wantAlz: false, - wantCustS: "/tmp/lib", - }, - { - name: "windows-style path", - input: `\\share\lib`, - wantAlz: false, - wantCustS: `\\share\lib`, - }, - { - name: "windows drive path with at sign", - input: `C:\libs\mylib@dev`, - wantAlz: false, - wantCustS: `C:\libs\mylib@dev`, - }, - { - name: "windows drive path lowercase with at sign", - input: `d:/libs/mylib@dev`, - wantAlz: false, - wantCustS: `d:/libs/mylib@dev`, - }, - { - name: "no separator", - input: "platform/alz", - wantAlz: false, - wantCustS: "platform/alz", - }, - { - name: "trailing at sign with empty ref", - input: "platform/alz@", - wantAlz: false, - wantCustS: "platform/alz@", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - got := parseLibraryReference(tc.input) - if tc.wantAlz { - ref, ok := got.(*alzlib.AlzLibraryReference) - if assert.True(t, ok, "expected AlzLibraryReference, got %T", got) { - assert.Equal(t, tc.wantPath, ref.Path()) - assert.Equal(t, tc.wantRef, ref.Ref()) - } - } else { - _, ok := got.(*alzlib.CustomLibraryReference) - assert.True(t, ok, "expected CustomLibraryReference, got %T", got) - assert.Equal(t, tc.wantCustS, got.String()) - } - }) - } -} diff --git a/metadata.go b/metadata.go index df6694a..38deb01 100644 --- a/metadata.go +++ b/metadata.go @@ -97,6 +97,30 @@ func NewAlzLibraryReference(path, ref string) *AlzLibraryReference { } } +// NewAlzLibraryReferenceFromString creates a new AlzLibraryReference by parsing +// a string in the form "@" (the inverse of AlzLibraryReference.String()). +// Returns an error if s does not contain an "@" separator, has an empty path or +// has an empty ref. +func NewAlzLibraryReferenceFromString(s string) (*AlzLibraryReference, error) { + idx := strings.LastIndex(s, "@") + if idx <= 0 { + return nil, fmt.Errorf( + "NewAlzLibraryReferenceFromString: %q is not in the form @", s, + ) + } + + path := s[:idx] + ref := s[idx+1:] + + if ref == "" { + return nil, fmt.Errorf( + "NewAlzLibraryReferenceFromString: %q has an empty ref", s, + ) + } + + return NewAlzLibraryReference(path, ref), nil +} + // NewAlzLibraryReferenceFromFS creates a new AlzLibraryReference with the given path, ref and filesystem. func NewAlzLibraryReferenceFromFS(path, ref string, filesystem fs.FS) *AlzLibraryReference { return &AlzLibraryReference{ @@ -173,6 +197,14 @@ func NewCustomLibraryReference(url string) *CustomLibraryReference { } } +// NewCustomLibraryReferenceFromString creates a new CustomLibraryReference from +// a go-getter URL or local path string (the inverse of CustomLibraryReference.String()). +// It is equivalent to NewCustomLibraryReference and is provided for symmetry with +// NewAlzLibraryReferenceFromString. +func NewCustomLibraryReferenceFromString(s string) *CustomLibraryReference { + return NewCustomLibraryReference(s) +} + // NewCustomLibraryReferenceFromFS creates a new CustomLibraryReference with the given URL and filesystem. func NewCustomLibraryReferenceFromFS(url string, filesystem fs.FS) *CustomLibraryReference { return &CustomLibraryReference{ @@ -288,3 +320,36 @@ func (m *Metadata) IsAlzLibraryRef() bool { func (m *Metadata) Ref() LibraryReference { return m.ref } + +// NewLibraryReference is a universal constructor that creates a LibraryReference +// from a string by detecting its form. Values that contain an "@" separator and +// do not look like a local filesystem path (i.e. do not start with ".", "/", "\" +// or a Windows drive letter such as "C:") are treated as ALZ Library references +// of the form "@" and an *AlzLibraryReference is returned. Everything +// else is returned as a *CustomLibraryReference. +func NewLibraryReference(s string) LibraryReference { + if !looksLikeLocalPath(s) { + if ref, err := NewAlzLibraryReferenceFromString(s); err == nil { + return ref + } + } + + return NewCustomLibraryReference(s) +} + +// looksLikeLocalPath reports whether p has a prefix that indicates a local +// filesystem path rather than an ALZ Library member path. +func looksLikeLocalPath(p string) bool { + if strings.HasPrefix(p, ".") || strings.HasPrefix(p, "/") || strings.HasPrefix(p, `\`) { + return true + } + // Windows drive letter, e.g. "C:" or "c:". + if len(p) >= 2 && p[1] == ':' { + c := p[0] + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') { + return true + } + } + + return false +} diff --git a/metadata_test.go b/metadata_test.go index d5a029a..16c56a7 100644 --- a/metadata_test.go +++ b/metadata_test.go @@ -97,3 +97,143 @@ func TestNewCustomLibraryReferenceFromFS(t *testing.T) { require.NoError(t, err) assert.Equal(t, fs.FS(memfs), got) } + +// TestNewAlzLibraryReferenceFromString verifies parsing of "@" strings. +func TestNewAlzLibraryReferenceFromString(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + wantErr bool + wantPath string + wantRef string + }{ + {name: "valid", input: "platform/alz@2026.01.3", wantPath: "platform/alz", wantRef: "2026.01.3"}, + {name: "single segment path", input: "alz@2024.07.01", wantPath: "alz", wantRef: "2024.07.01"}, + {name: "no separator", input: "platform/alz", wantErr: true}, + {name: "empty ref", input: "platform/alz@", wantErr: true}, + {name: "empty path", input: "@2026.01.3", wantErr: true}, + {name: "empty string", input: "", wantErr: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ref, err := NewAlzLibraryReferenceFromString(tc.input) + if tc.wantErr { + require.Error(t, err) + assert.Nil(t, ref) + + return + } + + require.NoError(t, err) + require.NotNil(t, ref) + assert.Equal(t, tc.wantPath, ref.Path()) + assert.Equal(t, tc.wantRef, ref.Ref()) + // Round-trip via String(). + assert.Equal(t, tc.input, ref.String()) + }) + } +} + +// TestNewLibraryReference verifies the universal constructor's detection of +// ALZ Library references vs. custom (URL or local path) references. +func TestNewLibraryReference(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + wantAlz bool + wantPath string + wantRef string // only for ALZ refs + wantCustS string // only for custom refs (String()) + }{ + { + name: "alz reference", + input: "platform/alz@2026.01.3", + wantAlz: true, + wantPath: "platform/alz", + wantRef: "2026.01.3", + }, + { + name: "alz reference single segment", + input: "alz@2024.07.01", + wantAlz: true, + wantPath: "alz", + wantRef: "2024.07.01", + }, + { + name: "local relative path", + input: "./mylib", + wantAlz: false, + wantCustS: "./mylib", + }, + { + name: "local relative path with at sign", + input: "./mylib@dev", + wantAlz: false, + wantCustS: "./mylib@dev", + }, + { + name: "absolute path", + input: "/tmp/lib", + wantAlz: false, + wantCustS: "/tmp/lib", + }, + { + name: "windows-style path", + input: `\\share\lib`, + wantAlz: false, + wantCustS: `\\share\lib`, + }, + { + name: "windows drive path with at sign", + input: `C:\libs\mylib@dev`, + wantAlz: false, + wantCustS: `C:\libs\mylib@dev`, + }, + { + name: "windows drive path lowercase with at sign", + input: `d:/libs/mylib@dev`, + wantAlz: false, + wantCustS: `d:/libs/mylib@dev`, + }, + { + name: "no separator", + input: "platform/alz", + wantAlz: false, + wantCustS: "platform/alz", + }, + { + name: "trailing at sign with empty ref", + input: "platform/alz@", + wantAlz: false, + wantCustS: "platform/alz@", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := NewLibraryReference(tc.input) + if tc.wantAlz { + ref, ok := got.(*AlzLibraryReference) + if assert.True(t, ok, "expected *AlzLibraryReference, got %T", got) { + assert.Equal(t, tc.wantPath, ref.Path()) + assert.Equal(t, tc.wantRef, ref.Ref()) + } + + return + } + + _, ok := got.(*CustomLibraryReference) + assert.True(t, ok, "expected *CustomLibraryReference, got %T", got) + assert.Equal(t, tc.wantCustS, got.String()) + }) + } +}