Skip to main content

sparrow/
gallery.rs

1//! v0.9 Pilier 6 — la galerie des possibles.
2//!
3//! On ne pousse pas les gens à *avoir* Sparrow, on leur montre tout ce qu'ils
4//! peuvent *faire* avec. `sparrow idees` (alias `ideas`) présente une galerie
5//! de recettes concrètes, classées par personne plutôt que par fonctionnalité.
6//! Chaque recette EST le tutoriel : son `prompt` est prêt à coller.
7
8use crate::humanize::Lang;
9
10/// Who a recipe is for — the personas from PLAN_v0.9.0 §1.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Persona {
13    Enfant,
14    Enseignant,
15    GrandMere,
16    Artisan,
17    Maison,
18    Builder,
19    Developpeur,
20    Expert,
21}
22
23impl Persona {
24    /// Stable lowercase slug used for filtering on the CLI.
25    pub fn slug(&self) -> &'static str {
26        match self {
27            Persona::Enfant => "enfant",
28            Persona::Enseignant => "enseignant",
29            Persona::GrandMere => "grand-mere",
30            Persona::Artisan => "artisan",
31            Persona::Maison => "maison",
32            Persona::Builder => "builder",
33            Persona::Developpeur => "developpeur",
34            Persona::Expert => "expert",
35        }
36    }
37
38    pub fn label(&self, lang: Lang) -> &'static str {
39        match (self, lang) {
40            (Persona::Enfant, Lang::Fr) => "Pour un enfant",
41            (Persona::Enfant, Lang::En) => "For a child",
42            (Persona::Enseignant, Lang::Fr) => "Pour un enseignant",
43            (Persona::Enseignant, Lang::En) => "For a teacher",
44            (Persona::GrandMere, Lang::Fr) => "Pour une grand-mère",
45            (Persona::GrandMere, Lang::En) => "For a grandparent",
46            (Persona::Artisan, Lang::Fr) => "Pour un artisan",
47            (Persona::Artisan, Lang::En) => "For a tradesperson",
48            (Persona::Maison, Lang::Fr) => "À la maison",
49            (Persona::Maison, Lang::En) => "At home",
50            (Persona::Builder, Lang::Fr) => "Pour un créateur",
51            (Persona::Builder, Lang::En) => "For a builder",
52            (Persona::Developpeur, Lang::Fr) => "Pour un développeur",
53            (Persona::Developpeur, Lang::En) => "For a developer",
54            (Persona::Expert, Lang::Fr) => "Pour un expert IA",
55            (Persona::Expert, Lang::En) => "For an AI expert",
56        }
57    }
58
59    fn from_slug(s: &str) -> Option<Persona> {
60        let s = s.trim().to_lowercase();
61        [
62            Persona::Enfant,
63            Persona::Enseignant,
64            Persona::GrandMere,
65            Persona::Artisan,
66            Persona::Maison,
67            Persona::Builder,
68            Persona::Developpeur,
69            Persona::Expert,
70        ]
71        .into_iter()
72        .find(|p| p.slug() == s || p.slug().starts_with(&s))
73    }
74}
75
76/// One ready-to-run recipe.
77pub struct Recipe {
78    pub persona: Persona,
79    pub title_fr: &'static str,
80    pub title_en: &'static str,
81    /// The exact prompt the user can run — this is the tutorial.
82    pub prompt_fr: &'static str,
83    pub prompt_en: &'static str,
84    pub est: &'static str,
85}
86
87impl Recipe {
88    pub fn title(&self, lang: Lang) -> &'static str {
89        match lang {
90            Lang::Fr => self.title_fr,
91            Lang::En => self.title_en,
92        }
93    }
94    pub fn prompt(&self, lang: Lang) -> &'static str {
95        match lang {
96            Lang::Fr => self.prompt_fr,
97            Lang::En => self.prompt_en,
98        }
99    }
100}
101
102const RECIPES: &[Recipe] = &[
103    Recipe {
104        persona: Persona::Enseignant,
105        title_fr: "30 quiz à difficulté progressive depuis mon cours",
106        title_en: "30 progressive quizzes from my lesson",
107        prompt_fr: "Transforme le fichier de mon cours en 30 questions de quiz à difficulté croissante, avec les réponses à la fin.",
108        prompt_en: "Turn my lesson file into 30 quiz questions of increasing difficulty, with answers at the end.",
109        est: "~2 min",
110    },
111    Recipe {
112        persona: Persona::GrandMere,
113        title_fr: "Ranger mes photos par année et par personne",
114        title_en: "Sort my photos by year and by person",
115        prompt_fr: "Range les photos de ce dossier dans des sous-dossiers par année, et explique-moi chaque étape simplement.",
116        prompt_en: "Sort the photos in this folder into subfolders by year, and explain each step to me simply.",
117        est: "~5 min",
118    },
119    Recipe {
120        persona: Persona::Artisan,
121        title_fr: "Croiser mes factures Excel et mes bons de commande",
122        title_en: "Cross-check my Excel invoices against purchase orders",
123        prompt_fr: "Compare mon fichier de factures et mon fichier de bons de commande, et liste-moi tous les écarts de montant.",
124        prompt_en: "Compare my invoices file and my purchase-orders file, and list every amount mismatch.",
125        est: "~3 min",
126    },
127    Recipe {
128        persona: Persona::Enfant,
129        title_fr: "Pourquoi mon jeu Scratch plante ?",
130        title_en: "Why does my Scratch game crash?",
131        prompt_fr: "Explique-moi pourquoi mon projet plante, comme si j'avais 9 ans, et aide-moi à le réparer.",
132        prompt_en: "Explain why my project crashes, like I'm 9 years old, and help me fix it.",
133        est: "~2 min",
134    },
135    Recipe {
136        persona: Persona::Maison,
137        title_fr: "Un planning de repas de la semaine",
138        title_en: "A weekly meal plan",
139        prompt_fr: "Fais-moi un planning de repas équilibrés pour la semaine à partir de ma liste de courses, et la liste de ce qui manque.",
140        prompt_en: "Make me a balanced weekly meal plan from my shopping list, plus a list of what's missing.",
141        est: "~2 min",
142    },
143    Recipe {
144        persona: Persona::Builder,
145        title_fr: "Une page de présentation pour mon idée",
146        title_en: "A landing page for my idea",
147        prompt_fr: "Monte-moi une page web simple et jolie qui présente mon idée, prête à ouvrir dans un navigateur.",
148        prompt_en: "Build me a simple, good-looking web page that presents my idea, ready to open in a browser.",
149        est: "~10 min",
150    },
151    Recipe {
152        persona: Persona::Developpeur,
153        title_fr: "Trouver pourquoi ce test échoue 1 fois sur 20",
154        title_en: "Find why this test flakes 1 in 20 runs",
155        prompt_fr: "Ce test échoue de façon intermittente. Trouve la cause de l'instabilité et propose un correctif.",
156        prompt_en: "This test fails intermittently. Find the source of the flakiness and propose a fix.",
157        est: "~5 min",
158    },
159    Recipe {
160        persona: Persona::Developpeur,
161        title_fr: "Expliquer ce dépôt que je découvre",
162        title_en: "Explain this repo I'm new to",
163        prompt_fr: "Fais-moi le tour de ce dépôt : à quoi il sert, son architecture, et par où commencer pour contribuer.",
164        prompt_en: "Give me a tour of this repo: what it does, its architecture, and where to start contributing.",
165        est: "~3 min",
166    },
167    Recipe {
168        persona: Persona::Expert,
169        title_fr: "Orchestrer un swarm sur un gros monorepo",
170        title_en: "Orchestrate a swarm over a large monorepo",
171        prompt_fr: "Lance un swarm : un planner qui découpe la tâche, des coders en parallèle par module, un verifier qui teste chaque diff.",
172        prompt_en: "Run a swarm: a planner that splits the task, coders in parallel per module, a verifier that tests each diff.",
173        est: "variable",
174    },
175];
176
177/// All recipes, optionally filtered by a persona slug and/or a free-text query
178/// (matched against title and prompt, case-insensitive).
179pub fn search<'a>(persona: Option<&str>, query: Option<&str>) -> Vec<&'a Recipe> {
180    let persona = persona.and_then(Persona::from_slug);
181    let q = query
182        .map(|s| s.trim().to_lowercase())
183        .filter(|s| !s.is_empty());
184    RECIPES
185        .iter()
186        .filter(|r| persona.is_none_or(|p| r.persona == p))
187        .filter(|r| {
188            q.as_ref().is_none_or(|q| {
189                r.title_fr.to_lowercase().contains(q)
190                    || r.title_en.to_lowercase().contains(q)
191                    || r.prompt_fr.to_lowercase().contains(q)
192                    || r.prompt_en.to_lowercase().contains(q)
193            })
194        })
195        .collect()
196}
197
198/// Distinct persona slugs that have at least one recipe, in display order.
199pub fn personas() -> Vec<&'static str> {
200    let mut seen = Vec::new();
201    for r in RECIPES {
202        if !seen.contains(&r.persona.slug()) {
203            seen.push(r.persona.slug());
204        }
205    }
206    seen
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn lists_everything_with_no_filter() {
215        assert_eq!(search(None, None).len(), RECIPES.len());
216        assert!(RECIPES.len() >= 8, "ship a meaningful starter gallery");
217    }
218
219    #[test]
220    fn filters_by_persona() {
221        let dev = search(Some("developpeur"), None);
222        assert!(!dev.is_empty());
223        assert!(dev.iter().all(|r| r.persona == Persona::Developpeur));
224        // Prefix match works too.
225        assert_eq!(search(Some("dev"), None).len(), dev.len());
226    }
227
228    #[test]
229    fn full_text_search_matches_title_and_prompt() {
230        assert!(!search(None, Some("photos")).is_empty());
231        assert!(!search(None, Some("swarm")).is_empty());
232        assert!(search(None, Some("zzzznotfound")).is_empty());
233    }
234
235    #[test]
236    fn personas_list_is_non_empty_and_unique() {
237        let p = personas();
238        assert!(!p.is_empty());
239        let mut sorted = p.clone();
240        sorted.sort_unstable();
241        sorted.dedup();
242        assert_eq!(sorted.len(), p.len(), "no duplicate personas");
243    }
244
245    #[test]
246    fn every_recipe_has_both_languages() {
247        for r in RECIPES {
248            assert!(!r.prompt(Lang::Fr).is_empty());
249            assert!(!r.prompt(Lang::En).is_empty());
250            assert!(!r.title(Lang::Fr).is_empty());
251            assert!(!r.title(Lang::En).is_empty());
252        }
253    }
254}