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}