vcard: callers split a card without rejoining it by hand
The one hand-rolled join left, in carried_and_lost_bday_lines, stays
that way: having insert take Lines would add two Line methods to
remove one call. VCard#broken_assumptions goes, redundant beside
Line#broke_assumption?.

Assisted-by: Claude Opus 5 via Claude Code
change mupnvxnukumysmxklkymyyrovqostyts
commit 4423337919a2e544d0bdb7aa191ba1b09aa81ab5
author Alpha Chen <alpha@kejadlen.dev>
date
parent oyywtlwn
diff --git a/db/migrations/002_birthdays.rb b/db/migrations/002_birthdays.rb
index dac5f5f..9d2f9e7 100644
--- a/db/migrations/002_birthdays.rb
+++ b/db/migrations/002_birthdays.rb
@@ -42,8 +42,8 @@ Sequel.migration do
     self[:cards].order(:id).each do |row|
       # The same move a write makes, one arm per shape the stored
       # card's BDAY lines take — only the modeled single line moves.
-      case ProTacts::VCard.new(row.fetch(:vcard)).lines.partition { it.names?("BDAY") }
-      in [[line], others]
+      case ProTacts::VCard.new(row.fetch(:vcard)).extract("BDAY")
+      in [[line], rest]
         # Only a line that reads as a BDAY can move it out: the line's
         # bytes move as one, and a line holding anything else — or
         # nothing this parser will read — stays where it is.
@@ -53,7 +53,7 @@ Sequel.migration do
         birthday = ProTacts::Birthday.from_property(property)
         next if birthday.nil?
 
