Skip to content

Commit 14734c8

Browse files
authored
feat(librariangen): implement the build command (googleapis#12817)
* feat(librariangen): implement the build command This assumes that all modules have at least one test file (may not be true for things like orgpolicy which are just generated from protos, no GAPIC). We test with `-short` to only run unit tests, not integration tests. In the future, it *may* be worth just running `go test` without the build part first; it's unclear to me whether that could lead to confusion though. Thoughts welcome. * chore: fixes from code review (and more inspection)
1 parent 5dab547 commit 14734c8

File tree

5 files changed

+283
-6
lines changed

5 files changed

+283
-6
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
// http://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 build
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
"log/slog"
22+
"path/filepath"
23+
24+
"cloud.google.com/go/internal/postprocessor/librarian/librariangen/execv"
25+
"cloud.google.com/go/internal/postprocessor/librarian/librariangen/request"
26+
)
27+
28+
// Test substitution vars.
29+
var (
30+
execvRun = execv.Run
31+
requestParse = request.Parse
32+
)
33+
34+
// Config holds the internal librariangen configuration for the build command.
35+
type Config struct {
36+
// LibrarianDir is the path to the librarian-tool input directory.
37+
// It is expected to contain the build-request.json file.
38+
LibrarianDir string
39+
// RepoDir is the path to ehte entire language repository.
40+
RepoDir string
41+
}
42+
43+
// Validate ensures that the configuration is valid.
44+
func (c *Config) Validate() error {
45+
if c.LibrarianDir == "" {
46+
return errors.New("librariangen: librarian directory must be set")
47+
}
48+
if c.RepoDir == "" {
49+
return errors.New("librariangen: repo directory must be set")
50+
}
51+
return nil
52+
}
53+
54+
// Build is the main entrypoint for the `build` command. It runs `go build`
55+
// and then `go test`.
56+
func Build(ctx context.Context, cfg *Config) error {
57+
if err := cfg.Validate(); err != nil {
58+
return fmt.Errorf("librariangen: invalid configuration: %w", err)
59+
}
60+
slog.Debug("librariangen: generate command started")
61+
62+
buildReq, err := readBuildReq(cfg.LibrarianDir)
63+
if err != nil {
64+
return fmt.Errorf("librariangen: failed to read request: %w", err)
65+
}
66+
moduleDir := filepath.Join(cfg.RepoDir, buildReq.ID)
67+
if err := goBuild(ctx, moduleDir, buildReq.ID); err != nil {
68+
return fmt.Errorf("librariangen: failed to run 'go build': %w", err)
69+
}
70+
if err := goTest(ctx, moduleDir, buildReq.ID); err != nil {
71+
return fmt.Errorf("librariangen: failed to run 'go test': %w", err)
72+
}
73+
return nil
74+
}
75+
76+
// goBuild builds all the code under the specified directory
77+
func goBuild(ctx context.Context, dir, module string) error {
78+
slog.Info("librariangen: building", "module", module)
79+
args := []string{"go", "build", "./..."}
80+
return execvRun(ctx, args, dir)
81+
}
82+
83+
// goTest builds all the code under the specified directory
84+
func goTest(ctx context.Context, dir, module string) error {
85+
slog.Info("librariangen: testing", "module", module)
86+
args := []string{"go", "test", "./...", "-short"}
87+
return execvRun(ctx, args, dir)
88+
}
89+
90+
// readBuildReq reads generate-request.json from the librarian-tool input directory.
91+
// The request file tells librariangen which library and APIs to generate.
92+
// It is prepared by the Librarian tool and mounted at /librarian.
93+
func readBuildReq(librarianDir string) (*request.Request, error) {
94+
reqPath := filepath.Join(librarianDir, "build-request.json")
95+
slog.Debug("librariangen: reading build request", "path", reqPath)
96+
97+
buildReq, err := requestParse(reqPath)
98+
if err != nil {
99+
return nil, err
100+
}
101+
slog.Debug("librariangen: successfully unmarshalled request", "library_id", buildReq.ID)
102+
return buildReq, nil
103+
}
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+
// http://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 build
16+
17+
import (
18+
"context"
19+
"errors"
20+
"os"
21+
"path/filepath"
22+
"slices"
23+
"testing"
24+
)
25+
26+
// testEnv encapsulates a temporary test environment.
27+
type testEnv struct {
28+
tmpDir string
29+
librarianDir string
30+
repoDir string
31+
}
32+
33+
func TestBuild(t *testing.T) {
34+
singleAPIRequest := `{"id": "foo", "apis": [{"path": "api/v1"}]}`
35+
tests := []struct {
36+
name string
37+
setup func(e *testEnv, t *testing.T)
38+
buildErr error
39+
testErr error
40+
wantErr bool
41+
wantExecvCount int
42+
}{
43+
{
44+
name: "happy path",
45+
setup: func(e *testEnv, t *testing.T) {
46+
e.writeRequestFile(t, singleAPIRequest)
47+
},
48+
wantErr: false,
49+
wantExecvCount: 2,
50+
},
51+
{
52+
name: "missing request file",
53+
wantErr: true,
54+
},
55+
{
56+
name: "go build fails",
57+
setup: func(e *testEnv, t *testing.T) {
58+
e.writeRequestFile(t, singleAPIRequest)
59+
},
60+
buildErr: errors.New("build failed"),
61+
wantErr: true,
62+
wantExecvCount: 1,
63+
},
64+
{
65+
name: "go test fails",
66+
setup: func(e *testEnv, t *testing.T) {
67+
e.writeRequestFile(t, singleAPIRequest)
68+
},
69+
testErr: errors.New("test failed"),
70+
wantErr: true,
71+
wantExecvCount: 2,
72+
},
73+
}
74+
75+
for _, tt := range tests {
76+
t.Run(tt.name, func(t *testing.T) {
77+
e := newTestEnv(t)
78+
defer e.cleanup(t)
79+
80+
if tt.setup != nil {
81+
tt.setup(e, t)
82+
}
83+
84+
var execvCount int
85+
execvRun = func(ctx context.Context, args []string, dir string) error {
86+
execvCount++
87+
want := filepath.Join(e.repoDir, "foo")
88+
if dir != want {
89+
t.Errorf("execv called with wrong working directory %s; want %s", dir, want)
90+
}
91+
switch {
92+
case slices.Equal(args, []string{"go", "build", "./..."}):
93+
return tt.buildErr
94+
case slices.Equal(args, []string{"go", "test", "./...", "-short"}):
95+
return tt.testErr
96+
default:
97+
t.Errorf("execv called with unexpected args %v", args)
98+
return nil
99+
}
100+
}
101+
102+
cfg := &Config{
103+
LibrarianDir: e.librarianDir,
104+
RepoDir: e.repoDir,
105+
}
106+
107+
if err := Build(context.Background(), cfg); (err != nil) != tt.wantErr {
108+
t.Errorf("Build() error = %v, wantErr %v", err, tt.wantErr)
109+
}
110+
111+
if execvCount != tt.wantExecvCount {
112+
t.Errorf("execv called = %v; want %v", execvCount, tt.wantExecvCount)
113+
}
114+
})
115+
}
116+
}
117+
118+
// cleanup removes the temporary directory.
119+
func (e *testEnv) cleanup(t *testing.T) {
120+
t.Helper()
121+
if err := os.RemoveAll(e.tmpDir); err != nil {
122+
t.Fatalf("failed to remove temp dir: %v", err)
123+
}
124+
}
125+
126+
// writeRequestFile writes a builf-request.json file.
127+
func (e *testEnv) writeRequestFile(t *testing.T, content string) {
128+
t.Helper()
129+
p := filepath.Join(e.librarianDir, "build-request.json")
130+
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
131+
t.Fatalf("failed to write request file: %v", err)
132+
}
133+
}
134+
135+
// newTestEnv creates a new test environment.
136+
func newTestEnv(t *testing.T) *testEnv {
137+
t.Helper()
138+
tmpDir, err := os.MkdirTemp("", "builder-test")
139+
if err != nil {
140+
t.Fatalf("failed to create temp dir: %v", err)
141+
}
142+
e := &testEnv{tmpDir: tmpDir}
143+
e.librarianDir = filepath.Join(tmpDir, "librarian")
144+
e.repoDir = filepath.Join(tmpDir, "repo")
145+
for _, dir := range []string{e.librarianDir, e.repoDir} {
146+
if err := os.Mkdir(dir, 0755); err != nil {
147+
t.Fatalf("failed to create dir %s: %v", dir, err)
148+
}
149+
}
150+
151+
return e
152+
}

