diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripShapeIdConditionalValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripShapeIdConditionalValidator.java
new file mode 100644
index 0000000000..363f3c385e
--- /dev/null
+++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripShapeIdConditionalValidator.java
@@ -0,0 +1,109 @@
+package org.mobilitydata.gtfsvalidator.validator;
+
+import java.util.List;
+import java.util.Optional;
+import javax.inject.Inject;
+import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator;
+import org.mobilitydata.gtfsvalidator.notice.MissingRequiredFieldNotice;
+import org.mobilitydata.gtfsvalidator.notice.NoticeContainer;
+import org.mobilitydata.gtfsvalidator.table.GtfsContinuousPickupDropOff;
+import org.mobilitydata.gtfsvalidator.table.GtfsRoute;
+import org.mobilitydata.gtfsvalidator.table.GtfsRouteTableContainer;
+import org.mobilitydata.gtfsvalidator.table.GtfsStopTime;
+import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeTableContainer;
+import org.mobilitydata.gtfsvalidator.table.GtfsTrip;
+import org.mobilitydata.gtfsvalidator.table.GtfsTripTableContainer;
+
+/**
+ * Validates that {@code trips.shape_id} is defined when the trip has continuous pickup or drop-off
+ * behavior.
+ *
+ *
The spec makes {@code trips.shape_id} conditionally required: it is required if the trip has a
+ * continuous pickup or drop-off behavior defined either in {@code routes.txt} or in {@code
+ * stop_times.txt}, and optional otherwise.
+ *
+ *
A value of {@code 1}, like an empty value, means no continuous stopping behavior, so only
+ * {@code 0}, {@code 2} and {@code 3} make {@code shape_id} required. Values in {@code
+ * stop_times.txt} override those in {@code routes.txt}.
+ *
+ *
Generated notice: {@link MissingRequiredFieldNotice}.
+ */
+@GtfsValidator
+public class TripShapeIdConditionalValidator extends FileValidator {
+ private final GtfsRouteTableContainer routeTable;
+ private final GtfsTripTableContainer tripTable;
+ private final GtfsStopTimeTableContainer stopTimeTable;
+
+ @Inject
+ TripShapeIdConditionalValidator(
+ GtfsRouteTableContainer routeTable,
+ GtfsTripTableContainer tripTable,
+ GtfsStopTimeTableContainer stopTimeTable) {
+ this.routeTable = routeTable;
+ this.tripTable = tripTable;
+ this.stopTimeTable = stopTimeTable;
+ }
+
+ @Override
+ public void validate(NoticeContainer noticeContainer) {
+ for (GtfsTrip trip : tripTable.getEntities()) {
+ if (trip.hasShapeId()) {
+ continue;
+ }
+ if (hasContinuousBehavior(trip)) {
+ noticeContainer.addValidationNotice(
+ new MissingRequiredFieldNotice(
+ GtfsTrip.FILENAME, trip.csvRowNumber(), GtfsTrip.SHAPE_ID_FIELD_NAME));
+ }
+ }
+ }
+
+ /**
+ * Returns true if any stop time of this trip has continuous pickup or drop-off behavior, falling
+ * back to the values on the route where the stop time does not set them.
+ */
+ private boolean hasContinuousBehavior(GtfsTrip trip) {
+ Optional route = routeTable.byRouteId(trip.routeId());
+ GtfsContinuousPickupDropOff routePickup =
+ route.map(GtfsRoute::continuousPickup).orElse(GtfsContinuousPickupDropOff.NOT_AVAILABLE);
+ GtfsContinuousPickupDropOff routeDropOff =
+ route.map(GtfsRoute::continuousDropOff).orElse(GtfsContinuousPickupDropOff.NOT_AVAILABLE);
+
+ List stopTimes = stopTimeTable.byTripId(trip.tripId());
+ if (stopTimes.isEmpty()) {
+ return isContinuous(routePickup) || isContinuous(routeDropOff);
+ }
+ for (GtfsStopTime stopTime : stopTimes) {
+ GtfsContinuousPickupDropOff pickup =
+ stopTime.hasContinuousPickup() ? stopTime.continuousPickup() : routePickup;
+ GtfsContinuousPickupDropOff dropOff =
+ stopTime.hasContinuousDropOff() ? stopTime.continuousDropOff() : routeDropOff;
+ if (isContinuous(pickup) || isContinuous(dropOff)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isContinuous(GtfsContinuousPickupDropOff value) {
+ return value == GtfsContinuousPickupDropOff.ALLOWED
+ || value == GtfsContinuousPickupDropOff.MUST_PHONE
+ || value == GtfsContinuousPickupDropOff.ON_REQUEST_TO_DRIVER;
+ }
+
+ @Override
+ public boolean shouldCallValidate() {
+ if (tripTable == null) {
+ return false;
+ }
+ boolean routeDefinesContinuous =
+ routeTable != null
+ && (routeTable.hasColumn(GtfsRoute.CONTINUOUS_PICKUP_FIELD_NAME)
+ || routeTable.hasColumn(GtfsRoute.CONTINUOUS_DROP_OFF_FIELD_NAME));
+ boolean stopTimeDefinesContinuous =
+ stopTimeTable != null
+ && (stopTimeTable.hasColumn(GtfsStopTime.CONTINUOUS_PICKUP_FIELD_NAME)
+ || stopTimeTable.hasColumn(GtfsStopTime.CONTINUOUS_DROP_OFF_FIELD_NAME));
+ return routeDefinesContinuous || stopTimeDefinesContinuous;
+ }
+}
diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripShapeIdConditionalValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripShapeIdConditionalValidatorTest.java
new file mode 100644
index 0000000000..09e897c5f9
--- /dev/null
+++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripShapeIdConditionalValidatorTest.java
@@ -0,0 +1,143 @@
+package org.mobilitydata.gtfsvalidator.validator;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mobilitydata.gtfsvalidator.notice.MissingRequiredFieldNotice;
+import org.mobilitydata.gtfsvalidator.notice.NoticeContainer;
+import org.mobilitydata.gtfsvalidator.notice.ValidationNotice;
+import org.mobilitydata.gtfsvalidator.table.GtfsRoute;
+import org.mobilitydata.gtfsvalidator.table.GtfsRouteTableContainer;
+import org.mobilitydata.gtfsvalidator.table.GtfsStopTime;
+import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeTableContainer;
+import org.mobilitydata.gtfsvalidator.table.GtfsTrip;
+import org.mobilitydata.gtfsvalidator.table.GtfsTripTableContainer;
+
+@RunWith(JUnit4.class)
+public class TripShapeIdConditionalValidatorTest {
+
+ private static List generateNotices(
+ List routes, List trips, List stopTimes) {
+ NoticeContainer noticeContainer = new NoticeContainer();
+ new TripShapeIdConditionalValidator(
+ GtfsRouteTableContainer.forEntities(routes, noticeContainer),
+ GtfsTripTableContainer.forEntities(trips, noticeContainer),
+ GtfsStopTimeTableContainer.forEntities(stopTimes, noticeContainer))
+ .validate(noticeContainer);
+ return noticeContainer.getValidationNotices();
+ }
+
+ private static GtfsRoute.Builder route() {
+ return new GtfsRoute.Builder().setCsvRowNumber(2).setRouteId("route1");
+ }
+
+ private static GtfsTrip.Builder trip() {
+ return new GtfsTrip.Builder().setCsvRowNumber(3).setTripId("trip1").setRouteId("route1");
+ }
+
+ private static GtfsStopTime.Builder stopTime(int csvRowNumber) {
+ return new GtfsStopTime.Builder()
+ .setCsvRowNumber(csvRowNumber)
+ .setTripId("trip1")
+ .setStopSequence(csvRowNumber);
+ }
+
+ private static MissingRequiredFieldNotice expectedNotice() {
+ return new MissingRequiredFieldNotice("trips.txt", 3, "shape_id");
+ }
+
+ @Test
+ public void continuousPickupOnRouteWithoutShapeIdShouldGenerateNotice() {
+ List notices =
+ generateNotices(
+ List.of(route().setContinuousPickup(0).build()),
+ List.of(trip().build()),
+ List.of(stopTime(4).build()));
+ assertThat(notices).containsExactly(expectedNotice());
+ }
+
+ @Test
+ public void continuousDropOffOnRouteWithoutShapeIdShouldGenerateNotice() {
+ List notices =
+ generateNotices(
+ List.of(route().setContinuousDropOff(2).build()),
+ List.of(trip().build()),
+ List.of(stopTime(4).build()));
+ assertThat(notices).containsExactly(expectedNotice());
+ }
+
+ @Test
+ public void continuousPickupOnStopTimeWithoutShapeIdShouldGenerateNotice() {
+ List notices =
+ generateNotices(
+ List.of(route().build()),
+ List.of(trip().build()),
+ List.of(stopTime(4).build(), stopTime(5).setContinuousPickup(3).build()));
+ assertThat(notices).containsExactly(expectedNotice());
+ }
+
+ @Test
+ public void continuousBehaviorWithShapeIdShouldNotGenerateNotice() {
+ List notices =
+ generateNotices(
+ List.of(route().setContinuousPickup(0).build()),
+ List.of(trip().setShapeId("shape1").build()),
+ List.of(stopTime(4).build()));
+ assertThat(notices).isEmpty();
+ }
+
+ @Test
+ public void notAvailableContinuousBehaviorShouldNotGenerateNotice() {
+ List notices =
+ generateNotices(
+ List.of(route().setContinuousPickup(1).setContinuousDropOff(1).build()),
+ List.of(trip().build()),
+ List.of(stopTime(4).setContinuousPickup(1).setContinuousDropOff(1).build()));
+ assertThat(notices).isEmpty();
+ }
+
+ @Test
+ public void stopTimeShouldOverrideContinuousBehaviorOnRoute() {
+ List notices =
+ generateNotices(
+ List.of(route().setContinuousPickup(0).build()),
+ List.of(trip().build()),
+ List.of(stopTime(4).setContinuousPickup(1).build()));
+ assertThat(notices).isEmpty();
+ }
+
+ @Test
+ public void oneOverridingStopTimeWithContinuousBehaviorShouldGenerateNotice() {
+ // Every stop time overrides the route, but one of them overrides it to a continuous value, so
+ // the trip still needs a shape.
+ List notices =
+ generateNotices(
+ List.of(route().setContinuousPickup(0).build()),
+ List.of(trip().build()),
+ List.of(
+ stopTime(4).setContinuousPickup(1).build(),
+ stopTime(5).setContinuousPickup(1).build(),
+ stopTime(6).setContinuousPickup(2).build(),
+ stopTime(7).setContinuousPickup(1).build()));
+ assertThat(notices).containsExactly(expectedNotice());
+ }
+
+ @Test
+ public void noContinuousBehaviorShouldNotGenerateNotice() {
+ List notices =
+ generateNotices(
+ List.of(route().build()), List.of(trip().build()), List.of(stopTime(4).build()));
+ assertThat(notices).isEmpty();
+ }
+
+ @Test
+ public void tripWithoutStopTimesShouldStillUseRouteContinuousBehavior() {
+ List notices =
+ generateNotices(
+ List.of(route().setContinuousPickup(2).build()), List.of(trip().build()), List.of());
+ assertThat(notices).containsExactly(expectedNotice());
+ }
+}