diff --git a/changelog/unreleased/pull-21795 b/changelog/unreleased/pull-21795 new file mode 100644 index 000000000..e9e93a4d5 --- /dev/null +++ b/changelog/unreleased/pull-21795 @@ -0,0 +1,6 @@ +Enhancement: add docs command + +Added a new `docs` command (with user and dev subcommands) to easily open +restic's documentation in the default web browser across Linux, macOS, and Windows. + +https://github.com/restic/restic/pull/21795 diff --git a/cmd/restic/cmd_docs.go b/cmd/restic/cmd_docs.go new file mode 100644 index 000000000..938c4e6a8 --- /dev/null +++ b/cmd/restic/cmd_docs.go @@ -0,0 +1,111 @@ +package main + +import ( + "fmt" + "io" + "log" + "os" + "os/exec" + "regexp" + "runtime" + "strings" + + "github.com/spf13/cobra" + + "github.com/restic/restic/internal/global" +) + +const ( + GOOS string = runtime.GOOS + ResticDocsURL string = "https://restic.readthedocs.io/en" +) + +type execFn func(name string, arg ...string) *exec.Cmd + +var ( + stdout io.Writer = os.Stdout + start execFn = exec.Command + versionRegex *regexp.Regexp = regexp.MustCompile(`^(\d+\.\d+\.\d+)`) +) + +// newDocsCommand is the `docs` subcommand entry point, +// using `restic docs` or `restic docs user` or `restic docs dev`. +// It opens the respective documetation in your chosen default browser. +func newDocsCommand(globalOptions *global.Options) *cobra.Command { + _ = globalOptions + + cmd := &cobra.Command{ + Use: "docs", + Short: "Opens the documentation in the default browser", + Run: func(_ *cobra.Command, _ []string) { + docsURL := docsURLForVersion(global.Version) + openDocs(GOOS, docsURL, "user") + }, + } + + cmd.AddCommand(&cobra.Command{ + Use: "user", + Short: "Show the user documentation", + Run: func(_ *cobra.Command, _ []string) { + docsURL := fmt.Sprintf("%s/stable", ResticDocsURL) + openDocs(GOOS, docsURL, "user") + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "dev", + Short: "Show the developer documentation", + Run: func(_ *cobra.Command, _ []string) { + docsURL := fmt.Sprintf("%s/latest", ResticDocsURL) + openDocs(GOOS, docsURL, "developer") + }, + }) + + return cmd +} + +// docsURLForVersion is a function that returns a the URL documentation as a string. +// it takes a version string as a "v1.2.3" as an input. +func docsURLForVersion(version string) string { + // Safe default fallback for empty/unknown versions + if version == "" || version == "unknown" { + return fmt.Sprintf("%s/stable", ResticDocsURL) + } + + // Route development builds / local compiled binaries directly to bleeding edge docs + if strings.Contains(version, "dev") || strings.Contains(version, "compiled") { + return fmt.Sprintf("%s/latest", ResticDocsURL) + } + + // Match strict tag releases (e.g., exact matches like "0.18.1") + matches := versionRegex.FindStringSubmatch(version) + if len(matches) == 2 { + return fmt.Sprintf("%s/v%s", ResticDocsURL, matches[1]) + } + + // Return the stable docs if all checks fail + return fmt.Sprintf("%s/stable", ResticDocsURL) +} + +// openDocs is a function that takes in the current operating system platform, documentation url and its type. +// It basically opens the documentation in your chosen default browser. +func openDocs(GOOS string, url string, docType string) { + _, _ = fmt.Fprintf(stdout, "Opening the %s documentation at %s\n", docType, url) + + var cmd *exec.Cmd + + switch GOOS { + case "linux": + cmd = start("xdg-open", url) + case "windows": + cmd = start("rundll32", "url.dll,FileProtocolHandler", url) + case "darwin": + cmd = start("open", url) + default: + log.Fatalf("Unsupported platform: %s", runtime.GOOS) + } + + if err := cmd.Start(); err != nil { + log.Fatalf("Failed to open browser: %v", err) + } +} diff --git a/cmd/restic/cmd_docs_test.go b/cmd/restic/cmd_docs_test.go new file mode 100644 index 000000000..f28b954fc --- /dev/null +++ b/cmd/restic/cmd_docs_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" + "testing" + + "github.com/restic/restic/internal/global" +) + +func TestNewDocsCommand(t *testing.T) { + cmd := newDocsCommand(&global.Options{}) + + if cmd.Use != "docs" { + t.Errorf("expected command Use 'docs' got %q", cmd.Use) + } + + subcommands := []struct { + name string + short string + }{ + {"user", "Show the user documentation"}, + {"dev", "Show the developer documentation"}, + } + + for _, sc := range subcommands { + found := false + for _, sub := range cmd.Commands() { + if sub.Name() == sc.name { + found = true + if sub.Short != sc.short { + t.Errorf("expected short description %q for %q, got %q", sc.short, sc.name, sub.Short) + } + } + } + if !found { + t.Errorf("expected subcommand %q not found", sc.name) + } + } +} + +func TestDocsURLForVersion(t *testing.T) { + // Dynamically build the expected URLs using the package's base constant + stableURL := fmt.Sprintf("%s/stable", ResticDocsURL) + latestURL := fmt.Sprintf("%s/latest", ResticDocsURL) + tagURL := func(tag string) string { + return fmt.Sprintf("%s/v%s", ResticDocsURL, tag) + } + + tests := []struct { + name string + version string + want string + }{ + // --- 1. Stable Tag Releases --- + {name: "Exact Release Tag", version: "0.18.1", want: tagURL("0.18.1")}, + {name: "Release Tag with Patch", version: "1.23.4", want: tagURL("1.23.4")}, + + // --- 2. Development & Bleeding Edge Builds --- + {name: "Dev Build with Suffix", version: "0.18.1-dev", want: latestURL}, + {name: "Manually Compiled Binary", version: "0.18.1 (compiled manually)", want: latestURL}, + {name: "Pure Dev Keyword", version: "dev", want: latestURL}, + + // --- 3. Fallbacks & Unknown States --- + {name: "Explicit Unknown Keyword", version: "unknown", want: stableURL}, + {name: "Empty String Fallback", version: "", want: stableURL}, + {name: "Malformed Version Fallback", version: "my-custom-version-string", want: stableURL}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := docsURLForVersion(tt.version); got != tt.want { + t.Errorf("docsURLForVersion(%q) = %q, want %q", tt.version, got, tt.want) + } + }) + } +} + +func TestOpenDocs(t *testing.T) { + stableURL := fmt.Sprintf("%s/stable", ResticDocsURL) + latestURL := fmt.Sprintf("%s/latest", ResticDocsURL) + tagURL := func(tag string) string { + return fmt.Sprintf("%s/v%s", ResticDocsURL, tag) + } + + tests := []struct { + name string + goos string + url string + docType string + wantBin string + wantArg string + }{ + // --- 1. TestOpenDocs on Linux + {"Linux version", "linux", tagURL("v0.18.1"), "user", "xdg-open", tagURL("v0.18.1")}, + {"Linux User", "linux", stableURL, "user", "xdg-open", stableURL}, + {"Linux Dev", "linux", latestURL, "developer", "xdg-open", latestURL}, + + // --- 2. TestOpenDocs on MacOS + {"Mac Version", "darwin", tagURL("v0.18.1"), "user", "open", tagURL("v0.18.1")}, + {"Mac User", "darwin", stableURL, "user", "open", stableURL}, + {"Mac Dev", "darwin", latestURL, "developer", "open", latestURL}, + + // --- 3. TestOpenDocs on Windows + {"Windows Version", "windows", tagURL("v0.18.1"), "user", "rundll32", "url.dll,FileProtocolHandler"}, + {"Windows User", "windows", stableURL, "user", "rundll32", "url.dll,FileProtocolHandler"}, + {"Windows Dev", "windows", latestURL, "developer", "rundll32", "url.dll,FileProtocolHandler"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + stdout = &buf // Capture console output + + var capturedBin string + var capturedArgs []string + + // Mock the execution + originalStart := start + start = func(name string, arg ...string) *exec.Cmd { + capturedBin = name + capturedArgs = arg + return exec.Command("echo") + } + + // Cleanup the global state + defer func() { + stdout = os.Stdout + start = originalStart + }() + + openDocs(tt.goos, tt.url, tt.docType) + + // Test 1. Verify Command Binary + if capturedBin != tt.wantBin { + t.Errorf("Binary mismatch: expected %q, got %q", tt.wantBin, capturedBin) + } + + // Test 2. Verify Command Arguments + argsJoined := strings.Join(capturedArgs, " ") + if !strings.Contains(argsJoined, tt.wantArg) { + t.Errorf("Args mismatch: expected %q, got %q", tt.wantArg, argsJoined) + } + + // Test 3. Verify Console Output Message + output := buf.String() + if !strings.Contains(output, tt.url) || !strings.Contains(output, tt.docType) { + t.Errorf("Console output mismatch. Got: %q", output) + } + }) + } +} diff --git a/cmd/restic/main.go b/cmd/restic/main.go index 619eee642..fed8539c8 100644 --- a/cmd/restic/main.go +++ b/cmd/restic/main.go @@ -102,6 +102,7 @@ The full documentation can be found at https://restic.readthedocs.io/ . newStatsCommand(globalOptions), newTagCommand(globalOptions), newUnlockCommand(globalOptions), + newDocsCommand(globalOptions), newVersionCommand(globalOptions), ) @@ -118,7 +119,7 @@ The full documentation can be found at https://restic.readthedocs.io/ . // user for authentication). func needsPassword(cmd string) bool { switch cmd { - case "cache", "generate", "help", "options", "self-update", "version", "__complete", "__completeNoDesc": + case "cache", "docs", "generate", "help", "options", "self-update", "version", "__complete", "__completeNoDesc": return false default: return true