use anyhow::{Context, Result};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileBlock {
pub marker: String,
pub lines: Vec<String>,
}
impl ProfileBlock {
pub fn new(
marker: impl Into<String>,
lines: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
marker: marker.into(),
lines: lines.into_iter().map(Into::into).collect(),
}
}
}
impl std::fmt::Display for ProfileBlock {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "{}", self.marker)?;
for line in &self.lines {
writeln!(f, "{}", line)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct ShellProfile {
pub header: Vec<String>,
pub blocks: Vec<ProfileBlock>,
pub footer: Vec<String>,
}
pub const MARKERS: &[&str] = &[
"# gvsn init",
"# gvsn wrapper",
"# gvsn path",
"# gvsn: binary location",
];
impl ShellProfile {
pub fn parse(content: &str) -> Self {
let mut profile = Self::default();
let mut current_block: Option<ProfileBlock> = None;
let mut in_gvsn_block = false;
let mut header_done = false;
for line in content.lines() {
let trimmed = line.trim();
if let Some(marker) = MARKERS.iter().find(|m| trimmed == **m) {
if let Some(block) = current_block.take() {
profile.blocks.push(block);
}
current_block = Some(ProfileBlock::new(
marker.to_string(),
std::iter::empty::<String>(),
));
in_gvsn_block = true;
header_done = true;
continue;
}
if in_gvsn_block {
if trimmed.is_empty() {
if let Some(block) = current_block.take() {
profile.blocks.push(block);
}
in_gvsn_block = false;
continue;
}
if let Some(ref mut block) = current_block {
block.lines.push(line.to_string());
}
} else if header_done {
profile.footer.push(line.to_string());
} else {
profile.header.push(line.to_string());
}
}
if let Some(block) = current_block {
profile.blocks.push(block);
}
profile
}
pub fn get_block(&self, marker: &str) -> Option<&ProfileBlock> {
self.blocks.iter().find(|b| b.marker == marker)
}
pub fn set_block(&mut self, block: ProfileBlock) {
if let Some(existing) = self.blocks.iter_mut().find(|b| b.marker == block.marker) {
*existing = block;
} else {
self.blocks.push(block);
}
}
fn write_to_string<W: std::fmt::Write>(&self, f: &mut W) -> std::fmt::Result {
for line in &self.header {
writeln!(f, "{}", line)?;
}
let mut wrote_something = !self.header.is_empty();
for (i, block) in self.blocks.iter().enumerate() {
if i > 0 || !self.header.is_empty() {
writeln!(f)?;
}
write!(f, "{}", block)?;
wrote_something = true;
}
if !self.footer.is_empty() {
if wrote_something {
writeln!(f)?;
}
for line in &self.footer {
writeln!(f, "{}", line)?;
}
}
Ok(())
}
}
impl std::fmt::Display for ShellProfile {
#[allow(clippy::inherent_to_string_shadow_display)]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.write_to_string(f)
}
}
impl ShellProfile {
pub fn has_block_with_content(&self, marker: &str, expected_lines: &[String]) -> bool {
self.get_block(marker)
.map(|b| b.lines == expected_lines)
.unwrap_or(false)
}
}
pub fn load_profile(path: &Path) -> Result<ShellProfile> {
if path.exists() {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Cannot read {}", path.display()))?;
Ok(ShellProfile::parse(&content))
} else {
Ok(ShellProfile::default())
}
}
pub fn save_profile(path: &Path, profile: &ShellProfile) -> Result<()> {
let content = profile.to_string();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Cannot create directory {}", parent.display()))?;
}
std::fs::write(path, content).with_context(|| format!("Cannot write {}", path.display()))?;
Ok(())
}
#[cfg(not(target_os = "windows"))]
pub fn update_path_block(path: &Path) -> Result<bool> {
const MARKER: &str = "# gvsn path";
const EXPORT_LINE: &str = r#"export PATH="$HOME/.gvsn/current/bin:$PATH""#;
let mut profile = load_profile(path)?;
let expected_lines = vec![EXPORT_LINE.to_string()];
let changed = !profile.has_block_with_content(MARKER, &expected_lines);
if changed {
profile.set_block(ProfileBlock::new(MARKER, expected_lines));
save_profile(path, &profile)?;
}
Ok(changed)
}
pub fn strip_gvsn_blocks(path: &Path) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let mut profile = load_profile(path)?;
let initial_len = profile.blocks.len();
profile.blocks.clear();
let changed = profile.blocks.len() != initial_len;
if changed {
save_profile(path, &profile)?;
}
Ok(changed)
}
pub fn ensure_profile(path: &Path, init_content: &str, wrapper_content: &str) -> Result<bool> {
let mut profile = load_profile(path)?;
let expected_init = init_content.lines().map(String::from).collect::<Vec<_>>();
let expected_wrapper = wrapper_content
.lines()
.map(String::from)
.collect::<Vec<_>>();
let mut modified = false;
if !profile.has_block_with_content("# gvsn init", &expected_init) {
profile.set_block(ProfileBlock::new("# gvsn init", expected_init));
modified = true;
}
if !profile.has_block_with_content("# gvsn wrapper", &expected_wrapper) {
profile.set_block(ProfileBlock::new("# gvsn wrapper", expected_wrapper));
modified = true;
}
if modified {
save_profile(path, &profile)?;
}
Ok(modified)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn parse_empty() {
let profile = ShellProfile::parse("");
assert!(profile.header.is_empty());
assert!(profile.blocks.is_empty());
assert!(profile.footer.is_empty());
}
#[test]
fn parse_header_only() {
let content = "export FOO=bar\nalias ll='ls -la'\n";
let profile = ShellProfile::parse(content);
assert_eq!(profile.header.len(), 2);
assert!(profile.blocks.is_empty());
}
#[test]
fn parse_single_block() {
let content = "# gvsn init\neval \"$(gvsn env --shell bash)\"\n";
let profile = ShellProfile::parse(content);
assert_eq!(profile.blocks.len(), 1);
assert_eq!(profile.blocks[0].marker, "# gvsn init");
assert_eq!(
profile.blocks[0].lines,
vec!["eval \"$(gvsn env --shell bash)\""]
);
}
#[test]
fn parse_ignores_user_comment_with_marker_prefix() {
let content = "# gvsn initially I used zsh\nexport FOO=bar\n";
let profile = ShellProfile::parse(content);
assert!(
profile.blocks.is_empty(),
"a prefix match must not be treated as a gvsn block"
);
assert_eq!(
profile.header,
vec![
"# gvsn initially I used zsh".to_string(),
"export FOO=bar".to_string(),
]
);
}
#[test]
fn parse_multiple_blocks() {
let content = r#"# user config
# gvsn init
eval "$(gvsn env --shell bash)"
# gvsn wrapper
gvsn() { command gvsn "$@"; }
# more config
"#;
let profile = ShellProfile::parse(content);
assert_eq!(profile.header.len(), 1);
assert_eq!(profile.blocks.len(), 2);
assert_eq!(profile.blocks[0].marker, "# gvsn init");
assert_eq!(profile.blocks[1].marker, "# gvsn wrapper");
assert_eq!(profile.footer.len(), 1);
}
#[test]
fn block_replacement() {
let mut profile = ShellProfile::parse("# gvsn init\nold content\n");
profile.set_block(ProfileBlock::new(
"# gvsn init",
vec!["new content".to_string()],
));
assert_eq!(profile.blocks[0].lines, vec!["new content"]);
}
#[test]
fn serialization_roundtrip() {
let content = r#"# header
# gvsn init
eval "$(gvsn env --shell bash)"
# gvsn wrapper
gvsn() { command gvsn "$@"; }
# footer
"#;
let profile = ShellProfile::parse(content);
let serialized = profile.to_string();
let reparsed = ShellProfile::parse(&serialized);
assert_eq!(profile.blocks.len(), reparsed.blocks.len());
assert!(reparsed.header.starts_with(&["# header".to_string()]));
assert_eq!(profile.footer, reparsed.footer);
}
#[test]
fn file_operations() {
let dir = tempdir().unwrap();
let path = dir.path().join("profile");
fs::write(&path, "# header\n# gvsn init\nold\n").unwrap();
let mut profile = load_profile(&path).unwrap();
assert_eq!(profile.blocks.len(), 1);
profile.set_block(ProfileBlock::new("# gvsn init", vec!["new".to_string()]));
save_profile(&path, &profile).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("new"));
assert!(!content.contains("old"));
}
#[test]
#[cfg(not(target_os = "windows"))]
fn update_path_block_adds_block_to_new_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("profile");
let changed = update_path_block(&path).unwrap();
assert!(changed);
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# gvsn path"));
assert!(content.contains(r#"export PATH="$HOME/.gvsn/current/bin:$PATH""#));
}
#[test]
#[cfg(not(target_os = "windows"))]
fn update_path_block_is_idempotent() {
let dir = tempdir().unwrap();
let path = dir.path().join("profile");
assert!(update_path_block(&path).unwrap());
let changed_again = update_path_block(&path).unwrap();
assert!(!changed_again, "second call must report no change");
let content = fs::read_to_string(&path).unwrap();
assert_eq!(content.matches("# gvsn path").count(), 1);
}
#[test]
#[cfg(not(target_os = "windows"))]
fn update_path_block_preserves_existing_content() {
let dir = tempdir().unwrap();
let path = dir.path().join("profile");
fs::write(&path, "# my custom profile\nexport FOO=bar\n").unwrap();
update_path_block(&path).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("export FOO=bar"));
assert!(content.contains("# gvsn path"));
}
#[test]
fn ensure_profile_creates_both_blocks_on_new_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("profile");
let modified = ensure_profile(
&path,
"eval \"$(gvsn env --shell bash)\"",
"gvsn() { command gvsn \"$@\"; }",
)
.unwrap();
assert!(modified);
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# gvsn init"));
assert!(content.contains("eval \"$(gvsn env --shell bash)\""));
assert!(content.contains("# gvsn wrapper"));
assert!(content.contains("gvsn() { command gvsn \"$@\"; }"));
}
#[test]
fn ensure_profile_is_idempotent() {
let dir = tempdir().unwrap();
let path = dir.path().join("profile");
let init = "eval \"$(gvsn env --shell bash)\"";
let wrapper = "gvsn() { command gvsn \"$@\"; }";
assert!(ensure_profile(&path, init, wrapper).unwrap());
let changed_again = ensure_profile(&path, init, wrapper).unwrap();
assert!(!changed_again, "second call must report no change");
}
#[test]
fn ensure_profile_updates_stale_content() {
let dir = tempdir().unwrap();
let path = dir.path().join("profile");
let old_wrapper = "gvsn() { command gvsn \"$@\"; }";
let new_wrapper = "gvsn() { command gvsn \"$@\"; case \"$1\" in use) gvsn env;; esac; }";
ensure_profile(&path, "eval init", old_wrapper).unwrap();
let modified = ensure_profile(&path, "eval init", new_wrapper).unwrap();
assert!(modified);
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains(new_wrapper));
assert!(!content.contains(old_wrapper));
}
#[test]
fn strip_profile_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("profile");
fs::write(
&path,
"# header\n# gvsn init\ncontent\n\n# gvsn wrapper\nmore\n\n# footer\n",
)
.unwrap();
let changed = strip_gvsn_blocks(&path).unwrap();
assert!(changed);
let content = fs::read_to_string(&path).unwrap();
assert!(!content.contains("# gvsn init"));
assert!(!content.contains("# gvsn wrapper"));
assert!(content.contains("# header"));
assert!(content.contains("# footer"));
}
}