use std::io::Write;
use std::path::{Path, PathBuf};
use crate::app::init as runex_init;
use crate::domain::sanitize::sanitize_for_display;
use crate::domain::shell::Shell;
use crate::infra::env::HomeDirResolver;
use crate::resolve_config_opt;
use crate::util::prompt::{prompt_confirm, read_rc_content};
use crate::util::shell::detect_shell;
use crate::{CmdOutcome, CmdResult};
pub(crate) fn handle(
config_path: PathBuf,
shell_override: Option<&str>,
yes: bool,
env: &dyn HomeDirResolver,
) -> CmdResult {
let msg = format!("Create config at {}?", sanitize_for_display(&config_path.display().to_string()));
if yes || prompt_confirm(&msg) {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&config_path)
{
Ok(mut f) => {
f.write_all(runex_init::default_config_content().as_bytes())?;
println!("Created: {}", sanitize_for_display(&config_path.display().to_string()));
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
println!("Config already exists: {}", sanitize_for_display(&config_path.display().to_string()));
}
Err(e) => return Err(e.into()),
}
} else {
println!("Skipped config creation.");
}
let shell = if let Some(s) = shell_override {
s.parse::<Shell>().map_err(|e: crate::domain::shell::ShellParseError| {
Box::<dyn std::error::Error>::from(e.to_string())
})?
} else {
detect_shell().unwrap_or_else(|| {
eprintln!(
"Could not detect shell. Defaulting to bash. \
Use `runex init <shell>` (e.g. `runex init pwsh`) to target a specific shell."
);
Shell::Bash
})
};
let rc_path_for_next_steps = match shell {
Shell::Clink => {
install_clink_lua(yes, &config_path, env)?;
None
}
_ => install_rcfile_integration(shell, yes, env)?,
};
println!();
println!("{}", runex_init::next_steps_message(shell, rc_path_for_next_steps.as_deref()));
Ok(CmdOutcome::Ok)
}
fn install_rcfile_integration(
shell: Shell,
yes: bool,
env: &dyn HomeDirResolver,
) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
let Some(rc_path) = crate::infra::env::rc_file_for(shell, env) else {
println!(
"Shell integration for {:?} must be added manually. \
Run `runex export {:?}` for the script.",
shell, shell
);
return Ok(None);
};
let existing = read_rc_content(&rc_path);
if existing.contains(crate::infra::integration_check::RUNEX_INIT_MARKER) {
println!(
"Shell integration already present in {}",
sanitize_for_display(&rc_path.display().to_string())
);
return Ok(Some(rc_path));
}
let msg = format!(
"Append shell integration to {}?",
sanitize_for_display(&rc_path.display().to_string())
);
if !(yes || prompt_confirm(&msg)) {
println!("Skipped shell integration.");
return Ok(Some(rc_path));
}
let line = runex_init::integration_line(shell, "runex");
let block = format!("\n{}\n{}\n", crate::infra::integration_check::RUNEX_INIT_MARKER, line);
if let Some(parent) = rc_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut open_opts = std::fs::OpenOptions::new();
open_opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
open_opts.custom_flags(libc::O_NOFOLLOW);
}
let mut file = open_opts.open(&rc_path)?;
file.write_all(block.as_bytes())?;
println!("Appended integration to {}", sanitize_for_display(&rc_path.display().to_string()));
Ok(Some(rc_path))
}
fn install_clink_lua(yes: bool, config_path: &Path, env: &dyn HomeDirResolver) -> CmdResult {
use crate::infra::integration_check::{check_clink_lua_freshness, IntegrationCheck};
let bin = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| "runex".to_string());
let (_path, config, _err) = resolve_config_opt(Some(config_path));
let new_content = crate::app::shell_export::export_script(Shell::Clink, &bin, config.as_ref());
let install_path = runex_init::clink_lua_install_path_with_resolver(env);
let probe = check_clink_lua_freshness(
&new_content,
&crate::infra::integration_check::default_clink_lua_paths(),
);
match probe {
IntegrationCheck::Ok { detail, .. } => {
println!("clink integration already up-to-date ({detail}).");
return Ok(CmdOutcome::Ok);
}
IntegrationCheck::Outdated { path, .. } => {
let msg = format!(
"clink lua at {} is out of date. Overwrite with the current export?",
sanitize_for_display(&path.display().to_string())
);
if !(yes || prompt_confirm(&msg)) {
println!("Skipped clink integration update.");
return Ok(CmdOutcome::Ok);
}
}
IntegrationCheck::Skipped { .. } | IntegrationCheck::Missing { .. } => {
let msg = format!(
"Write clink integration to {}?",
sanitize_for_display(&install_path.display().to_string())
);
if !(yes || prompt_confirm(&msg)) {
println!("Skipped clink integration.");
return Ok(CmdOutcome::Ok);
}
}
}
write_clink_lua_safely(&install_path, &new_content)?;
println!(
"Wrote clink integration to {}",
sanitize_for_display(&install_path.display().to_string())
);
Ok(CmdOutcome::Ok)
}
fn write_clink_lua_safely(install_path: &Path, contents: &str) -> CmdResult {
let parent = install_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or_else(|| {
Box::<dyn std::error::Error>::from(format!(
"clink lua install path has no parent directory: {}",
sanitize_for_display(&install_path.display().to_string())
))
})?;
std::fs::create_dir_all(parent)?;
if let Ok(meta) = std::fs::symlink_metadata(install_path) {
if meta.file_type().is_symlink() {
return Err(Box::<dyn std::error::Error>::from(format!(
"refusing to write through a symlink at {}",
sanitize_for_display(&install_path.display().to_string())
)));
}
}
let file_name = install_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
Box::<dyn std::error::Error>::from(format!(
"clink lua install path has no file name: {}",
sanitize_for_display(&install_path.display().to_string())
))
})?;
let tmp_path = parent.join(format!(".{file_name}.runex.tmp"));
let _ = std::fs::remove_file(&tmp_path);
let mut tmp_opts = std::fs::OpenOptions::new();
tmp_opts.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
tmp_opts.custom_flags(libc::O_NOFOLLOW);
}
let mut tmp_file = tmp_opts.open(&tmp_path)?;
tmp_file.write_all(contents.as_bytes())?;
tmp_file.sync_all()?;
drop(tmp_file);
if let Err(e) = std::fs::rename(&tmp_path, install_path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(Box::new(e));
}
Ok(CmdOutcome::Ok)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infra::env::EnvHomeDir;
use std::collections::HashMap;
fn env_with(home: &std::path::Path) -> EnvHomeDir<impl Fn(&str) -> Option<String> + Send + Sync> {
let owned: HashMap<String, String> = HashMap::from([(
"HOME".to_string(),
home.to_string_lossy().into_owned(),
)]);
EnvHomeDir::new(move |n| owned.get(n).cloned())
}
#[test]
fn handle_writes_bashrc_under_env_home_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path();
let env = env_with(home);
let cfg_path = home.join("config.toml");
let outcome = handle(cfg_path.clone(), Some("bash"), true, &env)
.expect("handle must succeed");
assert!(matches!(outcome, CmdOutcome::Ok));
assert!(cfg_path.is_file(), "config file must be created at {:?}", cfg_path);
let bashrc = home.join(".bashrc");
assert!(bashrc.is_file(), "bashrc must be created at {:?}", bashrc);
let body = std::fs::read_to_string(&bashrc).unwrap();
assert!(
body.contains(crate::infra::integration_check::RUNEX_INIT_MARKER),
"bashrc must contain the runex marker: {body}"
);
}
#[test]
fn handle_is_idempotent_for_rcfile_integration() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path();
let env = env_with(home);
let cfg_path = home.join("config.toml");
handle(cfg_path.clone(), Some("zsh"), true, &env).expect("first handle");
let zshrc = home.join(".zshrc");
let first = std::fs::read_to_string(&zshrc).unwrap();
handle(cfg_path.clone(), Some("zsh"), true, &env).expect("second handle");
let second = std::fs::read_to_string(&zshrc).unwrap();
assert_eq!(
first, second,
"rerunning handle must not duplicate the integration block"
);
}
}