use std::env;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shell {
Bash,
Zsh,
Fish,
Unknown,
}
impl Shell {
pub fn name(&self) -> &'static str {
match self {
Shell::Bash => "bash",
Shell::Zsh => "zsh",
Shell::Fish => "fish",
Shell::Unknown => "unknown",
}
}
}
impl std::fmt::Display for Shell {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name())
}
}
pub fn detect_shell() -> Shell {
if let Ok(shell_path) = env::var("SHELL") {
let basename = std::path::Path::new(&shell_path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
return match basename {
"bash" => Shell::Bash,
"zsh" => Shell::Zsh,
"fish" => Shell::Fish,
_ => Shell::Unknown,
};
}
if std::path::Path::new("/bin/zsh").exists() || std::path::Path::new("/usr/bin/zsh").exists() {
return Shell::Zsh;
}
if std::path::Path::new("/bin/bash").exists() || std::path::Path::new("/usr/bin/bash").exists()
{
return Shell::Bash;
}
Shell::Unknown
}
pub fn get_rc_file(shell: Shell) -> Option<PathBuf> {
let home = home_dir()?;
match shell {
Shell::Zsh => Some(home.join(".zshrc")),
Shell::Bash => {
let bashrc = home.join(".bashrc");
let bash_profile = home.join(".bash_profile");
if bashrc.exists() {
Some(bashrc)
} else if bash_profile.exists() {
Some(bash_profile)
} else {
Some(bashrc)
}
}
Shell::Fish => Some(home.join(".config").join("fish").join("config.fish")),
Shell::Unknown => None,
}
}
pub fn home_dir() -> Option<PathBuf> {
env::var("HOME")
.or_else(|_| env::var("USERPROFILE"))
.ok()
.map(PathBuf::from)
}
pub fn local_bin_dir() -> PathBuf {
let home = home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".local").join("bin")
}
#[derive(Clone)]
pub struct ShellConfig {
pub header: String,
pub lines: Vec<String>,
}
impl ShellConfig {
pub fn new() -> Self {
Self {
header: "CRW Configuration (added by crw setup)".to_string(),
lines: Vec::new(),
}
}
pub fn export(&mut self, key: &str, value: &str) -> &mut Self {
self.lines.push(format!("export {}=\"{}\"", key, value));
self
}
pub fn add_to_path(&mut self, path: &str) -> &mut Self {
self.lines.push(format!("export PATH=\"{}:$PATH\"", path));
self
}
pub fn generate(&self, shell: Shell) -> String {
let comment_prefix = match shell {
Shell::Fish => "#",
_ => "#",
};
let mut output = String::new();
output.push('\n');
output.push_str(&format!("{} {}\n", comment_prefix, self.header));
for line in &self.lines {
let converted = if shell == Shell::Fish {
convert_to_fish(line)
} else {
line.clone()
};
output.push_str(&converted);
output.push('\n');
}
output
}
#[allow(dead_code)]
pub fn is_present_in(&self, content: &str) -> bool {
let content_lines: Vec<&str> = content.lines().map(|l| l.trim()).collect();
self.lines.iter().all(|line| {
let trimmed = line.trim();
content_lines.contains(&trimmed)
})
}
pub fn filter_existing(&mut self, content: &str) {
let content_lines: Vec<&str> = content.lines().map(|l| l.trim()).collect();
self.lines.retain(|line| {
let trimmed = line.trim();
!content_lines.contains(&trimmed)
});
}
}
fn convert_to_fish(line: &str) -> String {
if let Some(rest) = line.strip_prefix("export ")
&& let Some((key, value)) = rest.split_once('=')
{
let value = value.trim_matches('"');
if key == "PATH" && value.contains("$PATH") {
let new_path = value.replace(":$PATH", "").replace("$PATH:", "");
return format!("fish_add_path {}", new_path);
}
return format!("set -gx {} {}", key, value);
}
line.to_string()
}
#[cfg(unix)]
fn write_secure(path: &PathBuf, content: &str) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600) .open(path)?;
let mut writer = std::io::BufWriter::new(file);
writer.write_all(content.as_bytes())?;
Ok(())
}
#[cfg(not(unix))]
fn write_secure(path: &PathBuf, content: &str) -> std::io::Result<()> {
std::fs::write(path, content)
}
pub fn append_to_rc(shell: Shell, config: &ShellConfig) -> Result<PathBuf, String> {
let rc_path =
get_rc_file(shell).ok_or_else(|| "Could not determine RC file path".to_string())?;
let existing = if rc_path.exists() {
fs::read_to_string(&rc_path)
.map_err(|e| format!("Failed to read {}: {}", rc_path.display(), e))?
} else {
if let Some(parent) = rc_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
}
String::new()
};
let mut config = config.clone();
config.filter_existing(&existing);
if config.lines.is_empty() {
return Ok(rc_path);
}
let new_content = format!("{}{}", existing, config.generate(shell));
write_secure(&rc_path, &new_content)
.map_err(|e| format!("Failed to write {}: {}", rc_path.display(), e))?;
Ok(rc_path)
}
#[derive(Debug)]
pub struct ResetReport {
pub rc_path: PathBuf,
pub lines_removed: usize,
}
pub fn reset_rc(shell: Shell) -> Result<ResetReport, String> {
let rc_path =
get_rc_file(shell).ok_or_else(|| "Could not determine RC file path".to_string())?;
if !rc_path.exists() {
return Ok(ResetReport {
rc_path,
lines_removed: 0,
});
}
let original = fs::read_to_string(&rc_path)
.map_err(|e| format!("Failed to read {}: {}", rc_path.display(), e))?;
let (cleaned, removed) = strip_crw_blocks(&original);
if removed == 0 {
return Ok(ResetReport {
rc_path,
lines_removed: 0,
});
}
write_secure(&rc_path, &cleaned)
.map_err(|e| format!("Failed to write {}: {}", rc_path.display(), e))?;
Ok(ResetReport {
rc_path,
lines_removed: removed,
})
}
fn strip_crw_blocks(input: &str) -> (String, usize) {
let lines: Vec<&str> = input.lines().collect();
let mut out: Vec<&str> = Vec::with_capacity(lines.len());
let mut removed = 0usize;
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if line.trim_start().starts_with("# CRW Configuration") {
removed += 1;
i += 1;
while i < lines.len() && is_setup_generated_line(lines[i]) {
removed += 1;
i += 1;
}
if let Some(last) = out.last()
&& last.trim().is_empty()
{
out.pop();
removed += 1;
}
continue;
}
out.push(line);
i += 1;
}
let mut cleaned = out.join("\n");
if !cleaned.is_empty() && input.ends_with('\n') && !cleaned.ends_with('\n') {
cleaned.push('\n');
}
(cleaned, removed)
}
fn is_setup_generated_line(line: &str) -> bool {
let l = line.trim_start();
if l.starts_with("export CRW_") {
return true;
}
if l == "export PATH=\"$HOME/.local/bin:$PATH\"" {
return true;
}
if l.starts_with("set -gx CRW_") {
return true;
}
if l == "fish_add_path $HOME/.local/bin" {
return true;
}
false
}
pub fn source_command(shell: Shell) -> Option<String> {
let rc_path = get_rc_file(shell)?;
let rc_str = rc_path.to_str()?;
match shell {
Shell::Fish => Some(format!("source {}", rc_str)),
_ => Some(format!("source {}", rc_str)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_shell() {
let shell = detect_shell();
assert!(matches!(
shell,
Shell::Bash | Shell::Zsh | Shell::Fish | Shell::Unknown
));
}
#[test]
fn test_shell_config_generate() {
let mut config = ShellConfig::new();
config.export("CRW_API_KEY", "test-key");
config.add_to_path("$HOME/.local/bin");
let output = config.generate(Shell::Bash);
assert!(output.contains("export CRW_API_KEY=\"test-key\""));
assert!(output.contains("export PATH=\"$HOME/.local/bin:$PATH\""));
}
#[test]
fn test_convert_to_fish() {
assert_eq!(convert_to_fish("export FOO=\"bar\""), "set -gx FOO bar");
assert_eq!(
convert_to_fish("export PATH=\"$HOME/.local/bin:$PATH\""),
"fish_add_path $HOME/.local/bin"
);
}
#[test]
fn strip_removes_single_block_with_leading_blank() {
let input = "alias g=git\n\n# CRW Configuration (added by crw setup)\nexport CRW_API_KEY=\"k\"\nexport CRW_API_URL=\"u\"\n";
let (out, removed) = strip_crw_blocks(input);
assert_eq!(out, "alias g=git\n");
assert_eq!(removed, 4);
}
#[test]
fn strip_removes_multiple_blocks_idempotent() {
let input = "\n# CRW Configuration (added by crw setup)\nexport CRW_EXTRACTION__LLM__PROVIDER=\"anthropic\"\nexport CRW_EXTRACTION__LLM__API_KEY=\"old\"\n\n# CRW Configuration (added by crw setup)\nexport CRW_EXTRACTION__LLM__PROVIDER=\"deepseek\"\nexport CRW_EXTRACTION__LLM__API_KEY=\"new\"\n";
let (out, removed) = strip_crw_blocks(input);
assert_eq!(out, "");
assert_eq!(removed, 8);
}
#[test]
fn strip_stops_at_unrelated_export() {
let input = "# CRW Configuration (added by crw setup)\nexport CRW_API_KEY=\"k\"\nexport PG_HOST=\"localhost\"\n";
let (out, _) = strip_crw_blocks(input);
assert!(out.contains("PG_HOST"));
assert!(!out.contains("CRW_API_KEY"));
}
#[test]
fn strip_noop_when_no_markers() {
let input = "alias g=git\nexport FOO=\"bar\"\n";
let (out, removed) = strip_crw_blocks(input);
assert_eq!(out, input);
assert_eq!(removed, 0);
}
#[test]
fn strip_preserves_trailing_newline_convention() {
let input = "alias g=git";
let (out, removed) = strip_crw_blocks(input);
assert_eq!(out, "alias g=git");
assert_eq!(removed, 0);
}
#[test]
fn strip_handles_fish_lines() {
let input = "# CRW Configuration (added by crw setup)\nset -gx CRW_API_KEY k\nfish_add_path $HOME/.local/bin\n";
let (out, removed) = strip_crw_blocks(input);
assert_eq!(out, "");
assert_eq!(removed, 3);
}
}