Skip to content

Commit 46a0d29

Browse files
authored
feat: add create command to librarian (#3229)
This introduces the placeholder create command with all input args, that checks to see if a library already exists, if so, invoke generate command, if not it will invoke rust specific create logic (not implemented). This also introduces an interface for the generate command that can be mocked out for testing purposes. For #3072
1 parent e7bf98e commit 46a0d29

File tree

4 files changed

+334
-0
lines changed

4 files changed

+334
-0
lines changed

internal/librarian/create.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
"log/slog"
22+
23+
"github.com/googleapis/librarian/internal/config"
24+
"github.com/googleapis/librarian/internal/librarian/internal/rust"
25+
"github.com/googleapis/librarian/internal/yaml"
26+
"github.com/urfave/cli/v3"
27+
)
28+
29+
var (
30+
errUnsupportedLanguage = errors.New("library creation is not supported for the specified language")
31+
errOutputFlagRequired = errors.New("output flag is required when default.output is not set in librarian.yaml")
32+
errServiceConfigOrSpecRequired = errors.New("both service-config and specification-source flags are required for creating a new library")
33+
errMissingNameFlag = errors.New("name flag is required to create a new library")
34+
errNoYaml = errors.New("unable to read librarian.yaml")
35+
)
36+
37+
func createCommand() *cli.Command {
38+
return &cli.Command{
39+
Name: "create",
40+
Usage: "create a new client library",
41+
UsageText: "librarian create --name <name> --specification-source <path> --service-config <path>",
42+
Flags: []cli.Flag{
43+
&cli.StringFlag{
44+
Name: "name",
45+
Usage: "library name",
46+
},
47+
&cli.StringFlag{
48+
Name: "specification-source",
49+
Usage: "path to the specification source (e.g., google/cloud/secretmanager/v1)",
50+
},
51+
&cli.StringFlag{
52+
Name: "service-config",
53+
Usage: "path to the service config",
54+
},
55+
&cli.StringFlag{
56+
Name: "output",
57+
Usage: "output directory (optional, will be derived if not provided)",
58+
},
59+
&cli.StringFlag{
60+
Name: "specification-format",
61+
Usage: "specification format (e.g., protobuf, discovery)",
62+
Value: "protobuf",
63+
},
64+
},
65+
Action: func(ctx context.Context, c *cli.Command) error {
66+
name := c.String("name")
67+
specSource := c.String("specification-source")
68+
serviceConfig := c.String("service-config")
69+
output := c.String("output")
70+
specFormat := c.String("specification-format")
71+
if name == "" {
72+
return errMissingNameFlag
73+
}
74+
return runCreate(ctx, name, specSource, serviceConfig, output, specFormat)
75+
},
76+
}
77+
}
78+
79+
func runCreate(ctx context.Context, name, specSource, serviceConfig, output, specFormat string) error {
80+
return runCreateWithGenerator(ctx, name, specSource, serviceConfig, output, specFormat, &Generate{})
81+
}
82+
83+
func runCreateWithGenerator(ctx context.Context, name, specSource, serviceConfig, output, specFormat string, gen Generator) error {
84+
cfg, err := yaml.Read[config.Config](librarianConfigPath)
85+
if err != nil {
86+
return fmt.Errorf("%w: %v", errNoYaml, err)
87+
}
88+
switch cfg.Language {
89+
case "rust":
90+
for _, lib := range cfg.Libraries {
91+
if lib.Name == name {
92+
return gen.Run(ctx, false, name)
93+
}
94+
}
95+
96+
// if we add support for creating veneers this check should be ignored
97+
if serviceConfig == "" && specSource == "" {
98+
return errServiceConfigOrSpecRequired
99+
}
100+
101+
if output == "" {
102+
if cfg.Default == nil || cfg.Default.Output == "" {
103+
return errOutputFlagRequired
104+
}
105+
output = rust.DefaultOutput(specSource, cfg.Default.Output)
106+
}
107+
108+
// TODO: port over sidekick rustGenerate logic to create a new librarian
109+
slog.InfoContext(ctx, "Creating new Rust library", "name", name, "specSource", specSource, "serviceConfig", serviceConfig, "output", output, "specFormat", specFormat)
110+
return nil
111+
default:
112+
return errUnsupportedLanguage
113+
}
114+
115+
}

