Skip to main content

dodot_lib/commands/
tutorial.rs

1//! `tutorial` — data and introspection for the interactive tutorial.
2//!
3//! The interactive driver lives in `dodot-cli`; this module provides
4//! the building blocks it composes: pack classification, shell-
5//! integration detection (read-only inspection plus an explicit
6//! append helper), JSON state persistence for resume, and the
7//! serializable [`TutorialCtx`] that the CLI passes to step
8//! templates. Reads are pure; writes (`append_shell_integration`,
9//! `save_state`, `clear_state`) only run on explicit user consent
10//! from the driver.
11
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16use crate::packs;
17use crate::packs::orchestration::ExecutionContext;
18use crate::paths::Pather;
19use crate::Result;
20
21// ── Pack classification ─────────────────────────────────────────
22
23/// Coarse categorisation of a pack used by the tutorial to pick a
24/// good starter. Names match human prose: "config-only" / "shell" /
25/// "install".
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PackKind {
28    /// Only files that map to the default symlink handler.
29    ConfigOnly,
30    /// Has shell-integration files (`aliases.sh`, …) and/or `bin/`.
31    ConfigPlusShell,
32    /// Has install scripts and/or `Brewfile`.
33    ConfigPlusInstall,
34    /// Has both shell-integration and provisioning files.
35    ConfigPlusShellAndInstall,
36    /// Pack is essentially empty (no top-level files at all).
37    Empty,
38}
39
40impl PackKind {
41    pub fn label(self) -> &'static str {
42        match self {
43            PackKind::ConfigOnly => "config only",
44            PackKind::ConfigPlusShell => "config + shell",
45            PackKind::ConfigPlusInstall => "config + install",
46            PackKind::ConfigPlusShellAndInstall => "config + shell + install",
47            PackKind::Empty => "empty",
48        }
49    }
50
51    /// Lower number = better starter pack for a first-time user.
52    fn starter_rank(self) -> u8 {
53        match self {
54            PackKind::ConfigOnly => 0,
55            PackKind::ConfigPlusShell => 1,
56            PackKind::ConfigPlusInstall => 2,
57            PackKind::ConfigPlusShellAndInstall => 3,
58            PackKind::Empty => 99,
59        }
60    }
61}
62
63/// Classify a pack by inspecting its top-level files.
64///
65/// Mirrors the default rules in `config::mappings_to_rules` rather
66/// than re-running the rules scanner, because the tutorial only
67/// needs a coarse summary and we want to stay independent of any
68/// custom rules a user may have added.
69pub fn classify_pack(pack: &packs::Pack) -> PackKind {
70    let entries = match std::fs::read_dir(&pack.path) {
71        Ok(e) => e,
72        Err(_) => return PackKind::Empty,
73    };
74
75    let mut has_install = false;
76    let mut has_shell = false;
77    let mut any = false;
78
79    for entry in entries.flatten() {
80        let name = entry.file_name();
81        let name = name.to_string_lossy().to_string();
82        if name.starts_with('.') {
83            continue;
84        }
85        any = true;
86        let path = entry.path();
87        let is_dir = path.is_dir();
88
89        if !is_dir {
90            if matches!(
91                name.as_str(),
92                "install.sh" | "install.bash" | "install.zsh" | "Brewfile"
93            ) {
94                has_install = true;
95            } else if is_shell_filename(&name) {
96                has_shell = true;
97            }
98            // Otherwise it's a default-symlink file — no flag to set;
99            // any non-empty pack with no shell/install evidence falls
100            // through to ConfigOnly below.
101        } else if name == "bin" {
102            has_shell = true;
103        }
104    }
105
106    if !any {
107        return PackKind::Empty;
108    }
109    match (has_shell, has_install) {
110        (false, false) => PackKind::ConfigOnly,
111        (true, false) => PackKind::ConfigPlusShell,
112        (false, true) => PackKind::ConfigPlusInstall,
113        (true, true) => PackKind::ConfigPlusShellAndInstall,
114    }
115}
116
117fn is_shell_filename(name: &str) -> bool {
118    let stems = ["aliases", "profile", "login", "env"];
119    let exts = [".sh", ".bash", ".zsh"];
120    for stem in stems {
121        for ext in exts {
122            if name == format!("{stem}{ext}") {
123                return true;
124            }
125        }
126    }
127    false
128}
129
130// ── Discover & recommend ────────────────────────────────────────
131
132/// Summary line for one pack as shown in the tutorial.
133#[derive(Debug, Clone, Serialize)]
134pub struct TutorialPack {
135    pub name: String,
136    pub kind: String,
137    pub recommended: bool,
138}
139
140/// Discover packs in the active context and classify each one.
141///
142/// Returns the list in scan order with the recommended starter pack
143/// flagged. If no pack is recommendable (only empty packs), no entry
144/// has `recommended = true`.
145pub fn discover_and_classify(ctx: &ExecutionContext) -> Result<Vec<TutorialPack>> {
146    let root_config = ctx.config_manager.root_config()?;
147    let scanned = packs::scan_packs(
148        ctx.fs.as_ref(),
149        ctx.paths.dotfiles_root(),
150        &root_config.pack.ignore,
151    )?;
152
153    let mut entries: Vec<(String, PackKind, packs::Pack)> = scanned
154        .packs
155        .into_iter()
156        .map(|p| {
157            let kind = classify_pack(&p);
158            (p.display_name.clone(), kind, p)
159        })
160        .collect();
161
162    // Pick the recommended starter — best rank wins. Ties broken
163    // by scan order (which is alphabetical).
164    let recommended_idx = entries
165        .iter()
166        .enumerate()
167        .filter(|(_, (_, kind, _))| !matches!(kind, PackKind::Empty))
168        .min_by_key(|(_, (_, kind, _))| kind.starter_rank())
169        .map(|(i, _)| i);
170
171    let result = entries
172        .drain(..)
173        .enumerate()
174        .map(|(i, (name, kind, _))| TutorialPack {
175            name,
176            kind: kind.label().to_string(),
177            recommended: Some(i) == recommended_idx,
178        })
179        .collect();
180
181    Ok(result)
182}
183
184// ── Shell integration detection ─────────────────────────────────
185
186/// What we found out about the `eval "$(dodot init-sh)"` line.
187#[derive(Debug, Clone, Serialize)]
188pub struct ShellIntegration {
189    /// Detected user shell (`zsh`, `bash`, `fish`, `unknown`).
190    pub shell_kind: String,
191    /// Path to the rc file we'd suggest editing (display form).
192    pub rc_path: String,
193    /// Absolute path of the rc file, for actual writes.
194    #[serde(skip)]
195    pub rc_path_abs: PathBuf,
196    /// True if the eval line is already present in the rc file.
197    pub line_present: bool,
198    /// The full eval line we'd suggest adding.
199    pub eval_line: String,
200}
201
202/// Detect the shell init situation for the user.
203///
204/// Reads `$SHELL`, picks a likely rc file, checks whether the
205/// `dodot init-sh` eval line is already there. Pure read-only.
206pub fn detect_shell_integration(home: &Path) -> ShellIntegration {
207    let shell_env = std::env::var("SHELL").unwrap_or_default();
208    let shell_kind = shell_env.rsplit('/').next().unwrap_or("").to_lowercase();
209
210    let (kind, rc_rel) = match shell_kind.as_str() {
211        "zsh" => ("zsh", ".zshrc"),
212        "bash" => ("bash", ".bashrc"),
213        "fish" => ("fish", ".config/fish/config.fish"),
214        _ => ("unknown", ".profile"),
215    };
216
217    let rc_path_abs = home.join(rc_rel);
218    let display = format!("~/{rc_rel}");
219    let eval_line = if kind == "fish" {
220        "dodot init-sh | source".to_string()
221    } else {
222        r#"eval "$(dodot init-sh)""#.to_string()
223    };
224
225    let line_present = std::fs::read_to_string(&rc_path_abs)
226        .map(|c| c.contains("dodot init-sh"))
227        .unwrap_or(false);
228
229    ShellIntegration {
230        shell_kind: kind.to_string(),
231        rc_path: display,
232        rc_path_abs,
233        line_present,
234        eval_line,
235    }
236}
237
238/// Append the eval line to the user's rc file with a header comment.
239/// Idempotent: returns Ok without writing if the line is already there.
240pub fn append_shell_integration(integ: &ShellIntegration) -> Result<()> {
241    if integ.line_present {
242        return Ok(());
243    }
244    if let Some(parent) = integ.rc_path_abs.parent() {
245        if !parent.exists() {
246            std::fs::create_dir_all(parent)
247                .map_err(|e| crate::DodotError::Other(format!("create rc parent: {e}")))?;
248        }
249    }
250    let existing = std::fs::read_to_string(&integ.rc_path_abs).unwrap_or_default();
251    let mut new = existing;
252    if !new.is_empty() && !new.ends_with('\n') {
253        new.push('\n');
254    }
255    new.push_str("\n# dodot — load packs into this shell session\n");
256    new.push_str(&integ.eval_line);
257    new.push('\n');
258    std::fs::write(&integ.rc_path_abs, new)
259        .map_err(|e| crate::DodotError::Other(format!("write rc: {e}")))?;
260    Ok(())
261}
262
263// ── State persistence ───────────────────────────────────────────
264
265/// Persisted between tutorial invocations so users can resume.
266#[derive(Debug, Clone, Serialize, Deserialize, Default)]
267pub struct TutorialState {
268    pub step_id: String,
269    pub pack: Option<String>,
270    pub started_at: Option<String>,
271}
272
273/// Path where tutorial state is stored.
274pub fn state_path(paths: &dyn Pather) -> PathBuf {
275    paths.data_dir().join("tutorial.json")
276}
277
278pub fn load_state(paths: &dyn Pather) -> Option<TutorialState> {
279    let path = state_path(paths);
280    let contents = std::fs::read_to_string(&path).ok()?;
281    serde_json::from_str(&contents).ok()
282}
283
284pub fn save_state(paths: &dyn Pather, state: &TutorialState) -> Result<()> {
285    let path = state_path(paths);
286    if let Some(parent) = path.parent() {
287        std::fs::create_dir_all(parent)
288            .map_err(|e| crate::DodotError::Other(format!("create state dir: {e}")))?;
289    }
290    let s = serde_json::to_string_pretty(state)
291        .map_err(|e| crate::DodotError::Other(format!("serialize state: {e}")))?;
292    std::fs::write(&path, s).map_err(|e| crate::DodotError::Other(format!("write state: {e}")))?;
293    Ok(())
294}
295
296pub fn clear_state(paths: &dyn Pather) -> Result<()> {
297    let path = state_path(paths);
298    if path.exists() {
299        std::fs::remove_file(&path)
300            .map_err(|e| crate::DodotError::Other(format!("remove state: {e}")))?;
301    }
302    Ok(())
303}
304
305// ── Tutorial Ctx ────────────────────────────────────────────────
306
307/// Serializable context passed to step templates. The CLI driver
308/// mutates this between steps; templates read fields by name.
309#[derive(Debug, Clone, Serialize, Default)]
310pub struct TutorialCtx {
311    pub dotfiles_root: String,
312    pub via: String,
313    pub packs: Vec<TutorialPack>,
314    pub chosen_pack: Option<String>,
315    pub chosen_pack_kind: Option<String>,
316    pub has_shell_files: bool,
317    pub has_install_files: bool,
318    pub status_output: Option<String>,
319    pub dry_run_output: Option<String>,
320    pub up_output: Option<String>,
321    pub shell_integration: Option<ShellIntegration>,
322    pub eval_line: String,
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    use std::path::PathBuf;
330
331    fn write(p: &PathBuf, body: &str) {
332        if let Some(parent) = p.parent() {
333            std::fs::create_dir_all(parent).unwrap();
334        }
335        std::fs::write(p, body).unwrap();
336    }
337
338    #[test]
339    fn classify_config_only_pack() {
340        let dir = tempfile::tempdir().unwrap();
341        let pack_path = dir.path().join("vim");
342        std::fs::create_dir_all(&pack_path).unwrap();
343        write(&pack_path.join("vimrc"), "set nu");
344        let pack = packs::Pack::new(
345            "vim".into(),
346            pack_path,
347            crate::handlers::HandlerConfig::default(),
348        );
349        assert_eq!(classify_pack(&pack), PackKind::ConfigOnly);
350    }
351
352    #[test]
353    fn classify_config_plus_shell_pack() {
354        let dir = tempfile::tempdir().unwrap();
355        let pack_path = dir.path().join("zsh");
356        std::fs::create_dir_all(&pack_path).unwrap();
357        write(&pack_path.join("aliases.sh"), "alias ll='ls -l'");
358        let pack = packs::Pack::new(
359            "zsh".into(),
360            pack_path,
361            crate::handlers::HandlerConfig::default(),
362        );
363        assert_eq!(classify_pack(&pack), PackKind::ConfigPlusShell);
364    }
365
366    #[test]
367    fn classify_config_plus_install_pack() {
368        let dir = tempfile::tempdir().unwrap();
369        let pack_path = dir.path().join("dev");
370        std::fs::create_dir_all(&pack_path).unwrap();
371        write(&pack_path.join("install.sh"), "echo");
372        write(&pack_path.join("config"), "k=v");
373        let pack = packs::Pack::new(
374            "dev".into(),
375            pack_path,
376            crate::handlers::HandlerConfig::default(),
377        );
378        assert_eq!(classify_pack(&pack), PackKind::ConfigPlusInstall);
379    }
380
381    #[test]
382    fn classify_empty_pack() {
383        let dir = tempfile::tempdir().unwrap();
384        let pack_path = dir.path().join("empty");
385        std::fs::create_dir_all(&pack_path).unwrap();
386        let pack = packs::Pack::new(
387            "empty".into(),
388            pack_path,
389            crate::handlers::HandlerConfig::default(),
390        );
391        assert_eq!(classify_pack(&pack), PackKind::Empty);
392    }
393
394    #[test]
395    fn detect_shell_with_no_rc_file_reports_absent() {
396        let dir = tempfile::tempdir().unwrap();
397        // Force a known shell — .zshrc doesn't exist in this temp HOME.
398        std::env::set_var("SHELL", "/bin/zsh");
399        let integ = detect_shell_integration(dir.path());
400        assert_eq!(integ.shell_kind, "zsh");
401        assert!(!integ.line_present);
402        assert!(integ.eval_line.contains("dodot init-sh"));
403    }
404}