vcard: substitute a line addressed by its digest
The addressed half of the web card editor's phones stage
(docs/plans/2026-09-05-web-card-editor.md).

Assisted-by: GLM-5.3 via pi
change zxutvspqrztlruuovszmyqrlpklyrsyk
commit 03f4c09c6129ade3fe1a331974d883c9cb961d2d
author Alpha Chen <alpha@kejadlen.dev>
date
parent pvlmmkrs
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 68dbdc6..b21978f 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -68,6 +68,27 @@ module ProTacts
       split_raw_components(value).map { unescape(it) }
     end
 
+    # A property's content line up to its colon — group, name,
+    # parameters — re-rendered, for an edit that swaps the value and
+    # keeps the header: a phone's line (say) carries parameters no
+    # form models, and macOS writes three TYPE parameters on one TEL
+    # (test/fixtures/cards/emoji.vcf), so a save that rebuilt the
+    # header from the form's fields would drop what the form never
+    # showed. The param values render as the grammar's own two
+    # spellings allow: bare when every char is PTEXT, quoted
+    # otherwise — the same line the parser read, either way. A folded
+    # header re-renders unfolded, a normalization a save applies only
+    # to the line it was asked to change.
+    #: (Parser::Property property) -> String
+    def self.header_of(property)
+      prefix = property.group ? "#{property.group}." : ""
+      parameters = property.parameters.map do |name, value|
+        bare = /\A#{Parser::PTEXT}\z/.match?(value)
+        ";#{name}=#{bare ? value : "\"#{value}\""}"
+      end
+      "#{prefix}#{property.name}#{parameters.join}:"
+    end
+
     # Folds a logical line into physical lines of at most LINE_LIMIT
     # octets, each continuation starting with a single space (RFC 2426
     # section 2.6). The walk is character-wise so a multibyte character
@@ -199,6 +220,32 @@ module ProTacts
       VCard.new(kept.join)
     end
 
+    # The one-line edit, for a property a card carries many of (TEL,
+    # EMAIL, ADR): the line whose verbatim bytes hash to `digest`
+    # swaps for the given ones, or is removed when they are empty.
+    # Which line a digest names was settled by the caller that watched
+    # the card render; a digest matching no line here is that
+    # caller's anomaly and raises (KeyError) rather than guessing —
+    # the snapshot guard makes a miss news, not an ordinary case. A
+    # card carrying the same line twice is addressed as one row by
+    # both copies (they hash alike), and this substitutes the first;
+    # the digests cannot tell identical bytes apart. A replacement
+    # line with no terminator of its own takes the substituted line's,
+    # #replace's own convention rule.
+    #: (String digest, Array[String] lines) -> VCard
+    def substitute(digest, lines)
+      all = self.lines
+      index = all.index { it.digest == digest }
+      raise KeyError, "no line of this card hashes to #{digest}" if index.nil?
+
+      terminator = terminator_of(all.fetch(index).verbatim)
+      replacements = lines.map { it.end_with?("\n") ? it : it + terminator }
+      kept = all.map(&:verbatim)
+      kept.delete_at(index)
+      kept.insert(index, *replacements)
+      VCard.new(kept.join)
+    end
+
     # Lines into the card immediately before END:VCARD. A line with no
     # terminator of its own takes the END line's, so a card stays
     # single-convention; a terminated line keeps its own, folds and
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index bb1554f..36cafa1 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -1,4 +1,5 @@
 
+require "digest"
 require "strscan"
 
 require "pro_tacts/vcard"
@@ -131,6 +132,16 @@ module ProTacts
         # taxonomy to tell the two apart.
         #: () -> bool
         def unreadable? = !error.nil? && !broke_assumption?
