| use std::fs; |
| use std::io::{BufRead, BufReader}; |
| use std::path::PathBuf; |
| use std::process::Stdio; |
|
|
| use anyhow::{Context, Result}; |
| use clap::CommandFactory; |
| use clap_complete::generate; |
| use clap_complete::shells::Zsh; |
| use include_dir::{Dir, include_dir}; |
|
|
| use crate::cli::Cli; |
|
|
| |
| static ZSH_PLUGIN_LIB: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../shell-plugin/lib"); |
|
|
| |
| |
| pub fn generate_zsh_plugin() -> Result<String> { |
| let mut output = String::new(); |
|
|
| |
| |
| for file in forge_embed::files(&ZSH_PLUGIN_LIB) { |
| let content = super::normalize_script(std::str::from_utf8(file.contents())?); |
| for line in content.lines() { |
| let trimmed = line.trim(); |
| |
| if !trimmed.is_empty() && !trimmed.starts_with('#') { |
| output.push_str(line); |
| output.push('\n'); |
| } |
| } |
| } |
|
|
| |
| let mut cmd = Cli::command(); |
| let mut completions = Vec::new(); |
| generate(Zsh, &mut cmd, "forge", &mut completions); |
|
|
| |
| let completions_str = String::from_utf8(completions)?; |
| output.push_str("\n# --- Clap Completions ---\n"); |
| output.push_str(&completions_str); |
|
|
| |
| output.push_str("\n_FORGE_PLUGIN_LOADED=$(date +%s)\n"); |
|
|
| Ok(output) |
| } |
|
|
| |
| pub fn generate_zsh_theme() -> Result<String> { |
| let mut content = |
| super::normalize_script(include_str!("../../../../shell-plugin/forge.theme.zsh")); |
|
|
| |
| content.push_str("\n_FORGE_THEME_LOADED=$(date +%s)\n"); |
|
|
| Ok(content) |
| } |
|
|
| |
| fn create_temp_zsh_script(script_content: &str) -> Result<(tempfile::TempDir, PathBuf)> { |
| use std::io::Write; |
|
|
| let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?; |
| let script_path = temp_dir.path().join("forge_script.zsh"); |
| let mut file = fs::File::create(&script_path).context("Failed to create temp script file")?; |
| file.write_all(script_content.as_bytes()) |
| .context("Failed to write temp script")?; |
|
|
| Ok((temp_dir, script_path)) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| fn execute_zsh_script_with_streaming(script_content: &str, script_name: &str) -> Result<()> { |
| let script_content = super::normalize_script(script_content); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| let (_temp_dir, mut child) = if cfg!(windows) { |
| let (temp_dir, script_path) = create_temp_zsh_script(&script_content)?; |
| let child = std::process::Command::new("zsh") |
| |
| .arg("-f") |
| .arg(script_path.to_string_lossy().as_ref()) |
| .stdout(Stdio::piped()) |
| .stderr(Stdio::piped()) |
| .spawn() |
| .context(format!("Failed to execute zsh {} script", script_name))?; |
| |
| (Some(temp_dir), child) |
| } else { |
| let child = std::process::Command::new("zsh") |
| .arg("-c") |
| .arg(&script_content) |
| .stdout(Stdio::piped()) |
| .stderr(Stdio::piped()) |
| .spawn() |
| .context(format!("Failed to execute zsh {} script", script_name))?; |
| (None, child) |
| }; |
|
|
| |
| let stdout = child.stdout.take().context("Failed to capture stdout")?; |
| let stderr = child.stderr.take().context("Failed to capture stderr")?; |
|
|
| |
| std::thread::scope(|s| { |
| |
| s.spawn(|| { |
| let stdout_reader = BufReader::new(stdout); |
| for line in stdout_reader.lines() { |
| match line { |
| Ok(line) => println!("{}", line), |
| Err(e) => eprintln!("Error reading stdout: {}", e), |
| } |
| } |
| }); |
|
|
| |
| s.spawn(|| { |
| let stderr_reader = BufReader::new(stderr); |
| for line in stderr_reader.lines() { |
| match line { |
| Ok(line) => eprintln!("{}", line), |
| Err(e) => eprintln!("Error reading stderr: {}", e), |
| } |
| } |
| }); |
| }); |
|
|
| |
| let status = child |
| .wait() |
| .context(format!("Failed to wait for zsh {} script", script_name))?; |
|
|
| if !status.success() { |
| let exit_code = status |
| .code() |
| .map_or_else(|| "unknown".to_string(), |code| code.to_string()); |
|
|
| anyhow::bail!( |
| "ZSH {} script failed with exit code: {}", |
| script_name, |
| exit_code |
| ); |
| } |
|
|
| Ok(()) |
| } |
|
|
| |
| |
| |
| |
| |
| pub fn run_zsh_doctor() -> Result<()> { |
| let script_content = include_str!("../../../../shell-plugin/doctor.zsh"); |
| execute_zsh_script_with_streaming(script_content, "doctor") |
| } |
|
|
| |
| |
| |
| |
| |
| pub fn run_zsh_keyboard() -> Result<()> { |
| let script_content = include_str!("../../../../shell-plugin/keyboard.zsh"); |
| execute_zsh_script_with_streaming(script_content, "keyboard") |
| } |
|
|
| |
| enum MarkerState { |
| |
| NotFound, |
| |
| Valid { start: usize, end: usize }, |
| |
| Invalid { |
| start: Option<usize>, |
| end: Option<usize>, |
| }, |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| fn parse_markers(lines: &[String], start_marker: &str, end_marker: &str) -> MarkerState { |
| let start_idx = lines.iter().position(|line| line.trim() == start_marker); |
| let end_idx = lines.iter().position(|line| line.trim() == end_marker); |
|
|
| match (start_idx, end_idx) { |
| (Some(start), Some(end)) if start < end => MarkerState::Valid { start, end }, |
| (None, None) => MarkerState::NotFound, |
| (start, end) => MarkerState::Invalid { start, end }, |
| } |
| } |
|
|
| |
| #[derive(Debug)] |
| pub struct ZshSetupResult { |
| |
| pub message: String, |
| |
| pub backup_path: Option<PathBuf>, |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| pub fn setup_zsh_integration( |
| disable_nerd_font: bool, |
| forge_editor: Option<&str>, |
| ) -> Result<ZshSetupResult> { |
| const START_MARKER: &str = "# >>> forge initialize >>>"; |
| const END_MARKER: &str = "# <<< forge initialize <<<"; |
| const FORGE_INIT_CONFIG_RAW: &str = include_str!("../../../../shell-plugin/forge.setup.zsh"); |
| let forge_init_config = super::normalize_script(FORGE_INIT_CONFIG_RAW); |
|
|
| let home = std::env::var("HOME").context("HOME environment variable not set")?; |
| let zdotdir = std::env::var("ZDOTDIR").unwrap_or_else(|_| home.clone()); |
| let zshrc_path = PathBuf::from(&zdotdir).join(".zshrc"); |
|
|
| |
| let content = if zshrc_path.exists() { |
| fs::read_to_string(&zshrc_path) |
| .context(format!("Failed to read {}", zshrc_path.display()))? |
| } else { |
| String::new() |
| }; |
|
|
| let mut lines: Vec<String> = content.lines().map(String::from).collect(); |
|
|
| |
| let marker_state = parse_markers(&lines, START_MARKER, END_MARKER); |
|
|
| |
| let mut forge_config: Vec<String> = vec![START_MARKER.to_string()]; |
| forge_config.extend(forge_init_config.lines().map(String::from)); |
|
|
| |
| if disable_nerd_font { |
| forge_config.push(String::new()); |
| forge_config.push( |
| "# Disable Nerd Fonts (set during setup - icons not displaying correctly)".to_string(), |
| ); |
| forge_config.push("# To re-enable: remove this line and install a Nerd Font from https://www.nerdfonts.com/".to_string()); |
| forge_config.push("export NERD_FONT=0".to_string()); |
| } |
|
|
| |
| if let Some(editor) = forge_editor { |
| forge_config.push(String::new()); |
| forge_config.push("# Editor for editing prompts (set during setup)".to_string()); |
| forge_config.push("# To change: update FORGE_EDITOR or remove to use $EDITOR".to_string()); |
| forge_config.push(format!("export FORGE_EDITOR=\"{}\"", editor)); |
| } |
|
|
| forge_config.push(END_MARKER.to_string()); |
|
|
| |
| let (new_content, config_action) = match marker_state { |
| MarkerState::Valid { start, end } => { |
| |
| lines.splice(start..=end, forge_config.iter().cloned()); |
| (lines.join("\n") + "\n", "updated") |
| } |
| MarkerState::Invalid { start, end } => { |
| let location = match (start, end) { |
| (Some(s), Some(e)) => Some(format!("{}:{}-{}", zshrc_path.display(), s + 1, e + 1)), |
| (Some(s), None) => Some(format!("{}:{}", zshrc_path.display(), s + 1)), |
| (None, Some(e)) => Some(format!("{}:{}", zshrc_path.display(), e + 1)), |
| (None, None) => None, |
| }; |
|
|
| let mut error = |
| anyhow::anyhow!("Invalid forge markers found in {}", zshrc_path.display()); |
| if let Some(loc) = location { |
| error = error.context(format!("Markers found at {}", loc)); |
| } |
| return Err(error); |
| } |
| MarkerState::NotFound => { |
| |
| |
| |
| if lines.last().is_some_and(|l| !l.trim().is_empty()) { |
| lines.push(String::new()); |
| } |
|
|
| lines.extend(forge_config.iter().cloned()); |
| (lines.join("\n") + "\n", "added") |
| } |
| }; |
|
|
| |
| let backup_path = if zshrc_path.exists() { |
| |
| let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); |
|
|
| |
| let parent = zshrc_path |
| .parent() |
| .context("zshrc path has no parent directory")?; |
| let filename = zshrc_path |
| .file_name() |
| .context("zshrc path has no filename")?; |
| let filename_str = filename |
| .to_str() |
| .context("zshrc filename is not valid UTF-8")?; |
|
|
| let backup = parent.join(format!("{}.bak.{}", filename_str, timestamp)); |
| fs::copy(&zshrc_path, &backup) |
| .context(format!("Failed to create backup at {}", backup.display()))?; |
| Some(backup) |
| } else { |
| None |
| }; |
|
|
| |
| fs::write(&zshrc_path, &new_content) |
| .context(format!("Failed to write to {}", zshrc_path.display()))?; |
|
|
| Ok(ZshSetupResult { |
| message: format!("forge plugins {}", config_action), |
| backup_path, |
| }) |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use std::sync::{LazyLock, Mutex}; |
|
|
| use super::*; |
|
|
| |
| static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(())); |
|
|
| |
| |
| |
| #[test] |
| fn test_run_zsh_doctor_streaming() { |
| |
| unsafe { |
| std::env::set_var("FORGE_SKIP_INTERACTIVE", "1"); |
| } |
|
|
| let actual = run_zsh_doctor(); |
|
|
| |
| |
| unsafe { |
| std::env::remove_var("FORGE_SKIP_INTERACTIVE"); |
| } |
|
|
| |
| |
| |
| match actual { |
| Ok(_) => { |
| |
| } |
| Err(e) => { |
| |
| |
| let error_msg = e.to_string(); |
| assert!( |
| error_msg.contains("exit code") || error_msg.contains("Failed to execute"), |
| "Unexpected error: {}", |
| error_msg |
| ); |
| } |
| } |
| } |
|
|
| #[test] |
| fn test_generated_plugin_wraps_zle_commands_with_osc133_markers() { |
| use pretty_assertions::assert_eq; |
|
|
| let fixture = generate_zsh_plugin().unwrap(); |
|
|
| |
| let actual = fixture.contains(" _forge_osc133_emit \"B\"\n _forge_osc133_emit \"C\"") |
| |
| && fixture.contains("CURSOR=${#BUFFER}\n zle redisplay") |
| |
| && fixture.contains(" case \"$user_action\" in") |
| |
| |
| && fixture.contains("for ((_i=1; _i<pad; _i++)); do print; done\n") |
| && fixture.contains("BUFFER=\"\"\n CURSOR=0") |
| |
| && fixture.contains( |
| " local action_status=$?\n _forge_osc133_emit \"D;$action_status\"\n _forge_osc133_emit \"A\"\n _forge_reset", |
| ); |
| let expected = true; |
| assert_eq!(actual, expected); |
| } |
|
|
| |
| |
| #[test] |
| fn test_generated_plugin_registers_zvm_after_init_hook() { |
| use pretty_assertions::assert_eq; |
|
|
| let fixture = generate_zsh_plugin().unwrap(); |
| let actual = fixture.contains("function _forge_apply_keybindings()") |
| && fixture.contains("typeset -ga zvm_after_init_commands") |
| && fixture.contains("zvm_after_init_commands+=('_forge_apply_keybindings')"); |
| let expected = true; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_setup_zsh_integration_without_nerd_font_config() { |
| use tempfile::TempDir; |
|
|
| |
| let _guard = ENV_LOCK.lock().unwrap(); |
|
|
| |
| let temp_dir = TempDir::new().unwrap(); |
| let zshrc_path = temp_dir.path().join(".zshrc"); |
|
|
| |
| let original_home = std::env::var("HOME").ok(); |
| let original_zdotdir = std::env::var("ZDOTDIR").ok(); |
|
|
| |
| unsafe { |
| std::env::set_var("HOME", temp_dir.path()); |
| std::env::remove_var("ZDOTDIR"); |
| } |
|
|
| |
| let actual = setup_zsh_integration(false, None); |
|
|
| |
| |
| unsafe { |
| if let Some(home) = original_home { |
| std::env::set_var("HOME", home); |
| } else { |
| std::env::remove_var("HOME"); |
| } |
| if let Some(zdotdir) = original_zdotdir { |
| std::env::set_var("ZDOTDIR", zdotdir); |
| } else { |
| std::env::remove_var("ZDOTDIR"); |
| } |
| } |
|
|
| assert!(actual.is_ok(), "Setup should succeed: {:?}", actual); |
|
|
| |
| assert!( |
| zshrc_path.exists(), |
| "zshrc file should be created at {:?}", |
| zshrc_path |
| ); |
| let content = fs::read_to_string(&zshrc_path).expect("Should be able to read zshrc"); |
|
|
| |
| assert!(!content.contains("NERD_FONT=0")); |
|
|
| |
| assert!(content.contains("# >>> forge initialize >>>")); |
| assert!(content.contains("# <<< forge initialize <<<")); |
| } |
|
|
| #[test] |
| fn test_setup_zsh_integration_with_nerd_font_disabled() { |
| use tempfile::TempDir; |
|
|
| |
| let _guard = ENV_LOCK.lock().unwrap(); |
|
|
| |
| let temp_dir = TempDir::new().unwrap(); |
| let zshrc_path = temp_dir.path().join(".zshrc"); |
|
|
| |
| let original_home = std::env::var("HOME").ok(); |
| let original_zdotdir = std::env::var("ZDOTDIR").ok(); |
|
|
| |
| unsafe { |
| std::env::set_var("HOME", temp_dir.path()); |
| std::env::set_var("ZDOTDIR", temp_dir.path()); |
| } |
|
|
| |
| let actual = setup_zsh_integration(true, None); |
| assert!(actual.is_ok(), "Setup should succeed: {:?}", actual); |
|
|
| |
| assert!(zshrc_path.exists(), "zshrc file should be created"); |
| let content = fs::read_to_string(&zshrc_path).expect("Should be able to read zshrc"); |
|
|
| |
| assert!( |
| content.contains("export NERD_FONT=0"), |
| "Content should contain NERD_FONT=0:\n{}", |
| content |
| ); |
| assert!( |
| content.contains( |
| "# Disable Nerd Fonts (set during setup - icons not displaying correctly)" |
| ), |
| "Should contain explanation comment" |
| ); |
| assert!(content.contains("# To re-enable: remove this line and install a Nerd Font from https://www.nerdfonts.com/"), "Should contain re-enable instructions"); |
|
|
| |
| assert!(content.contains("# >>> forge initialize >>>")); |
| assert!(content.contains("# <<< forge initialize <<<")); |
|
|
| |
| |
| unsafe { |
| if let Some(home) = original_home { |
| std::env::set_var("HOME", home); |
| } |
| if let Some(zdotdir) = original_zdotdir { |
| std::env::set_var("ZDOTDIR", zdotdir); |
| } |
| } |
| } |
|
|
| #[test] |
| fn test_setup_zsh_integration_with_editor() { |
| use tempfile::TempDir; |
|
|
| |
| let _guard = ENV_LOCK.lock().unwrap(); |
|
|
| |
| let temp_dir = TempDir::new().unwrap(); |
| let zshrc_path = temp_dir.path().join(".zshrc"); |
|
|
| |
| let original_home = std::env::var("HOME").ok(); |
| let original_zdotdir = std::env::var("ZDOTDIR").ok(); |
|
|
| |
| unsafe { |
| std::env::set_var("HOME", temp_dir.path()); |
| std::env::remove_var("ZDOTDIR"); |
| } |
|
|
| |
| let actual = setup_zsh_integration(false, Some("code --wait")); |
|
|
| assert!(actual.is_ok(), "Setup should succeed: {:?}", actual); |
|
|
| |
| assert!(zshrc_path.exists(), "zshrc file should be created"); |
| let content = fs::read_to_string(&zshrc_path).expect("Should be able to read zshrc"); |
|
|
| |
| assert!( |
| content.contains("export FORGE_EDITOR=\"code --wait\""), |
| "Content should contain FORGE_EDITOR:\n{}", |
| content |
| ); |
| assert!( |
| content.contains("# Editor for editing prompts (set during setup)"), |
| "Should contain editor explanation comment" |
| ); |
| assert!( |
| content.contains("# To change: update FORGE_EDITOR or remove to use $EDITOR"), |
| "Should contain editor change instructions" |
| ); |
|
|
| |
| assert!(content.contains("# >>> forge initialize >>>")); |
| assert!(content.contains("# <<< forge initialize <<<")); |
|
|
| |
| |
| unsafe { |
| if let Some(home) = original_home { |
| std::env::set_var("HOME", home); |
| } else { |
| std::env::remove_var("HOME"); |
| } |
| if let Some(zdotdir) = original_zdotdir { |
| std::env::set_var("ZDOTDIR", zdotdir); |
| } else { |
| std::env::remove_var("ZDOTDIR"); |
| } |
| } |
| } |
|
|
| #[test] |
| fn test_setup_zsh_integration_with_both_configs() { |
| use tempfile::TempDir; |
|
|
| |
| let _guard = ENV_LOCK.lock().unwrap(); |
|
|
| |
| let temp_dir = TempDir::new().unwrap(); |
| let zshrc_path = temp_dir.path().join(".zshrc"); |
|
|
| |
| let original_home = std::env::var("HOME").ok(); |
| let original_zdotdir = std::env::var("ZDOTDIR").ok(); |
|
|
| |
| unsafe { |
| std::env::set_var("HOME", temp_dir.path()); |
| std::env::set_var("ZDOTDIR", temp_dir.path()); |
| } |
|
|
| |
| let actual = setup_zsh_integration(true, Some("vim")); |
| assert!(actual.is_ok(), "Setup should succeed: {:?}", actual); |
|
|
| |
| assert!(zshrc_path.exists(), "zshrc file should be created"); |
| let content = fs::read_to_string(&zshrc_path).expect("Should be able to read zshrc"); |
|
|
| |
| assert!( |
| content.contains("export NERD_FONT=0"), |
| "Content should contain NERD_FONT=0:\n{}", |
| content |
| ); |
| assert!( |
| content.contains("export FORGE_EDITOR=\"vim\""), |
| "Content should contain FORGE_EDITOR:\n{}", |
| content |
| ); |
|
|
| |
| assert!(content.contains("# >>> forge initialize >>>")); |
| assert!(content.contains("# <<< forge initialize <<<")); |
|
|
| |
| |
| unsafe { |
| if let Some(home) = original_home { |
| std::env::set_var("HOME", home); |
| } |
| if let Some(zdotdir) = original_zdotdir { |
| std::env::set_var("ZDOTDIR", zdotdir); |
| } |
| } |
| } |
|
|
| #[test] |
| fn test_setup_zsh_integration_updates_existing_markers() { |
| use tempfile::TempDir; |
|
|
| |
| let _guard = ENV_LOCK.lock().unwrap(); |
|
|
| |
| let temp_dir = TempDir::new().unwrap(); |
| let zshrc_path = temp_dir.path().join(".zshrc"); |
|
|
| |
| let original_home = std::env::var("HOME").ok(); |
| let original_zdotdir = std::env::var("ZDOTDIR").ok(); |
|
|
| |
| unsafe { |
| std::env::set_var("HOME", temp_dir.path()); |
| std::env::remove_var("ZDOTDIR"); |
| } |
|
|
| |
| let result = setup_zsh_integration(true, None); |
| assert!(result.is_ok(), "Initial setup should succeed: {:?}", result); |
|
|
| |
| assert!( |
| result.as_ref().unwrap().backup_path.is_none(), |
| "Should not create backup on initial setup" |
| ); |
|
|
| let content = fs::read_to_string(&zshrc_path).expect("Should be able to read zshrc"); |
| assert!( |
| content.contains("export NERD_FONT=0"), |
| "Should contain NERD_FONT=0 after first setup" |
| ); |
| assert!( |
| !content.contains("export FORGE_EDITOR"), |
| "Should not contain FORGE_EDITOR after first setup" |
| ); |
|
|
| |
| let result = setup_zsh_integration(false, Some("nvim")); |
| assert!(result.is_ok(), "Update setup should succeed: {:?}", result); |
|
|
| |
| let backup_path = result.as_ref().unwrap().backup_path.as_ref(); |
| assert!(backup_path.is_some(), "Should create backup on update"); |
| let backup = backup_path.unwrap(); |
| assert!(backup.exists(), "Backup file should exist at {:?}", backup); |
|
|
| |
| let backup_name = backup.file_name().unwrap().to_str().unwrap(); |
| assert!( |
| backup_name.starts_with(".zshrc.bak."), |
| "Backup filename should start with .zshrc.bak.: {}", |
| backup_name |
| ); |
| assert!( |
| backup_name.len() > ".zshrc.bak.".len(), |
| "Backup filename should include timestamp: {}", |
| backup_name |
| ); |
|
|
| let content = fs::read_to_string(&zshrc_path).expect("Should be able to read zshrc"); |
|
|
| |
| assert!( |
| !content.contains("export NERD_FONT=0"), |
| "Should not contain NERD_FONT=0 after update:\n{}", |
| content |
| ); |
|
|
| |
| assert!( |
| content.contains("export FORGE_EDITOR=\"nvim\""), |
| "Should contain FORGE_EDITOR after update:\n{}", |
| content |
| ); |
|
|
| |
| assert!(content.contains("# >>> forge initialize >>>")); |
| assert!(content.contains("# <<< forge initialize <<<")); |
|
|
| |
| assert_eq!( |
| content.matches("# >>> forge initialize >>>").count(), |
| 1, |
| "Should have exactly one start marker" |
| ); |
| assert_eq!( |
| content.matches("# <<< forge initialize <<<").count(), |
| 1, |
| "Should have exactly one end marker" |
| ); |
|
|
| |
| |
| unsafe { |
| if let Some(home) = original_home { |
| std::env::set_var("HOME", home); |
| } else { |
| std::env::remove_var("HOME"); |
| } |
| if let Some(zdotdir) = original_zdotdir { |
| std::env::set_var("ZDOTDIR", zdotdir); |
| } else { |
| std::env::remove_var("ZDOTDIR"); |
| } |
| } |
| } |
| } |
|
|