contact: give phones the line they were read from
Assisted-by: GLM-5.3 via pi
change utrtmnuqvvolurpyzzosuvzxtyzowyno
commit 621c7c297d87aea5b49addf0f0714ccc8e11377c
author Alpha Chen <alpha@kejadlen.dev>
date
parent zxutvspq
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 0c9de38..813efbd 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -61,10 +61,16 @@ module ProTacts
     # ADR's seven components (section 3.2.1: post office box, extended
     # address, street, locality, region, postal code, country — nil
     # where the card left the position blank or stopped short of it).
+    # Phone also carries `line`, the parsed line its value was read
+    # from: the address a save names that phone's row by
+    # (docs/plans/2026-09-05-web-card-editor.md) — the accessors fold
+    # lines into typed values, and without provenance no form could
+    # say "edit this phone." Email and Address gain their own with
+    # their stage.
     # Data classes, whose members the inline syntax cannot read; the
     # signatures live in sig/pro_tacts/contact.rbs.
     # @rbs skip
-    Phone = Data.define(:value, :type)
+    Phone = Data.define(:value, :type, :line)
     # @rbs skip
     Email = Data.define(:value, :type)
     # @rbs skip
@@ -240,9 +246,12 @@ module ProTacts
 
     #: () -> Array[Phone]
     def phones
-      of_name("TEL").filter_map do |property|
+      of_line("TEL").filter_map do |line|
+        property = line.property
+        next if property.nil?
+
         value = text_of(property)
-        Phone.new(value:, type: type_of(property)) if value
+        Phone.new(value:, type: type_of(property), line:) if value
       end
     end
 
@@ -272,6 +281,16 @@ module ProTacts
       properties.select { it.name.casecmp?(name) }
     end
 
+    # The lines naming `name`, parsed beside their bytes — the walk the
+    # provenance-carrying accessors read from, so a row can carry the
+    # line it was read from as its address. A line that would not
+    # read is a row no form can address and no accessor reads; it
+    # stays enumerable in VCard#lines, served from its bytes.
+    #: (String name) -> Array[VCard::Parser::Line]
+    def of_line(name)
+      vcard.lines.select { it.names?(name) }
+    end
+
     # A text property's value, with the empty one reading as absent:
     # a property whose value carries nothing is one no caller has to
     # tell apart from a property that is not there.
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index edc960f..119ae34 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -8,8 +8,9 @@ module ProTacts
     class Phone
       attr_reader value: String
       attr_reader type: String?
+      attr_reader line: VCard::Parser::Line
 
-      def self.new: (value: String, type: String?) -> instance
+      def self.new: (value: String, type: String?, line: VCard::Parser::Line) -> instance
     end
 
     class Email
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 9e355c8..1be305f 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -110,6 +110,29 @@ class ContactTest < Minitest::Test
       contact(STRUCTURED).phones.map { [it.value, it.type] }
   end
 
+  # Each phone carries the line it was read from — the address a save
+  # names the row by, and the bytes a value-only edit spares. The digest
+  # is over the served card's line; composition only inserts the BDAY
+  # before END, so it names the stored card's line too.
+  def test_every_phone_carries_the_line_it_was_read_from
+    with_birthday = ProTacts::Birthday.new(year: 1985, month: 12, day: 10)
+    phones = contact(STRUCTURED, birthday: with_birthday).phones
+
+    assert_equal ["TEL;TYPE=mobile:+1-555-0100\r\n", "TEL;TYPE=work:+1-555-0199\r\n"],
+      phones.map { it.line.verbatim }
+    assert_equal Digest::SHA256.hexdigest("TEL;TYPE=mobile:+1-555-0100\r\n"),
+      phones.first.line.digest
+  end
+
+  # A line that would not read is a row no form can address: it reads
+  # as no phone, and stays in the card's lines, served from its bytes.
+  def test_a_phone_line_that_would_not_read_is_no_phone
+    broken = STRUCTURED.sub("TEL;TYPE=mobile:+1-555-0100\r\n", "TEL;=;:+1-555-0100\r\n")
+
+    assert_equal [["+1-555-0199", "work"]], contact(broken).phones.map { [it.value, it.type] }
+    assert contact(broken).vcard.lines.any? { it.names?("TEL") && it.property.nil? }
+  end
+
   def test_reads_email
     emails = contact(STRUCTURED).emails
     assert_equal 1, emails.length