+
+        # The line's address for a save that names it: the SHA-256 of
+        # the verbatim bytes, folds and terminator included — constant
+        # size, pure ASCII, and no card content hidden in the page
+        # (docs/plans/2026-09-05-web-card-editor.md). Stable across
+        # the render-to-save cycle the snapshot guard certifies, and
+        # blind otherwise: identical bytes digest alike, which is the
+        # one pair of lines no address can tell apart.
+        #: () -> String
+        def digest = Digest::SHA256.hexdigest(verbatim)
       end
 
       # The content line, RFC 2426 section 4:
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 36f5c54..6b6a20e 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -83,6 +83,41 @@ class VCardTest < Minitest::Test
       ProTacts::VCard.split_raw_components("Smith\\; Jr.;John;a\\xb").join(";")
   end
 
+  # The writer's line header, for a value-only edit: group, name,
+  # and every parameter re-render as they read, so a save swaps the
+  # value and keeps what the form never modeled. The macOS triple
+  # TYPE is the case the editor exists for; an unfolded, unquoted
+  # line re-renders as its own bytes.
+  def test_header_of_renders_group_name_and_every_parameter
+    card = ProTacts::VCard.new(
+      "BEGIN:VCARD\r\n" \
+      "TEL;type=CELL;type=VOICE;type=pref:+1-555-0100\r\n" \
+      "item1.EMAIL;TYPE=INTERNET:ada@example.com\r\n" \
+      "END:VCARD\r\n"
+    )
+    tel = card.properties.find { it.name.casecmp?("TEL") }
+    email = card.properties.find { it.name.casecmp?("EMAIL") }
+
+    assert_equal "TEL;type=CELL;type=VOICE;type=pref:", ProTacts::VCard.header_of(tel)
+    assert_equal "item1.EMAIL;TYPE=INTERNET:", ProTacts::VCard.header_of(email)
+
+    tel_line = card.lines.find { it.names?("TEL") }
+    assert_equal tel_line.verbatim, "#{ProTacts::VCard.header_of(tel)}#{tel.value}\r\n"
+  end
+
+  # A parameter value PTEXT cannot hold — the grammar's quote-worthy
+  # chars — renders in the grammar's other spelling, quoted; a value
+  # that fits renders bare, either way the line the parser read.
+  def test_header_of_quotes_what_ptext_cannot_hold
+    bare = ProTacts::VCard::Parser::Property.new(group: nil, name: "TEL",
+      parameters: [["TYPE", "cell"]], value: "+1")
+    quoted = ProTacts::VCard::Parser::Property.new(group: nil, name: "TEL",
+      parameters: [["X-FOO", "a:b,c"]], value: "+1")
+
+    assert_equal "TEL;TYPE=cell:", ProTacts::VCard.header_of(bare)
+    assert_equal 'TEL;X-FOO="a:b,c":', ProTacts::VCard.header_of(quoted)
+  end
+
   ## Property tests
 
   def test_unfolding_reverses_folding
diff --git a/test/pro_tacts/vcard/test_lines.rb b/test/pro_tacts/vcard/test_lines.rb
index 3f4491d..3150ddf 100644
--- a/test/pro_tacts/vcard/test_lines.rb
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -1,5 +1,7 @@
 require_relative "../../test_helper"
 
+require "digest"
+
 require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
 
@@ -104,6 +106,18 @@ class VCardLinesTest < Minitest::Test
     assert_equal "BDAY:1985-04-\r\n 12\r\n", bday&.verbatim
   end
 
+  # The line's address for a save: the SHA-256 of the verbatim bytes,
+  # so identical lines digest alike — the one pair no address tells
+  # apart — and any other difference is a different address.
+  def test_a_lines_digest_addresses_its_bytes
+    born = CARD.sub("UID:ada\r\n", "UID:ada\r\nBDAY:1985-04-12\r\n")
+    bday = VCARD.new(born).lines.find { it.names?("BDAY") }
+
+    assert_equal Digest::SHA256.hexdigest("BDAY:1985-04-12\r\n"), bday.digest
+    assert_equal bday.digest, VCARD.new(born + "BDAY:1985-04-12\r\n").lines.last.digest
+    refute_equal bday.digest, VCARD.new(born.sub("1985", "1986")).lines.find { it.names?("BDAY") }.digest
+  end
+
   # A group prefix counts as naming the property; anything else does
   # not, and a line that will not parse is a fact about the line,
   # carried as data rather than raised.
