use std::path::PathBuf;
use serde::Serialize;
use crate::packs::orchestration::ExecutionContext;
use crate::shell::activation::{self, ActivationNotice};
use crate::shell::probe;
use crate::shell::rc::{self, BlockOutcome, HookupShell, RcTarget};
use crate::{DodotError, Result};
#[derive(Debug, Clone, Default)]
pub struct InstallOptions {
pub write: bool,
pub rc: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
pub struct InstallResult {
pub shell: Option<String>,
pub rc_path: String,
pub rc_link_source: Option<String>,
pub rc_exists: bool,
pub hook_presence: String,
pub hook_line: String,
pub wrote: bool,
pub outcome: Option<String>,
pub notes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shell_hookup: Option<ActivationNotice>,
}
pub fn install(ctx: &ExecutionContext, opts: &InstallOptions) -> Result<InstallResult> {
let home = ctx.paths.home_dir();
let init_script = ctx.paths.init_script_path();
let hook_line = activation::hook_line(&init_script, home);
let shell = ctx.shell_env.hookup_shell();
if shell.is_none() && opts.rc.is_none() {
return Err(DodotError::Other(refusal(&ctx.shell_env.shell, &hook_line)));
}
let target = rc::resolve_rc(
ctx.fs.as_ref(),
home,
shell,
&ctx.shell_env,
opts.rc.as_deref(),
);
let mut notes = target.notes.clone();
let outcome = if opts.write {
Some(write_hookup(ctx, shell, &target, &hook_line, &mut notes)?)
} else {
None
};
let presence = rc::scan_hook_file(ctx.fs.as_ref(), &target.path);
let exists = target.exists || opts.write;
Ok(InstallResult {
shell: shell.map(|s| s.as_str().to_string()),
rc_path: rc::display_home_relative(&target.path, home),
rc_link_source: target
.link_source
.as_deref()
.map(|p| rc::display_home_relative(p, home)),
rc_exists: exists,
hook_presence: presence.as_str().to_string(),
hook_line,
wrote: opts.write,
outcome: outcome.map(|o| o.as_str().to_string()),
notes,
shell_hookup: verdict(ctx, opts, &target),
})
}
fn write_hookup(
ctx: &ExecutionContext,
shell: Option<HookupShell>,
target: &RcTarget,
hook_line: &str,
notes: &mut Vec<String>,
) -> Result<BlockOutcome> {
let home = ctx.paths.home_dir();
let outcome = rc::write_block(
ctx.fs.as_ref(),
&target.path,
&rc::render_block(&[hook_line]),
)?;
if outcome == BlockOutcome::Created {
notes.push(format!(
"created {}",
rc::display_home_relative(&target.path, home)
));
}
if shell == Some(HookupShell::Bash)
&& ctx.host_facts.os == "darwin"
&& target.nominal() == home.join(".bashrc")
{
if let Some(profile) =
rc::bash_chain_target(ctx.fs.as_ref(), home).filter(|p| p != &target.path)
{
rc::write_block(
ctx.fs.as_ref(),
&profile,
&rc::render_block(&[rc::BASH_CHAIN_LINE]),
)?;
notes.push(format!(
"macOS opens login shells: chained {} to ~/.bashrc",
rc::display_home_relative(&profile, home)
));
}
}
Ok(outcome)
}
fn verdict(
ctx: &ExecutionContext,
opts: &InstallOptions,
target: &RcTarget,
) -> Option<ActivationNotice> {
let reference = activation::read_script_generation(ctx.fs.as_ref(), ctx.paths.as_ref());
let evidence = activation::notice_for(
ctx.fs.as_ref(),
ctx.paths.as_ref(),
ctx.env_init_gen,
reference,
true,
false,
&ctx.shell_env,
);
let Some(timeout) = ctx.shell_probe.timeout().filter(|_| opts.write) else {
return evidence;
};
if !ctx.fs.exists(&ctx.paths.init_script_path()) {
return evidence;
}
probe::measure(
ctx.fs.as_ref(),
ctx.paths.as_ref(),
timeout,
&ctx.shell_env,
Some(&target.path),
reference,
evidence,
)
}
fn refusal(shell: &Option<String>, hook_line: &str) -> String {
let named = match shell.as_deref() {
Some(s) if !s.is_empty() => format!("your shell ({s})"),
_ => "your shell ($SHELL is not set)".to_string(),
};
format!(
"dodot can wire up bash and zsh; it will not guess an rc file for {named}. \
Add this line to whatever file your shell reads at startup: {hook_line} \
— or point dodot at it with `dodot install --write --rc <file>`."
)
}