diff --git a/cmd/all.go b/cmd/all.go index c4c6294..e65958a 100644 --- a/cmd/all.go +++ b/cmd/all.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "io/fs" + "net/url" "os" "strings" @@ -28,14 +29,9 @@ var allCmd = &cobra.Command{ format, _ := cmd.Flags().GetString("format") compression, _ := cmd.Flags().GetString("compression") - // For now, we only support github user/org urls - if !strings.Contains(url, "github.com") { - return fmt.Errorf("unsupported URL type: %s", url) - } - - owner, _, err := github.ParseRepoFromURL(url) + owner, err := parseGithubOwner(url) if err != nil { - return fmt.Errorf("failed to parse repository url: %w", err) + return err } repos, err := GithubClient.GetPublicRepos(cmd.Context(), owner) @@ -65,21 +61,31 @@ var allCmd = &cobra.Command{ } // This is not an efficient way to merge datanodes, but it's the only way for now // A better approach would be to add a Merge method to the DataNode + repoName := strings.TrimSuffix(repoURL, ".git") + parts := strings.Split(repoName, "/") + repoName = parts[len(parts)-1] + err = dn.Walk(".", func(path string, de fs.DirEntry, err error) error { if err != nil { return err } if !de.IsDir() { - file, err := dn.Open(path) + err := func() error { + file, err := dn.Open(path) + if err != nil { + return err + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil { + return err + } + allDataNodes.AddData(repoName+"/"+path, data) + return nil + }() if err != nil { return err } - defer file.Close() - data, err := io.ReadAll(file) - if err != nil { - return err - } - allDataNodes.AddData(path, data) } return nil }) @@ -122,10 +128,28 @@ var allCmd = &cobra.Command{ }, } -// init registers the 'all' command and its flags with the root command. func init() { RootCmd.AddCommand(allCmd) allCmd.PersistentFlags().String("output", "all.dat", "Output file for the DataNode") allCmd.PersistentFlags().String("format", "datanode", "Output format (datanode or matrix)") allCmd.PersistentFlags().String("compression", "none", "Compression format (none, gz, or xz)") } + +func parseGithubOwner(u string) (string, error) { + owner, _, err := github.ParseRepoFromURL(u) + if err == nil { + return owner, nil + } + + parsedURL, err := url.Parse(u) + if err != nil { + return "", fmt.Errorf("invalid URL: %w", err) + } + + path := strings.Trim(parsedURL.Path, "/") + parts := strings.Split(path, "/") + if len(parts) != 1 { + return "", fmt.Errorf("invalid owner URL: %s", u) + } + return parts[0], nil +} diff --git a/cmd/all_test.go b/cmd/all_test.go new file mode 100644 index 0000000..2ec4030 --- /dev/null +++ b/cmd/all_test.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/Snider/Borg/pkg/datanode" + "github.com/Snider/Borg/pkg/github" + "github.com/Snider/Borg/pkg/mocks" +) + +func TestAllCmd_Good(t *testing.T) { + mockGithubClient := mocks.NewMockClient(map[string]*http.Response{ + "https://api.github.com/users/testuser/repos": { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBufferString(`[{"clone_url": "https://github.com/testuser/repo1.git"}]`)), + }, + }) + oldNewAuthenticatedClient := github.NewAuthenticatedClient + github.NewAuthenticatedClient = func(ctx context.Context) *http.Client { + return mockGithubClient + } + defer func() { + github.NewAuthenticatedClient = oldNewAuthenticatedClient + }() + + mockCloner := &mocks.MockGitCloner{ + DN: datanode.New(), + Err: nil, + } + oldCloner := GitCloner + GitCloner = mockCloner + defer func() { + GitCloner = oldCloner + }() + + rootCmd := NewRootCmd() + rootCmd.AddCommand(allCmd) + + _, err := executeCommand(rootCmd, "all", "https://github.com/testuser", "--output", "/dev/null") + if err != nil { + t.Fatalf("all command failed: %v", err) + } +} + +func TestAllCmd_Bad(t *testing.T) { + mockGithubClient := mocks.NewMockClient(map[string]*http.Response{ + "https://api.github.com/users/testuser/repos": { + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewBufferString(`{"message": "Not Found"}`)), + }, + }) + oldNewAuthenticatedClient := github.NewAuthenticatedClient + github.NewAuthenticatedClient = func(ctx context.Context) *http.Client { + return mockGithubClient + } + defer func() { + github.NewAuthenticatedClient = oldNewAuthenticatedClient + }() + + rootCmd := NewRootCmd() + rootCmd.AddCommand(allCmd) + + _, err := executeCommand(rootCmd, "all", "https://github.com/testuser", "--output", "/dev/null") + if err == nil { + t.Fatalf("expected an error, but got none") + } +} diff --git a/cmd/collect.go b/cmd/collect.go index 067f11c..eed7a22 100644 --- a/cmd/collect.go +++ b/cmd/collect.go @@ -11,7 +11,6 @@ var collectCmd = &cobra.Command{ Long: `Collect a resource from a URI and store it in a DataNode.`, } -// init registers the collect command with the root. func init() { RootCmd.AddCommand(collectCmd) } diff --git a/cmd/collect_github.go b/cmd/collect_github.go index 8ddcf41..ec9d0ba 100644 --- a/cmd/collect_github.go +++ b/cmd/collect_github.go @@ -11,7 +11,6 @@ var collectGithubCmd = &cobra.Command{ Long: `Collect a resource from a GitHub repository, such as a repository or a release.`, } -// init registers the GitHub collection parent command. func init() { collectCmd.AddCommand(collectGithubCmd) } diff --git a/cmd/collect_github_release_subcommand.go b/cmd/collect_github_release_subcommand.go index 4bb376d..3022155 100644 --- a/cmd/collect_github_release_subcommand.go +++ b/cmd/collect_github_release_subcommand.go @@ -6,7 +6,6 @@ import ( "log/slog" "os" "path/filepath" - "strings" "github.com/Snider/Borg/pkg/datanode" borg_github "github.com/Snider/Borg/pkg/github" @@ -38,7 +37,6 @@ var collectGithubReleaseCmd = &cobra.Command{ }, } -// init registers the 'collect github release' subcommand and its flags. func init() { collectGithubCmd.AddCommand(collectGithubReleaseCmd) collectGithubReleaseCmd.PersistentFlags().String("output", ".", "Output directory for the downloaded file") @@ -87,10 +85,16 @@ func GetRelease(log *slog.Logger, repoURL string, outputDir string, pack bool, f if err != nil { return nil, fmt.Errorf("failed to create datanode: %w", err) } - outputFile := outputDir - if !strings.HasSuffix(outputFile, ".dat") { - outputFile = outputFile + ".dat" + + if err := os.MkdirAll(outputDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create output directory: %w", err) } + basename := release.GetTagName() + if basename == "" { + basename = "release" + } + outputFile := filepath.Join(outputDir, basename+".dat") + err = os.WriteFile(outputFile, tar, 0644) if err != nil { return nil, fmt.Errorf("failed to write datanode: %w", err) diff --git a/cmd/collect_github_release_subcommand_test.go b/cmd/collect_github_release_subcommand_test.go new file mode 100644 index 0000000..7da2c90 --- /dev/null +++ b/cmd/collect_github_release_subcommand_test.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "bytes" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/Snider/Borg/pkg/mocks" + borg_github "github.com/Snider/Borg/pkg/github" + "github.com/google/go-github/v39/github" +) + +func TestGetRelease_Good(t *testing.T) { + // Create a temporary directory for the output + dir, err := os.MkdirTemp("", "test-get-release") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(dir) + + mockClient := mocks.NewMockClient(map[string]*http.Response{ + "https://api.github.com/repos/owner/repo/releases/latest": { + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"tag_name": "v1.0.0", "assets": [{"name": "asset1.zip", "browser_download_url": "https://github.com/owner/repo/releases/download/v1.0.0/asset1.zip"}]}`)), + }, + "https://github.com/owner/repo/releases/download/v1.0.0/asset1.zip": { + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("asset content")), + }, + }) + + oldNewClient := borg_github.NewClient + borg_github.NewClient = func(httpClient *http.Client) *github.Client { + return github.NewClient(mockClient) + } + defer func() { + borg_github.NewClient = oldNewClient + }() + + oldDefaultClient := borg_github.DefaultClient + borg_github.DefaultClient = mockClient + defer func() { + borg_github.DefaultClient = oldDefaultClient + }() + + log := slog.New(slog.NewJSONHandler(io.Discard, nil)) + + // Test downloading a single asset + _, err = GetRelease(log, "https://github.com/owner/repo", dir, false, "asset1.zip", "") + if err != nil { + t.Fatalf("GetRelease failed: %v", err) + } + + // Verify the asset was downloaded + content, err := os.ReadFile(filepath.Join(dir, "asset1.zip")) + if err != nil { + t.Fatalf("failed to read downloaded asset: %v", err) + } + if string(content) != "asset content" { + t.Errorf("unexpected asset content: %s", string(content)) + } + + // Test packing all assets + packedDir := filepath.Join(dir, "packed") + _, err = GetRelease(log, "https://github.com/owner/repo", packedDir, true, "", "") + if err != nil { + t.Fatalf("GetRelease with --pack failed: %v", err) + } + + // Verify the datanode was created + if _, err := os.Stat(filepath.Join(packedDir, "v1.0.0.dat")); os.IsNotExist(err) { + t.Fatalf("datanode not created") + } +} + +func TestGetRelease_Bad(t *testing.T) { + // Create a temporary directory for the output + dir, err := os.MkdirTemp("", "test-get-release") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(dir) + + mockClient := mocks.NewMockClient(map[string]*http.Response{ + "https://api.github.com/repos/owner/repo/releases/latest": { + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewBufferString(`{"message": "Not Found"}`)), + }, + }) + + oldNewClient := borg_github.NewClient + borg_github.NewClient = func(httpClient *http.Client) *github.Client { + return github.NewClient(mockClient) + } + defer func() { + borg_github.NewClient = oldNewClient + }() + + log := slog.New(slog.NewJSONHandler(io.Discard, nil)) + + // Test failed release lookup + _, err = GetRelease(log, "https://github.com/owner/repo", dir, false, "", "") + if err == nil { + t.Fatalf("expected an error, but got none") + } +} diff --git a/cmd/collect_github_repo.go b/cmd/collect_github_repo.go index 6f02b7d..9fb5f84 100644 --- a/cmd/collect_github_repo.go +++ b/cmd/collect_github_repo.go @@ -13,6 +13,10 @@ import ( "github.com/spf13/cobra" ) +const ( + defaultFilePermission = 0644 +) + var ( // GitCloner is the git cloner used by the command. It can be replaced for testing. GitCloner = vcs.NewGitCloner() @@ -31,6 +35,13 @@ func NewCollectGithubRepoCmd() *cobra.Command { format, _ := cmd.Flags().GetString("format") compression, _ := cmd.Flags().GetString("compression") + if format != "datanode" && format != "matrix" { + return fmt.Errorf("invalid format: %s (must be 'datanode' or 'matrix')", format) + } + if compression != "none" && compression != "gz" && compression != "xz" { + return fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression) + } + prompter := ui.NewNonInteractivePrompter(ui.GetVCSQuote) prompter.Start() defer prompter.Stop() @@ -43,29 +54,29 @@ func NewCollectGithubRepoCmd() *cobra.Command { dn, err := GitCloner.CloneGitRepository(repoURL, progressWriter) if err != nil { - return fmt.Errorf("Error cloning repository: %w", err) + return fmt.Errorf("error cloning repository: %w", err) } var data []byte if format == "matrix" { matrix, err := matrix.FromDataNode(dn) if err != nil { - return fmt.Errorf("Error creating matrix: %w", err) + return fmt.Errorf("error creating matrix: %w", err) } data, err = matrix.ToTar() if err != nil { - return fmt.Errorf("Error serializing matrix: %w", err) + return fmt.Errorf("error serializing matrix: %w", err) } } else { data, err = dn.ToTar() if err != nil { - return fmt.Errorf("Error serializing DataNode: %w", err) + return fmt.Errorf("error serializing DataNode: %w", err) } } compressedData, err := compress.Compress(data, compression) if err != nil { - return fmt.Errorf("Error compressing data: %w", err) + return fmt.Errorf("error compressing data: %w", err) } if outputFile == "" { @@ -75,22 +86,21 @@ func NewCollectGithubRepoCmd() *cobra.Command { } } - err = os.WriteFile(outputFile, compressedData, 0644) + err = os.WriteFile(outputFile, compressedData, defaultFilePermission) if err != nil { - return fmt.Errorf("Error writing DataNode to file: %w", err) + return fmt.Errorf("error writing DataNode to file: %w", err) } fmt.Fprintln(cmd.OutOrStdout(), "Repository saved to", outputFile) return nil }, } - cmd.PersistentFlags().String("output", "", "Output file for the DataNode") - cmd.PersistentFlags().String("format", "datanode", "Output format (datanode or matrix)") - cmd.PersistentFlags().String("compression", "none", "Compression format (none, gz, or xz)") + cmd.Flags().String("output", "", "Output file for the DataNode") + cmd.Flags().String("format", "datanode", "Output format (datanode or matrix)") + cmd.Flags().String("compression", "none", "Compression format (none, gz, or xz)") return cmd } -// init registers the 'collect github repo' subcommand and its flags. func init() { collectGithubCmd.AddCommand(NewCollectGithubRepoCmd()) } diff --git a/cmd/collect_github_repo_test.go b/cmd/collect_github_repo_test.go new file mode 100644 index 0000000..e3745b5 --- /dev/null +++ b/cmd/collect_github_repo_test.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + "testing" + + "github.com/Snider/Borg/pkg/datanode" + "github.com/Snider/Borg/pkg/mocks" +) + +func TestCollectGithubRepoCmd_Good(t *testing.T) { + mockCloner := &mocks.MockGitCloner{ + DN: datanode.New(), + Err: nil, + } + oldCloner := GitCloner + GitCloner = mockCloner + defer func() { + GitCloner = oldCloner + }() + + rootCmd := NewRootCmd() + rootCmd.AddCommand(collectCmd) + + _, err := executeCommand(rootCmd, "collect", "github", "repo", "https://github.com/testuser/repo1.git", "--output", "/dev/null") + if err != nil { + t.Fatalf("collect github repo command failed: %v", err) + } +} + +func TestCollectGithubRepoCmd_Bad(t *testing.T) { + mockCloner := &mocks.MockGitCloner{ + DN: nil, + Err: fmt.Errorf("git clone error"), + } + oldCloner := GitCloner + GitCloner = mockCloner + defer func() { + GitCloner = oldCloner + }() + + rootCmd := NewRootCmd() + rootCmd.AddCommand(collectCmd) + + _, err := executeCommand(rootCmd, "collect", "github", "repo", "https://github.com/testuser/repo1.git", "--output", "/dev/null") + if err == nil { + t.Fatalf("expected an error, but got none") + } +} diff --git a/cmd/collect_github_repos.go b/cmd/collect_github_repos.go index e495a27..dfcd315 100644 --- a/cmd/collect_github_repos.go +++ b/cmd/collect_github_repos.go @@ -28,7 +28,6 @@ var collectGithubReposCmd = &cobra.Command{ }, } -// init registers the 'collect github repos' subcommand. func init() { collectGithubCmd.AddCommand(collectGithubReposCmd) } diff --git a/cmd/collect_pwa.go b/cmd/collect_pwa.go index d414832..af82a75 100644 --- a/cmd/collect_pwa.go +++ b/cmd/collect_pwa.go @@ -35,11 +35,11 @@ Example: format, _ := cmd.Flags().GetString("format") compression, _ := cmd.Flags().GetString("compression") - err := CollectPWA(c.PWAClient, pwaURL, outputFile, format, compression) + finalPath, err := CollectPWA(c.PWAClient, pwaURL, outputFile, format, compression) if err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout(), "PWA saved to", outputFile) + fmt.Fprintln(cmd.OutOrStdout(), "PWA saved to", finalPath) return nil }, } @@ -50,13 +50,12 @@ Example: return c } -// init registers the 'collect pwa' subcommand and its flags. func init() { collectCmd.AddCommand(&NewCollectPWACmd().Command) } -func CollectPWA(client pwa.PWAClient, pwaURL string, outputFile string, format string, compression string) error { +func CollectPWA(client pwa.PWAClient, pwaURL string, outputFile string, format string, compression string) (string, error) { if pwaURL == "" { - return fmt.Errorf("uri is required") + return "", fmt.Errorf("uri is required") } bar := ui.NewProgressBar(-1, "Finding PWA manifest") @@ -64,34 +63,34 @@ func CollectPWA(client pwa.PWAClient, pwaURL string, outputFile string, format s manifestURL, err := client.FindManifest(pwaURL) if err != nil { - return fmt.Errorf("error finding manifest: %w", err) + return "", fmt.Errorf("error finding manifest: %w", err) } bar.Describe("Downloading and packaging PWA") dn, err := client.DownloadAndPackagePWA(pwaURL, manifestURL, bar) if err != nil { - return fmt.Errorf("error downloading and packaging PWA: %w", err) + return "", fmt.Errorf("error downloading and packaging PWA: %w", err) } var data []byte if format == "matrix" { matrix, err := matrix.FromDataNode(dn) if err != nil { - return fmt.Errorf("error creating matrix: %w", err) + return "", fmt.Errorf("error creating matrix: %w", err) } data, err = matrix.ToTar() if err != nil { - return fmt.Errorf("error serializing matrix: %w", err) + return "", fmt.Errorf("error serializing matrix: %w", err) } } else { data, err = dn.ToTar() if err != nil { - return fmt.Errorf("error serializing DataNode: %w", err) + return "", fmt.Errorf("error serializing DataNode: %w", err) } } compressedData, err := compress.Compress(data, compression) if err != nil { - return fmt.Errorf("error compressing data: %w", err) + return "", fmt.Errorf("error compressing data: %w", err) } if outputFile == "" { @@ -103,7 +102,7 @@ func CollectPWA(client pwa.PWAClient, pwaURL string, outputFile string, format s err = os.WriteFile(outputFile, compressedData, 0644) if err != nil { - return fmt.Errorf("error writing PWA to file: %w", err) + return "", fmt.Errorf("error writing PWA to file: %w", err) } - return nil + return outputFile, nil } diff --git a/cmd/collect_pwa_test.go b/cmd/collect_pwa_test.go index 0f7b602..0620a09 100644 --- a/cmd/collect_pwa_test.go +++ b/cmd/collect_pwa_test.go @@ -1,8 +1,12 @@ package cmd import ( + "fmt" "strings" "testing" + + "github.com/Snider/Borg/pkg/datanode" + "github.com/Snider/Borg/pkg/pwa" ) func TestCollectPWACmd_NoURI(t *testing.T) { @@ -24,3 +28,29 @@ func Test_NewCollectPWACmd(t *testing.T) { t.Errorf("NewCollectPWACmd is nil") } } + +func TestCollectPWA_Good(t *testing.T) { + mockClient := &pwa.MockPWAClient{ + ManifestURL: "https://example.com/manifest.json", + DN: datanode.New(), + Err: nil, + } + + _, err := CollectPWA(mockClient, "https://example.com", "/dev/null", "datanode", "none") + if err != nil { + t.Fatalf("CollectPWA failed: %v", err) + } +} + +func TestCollectPWA_Bad(t *testing.T) { + mockClient := &pwa.MockPWAClient{ + ManifestURL: "", + DN: nil, + Err: fmt.Errorf("pwa error"), + } + + _, err := CollectPWA(mockClient, "https://example.com", "/dev/null", "datanode", "none") + if err == nil { + t.Fatalf("expected an error, but got none") + } +} diff --git a/cmd/collect_website.go b/cmd/collect_website.go index b88895c..34aa833 100644 --- a/cmd/collect_website.go +++ b/cmd/collect_website.go @@ -4,11 +4,11 @@ import ( "fmt" "os" + "github.com/schollz/progressbar/v3" "github.com/Snider/Borg/pkg/compress" "github.com/Snider/Borg/pkg/matrix" "github.com/Snider/Borg/pkg/ui" "github.com/Snider/Borg/pkg/website" - "github.com/schollz/progressbar/v3" "github.com/spf13/cobra" ) @@ -78,7 +78,6 @@ var collectWebsiteCmd = &cobra.Command{ }, } -// init registers the 'collect website' subcommand and its flags. func init() { collectCmd.AddCommand(collectWebsiteCmd) collectWebsiteCmd.PersistentFlags().String("output", "", "Output file for the DataNode") diff --git a/cmd/collect_website_test.go b/cmd/collect_website_test.go index c3fb780..3819f08 100644 --- a/cmd/collect_website_test.go +++ b/cmd/collect_website_test.go @@ -1,8 +1,13 @@ package cmd import ( + "fmt" "strings" "testing" + + "github.com/Snider/Borg/pkg/datanode" + "github.com/Snider/Borg/pkg/website" + "github.com/schollz/progressbar/v3" ) func TestCollectWebsiteCmd_NoArgs(t *testing.T) { @@ -24,3 +29,39 @@ func Test_NewCollectWebsiteCmd(t *testing.T) { t.Errorf("NewCollectWebsiteCmd is nil") } } + +func TestCollectWebsiteCmd_Good(t *testing.T) { + oldDownloadAndPackageWebsite := website.DownloadAndPackageWebsite + website.DownloadAndPackageWebsite = func(startURL string, maxDepth int, bar *progressbar.ProgressBar) (*datanode.DataNode, error) { + return datanode.New(), nil + } + defer func() { + website.DownloadAndPackageWebsite = oldDownloadAndPackageWebsite + }() + + rootCmd := NewRootCmd() + rootCmd.AddCommand(collectCmd) + + _, err := executeCommand(rootCmd, "collect", "website", "https://example.com", "--output", "/dev/null") + if err != nil { + t.Fatalf("collect website command failed: %v", err) + } +} + +func TestCollectWebsiteCmd_Bad(t *testing.T) { + oldDownloadAndPackageWebsite := website.DownloadAndPackageWebsite + website.DownloadAndPackageWebsite = func(startURL string, maxDepth int, bar *progressbar.ProgressBar) (*datanode.DataNode, error) { + return nil, fmt.Errorf("website error") + } + defer func() { + website.DownloadAndPackageWebsite = oldDownloadAndPackageWebsite + }() + + rootCmd := NewRootCmd() + rootCmd.AddCommand(collectCmd) + + _, err := executeCommand(rootCmd, "collect", "website", "https://example.com", "--output", "/dev/null") + if err == nil { + t.Fatalf("expected an error, but got none") + } +} diff --git a/cmd/root.go b/cmd/root.go index 651f2f7..9cadb27 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,7 +7,6 @@ import ( "github.com/spf13/cobra" ) -// NewRootCmd constructs the root cobra.Command for the Borg CLI and wires common flags. func NewRootCmd() *cobra.Command { rootCmd := &cobra.Command{ Use: "borg", diff --git a/cmd/serve.go b/cmd/serve.go index 15d333d..87e225f 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -62,7 +62,6 @@ var serveCmd = &cobra.Command{ }, } -// init registers the 'serve' command and its flags with the root command. func init() { RootCmd.AddCommand(serveCmd) serveCmd.PersistentFlags().String("port", "8080", "Port to serve the PWA on") diff --git a/main.go b/main.go index 41bdf81..54ad137 100644 --- a/main.go +++ b/main.go @@ -10,12 +10,7 @@ import ( var osExit = os.Exit func main() { - verbose, _ := cmd.RootCmd.PersistentFlags().GetBool("verbose") - log := logger.New(verbose) - if err := cmd.Execute(log); err != nil { - log.Error("fatal error", "err", err) - osExit(1) - } + Main() } func Main() { verbose, _ := cmd.RootCmd.PersistentFlags().GetBool("verbose") diff --git a/pkg/datanode/datanode.go b/pkg/datanode/datanode.go index 881ac8c..fe2f43b 100644 --- a/pkg/datanode/datanode.go +++ b/pkg/datanode/datanode.go @@ -260,35 +260,19 @@ type dataFile struct { modTime time.Time } -// Stat implements fs.File.Stat for a dataFile and returns its FileInfo. func (d *dataFile) Stat() (fs.FileInfo, error) { return &dataFileInfo{file: d}, nil } - -// Read implements fs.File.Read for a dataFile and reports EOF as files are in-memory. func (d *dataFile) Read(p []byte) (int, error) { return 0, io.EOF } - -// Close implements fs.File.Close for a dataFile; it's a no-op. -func (d *dataFile) Close() error { return nil } +func (d *dataFile) Close() error { return nil } // dataFileInfo implements fs.FileInfo for a dataFile. type dataFileInfo struct{ file *dataFile } -// Name returns the base name of the data file. -func (d *dataFileInfo) Name() string { return path.Base(d.file.name) } - -// Size returns the size of the data file in bytes. -func (d *dataFileInfo) Size() int64 { return int64(len(d.file.content)) } - -// Mode returns the file mode for data files (read-only). -func (d *dataFileInfo) Mode() fs.FileMode { return 0444 } - -// ModTime returns the modification time of the data file. +func (d *dataFileInfo) Name() string { return path.Base(d.file.name) } +func (d *dataFileInfo) Size() int64 { return int64(len(d.file.content)) } +func (d *dataFileInfo) Mode() fs.FileMode { return 0444 } func (d *dataFileInfo) ModTime() time.Time { return d.file.modTime } - -// IsDir reports whether the entry is a directory (false for data files). -func (d *dataFileInfo) IsDir() bool { return false } - -// Sys returns system-specific data, which is nil for data files. -func (d *dataFileInfo) Sys() interface{} { return nil } +func (d *dataFileInfo) IsDir() bool { return false } +func (d *dataFileInfo) Sys() interface{} { return nil } // dataFileReader implements fs.File for a dataFile. type dataFileReader struct { @@ -296,18 +280,13 @@ type dataFileReader struct { reader *bytes.Reader } -// Stat returns the FileInfo for the underlying data file. func (d *dataFileReader) Stat() (fs.FileInfo, error) { return d.file.Stat() } - -// Read reads from the underlying data file content. func (d *dataFileReader) Read(p []byte) (int, error) { if d.reader == nil { d.reader = bytes.NewReader(d.file.content) } return d.reader.Read(p) } - -// Close closes the data file reader (no-op). func (d *dataFileReader) Close() error { return nil } // dirInfo implements fs.FileInfo for an implicit directory. @@ -316,23 +295,12 @@ type dirInfo struct { modTime time.Time } -// Name returns the directory name. -func (d *dirInfo) Name() string { return d.name } - -// Size returns the size of the directory entry (always 0 for virtual dirs). -func (d *dirInfo) Size() int64 { return 0 } - -// Mode returns the file mode for directories. -func (d *dirInfo) Mode() fs.FileMode { return fs.ModeDir | 0555 } - -// ModTime returns the modification time of the directory. +func (d *dirInfo) Name() string { return d.name } +func (d *dirInfo) Size() int64 { return 0 } +func (d *dirInfo) Mode() fs.FileMode { return fs.ModeDir | 0555 } func (d *dirInfo) ModTime() time.Time { return d.modTime } - -// IsDir reports that the entry is a directory. -func (d *dirInfo) IsDir() bool { return true } - -// Sys returns system-specific data, which is nil for dirs. -func (d *dirInfo) Sys() interface{} { return nil } +func (d *dirInfo) IsDir() bool { return true } +func (d *dirInfo) Sys() interface{} { return nil } // dirFile implements fs.File for a directory. type dirFile struct { @@ -340,15 +308,10 @@ type dirFile struct { modTime time.Time } -// Stat returns the FileInfo for the directory. func (d *dirFile) Stat() (fs.FileInfo, error) { return &dirInfo{name: path.Base(d.path), modTime: d.modTime}, nil } - -// Read is invalid for directories and returns an error. func (d *dirFile) Read([]byte) (int, error) { return 0, &fs.PathError{Op: "read", Path: d.path, Err: fs.ErrInvalid} } - -// Close closes the directory file (no-op). func (d *dirFile) Close() error { return nil } diff --git a/pkg/github/github.go b/pkg/github/github.go index b21875b..75bc61e 100644 --- a/pkg/github/github.go +++ b/pkg/github/github.go @@ -107,7 +107,6 @@ func (g *githubClient) getPublicReposWithAPIURL(ctx context.Context, apiURL, use return allCloneURLs, nil } -// findNextURL parses the HTTP Link header and returns the URL with rel="next", if any. func (g *githubClient) findNextURL(linkHeader string) string { links := strings.Split(linkHeader, ",") for _, link := range links { diff --git a/pkg/github/github_test.go b/pkg/github/github_test.go index 229d7f6..f390e0d 100644 --- a/pkg/github/github_test.go +++ b/pkg/github/github_test.go @@ -107,3 +107,18 @@ func TestFindNextURL(t *testing.T) { t.Errorf("unexpected next URL: %s", nextURL) } } + +func TestNewAuthenticatedClient(t *testing.T) { + // Test with no token + client := NewAuthenticatedClient(context.Background()) + if client != http.DefaultClient { + t.Errorf("expected http.DefaultClient, but got something else") + } + + // Test with token + t.Setenv("GITHUB_TOKEN", "test-token") + client = NewAuthenticatedClient(context.Background()) + if client == http.DefaultClient { + t.Errorf("expected an authenticated client, but got http.DefaultClient") + } +} diff --git a/pkg/github/release_test.go b/pkg/github/release_test.go index 5bf89f2..bb6b6c6 100644 --- a/pkg/github/release_test.go +++ b/pkg/github/release_test.go @@ -46,6 +46,9 @@ func TestParseRepoFromURL(t *testing.T) { } func TestGetLatestRelease(t *testing.T) { + oldNewClient := NewClient + t.Cleanup(func() { NewClient = oldNewClient }) + mockClient := mocks.NewMockClient(map[string]*http.Response{ "https://api.github.com/repos/owner/repo/releases/latest": { StatusCode: http.StatusOK, @@ -115,6 +118,9 @@ func TestDownloadReleaseAsset_BadRequest(t *testing.T) { }() _, err := DownloadReleaseAsset(asset) + if err == nil { + t.Fatalf("expected error but got nil") + } if err.Error() != expectedErr { t.Fatalf("DownloadReleaseAsset failed: %v", err) } @@ -141,6 +147,9 @@ func TestDownloadReleaseAsset_NewRequestError(t *testing.T) { } func TestGetLatestRelease_Error(t *testing.T) { + oldNewClient := NewClient + t.Cleanup(func() { NewClient = oldNewClient }) + u, _ := url.Parse("https://api.github.com/repos/owner/repo/releases/latest") mockClient := mocks.NewMockClient(map[string]*http.Response{ "https://api.github.com/repos/owner/repo/releases/latest": { @@ -183,3 +192,23 @@ func TestDownloadReleaseAsset_DoError(t *testing.T) { t.Fatalf("DownloadReleaseAsset should have failed") } } +func TestParseRepoFromURL_More(t *testing.T) { + testCases := []struct { + url string + owner string + repo string + expectErr bool + }{ + {"git:github.com:owner/repo.git", "owner", "repo", false}, + } + + for _, tc := range testCases { + owner, repo, err := ParseRepoFromURL(tc.url) + if (err != nil) != tc.expectErr { + t.Errorf("unexpected error for URL %s: %v", tc.url, err) + } + if owner != tc.owner || repo != tc.repo { + t.Errorf("unexpected owner/repo for URL %s: %s/%s", tc.url, owner, repo) + } + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 5984095..0dfc2d2 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -5,8 +5,6 @@ import ( "os" ) -// New constructs a slog.Logger configured for stderr with the given verbosity. -// When verbose is true, the logger emits debug-level messages; otherwise info-level. func New(verbose bool) *slog.Logger { level := slog.LevelInfo if verbose { diff --git a/pkg/pwa/pwa.go b/pkg/pwa/pwa.go index 498f5f9..b6b3cf5 100644 --- a/pkg/pwa/pwa.go +++ b/pkg/pwa/pwa.go @@ -99,6 +99,10 @@ func (p *pwaClient) DownloadAndPackagePWA(pwaURL, manifestURL string, bar *progr } defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("failed to download %s: status code %d", assetURL, resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read body of %s: %w", assetURL, err) @@ -158,13 +162,14 @@ func (p *pwaClient) DownloadAndPackagePWA(pwaURL, manifestURL string, bar *progr // Skip icons with bad URLs continue } - downloadAndAdd(iconURL.String()) + if err := downloadAndAdd(iconURL.String()); err != nil { + return nil, err + } } return dn, nil } -// resolveURL resolves ref against base and returns the absolute URL. func (p *pwaClient) resolveURL(base, ref string) (*url.URL, error) { baseURL, err := url.Parse(base) if err != nil { diff --git a/pkg/pwa/pwa_test.go b/pkg/pwa/pwa_test.go index db64c03..6929aef 100644 --- a/pkg/pwa/pwa_test.go +++ b/pkg/pwa/pwa_test.go @@ -8,7 +8,7 @@ import ( "github.com/schollz/progressbar/v3" ) -func newTestPWAClient(serverURL string) PWAClient { +func newTestPWAClient() PWAClient { return NewPWAClient() } @@ -30,7 +30,7 @@ func TestFindManifest(t *testing.T) { })) defer server.Close() - client := newTestPWAClient(server.URL) + client := newTestPWAClient() expectedURL := server.URL + "/manifest.json" actualURL, err := client.FindManifest(server.URL) if err != nil { @@ -85,7 +85,7 @@ func TestDownloadAndPackagePWA(t *testing.T) { })) defer server.Close() - client := newTestPWAClient(server.URL) + client := newTestPWAClient() bar := progressbar.New(1) dn, err := client.DownloadAndPackagePWA(server.URL, server.URL+"/manifest.json", bar) if err != nil { @@ -131,3 +131,35 @@ func TestResolveURL(t *testing.T) { } } } + +func TestPWA_Bad(t *testing.T) { + client := NewPWAClient() + + // Test FindManifest with no manifest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(` + + +
+