import: write plans and execute them against a host
A plan is a directory of cards and backups, and a plan.json that keeps score:
execute lands each card, makes the group, joins them, and saves after every chore.
A rerun skips what the plan has recorded, and takes a bare 412 as a card that landed,
so a run that died between a write and its save picks up where it ended.
Assisted-by: Claude Opus 5 via Claude Code
Issue: rtqvmzvp
diff --git a/AGENTS.md b/AGENTS.md
index a65c3a5..eb4a59a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,6 +24,8 @@ rake db:dump # Write the cards, birthdays, and groups to data/dump
rake dev # Dev server, seeded from test/fixtures/cards into a
# throwaway tmpdir on every start, reloading on change
# (needs fd and entr)
+rake import:execute # Land an import plan on a host
+ # (PLAN=dir HOST=https://contacts)
rake profile:install # Download carddav.mobileconfig from the app and
# stage it for approval
```
@@ -50,6 +52,7 @@ lib/pro_tacts/
├── web/groups.rb # The group screens and their editor
├── web/setup.rb # The device setup screen and its profile
├── web/api.rb # JSON for scripts: the group list
+├── import/ # Import plans, and landing them on a host
├── admin/ # The Phlex views those screens render, plus
│ # card_form.rb, which reads a form back into a card
├── store.rb # Sequel over SQLite: cards, change log, derived index
diff --git a/Steepfile b/Steepfile
index d1652cd..82cb478 100644
--- a/Steepfile
+++ b/Steepfile
@@ -26,5 +26,5 @@ target :lib do
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
- library "date", "digest", "fileutils", "json", "logger", "pathname", "securerandom", "strscan", "yaml"
+ library "date", "digest", "fileutils", "json", "logger", "net-http", "pathname", "securerandom", "strscan", "time", "uri", "yaml"
end
diff --git a/docs/plans/2026-09-16-importing-from-macos.md b/docs/plans/2026-09-16-importing-from-macos.md
index 535bbf6..2e8cae3 100644
--- a/docs/plans/2026-09-16-importing-from-macos.md
+++ b/docs/plans/2026-09-16-importing-from-macos.md
@@ -6,26 +6,28 @@ host, and remove the originals from the Mac. Monica gets an importer of
its own later, and the two share everything but the first and last
steps.
-## A plan is what will happen
+## A plan is what will happen, and how far it got
A plan is a directory under `data/imports/`, which is ignored, since it
holds every contact in full:
| Path | Holds |
|---|---|
-| `plan.json` | The source, when it was built, the group, and each contact's minted id beside its source id |
+| `plan.json` | The source, when it was built, the group, and each contact's minted id beside its source id; then the host, the group's id, and each contact's status |
| `cards/<id>.vcf` | The bytes `execute` PUTs, byte for byte |
| `backups/<id>/` | The original, in whatever files the source reads it as |
-| `log.jsonl` | What `execute` and `remove` did, one event per line |
-The builder writes the first three once and nothing rewrites them. A plan
-has no open questions in it: every card is final when the plan is
+The builder writes the cards and backups once and nothing rewrites them.
+A plan has no open questions in it: every card is final when the plan is
written, so `execute` runs it rather than finishing it.
-The log is appended rather than rewritten, so a run that dies partway
-leaves an accurate record of what landed. Every event names the host,
-and `execute` refuses a `HOST` other than the one the log already names,
-so one plan cannot land on two servers.
+`plan.json` also records how far `execute` and `remove` have carried the
+plan, saved after every step. A contact's status goes from none to
+`landed`, `joined`, and `removed`. Each save writes a new file and renames
+it over the old one, so a run that dies mid-save leaves the plan as the
+step before left it. `execute` records the host before its first write
+and refuses a `HOST` other than the one recorded, so one plan cannot
+land on two servers.
Companies are not imported. A contact whose `contactType` is
`organization` is left out of the plan, so `remove` never touches it
@@ -36,8 +38,9 @@ contacts at a time: `rake import:macos:plan LIMIT=n` plans the first n
people, companies skipped. First means Contacts.app's own list order,
`CNContactSortOrderUserDefault`; the fetch request's default is
`CNContactSortOrderNone`, which promises no order at all
-(`CNContactFetchRequest.h`). Plans keep no record of one another. The next plan's first n are new because `remove` took
-the last batch off the Mac, so a contact that landed and was not removed
+(`CNContactFetchRequest.h`). Plans keep no record of one another. The
+next plan's first n are new because `remove` took the last batch off the
+Mac, so a contact that landed and was not removed
lands again, as a second card, from the next plan that includes it. Each
plan is its own group.
@@ -49,26 +52,31 @@ original.
## Execute is the same for every source
-`rake import:execute PLAN=dir HOST=host` does the following, logging
-each step:
+`rake import:execute PLAN=dir HOST=host` does the following, recording
+each step in the plan:
1. PUT each card to `/dav/addressbook/<id>.vcf` with `If-None-Match: *`.
2. Create the plan's group, `import-<timestamp>`, through `POST /groups`.
3. Join each card to it through `POST /contacts/<id>/groups`.
-Execute is idempotent against the host, not against its own log: a run
-can die after a write lands and before the log records it, so a rerun
-repeats every step and each step accepts its own earlier success.
+A rerun skips every step the plan records. The plan alone is not
+enough, because a run can die after a write lands and before the plan
+records it, so each step also accepts its own earlier success from the
+host. Skipping
+first keeps a rerun from collecting refusals, each of which warns Sentry
+(`RefusalAlerts`).
-- A PUT answered 412 means the id is already taken, and at 48 bits a
+- A bare 412 to the PUT is `If-None-Match` failing, and at 48 bits a
minted id is taken only by this plan's own earlier PUT, so the card
- counts as landed.
-- A group name the host refuses as taken is this plan's, since the name
- carries the plan's timestamp. Its id comes from `GET /api/groups`,
- every group's id and name as JSON, since the refusal does not name
- the group that holds the name.
+ counts as landed. A 412 with a body is the card refused.
+- The group is looked up by name in `GET /api/groups`, every group's id
+ and name as JSON, before it is created. A name that is already there
+ is this plan's, since it carries the plan's timestamp.
- Joining a group twice is joining once (`Store#add_member`).
+`HOST` is a base URL, such as `https://contacts` or the dev server's
+`http://localhost:9292`.
+
These are the routes a client and the admin UI already use, so the
server gains nothing for this. The host's proxy supplies the identity,
and a PUT that creates a card also puts it in the writer's `sync:` group
@@ -121,9 +129,9 @@ through, and Apple wrote them.
`rake import:macos:remove PLAN=dir` deletes, through `CNSaveRequest`, the
contacts this plan landed. For each one it first GETs the card from the
-host the log names, then reads the contact again and compares it to its
-backup, note included, and skips it with a logged reason if either
-check fails. A contact edited on the Mac since the plan is kept, and so
+host the plan names, then reads the contact again and compares it to its
+backup, note included, and skips it, printing why, if either check
+fails. A contact edited on the Mac since the plan is kept, and so
is one whose card is no longer on the host. A contact already gone from
the Mac counts as removed, so a rerun is safe for the same reason
`execute`'s is.
diff --git a/lib/pro_tacts/import/execute.rb b/lib/pro_tacts/import/execute.rb
new file mode 100644
index 0000000..c35e5db
--- /dev/null
+++ b/lib/pro_tacts/import/execute.rb
@@ -0,0 +1,110 @@
+require "json"
+require "uri"
+
+require "pro_tacts/import/plan"
+
+module ProTacts
+ module Import
+ # Lands a plan on a host through the routes a client and the admin
+ # UI use, the same for every source. Idempotent against the host as
+ # well as the plan (docs/plans/2026-09-16-importing-from-macos.md,
+ # "Execute is the same for every source").
+ class Execute
+ # @rbs @plan: Plan
+ # @rbs @host: String
+ # @rbs @client: _Client
+
+ class Failed < StandardError; end
+
+ # What a host answered. Signed in sig/pro_tacts/import.rbs, being a
+ # Data class.
+ # @rbs skip
+ Response = Data.define(:status, :headers, :body)
+
+ #: (Plan plan, host: String, client: _Client) -> void
+ def self.call(plan, host:, client:)
+ new(plan, host:, client:).call
+ end
+
+ #: (Plan plan, host: String, client: _Client) -> void
+ def initialize(plan, host:, client:)
+ @plan = plan
+ @host = host
+ @client = client
+ end
+
+ #: () -> void
+ def call
+ landed_on = @plan.host
+ if landed_on && landed_on != @host
+ raise Failed, "#{@plan.dir} is landing on #{landed_on}; refusing to land it on #{@host} too"
+ end
+ # Before the first write, so a run that dies after one still ties
+ # the plan to this host.
+ @plan.host = @host unless landed_on
+
+ @plan.contacts.each { land(it.id) if it.status.nil? }
+ group_id = @plan.group_id || make_group
+ @plan.contacts.each { join(it.id, group_id) if it.status == "landed" }
+ end
+
+ private
+
+ # A bare 412 is If-None-Match failing on an id this plan minted, so
+ # the card is on the host already: an earlier run's PUT landed and
+ # died before the plan recorded it. A 412 with a body is the card
+ # refused.
+ #: (String id) -> void
+ def land(id)
+ response = @client.call(
+ "PUT", "/dav/addressbook/#{id}.vcf",
+ headers: {"Content-Type" => "text/vcard; charset=utf-8", "If-None-Match" => "*"},
+ body: @plan.card(id)
+ )
+ unless [201, 204].include?(response.status) || response.status == 412 && response.body.empty?
+ raise Failed, "PUT of #{id} answered #{response.status}: #{response.body}"
+ end
+
+ @plan.record(id, "landed")
+ end
+
+ # Looked up before it is made, so a run that died after making it
+ # joins the one it made.
+ #: () -> String
+ def make_group
+ id = listed_group_id
+ unless id
+ response = post("/groups", [["name", @plan.group]])
+ raise Failed, "creating group #{@plan.group} answered #{response.status}" unless response.status == 303
+
+ id = listed_group_id or raise Failed, "group #{@plan.group} was created and is not listed"
+ end
+
+ @plan.group_id = id
+ id
+ end
+
+ #: () -> String?
+ def listed_group_id
+ response = @client.call("GET", "/api/groups")
+ raise Failed, "listing groups answered #{response.status}" unless response.status == 200
+
+ groups = JSON.parse(response.body) #: Array[Hash[String, String?]]
+ groups.find { it.fetch("name") == @plan.group }&.fetch("id")
+ end
+
+ #: (String id, String group_id) -> void
+ def join(id, group_id)
+ response = post("/contacts/#{id}/groups", [["groups[]", group_id]])
+ raise Failed, "joining #{id} to #{@plan.group} answered #{response.status}" unless response.status == 303
+
+ @plan.record(id, "joined")
+ end
+
+ #: (String path, Array[[String, String]] form) -> Response
+ def post(path, form)
+ @client.call("POST", path, headers: {"Content-Type" => "application/x-www-form-urlencoded"}, body: URI.encode_www_form(form))
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/import/http_client.rb b/lib/pro_tacts/import/http_client.rb
new file mode 100644
index 0000000..1fa89d1
--- /dev/null
+++ b/lib/pro_tacts/import/http_client.rb
@@ -0,0 +1,24 @@
+require "net/http"
+
+require "pro_tacts/import/execute"
+
+module ProTacts
+ module Import
+ # A host over one started connection. The identity header is the
+ # host's proxy to write, so nothing here sets one.
+ class HttpClient
+ # @rbs @http: Net::HTTP
+
+ #: (Net::HTTP http) -> void
+ def initialize(http)
+ @http = http
+ end
+
+ #: (String method, String path, ?headers: Hash[String, String], ?body: String?) -> Execute::Response
+ def call(method, path, headers: {}, body: nil)
+ response = @http.send_request(method, path, body, headers)
+ Execute::Response.new(status: response.code.to_i, headers: response.to_hash.transform_values { it.join(", ") }, body: response.body.to_s)
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
new file mode 100644
index 0000000..2477c26
--- /dev/null
+++ b/lib/pro_tacts/import/plan.rb
@@ -0,0 +1,129 @@
+require "fileutils"
+require "json"
+require "pathname"
+require "time"
+
+module ProTacts
+ module Import
+ # One import, as a directory: what a source builder decided, and how
+ # far execute and remove have carried it
+ # (docs/plans/2026-09-16-importing-from-macos.md).
+ class Plan
+ # @rbs @dir: Pathname
+ # @rbs @manifest: Hash[String, untyped]
+
+ # What a builder hands over for one contact: the card execute PUTs
+ # and the original's files, by name. Signed in
+ # sig/pro_tacts/import.rbs, being a Data class.
+ # @rbs skip
+ Entry = Data.define(:id, :source_id, :card, :backup)
+
+ # A contact as the plan lists it, with the last step it reached:
+ # nil, then landed, joined, and removed, in that order.
+ # @rbs skip
+ Contact = Data.define(:id, :source_id, :status)
+
+ FORMAT = 1 #: Integer
+
+ #: (Pathname dir, source: String, created_at: Time, entries: Array[Entry]) -> Plan
+ def self.write(dir, source:, created_at:, entries:)
+ FileUtils.mkdir_p(dir.dirname)
+ # Raises when the directory is there, so no plan is written over
+ # another.
+ Dir.mkdir(dir)
+
+ entries.each do |entry|
+ (dir / "cards").mkpath
+ (dir / "cards/#{entry.id}.vcf").binwrite(entry.card)
+ backup = dir / "backups" / entry.id
+ backup.mkpath
+ entry.backup.each { |name, bytes| (backup / name).binwrite(bytes) }
+ end
+
+ plan = new(dir, {
+ "format" => FORMAT,
+ "source" => source,
+ "created_at" => created_at.utc.iso8601,
+ "group" => "import-#{created_at.utc.strftime("%Y%m%dT%H%M%SZ")}",
+ "host" => nil,
+ "group_id" => nil,
+ "contacts" => entries.map { {"id" => it.id, "source_id" => it.source_id, "status" => nil} }
+ })
+ plan.save
+ plan
+ end
+
+ #: (Pathname dir) -> Plan
+ def self.read(dir)
+ new(dir, JSON.parse((dir / "plan.json").read))
+ end
+
+ attr_reader :dir #: Pathname
+
+ #: (Pathname dir, Hash[String, untyped] manifest) -> void
+ def initialize(dir, manifest)
+ format = manifest.fetch("format")
+ raise ArgumentError, "#{dir} is plan format #{format}, and this reads #{FORMAT}" unless format == FORMAT
+
+ @dir = dir
+ @manifest = manifest
+ end
+
+ #: () -> String
+ def source = @manifest.fetch("source")
+
+ #: () -> String
+ def created_at = @manifest.fetch("created_at")
+
+ #: () -> String
+ def group = @manifest.fetch("group")
+
+ # The host the plan is landing on, once execute has started.
+ #: () -> String?
+ def host = @manifest.fetch("host")
+
+ #: (String host) -> void
+ def host=(host)
+ @manifest["host"] = host
+ save
+ end
+
+ #: () -> String?
+ def group_id = @manifest.fetch("group_id")
+
+ #: (String id) -> void
+ def group_id=(id)
+ @manifest["group_id"] = id
+ save
+ end
+
+ #: () -> Array[Contact]
+ def contacts
+ @manifest.fetch("contacts").map { Contact.new(id: it.fetch("id"), source_id: it.fetch("source_id"), status: it.fetch("status")) }
+ end
+
+ #: (String id, String status) -> void
+ def record(id, status)
+ @manifest.fetch("contacts").find { it.fetch("id") == id }.store("status", status)
+ save
+ end
+
+ #: (String id) -> String
+ def card(id)
+ (dir / "cards/#{id}.vcf").binread
+ end
+
+ # Every step is saved as it is taken, and replaced whole by a rename
+ # so a run that dies mid-write leaves the plan as it was before.
+ #: () -> void
+ def save
+ written = dir / "plan.json.tmp"
+ written.open("w") do |file|
+ file.write(JSON.pretty_generate(@manifest))
+ file.fsync
+ end
+ written.rename(dir / "plan.json")
+ end
+ end
+ end
+end
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
new file mode 100644
index 0000000..fcf589d
--- /dev/null
+++ b/sig/pro_tacts/import.rbs
@@ -0,0 +1,43 @@
+# Import::Plan::Entry, ::Contact, and Import::Execute::Response are Data
+# classes, whose members the inline parser cannot read (see
+# sig/pro_tacts/contact.rbs). Each is marked `@rbs skip` where it is
+# defined and signed here. _Client is an interface, which inline syntax
+# has no way to declare.
+module ProTacts
+ module Import
+ class Plan
+ class Entry
+ attr_reader id: String
+ attr_reader source_id: String
+ attr_reader card: String
+ attr_reader backup: Hash[String, String]
+
+ def self.new: (id: String, source_id: String, card: String, backup: Hash[String, String]) -> instance
+ end
+
+ class Contact
+ attr_reader id: String
+ attr_reader source_id: String
+ attr_reader status: String?
+
+ def self.new: (id: String, source_id: String, status: String?) -> instance
+ end
+ end
+
+ class Execute
+ class Response
+ attr_reader status: Integer
+ attr_reader headers: Hash[String, String]
+ attr_reader body: String
+
+ def self.new: (status: Integer, headers: Hash[String, String], body: String) -> instance
+ end
+ end
+
+ # A host, spoken to by path: Import::HttpClient, or the app itself
+ # under test.
+ interface _Client
+ def call: (String method, String path, ?headers: Hash[String, String], ?body: String?) -> Execute::Response
+ end
+ end
+end
diff --git a/tasks/import.rake b/tasks/import.rake
new file mode 100644
index 0000000..d8dd04b
--- /dev/null
+++ b/tasks/import.rake
@@ -0,0 +1,23 @@
+# Importing an address book from another source
+# (docs/plans/2026-09-16-importing-from-macos.md).
+
+namespace :import do
+ desc "Land the plan in PLAN on the pro-tacts at HOST, a base URL such as https://contacts"
+ task :execute do
+ require "net/http"
+ require "pathname"
+ require "uri"
+ require "pro_tacts/import/execute"
+ require "pro_tacts/import/http_client"
+ require "pro_tacts/import/plan"
+
+ plan = ProTacts::Import::Plan.read(Pathname.new(ENV.fetch("PLAN")))
+ host = ENV.fetch("HOST")
+ uri = URI.parse(host)
+
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
+ ProTacts::Import::Execute.call(plan, host:, client: ProTacts::Import::HttpClient.new(http))
+ end
+ puts "#{plan.contacts.size} contacts in #{plan.group} on #{host}"
+ end
+end
diff --git a/test/pro_tacts/import/test_execute.rb b/test/pro_tacts/import/test_execute.rb
new file mode 100644
index 0000000..3fe135a
--- /dev/null
+++ b/test/pro_tacts/import/test_execute.rb
@@ -0,0 +1,117 @@
+require_relative "../../test_helper"
+
+require "pathname"
+require "rack/mock"
+require "tmpdir"
+
+require "pro_tacts/import/execute"
+require "pro_tacts/import/plan"
+require "pro_tacts/web"
+
+class ImportExecuteTest < Minitest::Test
+ include ThrowawayContacts
+
+ Plan = ProTacts::Import::Plan
+ Execute = ProTacts::Import::Execute
+
+ HOST = "https://contacts"
+ IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
+
+ # The app in place of a host, the proxy's identity header included.
+ class RackClient
+ attr_reader :requests
+
+ def initialize
+ @requests = []
+ end
+
+ def call(method, path, headers: {}, body: nil)
+ @requests << [method, path]
+ env = Rack::MockRequest.env_for(path, method:, input: body, "HTTP_REMOTE_USER" => "test@example.com")
+ headers.each { |name, value| env[name.casecmp?("Content-Type") ? "CONTENT_TYPE" : "HTTP_#{name.upcase.tr("-", "_")}"] = value }
+ status, response_headers, response_body = ProTacts::Web.call(env)
+ bytes = +""
+ response_body.each { bytes << it }
+ response_body.close if response_body.respond_to?(:close)
+ Execute::Response.new(status:, headers: response_headers, body: bytes)
+ end
+ end
+
+ def with_plan(cards = IDS.to_h { [it, card(it, "Contact #{it}")] })
+ Dir.mktmpdir do |tmp|
+ dir = Pathname.new(tmp) / "plan"
+ entries = cards.map { |id, card| Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card:, backup: {}) }
+ Plan.write(dir, source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries:)
+ with_contacts({}) { |store| yield Plan.read(dir), store, RackClient.new }
+ end
+ end
+
+ def imported_group(store)
+ store.all_groups.find { it.name == "import-20260916T180412Z" }
+ end
+
+ def test_every_card_lands_in_the_plans_group
+ with_plan do |plan, store, client|
+ Execute.call(plan, host: HOST, client:)
+
+ IDS.each { assert store.contact(it), it }
+ assert_equal IDS.sort, imported_group(store).members.sort
+ assert_equal %w[joined joined], Plan.read(plan.dir).contacts.map(&:status)
+ end
+ end
+
+ def test_a_rerun_writes_nothing_again
+ with_plan do |plan, store, client|
+ Execute.call(plan, host: HOST, client:)
+ changes = store.changes.size
+ client.requests.clear
+
+ Execute.call(Plan.read(plan.dir), host: HOST, client:)
+
+ assert_empty client.requests
+ assert_equal changes, store.changes.size
+ assert_equal 1, store.all_groups.count { it.name == "import-20260916T180412Z" }
+ end
+ end
+
+ # A run that died between a write and the plan recording it.
+ def test_a_card_already_on_the_host_counts_as_landed
+ with_plan do |plan, store, client|
+ client.call("PUT", "/dav/addressbook/#{IDS.first}.vcf", headers: {"Content-Type" => "text/vcard"}, body: plan.card(IDS.first))
+
+ Execute.call(plan, host: HOST, client:)
+
+ assert_equal IDS.sort, imported_group(store).members.sort
+ end
+ end
+
+ def test_a_group_already_on_the_host_is_joined_rather_than_made_again
+ with_plan do |plan, store, client|
+ id = store.create_group(name: "import-20260916T180412Z")
+
+ Execute.call(plan, host: HOST, client:)
+
+ assert_equal id, imported_group(store).id
+ assert_equal IDS.sort, store.group(id).members.sort
+ end
+ end
+
+ def test_a_plan_that_landed_on_one_host_refuses_another
+ with_plan do |plan, store, client|
+ Execute.call(plan, host: HOST, client:)
+
+ error = assert_raises(Execute::Failed) { Execute.call(Plan.read(plan.dir), host: "http://localhost:9292", client:) }
+ assert_includes error.message, HOST
+ end
+ end
+
+ def test_a_refused_card_stops_the_run_and_is_not_recorded
+ with_plan({"kmnuqmzxylru" => card("someone-else", "Mismatched UID")}) do |plan, store, client|
+ error = assert_raises(Execute::Failed) { Execute.call(plan, host: HOST, client:) }
+
+ assert_includes error.message, "kmnuqmzxylru"
+ assert_includes error.message, "412"
+ assert_equal [nil], Plan.read(plan.dir).contacts.map(&:status)
+ end
+ end
+end
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
new file mode 100644
index 0000000..fe493f3
--- /dev/null
+++ b/test/pro_tacts/import/test_plan.rb
@@ -0,0 +1,81 @@
+require_relative "../../test_helper"
+
+require "json"
+require "pathname"
+require "tmpdir"
+
+require "pro_tacts/import/plan"
+
+class ImportPlanTest < Minitest::Test
+ include ThrowawayContacts
+
+ Plan = ProTacts::Import::Plan
+
+ CREATED_AT = Time.utc(2026, 9, 16, 18, 4, 12)
+
+ def entry(id, source_id: "#{id}:ABPerson", backup: {"original.vcf" => "BEGIN:VCARD\r\n"})
+ Plan::Entry.new(id:, source_id:, card: card(id, "Ada Lovelace"), backup:)
+ end
+
+ def in_tmpdir
+ Dir.mktmpdir { yield Pathname.new(it) / "plan" }
+ end
+
+ def test_a_written_plan_reads_back
+ in_tmpdir do |dir|
+ Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
+
+ plan = Plan.read(dir)
+
+ assert_equal "macos", plan.source
+ assert_equal "2026-09-16T18:04:12Z", plan.created_at
+ assert_equal [Plan::Contact.new(id: "kmnuqmzxylru", source_id: "kmnuqmzxylru:ABPerson", status: nil), Plan::Contact.new(id: "vmnlryyvktux", source_id: "vmnlryyvktux:ABPerson", status: nil)], plan.contacts
+ assert_equal card("kmnuqmzxylru", "Ada Lovelace"), plan.card("kmnuqmzxylru")
+ assert_nil plan.host
+ assert_nil plan.group_id
+ end
+ end
+
+ def test_the_group_is_named_for_when_the_plan_was_built
+ in_tmpdir do |dir|
+ Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [])
+
+ assert_equal "import-20260916T180412Z", Plan.read(dir).group
+ end
+ end
+
+ def test_the_backup_is_written_as_given
+ in_tmpdir do |dir|
+ photo = "\xFF\xD8\xFF".b
+ Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru", backup: {"original.vcf" => "BEGIN:VCARD\r\n", "photo.jpg" => photo})])
+
+ assert_equal "BEGIN:VCARD\r\n", (dir / "backups/kmnuqmzxylru/original.vcf").read
+ assert_equal photo, (dir / "backups/kmnuqmzxylru/photo.jpg").binread
+ end
+ end
+
+ def test_a_plan_is_never_written_over_another
+ in_tmpdir do |dir|
+ Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
+
+ assert_raises(Errno::EEXIST) { Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: []) }
+ assert_equal ["kmnuqmzxylru"], Plan.read(dir).contacts.map(&:id)
+ end
+ end
+
+ def test_progress_is_saved_into_the_plan_as_it_is_recorded
+ in_tmpdir do |dir|
+ plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
+
+ plan.host = "https://contacts"
+ plan.record("kmnuqmzxylru", "landed")
+ plan.group_id = "kxsv"
+
+ read = Plan.read(dir)
+ assert_equal "https://contacts", read.host
+ assert_equal "kxsv", read.group_id
+ assert_equal ["landed", nil], read.contacts.map(&:status)
+ assert_equal ["plan.json"], dir.children.map { it.basename.to_s }.grep(/plan/)
+ end
+ end
+end