ocpi-tariffs 0.53.0

OCPI tariff calculations
Documentation
//! Resolving a tariff's fixable warnings, repeating until a pass proposes nothing.
//!
//! One pass cannot resolve everything, because a fix can be what raises the next warning, or
//! what allows the next fix to be proposed at all:
//!
//! - An `IncorrectCase` day rewrites to the spec's spelling, and only then can
//!   [`lint::tariff::Warning::DayOfWeekUnsorted`] be fixed: the sort is incorrect while any day
//!   is miscased, since it would re-case the surviving days as well as reorder them.
//! - Removing the last restriction from a `restrictions` object leaves an object that restricts
//!   nothing, which the linter reports on the next walk.
//! - A time bound covering the whole day is two fields, and removing one with a warning raises
//!   the same warning on the other.
//!
//! Each pass is therefore a whole walk: parse the source, build the tariff, lint it, and turn
//! both warning sets into [`fix::Edit`]s. [`fix::apply`] resolves each pass's edits against one
//! another, so the passes exist for edits that could not have been proposed together, not to
//! apply known edits one at a time.
//!
//! Use [`tariff::fix`](crate::tariff::fix) to fix a tariff.

#[cfg(test)]
mod test_passes;

use crate::{fix, json, lint, schema, string, warning, Versioned as _};

/// The most passes made over one document before we decide to stop trying to fix issues.
///
/// A document that is still proposing edits after this many passes is a bug in a [`fix::Fixable`]
/// implementation, not a document that needs more passes. The fixes that need a second pass need
/// one more, and no fix proposes an edit for a fault it has already resolved. This bound prevents
/// any infinite or overly long spinning through the lint/fix loop.
const MAX_PASSES: usize = 8;

/// Resolve the fixable warnings of `tariff`, repeating until a pass proposes no edit.
///
/// Both the schema warnings and the lint warnings are fixed. The schema warnings are recomputed
/// rather than taken from the caller, so that every pass reads the document it is about to edit.
///
/// The version is the one the `tariff` was built against and every pass reuses it, rather than
/// inferring a version per pass. An inference reads the fields that are present, and removing a
/// field is one of the edits, so re-inferring would let a fix carry the document into another
/// OCPI version.
pub(crate) fn tariff(
    tariff: &crate::tariff::Versioned<'_>,
    unexpected: UnexpectedFields,
) -> Result<Fixed, fix::Error> {
    fix_until_no_edits(
        tariff.as_json_str(),
        tariff.version(),
        unexpected,
        MAX_PASSES,
    )
}

/// What fixing a tariff produced.
#[derive(Debug)]
pub struct Fixed {
    /// The fixed tariff's JSON, which is the original source when no edit was made.
    pub source: String,

    /// How many edits were applied in total across all passes.
    pub edits: usize,

    /// How many passes made an edit.
    pub passes: usize,

    /// Whether the lint/fix loop has no more warnings to fix or it has hit the loop limit.
    pub outcome: Outcome,
}

/// Why the fixing stopped.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Outcome {
    /// The previous pass proposed no edits, so the tariff has no fixable warnings.
    AllFixesApplied,

    /// The lint/fix loop limit was reached while edits were still being proposed.
    /// The tariff is the output of the last pass that ran.
    ///
    /// Reaching this is a bug in a [`fix::Fixable`] implementation. It is reported rather than
    /// raised as an error because the passes that did run resolved real warnings, and a caller
    /// that asked for a fix is better served by those than by nothing.
    FixLimit,
}

/// Whether the fields the OCPI schema does not define are removed.
///
/// A tariff's version can be inferred from which fields are present, so removing a field can
/// change what a later inference concludes.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum UnexpectedFields {
    /// Remove unexpected fields.
    Remove,

    /// Leave unexpected fields in place.
    Keep,
}

/// Fix `source` until a pass proposes no edit, or until `max_passes` have made one.
fn fix_until_no_edits(
    source: &str,
    version: crate::Version,
    unexpected: UnexpectedFields,
    max_passes: usize,
) -> Result<Fixed, fix::Error> {
    let mut source = source.to_owned();
    let mut edits: usize = 0;
    let mut passes: usize = 0;

    let outcome = loop {
        let len = string::ReasonableLen::new(&source).map_err(|_e| fix::Error::OutputTooLarge)?;
        let doc = json::parse(len).map_err(fix::Error::Internal)?;
        let (built, schema_warnings) = crate::tariff::from_json(doc, version).into_parts();
        let edits_this_pass = pass_edits(&built, schema_warnings, unexpected)?;

        if edits_this_pass.is_empty() {
            break Outcome::AllFixesApplied;
        }

        if passes == max_passes {
            break Outcome::FixLimit;
        }

        let next = fix::apply(built.as_doc(), &edits_this_pass)?;

        edits = edits.saturating_add(edits_this_pass.len());
        passes = passes.saturating_add(1);
        source = next;
    };

    Ok(Fixed {
        source,
        edits,
        passes,
        outcome,
    })
}

/// The edits resulting from one pass over `tariff`.
///
/// These edits come from both the schema warnings and the lint warnings.
fn pass_edits(
    tariff: &crate::tariff::Versioned<'_>,
    mut schema_warnings: warning::Set<schema::Warning>,
    unexpected: UnexpectedFields,
) -> Result<Vec<fix::Edit>, fix::Error> {
    if unexpected == UnexpectedFields::Keep {
        schema_warnings.remove_unexpected_fields();
    }

    let lint::tariff::Report {
        warnings: lint_warnings,
    } = lint::tariff(tariff);

    let doc = tariff.as_doc();
    let mut edits = fix::edits(doc, &schema_warnings)?;
    let lint_edits = fix::edits(doc, &lint_warnings)?;

    edits.extend(lint_edits);

    Ok(edits)
}