internal/librarian/create_test.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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+
"os"
21+
"path/filepath"
22+
"testing"
23+
24+
"github.com/googleapis/librarian/internal/config"
25+
"gopkg.in/yaml.v3"
26+
)
27+
28+
const (
29+
libExists = "library-one"
30+
libExistsOutput = "output1"
31+
newLib = "library-two"
32+
newLibOutput = "output2"
33+
newLibSpec = "google/cloud/storage/v1"
34+
newLibSC = "google/cloud/storage/v1/storage_v1.yaml"
35+
)
36+
37+
type mockGenerator struct {
38+
called bool
39+
callArgs struct {
40+
all bool
41+
libraryName string
42+
}
43+
}
44+
45+
func (m *mockGenerator) Run(ctx context.Context, all bool, libraryName string) error {
46+
m.called = true
47+
m.callArgs.all = all
48+
m.callArgs.libraryName = libraryName
49+
return nil
50+
}
51+
52+
func TestCreateForExistingLib(t *testing.T) {
53+
54+
for _, test := range []struct {
55+
name string
56+
libName string
57+
output string
58+
language string
59+
}{
60+
{
61+
name: "run create for existing library",
62+
libName: libExists,
63+
output: libExistsOutput,
64+
language: "rust",
65+
},
66+
} {
67+
t.Run(test.name, func(t *testing.T) {
68+
69+
createLibrarianYaml(t, test.libName, test.output, test.language, "")
70+
var gen Generator = &mockGenerator{}
71+
72+
err := runCreateWithGenerator(context.Background(), test.libName, "", "", test.output, "protobuf", gen)
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
77+
mock := gen.(*mockGenerator)
78+
if !mock.called {
79+
t.Error("expected mockGenerator.Run to be called")
80+
}
81+
if mock.callArgs.libraryName != test.libName {
82+
t.Errorf("expected libraryName %s, got %s", test.libName, mock.callArgs.libraryName)
83+
}
84+
85+
})
86+
}
87+
88+
}
89+
90+
func TestCreateCommand(t *testing.T) {
91+
92+
for _, test := range []struct {
93+
name string
94+
args []string
95+
language string
96+
skipCreatingYaml bool
97+
wantErr error
98+
defaultOutput string
99+
libOutputFolder string
100+
}{
101+
{
102+
name: "no args",
103+
args: []string{"librarian", "create"},
104+
wantErr: errMissingNameFlag,
105+
},
106+
{
107+
name: "missing service-config",
108+
args: []string{"librarian", "create", "--name", newLib, "--output", newLibOutput, "--specification-source", newLibSpec},
109+
language: "rust",
110+
},
111+
{
112+
name: "missing specification-source",
113+
args: []string{"librarian", "create", "--name", newLib, "--output", newLibOutput, "--service-config", newLibSC},
114+
language: "rust",
115+
},
116+
{
117+
name: "missing specification-source and service-config",
118+
args: []string{"librarian", "create", "--name", newLib, "--output", newLibOutput},
119+
language: "rust",
120+
wantErr: errServiceConfigOrSpecRequired,
121+
},
122+
{
123+
name: "create new library",
124+
args: []string{"librarian", "create", "--name", newLib, "--output", newLibOutput, "--service-config", newLibSC, "--specification-source", newLibSpec},
125+
language: "rust",
126+
},
127+
{
128+
name: "no yaml",
129+
args: []string{"librarian", "create", "--name", newLib},
130+
skipCreatingYaml: true,
131+
wantErr: errNoYaml,
132+
},
133+
{
134+
name: "unsupported language",
135+
args: []string{"librarian", "create", "--name", newLib},
136+
language: "unsupported-lang",
137+
wantErr: errUnsupportedLanguage,
138+
},
139+
{
140+
name: "output flag required",
141+
args: []string{"librarian", "create", "--name", newLib, "--service-config", newLibSC, "--specification-source", newLibSpec},
142+
language: "rust",
143+
wantErr: errOutputFlagRequired,
144+
},
145+
{
146+
name: "default output directory used",
147+
args: []string{"librarian", "create", "--name", newLib, "--service-config", newLibSC, "--specification-source", newLibSpec},
148+
language: "rust",
149+
defaultOutput: "default",
150+
},
151+
} {
152+
t.Run(test.name, func(t *testing.T) {
153+
if !test.skipCreatingYaml {
154+
createLibrarianYaml(t, libExists, libExistsOutput, test.language, test.defaultOutput)
155+
}
156+
err := Run(t.Context(), test.args...)
157+
if test.wantErr != nil {
158+
if !errors.Is(err, test.wantErr) {
159+
t.Errorf("want error %v, got %v", test.wantErr, err)
160+
}
161+
return
162+
}
163+
if err != nil {
164+
t.Fatal(err)
165+
}
166+
})
167+
}
168+
}
169+
170+
func createLibrarianYaml(t *testing.T, libName string, libOutput string, language string, defaultOutput string) {
171+
tempDir := t.TempDir()
172+
t.Chdir(tempDir)
173+
configPath := filepath.Join(tempDir, librarianConfigPath)
174+
config := config.Config{
175+
Language: language,
176+
Sources: &config.Sources{
177+
Googleapis: &config.Source{
178+
Dir: "/googleapis/testdata",
179+
},
180+
},
181+
Default: &config.Default{
182+
Output: defaultOutput,
183+
},
184+
Libraries: []*config.Library{
185+
{
186+
Name: libName,
187+
Output: libOutput,
188+
},
189+
},
190+
}
191+
192+
configBytes, err := yaml.Marshal(&config)
193+
if err != nil {
194+
t.Fatalf("Failed to marshal YAML: %v", err)
195+
}
196+
197+
if err := os.WriteFile(configPath, configBytes, 0644); err != nil {
198+
t.Fatal(err)
199+
}
200+
201+
if err := os.MkdirAll(filepath.Join(tempDir, libOutput), 0755); err != nil {
202+
t.Fatal(err)
203+
}
204+
}

internal/librarian/generate.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ var (
4040
errEmptySources = errors.New("sources field is required in librarian.yaml: specify googleapis and/or discovery source commits")
4141
)
4242

43+
// Generator interface used for mocking in tests.
44+
type Generator interface {
45+
Run(ctx context.Context, all bool, libraryName string) error
46+
}
47+
48+
// Generate struct implements Generator interface.
49+
type Generate struct {
50+
}
51+
52+
// Run encaspulates runGenerate command on Genrator interface.
53+
func (g *Generate) Run(ctx context.Context, all bool, libraryName string) error {
54+
return runGenerate(ctx, all, libraryName)
55+
}
56+
4357
func generateCommand() *cli.Command {
4458
return &cli.Command{
4559
Name: "generate",

internal/librarian/librarian.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func Run(ctx context.Context, args ...string) error {
3333
UsageText: "librarian [command]",
3434
Version: Version(),
3535
Commands: []*cli.Command{
36+
createCommand(),
3637
generateCommand(),
3738
releaseCommand(),
3839
tidyCommand(),

0 commit comments

Comments
 (0)