mod picker;
mod preview;
mod rc;
use std::io::{self, IsTerminal, stdout};
use std::path::{Path, PathBuf};
use crate::paths;
use crate::shell::{self, Shell};
use crate::templates::{self, Template, TemplateContext};
use crate::theme::presets as theme_presets;
#[derive(Debug, Default, Clone)]
pub struct InstallOptions {
pub shell: Option<Shell>,
pub home_template: Option<String>,
pub project_template: Option<String>,
pub theme: Option<String>,
pub bg: Option<bool>,
pub wait: Option<bool>,
pub config_dir: Option<PathBuf>,
pub rc_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy)]
struct SettingsChoices {
theme: Option<&'static str>,
bg: bool,
wait: bool,
}
impl SettingsChoices {
const DEFAULT: Self = Self {
theme: None,
bg: true,
wait: false,
};
}
pub fn run(opts: InstallOptions) -> io::Result<()> {
let shell = resolve_shell(opts.shell)?;
let home_dir = resolve_home_dir(opts.config_dir.clone())?;
let destinations = Destinations::for_home_dir(&home_dir);
let need_templates = opts.home_template.is_none() || opts.project_template.is_none();
let need_settings_picker = opts.theme.is_none() && opts.bg.is_none() && opts.wait.is_none();
let theme_from_flag = match opts.theme.as_deref() {
Some(name) => Some(Some(validate_theme(name)?)),
None => None,
};
let picker_ran = is_tty() && (need_templates || need_settings_picker);
let (home, project, settings_choices) = if picker_ran {
let pre_home = match opts.home_template.as_deref() {
Some(name) => Some(select_template(Some(name), TemplateContext::Home)?),
None => None,
};
let needs = picker::Needs {
templates: need_templates,
settings: need_settings_picker,
};
let Some(selection) = picker::run(needs, pre_home)? else {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"install cancelled",
));
};
let home = match selection.templates.as_ref() {
Some(p) => p.home,
None => select_template(opts.home_template.as_deref(), TemplateContext::Home)?,
};
let project = match selection.templates.as_ref() {
Some(p) => p.project,
None => select_template(opts.project_template.as_deref(), TemplateContext::Project)?,
};
let settings = selection
.settings
.unwrap_or_else(|| merge_settings_flags(&opts, theme_from_flag));
(home, project, settings)
} else {
let home = select_template(opts.home_template.as_deref(), TemplateContext::Home)?;
let project = select_template(opts.project_template.as_deref(), TemplateContext::Project)?;
let settings = merge_settings_flags(&opts, theme_from_flag);
(home, project, settings)
};
if picker_ran {
let rc_path = opts
.rc_path
.clone()
.or_else(|| shell::default_rc_path(shell));
let summary = picker::Summary {
shell: shell.as_str().to_string(),
home_template: home.name.to_string(),
project_template: project.name.to_string(),
theme: settings_choices.theme.unwrap_or("default").to_string(),
bg: settings_choices.bg,
wait: settings_choices.wait,
files: vec![
destinations.home_dashboard.clone(),
destinations.project_dashboard.clone(),
destinations.settings.clone(),
],
rc_path,
};
if !picker::confirm(summary)? {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"install cancelled",
));
}
}
ensure_parent_dirs(&destinations)?;
ensure_secrets_gitignore(&home_dir)?;
let mut write_report = write_dashboards(&destinations, home, project)?;
let settings_report = write_settings(
&destinations.settings,
settings_choices,
&mut write_report.backups,
)?;
let rc_report = rc::wire_shell_rc(shell, opts.rc_path.clone())?;
print_summary(SummaryContext {
shell,
home,
project,
dest: &destinations,
write: &write_report,
settings: &settings_report,
rc: &rc_report,
settings_choices,
});
Ok(())
}
fn resolve_shell(override_shell: Option<Shell>) -> io::Result<Shell> {
if let Some(shell) = override_shell {
return Ok(shell);
}
if let Some(shell) = shell::detect_shell(|k| std::env::var(k).ok()) {
return Ok(shell);
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"could not detect your shell — pass --shell <bash|zsh|fish|powershell|nushell>",
))
}
fn merge_settings_flags(
opts: &InstallOptions,
theme_from_flag: Option<Option<&'static str>>,
) -> SettingsChoices {
SettingsChoices {
theme: theme_from_flag.unwrap_or(None),
bg: opts.bg.unwrap_or(SettingsChoices::DEFAULT.bg),
wait: opts.wait.unwrap_or(SettingsChoices::DEFAULT.wait),
}
}
fn validate_theme(name: &str) -> io::Result<&'static str> {
theme_presets::KNOWN
.iter()
.copied()
.find(|k| *k == name)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"unknown theme `{name}` (available: {})",
theme_presets::KNOWN.join(", ")
),
)
})
}
fn select_template(name: Option<&str>, context: TemplateContext) -> io::Result<&'static Template> {
let Some(name) = name else {
let label = match context {
TemplateContext::Home => "--home-template",
TemplateContext::Project => "--project-template",
};
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{label} is required in non-interactive mode ({} available)",
catalog_hint(context)
),
));
};
match templates::find(name) {
Some(t) if t.context == context => Ok(t),
Some(t) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"template `{name}` is a {:?} template; expected a {:?} template",
t.context, context
),
)),
None => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"unknown template `{name}` (available: {})",
catalog_hint(context)
),
)),
}
}
fn catalog_hint(context: TemplateContext) -> String {
templates::for_context(context)
.map(|t| t.name)
.collect::<Vec<_>>()
.join(", ")
}
fn resolve_home_dir(override_dir: Option<PathBuf>) -> io::Result<PathBuf> {
if let Some(d) = override_dir {
return Ok(d);
}
paths::splashboard_home().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"could not locate $HOME — set $HOME or $SPLASHBOARD_HOME",
)
})
}
fn is_tty() -> bool {
stdout().is_terminal() && io::stdin().is_terminal()
}
struct Destinations {
settings: PathBuf,
home_dashboard: PathBuf,
project_dashboard: PathBuf,
}
impl Destinations {
fn for_home_dir(dir: &Path) -> Self {
Self {
settings: dir.join("settings.toml"),
home_dashboard: dir.join("home.dashboard.toml"),
project_dashboard: dir.join("project.dashboard.toml"),
}
}
}
fn ensure_secrets_gitignore(home_dir: &Path) -> io::Result<()> {
let path = home_dir.join(".gitignore");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
if existing
.lines()
.any(|l| l.trim() == "secrets.toml" || l.trim() == "/secrets.toml")
{
return Ok(());
}
let mut next = existing;
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
if next.is_empty() {
next.push_str("# Written by `splashboard install` — keeps tokens out of dotfiles repos.\n");
}
next.push_str("secrets.toml\n");
std::fs::write(&path, next)
}
fn ensure_parent_dirs(dest: &Destinations) -> io::Result<()> {
for path in [
&dest.settings,
&dest.home_dashboard,
&dest.project_dashboard,
] {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WriteOutcome {
Created,
Overwrote,
Unchanged,
}
struct DashboardWriteReport {
home: WriteOutcome,
project: WriteOutcome,
backups: Vec<PathBuf>,
}
fn write_dashboards(
dest: &Destinations,
home: &Template,
project: &Template,
) -> io::Result<DashboardWriteReport> {
let mut backups = Vec::new();
let home_outcome = write_template(&dest.home_dashboard, home.body, &mut backups)?;
let project_outcome = write_template(&dest.project_dashboard, project.body, &mut backups)?;
Ok(DashboardWriteReport {
home: home_outcome,
project: project_outcome,
backups,
})
}
fn write_template(path: &Path, body: &str, backups: &mut Vec<PathBuf>) -> io::Result<WriteOutcome> {
if path.exists() {
if std::fs::read_to_string(path).ok().as_deref() == Some(body) {
return Ok(WriteOutcome::Unchanged);
}
let backup = path.with_extension(backup_extension(path));
std::fs::rename(path, &backup)?;
backups.push(backup);
std::fs::write(path, body)?;
return Ok(WriteOutcome::Overwrote);
}
std::fs::write(path, body)?;
Ok(WriteOutcome::Created)
}
fn backup_extension(path: &Path) -> String {
format!(
"{}.bak",
path.extension().and_then(|e| e.to_str()).unwrap_or("bak")
)
}
fn write_settings(
path: &Path,
choices: SettingsChoices,
backups: &mut Vec<PathBuf>,
) -> io::Result<WriteOutcome> {
let body = build_settings_toml(choices);
write_template(path, &body, backups)
}
fn build_settings_toml(choices: SettingsChoices) -> String {
let mut out = String::new();
out.push_str(
"# Written by `splashboard install`. User preferences that apply to every dashboard.\n\
# Safe to edit by hand. Re-running `splashboard install` leaves this file alone\n\
# unless you pass `--yes`, which backs it up as `settings.toml.bak` before rewriting.\n\n",
);
let mut wrote_general = false;
if choices.wait != SettingsChoices::DEFAULT.wait {
out.push_str("[general]\n");
out.push_str(&format!("wait_for_fresh = {}\n\n", choices.wait));
wrote_general = true;
}
let needs_theme_section = choices.theme.is_some() || !choices.bg;
if needs_theme_section {
if !wrote_general {
}
out.push_str("[theme]\n");
if let Some(name) = choices.theme {
out.push_str(&format!("preset = \"{name}\"\n"));
}
if !choices.bg {
out.push_str("bg = \"reset\"\n");
out.push_str("bg_subtle = \"reset\"\n");
}
out.push('\n');
}
out
}
struct SummaryContext<'a> {
shell: Shell,
home: &'a Template,
project: &'a Template,
dest: &'a Destinations,
write: &'a DashboardWriteReport,
settings: &'a WriteOutcome,
rc: &'a rc::RcReport,
settings_choices: SettingsChoices,
}
fn print_summary(ctx: SummaryContext<'_>) {
let SummaryContext {
shell,
home,
project,
dest,
write,
settings,
rc,
settings_choices,
} = ctx;
println!("splashboard install — done.");
println!();
println!("Shell: {}", shell.as_str());
println!(
"Templates: {} (home) · {} (project)",
home.name, project.name
);
println!(
"Settings: theme = {} · bg = {} · wait_for_fresh = {}",
settings_choices.theme.unwrap_or("default"),
if settings_choices.bg {
"on"
} else {
"off (terminal bg shows through)"
},
settings_choices.wait,
);
println!();
println!("Files:");
println!(
" {:8} {}",
label_for_outcome(write.home),
dest.home_dashboard.display()
);
println!(
" {:8} {}",
label_for_outcome(write.project),
dest.project_dashboard.display()
);
println!(
" {:8} {}",
label_for_outcome(*settings),
dest.settings.display()
);
for b in &write.backups {
println!(" backup {}", b.display());
}
rc.describe();
println!();
println!("Next steps:");
println!(
" • Start a new shell (or `source {}`) to see the splash.",
rc.rc_path_display()
);
println!(" • Browse widgets: `splashboard catalog`.");
println!(
" • Re-run with a different preset: `splashboard install --home-template <name> --project-template <name>` (old files land in `.bak`)."
);
}
fn label_for_outcome(outcome: WriteOutcome) -> &'static str {
match outcome {
WriteOutcome::Created => "created",
WriteOutcome::Overwrote => "updated",
WriteOutcome::Unchanged => "unchanged",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::TEST_ENV_LOCK;
use tempfile::tempdir;
struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
restore: Vec<(&'static str, Option<String>)>,
}
impl EnvGuard {
fn set(pairs: Vec<(&'static str, Option<String>)>) -> Self {
let lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let restore = pairs
.into_iter()
.map(|(key, value)| {
let previous = std::env::var(key).ok();
match value {
Some(value) => unsafe { std::env::set_var(key, value) },
None => unsafe { std::env::remove_var(key) },
}
(key, previous)
})
.collect();
Self {
_lock: lock,
restore,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
self.restore.iter().for_each(|(key, value)| match value {
Some(value) => unsafe { std::env::set_var(key, value) },
None => unsafe { std::env::remove_var(key) },
});
}
}
#[test]
fn select_template_rejects_context_mismatch() {
let err = select_template(Some("home_splash"), TemplateContext::Project).unwrap_err();
assert!(err.to_string().contains("Home template"));
}
#[test]
fn select_template_accepts_matching_context() {
let t = select_template(Some("home_splash"), TemplateContext::Home).unwrap();
assert_eq!(t.name, "home_splash");
}
#[test]
fn select_template_rejects_unknown_name() {
let err = select_template(Some("nonsense"), TemplateContext::Home).unwrap_err();
assert!(err.to_string().contains("unknown template"));
}
#[test]
fn select_template_requires_name_for_both_contexts() {
let home = select_template(None, TemplateContext::Home).unwrap_err();
assert!(home.to_string().contains("--home-template"));
let project = select_template(None, TemplateContext::Project).unwrap_err();
assert!(project.to_string().contains("--project-template"));
}
#[test]
fn merge_settings_flags_preserves_explicit_and_default_values() {
let explicit = merge_settings_flags(
&InstallOptions {
bg: Some(false),
wait: Some(true),
..Default::default()
},
Some(Some("tokyo_night")),
);
assert_eq!(explicit.theme, Some("tokyo_night"));
assert!(!explicit.bg);
assert!(explicit.wait);
let default_theme = merge_settings_flags(&InstallOptions::default(), Some(None));
assert_eq!(default_theme.theme, None);
assert!(default_theme.bg);
assert!(!default_theme.wait);
}
#[test]
fn catalog_hint_lists_templates_for_requested_context() {
let home = catalog_hint(TemplateContext::Home);
assert!(home.contains("home_splash"));
assert!(!home.contains("project_github"));
let project = catalog_hint(TemplateContext::Project);
assert!(project.contains("project_github"));
assert!(!project.contains("home_splash"));
}
#[test]
fn destinations_and_parent_dirs_use_expected_paths() {
let dir = tempdir().unwrap();
let home = dir.path().join("nested").join("splashboard");
let dest = Destinations::for_home_dir(&home);
assert_eq!(dest.settings, home.join("settings.toml"));
assert_eq!(dest.home_dashboard, home.join("home.dashboard.toml"));
assert_eq!(dest.project_dashboard, home.join("project.dashboard.toml"));
ensure_parent_dirs(&dest).unwrap();
assert!(home.exists());
}
#[test]
fn write_dashboards_creates_missing_files() {
let dir = tempdir().unwrap();
let dest = Destinations::for_home_dir(dir.path());
let home = templates::find("home_splash").unwrap();
let project = templates::find("project_github").unwrap();
let report = write_dashboards(&dest, home, project).unwrap();
assert_eq!(report.home, WriteOutcome::Created);
assert_eq!(report.project, WriteOutcome::Created);
assert!(dest.home_dashboard.exists());
assert!(dest.project_dashboard.exists());
assert!(report.backups.is_empty());
}
#[test]
fn write_dashboards_overwrites_with_backup() {
let dir = tempdir().unwrap();
let dest = Destinations::for_home_dir(dir.path());
std::fs::write(&dest.home_dashboard, "# existing\n").unwrap();
let home = templates::find("home_splash").unwrap();
let project = templates::find("project_github").unwrap();
let report = write_dashboards(&dest, home, project).unwrap();
assert_eq!(report.home, WriteOutcome::Overwrote);
assert_eq!(report.project, WriteOutcome::Created);
assert_eq!(report.backups.len(), 1);
assert!(report.backups[0].exists());
assert_eq!(
std::fs::read_to_string(&report.backups[0]).unwrap(),
"# existing\n"
);
}
#[test]
fn write_dashboards_is_noop_when_content_unchanged() {
let dir = tempdir().unwrap();
let dest = Destinations::for_home_dir(dir.path());
let home = templates::find("home_splash").unwrap();
let project = templates::find("project_github").unwrap();
write_dashboards(&dest, home, project).unwrap();
let report = write_dashboards(&dest, home, project).unwrap();
assert_eq!(report.home, WriteOutcome::Unchanged);
assert_eq!(report.project, WriteOutcome::Unchanged);
assert!(report.backups.is_empty());
}
#[test]
fn write_settings_creates_when_missing() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.toml");
let mut backups = Vec::new();
assert_eq!(
write_settings(&path, SettingsChoices::DEFAULT, &mut backups).unwrap(),
WriteOutcome::Created
);
assert!(backups.is_empty());
}
#[test]
fn write_settings_backs_up_and_overwrites() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.toml");
std::fs::write(&path, "# prior settings\n").unwrap();
let mut backups = Vec::new();
let choices = SettingsChoices {
theme: Some("tokyo_night"),
bg: false,
wait: true,
};
assert_eq!(
write_settings(&path, choices, &mut backups).unwrap(),
WriteOutcome::Overwrote
);
assert_eq!(backups.len(), 1);
assert_eq!(
std::fs::read_to_string(&backups[0]).unwrap(),
"# prior settings\n"
);
let new_contents = std::fs::read_to_string(&path).unwrap();
assert!(new_contents.contains("preset = \"tokyo_night\""));
assert!(new_contents.contains("wait_for_fresh = true"));
}
#[test]
fn write_settings_is_noop_when_content_unchanged() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.toml");
let choices = SettingsChoices {
theme: Some("tokyo_night"),
bg: true,
wait: false,
};
let mut backups = Vec::new();
write_settings(&path, choices, &mut backups).unwrap();
let report = write_settings(&path, choices, &mut backups).unwrap();
assert_eq!(report, WriteOutcome::Unchanged);
assert!(backups.is_empty());
}
#[test]
fn build_settings_toml_emits_empty_for_defaults() {
let body = build_settings_toml(SettingsChoices::DEFAULT);
assert!(!body.contains("[general]"));
assert!(!body.contains("[theme]"));
assert!(body.contains("Written by `splashboard install`"));
}
#[test]
fn build_settings_toml_writes_theme_and_bg_reset() {
let body = build_settings_toml(SettingsChoices {
theme: Some("tokyo_night"),
bg: false,
wait: false,
});
assert!(body.contains("[theme]"));
assert!(body.contains("preset = \"tokyo_night\""));
assert!(body.contains("bg = \"reset\""));
assert!(body.contains("bg_subtle = \"reset\""));
assert!(!body.contains("[general]"));
}
#[test]
fn build_settings_toml_writes_wait_for_fresh() {
let body = build_settings_toml(SettingsChoices {
theme: None,
bg: true,
wait: true,
});
assert!(body.contains("[general]"));
assert!(body.contains("wait_for_fresh = true"));
assert!(!body.contains("[theme]"));
}
#[test]
fn validate_theme_accepts_known_preset() {
assert_eq!(validate_theme("tokyo_night").unwrap(), "tokyo_night");
}
#[test]
fn validate_theme_rejects_unknown_preset() {
let err = validate_theme("not-a-theme").unwrap_err();
assert!(err.to_string().contains("unknown theme"));
}
#[test]
fn resolve_shell_detects_known_shell_from_env() {
let _env = EnvGuard::set(vec![
("SHELL", Some("/bin/bash".into())),
("PSModulePath", None),
]);
assert_eq!(resolve_shell(None).unwrap(), Shell::Bash);
}
#[test]
fn resolve_shell_uses_powershell_fallback_and_errors_when_undetectable() {
let _powershell = EnvGuard::set(vec![
("SHELL", Some("/bin/ksh".into())),
(
"PSModulePath",
Some("C:\\Users\\x\\Documents\\PowerShell\\Modules".into()),
),
]);
assert_eq!(resolve_shell(None).unwrap(), Shell::Powershell);
drop(_powershell);
let _missing = EnvGuard::set(vec![
("SHELL", Some("/bin/ksh".into())),
("PSModulePath", None),
]);
let err = resolve_shell(None).unwrap_err();
assert!(err.to_string().contains("could not detect your shell"));
}
#[test]
fn resolve_home_dir_uses_env_when_override_is_missing() {
let dir = tempdir().unwrap();
let home_dir = dir.path().join("env-home");
let _env = EnvGuard::set(vec![(
"SPLASHBOARD_HOME",
Some(home_dir.display().to_string()),
)]);
assert_eq!(resolve_home_dir(None).unwrap(), home_dir);
}
#[test]
fn run_end_to_end_wires_everything_on_fresh_home() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".zshrc");
let home_dir = dir.path().join("splashboard_home");
let opts = InstallOptions {
shell: Some(Shell::Zsh),
home_template: Some("home_splash".into()),
project_template: Some("project_github".into()),
config_dir: Some(home_dir.clone()),
rc_path: Some(rc.clone()),
..Default::default()
};
super::run(opts).unwrap();
assert!(home_dir.join("home.dashboard.toml").exists());
assert!(home_dir.join("project.dashboard.toml").exists());
assert!(home_dir.join("settings.toml").exists());
assert!(rc.exists());
let rc_contents = std::fs::read_to_string(&rc).unwrap();
assert!(rc_contents.contains("splashboard init zsh"));
}
#[test]
fn run_end_to_end_applies_settings_flags() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".zshrc");
let home_dir = dir.path().join("splashboard_home");
let opts = InstallOptions {
shell: Some(Shell::Zsh),
home_template: Some("home_splash".into()),
project_template: Some("project_github".into()),
theme: Some("tokyo_night".into()),
bg: Some(false),
wait: Some(true),
config_dir: Some(home_dir.clone()),
rc_path: Some(rc.clone()),
};
super::run(opts).unwrap();
let settings = std::fs::read_to_string(home_dir.join("settings.toml")).unwrap();
assert!(settings.contains("preset = \"tokyo_night\""));
assert!(settings.contains("bg = \"reset\""));
assert!(settings.contains("wait_for_fresh = true"));
}
#[test]
fn run_end_to_end_regenerates_settings_on_rerun() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".zshrc");
let home_dir = dir.path().join("splashboard_home");
std::fs::create_dir_all(&home_dir).unwrap();
let settings_path = home_dir.join("settings.toml");
std::fs::write(&settings_path, "# hand-edited settings\n").unwrap();
let opts = InstallOptions {
shell: Some(Shell::Zsh),
home_template: Some("home_splash".into()),
project_template: Some("project_github".into()),
theme: Some("tokyo_night".into()),
config_dir: Some(home_dir.clone()),
rc_path: Some(rc.clone()),
..Default::default()
};
super::run(opts).unwrap();
let new_contents = std::fs::read_to_string(&settings_path).unwrap();
assert!(new_contents.contains("preset = \"tokyo_night\""));
let backup = home_dir.join("settings.toml.bak");
assert!(backup.exists(), "expected settings.toml.bak backup");
assert_eq!(
std::fs::read_to_string(&backup).unwrap(),
"# hand-edited settings\n"
);
}
#[test]
fn ensure_secrets_gitignore_creates_when_missing() {
let dir = tempdir().unwrap();
ensure_secrets_gitignore(dir.path()).unwrap();
let body = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(body.contains("secrets.toml"));
}
#[test]
fn ensure_secrets_gitignore_appends_to_existing() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "node_modules\n").unwrap();
ensure_secrets_gitignore(dir.path()).unwrap();
let body = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(body.contains("node_modules"));
assert!(body.contains("secrets.toml"));
}
#[test]
fn ensure_secrets_gitignore_inserts_missing_trailing_newline() {
let dir = tempdir().unwrap();
let path = dir.path().join(".gitignore");
std::fs::write(&path, "node_modules").unwrap();
ensure_secrets_gitignore(dir.path()).unwrap();
assert_eq!(
std::fs::read_to_string(path).unwrap(),
"node_modules\nsecrets.toml\n"
);
}
#[test]
fn ensure_secrets_gitignore_is_idempotent() {
let dir = tempdir().unwrap();
ensure_secrets_gitignore(dir.path()).unwrap();
let first = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
ensure_secrets_gitignore(dir.path()).unwrap();
let second = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert_eq!(first, second);
}
#[test]
fn ensure_secrets_gitignore_treats_slashed_entry_as_already_present() {
let dir = tempdir().unwrap();
let path = dir.path().join(".gitignore");
std::fs::write(&path, "node_modules\n/secrets.toml").unwrap();
ensure_secrets_gitignore(dir.path()).unwrap();
assert_eq!(
std::fs::read_to_string(path).unwrap(),
"node_modules\n/secrets.toml"
);
}
#[test]
fn backup_extension_and_labels_cover_all_variants() {
assert_eq!(
backup_extension(Path::new("home.dashboard.toml")),
"toml.bak"
);
assert_eq!(backup_extension(Path::new("settings")), "bak.bak");
assert_eq!(label_for_outcome(WriteOutcome::Created), "created");
assert_eq!(label_for_outcome(WriteOutcome::Overwrote), "updated");
assert_eq!(label_for_outcome(WriteOutcome::Unchanged), "unchanged");
}
#[test]
fn run_rejects_missing_templates_in_non_interactive_mode() {
let dir = tempdir().unwrap();
let home_dir = dir.path().join("splashboard_home");
let home_err = super::run(InstallOptions {
shell: Some(Shell::Zsh),
project_template: Some("project_github".into()),
config_dir: Some(home_dir.clone()),
rc_path: Some(dir.path().join(".zshrc")),
..Default::default()
})
.unwrap_err();
assert!(home_err.to_string().contains("--home-template"));
let project_err = super::run(InstallOptions {
shell: Some(Shell::Zsh),
home_template: Some("home_splash".into()),
config_dir: Some(home_dir),
rc_path: Some(dir.path().join(".zshrc")),
..Default::default()
})
.unwrap_err();
assert!(project_err.to_string().contains("--project-template"));
}
#[test]
fn run_rejects_unknown_theme_before_writing_files() {
let dir = tempdir().unwrap();
let home_dir = dir.path().join("splashboard_home");
let rc = dir.path().join(".zshrc");
let err = super::run(InstallOptions {
shell: Some(Shell::Zsh),
home_template: Some("home_splash".into()),
project_template: Some("project_github".into()),
theme: Some("not-a-theme".into()),
config_dir: Some(home_dir.clone()),
rc_path: Some(rc.clone()),
..Default::default()
})
.unwrap_err();
assert!(err.to_string().contains("unknown theme"));
assert!(!home_dir.exists());
assert!(!rc.exists());
}
#[test]
fn run_end_to_end_is_idempotent_on_second_run() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".zshrc");
let home_dir = dir.path().join("splashboard_home");
let opts = InstallOptions {
shell: Some(Shell::Zsh),
home_template: Some("home_splash".into()),
project_template: Some("project_github".into()),
theme: Some("tokyo_night".into()),
config_dir: Some(home_dir.clone()),
rc_path: Some(rc.clone()),
..Default::default()
};
super::run(opts.clone()).unwrap();
super::run(opts).unwrap();
assert!(!home_dir.join("home.dashboard.toml.bak").exists());
assert!(!home_dir.join("project.dashboard.toml.bak").exists());
assert!(!home_dir.join("settings.toml.bak").exists());
let rc_contents = std::fs::read_to_string(&rc).unwrap();
assert_eq!(rc_contents.matches("# >>> splashboard >>>").count(), 1);
}
}