contact: give emails and addresses the line they were read from
Assisted-by: GLM-5.3 via pi
change yzknxrstymxkmwsvunxrvuwvzrwokzlm
commit 413ed3c13021133fe3c90a20241de3a17d037a12
author Alpha Chen <alpha@kejadlen.dev>
date
parent xmzwnonx
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 813efbd..c4de3f1 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -61,18 +61,17 @@ 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
+    # Each also carries `line`, the parsed line the value was read
+    # from: the address a save names that 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.
+    # say "edit this phone."
     # 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, :line)
     # @rbs skip
-    Email = Data.define(:value, :type)
+    Email = Data.define(:value, :type, :line)
     # @rbs skip
     Photo = Data.define(:mime_type, :bytes)
     # @rbs skip
@@ -85,6 +84,7 @@ module ProTacts
       :postal_code,
       :country,
       :type,
+      :line,
     )
 
     # A contact from its id, its stored card, and its birthday. The
@@ -257,15 +257,23 @@ module ProTacts
 
     #: () -> Array[Email]
     def emails
-      of_name("EMAIL").filter_map do |property|
+      of_line("EMAIL").filter_map do |line|
+        property = line.property
+        next if property.nil?
+
         value = text_of(property)
-        Email.new(value:, type: type_of(property)) if value
+        Email.new(value:, type: type_of(property), line:) if value
       end
     end
 
     #: () -> Array[Address]
     def addresses
-      of_name("ADR").filter_map { address_of(it) }
+      of_line("ADR").filter_map do |line|
+        property = line.property
+        next if property.nil?
+
+        address_of(property, line:)
+      end
     end
 
     # The card's NOTE (RFC 2426 section 3.6.2), in text form.
@@ -276,11 +284,6 @@ module ProTacts
 
     private
 
-    #: (String name) -> Array[VCard::Parser::Property]
-    def of_name(name)
-      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
@@ -311,14 +314,15 @@ module ProTacts
     end
 
     # ADR's components, with a value that is blank throughout reading
-    # as no address at all.
-    #: (VCard::Parser::Property property) -> Address?
-    def address_of(property)
+    # as no address at all. Carries the line it was read from, the
+    # other provenance-carrying shapes' own address for a save.
+    #: (VCard::Parser::Property property, line: VCard::Parser::Line) -> Address?
+    def address_of(property, line:)
       components = components_of(property)
       return if components.none?
 
       po_box, extended, street, locality, region, postal_code, country = components
-      Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, type: type_of(property))
+      Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, type: type_of(property), line:)
     end
 
     # RFC 2426 section 3.3.1: TYPE can repeat (TYPE=work;TYPE=voice) or
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 119ae34..63a5576 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -16,8 +16,9 @@ module ProTacts
     class Email
       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 Photo
@@ -36,6 +37,7 @@ module ProTacts
       attr_reader postal_code: String?
       attr_reader country: String?
       attr_reader type: String?
+      attr_reader line: VCard::Parser::Line
 
       def self.new: (
         po_box: String?,
@@ -45,7 +47,8 @@ module ProTacts
         region: String?,
         postal_code: String?,
         country: String?,
-        type: String?
+        type: String?,
+        line: VCard::Parser::Line
       ) -> instance
     end
   end
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 1be305f..b888bac 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -140,6 +140,26 @@ class ContactTest < Minitest::Test
     assert_equal "home", emails.fetch(0).type
   end
 
+  # Each email carries the line it was read from, the phones' own
+  # address for a save: the digest names the served card's line, which
+  # is the stored one's too — composition only inserts the BDAY.
+  def test_every_email_carries_the_line_it_was_read_from
+    email = contact(STRUCTURED).emails.fetch(0)
+
+    assert_equal "EMAIL;TYPE=home:ada@example.com\r\n", email.line.verbatim
+    assert_equal Digest::SHA256.hexdigest("EMAIL;TYPE=home:ada@example.com\r\n"),
+      email.line.digest
+  end
+
+  # A line that would not read is a row no form can address: it reads
+  # as no email, and stays in the card's lines, served from its bytes.
+  def test_an_email_line_that_would_not_read_is_no_email
+    broken = STRUCTURED.sub("EMAIL;TYPE=home:ada@example.com\r\n", "EMAIL;=;:ada@example.com\r\n")
+
+    assert_empty contact(broken).emails
+    assert contact(broken).vcard.lines.any? { it.names?("EMAIL") && it.property.nil? }
+  end
+
   def test_reads_an_address_as_its_seven_components
     address = contact(STRUCTURED).addresses.fetch(0)
     assert_nil address.po_box
@@ -152,6 +172,30 @@ class ContactTest < Minitest::Test
     assert_equal "home", address.type
   end
 
+  # The address carries its line like the single-valued shapes do —
+  # the digest is the row's address in a save.
+  def test_every_address_carries_the_line_it_was_read_from
+    address = contact(STRUCTURED).addresses.fetch(0)
+
+    assert_equal "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n",
+      address.line.verbatim
+    assert_equal Digest::SHA256.hexdigest(
+      "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n",
+    ), address.line.digest
+  end
+
+  # A line that would not read is a row no form can address — no
+  # address reads, and the line stays enumerable, served from its bytes.
+  def test_an_adr_line_that_would_not_read_is_no_address
+    broken = STRUCTURED.sub(
+      "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n",
+      "ADR;=;:;;12 Analytical Way\r\n",
+    )
+
+    assert_empty contact(broken).addresses
+    assert contact(broken).vcard.lines.any? { it.names?("ADR") && it.property.nil? }
+  end
+
   # A blank position and one past the end of the value read the same:
   # neither says anything the card did not.
   def test_address_components_beyond_the_value_are_nil