<NME> migrator_spec.rb
<BEF> ADDFILE
<MSG> Merge branch 'feature/implement-migration'

<DFF> @@ -0,0 +1,58 @@
+require "imap/backup/migrator"
+
+module Imap::Backup
+  RSpec.describe Migrator do
+    subject { described_class.new(serializer, folder, reset: reset) }
+
+    let(:serializer) { instance_double(Serializer::MboxStore, uids: [1]) }
+    let(:folder) do
+      instance_double(
+        Account::Folder,
+        append: nil, clear: nil, create: nil, name: "name", uids: folder_uids
+      )
+    end
+    let(:folder_uids) { [] }
+    let(:reset) { false }
+    let(:messages) { [[1, message]] }
+    let(:message) { instance_double(Email::Mboxrd::Message, supplied_body: body) }
+    let(:body) { "body" }
+
+    before do
+      allow(serializer).to receive(:each_message) do
+        messages.enum_for(:each)
+      end
+    end
+
+    it "creates the folder" do
+      subject.run
+
+      expect(folder).to have_received(:create)
+    end
+
+    it "uploads messages" do
+      subject.run
+
+      expect(folder).to have_received(:append).with(message)
+    end
+
+    context "when the folder is not empty" do
+      let(:folder_uids) { [99] }
+
+      it "fails" do
+        expect do
+          subject.run
+        end.to raise_error(RuntimeError, /The destination folder 'name' is not empty/)
+      end
+
+      context "when `reset` is true" do
+        let(:reset) { true }
+
+        it "clears the folder" do
+          subject.run
+
+          expect(folder).to have_received(:clear)
+        end
+      end
+    end
+  end
+end

