aboutsummaryrefslogtreecommitdiff
path: root/test/cli.t
diff options
context:
space:
mode:
Diffstat (limited to 'test/cli.t')
-rw-r--r--test/cli.t680
1 files changed, 677 insertions, 3 deletions
diff --git a/test/cli.t b/test/cli.t
index 1d4df3a..80c8078 100644
--- a/test/cli.t
+++ b/test/cli.t
@@ -17,7 +17,7 @@ A year outside the supported domain is rejected (exit 2):
No/garbage arguments give a usage error (exit 2):
$ colitur
- colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur check FILE | colitur new-overlay (try: colitur --help)
+ colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help)
[2]
The EF temporal cycle for a year, one line per day:
@@ -327,14 +327,14 @@ A flag needing a value, given none:
$ colitur day 2026 --overlay
colitur: --overlay needs a file path
- colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur check FILE | colitur new-overlay (try: colitur --help)
+ colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help)
[2]
An unknown option is rejected rather than treated as a positional word:
$ colitur day 2026 --diocese
colitur: unknown option --diocese
- colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur check FILE | colitur new-overlay (try: colitur --help)
+ colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help)
[2]
The shipped example overlay is runnable documentation, and it must actually
@@ -415,3 +415,677 @@ displaced silently.
$ colitur day 2026 --overlay ben.sexp | grep '^2026-03-21'
2026-03-21 saturday lent 4 transitus-of-our-holy-father-benedict class-1 white +ef-lent-4-saturday
+
+CSV emits a header and one row per day:
+
+ $ colitur emit --format csv --from 2027 --to 2027 | head -2
+ date,rite,season,week,slug,rank,colour,subject,name_la,name_en,first,gospel,comms
+ 2027-01-01,ef,christmastide,,ef-circumcision,class-1,white,temporal,,,Titus 2:11-15,Luke 2:21,
+
+ $ colitur emit --format csv --from 2027 --to 2027 | wc -l
+ 366
+
+JSON is one object, ICS one VCALENDAR:
+
+ $ colitur emit --format json --from 2027 --to 2027 | cut -c1-20
+ {"rite":"ef","year":
+
+ $ colitur emit --format ics --from 2027 --to 2027 | head -1 | cat -A | head -1
+ BEGIN:VCALENDAR^M$
+
+Two runs are byte-identical (no clock read anywhere):
+
+ $ colitur emit --format ics --from 2027 --to 2027 > /tmp/a.ics
+ $ colitur emit --format ics --from 2027 --to 2027 > /tmp/b.ics
+ $ cmp /tmp/a.ics /tmp/b.ics && echo identical
+ identical
+
+A multi-year range concatenates years in order, one header for the whole
+CSV run rather than one per year:
+
+ $ colitur emit --format csv --from 2027 --to 2028 | grep -c '^2028-'
+ 366
+
+ $ colitur emit --format csv --from 2027 --to 2028 | wc -l
+ 732
+
+sexp and xml are also available:
+
+ $ colitur emit --format sexp --from 2027 --to 2027 | wc -l
+ 8472
+
+ $ colitur emit --format xml --from 2027 --to 2027 | head -2
+ <?xml version="1.0" encoding="UTF-8"?>
+ <calendar rite="ef" year="2027">
+
+An unknown format is a usage error on stderr, exit 2:
+
+ $ colitur emit --format yaml --from 2027 --to 2027
+ colitur: unknown format "yaml" (want csv, json, sexp, xml or ics)
+ [2]
+
+--dtstamp is the only user string that reaches ICS output unescaped and
+unvalidated -- it must be exactly RFC 5545's UTC DATE-TIME form (8 digits,
+"T", 6 digits, "Z") or refused outright, rather than either silently
+emitting a malformed DTSTAMP or, worse, letting an embedded CRLF inject
+extra lines into every VEVENT:
+
+ $ colitur emit --format ics --from 2027 --to 2027 --dtstamp hello
+ colitur: --dtstamp "hello" is not RFC 5545 UTC form (want 8 digits, 'T', 6 digits, 'Z', e.g. 20270101T000000Z)
+ [2]
+
+ $ colitur emit --format ics --from 2027 --to 2027 --dtstamp "$(printf 'X\r\nBEGIN:VEVENT\r\nUID:evil')"
+ colitur: --dtstamp "X\r\nBEGIN:VEVENT\r\nUID:evil" is not RFC 5545 UTC form (want 8 digits, 'T', 6 digits, 'Z', e.g. 20270101T000000Z)
+ [2]
+
+A well-formed value is threaded through unchanged. (The events themselves
+end in CRLF per RFC 5545 -- match the substring, not a `$`-anchored full
+line, or a shell that does not mangle the trailing "\r" is doing the
+grep-anchor's job for it by accident.)
+
+ $ colitur emit --format ics --from 2027 --to 2027 --dtstamp 20270101T000000Z | grep -c 'DTSTAMP:20270101T000000Z'
+ 365
+
+emit refuses a reversed range rather than emitting nothing:
+
+ $ colitur emit --format csv --from 2028 --to 2027
+ colitur: --from 2028 is after --to 2027
+ [2]
+
+emit's own flags have no effect on the other commands, refused rather than
+silently ignored, the same discipline --overlay already gets:
+
+ $ colitur day 2027 --format csv
+ colitur: --format/--from/--to/--dtstamp have no effect on `day`; refusing rather than ignoring them
+ [2]
+
+day and readings are untouched:
+
+ $ colitur day 2027 | head -1
+ 2027-01-01 friday christmastide - ef-circumcision class-1 white
+
+ $ colitur readings 2027 | head -1
+ 2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21
+
+A minimal inline template renders -- table computes and renders in one
+process (2 January 2027 is a Saturday, not a Sunday, so Holy Name Sunday
+falls on the 3rd, not the 2nd, that year):
+
+ $ printf '{{#days}}{{iso}} {{slug}}\n{{/days}}' > /tmp/t.txt
+ $ colitur table --year 2027 --template /tmp/t.txt | head -2
+ 2027-01-01 ef-circumcision
+ 2027-01-02 ef-christmas-1-saturday
+
+render is the same operation under the name the design used:
+
+ $ colitur render --template /tmp/t.txt --year 2027 | head -2
+ 2027-01-01 ef-circumcision
+ 2027-01-02 ef-christmas-1-saturday
+
+Flavour is inferred from the extension and escapes data -- Sts. Peter &
+Paul (29 June) and its vigil are the only two 2035 entries whose English
+name needs LaTeX escaping:
+
+ $ printf '{{#days}}{{name.en}}\n{{/days}}' > /tmp/t.tex
+ $ colitur table --year 2035 --template /tmp/t.tex | grep -c 'Peter \\& Paul'
+ 2
+
+An unknown extension with no --flavour is an error, not a silent fallback:
+
+ $ printf 'x' > /tmp/t.wat
+ $ colitur table --year 2027 --template /tmp/t.wat
+ colitur: cannot infer a flavour from ".wat"; pass --flavour latex|groff|html|xml|ics|none
+ [2]
+
+ $ colitur table --year 2027 --template /tmp/t.wat --flavour none
+ x
+
+An unrecognised --flavour value is also an error naming the six valid ones:
+
+ $ colitur table --year 2027 --template /tmp/t.txt --flavour bogus
+ colitur: unknown flavour "bogus" (want latex, groff, html, xml, ics or none)
+ [2]
+
+A malformed template is a clear error, not a crash:
+
+ $ printf '{{#days}}oops' > /tmp/bad.txt
+ $ colitur table --year 2027 --template /tmp/bad.txt
+ colitur: template /tmp/bad.txt: unclosed section {{#days}}
+ [2]
+
+A missing template file is an error:
+
+ $ colitur table --year 2027 --template /tmp/nope.txt
+ colitur: cannot read template /tmp/nope.txt
+ [2]
+
+Pointing --template at a directory is an error, not a crash: the read
+itself is guarded, not only the open (F1, fix round 1). "." is used rather
+than a fixed /tmp path so this does not depend on anything outside the
+cram sandbox itself:
+
+ $ colitur table --year 2027 --template . --flavour none
+ colitur: cannot read template .: Sys_error("Value too large for defined data type")
+ [2]
+
+table and render both require --year and --template:
+
+ $ colitur table --year 2027
+ colitur: table requires --year YEAR and --template FILE
+ [2]
+
+ $ colitur render --template /tmp/t.txt
+ colitur: render requires --year YEAR and --template FILE
+ [2]
+
+table/render's own flags have no effect on the other commands, refused
+rather than silently ignored:
+
+ $ colitur emit --format csv --from 2027 --to 2027 --year 2028
+ colitur: --year/--template/--flavour have no effect on `emit`; refusing rather than ignoring them
+ [2]
+
+publish writes the documented tree (Task 12): the manifest itself
+(.colitur-manifest) is a real file `find` sees too, since it lives in the
+same directory as everything else it tracks. Every /tmp/pub* path below is
+cleared first, so this section is self-contained across repeat runs:
+
+ $ rm -rf /tmp/pub /tmp/pub1 /tmp/pub2 /tmp/pub3
+
+ $ colitur publish --from 2027 --to 2027 --out /tmp/pub >/dev/null
+ $ find /tmp/pub -type f | sed 's|/tmp/pub/||' | sort
+ .colitur-manifest
+ ef/2027.csv
+ ef/2027.ics
+ ef/2027.json
+ ef/2027.xml
+ ef/2027/01/01.json
+ ef/2027/01/02.json
+ ef/2027/01/03.json
+ ef/2027/01/04.json
+ ef/2027/01/05.json
+ ef/2027/01/06.json
+ ef/2027/01/07.json
+ ef/2027/01/08.json
+ ef/2027/01/09.json
+ ef/2027/01/10.json
+ ef/2027/01/11.json
+ ef/2027/01/12.json
+ ef/2027/01/13.json
+ ef/2027/01/14.json
+ ef/2027/01/15.json
+ ef/2027/01/16.json
+ ef/2027/01/17.json
+ ef/2027/01/18.json
+ ef/2027/01/19.json
+ ef/2027/01/20.json
+ ef/2027/01/21.json
+ ef/2027/01/22.json
+ ef/2027/01/23.json
+ ef/2027/01/24.json
+ ef/2027/01/25.json
+ ef/2027/01/26.json
+ ef/2027/01/27.json
+ ef/2027/01/28.json
+ ef/2027/01/29.json
+ ef/2027/01/30.json
+ ef/2027/01/31.json
+ ef/2027/02/01.json
+ ef/2027/02/02.json
+ ef/2027/02/03.json
+ ef/2027/02/04.json
+ ef/2027/02/05.json
+ ef/2027/02/06.json
+ ef/2027/02/07.json
+ ef/2027/02/08.json
+ ef/2027/02/09.json
+ ef/2027/02/10.json
+ ef/2027/02/11.json
+ ef/2027/02/12.json
+ ef/2027/02/13.json
+ ef/2027/02/14.json
+ ef/2027/02/15.json
+ ef/2027/02/16.json
+ ef/2027/02/17.json
+ ef/2027/02/18.json
+ ef/2027/02/19.json
+ ef/2027/02/20.json
+ ef/2027/02/21.json
+ ef/2027/02/22.json
+ ef/2027/02/23.json
+ ef/2027/02/24.json
+ ef/2027/02/25.json
+ ef/2027/02/26.json
+ ef/2027/02/27.json
+ ef/2027/02/28.json
+ ef/2027/03/01.json
+ ef/2027/03/02.json
+ ef/2027/03/03.json
+ ef/2027/03/04.json
+ ef/2027/03/05.json
+ ef/2027/03/06.json
+ ef/2027/03/07.json
+ ef/2027/03/08.json
+ ef/2027/03/09.json
+ ef/2027/03/10.json
+ ef/2027/03/11.json
+ ef/2027/03/12.json
+ ef/2027/03/13.json
+ ef/2027/03/14.json
+ ef/2027/03/15.json
+ ef/2027/03/16.json
+ ef/2027/03/17.json
+ ef/2027/03/18.json
+ ef/2027/03/19.json
+ ef/2027/03/20.json
+ ef/2027/03/21.json
+ ef/2027/03/22.json
+ ef/2027/03/23.json
+ ef/2027/03/24.json
+ ef/2027/03/25.json
+ ef/2027/03/26.json
+ ef/2027/03/27.json
+ ef/2027/03/28.json
+ ef/2027/03/29.json
+ ef/2027/03/30.json
+ ef/2027/03/31.json
+ ef/2027/04/01.json
+ ef/2027/04/02.json
+ ef/2027/04/03.json
+ ef/2027/04/04.json
+ ef/2027/04/05.json
+ ef/2027/04/06.json
+ ef/2027/04/07.json
+ ef/2027/04/08.json
+ ef/2027/04/09.json
+ ef/2027/04/10.json
+ ef/2027/04/11.json
+ ef/2027/04/12.json
+ ef/2027/04/13.json
+ ef/2027/04/14.json
+ ef/2027/04/15.json
+ ef/2027/04/16.json
+ ef/2027/04/17.json
+ ef/2027/04/18.json
+ ef/2027/04/19.json
+ ef/2027/04/20.json
+ ef/2027/04/21.json
+ ef/2027/04/22.json
+ ef/2027/04/23.json
+ ef/2027/04/24.json
+ ef/2027/04/25.json
+ ef/2027/04/26.json
+ ef/2027/04/27.json
+ ef/2027/04/28.json
+ ef/2027/04/29.json
+ ef/2027/04/30.json
+ ef/2027/05/01.json
+ ef/2027/05/02.json
+ ef/2027/05/03.json
+ ef/2027/05/04.json
+ ef/2027/05/05.json
+ ef/2027/05/06.json
+ ef/2027/05/07.json
+ ef/2027/05/08.json
+ ef/2027/05/09.json
+ ef/2027/05/10.json
+ ef/2027/05/11.json
+ ef/2027/05/12.json
+ ef/2027/05/13.json
+ ef/2027/05/14.json
+ ef/2027/05/15.json
+ ef/2027/05/16.json
+ ef/2027/05/17.json
+ ef/2027/05/18.json
+ ef/2027/05/19.json
+ ef/2027/05/20.json
+ ef/2027/05/21.json
+ ef/2027/05/22.json
+ ef/2027/05/23.json
+ ef/2027/05/24.json
+ ef/2027/05/25.json
+ ef/2027/05/26.json
+ ef/2027/05/27.json
+ ef/2027/05/28.json
+ ef/2027/05/29.json
+ ef/2027/05/30.json
+ ef/2027/05/31.json
+ ef/2027/06/01.json
+ ef/2027/06/02.json
+ ef/2027/06/03.json
+ ef/2027/06/04.json
+ ef/2027/06/05.json
+ ef/2027/06/06.json
+ ef/2027/06/07.json
+ ef/2027/06/08.json
+ ef/2027/06/09.json
+ ef/2027/06/10.json
+ ef/2027/06/11.json
+ ef/2027/06/12.json
+ ef/2027/06/13.json
+ ef/2027/06/14.json
+ ef/2027/06/15.json
+ ef/2027/06/16.json
+ ef/2027/06/17.json
+ ef/2027/06/18.json
+ ef/2027/06/19.json
+ ef/2027/06/20.json
+ ef/2027/06/21.json
+ ef/2027/06/22.json
+ ef/2027/06/23.json
+ ef/2027/06/24.json
+ ef/2027/06/25.json
+ ef/2027/06/26.json
+ ef/2027/06/27.json
+ ef/2027/06/28.json
+ ef/2027/06/29.json
+ ef/2027/06/30.json
+ ef/2027/07/01.json
+ ef/2027/07/02.json
+ ef/2027/07/03.json
+ ef/2027/07/04.json
+ ef/2027/07/05.json
+ ef/2027/07/06.json
+ ef/2027/07/07.json
+ ef/2027/07/08.json
+ ef/2027/07/09.json
+ ef/2027/07/10.json
+ ef/2027/07/11.json
+ ef/2027/07/12.json
+ ef/2027/07/13.json
+ ef/2027/07/14.json
+ ef/2027/07/15.json
+ ef/2027/07/16.json
+ ef/2027/07/17.json
+ ef/2027/07/18.json
+ ef/2027/07/19.json
+ ef/2027/07/20.json
+ ef/2027/07/21.json
+ ef/2027/07/22.json
+ ef/2027/07/23.json
+ ef/2027/07/24.json
+ ef/2027/07/25.json
+ ef/2027/07/26.json
+ ef/2027/07/27.json
+ ef/2027/07/28.json
+ ef/2027/07/29.json
+ ef/2027/07/30.json
+ ef/2027/07/31.json
+ ef/2027/08/01.json
+ ef/2027/08/02.json
+ ef/2027/08/03.json
+ ef/2027/08/04.json
+ ef/2027/08/05.json
+ ef/2027/08/06.json
+ ef/2027/08/07.json
+ ef/2027/08/08.json
+ ef/2027/08/09.json
+ ef/2027/08/10.json
+ ef/2027/08/11.json
+ ef/2027/08/12.json
+ ef/2027/08/13.json
+ ef/2027/08/14.json
+ ef/2027/08/15.json
+ ef/2027/08/16.json
+ ef/2027/08/17.json
+ ef/2027/08/18.json
+ ef/2027/08/19.json
+ ef/2027/08/20.json
+ ef/2027/08/21.json
+ ef/2027/08/22.json
+ ef/2027/08/23.json
+ ef/2027/08/24.json
+ ef/2027/08/25.json
+ ef/2027/08/26.json
+ ef/2027/08/27.json
+ ef/2027/08/28.json
+ ef/2027/08/29.json
+ ef/2027/08/30.json
+ ef/2027/08/31.json
+ ef/2027/09/01.json
+ ef/2027/09/02.json
+ ef/2027/09/03.json
+ ef/2027/09/04.json
+ ef/2027/09/05.json
+ ef/2027/09/06.json
+ ef/2027/09/07.json
+ ef/2027/09/08.json
+ ef/2027/09/09.json
+ ef/2027/09/10.json
+ ef/2027/09/11.json
+ ef/2027/09/12.json
+ ef/2027/09/13.json
+ ef/2027/09/14.json
+ ef/2027/09/15.json
+ ef/2027/09/16.json
+ ef/2027/09/17.json
+ ef/2027/09/18.json
+ ef/2027/09/19.json
+ ef/2027/09/20.json
+ ef/2027/09/21.json
+ ef/2027/09/22.json
+ ef/2027/09/23.json
+ ef/2027/09/24.json
+ ef/2027/09/25.json
+ ef/2027/09/26.json
+ ef/2027/09/27.json
+ ef/2027/09/28.json
+ ef/2027/09/29.json
+ ef/2027/09/30.json
+ ef/2027/10/01.json
+ ef/2027/10/02.json
+ ef/2027/10/03.json
+ ef/2027/10/04.json
+ ef/2027/10/05.json
+ ef/2027/10/06.json
+ ef/2027/10/07.json
+ ef/2027/10/08.json
+ ef/2027/10/09.json
+ ef/2027/10/10.json
+ ef/2027/10/11.json
+ ef/2027/10/12.json
+ ef/2027/10/13.json
+ ef/2027/10/14.json
+ ef/2027/10/15.json
+ ef/2027/10/16.json
+ ef/2027/10/17.json
+ ef/2027/10/18.json
+ ef/2027/10/19.json
+ ef/2027/10/20.json
+ ef/2027/10/21.json
+ ef/2027/10/22.json
+ ef/2027/10/23.json
+ ef/2027/10/24.json
+ ef/2027/10/25.json
+ ef/2027/10/26.json
+ ef/2027/10/27.json
+ ef/2027/10/28.json
+ ef/2027/10/29.json
+ ef/2027/10/30.json
+ ef/2027/10/31.json
+ ef/2027/11/01.json
+ ef/2027/11/02.json
+ ef/2027/11/03.json
+ ef/2027/11/04.json
+ ef/2027/11/05.json
+ ef/2027/11/06.json
+ ef/2027/11/07.json
+ ef/2027/11/08.json
+ ef/2027/11/09.json
+ ef/2027/11/10.json
+ ef/2027/11/11.json
+ ef/2027/11/12.json
+ ef/2027/11/13.json
+ ef/2027/11/14.json
+ ef/2027/11/15.json
+ ef/2027/11/16.json
+ ef/2027/11/17.json
+ ef/2027/11/18.json
+ ef/2027/11/19.json
+ ef/2027/11/20.json
+ ef/2027/11/21.json
+ ef/2027/11/22.json
+ ef/2027/11/23.json
+ ef/2027/11/24.json
+ ef/2027/11/25.json
+ ef/2027/11/26.json
+ ef/2027/11/27.json
+ ef/2027/11/28.json
+ ef/2027/11/29.json
+ ef/2027/11/30.json
+ ef/2027/12/01.json
+ ef/2027/12/02.json
+ ef/2027/12/03.json
+ ef/2027/12/04.json
+ ef/2027/12/05.json
+ ef/2027/12/06.json
+ ef/2027/12/07.json
+ ef/2027/12/08.json
+ ef/2027/12/09.json
+ ef/2027/12/10.json
+ ef/2027/12/11.json
+ ef/2027/12/12.json
+ ef/2027/12/13.json
+ ef/2027/12/14.json
+ ef/2027/12/15.json
+ ef/2027/12/16.json
+ ef/2027/12/17.json
+ ef/2027/12/18.json
+ ef/2027/12/19.json
+ ef/2027/12/20.json
+ ef/2027/12/21.json
+ ef/2027/12/22.json
+ ef/2027/12/23.json
+ ef/2027/12/24.json
+ ef/2027/12/25.json
+ ef/2027/12/26.json
+ ef/2027/12/27.json
+ ef/2027/12/28.json
+ ef/2027/12/29.json
+ ef/2027/12/30.json
+ ef/2027/12/31.json
+ index.html
+ schema/day-v1.json
+
+ $ ls /tmp/pub/ef/2027/01/*.json | wc -l
+ 31
+
+ $ test -f /tmp/pub/schema/day-v1.json && echo schema-present
+ schema-present
+
+ $ test -f /tmp/pub/index.html && echo index-present
+ index-present
+
+Publishing twice is byte-identical -- safe to publish into a git repo:
+
+ $ colitur publish --from 2027 --to 2027 --out /tmp/pub1 >/dev/null
+ $ colitur publish --from 2027 --to 2027 --out /tmp/pub2 >/dev/null
+ $ diff -r /tmp/pub1 /tmp/pub2 && echo identical
+ identical
+
+The published .ics is byte-identical to `emit --format ics` for the same
+year -- both walk through the identical Emit_ics.year:
+
+ $ colitur emit --format ics --from 2027 --to 2027 > /tmp/emit-2027.ics
+ $ diff /tmp/pub1/ef/2027.ics /tmp/emit-2027.ics && echo ics-identical
+ ics-identical
+
+publish never deletes a file it does not own:
+
+ $ touch /tmp/pub1/MY-NOTES.txt
+ $ colitur publish --from 2027 --to 2027 --out /tmp/pub1 >/dev/null
+ $ test -f /tmp/pub1/MY-NOTES.txt && echo kept
+ kept
+
+--prune removes only files a previous run created:
+
+ $ colitur publish --from 2027 --to 2027 --out /tmp/pub1 --prune >/dev/null
+ $ test -f /tmp/pub1/MY-NOTES.txt && echo still-kept
+ still-kept
+
+ $ colitur publish --from 2028 --to 2028 --out /tmp/pub1 --prune >/dev/null
+ $ test -d /tmp/pub1/ef/2027 || echo pruned-2027
+ pruned-2027
+ $ test -f /tmp/pub1/MY-NOTES.txt && echo notes-survived-prune
+ notes-survived-prune
+
+--out is required:
+
+ $ colitur publish --from 2027 --to 2027
+ colitur: publish requires --out DIR
+ [2]
+
+publish writes many files across a whole year range (mkdir_p/write_file),
+same as the template read guarded in commit 6bd741b -- --out is user input
+too, and an unwritable parent used to surface as an uncaught
+Unix.Unix_error instead of the project's one-line form. The read-only
+directory below lives in this test's own cram sandbox, not /tmp: a failed
+`rm -rf` of an unwritable directory would otherwise leave it behind in a
+shared location, so it is restored to writable before the test ends either
+way:
+
+ $ mkdir ro-parent && chmod 555 ro-parent
+ $ colitur publish --from 2027 --to 2027 --out ro-parent/sub
+ colitur: mkdir: ro-parent/sub: Permission denied
+ [2]
+ $ chmod 755 ro-parent
+
+publish's own flags have no effect on the other commands, and other
+commands' flags have no effect on publish -- refused rather than silently
+ignored, the same discipline as everywhere else:
+
+ $ colitur publish --from 2027 --to 2027 --out /tmp/pub3 --format json
+ colitur: --format has no effect on `publish`; refusing rather than ignoring it
+ [2]
+
+ $ colitur day 2027 --out /tmp/pub3 --prune
+ colitur: --out/--prune have no effect on `day`; refusing rather than ignoring them
+ [2]
+
+--prune's manifest-driven deletion is hardened against a manifest entry it
+did not itself write (fix round 1, F1, CRITICAL): the manifest lives INSIDE
+the tree publish writes into, so a bad merge or a hand-edit can put an
+arbitrary path in it -- no attacker required. This is the exact CANARY
+reproduction the finding was raised with: a ".." entry appended to the
+manifest must never let --prune delete outside --out.
+
+ $ rm -rf /tmp/pub-sec /tmp/pub-sec-outside
+ $ mkdir -p /tmp/pub-sec-outside
+ $ touch /tmp/pub-sec-outside/CANARY.txt
+ $ colitur publish --from 2027 --to 2027 --out /tmp/pub-sec >/dev/null
+ $ echo '../pub-sec-outside/CANARY.txt' >> /tmp/pub-sec/.colitur-manifest
+ $ colitur publish --from 2028 --to 2028 --out /tmp/pub-sec --prune >/dev/null
+ colitur: refusing to prune manifest entry "../pub-sec-outside/CANARY.txt" (absolute path or .. component)
+ $ test -f /tmp/pub-sec-outside/CANARY.txt && echo canary-survives
+ canary-survives
+
+The same run's own legitimate stale entries (2027's files, superseded by
+2028) still prune normally -- the hardening does not disable pruning, only
+unsafe entries:
+
+ $ test -d /tmp/pub-sec/ef/2027 || echo 2027-pruned-normally
+ 2027-pruned-normally
+
+An absolute-path entry is refused the same way, not only a ".." one:
+
+ $ echo '/tmp/pub-sec-outside/CANARY.txt' >> /tmp/pub-sec/.colitur-manifest
+ $ colitur publish --from 2028 --to 2028 --out /tmp/pub-sec --prune >/dev/null
+ colitur: refusing to prune manifest entry "/tmp/pub-sec-outside/CANARY.txt" (absolute path or .. component)
+ $ test -f /tmp/pub-sec-outside/CANARY.txt && echo canary-still-survives
+ canary-still-survives
+
+A legitimate filename that merely CONTAINS two dots -- but has no ".." path
+COMPONENT -- is not caught by the same check, proving it is not
+over-broad: it still prunes normally when stale.
+
+ $ touch /tmp/pub-sec/ef/2027..old.json
+ $ echo 'ef/2027..old.json' >> /tmp/pub-sec/.colitur-manifest
+ $ colitur publish --from 2029 --to 2029 --out /tmp/pub-sec --prune >/dev/null
+ $ test -f /tmp/pub-sec/ef/2027..old.json || echo dotted-name-pruned
+ dotted-name-pruned
+
+...and that same run is an ordinary --prune cycle in every other respect --
+2028's own files, now stale relative to 2029, are gone too:
+
+ $ test -d /tmp/pub-sec/ef/2028 || echo pruned-2028
+ pruned-2028