This commit is contained in:
Brook 2026-05-20 17:24:13 +00:00 committed by GitHub
commit a0002752a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 274 additions and 1 deletions

View file

@ -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

111
cmd/restic/cmd_docs.go Normal file
View file

@ -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)
}
}

155
cmd/restic/cmd_docs_test.go Normal file
View file

@ -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)
}
})
}
}

View file

@ -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