{'commit': 'diff --git a/CHANGELOG.md b/CHANGELOG.md\nindex bf0ea48..7ba1937 100644\n--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.\n The format is based on [Keep a Changelog](http://keepachangelog.com/)\n and this project adheres to [Semantic Versioning](http://semver.org/).\n \n+## [5.0.0] - 2022-02-06\n+\n+### Added\n+\n+* A dedicated `migrate` command, distinct from `restore`.\n+\n ## [4.0.8] - 2021-12-23\n \n ### Added\ndiff --git a/lib/imap/backup/account/folder.rb b/lib/imap/backup/account/folder.rb\nindex c68fc95..ec67575 100644\n--- a/lib/imap/backup/account/folder.rb\n+++ b/lib/imap/backup/account/folder.rb\n@@ -111,6 +111,14 @@ module Imap::Backup\n       extract_uid(response)\n     end\n \n+    def clear\n+      existing = uids\n+      # Use read-write access, via `select`\n+      client.select(utf7_encoded_name)\n+      client.uid_store(existing, "+FLAGS", [:Deleted])\n+      client.expunge\n+    end\n+\n     private\n \n     def examine\ndiff --git a/lib/imap/backup/cli.rb b/lib/imap/backup/cli.rb\nindex d81c563..535bc43 100644\n--- a/lib/imap/backup/cli.rb\n+++ b/lib/imap/backup/cli.rb\n@@ -9,6 +9,7 @@ module Imap::Backup\n     autoload :Backup, "imap/backup/cli/backup"\n     autoload :Folders, "imap/backup/cli/folders"\n     autoload :Local, "imap/backup/cli/local"\n+    autoload :Migrate, "imap/backup/cli/migrate"\n     autoload :Remote, "imap/backup/cli/remote"\n     autoload :Restore, "imap/backup/cli/restore"\n     autoload :Setup, "imap/backup/cli/setup"\n@@ -27,7 +28,7 @@ module Imap::Backup\n       method_option(\n         "accounts",\n         type: :string,\n-        banner: "a comma-separated list of accounts (defaults to all configured accounts)",\n+        desc: "a comma-separated list of accounts (defaults to all configured accounts)",\n         aliases: ["-a"]\n       )\n     end\n@@ -57,6 +58,54 @@ module Imap::Backup\n       Folders.new(symbolized(options)).run\n     end\n \n+    desc "local SUBCOMMAND [OPTIONS]", "View local info"\n+    subcommand "local", Local\n+\n+    desc "migrate SOURCE_EMAIL DESTINATION_EMAIL [OPTIONS]",\n+      "[Experimental] Uploads backed-up emails from account SOURCE_EMAIL to account DESTINATION_EMAIL"\n+    long_desc <<~DESC\n+      All emails which have been backed up for the "source account" (SOURCE_EMAIL) are\n+      uploaded to the "destination account" (DESTINATION_EMAIL).\n+\n+      When one or other account has namespaces (i.e. prefixes like "INBOX."),\n+      use the `--source-prefix=` and/or `--destination-prefix=` options.\n+\n+      Usually, you should migrate to an account with empty folders.\n+\n+      Before migrating each folder, `imap-backup` checks if the destination\n+      folder is empty.\n+\n+      If it finds a non-empty destination folder, it halts with an error.\n+\n+      If you are sure that these destination emails can be deleted,\n+      use the `--reset` option. In this case, all existing emails are\n+      deleted before uploading the migrated emails.\n+    DESC\n+    method_option(\n+      "destination-prefix",\n+      type: :string,\n+      desc: "the prefix (namespace) to add to destination folder names",\n+      aliases: ["-d"]\n+    )\n+    method_option(\n+      "reset",\n+      type: :boolean,\n+      desc: "DANGER! This option deletes all messages from destination folders before uploading",\n+      aliases: ["-r"]\n+    )\n+    method_option(\n+      "source-prefix",\n+      type: :string,\n+      desc: "the prefix (namespace) to strip from source folder names",\n+      aliases: ["-s"]\n+    )\n+    def migrate(source_email, destination_email)\n+      Migrate.new(source_email, destination_email, symbolized(options)).run\n+    end\n+\n+    desc "remote SUBCOMMAND [OPTIONS]", "View info about online accounts"\n+    subcommand "remote", Remote\n+\n     desc "restore [OPTIONS]", "This command is deprecated, use `imap-backup restore ACCOUNT`"\n     long_desc <<~DESC\n       By default, restores all local emails to their respective servers.\n@@ -86,12 +135,6 @@ module Imap::Backup\n       Status.new(symbolized(options)).run\n     end\n \n-    desc "local SUBCOMMAND [OPTIONS]", "View local info"\n-    subcommand "local", Local\n-\n-    desc "remote SUBCOMMAND [OPTIONS]", "View info about online accounts"\n-    subcommand "remote", Remote\n-\n     desc "utils SUBCOMMAND [OPTIONS]", "Various utilities"\n     subcommand "utils", Utils\n   end\ndiff --git a/lib/imap/backup/cli/helpers.rb b/lib/imap/backup/cli/helpers.rb\nindex 3c49482..1998807 100644\n--- a/lib/imap/backup/cli/helpers.rb\n+++ b/lib/imap/backup/cli/helpers.rb\n@@ -3,7 +3,10 @@ require "imap/backup/cli/accounts"\n \n module Imap::Backup::CLI::Helpers\n   def symbolized(options)\n-    options.each.with_object({}) { |(k, v), acc| acc[k.intern] = v }\n+    options.each.with_object({}) do |(k, v), acc|\n+      key = k.gsub("-", "_").intern\n+      acc[key] = v\n+    end\n   end\n \n   def account(email)\ndiff --git a/lib/imap/backup/cli/migrate.rb b/lib/imap/backup/cli/migrate.rb\nnew file mode 100644\nindex 0000000..3e728e3\n--- /dev/null\n+++ b/lib/imap/backup/cli/migrate.rb\n@@ -0,0 +1,114 @@\n+require "imap/backup/migrator"\n+\n+module Imap::Backup\n+  class CLI::Migrate < Thor\n+    include Thor::Actions\n+\n+    attr_reader :destination_email\n+    attr_reader :destination_prefix\n+    attr_reader :reset\n+    attr_reader :source_email\n+    attr_reader :source_prefix\n+\n+    def initialize(\n+      source_email,\n+      destination_email,\n+      destination_prefix: "",\n+      reset: false,\n+      source_prefix: ""\n+    )\n+      super([])\n+      @destination_email = destination_email\n+      @destination_prefix = destination_prefix\n+      @reset = reset\n+      @source_email = source_email\n+      @source_prefix = source_prefix\n+    end\n+\n+    no_commands do\n+      def run\n+        check_accounts!\n+        folders.each do |serializer, folder|\n+          Migrator.new(serializer, folder, reset: reset).run\n+        end\n+      end\n+\n+      def check_accounts!\n+        if destination_email == source_email\n+          raise "Source and destination accounts cannot be the same!"\n+        end\n+\n+        if !destination_account\n+          raise "Account #{destination_email} does not exist"\n+        end\n+\n+        if !source_account\n+          raise "Account #{source_email} does not exist"\n+        end\n+      end\n+\n+      def config\n+        Configuration.new\n+      end\n+\n+      def destination_account\n+        config.accounts.find { |a| a.username == destination_email }\n+      end\n+\n+      def folders\n+        return enum_for(:folders) if !block_given?\n+\n+        glob = File.join(source_local_path, "**", "*.imap")\n+        Pathname.glob(glob) do |path|\n+          name = source_folder_name(path)\n+          serializer = Serializer::Mbox.new(source_local_path, name)\n+          folder = folder_for(name)\n+          yield serializer, folder\n+        end\n+      end\n+\n+      def folder_for(source_folder)\n+        no_source_prefix =\n+          if source_prefix != "" &&\n+              source_folder.start_with?(source_prefix)\n+            source_folder.delete_prefix(source_prefix)\n+          else\n+            source_folder.to_s\n+          end\n+\n+        with_destination_prefix =\n+          if destination_prefix &&\n+              destination_prefix != ""\n+            destination_prefix + no_source_prefix\n+          else\n+            no_source_prefix\n+          end\n+\n+        Account::Folder.new(\n+          destination_account.connection,\n+          with_destination_prefix\n+        )\n+      end\n+\n+      def source_local_path\n+        source_account.local_path\n+      end\n+\n+      def source_account\n+        config.accounts.find { |a| a.username == source_email }\n+      end\n+\n+      def source_folder_name(imap_pathname)\n+        base = Pathname.new(source_local_path)\n+        imap_name = imap_pathname.relative_path_from(base).to_s\n+        dir = File.dirname(imap_name)\n+        stripped = File.basename(imap_name, ".imap")\n+        if dir == "."\n+          stripped\n+        else\n+          File.join(dir, stripped)\n+        end\n+      end\n+    end\n+  end\n+end\ndiff --git a/lib/imap/backup/client/default.rb b/lib/imap/backup/client/default.rb\nindex 7cae0ac..2f78ef8 100644\n--- a/lib/imap/backup/client/default.rb\n+++ b/lib/imap/backup/client/default.rb\n@@ -7,8 +7,8 @@ module Imap::Backup\n   class Client::Default\n     extend Forwardable\n     def_delegators :imap, *%i(\n-      append authenticate create disconnect examine\n-      login responses uid_fetch uid_search\n+      append authenticate create disconnect examine expunge\n+      login responses select uid_fetch uid_search uid_store\n     )\n \n     attr_reader :args\ndiff --git a/lib/imap/backup/migrator.rb b/lib/imap/backup/migrator.rb\nnew file mode 100644\nindex 0000000..12e81b3\n--- /dev/null\n+++ b/lib/imap/backup/migrator.rb\n@@ -0,0 +1,51 @@\n+module Imap::Backup\n+  class Migrator\n+    attr_reader :folder\n+    attr_reader :reset\n+    attr_reader :serializer\n+\n+    def initialize(serializer, folder, reset: false)\n+      @folder = folder\n+      @reset = reset\n+      @serializer = serializer\n+    end\n+\n+    def run\n+      count = serializer.uids.count\n+      folder.create\n+      ensure_destination_empty!\n+\n+      Imap::Backup::Logger.logger.debug "[#{folder.name}] #{count} to migrate"\n+      serializer.each_message(serializer.uids).with_index do |(uid, message), i|\n+        next if message.nil?\n+\n+        log_prefix = "[#{folder.name}] uid: #{uid} (#{i + 1}/#{count}) -"\n+        Imap::Backup::Logger.logger.debug(\n+          "#{log_prefix} #{message.supplied_body.size} bytes"\n+        )\n+\n+        folder.append(message)\n+      end\n+    end\n+\n+    private\n+\n+    def ensure_destination_empty!\n+      if reset\n+        folder.clear\n+      else\n+        fail_if_destination_not_empty!\n+      end\n+    end\n+\n+    def fail_if_destination_not_empty!\n+      return if folder.uids.empty?\n+\n+      raise <<~ERROR\n+      The destination folder \'#{folder.name}\' is not empty.\n+      Pass the --reset flag if you want to clear existing emails from destination\n+      folders before uploading.\n+      ERROR\n+    end\n+  end\n+end\ndiff --git a/lib/imap/backup/version.rb b/lib/imap/backup/version.rb\nindex afd94a6..148513b 100644\n--- a/lib/imap/backup/version.rb\n+++ b/lib/imap/backup/version.rb\n@@ -1,9 +1,9 @@\n module Imap; end\n \n module Imap::Backup\n-  MAJOR    = 4\n-  MINOR    = 2\n-  REVISION = 2\n+  MAJOR    = 5\n+  MINOR    = 0\n+  REVISION = 0\n   PRE      = nil\n   VERSION  = [MAJOR, MINOR, REVISION, PRE].compact.map(&:to_s).join(".")\n end\ndiff --git a/spec/features/migrate_spec.rb b/spec/features/migrate_spec.rb\nnew file mode 100644\nindex 0000000..309b45e\n--- /dev/null\n+++ b/spec/features/migrate_spec.rb\n@@ -0,0 +1,35 @@\n+require "features/helper"\n+\n+RSpec.describe "Migration", type: :aruba, docker: true do\n+  let(:email) { "me@example.com" }\n+  let(:folder) { "my_folder" }\n+  let(:source_account) do\n+    {\n+      username: email,\n+      local_path: File.join(config_path, email.gsub("@", "_"))\n+    }\n+  end\n+  let(:destination_account) { fixture("connection") }\n+\n+  before do\n+    create_config(accounts: [source_account, destination_account])\n+    store_email(email: email, folder: folder, subject: "Ciao")\n+    run_command_and_stop("imap-backup migrate #{email} #{destination_account[:username]}")\n+  end\n+\n+  after do\n+    delete_emails(folder)\n+  end\n+\n+  it "copies email to the destination account" do\n+    messages = server_messages(folder)\n+    expected = <<~MESSAGE.gsub("\\n", "\\r\\n")\n+      From: sender@example.com\n+      Subject: Ciao\n+\n+      body\n+\n+    MESSAGE\n+    expect(messages[0]["BODY[]"]).to eq(expected)\n+  end\n+end\ndiff --git a/spec/features/support/email_server.rb b/spec/features/support/email_server.rb\nindex 1142bcc..253ce16 100644\n--- a/spec/features/support/email_server.rb\n+++ b/spec/features/support/email_server.rb\n@@ -106,5 +106,6 @@ module EmailServerHelpers\n end\n \n RSpec.configure do |config|\n+  config.include EmailServerHelpers, type: :aruba\n   config.include EmailServerHelpers, type: :feature\n end\ndiff --git a/spec/unit/imap/backup/account/folder_spec.rb b/spec/unit/imap/backup/account/folder_spec.rb\nindex 1d08611..bd5a4ab 100644\n--- a/spec/unit/imap/backup/account/folder_spec.rb\n+++ b/spec/unit/imap/backup/account/folder_spec.rb\n@@ -12,7 +12,10 @@ describe Imap::Backup::Account::Folder do\n       append: append_response,\n       create: nil,\n       examine: nil,\n-      responses: responses\n+      expunge: nil,\n+      responses: responses,\n+      select: nil,\n+      uid_store: nil\n     )\n   end\n   let(:connection) do\n@@ -27,12 +30,11 @@ describe Imap::Backup::Account::Folder do\n   end\n   let(:responses) { [] }\n   let(:append_response) { nil }\n+  let(:uids) { [5678, 123] }\n \n-  describe "#uids" do\n-    let(:uids) { [5678, 123] }\n-\n-    before { allow(client).to receive(:uid_search) { uids } }\n+  before { allow(client).to receive(:uid_search) { uids } }\n \n+  describe "#uids" do\n     it "lists available messages" do\n       expect(subject.uids).to eq(uids.reverse)\n     end\n@@ -233,6 +235,25 @@ describe Imap::Backup::Account::Folder do\n       expect(subject.uid_validity).to eq(1)\n     end\n   end\n+\n+  describe "#clear" do\n+    before do\n+      subject.clear\n+    end\n+\n+    it "uses select to have read-write access" do\n+      expect(client).to have_received(:select)\n+    end\n+\n+    it "marks all emails as deleted" do\n+      expect(client).\n+        to have_received(:uid_store).with(uids.sort, "+FLAGS", [:Deleted])\n+    end\n+\n+    it "deletes marked emails" do\n+      expect(client).to have_received(:expunge)\n+    end\n+  end\n end\n \n # rubocop:enable RSpec/PredicateMatcher\ndiff --git a/spec/unit/imap/backup/cli/helpers_spec.rb b/spec/unit/imap/backup/cli/helpers_spec.rb\nnew file mode 100644\nindex 0000000..634c682\n--- /dev/null\n+++ b/spec/unit/imap/backup/cli/helpers_spec.rb\n@@ -0,0 +1,87 @@\n+require "imap/backup/cli/helpers"\n+\n+module Imap::Backup\n+  class WithHelpers\n+    include CLI::Helpers\n+  end\n+\n+  RSpec.describe CLI::Helpers do\n+    subject { WithHelpers.new }\n+\n+    let(:accounts) { instance_double(CLI::Accounts) }\n+    let(:email) { "email@example.com" }\n+    let(:account1) { instance_double(Account, username: email, connection: "c1") }\n+    let(:account2) { instance_double(Account, username: "foo", connection: "c2") }\n+    let(:items) { [account1, account2] }\n+\n+    before do\n+      allow(CLI::Accounts).to receive(:new) { accounts }\n+      allow(accounts).to receive(:each).\n+        and_yield(account1).\n+        and_yield(account2)\n+      allow(accounts).to receive(:find) do |&block|\n+        items.find { |a| block.call(a) }\n+      end\n+    end\n+\n+    describe ".symbolized" do\n+      let(:arguments) { {"foo" => 1, "bar" => 2} }\n+      let(:result) { subject.symbolized(arguments) }\n+\n+      it "converts string keys to symbols" do\n+        expect(result.keys).to eq([:foo, :bar])\n+      end\n+\n+      context "when keys have hyphens" do\n+        let(:arguments) { {"some-option"=> 3} }\n+\n+        it "replaces them with underscores" do\n+          expect(result.keys).to eq([:some_option])\n+        end\n+      end\n+    end\n+\n+    describe ".account" do\n+      it "returns any account with a matching username" do\n+        expect(subject.account(email)).to eq(account1)\n+      end\n+\n+      context "when no match is found" do\n+        let(:items) { [account2] }\n+        it "fails" do\n+          expect do\n+            subject.account(email)\n+          end.to raise_error(RuntimeError, /not a configured account/)\n+        end\n+      end\n+    end\n+\n+    describe ".connection" do\n+      it "returns the connection for any account with a matching username" do\n+        result = subject.connection(email)\n+        expect(result).to be_a(Account::Connection)\n+        expect(result.account).to eq(account1)\n+      end\n+    end\n+\n+    describe ".each_connection" do\n+      it "yields each connection" do\n+        expect { |b| subject.each_connection([email, "foo"], &b) }.\n+          to yield_successive_args("c1", "c2")\n+      end\n+\n+      context "when there is no configuration" do\n+        before do\n+          allow(accounts).to receive(:each).\n+            and_raise(Imap::Backup::ConfigurationNotFound)\n+        end\n+\n+        it "fails" do\n+          expect do\n+            subject.each_connection([email])\n+          end.to raise_error(RuntimeError, /not configured/)\n+        end\n+      end\n+    end\n+  end\n+end\ndiff --git a/spec/unit/imap/backup/migrator_spec.rb b/spec/unit/imap/backup/migrator_spec.rb\nnew file mode 100644\nindex 0000000..1a9887f\n--- /dev/null\n+++ b/spec/unit/imap/backup/migrator_spec.rb\n@@ -0,0 +1,58 @@\n+require "imap/backup/migrator"\n+\n+module Imap::Backup\n+  RSpec.describe Migrator do\n+    subject { described_class.new(serializer, folder, reset: reset) }\n+\n+    let(:serializer) { instance_double(Serializer::MboxStore, uids: [1]) }\n+    let(:folder) do\n+      instance_double(\n+        Account::Folder,\n+        append: nil, clear: nil, create: nil, name: "name", uids: folder_uids\n+      )\n+    end\n+    let(:folder_uids) { [] }\n+    let(:reset) { false }\n+    let(:messages) { [[1, message]] }\n+    let(:message) { instance_double(Email::Mboxrd::Message, supplied_body: body) }\n+    let(:body) { "body" }\n+\n+    before do\n+      allow(serializer).to receive(:each_message) do\n+        messages.enum_for(:each)\n+      end\n+    end\n+\n+    it "creates the folder" do\n+      subject.run\n+\n+      expect(folder).to have_received(:create)\n+    end\n+\n+    it "uploads messages" do\n+      subject.run\n+\n+      expect(folder).to have_received(:append).with(message)\n+    end\n+\n+    context "when the folder is not empty" do\n+      let(:folder_uids) { [99] }\n+\n+      it "fails" do\n+        expect do\n+          subject.run\n+        end.to raise_error(RuntimeError, /The destination folder \'name\' is not empty/)\n+      end\n+\n+      context "when `reset` is true" do\n+        let(:reset) { true }\n+\n+        it "clears the folder" do\n+          subject.run\n+\n+          expect(folder).to have_received(:clear)\n+        end\n+      end\n+    end\n+  end\n+end\n', 'commit_subject': "Merge branch 'feature/implement-migration'", 'repo_name': 'joeyates/imap-backup', 'committer': {'name': 'Joe Yates', 'email': '6f0698f0196cecf4f84c49c4eda76e51129c785e@gmail.com', 'time_sec': '1644163546', 'tz_offset': '60', 'date': {'seconds': '1644163546'}}, 'commit_tree': '5f08b94363094ec1d217746cc7c0c536d239ff84', 'commit_message': "Merge branch 'feature/implement-migration'\n", 'language_name': 'Ruby', 'license': 'mit', 'hunks': '@@ -0,0 +1,58 @@\n+require "imap/backup/migrator"\n+\n+module Imap::Backup\n+  RSpec.describe Migrator do\n+    subject { described_class.new(serializer, folder, reset: reset) }\n+\n+    let(:serializer) { instance_double(Serializer::MboxStore, uids: [1]) }\n+    let(:folder) do\n+      instance_double(\n+        Account::Folder,\n+        append: nil, clear: nil, create: nil, name: "name", uids: folder_uids\n+      )\n+    end\n+    let(:folder_uids) { [] }\n+    let(:reset) { false }\n+    let(:messages) { [[1, message]] }\n+    let(:message) { instance_double(Email::Mboxrd::Message, supplied_body: body) }\n+    let(:body) { "body" }\n+\n+    before do\n+      allow(serializer).to receive(:each_message) do\n+        messages.enum_for(:each)\n+      end\n+    end\n+\n+    it "creates the folder" do\n+      subject.run\n+\n+      expect(folder).to have_received(:create)\n+    end\n+\n+    it "uploads messages" do\n+      subject.run\n+\n+      expect(folder).to have_received(:append).with(message)\n+    end\n+\n+    context "when the folder is not empty" do\n+      let(:folder_uids) { [99] }\n+\n+      it "fails" do\n+        expect do\n+          subject.run\n+        end.to raise_error(RuntimeError, /The destination folder \'name\' is not empty/)\n+      end\n+\n+      context "when `reset` is true" do\n+        let(:reset) { true }\n+\n+        it "clears the folder" do\n+          subject.run\n+\n+          expect(folder).to have_received(:clear)\n+        end\n+      end\n+    end\n+  end\n+end\n', 'addition_count': 58, 'deletion_count': 0, 'src_file': '/dev/null', 'tgt_file': 'b/spec/unit/imap/backup/migrator_spec.rb', 'file_extension': '.rb', 'before_file': 'ADDFILE', 'message': "Merge branch 'feature/implement-migration'\n", 'author_name': 'Joe Yates', 'lang': 'rb'}