Skip to main content

ocpi_tariffs/lint/
fix.rs

1//! Resolving a tariff's fixable warnings, repeating until a pass proposes nothing.
2//!
3//! One pass cannot resolve everything, because a fix can be what raises the next warning, or
4//! what allows the next fix to be proposed at all:
5//!
6//! - An `IncorrectCase` day rewrites to the spec's spelling, and only then can
7//!   [`lint::tariff::Warning::DayOfWeekUnsorted`] be fixed: the sort is incorrect while any day
8//!   is miscased, since it would re-case the surviving days as well as reorder them.
9//! - Removing the last restriction from a `restrictions` object leaves an object that restricts
10//!   nothing, which the linter reports on the next walk.
11//! - A time bound covering the whole day is two fields, and removing one with a warning raises
12//!   the same warning on the other.
13//!
14//! Each pass is therefore a whole walk: parse the source, build the tariff, lint it, and turn
15//! both warning sets into [`fix::Edit`]s. [`fix::apply`] resolves each pass's edits against one
16//! another, so the passes exist for edits that could not have been proposed together, not to
17//! apply known edits one at a time.
18//!
19//! Use [`tariff::fix`](crate::tariff::fix) to fix a tariff.
20
21#[cfg(test)]
22mod test_passes;
23
24use crate::{fix, json, lint, schema, string, warning, Versioned as _};
25
26/// The most passes made over one document before we decide to stop trying to fix issues.
27///
28/// A document that is still proposing edits after this many passes is a bug in a [`fix::Fixable`]
29/// implementation, not a document that needs more passes. The fixes that need a second pass need
30/// one more, and no fix proposes an edit for a fault it has already resolved. This bound prevents
31/// any infinite or overly long spinning through the lint/fix loop.
32const MAX_PASSES: usize = 8;
33
34/// Resolve the fixable warnings of `tariff`, repeating until a pass proposes no edit.
35///
36/// Both the schema warnings and the lint warnings are fixed. The schema warnings are recomputed
37/// rather than taken from the caller, so that every pass reads the document it is about to edit.
38///
39/// The version is the one the `tariff` was built against and every pass reuses it, rather than
40/// inferring a version per pass. An inference reads the fields that are present, and removing a
41/// field is one of the edits, so re-inferring would let a fix carry the document into another
42/// OCPI version.
43pub(crate) fn tariff(
44    tariff: &crate::tariff::Versioned<'_>,
45    unexpected: UnexpectedFields,
46) -> Result<Fixed, fix::Error> {
47    fix_until_no_edits(
48        tariff.as_json_str(),
49        tariff.version(),
50        unexpected,
51        MAX_PASSES,
52    )
53}
54
55/// What fixing a tariff produced.
56#[derive(Debug)]
57pub struct Fixed {
58    /// The fixed tariff's JSON, which is the original source when no edit was made.
59    pub source: String,
60
61    /// How many edits were applied in total across all passes.
62    pub edits: usize,
63
64    /// How many passes made an edit.
65    pub passes: usize,
66
67    /// Whether the lint/fix loop has no more warnings to fix or it has hit the loop limit.
68    pub outcome: Outcome,
69}
70
71/// Why the fixing stopped.
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub enum Outcome {
74    /// The previous pass proposed no edits, so the tariff has no fixable warnings.
75    AllFixesApplied,
76
77    /// The lint/fix loop limit was reached while edits were still being proposed.
78    /// The tariff is the output of the last pass that ran.
79    ///
80    /// Reaching this is a bug in a [`fix::Fixable`] implementation. It is reported rather than
81    /// raised as an error because the passes that did run resolved real warnings, and a caller
82    /// that asked for a fix is better served by those than by nothing.
83    FixLimit,
84}
85
86/// Whether the fields the OCPI schema does not define are removed.
87///
88/// A tariff's version can be inferred from which fields are present, so removing a field can
89/// change what a later inference concludes.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum UnexpectedFields {
92    /// Remove unexpected fields.
93    Remove,
94
95    /// Leave unexpected fields in place.
96    Keep,
97}
98
99/// Fix `source` until a pass proposes no edit, or until `max_passes` have made one.
100fn fix_until_no_edits(
101    source: &str,
102    version: crate::Version,
103    unexpected: UnexpectedFields,
104    max_passes: usize,
105) -> Result<Fixed, fix::Error> {
106    let mut source = source.to_owned();
107    let mut edits: usize = 0;
108    let mut passes: usize = 0;
109
110    let outcome = loop {
111        let len = string::ReasonableLen::new(&source).map_err(|_e| fix::Error::OutputTooLarge)?;
112        let doc = json::parse(len).map_err(fix::Error::Internal)?;
113        let (built, schema_warnings) = crate::tariff::from_json(doc, version).into_parts();
114        let edits_this_pass = pass_edits(&built, schema_warnings, unexpected)?;
115
116        if edits_this_pass.is_empty() {
117            break Outcome::AllFixesApplied;
118        }
119
120        if passes == max_passes {
121            break Outcome::FixLimit;
122        }
123
124        let next = fix::apply(built.as_doc(), &edits_this_pass)?;
125
126        edits = edits.saturating_add(edits_this_pass.len());
127        passes = passes.saturating_add(1);
128        source = next;
129    };
130
131    Ok(Fixed {
132        source,
133        edits,
134        passes,
135        outcome,
136    })
137}
138
139/// The edits resulting from one pass over `tariff`.
140///
141/// These edits come from both the schema warnings and the lint warnings.
142fn pass_edits(
143    tariff: &crate::tariff::Versioned<'_>,
144    mut schema_warnings: warning::Set<schema::Warning>,
145    unexpected: UnexpectedFields,
146) -> Result<Vec<fix::Edit>, fix::Error> {
147    if unexpected == UnexpectedFields::Keep {
148        schema_warnings.remove_unexpected_fields();
149    }
150
151    let lint::tariff::Report {
152        warnings: lint_warnings,
153    } = lint::tariff(tariff);
154
155    let doc = tariff.as_doc();
156    let mut edits = fix::edits(doc, &schema_warnings)?;
157    let lint_edits = fix::edits(doc, &lint_warnings)?;
158
159    edits.extend(lint_edits);
160
161    Ok(edits)
162}