internal/librariangen/execv/execv.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ import (
2424
"strings"
2525
)
2626

27-
// Run executes a command and logs its output.
28-
func Run(ctx context.Context, args []string, outputDir string) error {
27+
// Run executes a command in a specified working directory and logs its output.
28+
func Run(ctx context.Context, args []string, workingDir string) error {
2929
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
3030
cmd.Env = os.Environ()
31-
cmd.Dir = outputDir // Run commands from the output directory.
31+
cmd.Dir = workingDir
3232
slog.Debug("librariangen: running command", "command", strings.Join(cmd.Args, " "), "dir", cmd.Dir)
3333

3434
output, err := cmd.Output()

internal/librariangen/main.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"os"
2424
"strings"
2525

26+
"cloud.google.com/go/internal/postprocessor/librarian/librariangen/build"
2627
"cloud.google.com/go/internal/postprocessor/librarian/librariangen/generate"
2728
"cloud.google.com/go/internal/postprocessor/librarian/librariangen/release"
2829
)
@@ -49,6 +50,7 @@ func main() {
4950
var (
5051
generateFunc = generate.Generate
5152
releaseInitFunc = release.Init
53+
buildFunc = build.Build
5254
)
5355

5456
// run executes the appropriate command based on the CLI's invocation arguments.
@@ -80,8 +82,7 @@ func run(ctx context.Context, args []string) error {
8082
slog.Warn("librariangen: configure command is not yet implemented")
8183
return nil
8284
case "build":
83-
slog.Warn("librariangen: build command is not yet implemented")
84-
return nil
85+
return handleBuild(ctx, flags)
8586
default:
8687
return fmt.Errorf("librariangen: unknown command: %s", cmd)
8788
}
@@ -114,3 +115,15 @@ func handleReleaseInit(ctx context.Context, args []string) error {
114115
}
115116
return releaseInitFunc(ctx, cfg)
116117
}
118+
119+
// handleBuild parses flags for the build command and calls the builder.
120+
func handleBuild(ctx context.Context, args []string) error {
121+
cfg := &build.Config{}
122+
buildFlags := flag.NewFlagSet("build", flag.ExitOnError)
123+
buildFlags.StringVar(&cfg.LibrarianDir, "librarian", "/librarian", "Path to the librarian-tool input directory. Contains generate-request.json.")
124+
buildFlags.StringVar(&cfg.RepoDir, "repo", "/repo", "Path to the root of the complete language repository.")
125+
if err := buildFlags.Parse(args); err != nil {
126+
return fmt.Errorf("librariangen: failed to parse flags: %w", err)
127+
}
128+
return buildFunc(ctx, cfg)
129+
}

internal/librariangen/main_test.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"context"
1919
"testing"
2020

21+
"cloud.google.com/go/internal/postprocessor/librarian/librariangen/build"
2122
"cloud.google.com/go/internal/postprocessor/librarian/librariangen/generate"
2223
"cloud.google.com/go/internal/postprocessor/librarian/librariangen/release"
2324
)
@@ -30,6 +31,9 @@ func TestRun(t *testing.T) {
3031
releaseInitFunc = func(ctx context.Context, cfg *release.Config) error {
3132
return nil
3233
}
34+
buildFunc = func(ctx context.Context, cfg *build.Config) error {
35+
return nil
36+
}
3337

3438
ctx := context.Background()
3539
tests := []struct {
@@ -58,10 +62,15 @@ func TestRun(t *testing.T) {
5862
wantErr: true,
5963
},
6064
{
61-
name: "build command",
65+
name: "build command no flags",
6266
args: []string{"build"},
6367
wantErr: false,
6468
},
69+
{
70+
name: "build command with flags",
71+
args: []string{"build", "--repo=.", "--librarian=./.librarian"},
72+
wantErr: false,
73+
},
6574
{
6675
name: "configure command",
6776
args: []string{"configure"},

0 commit comments

Comments
 (0)