diff --git a/cli/azd/internal/repository/app_init.go b/cli/azd/internal/repository/app_init.go index 7837a40c1c1..b145ebb9a44 100644 --- a/cli/azd/internal/repository/app_init.go +++ b/cli/azd/internal/repository/app_init.go @@ -345,7 +345,7 @@ func prjConfigFromDetect( root string, detect detectConfirm) (project.ProjectConfig, error) { config := project.ProjectConfig{ - Name: filepath.Base(root), + Name: LabelName(filepath.Base(root)), Metadata: &project.ProjectMetadata{ Template: fmt.Sprintf("%s@%s", InitGenTemplateId, internal.VersionInfo().Version), }, @@ -410,6 +410,7 @@ func prjConfigFromDetect( if name == "." { name = config.Name } + name = LabelName(name) config.Services[name] = &svc } diff --git a/cli/azd/internal/repository/infra_confirm.go b/cli/azd/internal/repository/infra_confirm.go index 57488b077dc..2f4a98b7408 100644 --- a/cli/azd/internal/repository/infra_confirm.go +++ b/cli/azd/internal/repository/infra_confirm.go @@ -94,7 +94,7 @@ func (i *Initializer) infraSpecFromDetect( } for _, svc := range detect.Services { - name := filepath.Base(svc.Path) + name := LabelName(filepath.Base(svc.Path)) serviceSpec := scaffold.ServiceSpec{ Name: name, Port: -1, diff --git a/cli/azd/internal/repository/util.go b/cli/azd/internal/repository/util.go new file mode 100644 index 00000000000..3e5f563646a --- /dev/null +++ b/cli/azd/internal/repository/util.go @@ -0,0 +1,106 @@ +package repository + +import "strings" + +//cspell:disable + +// LabelName cleans up a string to be used as a RFC 1123 Label name. +// It does not enforce the 63 character limit. +// +// RFC 1123 Label name: +// - contain only lowercase alphanumeric characters or '-' +// - start with an alphanumeric character +// - end with an alphanumeric character +// +// Examples: +// - myproject, MYPROJECT -> myproject +// - myProject, myProjecT, MyProject, MyProjecT -> my-project +// - my.project, My.Project, my-project, My-Project -> my-project +func LabelName(name string) string { + hasSeparator, n := cleanAlphaNumeric(name) + if hasSeparator { + return labelNameFromSeparators(n) + } + + return labelNameFromCasing(name) +} + +//cspell:enable + +// cleanAlphaNumeric removes non-alphanumeric characters from the name. +// +// It also returns whether the name uses word separators. +func cleanAlphaNumeric(name string) (hasSeparator bool, cleaned string) { + sb := strings.Builder{} + hasSeparator = false + for _, c := range name { + if isAsciiAlphaNumeric(c) { + sb.WriteRune(c) + } else if isSeparator(c) { + hasSeparator = true + sb.WriteRune(c) + } + } + + return hasSeparator, sb.String() +} + +func isAsciiAlphaNumeric(r rune) bool { + return ('0' <= r && r <= '9') || ('A' <= r && r <= 'Z') || ('a' <= r && r <= 'z') +} + +func isSeparator(r rune) bool { + return r == '-' || r == '_' || r == '.' +} + +func lowerCase(r rune) rune { + if 'A' <= r && r <= 'Z' { + r += 'a' - 'A' + } + return r +} + +// Converts camel-cased or Pascal-cased names into lower-cased dash-separated names. +// Example: MyProject, myProject -> my-project +func labelNameFromCasing(name string) string { + result := strings.Builder{} + // previously seen upper-case character + prevUpperCase := -2 // -2 to avoid matching the first character + + for i, c := range name { + if 'A' <= c && c <= 'Z' { + if prevUpperCase == i-1 { // handle runs of upper-case word + prevUpperCase = i + result.WriteRune(lowerCase(c)) + continue + } + + if i > 0 && i != len(name)-1 { + result.WriteRune('-') + } + + prevUpperCase = i + } + + if isAsciiAlphaNumeric(c) { + result.WriteRune(lowerCase(c)) + } + } + + return result.String() +} + +// Converts all word-separated names into lower-cased dash-separated names. +// Examples: my.project, my_project, My-Project -> my-project +func labelNameFromSeparators(name string) string { + result := strings.Builder{} + for i, c := range name { + if isAsciiAlphaNumeric(c) { + result.WriteRune(lowerCase(c)) + } else if i > 0 && i != len(name)-1 && isSeparator(c) { + result.WriteRune('-') + } + } + + return result.String() +} diff --git a/cli/azd/internal/repository/util_test.go b/cli/azd/internal/repository/util_test.go new file mode 100644 index 00000000000..56a2c467756 --- /dev/null +++ b/cli/azd/internal/repository/util_test.go @@ -0,0 +1,67 @@ +package repository + +import ( + "testing" +) + +//cspell:disable + +func TestLabelName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"Lowercase", "myproject", "myproject"}, + {"Uppercase", "MYPROJECT", "myproject"}, + {"MixedCase", "myProject", "my-project"}, + {"MixedCaseEnd", "myProjecT", "my-project"}, + {"TitleCase", "MyProject", "my-project"}, + {"TitleCaseEnd", "MyProjecT", "my-project"}, + {"WithDot", "my.project", "my-project"}, + {"WithDotTitleCase", "My.Project", "my-project"}, + {"WithHyphen", "my-project", "my-project"}, + {"WithHyphenTitleCase", "My-Project", "my-project"}, + {"StartWithNumber", "1myproject", "1myproject"}, + {"EndWithNumber", "myproject2", "myproject2"}, + {"MixedWithNumbers", "my2Project3", "my2-project3"}, + {"SpecialCharacters", "my_project!@#", "my-project"}, + {"EmptyString", "", ""}, + {"OnlySpecialCharacters", "@#$%^&*", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := LabelName(tt.input) + if result != tt.expected { + t.Errorf("LabelName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestLabelNameEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"SingleCharacter", "A", "a"}, + {"TwoCharacters", "Ab", "ab"}, + {"StartEndHyphens", "-abc-", "abc"}, + {"LongString", + "ThisIsOneVeryLongStringThatExceedsTheSixtyThreeCharacterLimitForRFC1123LabelNames", + "this-is-one-very-long-string-that-exceeds-the-sixty-three-character-limit-for-rfc1123-label-names"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := LabelName(tt.input) + if result != tt.expected { + t.Errorf("LabelName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +//cspell:enable