diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ae61d0c --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +# Go parameters +GOCMD=go +GOBUILD=$(GOCMD) build +GOTEST=$(GOCMD) test +GOCLEAN=$(GOCMD) clean +GOGET=$(GOCMD) get +BINARY_NAME=gnock + +# Main package path +MAIN_PACKAGE=./cmd/gnock + +install: + go install $(MAIN_PACKAGE) + +# Build the project +all: test build + +build: + $(GOBUILD) -o $(BINARY_NAME) -v $(MAIN_PACKAGE) + +# Run tests +test: + $(GOTEST) -race -v ./... + +# Dependencies +deps: + $(GOGET) -v ./... + +# Run golangci-lint +lint: + golangci-lint run + +# Format the code +fmt: + go fmt ./... + +.PHONY: install build test deps lint fmt diff --git a/cmd/gnock/main.go b/cmd/gnock/main.go new file mode 100644 index 0000000..78df600 --- /dev/null +++ b/cmd/gnock/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func main() { + var rootCmd = &cobra.Command{Use: "gnock"} + + var getCmd = &cobra.Command{ + Use: "gnock get [url]", + Short: "Get a package from an given URL (example: gnock get http://github.com/...)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + + }, + } + + rootCmd.AddCommand(getCmd) + + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..af3ca5b --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/gnoswap-labs/gnock + +go 1.22.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.8.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..912390a --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/fetch.go b/internal/fetch.go new file mode 100644 index 0000000..4637194 --- /dev/null +++ b/internal/fetch.go @@ -0,0 +1,120 @@ +package internal + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/gnoswap-labs/gnock/internal/modfile" +) + +const gnoModFilename = "gno.mod" + +var ( + ErrInvalidURL = errors.New("invalid URL") +) + +var execCommand = executeGitCommand + +func executeGitCommand(clone, url, dir string) error { + cmd := exec.Command("git", clone, url, dir) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to clone repository: %v", err) + } + return nil +} + +// GetPackage fetches a package from the given URL (e.g. github.com/username/repo) +// and copies it to the target directory which declared in the gno.mod file. +func GetPackage(url string) error { + // TODO: check valid URL + parts := strings.Split(url, "/") + + // pats are contains at least 3 parts + // ex: github.com/username/repo/... + // |---------|--------|-----| + // initial owner repo + if len(parts) < 3 { + return ErrInvalidURL + } + + tempDir, err := os.MkdirTemp("", "gnock-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // TODO: use universal method to clone repository + err = execCommand("clone", url, tempDir) + if err != nil { + return fmt.Errorf("failed to clone repository: %v", err) + } + + return processDirectory(tempDir, "") +} + +func processDirectory(dir, relpath string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("failed to rea directory %s: %v", dir, err) + } + + for _, entry := range entries { + if entry.IsDir() { + err := processDirectory(filepath.Join(dir, entry.Name()), filepath.Join(relpath, entry.Name())) + if err != nil { + return err + } + } else if entry.Name() == gnoModFilename { + gnoModPath := filepath.Join(dir, gnoModFilename) + module, err := modfile.Parse(gnoModPath) + if err != nil { + return fmt.Errorf("failed to parse gno.mod file in %s: %v", relpath, err) + } + + // TODO: the name of the `gno` directory can be different for each user. + // so need to set it as a variable and update via the CLI or find it automatically + destDir := filepath.Join("gno", "examples", module.Path) + if err := os.MkdirAll(destDir, 0755); err != nil { + return fmt.Errorf("failed to create destination directory %s: %v", destDir, err) + } + + if err := copyDir(dir, destDir); err != nil { + return fmt.Errorf("failed to copy directory %s to %s: %v", dir, destDir, err) + } + + fmt.Printf("Package %s installed successfully to %s\n", module.Path, destDir) + } + } + + return nil +} + +func copyDir(src string, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + + dstPath := filepath.Join(dst, relPath) + + if info.IsDir() { + return os.MkdirAll(dstPath, info.Mode()) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + return os.WriteFile(dstPath, data, info.Mode()) + }) +} diff --git a/internal/fetch_test.go b/internal/fetch_test.go new file mode 100644 index 0000000..d395bfc --- /dev/null +++ b/internal/fetch_test.go @@ -0,0 +1,261 @@ +package internal + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +var mockExecCommand func(clone, url, dir string) error + +func init() { + execCommand = func(clone, url, dir string) error { + return mockExecCommand(clone, url, dir) + } +} + +func TestGetPackage(t *testing.T) { + tests := []struct { + name string + url string + mockGit func(clone, url, dir string) error + wantErr bool + }{ + { + name: "Valid URL and successful clone", + url: "github.com/username/repo", + mockGit: func(clone, url, dir string) error { + gnoModPath := filepath.Join(dir, "gno.mod") + content := []byte("module gno.land/r/demo/mypackage") + if err := os.WriteFile(gnoModPath, content, 0644); err != nil { + return err + } + return nil + }, + wantErr: false, + }, + { + name: "Invalid URL", + url: "invalid-url", + mockGit: func(clone, url, dir string) error { return nil }, + wantErr: true, + }, + { + name: "Git clone fails", + url: "github.com/username/repo", + mockGit: func(clone, url, dir string) error { + return errors.New("git clone failed") + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExecCommand = tt.mockGit + + tmpDir, err := os.MkdirTemp("", "gnock-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // change working directory to temp directory + oldWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(oldWd) + + err = GetPackage(tt.url) + if (err != nil) != tt.wantErr { + t.Errorf("GetPackage() error = %v, wantErr %v", err, tt.wantErr) + } + + // check if the package was installed correctly + if !tt.wantErr { + destDir := filepath.Join("gno", "examples", "gno.land", "r", "demo", "mypackage") + if _, err := os.Stat(destDir); os.IsNotExist(err) { + t.Errorf("Expected directory %s was not created", destDir) + } + } + }) + } +} + +func TestGetPackageComplexStructure(t *testing.T) { + mockRepoDir, err := os.MkdirTemp("", "mock-repo-*") + if err != nil { + t.Fatalf("Failed to create mock repo dir: %v", err) + } + defer os.RemoveAll(mockRepoDir) + + dirs := []string{ + "example_pkg/nested1", + "example_pkg/nested2", + } + for _, dir := range dirs { + err := os.MkdirAll(filepath.Join(mockRepoDir, dir), 0755) + if err != nil { + t.Fatalf("Failed to create directory %s: %v", dir, err) + } + } + + // mock files + files := map[string]string{ + "example_pkg/nested1/foo.gno": "// foo.gno content", + "example_pkg/nested1/foo_test.gno": "// foo_test.gno content", + "example_pkg/nested1/gno.mod": "module gno.land/p/demo/nested1", + "example_pkg/nested2/bar.gno": "// bar.gno content", + "example_pkg/nested2/bar_test.gno": "// bar_test.gno content", + "example_pkg/nested2/gno.mod": "module gno.land/r/nested2", + } + for filePath, content := range files { + err := os.WriteFile(filepath.Join(mockRepoDir, filePath), []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to create file %s: %v", filePath, err) + } + } + + mockExecCommand = func(clone, url, dir string) error { + return copyDir(mockRepoDir, dir) + } + + // temporary directory for the test output + tmpDir, err := os.MkdirTemp("", "gnock-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(oldWd) + + err = GetPackage("github.com/example/repo") + if err != nil { + t.Fatalf("GetPackage failed: %v", err) + } + + // Check if the package was installed correctly + expectedStructure := map[string]bool{ + "gno/examples/gno.land/p/demo/nested1/foo.gno": true, + "gno/examples/gno.land/p/demo/nested1/foo_test.gno": true, + "gno/examples/gno.land/p/demo/nested1/gno.mod": true, + "gno/examples/gno.land/r/nested2/bar.gno": true, + "gno/examples/gno.land/r/nested2/bar_test.gno": true, + "gno/examples/gno.land/r/nested2/gno.mod": true, + } + + err = filepath.Walk(filepath.Join(tmpDir, "gno"), func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + relPath, err := filepath.Rel(tmpDir, path) + if err != nil { + return err + } + if _, expected := expectedStructure[relPath]; !expected { + t.Errorf("Unexpected file: %s", relPath) + } else { + delete(expectedStructure, relPath) + } + return nil + }) + + if err != nil { + t.Fatalf("Failed to walk directory structure: %v", err) + } + + for missingFile := range expectedStructure { + t.Errorf("Expected file not found: %s", missingFile) + } + + // verify content of gno.mod files + gnoModPaths := []struct { + path string + content string + }{ + {"gno/examples/gno.land/p/demo/nested1/gno.mod", "module gno.land/p/demo/nested1"}, + {"gno/examples/gno.land/r/nested2/gno.mod", "module gno.land/r/nested2"}, + } + + for _, gnoMod := range gnoModPaths { + content, err := os.ReadFile(filepath.Join(tmpDir, gnoMod.path)) + if err != nil { + t.Errorf("Failed to read gno.mod file %s: %v", gnoMod.path, err) + } else if string(content) != gnoMod.content { + t.Errorf("Incorrect content in gno.mod file %s. Expected: %s, Got: %s", gnoMod.path, gnoMod.content, string(content)) + } + } +} + +func TestCopyDir(t *testing.T) { + srcDir, err := os.MkdirTemp("", "src-*") + if err != nil { + t.Fatalf("Failed to create source dir: %v", err) + } + defer os.RemoveAll(srcDir) + + // create some files in the source directory + files := []string{"file1.txt", "file2.txt", "subdir/file3.txt"} + for _, file := range files { + path := filepath.Join(srcDir, file) + err := os.MkdirAll(filepath.Dir(path), 0755) + if err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + err = os.WriteFile(path, []byte("test content"), 0644) + if err != nil { + t.Fatalf("Failed to write file: %v", err) + } + } + + // temporary destination directory + dstDir, err := os.MkdirTemp("", "dst-*") + if err != nil { + t.Fatalf("Failed to create destination dir: %v", err) + } + defer os.RemoveAll(dstDir) + + err = copyDir(srcDir, dstDir) + if err != nil { + t.Fatalf("copyDir failed: %v", err) + } + + // check if all files were copied correctly + for _, file := range files { + srcPath := filepath.Join(srcDir, file) + dstPath := filepath.Join(dstDir, file) + + srcInfo, err := os.Stat(srcPath) + if err != nil { + t.Fatalf("Failed to stat source file: %v", err) + } + + dstInfo, err := os.Stat(dstPath) + if err != nil { + t.Fatalf("Failed to stat destination file: %v", err) + } + + if srcInfo.Mode() != dstInfo.Mode() { + t.Errorf("File mode mismatch for %s", file) + } + + srcContent, err := os.ReadFile(srcPath) + if err != nil { + t.Fatalf("Failed to read source file: %v", err) + } + + dstContent, err := os.ReadFile(dstPath) + if err != nil { + t.Fatalf("Failed to read destination file: %v", err) + } + + if string(srcContent) != string(dstContent) { + t.Errorf("File content mismatch for %s", file) + } + } +} diff --git a/internal/modfile/parser.go b/internal/modfile/parser.go new file mode 100644 index 0000000..ff50257 --- /dev/null +++ b/internal/modfile/parser.go @@ -0,0 +1,39 @@ +package modfile + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +type Module struct { + Path string +} + +func Parse(path string) (*Module, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // module gno.land/{p,r}/... + if strings.HasPrefix(line, "module") { + parts := strings.Fields(line) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid module declaration: %s", line) + } + return &Module{Path: parts[1]}, nil + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return nil, fmt.Errorf("module declaration not found") +} diff --git a/internal/modfile/parser_test.go b/internal/modfile/parser_test.go new file mode 100644 index 0000000..a0c29ca --- /dev/null +++ b/internal/modfile/parser_test.go @@ -0,0 +1,86 @@ +package modfile + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseFile(t *testing.T) { + tests := []struct { + name string + content string + expectedErr bool + expectedPath string + }{ + { + name: "Valid module file", + content: `module gno.land/r/dummy/foo + +require ( + gno.land/p/demo/grc/grc20 v0.0.0-latest + gno.land/p/demo/ownable v0.0.0-latest + gno.land/p/demo/testutils v0.0.0-latest + gno.land/p/demo/uassert v0.0.0-latest + gno.land/p/demo/ufmt v0.0.0-latest + gno.land/p/demo/users v0.0.0-latest + gno.land/r/demo/users v0.0.0-latest +) +`, + expectedErr: false, + expectedPath: "gno.land/r/dummy/foo", + }, + { + name: "Invalid module declaration", + content: `invalid module declaration +require ( + gno.land/p/demo/grc/grc20 v0.0.0-latest +) +`, + expectedErr: true, + }, + { + name: "Empty module declaration 2", + content: `module gno.land /r/dummy/foo`, + expectedErr: true, + }, + { + name: "No module declaration", + content: `require ( + gno.land/p/demo/grc/grc20 v0.0.0-latest +) +`, + expectedErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "gno-test") + if err != nil { + t.Fatalf("Failed to create temporary directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + tmpFile := filepath.Join(tmpDir, "gno.mod") + err = os.WriteFile(tmpFile, []byte(tt.content), 0644) + if err != nil { + t.Fatalf("Failed to write to temporary file: %v", err) + } + + module, err := Parse(tmpFile) + if tt.expectedErr { + if err == nil { + t.Error("Expected an error, but got nil") + } + } else { + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + if module.Path != tt.expectedPath { + t.Errorf("Expected module path %s, but got %s", tt.expectedPath, module.Path) + } + } + }) + } +}