Skip to main content

sparrow/
welcome.rs

1//! v0.9 Pilier 1 — l'accueil chaleureux et la détection de contexte.
2//!
3//! `sparrow bonjour` (alias `hello`, `salut`) ouvre une porte d'entrée
4//! accueillante : « Qu'est-ce qu'on règle aujourd'hui ? », une suggestion
5//! adaptée à ce qu'il y a dans le dossier courant, et quelques idées concrètes.
6//! C'est le premier contact « waouh », sans jargon.
7
8use crate::humanize::Lang;
9use std::path::Path;
10
11/// What Sparrow noticed about the current directory, used to lead with the
12/// most relevant offer.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Context {
15    /// A git repository with a merge/rebase in progress.
16    GitConflict,
17    /// A Node project whose dependencies aren't installed yet.
18    NodeNeedsInstall,
19    /// A folder that's mostly images.
20    ManyImages,
21    /// A git repository (no obvious problem).
22    GitRepo,
23    /// Nothing specific.
24    Generic,
25}
26
27/// Inspect `cwd` cheaply (a few stat calls, no recursion beyond one level) to
28/// pick the most relevant opening offer.
29pub fn detect_context(cwd: &Path) -> Context {
30    let git = cwd.join(".git");
31    if git.is_dir() {
32        // Merge/rebase in progress → a conflict the user likely wants resolved.
33        if git.join("MERGE_HEAD").exists()
34            || git.join("rebase-merge").exists()
35            || git.join("rebase-apply").exists()
36        {
37            return Context::GitConflict;
38        }
39    }
40    if cwd.join("package.json").is_file() && !cwd.join("node_modules").exists() {
41        return Context::NodeNeedsInstall;
42    }
43    if looks_like_photo_folder(cwd) {
44        return Context::ManyImages;
45    }
46    if git.is_dir() {
47        return Context::GitRepo;
48    }
49    Context::Generic
50}
51
52/// True when the directory's top level is dominated by image files.
53fn looks_like_photo_folder(cwd: &Path) -> bool {
54    let exts = ["jpg", "jpeg", "png", "heic", "gif", "webp"];
55    let mut images = 0usize;
56    let mut total = 0usize;
57    if let Ok(entries) = std::fs::read_dir(cwd) {
58        for e in entries.flatten().take(200) {
59            if e.path().is_file() {
60                total += 1;
61                if let Some(ext) = e.path().extension().and_then(|x| x.to_str()) {
62                    if exts.contains(&ext.to_lowercase().as_str()) {
63                        images += 1;
64                    }
65                }
66            }
67        }
68    }
69    total >= 8 && images * 2 >= total
70}
71
72/// The contextual one-liner offer for a detected context.
73fn context_offer(ctx: Context, lang: Lang) -> Option<String> {
74    let s = match (ctx, lang) {
75        (Context::GitConflict, Lang::Fr) => {
76            "Je vois un conflit git en cours ici. Tu veux que je t'aide à le régler ? → sparrow fix"
77        }
78        (Context::GitConflict, Lang::En) => {
79            "I see a git conflict in progress here. Want me to help resolve it? → sparrow fix"
80        }
81        (Context::NodeNeedsInstall, Lang::Fr) => {
82            "Ce projet n'est pas installé (pas de node_modules). Je m'en occupe ? → sparrow fix"
83        }
84        (Context::NodeNeedsInstall, Lang::En) => {
85            "This project isn't installed (no node_modules). Want me to handle it? → sparrow fix"
86        }
87        (Context::ManyImages, Lang::Fr) => {
88            "Beaucoup de photos ici. Je peux les ranger par année. → sparrow idees grand-mere"
89        }
90        (Context::ManyImages, Lang::En) => {
91            "Lots of photos here. I can sort them by year. → sparrow ideas grandparent"
92        }
93        (Context::GitRepo, Lang::Fr) => {
94            "Tu es dans un projet de code. Un bug à corriger ? → sparrow fix · Comprendre le code ? → sparrow explique"
95        }
96        (Context::GitRepo, Lang::En) => {
97            "You're in a code project. A bug to fix? → sparrow fix · Understand the code? → sparrow explain"
98        }
99        (Context::Generic, _) => return None,
100    };
101    Some(s.to_string())
102}
103
104/// Build the full warm welcome for the current directory.
105pub fn welcome_text(cwd: &Path, lang: Lang) -> String {
106    let mut out = String::new();
107    let header = match lang {
108        Lang::Fr => "🐦  Salut ! Qu'est-ce qu'on règle aujourd'hui ?",
109        Lang::En => "🐦  Hi! What are we sorting out today?",
110    };
111    out.push_str(header);
112    out.push_str("\n\n");
113
114    if let Some(offer) = context_offer(detect_context(cwd), lang) {
115        out.push_str("    ");
116        out.push_str(&offer);
117        out.push_str("\n\n");
118    }
119
120    let body = match lang {
121        Lang::Fr => {
122            "    Décris ton problème avec tes mots :\n\
123             \x20     · sparrow fix \"mon site ne s'affiche plus\"\n\
124             \x20     · sparrow explique \"ce message d'erreur\"\n\
125             \x20     · sparrow idees      (tout ce que tu peux faire)\n\
126             \x20     · sparrow whatis token   (c'est quoi ce mot ?)\n\n\
127            Et si ça ne te plaît pas : sparrow annule remet tout comme avant."
128        }
129        Lang::En => {
130            "    Describe your problem in your own words:\n\
131             \x20     · sparrow fix \"my site won't load\"\n\
132             \x20     · sparrow explain \"this error message\"\n\
133             \x20     · sparrow ideas      (everything you can do)\n\
134             \x20     · sparrow whatis token   (what's this word?)\n\n\
135            And if you don't like it: sparrow undo puts everything back."
136        }
137    };
138    out.push_str(body);
139    out
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::fs;
146
147    fn tmp(name: &str) -> std::path::PathBuf {
148        let p = std::env::temp_dir().join(format!(
149            "sparrow-welcome-{name}-{}",
150            std::time::SystemTime::now()
151                .duration_since(std::time::UNIX_EPOCH)
152                .unwrap()
153                .as_nanos()
154        ));
155        fs::create_dir_all(&p).unwrap();
156        p
157    }
158
159    #[test]
160    fn detects_node_needs_install() {
161        let d = tmp("node");
162        fs::write(d.join("package.json"), "{}").unwrap();
163        assert_eq!(detect_context(&d), Context::NodeNeedsInstall);
164        let _ = fs::remove_dir_all(d);
165    }
166
167    #[test]
168    fn detects_git_conflict() {
169        let d = tmp("conflict");
170        fs::create_dir_all(d.join(".git")).unwrap();
171        fs::write(d.join(".git").join("MERGE_HEAD"), "x").unwrap();
172        assert_eq!(detect_context(&d), Context::GitConflict);
173        let _ = fs::remove_dir_all(d);
174    }
175
176    #[test]
177    fn detects_photo_folder() {
178        let d = tmp("photos");
179        for i in 0..10 {
180            fs::write(d.join(format!("img{i}.jpg")), "x").unwrap();
181        }
182        assert_eq!(detect_context(&d), Context::ManyImages);
183        let _ = fs::remove_dir_all(d);
184    }
185
186    #[test]
187    fn empty_dir_is_generic() {
188        let d = tmp("empty");
189        assert_eq!(detect_context(&d), Context::Generic);
190        let _ = fs::remove_dir_all(d);
191    }
192
193    #[test]
194    fn welcome_text_is_warm_and_jargon_free() {
195        let d = tmp("welcome");
196        let text = welcome_text(&d, Lang::Fr);
197        assert!(text.contains("Qu'est-ce qu'on règle"));
198        assert!(text.contains("sparrow fix"));
199        assert!(text.contains("sparrow annule"));
200        // No raw technical noise in the welcome itself.
201        for bad in ["tier", "tokens", "0.0.0.0", "[Tool"] {
202            assert!(!text.contains(bad), "welcome leaked `{bad}`");
203        }
204        let _ = fs::remove_dir_all(d);
205    }
206}