Compare commits

...
Author SHA1 Message Date
nielashandNick Craig-Wood 5d3ed12fab Add named profiles for saving flags and arguments - fixes #2697
Add a way to save a combination of flags (and optionally the
positional arguments) under a name in the config file and reuse it
on later command lines, so users don't have to retype long flag
combinations every time.

    rclone sync src: dst: --checkers 16 --metadata --profile-save fast
    rclone sync src: dst: --profile fast

Only flags whose values were explicitly changed are saved. The
profile-control flags themselves (--profile, --profile-save,
--profile-save-args, --profile-strict-flags) and --config (which is
environment-specific) are never persisted.

Multiple profiles can be applied in one go, in lowest-to-highest
priority order:

    rclone sync src: dst: --profile common,fast

Explicit command-line flags always win over the profile. For safety a
profile can never turn off --dry-run if it was set on the command
line.

Commands:

    rclone config profile save NAME [args...] [flags...]
    rclone config profile list
    rclone config profile show NAME
    rclone config profile delete NAME

Flags:

    --profile NAME[,NAME...]       apply one or more profiles
    --profile-save NAME            save the current flags as a profile
    --profile-save-args            also save positional args
    --profile-strict-flags         error on unknown flags from a profile

Co-Authored-By: Nick Craig-Wood <nick@craig-wood.com>
2026-05-13 20:08:21 +01:00
Nick Craig-Wood 29809bb8e4 docs: upgrade protondrive to tier 4 tests pass and community maintains 2026-05-12 12:04:50 +01:00
14 changed files with 1505 additions and 6 deletions

No files matched your search

