Skip to main content

resopt/
batch.rs

1//! Policy-driven application of many reviewed candidates.
2//!
3//! Every file is applied as its own journaled transaction, so a batch can be
4//! cancelled or interrupted at any point: finished files stay individually
5//! restorable and unfinished ones are untouched.
6use crate::{
7    ImageCandidate, ResourceAnalysis,
8    analysis::WARNINGS,
9    filesystem::hash,
10    review::{Approvals, Review},
11};
12use anyhow::{Result, ensure};
13use serde::{Deserialize, Serialize};
14use std::{
15    path::PathBuf,
16    sync::{
17        Mutex,
18        atomic::{AtomicBool, Ordering},
19    },
20};
21
22/// What a batch may do. The defaults apply only verified lossless,
23/// same-format candidates.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(default, deny_unknown_fields)]
26pub struct BatchPolicy {
27    pub lossless: bool,
28    pub lossy: bool,
29    /// Allow conversions that change the file format (and migrate references).
30    pub cross_format: bool,
31    /// Extra floor for lossy candidates, on top of the analysis threshold.
32    pub min_score: Option<f64>,
33    /// Target formats to allow; empty allows every format.
34    pub formats: Vec<String>,
35    /// Warning kinds accepted for the whole batch. Empty skips warning candidates.
36    pub accept_warnings: Vec<String>,
37    /// Restrict the batch to these resource indexes (e.g. the filtered view).
38    pub resources: Option<Vec<usize>>,
39}
40
41impl Default for BatchPolicy {
42    fn default() -> Self {
43        Self {
44            lossless: true,
45            lossy: false,
46            cross_format: false,
47            min_score: None,
48            formats: vec![],
49            accept_warnings: vec![],
50            resources: None,
51        }
52    }
53}
54
55impl BatchPolicy {
56    pub fn validate(&self) -> Result<()> {
57        ensure!(
58            self.lossless || self.lossy,
59            "batch policy selects neither lossless nor lossy candidates"
60        );
61        ensure!(
62            self.min_score
63                .is_none_or(|s| s.is_finite() && (0.0..=100.0).contains(&s)),
64            "min_score must be 0..=100"
65        );
66        for warning in &self.accept_warnings {
67            ensure!(
68                WARNINGS.contains(&warning.as_str()),
69                "unknown warning kind: {warning}"
70            );
71        }
72        ensure!(
73            self.accept_warnings.is_empty() || self.lossy,
74            "warning candidates are lossy; enable lossy candidates to accept warnings"
75        );
76        Ok(())
77    }
78
79    fn approvals(&self) -> Approvals {
80        Approvals {
81            lossy: self.lossy,
82            warnings: self.accept_warnings.clone(),
83        }
84    }
85
86    fn allows(&self, resource: &ResourceAnalysis, candidate: &ImageCandidate) -> bool {
87        let accepted_warning = candidate.is_warning()
88            && candidate
89                .required_warnings()
90                .iter()
91                .all(|w| self.accept_warnings.contains(w));
92        let crossing =
93            candidate.format != resource.resource.format || resource.resource.extension_mismatch;
94        candidate.artifact.is_some()
95            && (candidate.valid || accepted_warning)
96            && (if candidate.lossy {
97                self.lossy
98            } else {
99                self.lossless
100            })
101            && (self.cross_format || !crossing)
102            && (!crossing || resource.resource.format_lock.is_none())
103            // Asset files are opened by path, and paths are often built at
104            // runtime. Renaming them is a per-file decision with its own
105            // reference preview, never a batch action.
106            && !(crossing
107                && resource
108                    .resource
109                    .android
110                    .as_ref()
111                    .is_some_and(|a| a.area == "assets"))
112            && (self.formats.is_empty() || self.formats.contains(&candidate.format))
113            && (!candidate.lossy
114                || self.min_score.is_none_or(|floor| {
115                    candidate
116                        .difference
117                        .as_ref()
118                        .and_then(|d| d.ssimulacra2)
119                        .is_some_and(|score| score >= floor)
120                }))
121    }
122}
123
124#[derive(Debug, Clone, Serialize)]
125pub struct BatchItem {
126    pub resource: usize,
127    pub candidate: usize,
128    pub path: PathBuf,
129    pub format: String,
130    pub quality: Option<u8>,
131    pub lossy: bool,
132    pub warning: Option<String>,
133    pub original_bytes: u64,
134    pub savings_bytes: u64,
135}
136
137#[derive(Debug, Clone, Serialize)]
138pub struct BatchPlan {
139    pub policy: BatchPolicy,
140    pub items: Vec<BatchItem>,
141    pub savings_bytes: u64,
142    pub lossy_items: usize,
143    pub warning_items: usize,
144    pub cross_format_items: usize,
145    /// Must accompany the apply request so the confirmed plan is the one run.
146    pub token: String,
147}
148
149/// Choose, per resource, the smallest candidate the policy allows. Resources
150/// with an existing operation are left alone.
151pub(crate) fn plan(review: &Review, policy: &BatchPolicy) -> Result<BatchPlan> {
152    policy.validate()?;
153    let states = review.states();
154    let mut items = Vec::new();
155    for (index, resource) in review.report.resources.iter().enumerate() {
156        if policy
157            .resources
158            .as_ref()
159            .is_some_and(|r| !r.contains(&index))
160            || resource.status != "candidates_available"
161            || resource.resource.conversion_exclusion.is_some()
162            || states
163                .get(index.to_string())
164                .is_some_and(|s| s["state"] != "original")
165        {
166            continue;
167        }
168        let Some((candidate_index, candidate)) = resource
169            .candidates
170            .iter()
171            .enumerate()
172            .filter(|(_, c)| policy.allows(resource, c))
173            .min_by_key(|(_, c)| c.bytes)
174        else {
175            continue;
176        };
177        items.push(BatchItem {
178            resource: index,
179            candidate: candidate_index,
180            path: resource.resource.path.clone(),
181            format: candidate.format.clone(),
182            quality: candidate.quality,
183            lossy: candidate.lossy,
184            warning: candidate
185                .rejection
186                .clone()
187                .filter(|_| candidate.is_warning()),
188            original_bytes: resource.resource.bytes,
189            savings_bytes: candidate.savings_bytes,
190        });
191    }
192    // Largest savings first: a cancelled batch has already done the most useful work.
193    items.sort_by(|a, b| {
194        b.savings_bytes
195            .cmp(&a.savings_bytes)
196            .then(a.resource.cmp(&b.resource))
197    });
198    let selection: Vec<_> = items.iter().map(|i| (i.resource, i.candidate)).collect();
199    let token = hash(&serde_json::to_vec(&(policy, &selection))?);
200    Ok(BatchPlan {
201        policy: policy.clone(),
202        savings_bytes: items.iter().map(|i| i.savings_bytes).sum(),
203        lossy_items: items.iter().filter(|i| i.lossy).count(),
204        warning_items: items.iter().filter(|i| i.warning.is_some()).count(),
205        cross_format_items: items
206            .iter()
207            .filter(|i| {
208                let r = &review.report.resources[i.resource].resource;
209                i.format != r.format || r.extension_mismatch
210            })
211            .count(),
212        items,
213        token,
214    })
215}
216
217#[derive(Debug, Clone, Serialize)]
218pub struct BatchOutcome {
219    pub resource: usize,
220    pub path: PathBuf,
221    /// `applied`, `failed` or `cancelled`.
222    pub outcome: &'static str,
223    pub error: Option<String>,
224    pub savings_bytes: u64,
225}
226
227#[derive(Debug, Clone, Default, Serialize)]
228pub struct BatchStatus {
229    pub running: bool,
230    pub cancelled: bool,
231    pub total: usize,
232    pub outcomes: Vec<BatchOutcome>,
233    pub applied: usize,
234    pub failed: usize,
235    pub savings_bytes: u64,
236}
237
238/// Run a confirmed plan. `status` is updated after every file so callers can
239/// stream per-file outcomes; `cancel` stops before the next file.
240pub(crate) fn run(
241    review: &Review,
242    plan: &BatchPlan,
243    status: &Mutex<BatchStatus>,
244    cancel: &AtomicBool,
245) {
246    {
247        let mut status = status.lock().unwrap_or_else(|e| e.into_inner());
248        *status = BatchStatus {
249            running: true,
250            total: plan.items.len(),
251            ..Default::default()
252        };
253    }
254    let approvals = plan.policy.approvals();
255    for item in &plan.items {
256        let (outcome, error) = if cancel.load(Ordering::SeqCst) {
257            ("cancelled", None)
258        } else {
259            match review.apply_with_warnings(item.resource, item.candidate, &approvals, None, false)
260            {
261                Ok(()) => ("applied", None),
262                Err(error) => ("failed", Some(format!("{error:#}"))),
263            }
264        };
265        let mut status = status.lock().unwrap_or_else(|e| e.into_inner());
266        match outcome {
267            "applied" => {
268                status.applied += 1;
269                status.savings_bytes += item.savings_bytes;
270            }
271            "failed" => status.failed += 1,
272            _ => status.cancelled = true,
273        }
274        status.outcomes.push(BatchOutcome {
275            resource: item.resource,
276            path: item.path.clone(),
277            outcome,
278            error,
279            savings_bytes: if outcome == "applied" {
280                item.savings_bytes
281            } else {
282                0
283            },
284        });
285    }
286    status.lock().unwrap_or_else(|e| e.into_inner()).running = false;
287}
288
289/// Restore applied or partially applied operations, optionally limited to a
290/// caller-selected set of resource indexes. Operations sharing a file (one
291/// Contents.json, one reference file) must be undone newest-first; repeated
292/// passes find that order without trusting recorded timestamps.
293pub(crate) fn restore_many(
294    review: &Review,
295    resources: Option<&[usize]>,
296    cancel: &AtomicBool,
297) -> BatchStatus {
298    let selected = resources.map(|indexes| {
299        indexes
300            .iter()
301            .copied()
302            .collect::<std::collections::BTreeSet<_>>()
303    });
304    let mut pending: Vec<usize> = review
305        .states()
306        .as_object()
307        .into_iter()
308        .flatten()
309        .filter_map(|(index, state)| {
310            let index = index.parse().ok()?;
311            (state["state"] != "original"
312                && selected
313                    .as_ref()
314                    .is_none_or(|indexes| indexes.contains(&index)))
315            .then_some(index)
316        })
317        .collect();
318    pending.sort_unstable();
319    let mut status = BatchStatus {
320        total: pending.len(),
321        ..Default::default()
322    };
323    let mut last_errors = std::collections::BTreeMap::new();
324    loop {
325        let before = pending.len();
326        pending.retain(|&index| {
327            if cancel.load(Ordering::SeqCst) {
328                return true;
329            }
330            match review.restore(index) {
331                Ok(()) => {
332                    status.applied += 1;
333                    status.outcomes.push(BatchOutcome {
334                        resource: index,
335                        path: review.report.resources[index].resource.path.clone(),
336                        outcome: "applied",
337                        error: None,
338                        savings_bytes: 0,
339                    });
340                    false
341                }
342                Err(error) => {
343                    last_errors.insert(index, format!("{error:#}"));
344                    true
345                }
346            }
347        });
348        if pending.is_empty() || pending.len() == before || cancel.load(Ordering::SeqCst) {
349            break;
350        }
351    }
352    status.cancelled = cancel.load(Ordering::SeqCst);
353    for index in pending {
354        status.failed += 1;
355        status.outcomes.push(BatchOutcome {
356            resource: index,
357            path: review.report.resources[index].resource.path.clone(),
358            outcome: if status.cancelled {
359                "cancelled"
360            } else {
361                "failed"
362            },
363            error: last_errors.remove(&index),
364            savings_bytes: 0,
365        });
366    }
367    status
368}
369
370pub(crate) fn restore_all(review: &Review, cancel: &AtomicBool) -> BatchStatus {
371    restore_many(review, None, cancel)
372}
373
374/// Plan a batch for the report in `directory` without changing anything.
375pub fn plan_report(
376    directory: impl AsRef<std::path::Path>,
377    policy: &BatchPolicy,
378) -> Result<BatchPlan> {
379    plan(&Review::open(directory.as_ref())?, policy)
380}
381
382/// Apply every candidate the policy allows and return per-file outcomes.
383/// Interrupting the process is safe: each file is a separate journaled
384/// operation that `restore_report` (or the web UI) can undo.
385pub fn apply_report(
386    directory: impl AsRef<std::path::Path>,
387    policy: &BatchPolicy,
388) -> Result<BatchStatus> {
389    let review = Review::open(directory.as_ref())?;
390    let plan = plan(&review, policy)?;
391    let status = Mutex::new(BatchStatus::default());
392    run(&review, &plan, &status, &AtomicBool::new(false));
393    Ok(status.into_inner().unwrap_or_else(|e| e.into_inner()))
394}
395
396/// Restore every operation recorded in the report directory.
397pub fn restore_report(directory: impl AsRef<std::path::Path>) -> Result<BatchStatus> {
398    let review = Review::open(directory.as_ref())?;
399    Ok(restore_all(&review, &AtomicBool::new(false)))
400}