summaryrefslogtreecommitdiff
path: root/lib/render/emit_csv.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukasz@arcofasiagroup.com>2026-08-27 12:33:43 +0200
committerLukasz Kasprzak <lukasz@arcofasiagroup.com>2026-08-27 12:33:43 +0200
commit32e792a0741f62fc2c0c7dcf9255408b3256fad5 (patch)
tree92f0b7b0a289de5251bb18bcc9239becdaee223c /lib/render/emit_csv.ml
parent85903385f8a32be3de3964f43b3d7831474d0a72 (diff)
downloadcolitur-32e792a0741f62fc2c0c7dcf9255408b3256fad5.tar.gz
colitur-32e792a0741f62fc2c0c7dcf9255408b3256fad5.zip
fix(cli): make the flag surface systematic
An audit probed every flag against every command rather than reading --help, and found three inconsistencies. --rite reached only `day` and `readings`. main.ml's own comment gave the reason -- "has not been widened to a second rite in this task" -- a scope note that had outlived its task and hardened into apparent design. View.of_days was already polymorphic over the rite's type parameters, so widening was plumbing, not library work: `temporal`, `rubrics`, `emit`, `table`, `render` and `publish` now all take it. `temporal` was the sharpest case. Its refusal said "--rite has no effect on `temporal`", which was false: the EF has Septuagesima and Passiontide, the OF neither, and every EF slug is ef-prefixed, so the flag would change nearly every line. A message claiming no effect where the effect is total is exactly what the audit set out to find. Three ways of naming a year (positional, --year, --from/--to) now cross-accept additively; naming two that disagree is a usage error rather than one silently winning. emit --format csv gained a rite-dependent header: EF's 16 columns are unchanged, OF gets a 17th "second" between "first" and "gospel". An earlier task had recorded RFC 4180 as permanently blocking this; the rule constrains one file, not a family of them. `rubrics` keeps refusing --lang/--raw, now with its reason. An intermediate version accepted them by adding a name column, which changed the default from six tab-separated fields to seven and broke both existing consumers and the byte-identical-EF rule. The asymmetry is real but principled: the row is a date, a slug, a source keyword, two booleans and a preface key, so there is nothing to translate and nothing to strip. `easter` refuses --rite for the same kind of reason -- its six anchors sit at identical Easter offsets in both rites. EF output verified byte-identical to 8590338 across day, readings, rubrics, temporal and easter for 2026, 1583 and 9999, and across all five emit formats.
Diffstat (limited to 'lib/render/emit_csv.ml')
-rw-r--r--lib/render/emit_csv.ml63
1 files changed, 41 insertions, 22 deletions
diff --git a/lib/render/emit_csv.ml b/lib/render/emit_csv.ml
index e04211f..ff11a81 100644
--- a/lib/render/emit_csv.ml
+++ b/lib/render/emit_csv.ml
@@ -23,26 +23,42 @@ let s v k = match get v k with Some (T.Str x) -> x | _ -> ""
Machine formats still carry [slug] alongside it, so a script keeps the
stable key and a human reads the name.
- W5: deliberately NOT widened with a "second" column, unlike
+ W5 left this deliberately NOT widened with a "second" column, unlike
[Emit_xml]/[Emit_ics] (each a repeated-element/free-text format that
- grows for free). A CSV's header row is a CONTRACT with every row under
+ grows for free): a CSV's header row is a CONTRACT with every row under
it (RFC 4180 sec. 2: "each field in the header... should contain the
same number of fields as the records"), so there is no way to add a
column only on the days that carry a [Second] reading -- a real
- three-citation day would either need an ALWAYS-present, mostly-empty
- "second" column (widening every EF row forever for a case EF can never
- hit) or genuinely variable-width rows (not CSV). Neither is a change to
- make speculatively: [bin/main.ml]'s [reject_rite_for] refuses [--rite]
- on `emit` entirely for any rite but EF, so no [Second] reading can reach
- this module today, and EF's own [citations] is always exactly
- [[First; Gospel]] ({!Rite_ef}'s own [citation_shapes]) -- there is
- nothing to widen FOR yet. When OF is admitted here, this is the one
- emitter that needs a deliberate, disclosed breaking change (a new
- column, a version bump, or a documented "third reading silently
- dropped" trade-off), not a silent one. *)
-let columns =
- [ "date"; "rite"; "season"; "season_name"; "week"; "slug"; "name"; "weekday";
- "rank"; "rank_name"; "colour"; "colour_name"; "subject"; "first"; "gospel"; "comms" ]
+ three-citation day needs either an ALWAYS-present, mostly-empty
+ "second" column or genuinely variable-width rows (not CSV). At the
+ time, [bin/main.ml]'s [reject_rite_for] refused [--rite] on `emit`
+ entirely for any rite but EF, so no [Second] reading could reach this
+ module, and this was recorded as the deliberate, disclosed breaking
+ change the day OF was admitted here would need -- "a new column, a
+ version bump, or a documented trade-off, not a silent one".
+
+ Fix 1 (cli-flags-report, 2026-08-27): that day is this one. The
+ "always-present, mostly-empty column" option is taken, gated on
+ [rite] (read from [v] itself, exactly as [row]'s own [~rite] already
+ is) rather than threaded as a new parameter: EF's [rite = "ef"] keeps
+ the ORIGINAL 16-column header and rows, byte for byte -- no [Second]
+ reading has ever reached EF's own [citations]
+ ({!Rite_ef}'s own [citation_shapes] is still exactly
+ [[First; Gospel]]), so [rite = "ef"]'s branch below is provably
+ unreachable-different, not merely untested-different. Any OTHER rite
+ (OF today) gets a 17th column, "second", spliced between "first" and
+ "gospel" -- the same position [Emit_xml]/[Emit_ics] already use for
+ it -- reading [s d "second"], which [get]'s own [None -> ""] fallback
+ already resolves to the empty string on every day that has no [Second]
+ part (a feria, feast or memorial; OLM 1981 Praenotanda n. 66.1/n. 69.1),
+ so nothing here needs to know in advance which OF days do carry one. *)
+let columns ~rite =
+ let base =
+ [ "date"; "rite"; "season"; "season_name"; "week"; "slug"; "name"; "weekday"; "rank"; "rank_name";
+ "colour"; "colour_name"; "subject"; "first" ]
+ in
+ let tail = [ "gospel"; "comms" ] in
+ if rite = "ef" then base @ tail else base @ [ "second" ] @ tail
let row ~rite d =
let comms =
@@ -50,17 +66,20 @@ let row ~rite d =
| Some (T.List l) -> String.concat " " (List.map (fun c -> s c "slug") l)
| _ -> ""
in
- String.concat ","
- (List.map escape_field
- [ s d "iso"; rite; s d "season"; s d "season_name"; s d "week"; s d "slug";
- s d "name"; s d "weekday"; s d "rank"; s d "rank_name"; s d "colour";
- s d "colour_name"; s d "subject"; s d "first"; s d "gospel"; comms ])
+ let base =
+ [ s d "iso"; rite; s d "season"; s d "season_name"; s d "week"; s d "slug"; s d "name";
+ s d "weekday"; s d "rank"; s d "rank_name"; s d "colour"; s d "colour_name"; s d "subject";
+ s d "first" ]
+ in
+ let tail = [ s d "gospel"; comms ] in
+ let fields = if rite = "ef" then base @ tail else base @ [ s d "second" ] @ tail in
+ String.concat "," (List.map escape_field fields)
let year v =
let rite = s v "rite" in
let days = match get v "days" with Some (T.List l) -> l | _ -> [] in
let b = Buffer.create (64 * 400) in
- Buffer.add_string b (String.concat "," columns);
+ Buffer.add_string b (String.concat "," (columns ~rite));
Buffer.add_char b '\n';
List.iter (fun d -> Buffer.add_string b (row ~rite d); Buffer.add_char b '\n') days;
Buffer.contents b