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}