Make an aggregate's own events a first-class mode
An aggregate with no default_sink already left events created in it alone, but treated doing so as a failure: it reported a skip per event per cycle saying no sink was configured, as though something had gone wrong. Nothing had. An aggregate is also a calendar, and holding events of its own is a legitimate way to use one. Skipped::NoSink is replaced by a kept_local count, reported plainly. `@local` joins the routing markers, so the mode also works per-event where a default_sink is configured — which was not previously expressible. Unlike every other marker it is deliberately not stripped: the others have done their job once the event reaches its source, whereas this one never leaves, so it has to stay legible for the next cycle to reach the same decision. `local` is therefore a reserved endpoint id, and configuring one is refused. This also fixes a real defect. `retarget` rebuilds the new target from the recorded links, which cover derived events only, so an event belonging to the aggregate itself did not follow the move — it stayed on the calendar being left behind while everything around it moved on, quietly. It is now carried across, since there is nothing to re-derive it from, and the new target's scheduling rule is applied on the way: this is a write to an aggregate like any other, and a guest list carried live onto a server that schedules would mail everyone on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
7607078394
commit
47ad8b4c47
9 changed files with 298 additions and 26 deletions
29
README.md
29
README.md
|
|
@ -144,6 +144,9 @@ reported in one pass:
|
|||
- `target` must not also be one of its own `sources`.
|
||||
- `sources` must be non-empty, and each must exist and be named once.
|
||||
- `default_sink`, if given, must be one of the `sources` and must be writable.
|
||||
Leaving it out is a deliberate mode, not an omission — see
|
||||
[Events of the aggregate's own](#events-of-the-aggregates-own).
|
||||
- No endpoint may be called `local`, which is reserved for the routing marker.
|
||||
|
||||
### A complete example
|
||||
|
||||
|
|
@ -256,6 +259,7 @@ aggregate `unified`
|
|||
new events go to `gcal` unless told otherwise
|
||||
to choose, put one of these on a line of its own in the event's notes:
|
||||
@gcal
|
||||
@local (keep the event here, in this calendar only)
|
||||
```
|
||||
|
||||
Read-only feeds are left out, since they cannot take an event. A marker naming
|
||||
|
|
@ -263,6 +267,31 @@ anything else is refused and reported — the event stays where it is rather tha
|
|||
being quietly filed under the default, which would put it somewhere you did not
|
||||
ask for.
|
||||
|
||||
### Events of the aggregate's own
|
||||
|
||||
An aggregate is also a calendar, and it can hold events that belong to nothing
|
||||
else. There are two ways to get one:
|
||||
|
||||
- **`@local`** on an event, exactly like any other marker, keeps it where it was
|
||||
written even though a `default_sink` would otherwise have taken it. Unlike
|
||||
every other marker it is *not* removed from the event: the others have done
|
||||
their job once the event reaches its source, whereas this one has to stay
|
||||
legible so every later cycle reaches the same decision.
|
||||
- **Leave `default_sink` out of the aggregate entirely.** Then nothing is
|
||||
configured to file a new event under, so every untagged event stays put and
|
||||
only `@`-tagged ones are sent anywhere. Routing becomes opt-in rather than
|
||||
opt-out.
|
||||
|
||||
`local` is a reserved endpoint id for this reason, and configuring an endpoint
|
||||
with that name is refused.
|
||||
|
||||
One thing to be aware of: every other event in an aggregate is derived from a
|
||||
source, so the aggregate is disposable — lose the calendar and it rebuilds
|
||||
itself on the next sync. **An event of the aggregate's own exists in exactly one
|
||||
place.** Nothing else holds a copy, so it is only as safe as that calendar is.
|
||||
`aggregate retarget` carries these events to the new target along with
|
||||
everything else, since there is nothing to re-derive them from.
|
||||
|
||||
A **category** matching an endpoint id works too, that being the field
|
||||
iCalendar intends for this. Be aware that any category on a newly created event
|
||||
is read as a routing instruction, so an event carrying an unrelated category
|
||||
|
|
|
|||
|
|
@ -207,10 +207,20 @@ pub enum Problem {
|
|||
NoUrlSource { endpoint: String },
|
||||
#[error("endpoint `{endpoint}` has both `url` and `url_command`; use one or the other")]
|
||||
AmbiguousUrlSource { endpoint: String },
|
||||
#[error(
|
||||
"endpoint `{endpoint}` uses a reserved id: `@{endpoint}` means \"keep this event in the aggregate\", so an endpoint of that name could never be routed to"
|
||||
)]
|
||||
ReservedEndpointId { endpoint: String },
|
||||
}
|
||||
|
||||
/// A webcal feed must name exactly one source for its URL.
|
||||
/// A webcal feed must name exactly one source for its URL, and no endpoint may
|
||||
/// take a name the routing markers have already spoken for.
|
||||
fn check_endpoint(endpoint: &Endpoint, problems: &mut Vec<Problem>) {
|
||||
if crate::mirror::is_local_sink(&endpoint.id) {
|
||||
problems.push(Problem::ReservedEndpointId {
|
||||
endpoint: endpoint.id.clone(),
|
||||
});
|
||||
}
|
||||
let EndpointKind::Webcal { url, url_command } = &endpoint.kind else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -609,4 +619,25 @@ sources = ["work", "work"]
|
|||
endpoint: "work".into(),
|
||||
}));
|
||||
}
|
||||
|
||||
/// `@local` already means "keep this event here", so an endpoint of that
|
||||
/// name could never be routed to.
|
||||
#[test]
|
||||
fn an_endpoint_may_not_take_the_reserved_name() {
|
||||
let problems = problems(
|
||||
r#"
|
||||
[[endpoint]]
|
||||
id = "local"
|
||||
type = "caldav"
|
||||
url = "https://caldav.example.com/other/"
|
||||
username = "me@example.com"
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
problems
|
||||
.iter()
|
||||
.any(|problem| matches!(problem, Problem::ReservedEndpointId { .. })),
|
||||
"{problems:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
src/main.rs
15
src/main.rs
|
|
@ -195,6 +195,12 @@ fn run_retarget(cli: &Cli, id: &str, to: &str, purge_old: bool) -> ExitCode {
|
|||
outcome.routed_first,
|
||||
outcome.purged,
|
||||
);
|
||||
if outcome.carried > 0 {
|
||||
println!(
|
||||
" {} event(s) belonging to the aggregate itself were moved across",
|
||||
outcome.carried
|
||||
);
|
||||
}
|
||||
if outcome.purged == 0 && !purge_old {
|
||||
println!(
|
||||
" the previous target still holds this aggregate's events; re-run with --purge-old to remove them"
|
||||
|
|
@ -269,6 +275,12 @@ fn print_report(report: &Report) {
|
|||
aggregate.deleted_from_aggregate,
|
||||
aggregate.deleted_from_sources,
|
||||
);
|
||||
if aggregate.kept_local > 0 {
|
||||
println!(
|
||||
" {} event(s) kept local: they belong to the aggregate itself, not to a source",
|
||||
aggregate.kept_local
|
||||
);
|
||||
}
|
||||
for conflict in &aggregate.conflicts {
|
||||
println!(
|
||||
" conflict: {} changed in both places; kept the version from `{}`",
|
||||
|
|
@ -286,9 +298,6 @@ fn print_report(report: &Report) {
|
|||
|
||||
fn describe(skipped: &reconcile::Skipped) -> String {
|
||||
match skipped {
|
||||
reconcile::Skipped::NoSink { aggregate_uid } => format!(
|
||||
"`{aggregate_uid}` was created in the aggregate, but no default_sink is set to route it to"
|
||||
),
|
||||
reconcile::Skipped::ReadOnlySource {
|
||||
aggregate_uid,
|
||||
source_id,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,15 @@ const OWN_PROPERTIES: &[&str] = &[SOURCE_PROPERTY, ORIGIN_UID_PROPERTY, ATTENDEE
|
|||
/// Live scheduling properties, whose presence is what makes a server send mail.
|
||||
const SCHEDULING_PROPERTIES: &[&str] = &["ATTENDEE", "ORGANIZER"];
|
||||
|
||||
/// The marker that keeps an event in the aggregate rather than filing it under
|
||||
/// a source. Reserved: an endpoint may not be given this id.
|
||||
pub const LOCAL_SINK: &str = "local";
|
||||
|
||||
/// Whether a routing hint asks for the event to stay where it is.
|
||||
pub fn is_local_sink(hint: &str) -> bool {
|
||||
hint.eq_ignore_ascii_case(LOCAL_SINK)
|
||||
}
|
||||
|
||||
/// Builds the aggregate copy of a source event.
|
||||
pub fn to_aggregate(
|
||||
source: &Calendar,
|
||||
|
|
@ -52,6 +61,24 @@ pub fn to_aggregate(
|
|||
mirrored
|
||||
}
|
||||
|
||||
/// Applies a target's scheduling rule to an event that is not a mirror.
|
||||
///
|
||||
/// An event living only in the aggregate still has to obey the rule that writes
|
||||
/// to an aggregate never emit scheduling mail — moving one between calendars is
|
||||
/// as capable of mailing a guest list as mirroring is. It carries no provenance,
|
||||
/// having none, so this is the demotion alone.
|
||||
pub fn make_inert(
|
||||
calendar: &Calendar,
|
||||
owner: Option<&str>,
|
||||
suppression: SchedulingSuppression,
|
||||
) -> Calendar {
|
||||
let mut inert = calendar.clone();
|
||||
if suppression == SchedulingSuppression::None {
|
||||
demote_attendees(&mut inert, owner);
|
||||
}
|
||||
inert
|
||||
}
|
||||
|
||||
/// Removes the live guest list, keeping its information in inert form.
|
||||
fn demote_attendees(calendar: &mut Calendar, owner: Option<&str>) {
|
||||
let guests: Vec<String> = calendar
|
||||
|
|
|
|||
|
|
@ -83,9 +83,6 @@ pub struct Conflict {
|
|||
/// Something that could not be done, and why.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Skipped {
|
||||
/// A user created an event in the aggregate, but no sink is configured to
|
||||
/// route it to, and guessing one would put it in the wrong calendar.
|
||||
NoSink { aggregate_uid: String },
|
||||
/// An edit was made to a mirror of a read-only feed, which cannot accept it.
|
||||
ReadOnlySource {
|
||||
aggregate_uid: String,
|
||||
|
|
@ -109,6 +106,9 @@ pub struct Outcome {
|
|||
pub links: BTreeMap<String, Link>,
|
||||
pub conflicts: Vec<Conflict>,
|
||||
pub skipped: Vec<Skipped>,
|
||||
/// Events left in the aggregate rather than filed under a source. Not a
|
||||
/// failure: an aggregate is also a calendar, and these are its own events.
|
||||
pub kept_local: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
|
|
@ -140,6 +140,7 @@ pub fn reconcile(
|
|||
links: BTreeMap::new(),
|
||||
conflicts: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
kept_local: Vec::new(),
|
||||
};
|
||||
let mut live: HashSet<String> = HashSet::new();
|
||||
let mut deletions = 0usize;
|
||||
|
|
@ -360,6 +361,14 @@ fn route_new_events(
|
|||
// An event may name the source it belongs in; otherwise the aggregate's
|
||||
// configured sink takes it.
|
||||
let requested = mirror::routing_hint(&item.calendar);
|
||||
// `@local` keeps the event where it was written. Unlike every other
|
||||
// marker it is deliberately not stripped: the others have done their
|
||||
// job once the event reaches its source, while this one has to survive
|
||||
// to make the same decision on every later cycle.
|
||||
if requested.as_deref().is_some_and(mirror::is_local_sink) {
|
||||
outcome.kept_local.push(uid.clone());
|
||||
continue;
|
||||
}
|
||||
let wanted = requested.as_deref().or(policy.default_sink);
|
||||
// Case-insensitively: the name is typed into a calendar app by hand, and
|
||||
// capitalisation is not worth failing over.
|
||||
|
|
@ -372,16 +381,18 @@ fn route_new_events(
|
|||
.filter(|sink| sink.writable);
|
||||
|
||||
let Some(sink) = sink else {
|
||||
outcome.skipped.push(match requested {
|
||||
Some(requested) => Skipped::UnknownSink {
|
||||
match requested {
|
||||
Some(requested) => outcome.skipped.push(Skipped::UnknownSink {
|
||||
aggregate_uid: uid.clone(),
|
||||
requested,
|
||||
available: writable_sinks(sources),
|
||||
},
|
||||
None => Skipped::NoSink {
|
||||
aggregate_uid: uid.clone(),
|
||||
},
|
||||
});
|
||||
}),
|
||||
// Nothing says where this event should go, so it stays where it
|
||||
// was written. With no default_sink configured that is the
|
||||
// mode, not an omission — the aggregate is a calendar the user
|
||||
// can also write in directly.
|
||||
None => outcome.kept_local.push(uid.clone()),
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
|
|
@ -879,7 +890,9 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Guessing a sink would put the event in the wrong calendar, so refuse.
|
||||
/// With nowhere configured to file it, the event stays where it was
|
||||
/// written. Guessing a sink would put it in the wrong calendar, and this is
|
||||
/// how an aggregate holds events of its own.
|
||||
#[test]
|
||||
fn without_a_sink_a_user_created_event_is_left_alone() {
|
||||
let sources = map(vec![]);
|
||||
|
|
@ -887,12 +900,30 @@ mod tests {
|
|||
|
||||
let outcome = reconcile(policy(), &[view(&sources, true)], &aggregate, None).expect("ok");
|
||||
assert!(outcome.actions.is_empty());
|
||||
assert_eq!(
|
||||
outcome.skipped,
|
||||
vec![Skipped::NoSink {
|
||||
aggregate_uid: "hand-written@phone".into()
|
||||
}]
|
||||
);
|
||||
assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped);
|
||||
assert_eq!(outcome.kept_local, vec!["hand-written@phone".to_string()]);
|
||||
}
|
||||
|
||||
/// `@local` opts one event out even where a default sink would take it.
|
||||
#[test]
|
||||
fn the_local_marker_keeps_an_event_out_of_every_source() {
|
||||
let sources = map(vec![]);
|
||||
let mut tagged = item("hand-written@phone", "Dentist");
|
||||
tagged
|
||||
.calendar
|
||||
.set_property("VEVENT", "DESCRIPTION", "Bring the referral\\n\\n@local");
|
||||
tagged.hash = tagged.calendar.content_hash();
|
||||
let aggregate = map(vec![tagged]);
|
||||
|
||||
let policy = Policy {
|
||||
default_sink: Some(SRC),
|
||||
..policy()
|
||||
};
|
||||
let outcome = reconcile(policy, &[view(&sources, true)], &aggregate, None).expect("ok");
|
||||
|
||||
assert!(outcome.actions.is_empty(), "{:?}", outcome.actions);
|
||||
assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped);
|
||||
assert_eq!(outcome.kept_local, vec!["hand-written@phone".to_string()]);
|
||||
}
|
||||
|
||||
/// Our own mirror appearing inside a source must not be mirrored again.
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ pub struct Outcome {
|
|||
/// Events flushed to a sink before the move, which existed nowhere else.
|
||||
pub routed_first: usize,
|
||||
pub materialised: usize,
|
||||
/// Events belonging to the aggregate itself, moved rather than re-derived.
|
||||
pub carried: usize,
|
||||
pub purged: usize,
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +119,14 @@ pub fn retarget(
|
|||
false,
|
||||
)?;
|
||||
|
||||
let materialised = materialise(config, aggregate, new_target, state_dir, &mut state)?;
|
||||
let (materialised, carried) = materialise(
|
||||
config,
|
||||
aggregate,
|
||||
&old_target,
|
||||
new_target,
|
||||
state_dir,
|
||||
&mut state,
|
||||
)?;
|
||||
let purged = if purge_old {
|
||||
purge(aggregate_id, &old_target, state_dir, &state)?
|
||||
} else {
|
||||
|
|
@ -138,6 +147,7 @@ pub fn retarget(
|
|||
to: new_target.id.clone(),
|
||||
routed_first: settled.written_back,
|
||||
materialised,
|
||||
carried,
|
||||
purged,
|
||||
})
|
||||
}
|
||||
|
|
@ -150,10 +160,11 @@ pub fn retarget(
|
|||
fn materialise(
|
||||
config: &Config,
|
||||
aggregate: &crate::config::Aggregate,
|
||||
old_target: &Endpoint,
|
||||
new_target: &Endpoint,
|
||||
state_dir: &Path,
|
||||
state: &mut State,
|
||||
) -> Result<usize, RetargetError> {
|
||||
) -> Result<(usize, usize), RetargetError> {
|
||||
let new_dir = vdir_path(state_dir, &new_target.id);
|
||||
let existing = vdir::read(&new_dir)?;
|
||||
let suppression = new_target.kind.scheduling_suppression();
|
||||
|
|
@ -188,10 +199,30 @@ fn materialise(
|
|||
}
|
||||
written += 1;
|
||||
}
|
||||
|
||||
// Events created in the aggregate and never filed under a source exist only
|
||||
// on the old target, so there is nothing to re-derive them from: they are
|
||||
// moved rather than rebuilt. Without this they would sit on the calendar
|
||||
// being left behind while everything around them moved on.
|
||||
//
|
||||
// The new target's scheduling rule still applies. This is a write to an
|
||||
// aggregate like any other, and a guest list carried live onto a server
|
||||
// that schedules would mail everyone on it.
|
||||
let mut carried = 0;
|
||||
for (uid, item) in vdir::read(&vdir_path(state_dir, &old_target.id))? {
|
||||
if provenance::is_derived(&uid) {
|
||||
continue;
|
||||
}
|
||||
let inert = mirror::make_inert(&item.calendar, new_target.kind.owner(), suppression);
|
||||
let replaces = existing.get(&uid).map(|held| held.path.clone());
|
||||
vdir::write(&new_dir, &uid, &inert, replaces.as_deref())?;
|
||||
carried += 1;
|
||||
}
|
||||
|
||||
state
|
||||
.aggregate_mut(&aggregate.id, target_of(new_target))
|
||||
.links = updated;
|
||||
Ok(written)
|
||||
Ok((written, carried))
|
||||
}
|
||||
|
||||
/// Removes this aggregate's mirrors from the calendar it has left.
|
||||
|
|
@ -280,6 +311,53 @@ default_sink = "src"
|
|||
)
|
||||
}
|
||||
|
||||
/// An event belonging to the aggregate itself has to follow the move.
|
||||
///
|
||||
/// There is nothing to re-derive it from, so before this it stayed on the
|
||||
/// calendar being left behind while every mirrored event moved on — quietly,
|
||||
/// which is the worst way for a calendar to lose an appointment.
|
||||
#[test]
|
||||
fn an_event_belonging_to_the_aggregate_is_carried_to_the_new_target() {
|
||||
// No default_sink: events written into the aggregate stay there.
|
||||
let config: Config = toml::from_str(&CONFIG.replace(r#"default_sink = "src""#, ""))
|
||||
.expect("config should parse");
|
||||
let dir = seed(&config);
|
||||
|
||||
let old = vdir_path(dir.path(), "agg1");
|
||||
fs::write(
|
||||
old.join("dentist.ics"),
|
||||
event("hand-written@phone", "Dentist"),
|
||||
)
|
||||
.expect("write");
|
||||
settle(&config, dir.path());
|
||||
|
||||
let outcome = retarget(&config, dir.path(), "unified", "agg2", false).expect("retarget");
|
||||
|
||||
assert_eq!(outcome.carried, 1, "{outcome:?}");
|
||||
let moved = vdir::read(&vdir_path(dir.path(), "agg2")).expect("read");
|
||||
assert!(
|
||||
moved.contains_key("hand-written@phone"),
|
||||
"the aggregate's own event did not follow: {:?}",
|
||||
moved.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
moved["hand-written@phone"]
|
||||
.calendar
|
||||
.to_ics()
|
||||
.contains("Dentist"),
|
||||
"it arrived without its content"
|
||||
);
|
||||
// And it is still not a mirror of anything, so nothing was invented for it.
|
||||
let state = State::load(&dir.path().join(crate::state::FILE_NAME)).expect("state");
|
||||
assert!(
|
||||
!state
|
||||
.aggregate("unified")
|
||||
.expect("aggregate")
|
||||
.links
|
||||
.contains_key("hand-written@phone")
|
||||
);
|
||||
}
|
||||
|
||||
/// Reconciles one aggregate and records the result, without running a full
|
||||
/// cycle. A full cycle would contact Google for real, which these tests have
|
||||
/// no business doing — they are about what happens to local files.
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ fn describe_routing(out: &mut String, config: &Config, aggregate: &Aggregate) {
|
|||
None => {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" new events are left alone: no default_sink is configured"
|
||||
" new events stay here: no default_sink is configured, so the aggregate keeps its own"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -84,6 +84,11 @@ fn describe_routing(out: &mut String, config: &Config, aggregate: &Aggregate) {
|
|||
for sink in sinks {
|
||||
let _ = writeln!(out, " @{sink}");
|
||||
}
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" @{} (keep the event here, in this calendar only)",
|
||||
crate::mirror::LOCAL_SINK
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -147,12 +152,18 @@ default_sink = "gcal"
|
|||
assert!(report().contains("new events go to `gcal`"));
|
||||
}
|
||||
|
||||
/// The escape hatch has to be as findable as the destinations.
|
||||
#[test]
|
||||
fn it_names_the_marker_that_keeps_an_event_local() {
|
||||
assert!(report().contains("@local"), "{}", report());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_says_when_nothing_would_be_routed() {
|
||||
let sinkless: Config =
|
||||
toml::from_str(&CONFIG.replace(r#"default_sink = "gcal""#, "")).expect("config");
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let report = render(&sinkless, dir.path()).expect("render");
|
||||
assert!(report.contains("no default_sink is configured"), "{report}");
|
||||
assert!(report.contains("the aggregate keeps its own"), "{report}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ pub struct AggregateReport {
|
|||
pub deleted_from_sources: usize,
|
||||
pub conflicts: Vec<Conflict>,
|
||||
pub skipped: Vec<Skipped>,
|
||||
pub kept_local: usize,
|
||||
}
|
||||
|
||||
/// Runs the local half of a cycle: reconcile every aggregate and apply the result.
|
||||
|
|
@ -564,6 +565,7 @@ pub(crate) fn sync_aggregate(
|
|||
deleted_from_sources: 0,
|
||||
conflicts: outcome.conflicts,
|
||||
skipped: outcome.skipped,
|
||||
kept_local: outcome.kept_local.len(),
|
||||
};
|
||||
|
||||
for action in &outcome.actions {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ const HAND_WRITTEN: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//phone//E
|
|||
DTSTART:20260911T140000Z\r\nDTEND:20260911T150000Z\r\nSUMMARY:Dentist\r\n\
|
||||
END:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
|
||||
/// The same event, asking to be left where it was written.
|
||||
const KEPT_LOCAL: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//phone//EN\r\n\
|
||||
BEGIN:VEVENT\r\nUID:kept@phone\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
DTSTART:20260912T140000Z\r\nDTEND:20260912T150000Z\r\nSUMMARY:Haircut\r\n\
|
||||
DESCRIPTION:Around the corner\\n\\n@local\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
|
||||
const HOLIDAY_FEED: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//feed//EN\r\n\
|
||||
BEGIN:VEVENT\r\nUID:newyear@feed\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\n\
|
||||
|
|
@ -329,3 +335,51 @@ fn an_event_created_in_a_sinkless_aggregate_stays_where_it_is() {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `@local` opts one event out of a default sink that would otherwise take it.
|
||||
///
|
||||
/// The marker has to survive, unlike every other one: it is stripped markers
|
||||
/// that have done their job on arrival, whereas this event never leaves, so the
|
||||
/// next cycle has to be able to reach the same decision.
|
||||
#[test]
|
||||
fn the_local_marker_keeps_an_event_out_of_a_configured_sink() {
|
||||
let Some(fixture) = fixture() else { return };
|
||||
|
||||
assert!(fixture.calcalist.run(&["sync"]).succeeded());
|
||||
fixture
|
||||
.server
|
||||
.put_event("unified", "haircut.ics", KEPT_LOCAL);
|
||||
|
||||
for cycle in 1..=3 {
|
||||
let run = fixture.calcalist.run(&["sync"]);
|
||||
assert!(
|
||||
run.succeeded(),
|
||||
"cycle {cycle} failed\n{}\n{}",
|
||||
run.stdout,
|
||||
run.stderr
|
||||
);
|
||||
assert!(
|
||||
run.stdout.contains("1 event(s) kept local"),
|
||||
"cycle {cycle} should report it as kept, not skipped:\n{}",
|
||||
run.stdout
|
||||
);
|
||||
|
||||
let kept = fixture
|
||||
.server
|
||||
.stored("unified")
|
||||
.into_iter()
|
||||
.find(|item| item.contains("Haircut"))
|
||||
.expect("the event should still be in the aggregate");
|
||||
// Stripping it would let the default sink claim the event next cycle.
|
||||
assert!(kept.contains("@local"), "cycle {cycle}: {kept}");
|
||||
|
||||
assert!(
|
||||
!fixture
|
||||
.server
|
||||
.stored("work")
|
||||
.iter()
|
||||
.any(|item| item.contains("Haircut")),
|
||||
"cycle {cycle}: it reached the default sink anyway"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue