1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PackKind {
28 ConfigOnly,
30 ConfigPlusShell,
32 ConfigPlusInstall,
34 ConfigPlusShellAndInstall,
36 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 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
63pub 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 } 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#[derive(Debug, Clone, Serialize)]
134pub struct TutorialPack {
135 pub name: String,
136 pub kind: String,
137 pub recommended: bool,
138}
139
140pub 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 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#[derive(Debug, Clone, Serialize)]
188pub struct ShellIntegration {
189 pub shell_kind: String,
191 pub rc_path: String,
193 #[serde(skip)]
195 pub rc_path_abs: PathBuf,
196 pub line_present: bool,
198 pub eval_line: String,
200}
201
202pub 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
238pub 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#[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
273pub 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#[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 let _g = crate::testing::ShellEnvGuard::set("/bin/zsh");
403 let integ = detect_shell_integration(dir.path());
404 assert_eq!(integ.shell_kind, "zsh");
405 assert!(!integ.line_present);
406 assert!(integ.eval_line.contains("dodot init-sh"));
407 }
408}