Skip to main content

resopt/
analysis.rs

1use crate::{
2    ImageDifference, ImageInfo, Policy, Resource, ResourceInventory,
3    analyze_image::{self, Context as ImageContext},
4    cache::Cache,
5    filesystem::{contained_file, hash, write_new},
6    image_backend,
7    resources::{bounded_read, inventory_with_options},
8    timings::{Phase, Timings},
9};
10use anyhow::{Context, Result, ensure};
11use serde::{Deserialize, Serialize};
12use std::{
13    collections::{BTreeMap, HashMap},
14    fs,
15    path::{Path, PathBuf},
16    sync::{
17        Arc, Condvar, Mutex, OnceLock,
18        atomic::{AtomicBool, AtomicUsize, Ordering},
19    },
20    time::Instant,
21};
22
23/// Candidate verdicts a user may accept after reviewing the actual result.
24/// Every other rejection is a hard failure that approval cannot bypass.
25pub(crate) const WARNINGS: [&str; 3] = [
26    "alpha_error_exceeds_policy",
27    "transparency_presence_changed",
28    "quality_below_policy",
29];
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(default, deny_unknown_fields)]
33pub struct AnalysisOptions {
34    /// Encoder quality parameters for lossy candidates; not savings percentages.
35    pub qualities: Vec<u8>,
36    pub include_ignored: bool,
37    /// Parallel image workers; 0 selects the CPU count, capped at 8.
38    pub jobs: usize,
39    /// All sizes are included by default, unlike the legacy lossless plan.
40    pub min_input_bytes: u64,
41    pub min_savings_bytes: u64,
42    pub probe_only: bool,
43    /// Lossy HEIC may quantize alpha. 0 requires exact alpha samples.
44    pub max_alpha_error: f32,
45    /// Lowest SSIMULACRA2 score a lossy candidate may have and still be
46    /// recommended. Lower-scoring candidates are kept as reviewable warnings.
47    pub min_score: f64,
48    /// Largest decoded image analyzed; each pixel costs 16 bytes per decode.
49    pub max_pixels: usize,
50    /// oxipng effort for the lossless PNG candidate.
51    pub png_level: u8,
52    /// Allow lossless PNG color-type, bit-depth and palette reductions.
53    pub png_reductions: bool,
54    /// Include WebP candidates for loose files and Android resources.
55    pub webp: bool,
56    /// Overrides the `minSdk` detected from Gradle files.
57    pub android_min_sdk: Option<u32>,
58    /// Persistent result cache directory. `None` disables the cache; the CLI
59    /// passes the per-user cache directory unless `--no-cache` is given.
60    pub cache_dir: Option<PathBuf>,
61}
62impl Default for AnalysisOptions {
63    fn default() -> Self {
64        Self {
65            qualities: vec![75, 85, 95],
66            include_ignored: false,
67            jobs: 0,
68            min_input_bytes: 0,
69            min_savings_bytes: 1,
70            probe_only: false,
71            max_alpha_error: 1.0 / 255.0 + 0.000001,
72            min_score: 80.0,
73            max_pixels: image_backend::DEFAULT_MAX_PIXELS,
74            png_level: Policy::default().png_level,
75            png_reductions: false,
76            webp: false,
77            android_min_sdk: None,
78            cache_dir: None,
79        }
80    }
81}
82impl AnalysisOptions {
83    pub(crate) fn validate(&self) -> Result<()> {
84        ensure!(
85            self.max_alpha_error.is_finite() && (0.0..=1.0).contains(&self.max_alpha_error),
86            "max_alpha_error must be 0..=1"
87        );
88        ensure!(
89            self.min_score.is_finite() && (0.0..=100.0).contains(&self.min_score),
90            "min_score must be 0..=100"
91        );
92        ensure!(
93            self.jobs <= 16,
94            "jobs must be 0..=16 (0 selects automatically)"
95        );
96        ensure!(
97            self.android_min_sdk.is_none_or(|v| (1..=99).contains(&v)),
98            "android_min_sdk must be 1..=99"
99        );
100        ensure!(
101            (1..=image_backend::MAX_PIXELS_LIMIT).contains(&self.max_pixels),
102            "max_pixels must be 1..={}",
103            image_backend::MAX_PIXELS_LIMIT
104        );
105        self.png_policy().validate()?;
106        ensure!(
107            !self.qualities.is_empty()
108                && self.qualities.len() <= 8
109                && self.qualities.iter().all(|q| (1..=100).contains(q)),
110            "qualities must contain 1..=8 values in 1..=100"
111        );
112        let mut qualities = self.qualities.clone();
113        qualities.sort_unstable();
114        qualities.dedup();
115        ensure!(
116            qualities.len() == self.qualities.len(),
117            "duplicate quality values"
118        );
119        Ok(())
120    }
121
122    pub(crate) fn png_policy(&self) -> Policy {
123        Policy {
124            png_level: self.png_level,
125            reductions: self.png_reductions,
126            ..Policy::default()
127        }
128    }
129
130    pub(crate) fn worker_count(&self) -> usize {
131        if self.jobs > 0 {
132            return self.jobs;
133        }
134        std::thread::available_parallelism()
135            .map_or(2, |n| n.get())
136            .clamp(1, 8)
137    }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ImageCandidate {
142    pub format: String,
143    pub quality: Option<u8>,
144    pub lossy: bool,
145    pub bytes: u64,
146    pub savings_bytes: u64,
147    pub valid: bool,
148    pub rejection: Option<String>,
149    pub difference: Option<ImageDifference>,
150    pub artifact: Option<PathBuf>,
151    pub preview: Option<PathBuf>,
152    /// SHA-256 of the artifact, checked again before it is applied.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub sha256: Option<String>,
155    /// Every visual policy threshold this candidate misses. `rejection` holds
156    /// the first one; approval must cover all of them.
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    pub warnings: Vec<String>,
159    /// Facts the reviewer should know, e.g. metadata a conversion does not carry.
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub notes: Vec<String>,
162}
163
164impl ImageCandidate {
165    /// A structurally sound candidate that only misses a visual policy threshold.
166    pub(crate) fn is_warning(&self) -> bool {
167        !self.valid
168            && self.lossy
169            && self.artifact.is_some()
170            && self
171                .rejection
172                .as_deref()
173                .is_some_and(|r| WARNINGS.contains(&r))
174    }
175
176    /// Warning kinds that must be approved before this candidate is applied.
177    pub(crate) fn required_warnings(&self) -> Vec<String> {
178        if !self.is_warning() {
179            vec![]
180        } else if self.warnings.is_empty() {
181            // Reports written before `warnings` existed recorded one kind.
182            self.rejection.iter().cloned().collect()
183        } else {
184            self.warnings.clone()
185        }
186    }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ResourceAnalysis {
191    pub resource: Resource,
192    pub sha256: Option<String>,
193    pub image: Option<ImageInfo>,
194    /// `candidates_available`, `inspected`, `excluded`, `unsupported`, `failed`
195    /// or `not_analyzed` (analysis was cancelled first).
196    pub status: String,
197    pub issues: Vec<String>,
198    pub candidates: Vec<ImageCandidate>,
199    /// A size winner among candidates that passed every policy check.
200    pub smallest_candidate: Option<usize>,
201    pub original_preview: Option<PathBuf>,
202    pub original_artifact: Option<PathBuf>,
203    /// Codec, duration and bitrate of audio/video files, when ffprobe is installed.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub media: Option<crate::media::MediaInfo>,
206    /// Scale-invariant fingerprint used to find duplicate and resized images.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub fingerprint: Option<crate::similarity::Fingerprint>,
209}
210
211impl ResourceAnalysis {
212    pub(crate) fn new(resource: &Resource, status: &str) -> Self {
213        Self {
214            resource: resource.clone(),
215            sha256: None,
216            image: None,
217            status: status.into(),
218            issues: vec![],
219            candidates: vec![],
220            smallest_candidate: None,
221            original_preview: None,
222            original_artifact: None,
223            fingerprint: None,
224            media: None,
225        }
226    }
227
228    pub(crate) fn recommended_savings(&self) -> u64 {
229        self.smallest_candidate
230            .and_then(|i| self.candidates.get(i))
231            .filter(|c| c.valid && c.artifact.is_some())
232            .map_or(0, |c| c.savings_bytes)
233    }
234}
235
236#[derive(Debug, Serialize, Deserialize)]
237pub struct AnalysisReport {
238    pub schema_version: u32,
239    pub root: PathBuf,
240    pub backend: String,
241    pub options: AnalysisOptions,
242    pub inventory: ResourceInventory,
243    pub resources: Vec<ResourceAnalysis>,
244    pub status_counts: BTreeMap<String, usize>,
245    /// Sum of recommended candidates only; warning candidates are excluded.
246    pub potential_source_bytes_saved: u64,
247    /// Images that show the same picture (identical, resized or near-duplicate),
248    /// excluding intended variants such as `@2x`/`@3x` or density folders.
249    #[serde(default, skip_serializing_if = "Vec::is_empty")]
250    pub similar_groups: Vec<crate::similarity::SimilarGroup>,
251    /// Analysis stopped early; unfinished resources are `not_analyzed`.
252    #[serde(default)]
253    pub cancelled: bool,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub performance: Option<Performance>,
256}
257
258#[derive(Debug, Clone, Default, Serialize, Deserialize)]
259pub struct Performance {
260    pub wall_seconds: f64,
261    pub first_result_seconds: Option<f64>,
262    pub workers: usize,
263    pub cache_hits: usize,
264    pub duplicate_reuses: usize,
265    /// Seconds per phase summed over workers (CPU-side, not wall-clock).
266    pub phase_seconds: BTreeMap<String, f64>,
267}
268
269/// Cooperative cancellation shared with the caller.
270#[derive(Clone, Default)]
271pub struct AnalysisControl(Arc<AtomicBool>);
272impl AnalysisControl {
273    pub fn cancel(&self) {
274        self.0.store(true, Ordering::SeqCst);
275    }
276    pub fn is_cancelled(&self) -> bool {
277        self.0.load(Ordering::SeqCst)
278    }
279}
280
281/// Bounds decoded source pixels in flight to one maximum-size image, so adding
282/// workers speeds up ordinary assets without multiplying peak memory.
283pub(crate) struct PixelBudget {
284    capacity: usize,
285    available: Mutex<usize>,
286    released: Condvar,
287}
288pub(crate) struct PixelLease<'a>(&'a PixelBudget, usize);
289impl PixelBudget {
290    pub fn new(capacity: usize) -> Self {
291        Self {
292            capacity,
293            available: Mutex::new(capacity),
294            released: Condvar::new(),
295        }
296    }
297    pub fn acquire(&self, pixels: usize) -> PixelLease<'_> {
298        let wanted = pixels.clamp(1, self.capacity);
299        let mut available = self.available.lock().unwrap_or_else(|e| e.into_inner());
300        while *available < wanted {
301            available = self
302                .released
303                .wait(available)
304                .unwrap_or_else(|e| e.into_inner());
305        }
306        *available -= wanted;
307        PixelLease(self, wanted)
308    }
309}
310impl Drop for PixelLease<'_> {
311    fn drop(&mut self) {
312        *self.0.available.lock().unwrap_or_else(|e| e.into_inner()) += self.1;
313        self.0.released.notify_all();
314    }
315}
316
317/// Read-only analysis of all inventoried resources, with lossy candidates staged
318/// solely for review. This report is deliberately not an executable apply plan.
319pub fn analyze(
320    root: impl AsRef<Path>,
321    out: impl AsRef<Path>,
322    options: AnalysisOptions,
323) -> Result<AnalysisReport> {
324    analyze_with_progress(root, out, options, |_, _| {})
325}
326
327pub fn analyze_with_progress(
328    root: impl AsRef<Path>,
329    out: impl AsRef<Path>,
330    options: AnalysisOptions,
331    progress: impl Fn(usize, usize) + Sync,
332) -> Result<AnalysisReport> {
333    analyze_with_observer(
334        root,
335        out,
336        options,
337        &AnalysisControl::default(),
338        |_, _, done, total| progress(done, total),
339    )
340}
341
342type Shared = Arc<OnceLock<(usize, ResourceAnalysis)>>;
343
344pub(crate) fn analyze_with_observer(
345    root: impl AsRef<Path>,
346    out: impl AsRef<Path>,
347    options: AnalysisOptions,
348    control: &AnalysisControl,
349    progress: impl Fn(usize, &ResourceAnalysis, usize, usize) + Sync,
350) -> Result<AnalysisReport> {
351    let started = Instant::now();
352    options.validate()?;
353    if !options.probe_only && image_backend::image_backend_available() {
354        image_backend::check_encoders()?;
355    }
356    let timings = Timings::default();
357    let inventory = timings.time(Phase::Scan, || {
358        inventory_with_options(
359            root,
360            crate::ScanOptions {
361                include_ignored: options.include_ignored,
362            },
363        )
364    })?;
365    let out = out.as_ref();
366    let parent = fs::canonicalize(
367        out.parent()
368            .filter(|p| !p.as_os_str().is_empty())
369            .unwrap_or(Path::new(".")),
370    )?;
371    let out = parent.join(out.file_name().context("output directory has no name")?);
372    ensure!(
373        !out.starts_with(&inventory.root),
374        "analysis output must be outside the scanned project"
375    );
376    fs::create_dir(&out).context("analysis output must be a new directory")?;
377    for folder in ["candidates", "previews", "originals"] {
378        fs::create_dir(out.join(folder))?;
379    }
380    let cache = options
381        .cache_dir
382        .as_ref()
383        .filter(|_| !options.probe_only)
384        .and_then(|directory| Cache::open(directory).ok());
385    let min_sdk = options
386        .android_min_sdk
387        .or(inventory.android_min_sdk.as_ref().map(|sdk| sdk.level));
388    let workers = options.worker_count();
389    let budget = PixelBudget::new(options.max_pixels);
390    let context = ImageContext {
391        root: &inventory.root,
392        out: &out,
393        options: &options,
394        min_sdk,
395        timings: &timings,
396        control,
397        budget: &budget,
398    };
399    let total = inventory.assets.len();
400    let complete = AtomicUsize::new(0);
401    let cache_hits = AtomicUsize::new(0);
402    let duplicate_reuses = AtomicUsize::new(0);
403    let first_result = OnceLock::new();
404    let in_flight: Mutex<HashMap<String, Shared>> = Mutex::new(HashMap::new());
405    let finish = |index: usize, result: ResourceAnalysis| {
406        if result.status == "candidates_available" {
407            first_result.get_or_init(|| started.elapsed().as_secs_f64());
408        }
409        let done = complete.fetch_add(1, Ordering::Relaxed) + 1;
410        progress(index, &result, done, total);
411        (index, result)
412    };
413
414    // Rows that need no image work are published first so the inventory is
415    // visible immediately; images follow largest-first because they hold most
416    // of the savings.
417    let (mut work, settled): (Vec<usize>, Vec<usize>) = (0..total).partition(|&index| {
418        let resource = &inventory.assets[index];
419        resource.support == "optimizable"
420            || (resource.kind == "image" && options.probe_only)
421            || (is_media(resource) && crate::media::ffprobe_available())
422    });
423    work.sort_by_key(|&index| std::cmp::Reverse(inventory.assets[index].bytes));
424    let mut indexed: Vec<(usize, ResourceAnalysis)> = settled
425        .into_iter()
426        .map(|index| finish(index, settled_row(&inventory.assets[index])))
427        .collect();
428
429    let analyze_one = |index: usize| -> ResourceAnalysis {
430        let resource = &inventory.assets[index];
431        if control.is_cancelled() {
432            return ResourceAnalysis::new(resource, "not_analyzed");
433        }
434        if is_media(resource) {
435            // Inspection only: spawning ffprobe runs on the worker pool so it
436            // never delays image results.
437            let mut row = settled_row(resource);
438            if let Ok(path) = contained_file(&inventory.root, &resource.path) {
439                row.media = timings.time(Phase::Decode, || crate::media::probe(&path));
440            }
441            return row;
442        }
443        let read = timings.time(Phase::Hash, || {
444            contained_file(&inventory.root, &resource.path)
445                .and_then(|path| bounded_read(&path))
446                .map(|bytes| {
447                    let digest = hash(&bytes);
448                    (bytes, digest)
449                })
450        });
451        let (bytes, digest) = match read {
452            Ok(read) => read,
453            Err(error) => {
454                let mut failed = ResourceAnalysis::new(resource, "failed");
455                failed.issues.push(format!("{error:#}"));
456                return failed;
457            }
458        };
459        let compute = |own_index: usize| {
460            #[cfg(target_os = "macos")]
461            {
462                objc2::rc::autoreleasepool(|_| {
463                    analyze_image::analyze(&context, resource, own_index, &bytes, &digest)
464                })
465            }
466            #[cfg(not(target_os = "macos"))]
467            {
468                analyze_image::analyze(&context, resource, own_index, &bytes, &digest)
469            }
470        };
471        if options.probe_only {
472            return compute(index);
473        }
474        let key = match Cache::key(
475            &digest,
476            &analyze_image::policy_key(resource, min_sdk),
477            &options,
478        ) {
479            Ok(key) => key,
480            Err(_) => return compute(index),
481        };
482        // Identical content under an identical policy is analyzed once per run.
483        let slot = in_flight
484            .lock()
485            .unwrap_or_else(|e| e.into_inner())
486            .entry(key.clone())
487            .or_default()
488            .clone();
489        let mut computed_here = false;
490        let (owner, shared) = slot.get_or_init(|| {
491            computed_here = true;
492            if let Some(cache) = &cache
493                && let Some(mut hit) = timings.time(Phase::Cache, || cache.load(&key, &out, index))
494                && restore_original_artifact(&mut hit, &out, index, &bytes, resource).is_ok()
495            {
496                cache_hits.fetch_add(1, Ordering::Relaxed);
497                hit.resource = resource.clone();
498                return (index, hit);
499            }
500            let result = compute(index);
501            if let Some(cache) = &cache
502                && matches!(result.status.as_str(), "candidates_available" | "inspected")
503            {
504                let _ = timings.time(Phase::Cache, || cache.store(&key, &result, &out, index));
505            }
506            (index, result)
507        });
508        if computed_here || *owner == index {
509            return shared.clone();
510        }
511        if matches!(shared.status.as_str(), "failed" | "not_analyzed") {
512            return compute(index);
513        }
514        duplicate_reuses.fetch_add(1, Ordering::Relaxed);
515        let mut reused = shared.clone();
516        reused.resource = resource.clone();
517        reused
518    };
519    // Plain threads pulling from a shared queue, not a rayon pool: oxipng uses
520    // rayon internally, and a rayon worker that waits on nested work runs other
521    // queued tasks on the same stack. With a task already holding a pixel lease
522    // or initializing a shared duplicate slot, that re-entrancy deadlocked.
523    let next = AtomicUsize::new(0);
524    let finished = Mutex::new(Vec::with_capacity(work.len()));
525    std::thread::scope(|scope| {
526        for _ in 0..workers.min(work.len()).max(1) {
527            scope.spawn(|| {
528                while let Some(&index) = work.get(next.fetch_add(1, Ordering::Relaxed)) {
529                    let done = finish(index, analyze_one(index));
530                    finished
531                        .lock()
532                        .unwrap_or_else(|e| e.into_inner())
533                        .push(done);
534                }
535            });
536        }
537    });
538    indexed.extend(finished.into_inner().unwrap_or_else(|e| e.into_inner()));
539    indexed.sort_by_key(|(index, _)| *index);
540    let resources: Vec<_> = indexed.into_iter().map(|(_, result)| result).collect();
541    let mut status_counts = BTreeMap::new();
542    let mut savings = 0;
543    for resource in &resources {
544        *status_counts.entry(resource.status.clone()).or_insert(0) += 1;
545        savings += resource.recommended_savings();
546    }
547    if let Some(cache) = &cache {
548        let _ = cache.prune(crate::cache::DEFAULT_MAX_BYTES);
549    }
550    let similar_groups = crate::similarity::group(&resources);
551    // Fingerprints exist for grouping (and the cache); the report keeps the groups.
552    let resources: Vec<_> = resources
553        .into_iter()
554        .map(|resource| ResourceAnalysis {
555            fingerprint: None,
556            ..resource
557        })
558        .collect();
559    let mut report = AnalysisReport {
560        schema_version: 2,
561        root: inventory.root.clone(),
562        backend: if image_backend::image_backend_available() {
563            "Apple ImageIO + CoreGraphics sRGB float comparison; bundled oxipng and libwebp"
564        } else {
565            "Portable PNG and WebP (bundled oxipng and libwebp); JPEG/HEIC require macOS"
566        }
567        .into(),
568        options,
569        inventory,
570        resources,
571        status_counts,
572        potential_source_bytes_saved: savings,
573        similar_groups,
574        cancelled: control.is_cancelled(),
575        performance: None,
576    };
577    let html = timings.time(Phase::Report, || crate::report::render_html(&report))?;
578    report.performance = Some(Performance {
579        wall_seconds: started.elapsed().as_secs_f64(),
580        first_result_seconds: first_result.get().copied(),
581        workers,
582        cache_hits: cache_hits.load(Ordering::Relaxed),
583        duplicate_reuses: duplicate_reuses.load(Ordering::Relaxed),
584        phase_seconds: timings.snapshot(),
585    });
586    write_new(&out.join("analysis.json"), &serde_json::to_vec(&report)?)?;
587    write_new(&out.join("report.html"), html.as_bytes())?;
588    Ok(report)
589}
590
591fn is_media(resource: &Resource) -> bool {
592    matches!(resource.kind.as_str(), "audio" | "video") && resource.conversion_exclusion.is_none()
593}
594
595/// Inventory rows that need no decoding.
596fn settled_row(resource: &Resource) -> ResourceAnalysis {
597    if let Some(reason) = &resource.conversion_exclusion {
598        let mut row = ResourceAnalysis::new(resource, "excluded");
599        row.issues.push(reason.clone());
600        return row;
601    }
602    let mut row = ResourceAnalysis::new(resource, "unsupported");
603    if is_media(resource) && !crate::media::ffprobe_available() {
604        row.issues.push("ffprobe_not_installed".into());
605    }
606    row.issues.push(if resource.kind == "image" {
607        format!("{}_decoding_requires_macos_imageio", resource.format)
608    } else {
609        format!("{}_optimization_backend_not_implemented", resource.kind)
610    });
611    row
612}
613
614/// Cached entries omit the original artifact because it is the source itself.
615fn restore_original_artifact(
616    hit: &mut ResourceAnalysis,
617    out: &Path,
618    index: usize,
619    bytes: &[u8],
620    resource: &Resource,
621) -> Result<()> {
622    if hit.status == "candidates_available" {
623        let artifact = PathBuf::from(format!("originals/{index}.{}", resource.format));
624        crate::filesystem::write_artifact(&out.join(&artifact), bytes)?;
625        hit.original_artifact = Some(artifact);
626    }
627    Ok(())
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[test]
635    fn pixel_budget_serializes_oversized_work_without_deadlock() {
636        let budget = PixelBudget::new(100);
637        let peak = AtomicUsize::new(0);
638        let current = AtomicUsize::new(0);
639        std::thread::scope(|scope| {
640            for _ in 0..6 {
641                scope.spawn(|| {
642                    // Requests above capacity are clamped instead of waiting forever.
643                    let _lease = budget.acquire(1_000);
644                    let now = current.fetch_add(1, Ordering::SeqCst) + 1;
645                    peak.fetch_max(now, Ordering::SeqCst);
646                    std::thread::sleep(std::time::Duration::from_millis(5));
647                    current.fetch_sub(1, Ordering::SeqCst);
648                });
649            }
650        });
651        assert_eq!(peak.load(Ordering::SeqCst), 1);
652        let _a = budget.acquire(60);
653        let _b = budget.acquire(40);
654    }
655
656    #[test]
657    fn options_reject_out_of_range_policy_values() {
658        for options in [
659            AnalysisOptions {
660                min_score: 101.0,
661                ..Default::default()
662            },
663            AnalysisOptions {
664                min_score: f64::NAN,
665                ..Default::default()
666            },
667            AnalysisOptions {
668                jobs: 17,
669                ..Default::default()
670            },
671            AnalysisOptions {
672                android_min_sdk: Some(0),
673                ..Default::default()
674            },
675        ] {
676            assert!(options.validate().is_err());
677        }
678        assert!(AnalysisOptions::default().validate().is_ok());
679        assert!((1..=8).contains(&AnalysisOptions::default().worker_count()));
680    }
681}