+1
View File
@@ -20,3 +20,4 @@ __pycache__
.DS_Store
resource_windows_*.syso
.devcontainer
.vscode/launch.json
+2
View File
@@ -25,6 +25,7 @@ import (
"github.com/rclone/rclone/fs/cache"
"github.com/rclone/rclone/fs/config/configfile"
"github.com/rclone/rclone/fs/config/configflags"
profiles "github.com/rclone/rclone/fs/config/configprofiles"
"github.com/rclone/rclone/fs/config/flags"
"github.com/rclone/rclone/fs/filter"
"github.com/rclone/rclone/fs/fserrors"
@@ -536,6 +537,7 @@ func AddBackendFlags() {
func Main() {
setupRootCommand(Root)
AddBackendFlags()
profiles.AddProfiles(context.Background(), Root)
if err := Root.Execute(); err != nil {
if strings.HasPrefix(err.Error(), "unknown command") && selfupdateEnabled {
Root.PrintErrf("You could use '%s selfupdate' to get latest features.\n\n", Root.CommandPath())
+16
View File
@@ -26,6 +26,7 @@ func init() {
configCommand.AddCommand(configTouchCommand)
configCommand.AddCommand(configPathsCommand)
configCommand.AddCommand(configShowCommand)
configCommand.AddCommand(configProfileCommand)
configCommand.AddCommand(configRedactedCommand)
configCommand.AddCommand(configDumpCommand)
configCommand.AddCommand(configProvidersCommand)
@@ -123,6 +124,21 @@ var configShowCommand = &cobra.Command{
},
}
var configProfileCommand = &cobra.Command{
Use: "profile",
Short: `Manage saved profiles of flags.`,
Long: `Manage saved profiles of flags, stored in the config file under
"[profile:NAME]" sections.
Use the save, list, show and delete subcommands. See ` + "`rclone config profile --help`" + `
for the full list, and the [Profiles](/docs/#profiles) section of the
docs for an overview of the feature.`,
Annotations: map[string]string{
"versionIntroduced": "v1.66",
},
Aliases: []string{"profiles"},
}
var configRedactedCommand = &cobra.Command{
Use: "redacted [<remote>]",
Short: `Print redacted (decrypted) config file, or the redacted config for a single remote.`,
+161
View File
@@ -0,0 +1,161 @@
// Profile subcommands for `rclone config profile`.
package config
import (
"context"
"errors"
"fmt"
"os"
"github.com/rclone/rclone/cmd"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
profiles "github.com/rclone/rclone/fs/config/configprofiles"
"github.com/rclone/rclone/fs/config/flags"
"github.com/spf13/cobra"
)
func init() {
configProfileCommand.AddCommand(configProfileListCommand)
configProfileCommand.AddCommand(configProfileShowCommand)
configProfileCommand.AddCommand(configProfileDeleteCommand)
configProfileCommand.AddCommand(configProfileSaveCommand)
flags.FVarP(configProfileSaveCommand.Flags(), &profileSaveFlagsFrom,
"flags-from", "",
"Import command-specific flags from these subcommands (comma separated)", "")
configProfileSaveCommand.PersistentPreRunE = profileSaveAddFlagsFrom
configProfileSaveCommand.FParseErrWhitelist.UnknownFlags = true
}
// profileSaveFlagsFrom backs --flags-from on `rclone config profile save`.
var profileSaveFlagsFrom fs.CommaSepList
var configProfileListCommand = &cobra.Command{
Use: "list",
Short: "List the profiles in the config file.",
Annotations: map[string]string{
"versionIntroduced": "v1.72",
},
RunE: func(command *cobra.Command, args []string) error {
cmd.CheckArgs(0, 0, command, args)
profiles.ShowProfiles()
return nil
},
}
var configProfileShowCommand = &cobra.Command{
Use: "show NAME",
Short: "Show the flags saved in a profile.",
Annotations: map[string]string{
"versionIntroduced": "v1.72",
},
RunE: func(command *cobra.Command, args []string) error {
cmd.CheckArgs(1, 1, command, args)
name := args[0]
section := config.ProfileSectionPrefix + name
if !config.LoadedData().HasSection(section) {
return fmt.Errorf("no profile named %q", name)
}
fmt.Print(profiles.SprintSection(section))
return nil
},
}
var configProfileDeleteCommand = &cobra.Command{
Use: "delete NAME",
Short: "Delete a profile.",
Annotations: map[string]string{
"versionIntroduced": "v1.72",
},
RunE: func(command *cobra.Command, args []string) error {
cmd.CheckArgs(1, 1, command, args)
name := args[0]
section := config.ProfileSectionPrefix + name
if !config.LoadedData().HasSection(section) {
return fmt.Errorf("no profile named %q", name)
}
config.LoadedData().DeleteSection(section)
if err := config.LoadedData().Save(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
},
}
var configProfileSaveCommand = &cobra.Command{
Use: "save NAME [args...]",
Short: "Save the given flags (and optional args) as a reusable profile.",
Long: `Save a profile under NAME built from the flags (and, if
` + "`--profile-save-args`" + ` is set, the positional arguments) passed
on this command line.
Without ` + "`--flags-from`" + ` only global rclone flags can be saved. To
save flags that belong to a specific command (e.g. VFS flags from
` + "`rclone mount`" + ` or bisync-specific flags), import that command's
flag set with ` + "`--flags-from CMD[,CMD...]`" + `.
`,
Annotations: map[string]string{
"versionIntroduced": "v1.72",
},
RunE: func(command *cobra.Command, args []string) error {
cmd.CheckArgs(1, 100, command, args)
name := args[0]
ctx := context.Background()
opt := profiles.ProfileOptFromCtx(ctx)
p := profiles.NewProfile(name)
p.NewFromCommand(command, args[1:])
return p.Save(ctx, &opt)
},
}
// profileSaveAddFlagsFrom is the PersistentPreRunE for
// `rclone config profile save`. It interprets --flags-from, merges in
// the requested commands' flag sets, then re-parses the command line so
// the newly-added flags pick up their values.
func profileSaveAddFlagsFrom(saveCmd *cobra.Command, args []string) error {
// The first parse was done with UnknownFlags=true so --verbose may
// have been counted; preserve its current value across the reparse.
var verboseSaved string
if vf := saveCmd.Flags().Lookup("verbose"); vf != nil {
verboseSaved = vf.Value.String()
defer func() {
if err := vf.Value.Set(verboseSaved); err != nil {
fs.Errorf(nil, "error restoring --verbose: %v", err)
}
}()
}
for _, sub := range profileSaveFlagsFrom {
sc, _, err := cmd.Root.Find([]string{sub})
if err != nil {
return fmt.Errorf("--flags-from %s: %w", sub, err)
}
if sc == cmd.Root {
return fmt.Errorf("--flags-from %s: not a known subcommand", sub)
}
saveCmd.Flags().AddFlagSet(sc.Flags())
}
// Re-parse strictly now that the flag set is complete.
saveCmd.FParseErrWhitelist.UnknownFlags = false
flagArgs, err := argsAfterCommand(saveCmd)
if err != nil {
return err
}
return saveCmd.ParseFlags(flagArgs)
}
// argsAfterCommand returns the portion of os.Args that came after the
// chain of cobra command names leading to cmd.
func argsAfterCommand(cmd *cobra.Command) ([]string, error) {
depth := 0
for c := cmd; c != nil; c = c.Parent() {
depth++
}
if depth > len(os.Args) {
return nil, errors.New("internal error: command depth exceeds os.Args length")
}
return os.Args[depth:], nil
}
+236
View File
@@ -542,6 +542,212 @@ If two remotes set the same global variable then the first one instantiated will
be overridden by the second one. A `global.var` will override all other config
methods when the remote is created.
## Profiles {#profiles}
Profiles let you save a named set of flags (and optionally arguments)
in the config file and reuse them on later command lines, so you
don't have to retype long flag combinations every time.
For example, if you regularly sync a directory to Google Drive with a
specific set of flags, you can save that combination once as a
profile and from then on use `--profile mybackup` instead.
### Saving a profile
Add `--profile-save NAME` to any rclone command. The command will run
as normal, and rclone will additionally write all the explicitly-set
flags from that command line into the config file under a new section
named `[profile:NAME]`. The `profile:` prefix is the namespace
profiles live in - because `:` is illegal in remote names, a profile
called `backup` and a remote called `backup` can coexist without
colliding.
For example:
```sh
rclone sync src: dst: --checkers 16 --transfers 8 --metadata --profile-save fast-sync
```
writes:
```ini
[profile:fast-sync]
checkers = 16
transfers = 8
metadata = true
```
Only flags whose values you explicitly changed are saved - flags left
at their default are not.
The profile-control flags themselves (`--profile-save`, `--profile`,
`--profile-save-args`, `--profile-strict-flags`) are never saved
into the profile. `--config` is also excluded - it's
environment-specific and would re-point the config storage mid-load
when applied from a profile.
### Using a profile
Add `--profile NAME` to apply a saved profile:
```sh
rclone sync src: dst: --profile fast-sync
```
This is equivalent to repeating all the flags from the saved profile
on the command line.
You can apply several profiles in one go by passing a comma-separated
list. They are applied in lowest-to-highest priority order, so later
profiles override earlier ones:
```sh
rclone sync src: dst: --profile common,fast-sync
```
Any flag you pass explicitly on the command line overrides the
profile's value for that flag, so you can use a profile as a baseline
and tweak individual values for one run.
For safety, a profile can never turn off `--dry-run` if it was set
explicitly on the command line.
### Including command arguments
By default `--profile-save` only saves flags. If you also want to
save the positional arguments (e.g. the source and destination paths)
add `--profile-save-args`:
```sh
rclone sync /local/path remote:backup --profile-save nightly --profile-save-args
```
When the profile is later used, saved arguments are applied
automatically - you don't need to pass anything extra at use time.
If you pass your own positional arguments on the command line, they
take precedence over the profile's saved ones (the profile only
fills slots you didn't provide).
### Strict flag checking
When you save a profile from one command (say `sync`) and then apply
it to another (say `copy`), the profile may contain flags that don't
exist on the new command. By default these flags are silently
ignored. Pass `--profile-strict-flags` to make rclone error out
instead of skipping them.
### Command-specific flags
Profiles work with the flag set of the command they're saved from
and applied to, so command-specific flags (e.g. the [VFS
flags](/commands/rclone_mount/#vfs-options) on `rclone mount`, or
bisync's `--resilient`) are saved and loaded just like global flags:
```sh
rclone mount remote: /mnt --vfs-cache-mode full --dir-cache-time 24h \
--profile-save media-mount
rclone mount remote: /mnt --profile media-mount
```
The resulting section looks like any other profile:
```ini
[profile:media-mount]
vfs_cache_mode = full
dir_cache_time = 24h0m0s
```
If you later apply this profile to a different command that doesn't
have those flags (e.g. `rclone copy --profile media-mount`), the
VFS flags are silently skipped - or rejected if you also pass
`--profile-strict-flags`.
To build a profile for command-specific flags without actually
running the command, use `rclone config profile save NAME --flags-from CMD`:
```sh
rclone config profile save media-mount --flags-from mount \
--vfs-cache-mode full --dir-cache-time 24h
```
### Inheriting from parent profiles
A profile can inherit settings from one or more parent profiles by
listing them in `parent_profiles` in the config:
```ini
[profile:shared]
checkers = 16
metadata = true
[profile:nightly]
parent_profiles = shared
transfers = 4
```
Parent names are also given in their user-facing form (without the
`profile:` prefix).
Using `--profile nightly` first applies `shared`, then applies
`nightly` on top, so `nightly` overrides any flag the two have in
common. Parents themselves can have parents - profiles are resolved
recursively.
### Always applying a profile
There is no "default profile" mechanism - if you want a profile to
apply every time, set it in the environment instead:
```sh
export RCLONE_PROFILE=mybackup
```
`--profile` is exposed as a standard rclone option, so the
`RCLONE_PROFILE` environment variable Just Works. Use a shell
alias or wrapper script if you want different defaults in different
contexts. This keeps "what flags am I actually running with" answerable
from the command line and environment alone, with no spooky action
from the config file.
### Managing profiles
Use the `rclone config profile` subcommands to manage profiles
without editing the config file by hand:
```sh
rclone config profile save NAME [args...] [flags...]
rclone config profile list
rclone config profile show NAME
rclone config profile delete NAME
```
- `save` writes a new profile from the flags (and optional positional
args, with `--profile-save-args`) given on the command line.
Pass `--flags-from CMD[,CMD...]` to make flags from other commands
(e.g. `mount`, `bisync`) available for saving without having to
actually run those commands.
- `list` prints the names of all profiles in the config file.
- `show` prints the keys and values stored in one profile.
- `delete` removes a profile.
To edit an existing profile, either re-save it (overwriting the old
one) or edit the corresponding `[profile:NAME]` section in the
config file by hand.
### Config file layout
Profiles live under the `[profile:NAME]` namespace in the config
file. The `profile:` prefix is the canonical signal that a section
is a profile - `:` is illegal in remote names, so profiles and
remotes can share a name without colliding. Profile sections are
hidden from `rclone listremotes`, `rclone config show <remote>` and
the rc API's `config/dump` (they don't appear as remotes).
Saved flags are written with their option name in snake_case (e.g.
`drive-skip-gdocs` becomes `drive_skip_gdocs = true`). Saved
arguments are written as `arg_1 = ...`, `arg_2 = ...` and so on.
## Quoting and the shell
When you are typing commands to your computer you are using something
@@ -2464,6 +2670,36 @@ See the [Configuration Encryption](#configuration-encryption) for more info.
See a [Windows PowerShell example on the Wiki](https://github.com/rclone/rclone/wiki/Windows-Powershell-use-rclone-password-command-for-Config-file-password).
### --profile CommaSepList
Apply one or more saved profiles to this command, as if their flags
had been typed on the command line. Multiple profiles are
comma-separated and applied lowest-to-highest priority (later
profiles override earlier ones). Explicit command-line flags still
take precedence over the profile. See the [Profiles](#profiles)
section.
### --profile-save string
Save the explicitly-set flags from this command (and optionally its
arguments, with `--profile-save-args`) into the config file as a
reusable profile under the given name. The command still runs as
normal. See the [Profiles](#profiles) section.
### --profile-save-args
When used with `--profile-save`, also save the command's positional
arguments (e.g. the source and destination paths) into the profile.
At use time the saved arguments are applied automatically (the user's
own positional arguments, if any, take precedence). See the
[Profiles](#profiles) section.
### --profile-strict-flags
When used with `--profile`, error out if the profile contains a
flag that isn't valid for the current command. The default is to
silently skip such flags. See the [Profiles](#profiles) section.
### -P, --progress
This flag makes rclone update the stats in a static block in the
+3 -3
View File
@@ -1,9 +1,9 @@
backend: protondrive
name: Proton Drive
tier: Tier 5
maintainers: None
tier: Tier 4
maintainers: Community
features_score: 4
integration_tests: Failing
integration_tests: Passing
data_integrity: Hash
performance: High
adoption: Often used
+24
View File
@@ -566,6 +566,26 @@ var ConfigOptionsInfo = Options{{
Default: "",
Help: "HTTP proxy URL.",
Groups: "Networking",
}, {
Name: "profile_save",
Default: "",
Help: "Save this flag configuration as a reusable preset",
Groups: "Config",
}, {
Name: "profile",
Default: []string{},
Help: "Apply the flag configuration saved with this name. If more than one is provided, priority will be lowest to highest.",
Groups: "Config",
}, {
Name: "profile_save_args",
Default: false,
Help: "When used with --profile-save, also save positional args (e.g. the paths being synced) into the profile",
Groups: "Config",
}, {
Name: "profile_strict_flags",
Default: false,
Help: "If set, --profile will error if any flags are invalid for this command, instead of ignoring.",
Groups: "Config",
}}
// ConfigInfo is filesystem config options
@@ -680,6 +700,10 @@ type ConfigInfo struct {
MaxConnections int `config:"max_connections"`
NameTransform []string `config:"name_transform"`
HTTPProxy string `config:"http_proxy"`
SaveProfile string `config:"profile_save"`
UseProfile CommaSepList `config:"profile"`
ProfileSaveArgs bool `config:"profile_save_args"`
ProfileStrictFlags bool `config:"profile_strict_flags"`
}
func init() {
+16
View File
@@ -396,6 +396,19 @@ func SaveConfig() {
fs.Errorf(nil, "Failed to save config after %d tries: %v", ci.LowLevelRetries, err)
}
// ProfileSectionPrefix is the namespace prefix used for profile
// sections in the config file (e.g. [profile:fast-sync]). It lets
// profiles and remotes share the config file without colliding on
// names: a section called "backup" is a remote, "profile:backup" is
// a profile.
const ProfileSectionPrefix = "profile:"
// IsProfileSection reports whether the given section name lives in
// the profiles namespace.
func IsProfileSection(name string) bool {
return strings.HasPrefix(name, ProfileSectionPrefix)
}
// FileSections returns the sections in the config file
func FileSections() []string {
return LoadedData().GetSectionList()
@@ -710,6 +723,9 @@ func DumpRcRemote(name string) (dump rc.Params) {
func DumpRcBlob() (dump rc.Params) {
dump = rc.Params{}
for _, name := range LoadedData().GetSectionList() {
if IsProfileSection(name) {
continue
}
dump[name] = DumpRcRemote(name)
}
return dump
+371
View File
@@ -0,0 +1,371 @@
// Package profiles handles presets for config
package profiles
import (
"context"
"fmt"
"os"
"slices"
"strings"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configflags"
"github.com/rclone/rclone/fs/operations"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// ignoredFlags are flag names (in profile-key form, i.e. snake_case)
// that must never be persisted into a profile or copied out of one.
//
// "config" is excluded because applying it from a profile would
// re-point the config-file storage mid-Use(), while we're reading
// the profile out of that very file.
var ignoredFlags = []string{"profile", "profile_save", "profile_save_args", "profile_strict_flags", "flags_from", "config"}
const (
argPrefix = "arg_"
parentKey = "parent_profiles"
)
// ProfileOpt stores the settings for Profiles
type ProfileOpt struct {
SaveProfile string // name of profile to save
UseProfile fs.CommaSepList // name(s) of profiles to use
StrictFlags bool // If set, --profile will error if any flags are invalid for this command, instead of ignoring.
SaveArgs bool // When saving, also include positional args (e.g. the paths being synced)
}
// Profile represents a flag/arg configuration to save/use as a reusable preset
type Profile struct {
Name string // the name of the profile
Args []string // command args (ex. the paths being synced)
Flags map[string]string // any flags (command, config, backend...)
Parents []string // see p.GetParents() to get a slice of *Profiles instead
}
// NewProfile returns a new empty *Profile
func NewProfile(name string) *Profile {
return &Profile{
Name: name,
Args: []string{},
Flags: map[string]string{},
Parents: []string{},
}
}
// ProfileOptFromCtx gets a new ProfileOpt from config settings
func ProfileOptFromCtx(ctx context.Context) ProfileOpt {
ci := fs.GetConfig(ctx)
return ProfileOpt{
SaveProfile: ci.SaveProfile,
UseProfile: ci.UseProfile,
StrictFlags: ci.ProfileStrictFlags,
SaveArgs: ci.ProfileSaveArgs,
}
}
// NewFromCommand sets the profile's Args and Flags from the cmd and args passed in
func (p *Profile) NewFromCommand(cmd *cobra.Command, args []string) {
p.Args = args
setProfileVal := func(flag *pflag.Flag) {
key := optionToProfileKey(flag.Name)
if slices.Contains(ignoredFlags, key) {
return
}
if !flag.Changed {
return
}
p.Flags[key] = flag.Value.String()
}
// .Visit() does not visit the flags we add manually via
// --flags-from (in `rclone config profile save`), but .VisitAll()
// with a flag.Changed check does, so use that.
cmd.Flags().VisitAll(setProfileVal)
}
// sectionName returns the config-file section name for the profile
// "name" (e.g. "fast" -> "profile:fast"). Profile.Name itself stays
// in its user-facing form.
func sectionName(name string) string {
return config.ProfileSectionPrefix + name
}
// GetProfile gets a profile from the config
func GetProfile(name string) (*Profile, error) {
p := NewProfile(name)
section := sectionName(p.Name)
data := config.LoadedData()
if !data.HasSection(section) {
return nil, fmt.Errorf("no profile named %q found in config file", p.Name)
}
// Args may appear out of order or be sparse (e.g. arg_3 without
// arg_1) so collect them first then size the slice from the max
// index.
args := map[int]string{}
keys := data.GetKeyList(section)
for _, key := range keys {
if slices.Contains(ignoredFlags, key) {
continue
}
if key == parentKey {
parentList, _ := data.GetValue(section, key)
p.Parents = strings.Split(parentList, ",")
continue
}
if strings.HasPrefix(key, argPrefix) {
i, err := argNum(key)
if err != nil {
return nil, fmt.Errorf("error parsing arg %s: %v", key, err)
}
if i < 0 {
return nil, fmt.Errorf("arg index out of range: %s", key)
}
val, _ := data.GetValue(section, key)
args[i] = val
continue
}
val, _ := data.GetValue(section, key)
p.Flags[key] = val
}
if len(args) > 0 {
maxIdx := -1
for i := range args {
if i > maxIdx {
maxIdx = i
}
}
p.Args = make([]string, maxIdx+1)
for i, v := range args {
p.Args[i] = v
}
}
return p, nil
}
// GetProfiles gets a slice of profiles
func GetProfiles(names []string) ([]*Profile, error) {
profiles := []*Profile{}
for _, name := range names {
p, err := GetProfile(name)
if err != nil {
return nil, err
}
profiles = append(profiles, p)
}
return profiles, nil
}
// UseProfiles gets one or more Profiles from opt.UseProfile and applies them to context (priority: lowest to highest)
func UseProfiles(ctx context.Context, cmd *cobra.Command, args []string, opt *ProfileOpt) error {
for _, profileName := range opt.UseProfile {
err := GetAndUseProfile(ctx, cmd, args, opt, profileName)
if err != nil {
return err
}
}
return nil
}
// GetAndUseProfile gets one Profile and uses it
func GetAndUseProfile(ctx context.Context, cmd *cobra.Command, args []string, opt *ProfileOpt, profileName string) error {
profile, err := GetProfile(profileName)
if err != nil {
return err
}
err = profile.Use(ctx, cmd, args, opt)
if err != nil {
return err
}
return nil
}
// Save saves a profile to the config file
func (p *Profile) Save(ctx context.Context, opt *ProfileOpt) error {
var err error
p.Name, err = validateProfileName(p.Name)
if err != nil {
return err
}
if operations.SkipDestructive(ctx, p.Name, "save profile") {
return nil
}
section := sectionName(p.Name)
data := config.LoadedData()
data.DeleteSection(section) // clear it if already exists
for k, v := range p.Flags {
// Defensive normalisation: callers building a *Profile
// directly may pass keys in either form.
data.SetValue(section, optionToProfileKey(k), v)
}
if opt.SaveArgs {
p.saveArgs()
}
if len(p.Parents) > 0 {
data.SetValue(section, parentKey, strings.Join(p.Parents, ","))
}
if err := data.Save(); err != nil {
return fmt.Errorf("error saving config: %v", err)
}
fs.Debugf(p.Name, "saved profile: %v", SprintSection(sectionName(p.Name)))
return nil
}
func (p *Profile) saveArgs() {
section := sectionName(p.Name)
for i, arg := range p.Args {
config.LoadedData().SetValue(section, argPrefix+fmt.Sprint(i+1), arg)
}
}
// Use sets the current command args and flags from the profile struct
func (p *Profile) Use(ctx context.Context, cmd *cobra.Command, cobraArgs []string, opt *ProfileOpt) error {
// set logging first
ci := fs.GetConfig(ctx)
section := sectionName(p.Name)
if ci.LogLevel == fs.LogLevelNotice {
if verbose, ok := config.FileGetValue(section, "verbose"); ok {
_ = cmd.Flags().Lookup("verbose").Value.Set(verbose)
configflags.SetFlags(ci)
}
}
fs.Debugf(p.Name, "loading profile: %v", SprintSection(section))
err := p.useParents(ctx, cmd, cobraArgs, opt)
if err != nil {
return err
}
err = p.useFlags(ctx, cmd, cobraArgs, opt)
if err != nil {
return err
}
// Apply saved args whenever the profile carries them. Whether or
// not args were saved is a per-profile decision made at save time
// (--profile-save-args); the user shouldn't have to know or
// re-pass that knob at use time.
if len(p.Args) > 0 {
p.useArgs(ctx, cmd, cobraArgs, opt)
}
// Copy any option-backed flag values we just changed (e.g.
// --checkers, --transfers, --metadata, backend flags) out of the
// pflag/options registry and into ConfigInfo.
if err := fs.GlobalOptionsInit(); err != nil {
return fmt.Errorf("failed to reload global options from profile: %w", err)
}
// Re-run configflags.SetFlags() for the hand-rolled flags it owns
// (-v, -q, --bind, --headers, --config, --cache-dir...).
configflags.SetFlags(ci)
return nil
}
func (p *Profile) useFlags(ctx context.Context, cmd *cobra.Command, cobraArgs []string, opt *ProfileOpt) error {
ci := fs.GetConfig(ctx)
for rawKey, val := range p.Flags {
key := optionToProfileKey(rawKey) // tolerate hyphenated keys from external callers
if slices.Contains(ignoredFlags, key) {
continue
}
flag := cmd.Flags().Lookup(profileKeyToFlag(key))
if flag == nil {
if opt.StrictFlags {
return fmt.Errorf("invalid flag: %s", profileKeyToFlag(key))
}
fs.Debugf(p.Name, "ignoring unknown flag %s for this command", profileKeyToFlag(key))
continue
}
if ci.DryRun && key == "dry_run" && val == "false" {
// disallow overriding --dry-run if it was specifically set
fs.Logf(nil, "for safety, profiles cannot change --dry-run from true to false. Ignoring.")
continue
}
logChange(p.Name, key, flag.Value.String(), val)
err := flag.Value.Set(val)
if err != nil {
return fmt.Errorf("%s: error setting val %s from profile: %v", flag.Name, val, err)
}
}
return nil
}
// useArgs fills positional args from the profile's saved Args list,
// but only into slots the user did not provide on the command line.
// We don't CheckArgs() here as the command will do it later; users
// should take care to not exceed MaxArgs.
//
// Args on parent profiles are generally a bad idea (children would
// overwrite parent), but the same precedence rule applies if you
// use them.
func (p *Profile) useArgs(ctx context.Context, cmd *cobra.Command, cobraArgs []string, opt *ProfileOpt) {
userProvided := len(cobraArgs)
for i, pArg := range p.Args { // we DO assume that the p.Args are in the right order here.
if pArg == "" {
continue
}
if i < userProvided {
// User gave an explicit positional arg for this slot - keep it.
continue
}
if i > len(cobraArgs)-1 {
cobraArgs = append(cobraArgs, "")
}
logChange(p.Name, fmt.Sprintf("Arg %d", i+1), cobraArgs[i], pArg)
cobraArgs[i] = pArg
}
// rclone command [args...]
os.Args = append([]string{os.Args[0], os.Args[1]}, cobraArgs...)
os.Args = cleanArgs(os.Args)
}
// allows nested profiles (priority lowest to highest, parents all lower than child)
func (p *Profile) useParents(ctx context.Context, cmd *cobra.Command, args []string, opt *ProfileOpt) error {
if len(p.Parents) == 0 {
return nil
}
newopt := *opt
newopt.UseProfile = p.Parents
return UseProfiles(ctx, cmd, args, &newopt)
}
// GetParents returns the parent profiles of this profile
func (p *Profile) GetParents() ([]*Profile, error) {
return GetProfiles(p.Parents)
}
/*
TODO:
- should we be storing more in memory to cut down on config.LoadedData() calls?
- when profile/key names have illegal characters should we error or auto-convert them? currently we do some of both.
- more tests
- docs
- document handling of blank or default values
- UI could use some cleanup (and see the note there about the use of the word "remote")
- rc methods
- API for other commands (bisync) to save/use profiles
- better handle scenario where logging level passed in is higher than that of the profile we're getting/using...
...probably needs a defer to make sure debugs are seen (if requested) during profile setting, then set the real level (from profile) after
- maybe edit more of the helper functions to use the types and methods instead of modifying config directly
- the ProfileOpt logic maybe needs another look -- slightly confusing that we store the profile name both there and in the profile struct.
- search other TODOs
*/
@@ -0,0 +1,47 @@
package profiles
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestProfileRequested(t *testing.T) {
// Clear any RCLONE_PROFILE* env vars set in the caller's
// environment so they don't leak into the test cases. t.Setenv
// restores them automatically.
t.Setenv("RCLONE_PROFILE", "")
t.Setenv("RCLONE_PROFILE_SAVE", "")
savedArgs := os.Args
defer func() { os.Args = savedArgs }()
cases := []struct {
name string
args []string
env map[string]string
expect bool
}{
{"no flag, no env", []string{"rclone", "ls", "remote:"}, nil, false},
{"--profile NAME", []string{"rclone", "ls", "remote:", "--profile", "x"}, nil, true},
{"--profile=NAME", []string{"rclone", "ls", "remote:", "--profile=x"}, nil, true},
{"--profile-save NAME", []string{"rclone", "ls", "remote:", "--profile-save", "x"}, nil, true},
{"--profile-save=NAME", []string{"rclone", "ls", "remote:", "--profile-save=x"}, nil, true},
{"--profile-save-args alone does not trigger", []string{"rclone", "ls", "remote:", "--profile-save-args"}, nil, false},
{"--profile-strict-flags alone does not trigger", []string{"rclone", "ls", "remote:", "--profile-strict-flags"}, nil, false},
{"RCLONE_PROFILE env var", []string{"rclone", "ls", "remote:"}, map[string]string{"RCLONE_PROFILE": "x"}, true},
{"RCLONE_PROFILE_SAVE env var", []string{"rclone", "ls", "remote:"}, map[string]string{"RCLONE_PROFILE_SAVE": "x"}, true},
{"empty RCLONE_PROFILE does not trigger", []string{"rclone", "ls", "remote:"}, map[string]string{"RCLONE_PROFILE": ""}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
os.Args = c.args
for k, v := range c.env {
t.Setenv(k, v)
}
assert.Equal(t, c.expect, profileRequested())
})
}
}
@@ -0,0 +1,187 @@
// Package profiles handles presets for config
package profiles
import (
"context"
"errors"
"fmt"
"os"
"slices"
"strconv"
"strings"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configfile"
"github.com/rclone/rclone/fs/config/configflags"
"github.com/rclone/rclone/fs/fspath"
fslog "github.com/rclone/rclone/fs/log"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// AddProfiles handles saving/loading of config profiles
// We do this before Root.Execute() because there's otherwise
// no good way to add args to an already-executing command
func AddProfiles(ctx context.Context, Root *cobra.Command) {
if !profileRequested() {
return
}
cmd, remaining, err := Root.Find(os.Args[1:])
if err != nil {
fs.Fatalf(nil, "Fatal error: %v", err)
}
// Anything under `rclone config` (notably `config profile save`)
// manages profiles directly and must not be intercepted here.
for c := cmd; c != nil; c = c.Parent() {
if c.Name() == "config" {
return
}
}
// Start the logger so debug/info messages from the profile code
// reach the user; the normal cobra.OnInitialize path hasn't run
// yet because Root.Execute() hasn't been called.
fslog.InitLogging()
if err := cmd.Flags().Parse(remaining); err != nil {
fs.Fatalf(nil, "Fatal error: %v", err)
}
ci := fs.GetConfig(ctx)
// The hand-rolled flags (--config, --cache-dir, -v, -q, ...) live
// on pflag.CommandLine rather than on `cmd`, so parse it as well
// to populate the package vars configflags.SetFlags reads from.
if err := pflag.CommandLine.Parse(os.Args[1:]); err != nil {
fs.Fatalf(nil, "Fatal error: %v", err)
}
// Populate ConfigInfo from the option-registry values (covers
// every option-backed flag including --profile, --profile-save,
// and env vars like RCLONE_PROFILE).
if err := fs.GlobalOptionsInit(); err != nil {
fs.Fatalf(nil, "Failed to initialise global options: %v", err)
}
// Apply the hand-rolled flags last - SetFlags writes directly to
// ConfigInfo (e.g. ci.LogLevel from -vv) and would otherwise be
// overwritten by GlobalOptionsInit reloading from the option
// defaults.
configflags.SetFlags(ci)
// Install the config file handler *after* SetFlags has applied
// --config (or RCLONE_CONFIG), otherwise the install is a no-op
// against an empty path and any save goes to an in-memory
// storage that's discarded at exit.
configfile.Install()
if err := HandleProfiles(ctx, cmd, cmd.Flags().Args()); err != nil {
fs.Fatalf(nil, "Fatal error: %v", err)
}
}
// HandleProfiles handles --profile-save and --profile
func HandleProfiles(ctx context.Context, cmd *cobra.Command, args []string) error {
opt := ProfileOptFromCtx(ctx)
if len(opt.UseProfile) > 0 {
err := UseProfiles(ctx, cmd, args, &opt)
if err != nil {
return err
}
}
if opt.SaveProfile != "" {
saveProfile := NewProfile(opt.SaveProfile)
saveProfile.NewFromCommand(cmd, args)
return saveProfile.Save(ctx, &opt)
}
return nil
}
func argNum(key string) (int, error) {
s := strings.TrimPrefix(key, argPrefix)
i, err := strconv.Atoi(s)
if err != nil {
return i, err
}
return i - 1, nil
}
func optionToProfileKey(name string) string {
name = strings.TrimSpace(name)
name = strings.ReplaceAll(name, " ", "_")
return strings.ToLower(strings.ReplaceAll(name, "-", "_"))
}
// note that this is case-insensitive
func profileKeyToFlag(name string) string {
return strings.ToLower(strings.ReplaceAll(name, "_", "-"))
}
func validateKeyName(name string) (convertedName string, err error) {
convertedName = fspath.MakeConfigName(optionToProfileKey(name))
if convertedName != name {
return convertedName, errors.New("invalid characters detected")
}
return convertedName, nil
}
func validateProfileName(name string) (convertedName string, err error) {
// Profiles live in the "profile:" namespace so there is no
// conflict possible with remote names - we only need to validate
// the name's character set.
return validateKeyName(name)
}
func logChange(useProfileName, key, oldval, newval string) {
if oldval == newval {
return
}
if oldval == "" {
oldval = "[blank]"
}
fs.Infof(useProfileName, "changing %s from %s to %s", key, oldval, newval)
}
// SprintSection returns a string of this section from the config file
func SprintSection(name string) string {
keys := config.LoadedData().GetKeyList(name)
s := fmt.Sprintf("\n[%s]\n", name)
for _, key := range keys {
if value, ok := config.FileGetValue(name, key); ok {
s += fmt.Sprintf("%s%s%s\n", key, " = ", value)
}
}
return s
}
func cleanArgs(s []string) []string {
var r []string
// s = s[:cap(s)]
for _, str := range s {
if str != "" {
r = append(r, str)
}
}
return r
}
// profileRequested returns true when the user has asked for profile
// machinery to fire on this invocation, via either a CLI flag or an
// env var.
//
// We need this *before* cobra/options have done any of their work,
// so we look at the raw os.Args / os.Environ directly rather than
// reading from ConfigInfo (which is still populated from defaults
// at this point).
func profileRequested() bool {
if os.Getenv("RCLONE_PROFILE") != "" || os.Getenv("RCLONE_PROFILE_SAVE") != "" {
return true
}
return slices.ContainsFunc(os.Args, func(a string) bool {
// Exact "--profile" / "--profile-save" and their "=value" forms.
// Anything else (e.g. --profile-save-args, --profile-strict-flags)
// is a modifier and does not on its own trigger the profile
// machinery.
return a == "--profile" || strings.HasPrefix(a, "--profile=") ||
a == "--profile-save" || strings.HasPrefix(a, "--profile-save=")
})
}
+389
View File
@@ -0,0 +1,389 @@
package profiles_test
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configfile"
profiles "github.com/rclone/rclone/fs/config/configprofiles"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newTestCmd returns a cobra.Command with a small synthetic flag set
// covering the flag types the profile code actually has to handle.
//
// It is deliberately decoupled from cmd.Root and the real
// configflags/filterflags registrations: tests should exercise the
// profile machinery against a known, minimal surface rather than
// against the whole of rclone.
func newTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
fs := cmd.Flags()
fs.Int("checkers", 8, "Number of checkers")
fs.Bool("checksum", false, "Use checksum")
fs.Bool("metadata", false, "Preserve metadata")
fs.Bool("dry-run", false, "Do a trial run")
fs.String("backup-dir", "", "Backup directory")
fs.String("drive-skip-gdocs", "", "Drive: skip gdocs")
return cmd
}
// useTempConfig points the config subsystem at a fresh empty file
// inside t.TempDir() and registers cleanup to restore the previous
// path.
func useTempConfig(t *testing.T) string {
t.Helper()
configfile.Install()
old := config.GetConfigPath()
path := filepath.Join(t.TempDir(), "rclone.conf")
require.NoError(t, os.WriteFile(path, nil, 0600))
require.NoError(t, config.SetConfigPath(path))
t.Cleanup(func() {
_ = config.SetConfigPath(old)
})
// Make sure we start from an empty in-memory view too.
for _, section := range config.LoadedData().GetSectionList() {
config.LoadedData().DeleteSection(section)
}
return path
}
// parse parses argv into the given command's flag set and returns the
// remaining positional arguments.
func parse(t *testing.T, cmd *cobra.Command, argv ...string) []string {
t.Helper()
require.NoError(t, cmd.Flags().Parse(argv))
return cmd.Flags().Args()
}
func TestNewFromCommand_CollectsOnlyChangedFlags(t *testing.T) {
useTempConfig(t)
cmd := newTestCmd()
args := parse(t, cmd,
"--checkers", "16",
"--checksum",
"--drive-skip-gdocs", "true",
"src", "dst",
)
p := profiles.NewProfile("name")
p.NewFromCommand(cmd, args)
assert.Equal(t, []string{"src", "dst"}, p.Args)
assert.Equal(t, map[string]string{
"checkers": "16",
"checksum": "true",
"drive_skip_gdocs": "true",
}, p.Flags)
}
func TestNewFromCommand_SkipsIgnoredFlags(t *testing.T) {
useTempConfig(t)
cmd := newTestCmd()
// Profile-control flags must not be persisted into the profile,
// even if the test command happens to define them.
cmd.Flags().String("profile-save", "", "")
cmd.Flags().String("profile", "", "")
cmd.Flags().Bool("profile-save-args", false, "")
parse(t, cmd,
"--checkers", "4",
"--profile-save", "ignored",
"--profile", "ignored",
"--profile-save-args",
)
p := profiles.NewProfile("name")
p.NewFromCommand(cmd, nil)
assert.Equal(t, map[string]string{"checkers": "4"}, p.Flags)
}
func TestSaveAndGet_RoundTripsFlagsAndArgs(t *testing.T) {
useTempConfig(t)
ctx := context.Background()
p := profiles.NewProfile("preset")
p.Flags = map[string]string{
"checkers": "16",
"metadata": "true",
"drive_skip_gdocs": "true",
}
p.Args = []string{"src", "remote:dst"}
p.Parents = []string{"parent_a", "parent_b"}
opt := &profiles.ProfileOpt{SaveArgs: true}
require.NoError(t, p.Save(ctx, opt))
got, err := profiles.GetProfile("preset")
require.NoError(t, err)
assert.Equal(t, p, got)
}
func TestUse_UserPositionalArgsBeatProfileArgs(t *testing.T) {
useTempConfig(t)
ctx := context.Background()
// Save a profile with positional args.
p := profiles.NewProfile("paths")
p.Args = []string{"saved-src", "saved-dst"}
require.NoError(t, p.Save(ctx, &profiles.ProfileOpt{SaveArgs: true}))
// useArgs mutates os.Args; save/restore.
savedOSArgs := os.Args
defer func() { os.Args = savedOSArgs }()
os.Args = []string{"rclone", "copy", "user-src", "user-dst", "--profile", "paths"}
cmd := newTestCmd()
loaded, err := profiles.GetProfile("paths")
require.NoError(t, err)
require.NoError(t, loaded.Use(ctx, cmd, []string{"user-src", "user-dst"}, &profiles.ProfileOpt{}))
// The user's positional args must win over the profile's.
assert.Equal(t, []string{"rclone", "copy", "user-src", "user-dst"}, os.Args)
}
func TestUse_ProfileArgsFillUnprovidedSlots(t *testing.T) {
useTempConfig(t)
ctx := context.Background()
p := profiles.NewProfile("paths")
p.Args = []string{"saved-src", "saved-dst"}
require.NoError(t, p.Save(ctx, &profiles.ProfileOpt{SaveArgs: true}))
savedOSArgs := os.Args
defer func() { os.Args = savedOSArgs }()
// User provided no positional args.
os.Args = []string{"rclone", "copy", "--profile", "paths"}
cmd := newTestCmd()
loaded, err := profiles.GetProfile("paths")
require.NoError(t, err)
require.NoError(t, loaded.Use(ctx, cmd, nil, &profiles.ProfileOpt{}))
// With no user args, the profile's saved args fill in.
assert.Equal(t, []string{"rclone", "copy", "saved-src", "saved-dst"}, os.Args)
}
func TestSave_DefensiveKeyNormalisation(t *testing.T) {
// Callers building a *Profile directly may pass keys in either
// snake_case or hyphenated form. Both should land in the same
// section.
useTempConfig(t)
p := profiles.NewProfile("preset")
p.Flags = map[string]string{"drive-skip-gdocs": "true"}
require.NoError(t, p.Save(context.Background(), &profiles.ProfileOpt{}))
got, err := profiles.GetProfile("preset")
require.NoError(t, err)
assert.Equal(t, map[string]string{"drive_skip_gdocs": "true"}, got.Flags)
}
func TestSave_RemoteAndProfileWithSameNameCoexist(t *testing.T) {
// Profiles live in the "profile:" namespace so they can share a
// name with a remote without colliding.
useTempConfig(t)
config.LoadedData().SetValue("clash", "type", "drive")
require.NoError(t, config.LoadedData().Save())
p := profiles.NewProfile("clash")
p.Flags = map[string]string{"checkers": "4"}
require.NoError(t, p.Save(context.Background(), &profiles.ProfileOpt{}))
// Remote section still intact.
rt, ok := config.LoadedData().GetValue("clash", "type")
require.True(t, ok)
assert.Equal(t, "drive", rt)
// Profile under its namespaced section name.
assert.True(t, config.LoadedData().HasSection("profile:clash"))
}
func TestGetProfile_HandlesSparseArgIndices(t *testing.T) {
useTempConfig(t)
// Write arg_3 with no arg_1 / arg_2 - GetProfile used to crash
// here.
config.LoadedData().SetValue("profile:preset", "arg_3", "third")
require.NoError(t, config.LoadedData().Save())
got, err := profiles.GetProfile("preset")
require.NoError(t, err)
assert.Equal(t, []string{"", "", "third"}, got.Args)
}
func TestUse_AppliesFlagValues(t *testing.T) {
useTempConfig(t)
ctx := context.Background()
saved := profiles.NewProfile("preset")
saved.Flags = map[string]string{
"checkers": "16",
"metadata": "true",
}
require.NoError(t, saved.Save(ctx, &profiles.ProfileOpt{}))
cmd := newTestCmd()
loaded, err := profiles.GetProfile("preset")
require.NoError(t, err)
require.NoError(t, loaded.Use(ctx, cmd, nil, &profiles.ProfileOpt{}))
assert.Equal(t, "16", cmd.Flags().Lookup("checkers").Value.String())
assert.Equal(t, "true", cmd.Flags().Lookup("metadata").Value.String())
}
func TestUse_UnknownFlagSkippedByDefault(t *testing.T) {
// A profile may carry flags for commands other than the one it
// is currently being applied to; non-strict mode should just
// ignore them rather than erroring or nil-dereferencing.
useTempConfig(t)
ctx := context.Background()
saved := profiles.NewProfile("preset")
saved.Flags = map[string]string{
"checkers": "12",
"not_a_real_flag": "value",
}
require.NoError(t, saved.Save(ctx, &profiles.ProfileOpt{}))
cmd := newTestCmd()
loaded, err := profiles.GetProfile("preset")
require.NoError(t, err)
require.NoError(t, loaded.Use(ctx, cmd, nil, &profiles.ProfileOpt{}))
assert.Equal(t, "12", cmd.Flags().Lookup("checkers").Value.String())
}
func TestUse_UnknownFlagErrorsInStrictMode(t *testing.T) {
useTempConfig(t)
ctx := context.Background()
saved := profiles.NewProfile("preset")
saved.Flags = map[string]string{"not_a_real_flag": "value"}
require.NoError(t, saved.Save(ctx, &profiles.ProfileOpt{}))
cmd := newTestCmd()
loaded, err := profiles.GetProfile("preset")
require.NoError(t, err)
err = loaded.Use(ctx, cmd, nil, &profiles.ProfileOpt{StrictFlags: true})
require.Error(t, err)
assert.Contains(t, err.Error(), "not-a-real-flag")
}
func TestUse_Parents_LowestToHighestPriority(t *testing.T) {
useTempConfig(t)
ctx := context.Background()
opt := &profiles.ProfileOpt{}
parentA := profiles.NewProfile("parent_a")
parentA.Flags = map[string]string{
"checkers": "4",
"metadata": "true",
}
require.NoError(t, parentA.Save(ctx, opt))
parentB := profiles.NewProfile("parent_b")
parentB.Flags = map[string]string{
"checkers": "8",
"checksum": "true",
}
require.NoError(t, parentB.Save(ctx, opt))
child := profiles.NewProfile("child")
child.Flags = map[string]string{"checkers": "16"}
child.Parents = []string{"parent_a", "parent_b"}
require.NoError(t, child.Save(ctx, opt))
cmd := newTestCmd()
loaded, err := profiles.GetProfile("child")
require.NoError(t, err)
require.NoError(t, loaded.Use(ctx, cmd, nil, opt))
// child overrides both parents
assert.Equal(t, "16", cmd.Flags().Lookup("checkers").Value.String())
// parent_b overrides parent_a where both set the same flag
// (n/a here for checkers; for checksum only parent_b sets)
assert.Equal(t, "true", cmd.Flags().Lookup("checksum").Value.String())
// flag set only by parent_a still applies
assert.Equal(t, "true", cmd.Flags().Lookup("metadata").Value.String())
}
func TestUse_DryRunCannotBeTurnedOff(t *testing.T) {
useTempConfig(t)
saved := profiles.NewProfile("preset")
saved.Flags = map[string]string{"dry_run": "false"}
require.NoError(t, saved.Save(context.Background(), &profiles.ProfileOpt{}))
// Turn dry-run on *after* saving, otherwise Save itself is
// skipped as a destructive op under --dry-run.
ctx, ci := fs.AddConfig(context.Background())
ci.DryRun = true
cmd := newTestCmd()
loaded, err := profiles.GetProfile("preset")
require.NoError(t, err)
require.NoError(t, loaded.Use(ctx, cmd, nil, &profiles.ProfileOpt{}))
// The profile's "dry_run = false" must be ignored.
assert.Equal(t, "false", cmd.Flags().Lookup("dry-run").Value.String())
// We don't assert on ci.DryRun because Profile.Use re-runs
// GlobalOptionsInit, which would copy ci.DryRun back from the
// flag default - the relevant guarantee is that the flag was
// not overwritten.
}
func TestHandleProfiles_SaveRoundTrip(t *testing.T) {
useTempConfig(t)
ctx, ci := fs.AddConfig(context.Background())
cmd := newTestCmd()
parse(t, cmd, "--checkers", "32", "--checksum")
ci.SaveProfile = "fromcmd"
require.NoError(t, profiles.HandleProfiles(ctx, cmd, cmd.Flags().Args()))
loaded, err := profiles.GetProfile("fromcmd")
require.NoError(t, err)
assert.Equal(t, map[string]string{
"checkers": "32",
"checksum": "true",
}, loaded.Flags)
}
func TestSprintSection_ShowsAllKeys(t *testing.T) {
useTempConfig(t)
p := profiles.NewProfile("preset")
p.Flags = map[string]string{"checkers": "9"}
require.NoError(t, p.Save(context.Background(), &profiles.ProfileOpt{}))
s := profiles.SprintSection("profile:preset")
lines := sortedNonBlank(s)
want := sortedNonBlank("[profile:preset]\ncheckers = 9\n")
assert.Equal(t, want, lines)
}
func sortedNonBlank(s string) []string {
out := []string{}
for _, line := range strings.Split(s, "\n") {
if line == "" {
continue
}
out = append(out, line)
}
sort.Strings(out)
return out
}
// TestGlobalOptionsLoadable pins the contract that callers like
// HandleProfiles / Profile.Use rely on: fs.GlobalOptionsInit must be
// callable on an initialised process without erroring. If the global
// options registry ever ends up in a state that can't be loaded the
// rest of this suite will start blowing up too, but this gives a
// pointed failure.
func TestGlobalOptionsLoadable(t *testing.T) {
require.NoError(t, fs.GlobalOptionsInit())
}
+35
View File
@@ -0,0 +1,35 @@
// Package profiles handles presets for config
package profiles
import (
"fmt"
"sort"
"strings"
"github.com/rclone/rclone/fs/config"
)
// ShowProfiles prints an overview of every profile in the config file.
func ShowProfiles() {
profiles := GetProfileList()
fmt.Printf("%-20s %s\n", "Name", "Type")
fmt.Printf("%-20s %s\n", "====", "====")
for _, profile := range profiles {
fmt.Printf("%-20s %s\n", profile, "profile")
}
}
// GetProfileList returns the user-facing names of all profile
// sections (i.e. without the "profile:" prefix), sorted.
func GetProfileList() []string {
sections := config.LoadedData().GetSectionList()
profiles := []string{}
for _, s := range sections {
if !config.IsProfileSection(s) {
continue
}
profiles = append(profiles, strings.TrimPrefix(s, config.ProfileSectionPrefix))
}
sort.Strings(profiles)
return profiles
}
+17 -3
View File
@@ -294,7 +294,7 @@ func ChooseNumber(what string, min, max int) int {
// ShowRemotes shows an overview of the config file
func ShowRemotes() {
remotes := LoadedData().GetSectionList()
remotes := remoteSections()
if len(remotes) == 0 {
return
}
@@ -308,12 +308,26 @@ func ShowRemotes() {
// ChooseRemote chooses a remote name
func ChooseRemote() string {
remotes := LoadedData().GetSectionList()
remotes := remoteSections()
sort.Strings(remotes)
fmt.Println("Select remote.")
return Choose("remote", "value", remotes, nil, "", true, false)
}
// remoteSections returns the section names that are remotes (i.e.
// everything except profile sections).
func remoteSections() []string {
all := LoadedData().GetSectionList()
out := make([]string, 0, len(all))
for _, s := range all {
if IsProfileSection(s) {
continue
}
out = append(out, s)
}
return out
}
// mustFindByName finds the RegInfo for the remote name passed in or
// exits with a fatal error.
func mustFindByName(name string) *fs.RegInfo {
@@ -702,7 +716,7 @@ func ShowRedactedConfig() {
// EditConfig edits the config file interactively
func EditConfig(ctx context.Context) (err error) {
for {
haveRemotes := len(LoadedData().GetSectionList()) != 0
haveRemotes := len(remoteSections()) != 0
what := []string{"eEdit existing remote", "nNew remote", "dDelete remote", "rRename remote", "cCopy remote", "sSet configuration password", "qQuit config"}
if haveRemotes {
fmt.Printf("Current remotes:\n\n")