-        self[:cards].where(id: row.fetch(:id)).update(vcard: others.map(&:verbatim).join)
+        self[:cards].where(id: row.fetch(:id)).update(vcard: rest.to_s)
         self[:birthdays].insert(
           card_id: row.fetch(:id),
           year: birthday.year,
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index b9ebd11..3903fbc 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -223,12 +223,12 @@ module ProTacts
       report_broken_assumptions(card)
       existing = birthday_of(id)
       birthday, stored =
-        case card.lines.partition { it.names?("BDAY") }
-        in [[line], others]
+        case card.extract("BDAY")
+        in [[line], rest]
           report_unrecognized_bday_lines([line])
           property = bday_of(line)
           birthday = property && Birthday.from_property(property)
-          [birthday, birthday ? others.map(&:verbatim).join : vcard]
+          [birthday, birthday ? rest.to_s : vcard]
         in [[], _]
           # The rewrite arm: carry the unrendered lines out of the
           # stored card, report the unrecognized ones' loss, and keep
@@ -404,7 +404,7 @@ module ProTacts
     def carried_and_lost_bday_lines(vcard)
       return [[], []] if vcard.nil?
 
-      bdays, = VCard.new(vcard).lines.partition { it.names?("BDAY") }
+      bdays, = VCard.new(vcard).extract("BDAY")
       carried, rest = bdays.partition { |line|
         property = bday_of(line)
         property && Birthday.unrendered_value?(property.value)
@@ -470,7 +470,7 @@ module ProTacts
     # raw.
     #: (VCard card) -> void
     def report_broken_assumptions(card)
-      broken = card.broken_assumptions.length
+      broken = card.lines.count { it.broke_assumption? }
       return if broken.zero?
 
       Sentry.capture_message(
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index d39817c..0a891bd 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -188,21 +188,21 @@ module ProTacts
       parsed && parsed.find { it.name.casecmp?("UID") }&.value
     end
 
-    # The parser assumptions this card broke — the errors themselves,
-    # so a caller reporting them can say what was assumed and where.
-    # Empty for a card that reads, and for one that merely will not:
-    # a broken assumption is news about this server, ordinary bad
-    # input is not (Parser::BrokenAssumption). Deciding what to do
-    # about it is the caller's — reporting on every parse would fire
-    # on every read of the card, not once on its arrival.
-    #: () -> Array[Parser::BrokenAssumption]
-    def broken_assumptions
-      lines.filter_map { |line|
-        error = line.error
-        error if error.is_a?(Parser::BrokenAssumption)
-      }
+    # The lines naming `name`, and the card without them: the split
+    # every caller that moves a property makes, so none of them has to
+    # rejoin the rest by hand and none can lose a byte doing it. The
+    # taken lines come back parsed beside their bytes; what is left
+    # comes back a card, ready to store or to take another split.
+    #: (String name) -> [Array[Line], VCard]
+    def extract(name)
+      taken, rest = lines.partition { it.names?(name) }
+      [taken, VCard.new(rest.map(&:verbatim).join)]
     end
 
+    # The card's bytes, exactly as it was given them.
+    #: () -> String
+    def to_s = @bytes
+
     # The card's logical lines, each parsed beside its verbatim bytes —
     # the enumeration the questions that move bytes are asked over. A
     # continuation travels with its line, terminators attached, so no
diff --git a/test/pro_tacts/vcard/test_lines.rb b/test/pro_tacts/vcard/test_lines.rb
index 9683640..62078e7 100644
--- a/test/pro_tacts/vcard/test_lines.rb
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -58,14 +58,25 @@ class VCardLinesTest < Minitest::Test
     assert_equal CARD, lines.map(&:verbatim).join
   end
 
-  def test_partition_splits_a_card_without_losing_a_byte
+  # The split every caller that moves a property makes: the named
+  # lines come back parsed beside their bytes, and what is left comes
+  # back a card, byte for byte, ready to be split again.
+  def test_extract_splits_a_card_without_losing_a_byte
     born = CARD.sub("UID:ada\r\n", "UID:ada\r\nBDAY:1985-04-12\r\n")
-    bdays, others = VCARD.new(born).lines.partition { it.names?("BDAY") }
+    bdays, rest = VCARD.new(born).extract("BDAY")
 
     assert_equal 1, bdays.length
     assert_equal "1985-04-12", bdays.fetch(0).property&.value
     assert_equal "BDAY:1985-04-12\r\n", bdays.fetch(0).verbatim
-    assert_equal CARD, others.map(&:verbatim).join
+    assert_equal CARD, rest.to_s
+    assert_equal "ada", rest.uid
+  end
+
+  def test_extract_of_a_property_the_card_lacks_leaves_it_whole
+    bdays, rest = VCARD.new(CARD).extract("BDAY")
+
+    assert_empty bdays
+    assert_equal CARD, rest.to_s
   end
 
   # A fold travels with its line, byte for byte, and parses as the one
@@ -103,22 +114,18 @@ class VCardLinesTest < Minitest::Test
   # in whoever reports it: news about this server and ordinary bad
   # input want different handling, and neither caller should have to
   # know the parser's error taxonomy to pick.
-  def test_a_card_separates_broken_assumptions_from_bad_input
+  def test_a_line_says_whether_it_broke_an_assumption_or_merely_failed
     packed = VCARD.new("BEGIN:VCARD\r\nFN:A\rNOTE:n\r\nEND:VCARD\r\n")
 
-    assert_equal 1, packed.broken_assumptions.length
-    assert_match "bare CR", packed.broken_assumptions.fetch(0).message
     assert_equal [false, true, false], packed.lines.map { it.broke_assumption? }
+    assert_match "bare CR", packed.lines.fetch(1).error&.message
 
     unreadable = VCARD.new("BEGIN:VCARD\r\nBDAY;=;:\r\nEND:VCARD\r\n")
 
     assert_kind_of VCARD::ParseError, unreadable.lines.fetch(1).error
-    assert_empty unreadable.broken_assumptions
     refute unreadable.lines.fetch(1).broke_assumption?
-  end
 
-  def test_a_card_that_reads_broke_no_assumption
-    assert_empty VCARD.new(CARD).broken_assumptions
+    refute VCARD.new(CARD).lines.any? { it.broke_assumption? }
   end
 
   ## insert