diff --git a/cmd/restic/cmd_forget.go b/cmd/restic/cmd_forget.go index 4debaef3b..4afef1380 100644 --- a/cmd/restic/cmd_forget.go +++ b/cmd/restic/cmd_forget.go @@ -27,14 +27,14 @@ data after 'forget' was run successfully, see the 'prune' command. `, // ForgetOptions collects all options for the forget command. type ForgetOptions struct { - Last int - Hourly int - Daily int - Weekly int - Monthly int - Yearly int - WithinDays int - KeepTags restic.TagLists + Last int + Hourly int + Daily int + Weekly int + Monthly int + Yearly int + Within restic.Duration + KeepTags restic.TagLists Host string Tags restic.TagLists @@ -59,7 +59,7 @@ func init() { f.IntVarP(&forgetOptions.Weekly, "keep-weekly", "w", 0, "keep the last `n` weekly snapshots") f.IntVarP(&forgetOptions.Monthly, "keep-monthly", "m", 0, "keep the last `n` monthly snapshots") f.IntVarP(&forgetOptions.Yearly, "keep-yearly", "y", 0, "keep the last `n` yearly snapshots") - f.IntVar(&forgetOptions.WithinDays, "keep-within", 0, "keep snapshots that were created within `days` before the newest") + f.VarP(&forgetOptions.Within, "keep-within", "", "keep snapshots that were created within `duration` before the newest (e.g. 1y5m7d)") f.Var(&forgetOptions.KeepTags, "keep-tag", "keep snapshots with this `taglist` (can be specified multiple times)") // Sadly the commonly used shortcut `H` is already used. @@ -172,7 +172,7 @@ func runForget(opts ForgetOptions, gopts GlobalOptions, args []string) error { Weekly: opts.Weekly, Monthly: opts.Monthly, Yearly: opts.Yearly, - Within: opts.WithinDays, + Within: opts.Within, Tags: opts.KeepTags, } diff --git a/internal/restic/duration.go b/internal/restic/duration.go new file mode 100644 index 000000000..09289849b --- /dev/null +++ b/internal/restic/duration.go @@ -0,0 +1,131 @@ +package restic + +import ( + "fmt" + "strconv" + "strings" + "unicode" + + "github.com/restic/restic/internal/errors" +) + +// Duration is similar to time.Duration, except it only supports larger ranges +// like days, months, and years. +type Duration struct { + Days, Months, Years int +} + +func (d Duration) String() string { + var s string + if d.Years != 0 { + s += fmt.Sprintf("%dy", d.Years) + } + + if d.Months != 0 { + s += fmt.Sprintf("%dm", d.Months) + } + + if d.Days != 0 { + s += fmt.Sprintf("%dd", d.Days) + } + + return s +} + +func nextNumber(input string) (num int, rest string, err error) { + if len(input) == 0 { + return 0, "", nil + } + + var ( + n string + negative bool + ) + + if input[0] == '-' { + negative = true + input = input[1:] + } + + for i, s := range input { + if !unicode.IsNumber(s) { + rest = input[i:] + break + } + + n += string(s) + } + + if len(n) == 0 { + return 0, input, errors.New("no number found") + } + + num, err = strconv.Atoi(n) + if err != nil { + panic(err) + } + + if negative { + num = -num + } + + return num, rest, nil +} + +// ParseDuration parses a duration from a string. The format is: +// 6y5m234d +func ParseDuration(s string) (Duration, error) { + var ( + d Duration + num int + err error + ) + + s = strings.TrimSpace(s) + + for s != "" { + num, s, err = nextNumber(s) + if err != nil { + return Duration{}, err + } + + if len(s) == 0 { + return Duration{}, errors.Errorf("no unit found after number %d", num) + } + + switch s[0] { + case 'y': + d.Years = num + case 'm': + d.Months = num + case 'd': + d.Days = num + } + + s = s[1:] + } + + return d, nil +} + +// Set calls ParseDuration and updates d. +func (d *Duration) Set(s string) error { + v, err := ParseDuration(s) + if err != nil { + return err + } + + *d = v + return nil +} + +// Type returns the type of Duration, usable within github.com/spf13/pflag and +// in help texts. +func (d Duration) Type() string { + return "duration" +} + +// Zero returns true if the duration is empty (all values are set to zero). +func (d Duration) Zero() bool { + return d.Years == 0 && d.Months == 0 && d.Days == 0 +} diff --git a/internal/restic/duration_test.go b/internal/restic/duration_test.go new file mode 100644 index 000000000..0d5306069 --- /dev/null +++ b/internal/restic/duration_test.go @@ -0,0 +1,82 @@ +package restic + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestNextNumber(t *testing.T) { + var tests = []struct { + input string + num int + rest string + err bool + }{ + { + input: "3d", num: 3, rest: "d", + }, + { + input: "7m5d", num: 7, rest: "m5d", + }, + { + input: "-23y7m5d", num: -23, rest: "y7m5d", + }, + { + input: " 5d", num: 0, rest: " 5d", err: true, + }, + { + input: "5d ", num: 5, rest: "d ", + }, + } + + for _, test := range tests { + t.Run("", func(t *testing.T) { + num, rest, err := nextNumber(test.input) + + if err != nil && !test.err { + t.Fatal(err) + } + + if num != test.num { + t.Errorf("wrong num, want %d, got %d", test.num, num) + } + + if rest != test.rest { + t.Errorf("wrong rest, want %q, got %q", test.rest, rest) + } + }) + } +} + +func TestParseDuration(t *testing.T) { + var tests = []struct { + input string + d Duration + output string + }{ + {"3d", Duration{Days: 3}, "3d"}, + {"7m5d", Duration{Months: 7, Days: 5}, "7m5d"}, + {"5d7m", Duration{Months: 7, Days: 5}, "7m5d"}, + {"-7m5d", Duration{Months: -7, Days: 5}, "-7m5d"}, + {"2y7m-5d", Duration{Years: 2, Months: 7, Days: -5}, "2y7m-5d"}, + } + + for _, test := range tests { + t.Run("", func(t *testing.T) { + d, err := ParseDuration(test.input) + if err != nil { + t.Fatal(err) + } + + if !cmp.Equal(d, test.d) { + t.Error(cmp.Diff(test.d, d)) + } + + s := d.String() + if s != test.output { + t.Errorf("unexpected return of String(), want %q, got %q", test.output, s) + } + }) + } +} diff --git a/internal/restic/snapshot_policy.go b/internal/restic/snapshot_policy.go index 244c19140..df142c0fb 100644 --- a/internal/restic/snapshot_policy.go +++ b/internal/restic/snapshot_policy.go @@ -16,7 +16,7 @@ type ExpirePolicy struct { Weekly int // keep the last n weekly snapshots Monthly int // keep the last n monthly snapshots Yearly int // keep the last n yearly snapshots - Within int // keep snapshots made within this number of days since the newest snapshot + Within Duration // keep snapshots made within this duration Tags []TagList // keep all snapshots that include at least one of the tag lists. } @@ -40,11 +40,26 @@ func (e ExpirePolicy) String() (s string) { if e.Yearly > 0 { keeps = append(keeps, fmt.Sprintf("%d yearly", e.Yearly)) } - if e.Within != 0 { - keeps = append(keeps, fmt.Sprintf("snapshots within %d days of the newest snapshot", e.Within)) + + if len(keeps) > 0 { + s = fmt.Sprintf("keep the last %s snapshots", strings.Join(keeps, ", ")) } - return fmt.Sprintf("keep the last %s snapshots", strings.Join(keeps, ", ")) + if len(e.Tags) > 0 { + if s != "" { + s += " and " + } + s += fmt.Sprintf("all snapshots with tags %s", e.Tags) + } + + if !e.Within.Zero() { + if s != "" { + s += " and " + } + s += fmt.Sprintf("all snapshots within %s of the newest", e.Within) + } + + return s } // Sum returns the maximum number of snapshots to be kept according to this @@ -149,8 +164,8 @@ func ApplyPolicy(list Snapshots, p ExpirePolicy) (keep, remove Snapshots) { } // If the timestamp of the snapshot is within the range, then keep it. - if p.Within != 0 { - t := latest.AddDate(0, 0, -p.Within) + if !p.Within.Zero() { + t := latest.AddDate(-p.Within.Years, -p.Within.Months, -p.Within.Days) if cur.Time.After(t) { keepSnap = true } diff --git a/internal/restic/snapshot_policy_test.go b/internal/restic/snapshot_policy_test.go index 9aa5200f1..f2e0605e3 100644 --- a/internal/restic/snapshot_policy_test.go +++ b/internal/restic/snapshot_policy_test.go @@ -21,8 +21,8 @@ func parseTimeUTC(s string) time.Time { return t.UTC() } -func parseDuration(s string) time.Duration { - d, err := time.ParseDuration(s) +func parseDuration(s string) restic.Duration { + d, err := restic.ParseDuration(s) if err != nil { panic(err) } @@ -180,10 +180,10 @@ var expireTests = []restic.ExpirePolicy{ {Tags: []restic.TagList{{"foo"}}}, {Tags: []restic.TagList{{"foo", "bar"}}}, {Tags: []restic.TagList{{"foo"}, {"bar"}}}, - {Within: 1}, - {Within: 2}, - {Within: 7}, - {Within: 30}, + {Within: parseDuration("1d")}, + {Within: parseDuration("2d")}, + {Within: parseDuration("7d")}, + {Within: parseDuration("1m")}, } func TestApplyPolicy(t *testing.T) {