@@ -197,6 +211,69 @@ class VCardLinesTest < Minitest::Test
       VCARD.new(lf).replace("NICKNAME", ["NICKNAME:Crimson\r\n"]).to_s
   end
 
+  ## substitute
+
+  # The one-line edit: the line whose bytes digest to the address
+  # swaps in place, and every other line keeps its own bytes — the
+  # fold on the untouched neighbor is the byte-identity claim at its
+  # sharpest.
+  def test_substitute_swaps_the_addressed_line_and_no_other
+    card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nTEL:+1-555-0100\r\nADR:;;12 Way\r\n")
+    digest = VCARD.new(card).lines.find { it.names?("TEL") }.digest
+
+    assert_equal card.sub("TEL:+1-555-0100\r\n", "TEL:+1-555-0199\r\n"),
+      VCARD.new(card).substitute(digest, ["TEL:+1-555-0199\r\n"]).to_s
+
+    folded = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nTEL:+1-555-0100\r\nADR:;;12 \r\n Way\r\n")
+
+    assert_includes VCARD.new(folded).substitute(digest, ["TEL:+1-555-0199\r\n"]).to_s,
+      "ADR:;;12 \r\n Way\r\n"
+  end
+
+  # Empty lines remove the addressed line — the blank-equals-absent
+  # rule for a row the form names by digest.
+  def test_substitute_with_nothing_removes_the_addressed_line
+    card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nTEL:+1-555-0100\r\n")
+    digest = VCARD.new(card).lines.find { it.names?("TEL") }.digest
+
+    assert_equal CARD, VCARD.new(card).substitute(digest, []).to_s
+  end
+
+  # A digest matching no line is the caller's anomaly — the snapshot
+  # guard means a miss is news, not an ordinary case — and refuses
+  # loudly rather than guessing.
+  def test_substitute_of_a_digest_no_line_hashes_to_raises
+    card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nTEL:+1-555-0100\r\n")
+
+    error = assert_raises(KeyError) { VCARD.new(card).substitute("0" * 64, []) }
+
+    assert_match "no line", error.message
+  end
+
+  # A bare replacement line takes the substituted line's own
+  # terminator, #replace's convention rule; a terminated one keeps
+  # its own.
+  def test_substitute_matches_the_cards_line_break_convention
+    lf = "BEGIN:VCARD\nFN:Ada\nTEL:+1-555-0100\nEND:VCARD\n"
+    digest = VCARD.new(lf).lines.find { it.names?("TEL") }.digest
+
+    assert_equal "BEGIN:VCARD\nFN:Ada\nTEL:+1-555-0199\nEND:VCARD\n",
+      VCARD.new(lf).substitute(digest, ["TEL:+1-555-0199"]).to_s
+    assert_equal "BEGIN:VCARD\nFN:Ada\nTEL:+1-555-0199\r\nEND:VCARD\n",
+      VCARD.new(lf).substitute(digest, ["TEL:+1-555-0199\r\n"]).to_s
+  end
+
+  # Identical bytes digest alike, which is the one pair of lines no
+  # address can tell apart: the first is substituted, the second
+  # keeps its bytes.
+  def test_substitute_addresses_the_first_of_identical_lines
+    card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nTEL:+1-555-0100\r\nTEL:+1-555-0100\r\n")
+    digest = VCARD.new(card).lines.find { it.names?("TEL") }.digest
+
+    assert_equal CARD.sub("FN:Ada\r\n", "FN:Ada\r\nTEL:+1-555-0199\r\nTEL:+1-555-0100\r\n"),
+      VCARD.new(card).substitute(digest, ["TEL:+1-555-0199\r\n"]).to_s
+  end
+
   ## insert
 
   def test_insert_takes_the_end_lines_terminator_for_a_bare_line