Skip to main content

barometre_prix_web_fr/
lib.rs

1//! # Baromètre des prix de création de site web en France 2026
2//!
3//! This crate embeds the open dataset behind the [Baromètre des prix de
4//! création de site web en France 2026](https://lescreavores.fr/prix-creation-site-internet/),
5//! a survey of real-world website-creation prices collected by [Les
6//! Créavores](https://lescreavores.fr/) across French freelancers, agencies
7//! and SaaS website builders.
8//!
9//! The dataset contains 103 individually sourced price points covering six
10//! service categories (`vitrine`, `ecommerce`, `blog`, `refonte`,
11//! `seo_mensuel`, `sur_mesure`), three provider types (`freelance`,
12//! `agence_guide`, `builder_saas`) and three pricing models
13//! (`forfait_unique`, `abonnement_mensuel`, `tjm`). Every record carries its
14//! own source (a public URL) and the date it was recorded, so the data can
15//! be audited and re-verified.
16//!
17//! This crate has **zero external dependencies**: the CSV is parsed by hand
18//! at runtime from a string embedded at compile time via [`include_str!`],
19//! which keeps the build fully reproducible on docs.rs and everywhere else.
20//!
21//! ## Methodology
22//!
23//! Each row is an individually sourced price observation, not an estimate:
24//! `source_url` points at the public page the price was read from, and
25//! `date_releve` records when it was collected. Prices are stored in euros
26//! (EUR). Where a provider publishes a price range, `prix_min_eur` and
27//! `prix_max_eur` capture the range and `prix_typique_eur` is the price used
28//! for aggregate statistics.
29//!
30//! ## License
31//!
32//! The **code** of this crate is licensed under MIT (see `LICENSE`).
33//!
34//! The **dataset** itself (`data/barometre-prix-creation-site-web-france-2026.csv`)
35//! is licensed separately under **CC-BY 4.0**. If you redistribute or build
36//! upon the data, please credit:
37//!
38//! > Data: Les Créavores — Baromètre des prix de création de site web en
39//! > France 2026 (CC-BY 4.0). Source:
40//! > <https://lescreavores.fr/prix-creation-site-internet/>
41//!
42//! ## Example
43//!
44//! ```rust
45//! let records = barometre_prix_web_fr::all();
46//! assert!(records.len() > 90);
47//!
48//! let vitrine_stats = barometre_prix_web_fr::stats(&barometre_prix_web_fr::by_category(
49//!     barometre_prix_web_fr::category::VITRINE,
50//! ));
51//! if let Some(stats) = vitrine_stats {
52//!     println!(
53//!         "vitrine: {} records, typical price from {:.2} to {:.2} EUR (median {:.2})",
54//!         stats.count, stats.min, stats.max, stats.median
55//!     );
56//! }
57//! ```
58
59/// The raw dataset, embedded at compile time.
60///
61/// Columns (in order): `categorie_prestation`, `type_prestataire`,
62/// `pricing_model`, `prix_min_eur`, `prix_max_eur`, `prix_typique_eur`,
63/// `region`, `source_categorie`, `source_url`, `date_releve`, `notes`.
64const CSV_DATA: &str = include_str!("../data/barometre-prix-creation-site-web-france-2026.csv");
65
66/// Known `categorie_prestation` codes found in the dataset.
67pub mod category {
68    /// Showcase / brochure website (`vitrine`).
69    pub const VITRINE: &str = "vitrine";
70    /// Online store (`ecommerce`).
71    pub const ECOMMERCE: &str = "ecommerce";
72    /// Blog / editorial website (`blog`).
73    pub const BLOG: &str = "blog";
74    /// Redesign of an existing website (`refonte`).
75    pub const REFONTE: &str = "refonte";
76    /// Recurring monthly SEO service (`seo_mensuel`).
77    pub const SEO_MENSUEL: &str = "seo_mensuel";
78    /// Fully custom / bespoke development (`sur_mesure`).
79    pub const SUR_MESURE: &str = "sur_mesure";
80}
81
82/// Known `type_prestataire` codes found in the dataset.
83pub mod provider_type {
84    /// Independent freelance developer or designer.
85    pub const FREELANCE: &str = "freelance";
86    /// Web agency, priced from a published rate card / guide.
87    pub const AGENCE_GUIDE: &str = "agence_guide";
88    /// SaaS website builder (Wix, Webflow, Shopify, etc.).
89    pub const BUILDER_SAAS: &str = "builder_saas";
90}
91
92/// A single price observation from the Baromètre dataset.
93///
94/// All monetary fields are expressed in euros (EUR). Text fields that are a
95/// single CSV token (no embedded commas in the source data) are kept as
96/// zero-copy `&'static str` slices into the embedded dataset; the two
97/// free-text fields that can legitimately contain commas (`source_label`
98/// and `notes`) are reconstructed into owned `String`s.
99#[derive(Debug, Clone, PartialEq)]
100pub struct PriceRecord {
101    /// Service category, e.g. `"vitrine"`, `"ecommerce"`, `"blog"`.
102    pub category: &'static str,
103    /// Provider type, e.g. `"freelance"`, `"agence_guide"`, `"builder_saas"`.
104    pub provider_type: &'static str,
105    /// Pricing model, e.g. `"forfait_unique"`, `"abonnement_mensuel"`, `"tjm"`.
106    pub pricing_model: &'static str,
107    /// Minimum observed price, in EUR. `0.0` if missing or unparsable.
108    pub price_min_eur: f64,
109    /// Maximum observed price, in EUR. `0.0` if missing or unparsable.
110    pub price_max_eur: f64,
111    /// Typical (representative) price used for aggregate statistics, in EUR.
112    /// `0.0` if missing or unparsable.
113    pub typical_price_eur: f64,
114    /// Region the price applies to (e.g. `"France"`, `"Paris"`, `"Lyon"`).
115    pub region: &'static str,
116    /// Human-readable label for the source (offer name, plan name, etc.).
117    pub source_label: String,
118    /// URL of the public source the price was read from.
119    pub source_url: &'static str,
120    /// Date the price was recorded, in `YYYY-MM-DD` format.
121    pub date_recorded: &'static str,
122    /// Free-text notes about the observation (scope, caveats, inclusions).
123    pub notes: String,
124}
125
126/// Aggregate price statistics computed over a set of [`PriceRecord`]s.
127#[derive(Debug, Clone, Copy, PartialEq)]
128pub struct Stats {
129    /// Number of records the statistics were computed over.
130    pub count: usize,
131    /// Minimum typical price, in EUR.
132    pub min: f64,
133    /// Maximum typical price, in EUR.
134    pub max: f64,
135    /// Arithmetic mean of the typical price, in EUR.
136    pub mean: f64,
137    /// Median of the typical price, in EUR.
138    pub median: f64,
139}
140
141/// Parses a single embedded CSV line into a [`PriceRecord`].
142///
143/// Most fields are simple, comma-free tokens and can be sliced directly out
144/// of the 11 `split(',')` parts. Two fields (`source_categorie` and
145/// `notes`) are free text and occasionally contain unescaped commas in the
146/// source data (e.g. `"Webflow Basic site plan (annuel, en USD converti)"`).
147/// Since `source_url` always starts with `http` and always immediately
148/// precedes `date_releve`, we locate it by scanning instead of assuming a
149/// fixed column index — this makes the parser robust to those embedded
150/// commas without requiring a full CSV-quoting implementation.
151///
152/// Returns `None` for blank lines or lines that don't have enough fields to
153/// be a valid record. This function never panics on malformed input: it is
154/// used purely as an internal, best-effort parser over external data.
155fn parse_row(line: &'static str) -> Option<PriceRecord> {
156    if line.trim().is_empty() {
157        return None;
158    }
159
160    let parts: Vec<&'static str> = line.split(',').collect();
161    if parts.len() < 11 {
162        return None;
163    }
164
165    let category = parts[0];
166    let provider_type = parts[1];
167    let pricing_model = parts[2];
168    let price_min_eur = parts[3].trim().parse::<f64>().unwrap_or(0.0);
169    let price_max_eur = parts[4].trim().parse::<f64>().unwrap_or(0.0);
170    let typical_price_eur = parts[5].trim().parse::<f64>().unwrap_or(0.0);
171    let region = parts[6];
172
173    let url_idx = parts
174        .iter()
175        .enumerate()
176        .skip(7)
177        .find(|(_, p)| p.starts_with("http"))
178        .map(|(i, _)| i)?;
179
180    if url_idx + 1 >= parts.len() {
181        return None;
182    }
183
184    let source_label = parts[7..url_idx].join(",");
185    let source_url = parts[url_idx];
186    let date_recorded = parts[url_idx + 1];
187    let notes_start = (url_idx + 2).min(parts.len());
188    let notes = parts[notes_start..].join(",");
189
190    Some(PriceRecord {
191        category,
192        provider_type,
193        pricing_model,
194        price_min_eur,
195        price_max_eur,
196        typical_price_eur,
197        region,
198        source_label,
199        source_url,
200        date_recorded,
201        notes,
202    })
203}
204
205/// Returns every price record in the Baromètre dataset.
206///
207/// # Examples
208///
209/// ```rust
210/// let records = barometre_prix_web_fr::all();
211/// assert!(records.len() > 90);
212/// ```
213pub fn all() -> Vec<PriceRecord> {
214    CSV_DATA.lines().skip(1).filter_map(parse_row).collect()
215}
216
217/// Returns all records matching the given service category (e.g.
218/// `"vitrine"`, `"ecommerce"`; see the [`category`] module for known
219/// values).
220///
221/// # Examples
222///
223/// ```rust
224/// let vitrine = barometre_prix_web_fr::by_category(barometre_prix_web_fr::category::VITRINE);
225/// assert!(!vitrine.is_empty());
226/// for record in &vitrine {
227///     assert_eq!(record.category, "vitrine");
228/// }
229/// ```
230pub fn by_category(category: &str) -> Vec<PriceRecord> {
231    all().into_iter().filter(|r| r.category == category).collect()
232}
233
234/// Returns all records matching the given provider type (e.g.
235/// `"freelance"`, `"agence_guide"`, `"builder_saas"`; see the
236/// [`provider_type`] module for known values).
237///
238/// # Examples
239///
240/// ```rust
241/// let freelance = barometre_prix_web_fr::by_provider_type(
242///     barometre_prix_web_fr::provider_type::FREELANCE,
243/// );
244/// assert!(!freelance.is_empty());
245/// for record in &freelance {
246///     assert_eq!(record.provider_type, "freelance");
247/// }
248/// ```
249pub fn by_provider_type(provider_type: &str) -> Vec<PriceRecord> {
250    all()
251        .into_iter()
252        .filter(|r| r.provider_type == provider_type)
253        .collect()
254}
255
256/// Computes min / max / mean / median statistics over the *typical* price
257/// (`typical_price_eur`) of the given records.
258///
259/// Returns `None` if `records` is empty, so callers never need to guess a
260/// sentinel value for an empty dataset slice.
261///
262/// # Examples
263///
264/// ```rust
265/// let records = barometre_prix_web_fr::all();
266/// match barometre_prix_web_fr::stats(&records) {
267///     Some(stats) => {
268///         assert!(stats.min <= stats.median);
269///         assert!(stats.median <= stats.max);
270///         assert_eq!(stats.count, records.len());
271///     }
272///     None => panic!("dataset should not be empty"),
273/// }
274///
275/// // An empty slice yields no statistics.
276/// assert!(barometre_prix_web_fr::stats(&[]).is_none());
277/// ```
278pub fn stats(records: &[PriceRecord]) -> Option<Stats> {
279    if records.is_empty() {
280        return None;
281    }
282
283    let mut prices: Vec<f64> = records.iter().map(|r| r.typical_price_eur).collect();
284    prices.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
285
286    let count = prices.len();
287    let min = prices[0];
288    let max = prices[count - 1];
289    let mean = prices.iter().sum::<f64>() / count as f64;
290    let median = if count % 2 == 0 {
291        (prices[count / 2 - 1] + prices[count / 2]) / 2.0
292    } else {
293        prices[count / 2]
294    };
295
296    Some(Stats {
297        count,
298        min,
299        max,
300        mean,
301        median,
302    })
303}
304
305/// Returns the distinct list of service categories present in the dataset,
306/// sorted alphabetically.
307///
308/// # Examples
309///
310/// ```rust
311/// let categories = barometre_prix_web_fr::categories();
312/// assert!(categories.contains(&"vitrine"));
313/// assert!(categories.contains(&"ecommerce"));
314/// ```
315pub fn categories() -> Vec<&'static str> {
316    let mut values: Vec<&'static str> = all().iter().map(|r| r.category).collect();
317    values.sort_unstable();
318    values.dedup();
319    values
320}
321
322/// Returns the distinct list of provider types present in the dataset,
323/// sorted alphabetically.
324///
325/// # Examples
326///
327/// ```rust
328/// let providers = barometre_prix_web_fr::provider_types();
329/// assert!(providers.contains(&"freelance"));
330/// ```
331pub fn provider_types() -> Vec<&'static str> {
332    let mut values: Vec<&'static str> = all().iter().map(|r| r.provider_type).collect();
333    values.sort_unstable();
334    values.dedup();
335    values
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn dataset_parses_without_error() {
344        let records = all();
345        assert!(!records.is_empty(), "the dataset should parse at least one record");
346    }
347
348    #[test]
349    fn record_count_is_above_ninety() {
350        let records = all();
351        assert!(
352            records.len() > 90,
353            "expected more than 90 records, got {}",
354            records.len()
355        );
356    }
357
358    #[test]
359    fn stats_are_internally_consistent() {
360        let records = all();
361        let s = stats(&records).expect("dataset is not empty");
362        assert!(s.min <= s.median, "min ({}) should be <= median ({})", s.min, s.median);
363        assert!(s.median <= s.max, "median ({}) should be <= max ({})", s.median, s.max);
364        assert!(s.min <= s.mean, "min ({}) should be <= mean ({})", s.min, s.mean);
365        assert!(s.mean <= s.max, "mean ({}) should be <= max ({})", s.mean, s.max);
366        assert_eq!(s.count, records.len());
367    }
368
369    #[test]
370    fn known_category_returns_results() {
371        let vitrine = by_category(category::VITRINE);
372        assert!(!vitrine.is_empty());
373        for record in &vitrine {
374            assert_eq!(record.category, "vitrine");
375        }
376    }
377
378    #[test]
379    fn known_provider_type_returns_results() {
380        let builders = by_provider_type(provider_type::BUILDER_SAAS);
381        assert!(!builders.is_empty());
382        for record in &builders {
383            assert_eq!(record.provider_type, "builder_saas");
384        }
385    }
386
387    #[test]
388    fn unknown_category_returns_empty() {
389        let none = by_category("does_not_exist");
390        assert!(none.is_empty());
391    }
392
393    #[test]
394    fn categories_and_provider_types_are_deduplicated_and_sorted() {
395        let cats = categories();
396        let mut sorted = cats.clone();
397        sorted.sort_unstable();
398        sorted.dedup();
399        assert_eq!(cats, sorted, "categories() should be sorted and deduplicated");
400
401        let providers = provider_types();
402        let mut sorted_providers = providers.clone();
403        sorted_providers.sort_unstable();
404        sorted_providers.dedup();
405        assert_eq!(
406            providers, sorted_providers,
407            "provider_types() should be sorted and deduplicated"
408        );
409    }
410
411    #[test]
412    fn rows_with_embedded_commas_in_notes_still_parse_source_url() {
413        // Two rows in the dataset (Webflow plans) have an unescaped comma
414        // inside the `source_categorie` field. Make sure those still parse
415        // into a valid https URL for source_url rather than swallowing the
416        // URL into source_label.
417        let records = all();
418        let matches: Vec<&PriceRecord> = records
419            .iter()
420            .filter(|r| r.source_url.starts_with("https://www.agence-synqro.fr"))
421            .collect();
422        assert!(!matches.is_empty(), "expected at least one Webflow-sourced record");
423        for record in matches {
424            assert!(record.source_url.starts_with("http"));
425            assert_eq!(record.date_recorded.len(), 10, "date should be YYYY-MM-DD");
426        }
427    }
428
429    #[test]
430    fn price_fields_are_never_negative() {
431        for record in all() {
432            assert!(record.price_min_eur >= 0.0);
433            assert!(record.price_max_eur >= 0.0);
434            assert!(record.typical_price_eur >= 0.0);
435        }
436    }
437}