vcard: replace a property's lines in place
extract plus insert would drag an edited property to the card's
end; replace keeps it at its own position, which is what a surgical
edit over the stored bytes needs.

Assisted-by: GLM-5.3 via pi
change syzmruynowntxllutzxqszmqptwsronw
commit a068c35466693420397fc2cff352e38725c4c2f8
author Alpha Chen <alpha@kejadlen.dev>
date
parent zwqluxxk
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index da4d9ec..68dbdc6 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -50,10 +50,22 @@ module ProTacts
 
     # Splits a structured value's ";"-delimited components (RFC 2426
     # section 3.2.1, e.g. ADR and N) without breaking on an escaped
-    # "\;", and unescapes each component in the same pass.
+    # "\;" — the writer's half of the split, over the still-escaped
+    # value: a splice replaces some components and joins the rest back
+    # byte for byte, where unescape-then-re-escape is not byte-stable
+    # (#unescape leaves an unrecognized escape like "\x" alone, and
+    # #escape would double its backslash).
+    #: (String value) -> Array[String]
+    def self.split_raw_components(value)
+      value.split(/(?<!\\);/, -1)
+    end
+
+    # split_raw_components with each component unescaped — the
+    # reader's half, for a caller asking what the components say
+    # rather than splicing them.
     #: (String value) -> Array[String]
     def self.split_components(value)
-      value.split(/(?<!\\);/, -1).map { unescape(it) }
+      split_raw_components(value).map { unescape(it) }
     end
 
     # Folds a logical line into physical lines of at most LINE_LIMIT
@@ -159,6 +171,34 @@ module ProTacts
       @lines = Parser.lines(@bytes)
     end
 
+    # The lines naming `name` swapped for the given ones, at the first
+    # match's position — the operation a property a card carries one of
+    # (N, FN, NICKNAME, NOTE) is edited through, which must not drag
+    # the property to the card's end the way #extract plus #insert
+    # would. Empty lines remove the property: blank is absent, the
+    # reader's own rule (Contact#text_of) in the other direction. A
+    # card lacking the property is #insert's case, the lines landing
+    # before END:VCARD. Every other line keeps its own bytes, which is
+    # the editor's whole safety: a line a save never mentioned cannot
+    # be lost by an operation that only touches the lines it names.
+    #: (String name, Array[String] lines) -> VCard
+    def replace(name, lines)
+      all = self.lines
+      first = all.index { it.names?(name) }
+      return insert(lines) if first.nil?
+
+      # The replaced line's own terminator, so a card written in one
+      # line-break convention stays single-convention; a terminated
+      # replacement line keeps its own, folds and all.
+      terminator = terminator_of(all.fetch(first).verbatim)
+      replacements = lines.map { it.end_with?("\n") ? it : it + terminator }
+      kept = all.reject { it.names?(name) }.map(&:verbatim)
+      # `first` still names the splice point: the lines before it are
+      # untouched, and every line it counted past was a named one.
+      kept.insert(first, *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/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 278ea0e..36f5c54 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -72,6 +72,17 @@ class VCardTest < Minitest::Test
     assert_equal ["Smith; Jr.", "John"], ProTacts::VCard.split_components("Smith\\; Jr.;John")
   end
 
+  # The writer's splice: the same split with the escaping left alone,
+  # so components rejoin byte for byte — unescape-then-re-escape is
+  # not byte-stable (unescape leaves an unrecognized escape like "\\x"
+  # alone, and escape would double its backslash).
+  def test_split_raw_components_leaves_each_component_escaped
+    assert_equal ["Smith\\; Jr.", "John", "a\\xb"],
+      ProTacts::VCard.split_raw_components("Smith\\; Jr.;John;a\\xb")
+    assert_equal "Smith\\; Jr.;John;a\\xb",
+      ProTacts::VCard.split_raw_components("Smith\\; Jr.;John;a\\xb").join(";")
+  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 f98e5e1..3f4491d 100644
--- a/test/pro_tacts/vcard/test_lines.rb
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -143,6 +143,60 @@ class VCardLinesTest < Minitest::Test
     refute VCARD.new(CARD).lines.any? { it.broke_assumption? }
   end
 
+  ## replace
+
+  # The cardinality-1 edit: the named lines swap for the new one at
+  # the first match's position — not dragged to the card's end — and
+  # every other line keeps its own bytes, folds and all.
+  def test_replace_swaps_a_property_in_place
+    card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nNICKNAME:Red\r\n")
+
+    assert_equal card.sub("NICKNAME:Red\r\n", "NICKNAME:Crimson\r\n"),
+      VCARD.new(card).replace("NICKNAME", ["NICKNAME:Crimson\r\n"]).to_s
+
+    folded = VCARD.new(CARD.sub("UID:ada\r\n", "UID:ada\r\nTEL:+1-555-\r\n 0100\r\n"))
+
+    assert_includes folded.replace("FN", ["FN:Grace\r\n"]).to_s, "TEL:+1-555-\r\n 0100\r\n"
+  end
+
+  # Empty lines remove the property — blank equals absent, the
+  # reader's rule (see Contact#text_of) in the other direction.
+  def test_replace_with_nothing_removes_the_property
+    card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nNICKNAME:Red\r\n")
+
+    assert_equal CARD, VCARD.new(card).replace("NICKNAME", []).to_s
+  end
+
+  # A card lacking the property is insert's case: the lines land
+  # before END:VCARD, and nothing at all happens for an empty ask.
+  def test_replace_inserts_when_the_card_lacks_the_property
+    assert_equal CARD.sub("END:VCARD\r\n", "NICKNAME:Red\r\nEND:VCARD\r\n"),
+      VCARD.new(CARD).replace("NICKNAME", ["NICKNAME:Red\r\n"]).to_s
+    assert_equal CARD, VCARD.new(CARD).replace("NICKNAME", []).to_s
+  end
+
+  # A card that carries the property twice is cardinality-broken
+  # data, and the one replacement line this writes is the repair:
+  # all the named lines go, not one of them.
+  def test_replace_takes_every_line_naming_the_property
+    twice = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nNICKNAME:Red\r\nNICKNAME:Ruby\r\n")
+
+    assert_equal CARD.sub("FN:Ada\r\n", "FN:Ada\r\nNICKNAME:Crimson\r\n"),
+      VCARD.new(twice).replace("NICKNAME", ["NICKNAME:Crimson\r\n"]).to_s
+  end
+
+  # A bare replacement line takes the replaced line's own terminator,
+  # so a card the wire writes in LF stays single-convention; a
+  # terminated one keeps its own.
+  def test_replace_matches_the_cards_line_break_convention
+    lf = "BEGIN:VCARD\nFN:Ada\nNICKNAME:Red\nEND:VCARD\n"
+
+    assert_equal "BEGIN:VCARD\nFN:Ada\nNICKNAME:Crimson\nEND:VCARD\n",
+      VCARD.new(lf).replace("NICKNAME", ["NICKNAME:Crimson"]).to_s
+    assert_equal "BEGIN:VCARD\nFN:Ada\nNICKNAME:Crimson\r\nEND:VCARD\n",
+      VCARD.new(lf).replace("NICKNAME", ["NICKNAME:Crimson\r\n"]).to_s
+  end
+
   ## insert
 
   def test_insert_takes_the_end_lines_terminator_for_a_bare_line