Skip to content

Commit de21d85

Browse files
authored
feat(internal/librarian): add tidy command (#3103)
The librarian tidy command is added, which validates and formats the librarian.yaml file. It checks for duplicate library names and channel paths, then sorts libraries by name, channels by path, and Rust package dependencies by name. Reorder fields in `config.RustPackageDependency` so that when librarian tidy runs, `Name` is the first field.
1 parent e9597f1 commit de21d85

File tree

4 files changed

+269
-7
lines changed

4 files changed

+269
-7
lines changed

internal/config/language.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,6 @@ type RustCrate struct {
9999

100100
// RustPackageDependency represents a package dependency configuration.
101101
type RustPackageDependency struct {
102-
// Feature is the feature name for the dependency.
103-
Feature string `yaml:"feature,omitempty"`
104-
105-
// ForceUsed forces the dependency to be used even if not referenced.
106-
ForceUsed bool `yaml:"force_used,omitempty"`
107-
108102
// Name is the dependency name.
109103
Name string `yaml:"name"`
110104

@@ -114,6 +108,12 @@ type RustPackageDependency struct {
114108
// Source is the dependency source.
115109
Source string `yaml:"source,omitempty"`
116110

111+
// Feature is the feature name for the dependency.
112+
Feature string `yaml:"feature,omitempty"`
113+
114+
// ForceUsed forces the dependency to be used even if not referenced.
115+
ForceUsed bool `yaml:"force_used,omitempty"`
116+
117117
// UsedIf specifies a condition for when the dependency is used.
118118
UsedIf string `yaml:"used_if,omitempty"`
119119
}

internal/librarian/librarian.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ func Run(ctx context.Context, args ...string) error {
3333
UsageText: "librarian [command]",
3434
Version: Version(),
3535
Commands: []*cli.Command{
36-
versionCommand(),
3736
generateCommand(),
3837
releaseCommand(),
38+
tidyCommand(),
39+
versionCommand(),
3940
},
4041
}
4142
return cmd.Run(ctx, args)

internal/librarian/tidy.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package librarian
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
"slices"
22+
"strings"
23+
24+
"github.com/googleapis/librarian/internal/config"
25+
"github.com/googleapis/librarian/internal/yaml"
26+
"github.com/urfave/cli/v3"
27+
)
28+
29+
var (
30+
errDuplicateLibraryName = errors.New("duplicate library name")
31+
errDuplicateChannelPath = errors.New("duplicate channel path")
32+
)
33+
34+
func tidyCommand() *cli.Command {
35+
return &cli.Command{
36+
Name: "tidy",
37+
Usage: "format and validate librarian.yaml",
38+
UsageText: "librarian tidy [path]",
39+
Action: func(ctx context.Context, cmd *cli.Command) error {
40+
return runTidy()
41+
},
42+
}
43+
}
44+
45+
func runTidy() error {
46+
cfg, err := yaml.Read[config.Config](librarianConfigPath)
47+
if err != nil {
48+
return err
49+
}
50+
if err := validateLibraries(cfg); err != nil {
51+
return err
52+
}
53+
return yaml.Write(librarianConfigPath, formatConfig(cfg))
54+
}
55+
56+
func validateLibraries(cfg *config.Config) error {
57+
var (
58+
errs []error
59+
nameCount = make(map[string]int)
60+
pathCount = make(map[string]int)
61+
)
62+
for _, lib := range cfg.Libraries {
63+
if lib.Name != "" {
64+
nameCount[lib.Name]++
65+
}
66+
for _, ch := range lib.Channels {
67+
if ch.Path != "" {
68+
pathCount[ch.Path]++
69+
}
70+
}
71+
}
72+
for name, count := range nameCount {
73+
if count > 1 {
74+
errs = append(errs, fmt.Errorf("%w: %s (appears %d times)", errDuplicateLibraryName, name, count))
75+
}
76+
}
77+
for path, count := range pathCount {
78+
if count > 1 {
79+
errs = append(errs, fmt.Errorf("%w: %s (appears %d times)", errDuplicateChannelPath, path, count))
80+
}
81+
}
82+
if len(errs) > 0 {
83+
return errors.Join(errs...)
84+
}
85+
return nil
86+
}
87+
88+
func formatConfig(cfg *config.Config) *config.Config {
89+
if cfg.Default != nil && cfg.Default.Rust != nil {
90+
slices.SortFunc(cfg.Default.Rust.PackageDependencies, func(a, b *config.RustPackageDependency) int {
91+
return strings.Compare(a.Name, b.Name)
92+
})
93+
}
94+
95+
slices.SortFunc(cfg.Libraries, func(a, b *config.Library) int {
96+
return strings.Compare(a.Name, b.Name)
97+
})
98+
for _, lib := range cfg.Libraries {
99+
slices.SortFunc(lib.Channels, func(a, b *config.Channel) int {
100+
return strings.Compare(a.Path, b.Path)
101+
})
102+
if lib.Rust != nil {
103+
slices.SortFunc(lib.Rust.PackageDependencies, func(a, b *config.RustPackageDependency) int {
104+
return strings.Compare(a.Name, b.Name)
105+
})
106+
}
107+
}
108+
return cfg
109+
}

internal/librarian/tidy_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package librarian
16+
17+
import (
18+
"errors"
19+
"os"
20+
"path/filepath"
21+
"testing"
22+
23+
"github.com/google/go-cmp/cmp"
24+
"github.com/googleapis/librarian/internal/config"
25+
"github.com/googleapis/librarian/internal/yaml"
26+
)
27+
28+
func TestValidateLibraries(t *testing.T) {
29+
for _, test := range []struct {
30+
name string
31+
libraries []*config.Library
32+
wantErr error
33+
}{
34+
{
35+
name: "valid libraries",
36+
libraries: []*config.Library{
37+
{Name: "google-cloud-secretmanager-v1"},
38+
{Name: "google-cloud-storage-v1"},
39+
},
40+
},
41+
{
42+
name: "duplicate library names",
43+
libraries: []*config.Library{
44+
{Name: "google-cloud-secretmanager-v1"},
45+
{Name: "google-cloud-secretmanager-v1"},
46+
},
47+
wantErr: errDuplicateLibraryName,
48+
},
49+
} {
50+
t.Run(test.name, func(t *testing.T) {
51+
cfg := &config.Config{Libraries: test.libraries}
52+
err := validateLibraries(cfg)
53+
if test.wantErr == nil {
54+
if err != nil {
55+
t.Fatal(err)
56+
}
57+
return
58+
}
59+
if err == nil {
60+
t.Fatalf("expected %v, got nil", test.wantErr)
61+
}
62+
if !errors.Is(err, test.wantErr) {
63+
t.Errorf("expected %v, got %v", test.wantErr, err)
64+
}
65+
})
66+
}
67+
}
68+
69+
func TestFormatConfig(t *testing.T) {
70+
cfg := formatConfig(&config.Config{
71+
Libraries: []*config.Library{
72+
{Name: "google-cloud-storage-v1", Version: "1.0.0"},
73+
{Name: "google-cloud-bigquery-v1", Version: "2.0.0"},
74+
{Name: "google-cloud-secretmanager-v1", Version: "3.0.0"},
75+
},
76+
})
77+
want := []string{
78+
"google-cloud-bigquery-v1",
79+
"google-cloud-secretmanager-v1",
80+
"google-cloud-storage-v1",
81+
}
82+
var got []string
83+
for _, lib := range cfg.Libraries {
84+
got = append(got, lib.Name)
85+
}
86+
if diff := cmp.Diff(want, got); diff != "" {
87+
t.Errorf("mismatch (-want +got):\n%s", diff)
88+
}
89+
}
90+
91+
func TestTidyCommand(t *testing.T) {
92+
tempDir := t.TempDir()
93+
t.Chdir(tempDir)
94+
configPath := filepath.Join(tempDir, librarianConfigPath)
95+
configContent := `language: rust
96+
sources:
97+
googleapis:
98+
commit: abc123
99+
libraries:
100+
- name: google-cloud-storage-v1
101+
version: "1.0.0"
102+
- name: google-cloud-bigquery-v1
103+
version: "2.0.0"
104+
`
105+
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
106+
t.Fatal(err)
107+
}
108+
if err := Run(t.Context(), "librarian", "tidy"); err != nil {
109+
t.Fatal(err)
110+
}
111+
112+
cfg, err := yaml.Read[config.Config](configPath)
113+
if err != nil {
114+
t.Fatal(err)
115+
}
116+
117+
var got []string
118+
for _, lib := range cfg.Libraries {
119+
got = append(got, lib.Name)
120+
}
121+
want := []string{
122+
"google-cloud-bigquery-v1",
123+
"google-cloud-storage-v1",
124+
}
125+
if diff := cmp.Diff(want, got); diff != "" {
126+
t.Errorf("mismatch (-want +got):\n%s", diff)
127+
}
128+
}
129+
130+
func TestTidyCommandDuplicateError(t *testing.T) {
131+
tempDir := t.TempDir()
132+
t.Chdir(tempDir)
133+
configPath := filepath.Join(tempDir, librarianConfigPath)
134+
configContent := `language: rust
135+
sources:
136+
googleapis:
137+
commit: abc123
138+
libraries:
139+
- name: google-cloud-storage-v1
140+
- name: google-cloud-storage-v1
141+
`
142+
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
143+
t.Fatal(err)
144+
}
145+
err := Run(t.Context(), "librarian", "tidy")
146+
if err == nil {
147+
t.Fatal("expected error for duplicate library")
148+
}
149+
if !errors.Is(err, errDuplicateLibraryName) {
150+
t.Errorf("expected %v, got %v", errDuplicateLibraryName, err)
151+
}
152+
}

0 commit comments

Comments
 (0)