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