import: move under data/import and file finished plans away
Plans on disk need a one-time hand move
(docs/plans/2026-09-19-import-under-data-import.md), and statuses
recorded `removed` before the rename still count as done.
Assisted-by: GLM-5.3 via pi
diff --git a/AGENTS.md b/AGENTS.md
index e67116c..c2986d7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -31,13 +31,14 @@ rake console # irb with the store open on the configured database
# safety net for what the session might do there
rake import:macos:plan # Plan importing this Mac's iCloud contacts
# (LIMIT=n; reads Contacts, so leave it to the user)
-rake import:macos:remove # Delete from this Mac the contacts the oldest
- # landed plan imported (PLAN=dir for another;
- # writes Contacts, so leave it to the user)
+rake import:macos:clear # Clear this Mac of the contacts the oldest
+ # landed plan imported, filing the plan away
+ # (PLAN=dir for another; writes Contacts, so
+ # leave it to the user)
rake import:execute # Land the oldest plan still to land on a host
# (HOST=https://contacts; PLAN=dir for another)
-rake import:status # Summarize the imports in data/imports and what
- # execute and remove would carry next
+rake import:status # Summarize the plans in data/import and what
+ # execute and clear would carry next
rake profile:install # Download carddav.mobileconfig from the app and
# stage it for approval
```
diff --git a/docs/plans/2026-09-19-import-under-data-import.md b/docs/plans/2026-09-19-import-under-data-import.md
new file mode 100644
index 0000000..fc0f0ad
--- /dev/null
+++ b/docs/plans/2026-09-19-import-under-data-import.md
@@ -0,0 +1,44 @@
+# Import collateral under data/import
+
+2026-09-19. The import tasks' whole world moved under `data/import`,
+and the last step of an import stopped being named for its action.
+
+## The layout
+
+```
+data/import/
+├── config.yml # standing data between plans (ProTacts::Import::Config)
+├── plans/ # the plans in flight
+└── done/ # plans filed away, every contact off this Mac
+```
+
+Before, plans lived in `data/imports` beside nothing. Everything the
+tasks keep now sits under the one directory, plans active under
+`plans/` and finished under `done/`. A machine with plans in the old
+place moves them once by hand:
+
+```bash
+mkdir -p data/import/plans data/import/done
+mv data/imports/* data/import/plans/
+```
+
+## Clearing, not removing
+
+The step that takes a plan's contacts off this Mac was `remove`, an
+action's name, and its result was a contact `removed`. The vocabulary
+now names the state: `rake import:macos:clear` clears this Mac of a
+plan's contacts, and a contact's final status is `cleared` — on the
+host, in its groups, off this Mac. `Import::Remove` is `Import::Clear`.
+
+Statuses already recorded as `removed` read as `cleared` (`Plan`
+carries the old spelling beside the new), so a plan built before the
+rename still finishes and files away; nothing is rewritten on disk.
+
+## A finished plan files away
+
+A plan whose every contact is cleared is done (`Plan#done?`). The
+clear task files one away into `done/` as its last step, and the
+status read sweeps any a dead run left in `plans/` before listing —
+so what status lists is what still has work, and the finished plans
+are out of the way but kept, with their cards and sources, for as
+long as `data/` is.
diff --git a/lib/pro_tacts/import/card.rb b/lib/pro_tacts/import/card.rb
index 6892a24..3907d0f 100644
--- a/lib/pro_tacts/import/card.rb
+++ b/lib/pro_tacts/import/card.rb
@@ -23,7 +23,7 @@ module ProTacts
KEYS = %w[first last nickname birthday phones emails addresses note photo groups source].freeze #: Array[String]
# What the source gave, kept in the card rather than off to the
- # side: the fields above are read out of it, and `remove` compares
+ # side: the fields above are read out of it, and `clear` compares
# the Mac's contact against it before deleting anything, so the two
# are read together and edited in one place.
SOURCE_KEYS = %w[identifier vcard note contact].freeze #: Array[String]
diff --git a/lib/pro_tacts/import/remove.rb b/lib/pro_tacts/import/clear.rb
similarity index 82%
rename from lib/pro_tacts/import/remove.rb
rename to lib/pro_tacts/import/clear.rb
index f8df7e7..193f2f9 100644
--- a/lib/pro_tacts/import/remove.rb
+++ b/lib/pro_tacts/import/clear.rb
@@ -2,12 +2,12 @@ require "pro_tacts/import/plan"
module ProTacts
module Import
- # Takes the originals off the Mac, once a plan has carried them to a
- # host (docs/plans/2026-09-16-importing-from-macos.md, "Removing the
- # originals"). Every check is a reason to keep a contact rather than
- # to delete one, because a wrong keep costs a second look and a wrong
- # delete costs the contact.
- class Remove
+ # Clears this Mac of a plan's originals, once they have landed on
+ # a host (docs/plans/2026-09-16-importing-from-macos.md, "Removing
+ # the originals"). Every check is a reason to keep a contact rather
+ # than to delete one, because a wrong keep costs a second look and a
+ # wrong delete costs the contact.
+ class Clear
# @rbs @plan: Plan
# @rbs @client: _Client
# @rbs @mac: _Mac
@@ -20,7 +20,7 @@ module ProTacts
# find in Contacts and a line to find in plan.yml. Signed in
# sig/pro_tacts/import.rbs, being a Data class.
# @rbs skip
- Result = Data.define(:removed, :kept)
+ Result = Data.define(:cleared, :kept)
#: (Plan plan, client: _Client, mac: _Mac) -> Result
def self.call(plan, client:, mac:)
@@ -39,9 +39,9 @@ module ProTacts
imported = @plan.with_status("imported")
records = @mac.show(imported.map(&:source_id))
gone, still_there = imported.partition { !records.key?(it.source_id) }
- # A contact this Mac no longer has is a contact removed, which is
+ # A contact this Mac no longer has is a contact cleared, which is
# what a rerun of an interrupted run sees.
- gone.each { @plan.record(it.id, "removed") }
+ gone.each { @plan.record(it.id, Plan::CLEARED) }
kept = [] #: Array[[String, String, String]]
going = still_there.select { |contact|
@@ -54,13 +54,13 @@ module ProTacts
# store refuses is the only one that stays. A run that dies before
# the plan records the rest finds them gone next time.
refused = @mac.delete(going.map(&:source_id))
- removed, stuck = going.partition { !refused.key?(it.source_id) }
- removed.each { @plan.record(it.id, "removed") }
+ deleted, stuck = going.partition { !refused.key?(it.source_id) }
+ deleted.each { @plan.record(it.id, Plan::CLEARED) }
stuck.each {
kept << [it.id, it.name, "this Mac would not delete #{it.source_id}: #{refused.fetch(it.source_id)}"]
}
- Result.new(removed: gone.size + removed.size, kept:)
+ Result.new(cleared: gone.size + deleted.size, kept:)
end
private
diff --git a/lib/pro_tacts/import/config.rb b/lib/pro_tacts/import/config.rb
index 0747208..9dbf24e 100644
--- a/lib/pro_tacts/import/config.rb
+++ b/lib/pro_tacts/import/config.rb
@@ -3,10 +3,11 @@ require "yaml"
module ProTacts
module Import
# The standing data the import tasks keep between plans, as a YAML
- # mapping: data/import/config.yml, beside the plans under
- # data/imports. Not committed — /data is ignored — because it holds
- # this deployment's own host and choices. The keys are the tasks'
- # to grow as they take standing data off the command line.
+ # mapping: data/import/config.yml, at the root of the import
+ # collateral under data/import. Not committed — /data is ignored —
+ # because it holds this deployment's own host and choices. The keys
+ # are the tasks' to grow as they take standing data off the command
+ # line.
class Config
# A missing file is no configuration and an empty one is none
# either; a file whose top level is not a mapping is refused
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index ae8455e..6c5f41c 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -68,7 +68,7 @@ module ProTacts
run("read", *[limit&.to_s].compact)
end
- # This Mac as `Remove` speaks to it: the contacts it still has, by
+ # This Mac as `Clear` speaks to it: the contacts it still has, by
# source id, and the delete that takes them off it.
module Mac
#: (Array[String] source_ids) -> Hash[String, untyped]
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
index ec2d295..5afebfa 100644
--- a/lib/pro_tacts/import/plan.rb
+++ b/lib/pro_tacts/import/plan.rb
@@ -8,7 +8,7 @@ require "pro_tacts/import/card"
module ProTacts
module Import
# One import, as a directory: what a source builder decided, and how
- # far execute and remove have carried it
+ # far execute and clear have carried it
# (docs/plans/2026-09-16-importing-from-macos.md).
class Plan
# @rbs @dir: Pathname
@@ -22,7 +22,7 @@ module ProTacts
Entry = Data.define(:id, :source_id, :card)
# A contact as the plan lists it, with the last step it reached:
- # nil, then landed, imported, and removed, in that order. The name is
+ # nil, then landed, imported, and cleared, in that order. The name is
# the card's when the plan was built, written down so a person
# reading plan.yml can tell which line is whose without opening a
# card; nothing reads it back to decide anything.
@@ -34,6 +34,23 @@ module ProTacts
# Store::EVERYONE, spelled out so a plan loads no database.
EVERYONE = "sync:*" #: String
+ # A contact's final state: on the host, in its groups, and off
+ # this Mac. A plan whose every contact has reached it is done and
+ # files away under done/ (tasks/import.rake).
+ CLEARED = "cleared" #: String
+
+ # The spelling of CLEARED plans recorded before the state was
+ # named for what it is; read as it so a plan built then files
+ # away now.
+ REMOVED = "removed" #: String
+
+ # Whether the plan has carried every contact through: nothing
+ # left for execute or clear to do.
+ #: () -> bool
+ def done?
+ contacts.all? { [CLEARED, REMOVED].include?(it.status) }
+ end
+
#: (Pathname dir, source: String, created_at: Time, entries: Array[Entry]) -> Plan
def self.write(dir, source:, created_at:, entries:)
FileUtils.mkdir_p(dir.dirname)
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index fec56e6..22ec5e3 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -43,12 +43,12 @@ module ProTacts
end
end
- class Remove
+ class Clear
class Result
- attr_reader removed: Integer
+ attr_reader cleared: Integer
attr_reader kept: Array[[String, String, String]]
- def self.new: (removed: Integer, kept: Array[[String, String, String]]) -> instance
+ def self.new: (cleared: Integer, kept: Array[[String, String, String]]) -> instance
end
end
diff --git a/tasks/import.rake b/tasks/import.rake
index 37b189f..411c4d5 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -1,51 +1,64 @@
# Importing an address book from another source
-# (docs/plans/2026-09-16-importing-from-macos.md).
+# (docs/plans/2026-09-16-importing-from-macos.md, and
+# docs/plans/2026-09-19-import-under-data-import.md for the layout
+# and the vocabulary).
+require "fileutils"
require "pathname"
# The plan a task carries further. A module rather than task-file methods,
# which rake redefines noisily when a file is loaded twice.
module ImportTasks
- DIR = Pathname.new("data/imports")
+ # All the import collateral under data/import: the standing
+ # configuration in config.yml beside the plans, active under plans/
+ # and filed away under done/ once every contact is off this Mac.
+ PLANS = Pathname.new("data/import/plans")
+ DONE = Pathname.new("data/import/done")
# Where the import tasks keep their standing data between plans:
- # data/import/config.yml (ProTacts::Import::Config reads it). The
- # singular beside DIR's plural is deliberate — the directory of
- # plans beside the one file of standing state.
+ # data/import/config.yml (ProTacts::Import::Config reads it).
CONFIG = Pathname.new("data/import/config.yml")
# What each carrying task waits on — a contact still to carry through
# that step — named so a status read can say what those tasks would
# pick next without restating their choice.
TO_LAND = ->(plan) { plan.with_status(nil).any? || plan.with_status("landed").any? }
- TO_LEAVE = ->(plan) { plan.with_status("imported").any? }
+ TO_CLEAR = ->(plan) { plan.with_status("imported").any? }
# The plan PLAN names, or the oldest one still waiting for this step:
# plans are carried in the order they were built, so the next one to
# take further is the oldest that has not been. A PLAN that is not a
- # directory is read as a plan's name under DIR, the way the directories
- # there are named.
+ # directory is read as a plan's name under PLANS, the way the
+ # directories there are named.
def self.plan(waiting, step)
named = ENV.fetch("PLAN", nil)&.then { Pathname.new(it) }
- named = DIR / named if named && !named.directory?
+ named = PLANS / named if named && !named.directory?
if named
abort "#{named} holds no plan.yml" unless (named / "plan.yml").file?
return ProTacts::Import::Plan.read(named)
end
- ProTacts::Import::Plan.all(DIR).find(&waiting) ||
- abort("no plan in #{DIR} is waiting #{step}: run rake import:macos:plan, or name one in PLAN")
+ ProTacts::Import::Plan.all(PLANS).find(&waiting) ||
+ abort("no plan in #{PLANS} is waiting #{step}: run rake import:macos:plan, or name one in PLAN")
+ end
+
+ # A finished plan's final state: moved under DONE, nothing left to
+ # carry. The clear task files one away as its last step; the status
+ # read sweeps any a dead run left behind.
+ def self.file_away(plan)
+ FileUtils.mkdir_p(DONE)
+ FileUtils.mv(plan.dir, DONE / plan.dir.basename)
end
end
namespace :import do
namespace :macos do
- desc "Plan importing this Mac's iCloud contacts into data/imports (LIMIT=n for the first n)"
+ desc "Plan importing this Mac's iCloud contacts into data/import/plans (LIMIT=n for the first n)"
task :plan do
require "pro_tacts/import/macos"
created_at = Time.now.utc
- dir = ImportTasks::DIR / "macos-#{created_at.strftime("%Y%m%dT%H%M%SZ")}"
+ dir = ImportTasks::PLANS / "macos-#{created_at.strftime("%Y%m%dT%H%M%SZ")}"
limit = ENV.fetch("LIMIT", nil)&.then { Integer(it) }
records = ProTacts::Import::Macos.read(limit:)
@@ -60,61 +73,68 @@ namespace :import do
abort error.message
end
- desc "Delete from this Mac the contacts the oldest landed plan imported (PLAN=dir for another)"
- task :remove do
+ desc "Clear this Mac of the contacts the oldest landed plan imported, filing the plan away once its last contact is off (PLAN=dir for another)"
+ task :clear do
require "net/http"
require "uri"
require "pro_tacts/import/http_client"
require "pro_tacts/import/macos"
- require "pro_tacts/import/remove"
+ require "pro_tacts/import/clear"
- plan = ImportTasks.plan(ImportTasks::TO_LEAVE, "to leave this Mac")
+ plan = ImportTasks.plan(ImportTasks::TO_CLEAR, "to clear")
host = plan.host or abort("#{plan.dir} has not landed on a host")
uri = URI.parse(host)
- puts "removing the contacts #{plan.dir} landed on #{host}"
+ puts "clearing the contacts #{plan.dir} landed on #{host}"
- result = nil #: ProTacts::Import::Remove::Result?
+ result = nil #: ProTacts::Import::Clear::Result?
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
- result = ProTacts::Import::Remove.call(
+ result = ProTacts::Import::Clear.call(
plan, client: ProTacts::Import::HttpClient.new(http), mac: ProTacts::Import::Macos::Mac
)
end
result.kept.each { |id, name, why| puts "kept #{id} #{name}: #{why}" }
- puts "#{result.removed} contacts removed from this Mac"
+ puts "#{result.cleared} contacts cleared from this Mac"
+ if plan.done?
+ ImportTasks.file_away(plan)
+ puts "filed #{plan.dir.basename} away in #{ImportTasks::DONE}"
+ end
end
end
- desc "Summarize the imports in data/imports and what execute and remove would carry next"
+ desc "Summarize the plans in data/import and what execute and clear would carry next"
task :status do
require "pro_tacts/import/plan"
- plans = ProTacts::Import::Plan.all(ImportTasks::DIR)
+ # A finished plan files away; this sweeps any a dead run left in
+ # plans/, so what is listed is what still has work — which is also
+ # why nothing here is ever finished: the sweep took it.
+ ProTacts::Import::Plan.all(ImportTasks::PLANS).each do |plan|
+ ImportTasks.file_away(plan) if plan.done?
+ end
+
+ plans = ProTacts::Import::Plan.all(ImportTasks::PLANS)
if plans.empty?
- puts "no plans in #{ImportTasks::DIR} yet; run rake import:macos:plan"
+ puts "no plans in #{ImportTasks::PLANS}; run rake import:macos:plan"
next
end
# One line per plan, as far along as it is: the fraction counts
# everything past “planned”, because from “landed” on the card is
- # on the host — which is the state a glance wants. The marker is
- # ✅ only once every contact is off the Mac — removed, the last
- # status — and ⏳ until then, so it can never disagree with the
- # next lines below. The plan names are the directories',
- # copy-pasteable into PLAN=.
+ # on the host — which is the state a glance wants. The plan names
+ # are the directories', copy-pasteable into PLAN=.
plans.each do |plan|
done = plan.contacts.size - plan.with_status(nil).size
- marker = ImportTasks::TO_LAND.call(plan) || ImportTasks::TO_LEAVE.call(plan) ? "⏳" : "✅"
- puts "#{marker} #{plan.dir.basename} #{done}/#{plan.contacts.size} contacts"
+ puts "#{plan.dir.basename} #{done}/#{plan.contacts.size} contacts"
end
puts ""
landing = plans.find(&ImportTasks::TO_LAND)
- leaving = plans.find(&ImportTasks::TO_LEAVE)
+ clearing = plans.find(&ImportTasks::TO_CLEAR)
puts "next: import:execute would land #{landing.dir.basename}" if landing
- puts "next: import:macos:remove would clear #{leaving.dir.basename}" if leaving
+ puts "next: import:macos:clear would finish #{clearing.dir.basename}" if clearing
end
- desc "Land the oldest plan in data/imports still to land, or the one in PLAN, on the pro-tacts at HOST, a base URL such as https://contacts"
+ desc "Land the oldest plan in data/import/plans still to land, or the one in PLAN, on the pro-tacts at HOST, a base URL such as https://contacts"
task :execute do
require "net/http"
require "uri"
diff --git a/test/pro_tacts/import/test_remove.rb b/test/pro_tacts/import/test_clear.rb
similarity index 82%
rename from test/pro_tacts/import/test_remove.rb
rename to test/pro_tacts/import/test_clear.rb
index 5560f0c..c7575fb 100644
--- a/test/pro_tacts/import/test_remove.rb
+++ b/test/pro_tacts/import/test_clear.rb
@@ -6,16 +6,16 @@ require "tmpdir"
require "pro_tacts/import/execute"
require "pro_tacts/import/plan"
-require "pro_tacts/import/remove"
+require "pro_tacts/import/clear"
require "pro_tacts/web"
-class ImportRemoveTest < Minitest::Test
+class ImportClearTest < Minitest::Test
include ThrowawayContacts
Card = ProTacts::Import::Card
Plan = ProTacts::Import::Plan
Execute = ProTacts::Import::Execute
- Remove = ProTacts::Import::Remove
+ Clear = ProTacts::Import::Clear
HOST = "https://contacts"
IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
@@ -52,7 +52,7 @@ class ImportRemoveTest < Minitest::Test
{"identifier" => source_id(id), "vcard" => vcard(id), "note" => note}
end
- # A plan already landed on the host, since that is what remove works on.
+ # A plan already landed on the host, since that is what clear works on.
def with_landed_plan(notes: {}, edit: nil)
Dir.mktmpdir do |tmp|
dir = Pathname.new(tmp) / "plan"
@@ -76,12 +76,12 @@ class ImportRemoveTest < Minitest::Test
with_landed_plan do |plan, _store, client|
mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
- result = Remove.call(plan, client:, mac:)
+ result = Clear.call(plan, client:, mac:)
- assert_equal 2, result.removed
+ assert_equal 2, result.cleared
assert_empty result.kept
assert_equal IDS.map { source_id(it) }, mac.deleted
- assert_equal %w[removed removed], Plan.read(plan.dir).contacts.map(&:status)
+ assert_equal %w[cleared cleared], Plan.read(plan.dir).contacts.map(&:status)
end
end
@@ -91,11 +91,11 @@ class ImportRemoveTest < Minitest::Test
records[source_id(IDS.first)]["vcard"] = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Renamed\r\nEND:VCARD\r\n"
mac = Mac.new(records)
- result = Remove.call(plan, client:, mac:)
+ result = Clear.call(plan, client:, mac:)
assert_equal [[IDS.first, "Contact #{IDS.first}", "it has changed on this Mac since the plan"]], result.kept
assert_equal [source_id(IDS.last)], mac.deleted
- assert_equal ["imported", "removed"], Plan.read(plan.dir).contacts.map(&:status)
+ assert_equal ["imported", "cleared"], Plan.read(plan.dir).contacts.map(&:status)
end
end
@@ -105,7 +105,7 @@ class ImportRemoveTest < Minitest::Test
records[source_id(IDS.first)]["note"] = "Analyst. Also a countess."
mac = Mac.new(records)
- result = Remove.call(plan, client:, mac:)
+ result = Clear.call(plan, client:, mac:)
assert_equal [[IDS.first, "Contact #{IDS.first}", "its note has changed on this Mac since the plan"]], result.kept
assert_equal [source_id(IDS.last)], mac.deleted
@@ -117,7 +117,7 @@ class ImportRemoveTest < Minitest::Test
store.delete(IDS.first)
mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
- result = Remove.call(plan, client:, mac:)
+ result = Clear.call(plan, client:, mac:)
assert_equal [[IDS.first, "Contact #{IDS.first}", "its card is no longer on #{HOST}"]], result.kept
assert_equal [source_id(IDS.last)], mac.deleted
@@ -131,22 +131,22 @@ class ImportRemoveTest < Minitest::Test
refute_includes store.book("test@example.com"), IDS.first
mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
- result = Remove.call(plan, client:, mac:)
+ result = Clear.call(plan, client:, mac:)
assert_empty result.kept
assert_equal IDS.map { source_id(it) }, mac.deleted
end
end
- def test_a_contact_already_off_the_mac_counts_as_removed
+ def test_a_contact_already_off_the_mac_counts_as_cleared
with_landed_plan do |plan, _store, client|
mac = Mac.new({source_id(IDS.last) => record(IDS.last)})
- result = Remove.call(plan, client:, mac:)
+ result = Clear.call(plan, client:, mac:)
- assert_equal 2, result.removed
+ assert_equal 2, result.cleared
assert_equal [source_id(IDS.last)], mac.deleted
- assert_equal %w[removed removed], Plan.read(plan.dir).contacts.map(&:status)
+ assert_equal %w[cleared cleared], Plan.read(plan.dir).contacts.map(&:status)
end
end
@@ -157,23 +157,23 @@ class ImportRemoveTest < Minitest::Test
with_landed_plan do |plan, _store, client|
mac = Mac.new(IDS.to_h { [source_id(it), record(it)] }, refuses: {source_id(IDS.first) => "faulting, 134092"})
- result = Remove.call(plan, client:, mac:)
+ result = Clear.call(plan, client:, mac:)
- assert_equal 1, result.removed
+ assert_equal 1, result.cleared
assert_equal [[IDS.first, "Contact #{IDS.first}", "this Mac would not delete #{source_id(IDS.first)}: faulting, 134092"]], result.kept
assert_equal [source_id(IDS.last)], mac.deleted
- assert_equal ["imported", "removed"], Plan.read(plan.dir).contacts.map(&:status)
+ assert_equal ["imported", "cleared"], Plan.read(plan.dir).contacts.map(&:status)
end
end
- def test_a_rerun_removes_nothing_again
+ def test_a_rerun_clears_nothing_again
with_landed_plan do |plan, _store, client|
mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
- Remove.call(plan, client:, mac:)
+ Clear.call(plan, client:, mac:)
- result = Remove.call(Plan.read(plan.dir), client:, mac:)
+ result = Clear.call(Plan.read(plan.dir), client:, mac:)
- assert_equal 0, result.removed
+ assert_equal 0, result.cleared
assert_equal IDS.map { source_id(it) }, mac.deleted
end
end
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
index bac9c06..b4dd22b 100644
--- a/test/pro_tacts/import/test_plan.rb
+++ b/test/pro_tacts/import/test_plan.rb
@@ -132,4 +132,25 @@ class ImportPlanTest < Minitest::Test
assert_equal ["plan.yml"], dir.children.map { it.basename.to_s }.grep(/plan/)
end
end
+
+ def test_a_plan_is_done_once_every_contact_is_cleared
+ in_tmpdir do |dir|
+ plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
+
+ refute plan.done?
+ plan.record("kmnuqmzxylru", Plan::CLEARED)
+ refute plan.done?
+ plan.record("vmnlryyvktux", Plan::CLEARED)
+ assert plan.done?
+ end
+ end
+
+ def test_a_plan_recorded_under_the_old_vocabulary_finishes_too
+ in_tmpdir do |dir|
+ plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
+ plan.record("kmnuqmzxylru", "removed")
+
+ assert plan.done?
+ end
+ end
end
diff --git a/test/tasks/test_import.rb b/test/tasks/test_import.rb
index a46224b..c14f319 100644
--- a/test/tasks/test_import.rb
+++ b/test/tasks/test_import.rb
@@ -14,27 +14,30 @@ class ImportStatusTaskTest < Minitest::Test
def entry(id) = Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card: ADA)
- def test_status_counts_each_plans_contacts_and_names_what_comes_next
+ def test_status_counts_each_plans_contacts_and_files_finished_plans_away
Dir.mktmpdir do |root|
root = Pathname.new(root)
- imports = root / "data" / "imports"
- # A plan carried all the way off the Mac, one mid-flight — one
- # contact through to the Mac-cleanup step, one past it — and one
- # freshly built with everything still to do.
- through = Plan.write(imports / "macos-20260831T000000Z", source: "macos", created_at: Time.utc(2026, 8, 31), entries: [entry("qxqmqmqmqmqm"), entry("zzzzzzzzzzzz")])
- through.contacts.each { it.status or through.record(it.id, "removed") }
- underway = Plan.write(imports / "macos-20260901T000000Z", source: "macos", created_at: Time.utc(2026, 9, 1), entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
+ plans = root / "data" / "import" / "plans"
+ # A plan carried all the way off the Mac — done, so swept into
+ # done/ rather than listed — one mid-flight with a contact through
+ # to the Mac-clearing step and one past it, and one freshly built
+ # with everything to do.
+ through = Plan.write(plans / "macos-20260831T000000Z", source: "macos", created_at: Time.utc(2026, 8, 31), entries: [entry("qxqmqmqmqmqm"), entry("zzzzzzzzzzzz")])
+ through.contacts.each { it.status or through.record(it.id, Plan::CLEARED) }
+ underway = Plan.write(plans / "macos-20260901T000000Z", source: "macos", created_at: Time.utc(2026, 9, 1), entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
underway.record("kmnuqmzxylru", "imported")
- underway.record("vmnlryyvktux", "removed")
- Plan.write(imports / "macos-20260916T180412Z", source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries: [entry("aaaaaaaaaaaa")])
+ underway.record("vmnlryyvktux", Plan::CLEARED)
+ Plan.write(plans / "macos-20260916T180412Z", source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries: [entry("aaaaaaaaaaaa")])
out, = run_task(root)
- assert_includes out, "✅ macos-20260831T000000Z 2/2 contacts\n"
- assert_includes out, "⏳ macos-20260901T000000Z 2/2 contacts\n"
- assert_includes out, "⏳ macos-20260916T180412Z 0/1 contacts\n"
+ assert_includes out, "macos-20260901T000000Z 2/2 contacts\n"
+ assert_includes out, "macos-20260916T180412Z 0/1 contacts\n"
assert_includes out, "next: import:execute would land macos-20260916T180412Z\n"
- assert_includes out, "next: import:macos:remove would clear macos-20260901T000000Z\n"
+ assert_includes out, "next: import:macos:clear would finish macos-20260901T000000Z\n"
+ assert (root / "data/import/done/macos-20260831T000000Z/plan.yml").file?
+ refute (plans / "macos-20260831T000000Z").directory?
+ refute_includes out, "macos-20260831T000000Z "
end
end
@@ -42,14 +45,14 @@ class ImportStatusTaskTest < Minitest::Test
Dir.mktmpdir do |root|
out, = run_task(Pathname.new(root))
- assert_includes out, "no plans in data/imports yet"
+ assert_includes out, "no plans in data/import/plans; run rake import:macos:plan"
end
end
private
# Runs import:status in a Rake application of its own, from root as
- # the working directory: the task reads data/imports relative to
+ # the working directory: the task reads data/import relative to
# where rake runs, not through ProTacts.config.
def run_task(root)
application = Rake.application