use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::fs::Fs;
use crate::packs;
use crate::packs::orchestration::ExecutionContext;
use crate::paths::Pather;
use crate::shell::rc::{self, ShellEnv};
use crate::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackKind {
ConfigOnly,
ConfigPlusShell,
ConfigPlusInstall,
ConfigPlusShellAndInstall,
Empty,
}
impl PackKind {
pub fn label(self) -> &'static str {
match self {
PackKind::ConfigOnly => "config only",
PackKind::ConfigPlusShell => "config + shell",
PackKind::ConfigPlusInstall => "config + install",
PackKind::ConfigPlusShellAndInstall => "config + shell + install",
PackKind::Empty => "empty",
}
}
fn starter_rank(self) -> u8 {
match self {
PackKind::ConfigOnly => 0,
PackKind::ConfigPlusShell => 1,
PackKind::ConfigPlusInstall => 2,
PackKind::ConfigPlusShellAndInstall => 3,
PackKind::Empty => 99,
}
}
}
pub fn classify_pack(pack: &packs::Pack) -> PackKind {
let entries = match std::fs::read_dir(&pack.path) {
Ok(e) => e,
Err(_) => return PackKind::Empty,
};
let mut has_install = false;
let mut has_shell = false;
let mut any = false;
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
any = true;
let path = entry.path();
let is_dir = path.is_dir();
if !is_dir {
if matches!(
name.as_str(),
"install.sh" | "install.bash" | "install.zsh" | "Brewfile"
) {
has_install = true;
} else if is_shell_filename(&name) {
has_shell = true;
}
} else if name == "bin" {
has_shell = true;
}
}
if !any {
return PackKind::Empty;
}
match (has_shell, has_install) {
(false, false) => PackKind::ConfigOnly,
(true, false) => PackKind::ConfigPlusShell,
(false, true) => PackKind::ConfigPlusInstall,
(true, true) => PackKind::ConfigPlusShellAndInstall,
}
}
fn is_shell_filename(name: &str) -> bool {
name.ends_with(".sh") || name.ends_with(".bash") || name.ends_with(".zsh")
}
#[derive(Debug, Clone, Serialize)]
pub struct TutorialPack {
pub name: String,
pub kind: String,
pub recommended: bool,
}
pub fn discover_and_classify(ctx: &ExecutionContext) -> Result<Vec<TutorialPack>> {
let root_config = ctx.config_manager.root_config()?;
let scanned = packs::scan_packs(
ctx.fs.as_ref(),
ctx.paths.dotfiles_root(),
&root_config.pack.ignore,
)?;
let mut entries: Vec<(String, PackKind, packs::Pack)> = scanned
.packs
.into_iter()
.map(|p| {
let kind = classify_pack(&p);
(p.display_name.clone(), kind, p)
})
.collect();
let recommended_idx = entries
.iter()
.enumerate()
.filter(|(_, (_, kind, _))| !matches!(kind, PackKind::Empty))
.min_by_key(|(_, (_, kind, _))| kind.starter_rank())
.map(|(i, _)| i);
let result = entries
.drain(..)
.enumerate()
.map(|(i, (name, kind, _))| TutorialPack {
name,
kind: kind.label().to_string(),
recommended: Some(i) == recommended_idx,
})
.collect();
Ok(result)
}
#[derive(Debug, Clone, Serialize)]
pub struct ShellIntegration {
pub shell_kind: String,
pub supported: bool,
pub rc_path: String,
pub line_present: bool,
pub eval_line: String,
}
pub fn detect_shell_integration(
fs: &dyn Fs,
home: &Path,
shell_env: &ShellEnv,
) -> ShellIntegration {
let shell = shell_env.hookup_shell();
let shell_kind = match shell {
Some(s) => s.as_str().to_string(),
None => shell_env
.shell
.as_deref()
.and_then(|p| Path::new(p).file_name())
.and_then(|n| n.to_str())
.map(|s| s.trim_start_matches('-').to_lowercase())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown".to_string()),
};
let eval_line = if shell_kind == "fish" {
"dodot init-sh | source".to_string()
} else {
r#"eval "$(dodot init-sh)""#.to_string()
};
let (rc_path, line_present) = match shell {
Some(s) => {
let target = rc::resolve_rc(fs, home, Some(s), shell_env, None);
(
rc::display_home_relative(target.nominal(), home),
rc::scan_hook_file(fs, &target.path).is_present(),
)
}
None => (String::new(), false),
};
ShellIntegration {
shell_kind,
supported: shell.is_some(),
rc_path,
line_present,
eval_line,
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TutorialState {
pub step_id: String,
pub pack: Option<String>,
pub started_at: Option<String>,
}
pub fn state_path(paths: &dyn Pather) -> PathBuf {
paths.data_dir().join("tutorial.json")
}
pub fn load_state(paths: &dyn Pather) -> Option<TutorialState> {
let path = state_path(paths);
let contents = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&contents).ok()
}
pub fn save_state(paths: &dyn Pather, state: &TutorialState) -> Result<()> {
let path = state_path(paths);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| crate::DodotError::Other(format!("create state dir: {e}")))?;
}
let s = serde_json::to_string_pretty(state)
.map_err(|e| crate::DodotError::Other(format!("serialize state: {e}")))?;
std::fs::write(&path, s).map_err(|e| crate::DodotError::Other(format!("write state: {e}")))?;
Ok(())
}
pub fn clear_state(paths: &dyn Pather) -> Result<()> {
let path = state_path(paths);
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| crate::DodotError::Other(format!("remove state: {e}")))?;
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct TutorialCtx {
pub dotfiles_root: String,
pub via: String,
pub packs: Vec<TutorialPack>,
pub chosen_pack: Option<String>,
pub chosen_pack_kind: Option<String>,
pub has_shell_files: bool,
pub has_install_files: bool,
pub status_output: Option<String>,
pub dry_run_output: Option<String>,
pub up_output: Option<String>,
pub shell_integration: Option<ShellIntegration>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn write(p: &PathBuf, body: &str) {
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, body).unwrap();
}
#[test]
fn classify_config_only_pack() {
let dir = tempfile::tempdir().unwrap();
let pack_path = dir.path().join("vim");
std::fs::create_dir_all(&pack_path).unwrap();
write(&pack_path.join("vimrc"), "set nu");
let pack = packs::Pack::new(
"vim".into(),
pack_path,
crate::handlers::HandlerConfig::default(),
);
assert_eq!(classify_pack(&pack), PackKind::ConfigOnly);
}
#[test]
fn classify_config_plus_shell_pack() {
let dir = tempfile::tempdir().unwrap();
let pack_path = dir.path().join("zsh");
std::fs::create_dir_all(&pack_path).unwrap();
write(&pack_path.join("aliases.sh"), "alias ll='ls -l'");
let pack = packs::Pack::new(
"zsh".into(),
pack_path,
crate::handlers::HandlerConfig::default(),
);
assert_eq!(classify_pack(&pack), PackKind::ConfigPlusShell);
}
#[test]
fn classify_pack_with_arbitrary_shell_extension_filenames() {
let dir = tempfile::tempdir().unwrap();
let pack_path = dir.path().join("shell");
std::fs::create_dir_all(&pack_path).unwrap();
write(&pack_path.join("path.sh"), "export PATH=...");
write(&pack_path.join("functions.zsh"), "function f() {}");
write(&pack_path.join("50_prompt.bash"), "PS1='>'");
let pack = packs::Pack::new(
"shell".into(),
pack_path,
crate::handlers::HandlerConfig::default(),
);
assert_eq!(classify_pack(&pack), PackKind::ConfigPlusShell);
}
#[test]
fn classify_config_plus_install_pack() {
let dir = tempfile::tempdir().unwrap();
let pack_path = dir.path().join("dev");
std::fs::create_dir_all(&pack_path).unwrap();
write(&pack_path.join("install.sh"), "echo");
write(&pack_path.join("config"), "k=v");
let pack = packs::Pack::new(
"dev".into(),
pack_path,
crate::handlers::HandlerConfig::default(),
);
assert_eq!(classify_pack(&pack), PackKind::ConfigPlusInstall);
}
#[test]
fn classify_empty_pack() {
let dir = tempfile::tempdir().unwrap();
let pack_path = dir.path().join("empty");
std::fs::create_dir_all(&pack_path).unwrap();
let pack = packs::Pack::new(
"empty".into(),
pack_path,
crate::handlers::HandlerConfig::default(),
);
assert_eq!(classify_pack(&pack), PackKind::Empty);
}
fn zsh_env() -> ShellEnv {
ShellEnv {
shell: Some("/bin/zsh".into()),
zdotdir: None,
}
}
fn os_fs() -> crate::fs::OsFs {
crate::fs::OsFs::new()
}
#[test]
fn detect_shell_with_no_rc_file_reports_absent() {
let dir = tempfile::tempdir().unwrap();
let integ = detect_shell_integration(&os_fs(), dir.path(), &zsh_env());
assert_eq!(integ.shell_kind, "zsh");
assert!(integ.supported);
assert_eq!(integ.rc_path, "~/.zshrc");
assert!(!integ.line_present);
}
#[test]
fn detect_shell_sees_both_hookup_forms() {
for rc in [
"eval \"$(dodot init-sh)\"\n",
"# >>> dodot shell hookup >>>\n\
[ -f \"$HOME/.local/share/dodot/shell/dodot-init.sh\" ] \
&& . \"$HOME/.local/share/dodot/shell/dodot-init.sh\"\n\
# <<< dodot shell hookup <<<\n",
] {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".zshrc"), rc).unwrap();
assert!(
detect_shell_integration(&os_fs(), dir.path(), &zsh_env()).line_present,
"should recognise this hookup: {rc}"
);
}
}
#[test]
fn detect_shell_ignores_commented_out_hookups() {
for rc in [
"# eval \"$(dodot init-sh)\"\n",
" # . \"$HOME/.local/share/dodot/shell/dodot-init.sh\"\n",
"# see docs: dodot init-sh prints the script\n",
] {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".zshrc"), rc).unwrap();
assert!(
!detect_shell_integration(&os_fs(), dir.path(), &zsh_env()).line_present,
"should not count this as a hookup: {rc}"
);
}
}
#[test]
fn detect_shell_honours_zdotdir() {
let dir = tempfile::tempdir().unwrap();
let zdot = dir.path().join("cfg/zsh");
let env = ShellEnv {
shell: Some("/bin/zsh".into()),
zdotdir: Some(zdot.display().to_string()),
};
write(&zdot.join(".zshrc"), "eval \"$(dodot init-sh)\"\n");
let integ = detect_shell_integration(&os_fs(), dir.path(), &env);
assert!(integ.line_present, "hook in $ZDOTDIR/.zshrc must count");
std::fs::remove_file(zdot.join(".zshrc")).unwrap();
write(&dir.path().join(".zshrc"), "eval \"$(dodot init-sh)\"\n");
let integ = detect_shell_integration(&os_fs(), dir.path(), &env);
assert!(
!integ.line_present,
"a hook in ~/.zshrc is dead when ZDOTDIR is set — it must not count"
);
}
#[test]
fn detect_shell_follows_symlinked_rc() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("dotfiles/zshrc");
write(&real, "eval \"$(dodot init-sh)\"\n");
std::os::unix::fs::symlink(&real, dir.path().join(".zshrc")).unwrap();
let integ = detect_shell_integration(&os_fs(), dir.path(), &zsh_env());
assert!(integ.line_present, "hook behind a symlinked rc must count");
assert_eq!(
integ.rc_path, "~/.zshrc",
"display names the file zsh opens"
);
}
#[test]
fn detect_unsupported_shell_is_informational_only() {
let dir = tempfile::tempdir().unwrap();
let env = ShellEnv {
shell: Some("/usr/bin/fish".into()),
zdotdir: None,
};
let integ = detect_shell_integration(&os_fs(), dir.path(), &env);
assert_eq!(integ.shell_kind, "fish");
assert!(!integ.supported);
assert!(integ.rc_path.is_empty());
assert!(!integ.line_present);
assert_eq!(integ.eval_line, "dodot init-sh | source");
}
}