vcard: the card as the post-parsed abstraction
Parsing was scattered — web parse_card, Store properties_of, and
Birthday parse_line, each rescuing ParseError on its own — and the
birthday line surgery lived in the model. VCard now reads the bytes
once and owns every question about either half; Birthday is the
partial date and nothing more; Store keeps the policy.
Assisted-by: GLM-5.3 via pi
diff --git a/db/migrations/002_birthdays.rb b/db/migrations/002_birthdays.rb
index 44fd9a2..617785d 100644
--- a/db/migrations/002_birthdays.rb
+++ b/db/migrations/002_birthdays.rb
@@ -7,6 +7,7 @@
# which refuse Sequel's plain String. No timestamp: a birthday changes
# with its card's write and the card's stamp is the write's stamp.
require "pro_tacts/birthday"
+require "pro_tacts/vcard"
Sequel.migration do
change do
@@ -35,17 +36,29 @@ Sequel.migration do
# birthday-carrying card once, by design rather than by accident.
# The change log is not written: as far as any client can tell, no
# contact changed, and the stamps are left alone to say the same.
+ # Spelled out against VCard and Birthday directly rather than
+ # through a Store helper, so what this migration does cannot drift
+ # with the app's internals; its test pins the behavior.
self[:cards].order(:id).each do |row|
- split = ProTacts::Birthday.subtract(row.fetch(:vcard))
- next if split.birthday.nil?
+ # 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)).partition { it.names?("BDAY") }
+ in [[line], others]
+ property = line.property
+ birthday = property && ProTacts::Birthday.from_property(property)
+ next if birthday.nil?
- self[:cards].where(id: row.fetch(:id)).update(vcard: split.card)
- self[:birthdays].insert(
- card_id: row.fetch(:id),
- year: split.birthday.year,
- month: split.birthday.month,
- day: split.birthday.day,
- )
+ self[:cards].where(id: row.fetch(:id)).update(vcard: others.map(&:verbatim).join)
+ self[:birthdays].insert(
+ card_id: row.fetch(:id),
+ year: birthday.year,
+ month: birthday.month,
+ day: birthday.day,
+ )
+ in [[], _] | [_, _]
+ # Nothing to move, or lines the model cannot recompose: the
+ # card stands as stored.
+ end
end
end
end
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index 9ac906f..4f60289 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -18,9 +18,9 @@ module ProTacts
# with all three components required, and RFC 2426 section 3.1.5 builds
# BDAY on it. So a birthday is database state that no stored card
# carries, composed into the served card on read and subtracted out of
- # a submitted one on write — the same split a group's shared attributes
- # take (docs/plans/2026-08-24-vcard-storage-and-groups.md), and the
- # full reasoning is docs/plans/2026-08-31-partial-birthdays.md.
+ # a submitted one on write — Store owns that surgery, over the line
+ # rules VCard provides and the property-level reading below; the full
+ # reasoning is docs/plans/2026-08-31-partial-birthdays.md.
#
# The signature lives in sig/pro_tacts/birthday.rbs: a Data class has
# no constant super class for the inline syntax to read.
@@ -42,27 +42,6 @@ module ProTacts
OMIT_YEAR_DATE = /\A1604-(\d{2})-(\d{2})\z/ #: Regexp
OMIT_YEAR = "1604" #: String
- # A content line naming BDAY, optionally group-prefixed the way
- # macOS labels properties (`item1.BDAY`). A group's first physical
- # line is all that is matched; continuations travel with it.
- BDAY_LINE = /\A(?:[A-Za-z0-9-]+\.)?BDAY[;:]/i #: Regexp
-
- # The anchor compose inserts before, so the property lands inside
- # the envelope however bare the card is.
- END_LINE = /\AEND:VCARD/i #: Regexp
-
- # A physical line that continues the one above it (RFC 2426 section
- # 2.6 folding).
- CONTINUATION = /\A[ \t]/ #: Regexp
-
- # What subtract found in a card: the card to store, the birthday it
- # gave up, and whether a BDAY line is still in the card because no
- # modeled spelling would recompose it — the probe forms ride on
- # exactly that, served as stored.
- #
- # The signature lives in sig/pro_tacts/birthday.rbs with the class's.
- Split = Data.define(:card, :birthday, :bday_kept)
-
# The only constructor, so no birthday exists in a shape the grammar
# rejects: one `in` clause per shape RFC 6350 section 4.3.1 admits,
# each component's range standing where the component stands, so the
@@ -93,7 +72,7 @@ module ProTacts
# The two wire forms a client has been verified to accept: a plain
# full date, and Apple's 1604 sentinel for a month and day. The other
# four shapes have no verified spelling and reach no client — this
- # returns nil for them and compose leaves the card alone.
+ # returns nil for them and no card grows a BDAY on their behalf.
def to_line
if year && month && day
format("BDAY:%04d-%02d-%02d", year, month, day)
@@ -112,7 +91,7 @@ module ProTacts
# Reads one parsed BDAY property into the model, or nil for every
# spelling the model does not recompose — which keeps it in the card
# verbatim rather than losing it (RFC 6352 section 6.3.2.2). Only the
- # two forms compose emits are accepted, and only bare: any extra
+ # two forms #to_line emits are accepted, and only bare: any extra
# parameter travels with the line, so a line carrying one is left
# whole. A grouped `item1.BDAY` likewise stays, because its label
# pairing belongs to the card.
@@ -140,84 +119,5 @@ module ProTacts
name, value = property.parameters.fetch(0)
name.casecmp?("X-APPLE-OMIT-YEAR") && value == OMIT_YEAR
end
-
- # Splits a submitted card into what gets stored and the birthday it
- # carried. One BDAY line in a modeled spelling moves into the model
- # and out of the card; any other BDAY — the vCard 4.0 forms, a
- # foreign sentinel, more than one — is data this model cannot
- # recompose and stays in the card byte for byte, with the
- # model emptied so compose never adds a second BDAY beside it.
- def self.subtract(card)
- # Bytes that are not the UTF-8 the store's contract promises are
- # never parsed — they pass through untouched to the bind that
- # refuses them, which is where the contract is enforced.
- unless card.valid_encoding?
- return Split.new(card:, birthday: nil, bday_kept: false)
- end
-
- bday_groups, other_groups = line_groups(card).partition { it.first.match?(BDAY_LINE) }
-
- birthday = nil
- if bday_groups.length == 1
- property = parse_line(bday_groups.fetch(0).join)
- birthday = property && from_property(property)
- end
-
- extracted = !birthday.nil?
- Split.new(
- card: extracted ? other_groups.join : card,
- birthday:,
- bday_kept: !extracted && !bday_groups.empty?,
- )
- end
-
- # Puts a birthday into a stored card, immediately before END:VCARD,
- # as the inverse of subtract. A birthday with no wire form, or none
- # at all, leaves the card exactly as it is. The inserted line takes
- # the END line's own terminator so a card stays single-convention;
- # with no END to anchor to there is no envelope worth respecting, and
- # the line is appended with CRLF.
- def self.compose(card, birthday)
- line = birthday && birthday.to_line
- return card if line.nil?
-
- lines = physical_lines(card)
- index = lines.rindex { it.match?(END_LINE) }
- if index
- lines.insert(index, line + terminator_of(lines.fetch(index)))
- lines.join
- else
- card + line + "\r\n"
- end
- end
-
- # One content line read as a property, or nil when it does not read
- # as one. The parser unfolds what it is handed, so a folded BDAY
- # arrives here as the single logical line it is.
- def self.parse_line(text)
- VCard::Parser.parse(text).fetch(0, nil)
- rescue VCard::ParseError
- nil
- end
-
- # Physical lines with their terminators attached, so surgery on them
- # cannot lose or normalize a line break.
- def self.physical_lines(card)
- card.split(/(?<=\n)/, -1)
- end
-
- # Logical lines: a physical line and the continuations folded under
- # it, kept together because a BDAY and its fold are one property.
- # Slicing before each non-continuation, rather than chunking, is
- # what attaches a continuation to the line above it.
- def self.line_groups(card)
- physical_lines(card).slice_when { |_line, next_line| !next_line.match?(CONTINUATION) }.to_a
- end
-
- # The line break a line ends with, for the line inserted beside it to
- # match. CRLF when it has none, being the grammar's own.
- def self.terminator_of(line)
- line[/\r?\n\z/] || "\r\n"
- end
end
end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 5ab9674..2d64d98 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -95,10 +95,12 @@ module ProTacts
# The card's properties, parsed once and memoized — the substrate
# the typed accessors sit on, and the read for what none of them
# models. Lets VCard::ParseError raise rather than returning an
- # empty card: PUT checks VCard.card? before the store accepts a
- # card, so one reaching this unparseable is a bug or a corrupt row,
- # and Sentry (already wired into every request, see ProTacts::Web)
- # is where that should surface, not a silently blank screen.
+ # empty card: PUT checks the card's envelope before the store
+ # accepts it, so one reaching this unparseable is a bug or a
+ # corrupt row, and Sentry (already wired into every request, see
+ # ProTacts::Web) is where that should surface, not a silently
+ # blank screen. VCard.new tolerates the same bytes for the paths
+ # that serve them; this model of a served contact does not.
#: () -> Array[VCard::Property]
def properties
return @properties if defined?(@properties)
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 45ae0f6..7bac3f6 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -207,36 +207,42 @@ module ProTacts
# where it is read, in write_card.
#: (String id, String vcard) -> Contact
def put(id, vcard)
- split = Birthday.subtract(vcard)
+ # The birthday half of the split a write makes, one arm per shape
+ # a submitted card's BDAY lines can take. One line that reads as
+ # a modeled birthday moves out of the card and into the model;
+ # any other BDAY — the vCard 4.0 forms, a foreign sentinel, a
+ # line that will not parse, more than one — is data the model
+ # cannot recompose and stays in the card byte for byte, with the
+ # model emptied so nothing composes a second BDAY beside it
+ # (RFC 6352 section 6.3.2.2). No BDAY at all is nothing to split:
+ # the birthday to carry is whatever an unseen model row already
+ # holds, and a served one was the user's deletion. Bytes that are
+ # not UTF-8 raise at the card before any of this runs — a PUT
+ # has already answered them with a 412 by then.
existing = birthday_of(id)
- birthday =
- if split.bday_kept
- nil
- elsif split.birthday
- split.birthday
- elsif existing && !existing.served?
- existing
- else
- nil
+ birthday, stored =
+ case VCard.new(vcard).partition { it.names?("BDAY") }
+ in [[line], others]
+ property = line.property
+ birthday = property && Birthday.from_property(property)
+ [birthday, birthday ? others.map(&:verbatim).join : vcard]
+ in [[], _]
+ [existing && !existing.served? ? existing : nil, vcard]
+ in [_, _]
+ [nil, vcard]
end
- contact = Contact.for(id:, vcard: Birthday.compose(split.card, birthday))
+ # The Contact this returns is the composed one, and the logged
+ # etag is its hash, so a client's token describes the card it
+ # downloads.
+ contact = Contact.for(id:, vcard: with_birthday(stored, birthday))
@database.transaction do
cards
.insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
- .insert(id: contact.id, vcard: split.card)
- if birthday
- birthdays
- .insert_conflict(
- target: :card_id,
- update: {year: Sequel[:excluded][:year], month: Sequel[:excluded][:month], day: Sequel[:excluded][:day]},
- )
- .insert(card_id: contact.id, year: birthday.year, month: birthday.month, day: birthday.day)
- else
- birthdays.where(card_id: contact.id).delete
- end
+ .insert(id: contact.id, vcard: stored)
+ write_birthday(contact.id, birthday)
record(contact.id, "put", contact.etag)
- reindex(contact.id, split.card)
+ reindex(contact.id, stored)
end
contact
end
@@ -343,9 +349,7 @@ module ProTacts
# A card's properties, or nil when the bytes are not a vCard.
#: (String vcard) -> Array[VCard::Property]?
def properties_of(vcard)
- VCard::Parser.parse(vcard)
- rescue VCard::ParseError
- nil
+ VCard.new(vcard).properties
end
# Applies whatever migrations the database has not seen. On one it is
@@ -365,10 +369,39 @@ module ProTacts
def contact_from(row, birthday)
Contact.for(
id: row.fetch(:id).to_s,
- vcard: Birthday.compose(row.fetch(:vcard).to_s, birthday),
+ vcard: with_birthday(row.fetch(:vcard).to_s, birthday),
)
end
+ # The card to serve: the stored card with its birthday composed
+ # back in, immediately before END:VCARD. A birthday with no wire
+ # form, or none at all, leaves the card exactly as it is.
+ #: (String card, Birthday? birthday) -> String
+ def with_birthday(card, birthday)
+ line = birthday && birthday.to_line
+ return card if line.nil?
+
+ VCard.new(card).insert([line])
+ end
+
+ # The model's half of a write: hold the birthday the card gave up,
+ # or empty the model — a BDAY that stayed in the card must not grow
+ # a composed twin beside it, and a deletion must take the row with
+ # it.
+ #: (String id, Birthday? birthday) -> void
+ def write_birthday(id, birthday)
+ if birthday
+ birthdays
+ .insert_conflict(
+ target: :card_id,
+ update: {year: Sequel[:excluded][:year], month: Sequel[:excluded][:month], day: Sequel[:excluded][:day]},
+ )
+ .insert(card_id: id, year: birthday.year, month: birthday.month, day: birthday.day)
+ else
+ birthdays.where(card_id: id).delete
+ end
+ end
+
# Every birthday, keyed by card, for the listing reads that compose
# a whole collection in one pass.
#: () -> Hash[String, Birthday]
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 836e57e..4e7d18c 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -1,9 +1,21 @@
-
module ProTacts
- # vCard 3.0 (RFC 2426): the line-level rules a card obeys, and the
- # shape a parsed one takes. VCard::Parser reads a card into those;
- # escape and fold write one back out.
- module VCard
+ # A card, read once: the parsed properties beside the exact bytes
+ # they came from, so a caller asks questions of either without
+ # re-parsing, and moves a property without a byte around it moving
+ # too. A card is Enumerable over its logical lines — each one parsed
+ # beside its verbatim bytes (#each and Line) — so the structured
+ # questions are select and reject and count, and #insert puts lines
+ # back in. vCard 3.0 (RFC 2426): the line-level rules a card obeys
+ # are here whichever half is being asked — Parser reads, escape and
+ # fold write. The line walk is lazy for the same reason Contact's
+ # parse is: the byte-moving paths never read structure.
+ # The signature lives in sig/pro_tacts/vcard.rbs: the generic
+ # Enumerable include is beyond the inline parser.
+ # @rbs skip
+ class VCard
+ include Enumerable
+
+
# Folded lines must not exceed 75 octets, excluding the line break
# (RFC 2426 section 2.6). The octet count, not character count, is
# what matters: a continuation must never split a multibyte
@@ -27,6 +39,14 @@ module ProTacts
# `(?:\r\n|[\r\n])[ \t]` rather than `\r\n` or `[\r\n][ \t]`.
FOLD = /#{LINE_BREAK}[ \t]/ #: Regexp
+ # A physical line that continues the one above it (RFC 2426 section
+ # 2.6 folding).
+ CONTINUATION = /\A[ \t]/ #: Regexp
+
+ # The line inserted lines go immediately before, so a property
+ # lands inside the envelope however bare the card is.
+ END_LINE = /\AEND:VCARD/i #: Regexp
+
# A card whose bytes are not a vCard at all. Callers are expected to
# carry on serving the card: failing to parse costs an index entry,
# not the contact.
@@ -46,39 +66,34 @@ module ProTacts
#
# The signature lives in sig/pro_tacts/vcard.rbs: a Data class has no
# constant super class for the inline syntax to read.
- # @rbs skip
Property = Data.define(:group, :name, :parameters, :value)
- # Whether parsed properties carry the envelope RFC 2426 section 4
- # requires of a card: BEGIN:VCARD first, END:VCARD last, and a
- # VERSION in between. Deciding that is deliberately not the
- # parser's job — it reads lines without judging the card — so it
- # lives here, where a caller that has to decide (a PUT) can ask.
- # The VERSION's value is not judged: any version is stored verbatim,
- # and this only decides whether there is a card at all.
- #: (Array[Property] properties) -> bool
- def self.card?(properties)
- return false if properties.empty?
-
- first, last = properties.first, properties.last
- first.name.casecmp?("BEGIN") && first.value.casecmp?("VCARD") &&
- last.name.casecmp?("END") && last.value.casecmp?("VCARD") &&
- properties.any? { it.name.casecmp?("VERSION") }
- end
+ # One logical line of a card, parsed beside the exact bytes that
+ # carried it: `property` is the parsed form — nil when the line
+ # will not read as one, which is a fact about the line, not an
+ # error — and `verbatim` is the line itself, folds and terminator
+ # included, so what moves between cards moves unchanged.
+ #
+ # The signature lives in sig/pro_tacts/vcard.rbs with Property's.
+ Line = Data.define(:property, :verbatim)
- # The value of the card's UID property, if it carries one. Names
- # compare without case, as the index's NOCASE collation already
- # assumes for them.
- #: (Array[Property] properties) -> String?
- def self.uid(properties)
- properties.find { it.name.casecmp?("UID") }&.value
+ # Reopened rather than defined in the block above: steep reads a
+ # define block's self as this class's, not the constant's, so the
+ # method could not live there.
+ class Line
+ # Whether this line names the property, a group prefix
+ # (`item1.BDAY`) counting as naming it. Asked of the line rather
+ # than the property because a line that will not parse still has
+ # a name in it.
+ def names?(name)
+ verbatim.match?(/\A(?:[A-Za-z0-9-]+\.)?#{Regexp.escape(name)}[;:]/i)
+ end
end
# Text values escape backslash, the component separator, and the
# sub-component separator (RFC 2426 section 2.4.2); CRLF and CR are
# normalized to the `\n` escape because a raw line break would end
# the property line.
- #: (String text) -> String
def self.escape(text)
text.gsub(/\r\n|\r/, "\n").gsub(/[\\;,\n]/) { TEXT_ESCAPES.fetch(it) }
end
@@ -89,7 +104,6 @@ module ProTacts
# out unparsed — but a view that displays a structured value (an
# admin screen, not this server's CardDAV responses) has to undo the
# escaping or show the reader literal backslashes.
- #: (String text) -> String
def self.unescape(text)
text.gsub(/\\[\\;,n]/) { it == "\\n" ? "\n" : it[1..].to_s }
end
@@ -97,7 +111,6 @@ 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.
- #: (String value) -> Array[String]
def self.split_components(value)
value.split(/(?<!\\);/, -1).map { unescape(it) }
end
@@ -106,7 +119,6 @@ module ProTacts
# octets, each continuation starting with a single space (RFC 2426
# section 2.6). The walk is character-wise so a multibyte character
# is never split mid-sequence.
- #: (String line) -> String
def self.fold(line)
return line if line.bytesize <= LINE_LIMIT
@@ -124,13 +136,138 @@ module ProTacts
end
# Fold's inverse, and the first thing a parse does: RFC 2426 section
- # 2.6 has a content line unfolded before it is read. It runs over the
- # whole card rather than token by token because a fold can land
+ # 2.6 has a content line unfolded before it is read. It runs over
+ # the whole card rather than token by token because a fold can land
# anywhere in a line — Contacts folds at 75 octets without regard for
# what it splits — so there is no boundary to do it at.
- #: (String card) -> String
def self.unfold(card)
card.gsub(FOLD, "")
end
+
+ # A card is made of UTF-8 text, and refuses to be made of anything
+ # else: bytes that are not valid UTF-8 raise here, at the boundary,
+ # rather than leaking an ArgumentError out of whatever regex first
+ # trips over them. A PUT gates this with its own 412 first, and the
+ # store's bind holds the same line again below the card.
+ def initialize(bytes)
+ raise ArgumentError, "not valid UTF-8: a card is text" unless bytes.valid_encoding?
+
+ @bytes = bytes
+ end
+
+ def bytes
+ @bytes
+ end
+
+ # The card's properties, parsed once on the first structured read.
+ # Nil when the bytes are not a vCard — a caller is expected to carry
+ # on serving the bytes (ParseError's rule, held one level up), and
+ # #parseable? is the question to ask about it.
+ def properties
+ return @properties if defined?(@properties)
+
+ @properties = Parser.parse(@bytes)
+ rescue ParseError
+ @properties = nil
+ end
+
+ def parseable?
+ !properties.nil?
+ end
+
+ # Whether the parsed properties carry the envelope RFC 2426 section
+ # 4 requires of a card: BEGIN:VCARD first, END:VCARD last, and a
+ # VERSION in between. Deciding that is deliberately not the
+ # parser's job — it reads lines without judging the card — so it
+ # lives here, where a caller that has to decide (a PUT) can ask.
+ # The VERSION's value is not judged: any version is stored verbatim,
+ # and this only decides whether there is a card at all.
+ def card?
+ parsed = properties
+ return false if parsed.nil? || parsed.empty?
+
+ first, last = parsed.first, parsed.last
+ first.name.casecmp?("BEGIN") && first.value.casecmp?("VCARD") &&
+ last.name.casecmp?("END") && last.value.casecmp?("VCARD") &&
+ parsed.any? { it.name.casecmp?("VERSION") }
+ end
+
+ # The value of the card's UID property, if it carries one. Names
+ # compare without case, as the index's NOCASE collation already
+ # assumes for them.
+ def uid
+ parsed = properties
+ parsed && parsed.find { it.name.casecmp?("UID") }&.value
+ end
+
+ # Each logical line of the card, parsed beside its verbatim bytes —
+ # the one enumeration every structured question about a card is
+ # asked over. A continuation travels with its line, terminators
+ # attached, so no line's bytes are lost or normalized on the way
+ # through (RFC 2426 section 2.6 folding).
+ def each(&block)
+ lines.each(&block)
+ 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
+ # all. With no END to anchor to there is no envelope worth
+ # respecting, and the lines are appended with CRLF.
+ def insert(lines)
+ return @bytes if lines.empty?
+
+ physical = physical_lines
+ index = physical.rindex { it.match?(END_LINE) }
+ if index
+ terminator = terminator_of(physical.fetch(index))
+ physical.insert(index, *lines.map { it.end_with?("\n") ? it : it + terminator })
+ physical.join
+ else
+ @bytes + lines.map { it.end_with?("\n") ? it : it + "\r\n" }.join
+ end
+ end
+
+ private
+
+ # One line group read as a property, or nil when it does not read
+ # as one. The parser unfolds what it is handed, so a folded group
+ # arrives as the single logical line it is.
+ def parse(group)
+ Parser.parse(group.join).fetch(0, nil)
+ rescue ParseError
+ nil
+ end
+
+ # The card's logical lines, walked once on the first question
+ # asked over them. The terminatorless trailing group the byte-level
+ # split leaves behind is not a line — dropping it loses nothing,
+ # an empty group joining to nothing.
+ def lines
+ return @lines if defined?(@lines)
+
+ groups = line_groups.reject { |group| group == [""] }
+ @lines = groups.map { |group| Line.new(property: parse(group), verbatim: group.join) }
+ end
+
+ # Physical lines with their terminators attached, so surgery on them
+ # cannot lose or normalize a line break.
+ def physical_lines
+ @bytes.split(/(?<=\n)/, -1)
+ end
+
+ # Logical lines: a physical line and the continuations folded under
+ # it, kept together because a property and its fold are one unit.
+ # Slicing before each non-continuation, rather than chunking, is
+ # what attaches a continuation to the line above it.
+ def line_groups
+ physical_lines.slice_when { |_line, next_line| !next_line.match?(CONTINUATION) }.to_a
+ end
+
+ # The line break a line ends with, for the line inserted beside it
+ # to match. CRLF when it has none, being the grammar's own.
+ def terminator_of(line)
+ line[/\r?\n\z/] || "\r\n"
+ end
end
end
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 96f8335..958c614 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -4,7 +4,7 @@ require "strscan"
require "pro_tacts/vcard"
module ProTacts
- module VCard
+ class VCard
# Reads a stored card into VCard::Property values.
#
# Deliberately shallow: a property is a name, its parameters, and the
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index fdf9ed5..71d25a7 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -448,8 +448,8 @@ module ProTacts
# client could be told what was wrong with the body.
return precondition("valid-address-data") unless vcard.valid_encoding?
- properties = parse_card(vcard)
- return precondition("valid-address-data") unless properties && VCard.card?(properties)
+ card = VCard.new(vcard)
+ return precondition("valid-address-data") unless card.card?
# CARDDAV:no-uid-conflict: the submitted UID must not belong to a
# different resource, and a mapped URI must not be overwritten by
@@ -458,7 +458,7 @@ module ProTacts
# UID naming the resource being written, and no other card claims
# it. The href in the body is the SHOULD that section attaches to
# the first clause — report where the UID already lives.
- uid = VCard.uid(properties)
+ uid = card.uid
owner = uid && store.card_id_with_uid(uid)
return precondition("no-uid-conflict", owner) if uid != id || owner && owner != id
@@ -491,16 +491,6 @@ module ProTacts
nil
end
- # A parse that answers nil rather than raising when the bytes are
- # not a card — the same tolerance Store#properties_of gives the
- # index, at the point where a caller must decide.
- #: (String vcard) -> Array[VCard::Property]?
- def parse_card(vcard)
- VCard::Parser.parse(vcard)
- rescue VCard::ParseError
- nil
- end
-
# A 412 whose body names the CardDAV precondition that failed, in
# the DAV:error form RFC 4918 section 16 defines; the element names
# are RFC 6352 section 6.3.2.1's, qualified by the card: namespace
diff --git a/sig/pro_tacts/birthday.rbs b/sig/pro_tacts/birthday.rbs
index f6b7e99..a73636a 100644
--- a/sig/pro_tacts/birthday.rbs
+++ b/sig/pro_tacts/birthday.rbs
@@ -1,14 +1,11 @@
-# Birthday and Birthday::Split are Data classes, whose members the
-# inline parser cannot read (see sig/pro_tacts/contact.rbs). Both are
-# marked `@rbs skip` in birthday.rb and signed here instead.
+# Birthday is a Data class, whose members the inline parser cannot
+# read (see sig/pro_tacts/contact.rbs). It is marked `@rbs skip` in
+# birthday.rb and signed here instead.
module ProTacts
class Birthday
FULL_DATE: Regexp
OMIT_YEAR_DATE: Regexp
OMIT_YEAR: String
- BDAY_LINE: Regexp
- END_LINE: Regexp
- CONTINUATION: Regexp
attr_reader year: Integer?
attr_reader month: Integer?
@@ -23,25 +20,5 @@ module ProTacts
def self.from_property: (VCard::Property property) -> Birthday?
def self.apple_no_year?: (VCard::Property property) -> bool
-
- def self.subtract: (String card) -> Split
-
- def self.compose: (String card, Birthday? birthday) -> String
-
- def self.parse_line: (String text) -> VCard::Property?
-
- def self.physical_lines: (String card) -> Array[String]
-
- def self.line_groups: (String card) -> Array[Array[String]]
-
- def self.terminator_of: (String line) -> String
-
- class Split
- attr_reader card: String
- attr_reader birthday: Birthday?
- attr_reader bday_kept: bool
-
- def self.new: (card: String, birthday: Birthday?, bday_kept: bool) -> instance
- end
end
end
diff --git a/sig/pro_tacts/vcard.rbs b/sig/pro_tacts/vcard.rbs
index b1482a9..2d5ad8e 100644
--- a/sig/pro_tacts/vcard.rbs
+++ b/sig/pro_tacts/vcard.rbs
@@ -1,9 +1,59 @@
-# Property is a Data class, whose members the inline parser cannot read
-# (see sig/pro_tacts/contact.rbs). Everything else about vcard.rb is
-# inline: its methods are singleton methods rather than
-# `module_function`, which is a shape RBS reads without help.
+# VCard is signed here rather than inline: its Enumerable include is
+# generic, which the inline parser refuses (the rule
+# docs/plans/2026-08-20-type-checking.md records). Property and Line
+# are Data classes, which the inline syntax cannot read either.
module ProTacts
- module VCard
+ class VCard
+ include Enumerable[Line]
+
+ LINE_LIMIT: Integer
+ TEXT_ESCAPES: Hash[String, String]
+ LINE_BREAK: Regexp
+ FOLD: Regexp
+ CONTINUATION: Regexp
+ END_LINE: Regexp
+
+ class ParseError < StandardError
+ end
+
+ def initialize: (String bytes) -> void
+
+ def bytes: () -> String
+
+ def properties: () -> Array[Property]?
+
+ def parseable?: () -> bool
+
+ def card?: () -> bool
+
+ def uid: () -> String?
+
+ def each: () { (Line) -> void } -> Array[Line]
+
+ def insert: (Array[String] lines) -> String
+
+ def self.escape: (String text) -> String
+
+ def self.unescape: (String text) -> String
+
+ def self.split_components: (String value) -> Array[String]
+
+ def self.fold: (String line) -> String
+
+ def self.unfold: (String card) -> String
+
+ private
+
+ def parse: (Array[String] group) -> Property?
+
+ def lines: () -> Array[Line]
+
+ def physical_lines: () -> Array[String]
+
+ def line_groups: () -> Array[Array[String]]
+
+ def terminator_of: (String line) -> String
+
class Property
attr_reader group: String?
attr_reader name: String
@@ -12,5 +62,14 @@ module ProTacts
def self.new: (group: String?, name: String, parameters: Array[[String, String]], value: String) -> instance
end
+
+ class Line
+ attr_reader property: Property?
+ attr_reader verbatim: String
+
+ def self.new: (property: Property?, verbatim: String) -> instance
+
+ def names?: (String name) -> bool
+ end
end
end
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index 1bf1ec5..b256673 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -1,6 +1,7 @@
require_relative "../test_helper"
require "pro_tacts/birthday"
+require "pro_tacts/vcard"
class BirthdayTest < Minitest::Test
# The six shapes RFC 6350 section 4.3.1 admits, one assertion per
@@ -20,7 +21,7 @@ class BirthdayTest < Minitest::Test
error = assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 1985, day: 12) }
assert_match "not a shape", error.message
- assert_raises(ArgumentError) { ProTacts::Birthday.new }
+ assert_raises(ArgumentError) { ProTacts::Birthday.new() }
end
def test_components_out_of_range_are_refused
@@ -60,145 +61,58 @@ class BirthdayTest < Minitest::Test
end
end
- ## subtract
+ ## from_property
- CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada\r\nUID:ada\r\nEND:VCARD\r\n"
-
- def test_a_full_date_moves_into_the_model_and_out_of_the_card
- split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\nBDAY:1985-04-12\r\n"))
+ # A parsed property, the shape a read out of a card takes.
+ def property(value, parameters: [], group: nil)
+ ProTacts::VCard::Property.new(group:, name: "BDAY", parameters:, value:)
+ end
- assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), split.birthday
- assert_equal CARD, split.card
- refute split.bday_kept
+ def test_a_full_date_reads_into_the_model
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12),
+ ProTacts::Birthday.from_property(property("1985-04-12"))
end
# docs/macos-contacts.md, "Birthdays without a year": the verified
# Apple form, with 1604 standing in for the year in both halves.
- def test_the_apple_no_year_form_moves_into_the_model
- split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\nBDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\n"))
+ def test_the_apple_no_year_form_reads_into_the_model
+ sentinel = property("1604-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"]])
- assert_equal ProTacts::Birthday.new(month: 4, day: 12), split.birthday
- assert_equal CARD, split.card
+ assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_property(sentinel)
end
# RFC 2426 section 3.1.5 lets BDAY carry a date and time; the time is
# the one thing ingest deliberately loses, because a birthday is a
# date — the design record's decision, not an oversight.
def test_a_date_time_loses_its_time_on_the_way_in
- ["BDAY:1985-04-12T23:10:00Z", "BDAY:1985-04-12T08:30:00-06:00"].each do |line|
- split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\n#{line}\r\n"))
-
- assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), split.birthday, line
- assert_equal CARD, split.card, line
+ ["1985-04-12T23:10:00Z", "1985-04-12T08:30:00-06:00"].each do |value|
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12),
+ ProTacts::Birthday.from_property(property(value)), value
end
end
def test_names_and_parameters_match_without_case
- split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\nbday;x-apple-omit-year=1604:1604-04-12\r\n"))
+ sentinel = property("1604-04-12", parameters: [["x-apple-omit-year", "1604"]])
- assert_equal ProTacts::Birthday.new(month: 4, day: 12), split.birthday
+ assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_property(sentinel)
end
- def test_a_folded_bday_is_one_property
- split = ProTacts::Birthday.subtract(CARD.sub("UID:ada\r\n", "BDAY:1985-04-\r\n 12\r\nUID:ada\r\n"))
-
- assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), split.birthday
- assert_equal CARD, split.card
+ # A component out of range is unmodeled, not exceptional: from_property
+ # reads nil and the line stays wherever it was.
+ def test_an_out_of_range_date_reads_as_nil
+ assert_nil ProTacts::Birthday.from_property(property("1985-13-40"))
end
- # Everything the model cannot recompose stays in the card byte for
- # byte (RFC 6352 section 6.3.2.2), and the model is emptied so compose
- # never adds a second BDAY beside it. The probe cards ride on this.
- def test_the_unmodeled_spellings_stay_verbatim
- unmodeled = [
- "BDAY:--0412", # vCard 4.0 basic, month-day
- "BDAY:--04-12", # vCard 4.0 extended, month-day
- "BDAY:1985", # year alone
- "BDAY:1985-04", # year and month
- "BDAY:---12", # day alone
- "BDAY:19850412", # undashed full date, legal 3.0
- "BDAY:1985-4-12", # unpadded, not the grammar's shape
- "BDAY;VALUE=DATE:1985-04-12",
- "BDAY;X-APPLE-OMIT-YEAR=1605:1605-04-12", # a foreign sentinel
- "BDAY;X-APPLE-OMIT-YEAR=1604;X-OTHER=1:1604-04-12", # parameters compose would drop
- "item1.BDAY:1985-04-12", # a grouped property's label pairing belongs to the card
- "BDAY:", # nothing to read
- "BDAY:1985-13-40", # components out of range
- ]
-
- unmodeled.each do |line|
- card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\n#{line}\r\n")
- split = ProTacts::Birthday.subtract(card)
-
- assert_nil split.birthday, line
- assert_equal card, split.card, line
- assert split.bday_kept, line
+ # Everything the model cannot recompose reads as nil, keeping it in
+ # the card verbatim (RFC 6352 section 6.3.2.2): extra parameters
+ # travel with their line, and a grouped property's label pairing
+ # belongs to the card.
+ def test_what_the_model_cannot_recompose_reads_as_nil
+ [property("--0412"), property("1985-04"), property("19850412"),
+ property("1985-04-12", parameters: [["X-OTHER", "1"]]),
+ property("1604-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"], ["X-OTHER", "1"]]),
+ property("1985-04-12", group: "item1")].each do |unmodeled|
+ assert_nil ProTacts::Birthday.from_property(unmodeled), unmodeled.value
end
end
-
- def test_more_than_one_bday_stays_verbatim
- card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nBDAY:1985-04-12\r\nBDAY:1986-04-12\r\n")
- split = ProTacts::Birthday.subtract(card)
-
- assert_nil split.birthday
- assert_equal card, split.card
- assert split.bday_kept
- end
-
- def test_a_card_without_a_bday_is_untouched
- split = ProTacts::Birthday.subtract(CARD)
-
- assert_nil split.birthday
- assert_equal CARD, split.card
- refute split.bday_kept
- end
-
- # Bytes that are not the UTF-8 the store's contract promises are not
- # this model's business: they pass through so the bind that enforces
- # the contract is what refuses them.
- def test_bytes_that_are_not_utf_8_pass_through_unparsed
- invalid = "BDAY:\xFF\xFE\r\n".dup.force_encoding(Encoding::UTF_8)
- split = ProTacts::Birthday.subtract(invalid)
-
- assert_equal invalid, split.card
- assert_nil split.birthday
- refute split.bday_kept
- end
-
- ## compose
-
- def test_compose_puts_the_birthday_before_the_end
- composed = ProTacts::Birthday.compose(CARD, ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
-
- assert_equal CARD.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), composed
- end
-
- def test_compose_takes_the_end_lines_own_terminator
- card = "BEGIN:VCARD\nUID:ada\nEND:VCARD\n"
- composed = ProTacts::Birthday.compose(card, ProTacts::Birthday.new(month: 4, day: 12))
-
- assert_equal "BEGIN:VCARD\nUID:ada\nBDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\nEND:VCARD\n", composed
- end
-
- def test_compose_leaves_a_card_alone_when_there_is_nothing_to_say
- assert_equal CARD, ProTacts::Birthday.compose(CARD, nil)
- assert_equal CARD, ProTacts::Birthday.compose(CARD, ProTacts::Birthday.new(year: 1985))
- end
-
- def test_compose_appends_when_there_is_no_end_to_anchor_to
- assert_equal "not a card\r\nBDAY:1985-04-12\r\n",
- ProTacts::Birthday.compose("not a card\r\n", ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
- end
-
- ## the pair of them
-
- # subtract and compose are inverses on the model and on every line
- # but the BDAY's own place in the card, which compose chooses.
- def test_compose_undoes_subtract
- birthday = ProTacts::Birthday.new(month: 4, day: 12)
- split = ProTacts::Birthday.subtract(CARD.sub("END:VCARD\r\n", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\nEND:VCARD\r\n"))
-
- assert_equal birthday, split.birthday
- assert_equal birthday, ProTacts::Birthday.subtract(ProTacts::Birthday.compose(split.card, birthday)).birthday
- end
end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 88be821..f7c874e 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -233,15 +233,17 @@ class StoreTest < Minitest::Test
# not UTF-8 at all is refused rather than stored: better than serving
# it back as `text/vcard; charset=utf-8` while it is no such thing.
# Relabelled first, as the route would — a request this bad never
- # reaches the store, and this pins the last line that catches it.
+ # reaches the store, and this pins the last line that catches it: the
+ # card itself, before any walk or bind could meet the bytes.
def test_a_card_that_is_not_utf_8_is_refused
with_store do |store|
invalid = "BEGIN:VCARD\r\nFN:\xFF\xFE\r\nEND:VCARD\r\n".dup.force_encoding(Encoding::UTF_8)
- assert_raises(Sequel::DatabaseError) do
+ error = assert_raises(ArgumentError) do
store.put("bad", invalid)
end
+ assert_match "not valid UTF-8", error.message
assert_empty store.contacts
end
end
@@ -507,14 +509,34 @@ class StoreTest < Minitest::Test
# A BDAY the model cannot recompose stays in the card verbatim and
# empties the model: the card's own line speaks for itself, and
# compose must never add a second one beside it.
+ # A BDAY the model cannot recompose stays in the card verbatim and
+ # empties the model: the card's own line speaks for itself, and
+ # nothing composes a second one beside it. A fold travels with its
+ # line, byte for byte.
def test_an_unmodeled_bday_stays_in_the_card_and_empties_the_model
- unmodeled = AIDEN.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n")
+ ["BDAY:--0412", "BDAY:1985-\r\n 04\r\n", "BDAY:1985-04-12\r\nBDAY:1986-04-12\r\n"].each do |bday|
+ unmodeled = AIDEN.sub("END:VCARD\r\n", "#{bday}END:VCARD\r\n")
- with_store({"aiden" => AIDEN_BORN}) do |store|
- store.put("aiden", unmodeled)
+ with_store({"aiden" => AIDEN_BORN}) do |store|
+ store.put("aiden", unmodeled)
- assert_equal unmodeled, store.contact("aiden").vcard
- assert_nil birthday_row(store, "aiden")
+ assert_equal unmodeled, store.contact("aiden").vcard, bday
+ assert_nil birthday_row(store, "aiden"), bday
+ end
+ end
+ end
+
+ # The card is made of text and says so itself: bytes that are not
+ # UTF-8 raise at the card, before any walk — the web's PUT has
+ # already answered them with a 412 by the time one reaches a caller
+ # this direct.
+ def test_put_of_bytes_that_are_not_text_raises_at_the_card
+ invalid = "BEGIN:VCARD\r\nFN:\xFF\r\nEND:VCARD\r\n".dup.force_encoding(Encoding::UTF_8)
+
+ with_store({}) do |store|
+ error = assert_raises(ArgumentError) { store.put("aiden", invalid) }
+
+ assert_match "not valid UTF-8", error.message
end
end
diff --git a/test/pro_tacts/vcard/test_lines.rb b/test/pro_tacts/vcard/test_lines.rb
new file mode 100644
index 0000000..ee9e40b
--- /dev/null
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -0,0 +1,133 @@
+require_relative "../../test_helper"
+
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
+class VCardLinesTest < Minitest::Test
+ VCARD = ProTacts::VCard
+ CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada\r\nUID:ada\r\nEND:VCARD\r\n"
+
+ ## the card, read once
+
+ def test_a_card_reads_its_properties_beside_its_bytes
+ card = VCARD.new(CARD)
+
+ assert_equal CARD, card.bytes
+ assert card.parseable?
+ assert_equal %w[BEGIN VERSION FN UID END], card.properties.map(&:name)
+ end
+
+ # The parse is lazy, so moving bytes never pays for reading structure.
+ def test_the_parse_happens_only_when_read
+ card = VCARD.new("this is not a vCard\r\n")
+
+ assert_equal "this is not a vCard\r\n", card.bytes
+ refute card.parseable?
+ assert_nil card.properties
+ refute card.card?
+ assert_nil card.uid
+ end
+
+ # A card is made of UTF-8 text and refuses to be made of anything
+ # else — at the boundary, where the blame lands on the input rather
+ # than on whatever regex would have tripped over the bytes later.
+ def test_bytes_that_are_not_text_are_refused
+ invalid = "FN:\xFF\r\n".dup.force_encoding(Encoding::UTF_8)
+
+ error = assert_raises(ArgumentError) { VCARD.new(invalid) }
+
+ assert_match "not valid UTF-8", error.message
+ end
+
+ # The envelope RFC 2426 section 4 requires, the one judgment a PUT
+ # needs and the parser deliberately does not make.
+ def test_card_judges_the_envelope
+ assert VCARD.new(CARD).card?
+ refute VCARD.new(CARD.sub("VERSION:3.0\r\n", "")).card?
+ refute VCARD.new(CARD.sub("END:VCARD\r\n", "")).card?
+ refute VCARD.new("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:ada\r\nEND:VCARD\r\n" + "FN:Ada\r\n").card?
+ end
+
+ def test_uid_reads_without_case
+ assert_equal "ada", VCARD.new(CARD).uid
+ assert_nil VCARD.new(CARD.sub("UID:ada\r\n", "")).uid
+ end
+
+ ## the enumeration
+
+ def test_each_yields_the_cards_lines_parsed_beside_verbatim
+ lines = VCARD.new(CARD).to_a
+
+ assert_equal %w[BEGIN VERSION FN UID END], lines.map { it.property&.name }
+ assert_equal CARD, lines.map(&:verbatim).join
+ end
+
+ def test_partition_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).partition { it.names?("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
+ end
+
+ # A fold travels with its line, byte for byte, and parses as the one
+ # logical line it is.
+ def test_a_fold_is_one_line
+ folded = CARD.sub("UID:ada\r\n", "UID:ada\r\nBDAY:1985-04-\r\n 12\r\n")
+ bday = VCARD.new(folded).find { it.names?("BDAY") }
+
+ assert_equal "1985-04-12", bday&.property&.value
+ assert_equal "BDAY:1985-04-\r\n 12\r\n", bday&.verbatim
+ 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
+ # (property nil), not an error.
+ def test_names_judges_lines_not_properties
+ grouped = CARD.sub("UID:ada\r\n", "UID:ada\r\nitem1.BDAY:1985-04-12\r\n")
+
+ assert VCARD.new(grouped).any? { it.names?("BDAY") }
+
+ lookalike = "NOTE:BDAY:1985\r\nX-BDAY:1\r\n"
+
+ refute VCARD.new(lookalike).any? { it.names?("BDAY") }
+
+ unparsed = VCARD.new("BDAY;=;:\r\n").first
+
+ assert_nil unparsed.property
+ assert unparsed.names?("BDAY")
+ assert_equal "BDAY;=;:\r\n", unparsed.verbatim
+ end
+
+ ## insert
+
+ def test_insert_takes_the_end_lines_terminator_for_a_bare_line
+ inserted = VCARD.new(CARD).insert(["BDAY:1985-04\r\n"])
+
+ assert_equal CARD.sub("END:VCARD\r\n", "BDAY:1985-04\r\nEND:VCARD\r\n"), inserted
+ assert_operator inserted.index("BDAY"), :<, inserted.index("END:VCARD")
+ end
+
+ # A bare line takes the END line's own terminator so a card stays
+ # single-convention; a terminated line keeps its own, folds and all,
+ # and must not grow a second one.
+ def test_insert_leaves_a_terminated_fold_its_own
+ folded = "BDAY:1985-\r\n 04\r\n"
+
+ assert_equal CARD.sub("END:VCARD\r\n", "BDAY:1985-\r\n 04\r\nEND:VCARD\r\n"),
+ VCARD.new(CARD).insert([folded])
+ assert_equal "BEGIN:VCARD\nEND:VCARD\n".sub("END:VCARD\n", "NOTE:x\nEND:VCARD\n"),
+ VCARD.new("BEGIN:VCARD\nEND:VCARD\n").insert(["NOTE:x\n"])
+ end
+
+ def test_insert_appends_with_crlf_when_there_is_no_end
+ assert_equal "not a card\r\nBDAY:1985-04\r\n",
+ VCARD.new("not a card\r\n").insert(["BDAY:1985-04\r\n"])
+ end
+
+ def test_insert_with_nothing_leaves_the_card_alone
+ assert_equal CARD, VCARD.new(CARD).insert([])
+ end
+end