use std::fs;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
#[cfg(unix)]
const LOCK_TIMEOUT_SECS: u64 = 10;
#[cfg(unix)]
pub(crate) struct RcFileLock {
file: fs::File,
}
#[cfg(unix)]
impl RcFileLock {
pub fn acquire(rc_path: &Path) -> Result<Self> {
use std::os::unix::io::AsRawFd;
use std::time::{Duration, Instant};
let lock_path = lock_path_for(rc_path);
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent).map_err(|source| Error::DirCreate {
path: parent.to_owned(),
source,
})?;
}
let file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.map_err(|source| Error::LockFailed {
path: lock_path.clone(),
source,
})?;
let timeout = Duration::from_secs(LOCK_TIMEOUT_SECS);
let start = Instant::now();
let fd = file.as_raw_fd();
loop {
#[allow(unsafe_code)]
let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if result == 0 {
return Ok(Self { file });
}
let err = std::io::Error::last_os_error();
if err.kind() != std::io::ErrorKind::WouldBlock {
return Err(Error::LockFailed {
path: lock_path,
source: err,
});
}
if start.elapsed() >= timeout {
return Err(Error::LockTimeout { path: lock_path });
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
#[cfg(unix)]
impl Drop for RcFileLock {
fn drop(&mut self) {
use std::os::unix::io::AsRawFd;
#[allow(unsafe_code)]
unsafe {
libc::flock(self.file.as_raw_fd(), libc::LOCK_UN);
}
}
}
#[cfg(unix)]
fn lock_path_for(rc_path: &Path) -> PathBuf {
let mut lock_name = rc_path
.file_name()
.map(std::ffi::OsStr::to_os_string)
.unwrap_or_default();
lock_name.push(".onpath.lock");
rc_path.with_file_name(lock_name)
}
fn open_marker(tool_name: &str) -> String {
format!("# >>> onpath:{tool_name} >>>")
}
fn close_marker(tool_name: &str) -> String {
format!("# <<< onpath:{tool_name} <<<")
}
pub fn has_source_block(rc_content: &str, tool_name: &str) -> bool {
rc_content.contains(&open_marker(tool_name))
}
pub fn build_source_block(tool_name: &str, source_line: &str) -> String {
format!(
"{}\n{}\n{}\n",
open_marker(tool_name),
source_line,
close_marker(tool_name),
)
}
pub fn insert_source_block(rc_content: &str, tool_name: &str, source_line: &str) -> Option<String> {
if has_source_block(rc_content, tool_name) {
return None;
}
let block = build_source_block(tool_name, source_line);
let mut result = rc_content.to_owned();
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
result.push_str(&block);
Some(result)
}
pub fn remove_source_block(rc_content: &str, tool_name: &str) -> Option<String> {
let open = open_marker(tool_name);
let close = close_marker(tool_name);
let start = rc_content.find(&open)?;
let close_pos = rc_content[start..].find(&close)?;
let end = start + close_pos + close.len();
let end = if rc_content.as_bytes().get(end) == Some(&b'\n') {
end + 1
} else {
end
};
let mut result = String::with_capacity(rc_content.len());
result.push_str(&rc_content[..start]);
result.push_str(&rc_content[end..]);
while result.ends_with("\n\n") {
result.pop();
}
Some(result)
}
pub fn backup_file(path: &Path) -> Result<PathBuf> {
let backup_path = path.with_extension("onpath.bak");
if backup_path.exists() {
return Ok(backup_path);
}
fs::copy(path, &backup_path).map_err(|source| Error::BackupFailed {
path: path.to_owned(),
source,
})?;
Ok(backup_path)
}
pub fn read_file_or_empty(path: &Path) -> Result<String> {
match fs::read_to_string(path) {
Ok(content) => Ok(content),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(source) => Err(Error::FileRead {
path: path.to_owned(),
source,
}),
}
}
pub fn write_file(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| Error::DirCreate {
path: parent.to_owned(),
source,
})?;
}
fs::write(path, content).map_err(|source| Error::FileWrite {
path: path.to_owned(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn has_source_block_detects_existing() {
let content = "# existing\n# >>> onpath:myapp >>>\n. /path/env\n# <<< onpath:myapp <<<\n";
assert!(has_source_block(content, "myapp"));
assert!(!has_source_block(content, "other"));
}
#[test]
fn build_source_block_format() {
let block = build_source_block("myapp", ". \"/home/user/.myapp/env\"");
assert_eq!(
block,
"# >>> onpath:myapp >>>\n. \"/home/user/.myapp/env\"\n# <<< onpath:myapp <<<\n"
);
}
#[test]
fn insert_source_block_appends() {
let content = "# existing config\n";
let result = insert_source_block(content, "myapp", ". /path/env");
assert!(result.is_some());
let result = result.unwrap();
assert!(result.starts_with("# existing config\n"));
assert!(result.contains("# >>> onpath:myapp >>>"));
assert!(result.contains(". /path/env"));
assert!(result.contains("# <<< onpath:myapp <<<"));
}
#[test]
fn insert_source_block_idempotent() {
let content = "# >>> onpath:myapp >>>\n. /path/env\n# <<< onpath:myapp <<<\n";
let result = insert_source_block(content, "myapp", ". /path/env");
assert!(result.is_none());
}
#[test]
fn insert_adds_newline_if_missing() {
let content = "# existing config";
let result = insert_source_block(content, "myapp", ". /path/env").unwrap();
assert!(result.starts_with("# existing config\n"));
}
#[test]
fn insert_handles_empty_file() {
let result = insert_source_block("", "myapp", ". /path/env").unwrap();
assert!(result.starts_with("# >>> onpath:myapp >>>"));
}
#[test]
fn remove_source_block_removes_cleanly() {
let content =
"before\n# >>> onpath:myapp >>>\n. /path/env\n# <<< onpath:myapp <<<\nafter\n";
let result = remove_source_block(content, "myapp").unwrap();
assert_eq!(result, "before\nafter\n");
}
#[test]
fn remove_source_block_returns_none_when_missing() {
let content = "just normal config\n";
assert!(remove_source_block(content, "myapp").is_none());
}
#[test]
fn remove_source_block_handles_end_of_file() {
let content = "before\n# >>> onpath:myapp >>>\n. /path/env\n# <<< onpath:myapp <<<\n";
let result = remove_source_block(content, "myapp").unwrap();
assert_eq!(result, "before\n");
}
#[test]
fn multiple_tools_dont_interfere() {
let content = "";
let content = insert_source_block(content, "tool_a", ". /a/env").unwrap();
let content = insert_source_block(&content, "tool_b", ". /b/env").unwrap();
assert!(has_source_block(&content, "tool_a"));
assert!(has_source_block(&content, "tool_b"));
let content = remove_source_block(&content, "tool_a").unwrap();
assert!(!has_source_block(&content, "tool_a"));
assert!(has_source_block(&content, "tool_b"));
}
#[test]
fn backup_and_read_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.rc");
std::fs::write(&path, "content").unwrap();
let backup_path = backup_file(&path).unwrap();
assert!(backup_path.exists());
assert_eq!(std::fs::read_to_string(&backup_path).unwrap(), "content");
}
#[test]
fn read_file_or_empty_returns_empty_for_missing() {
let result = read_file_or_empty(Path::new("/nonexistent/path")).unwrap();
assert!(result.is_empty());
}
#[test]
fn remove_source_block_crlf() {
let content = "before\r\n# >>> onpath:myapp >>>\r\n. /path/env\r\n# <<< onpath:myapp <<<\r\nafter\r\n";
let result = remove_source_block(content, "myapp").unwrap();
assert!(!result.contains("onpath:myapp"));
assert!(result.contains("before"));
assert!(result.contains("after"));
}
#[test]
fn insert_into_file_with_bom() {
let bom = "\u{FEFF}";
let content = format!("{bom}# existing config\n");
let result = insert_source_block(&content, "myapp", ". /path/env").unwrap();
assert!(result.starts_with(bom));
assert!(result.contains("# >>> onpath:myapp >>>"));
}
#[test]
fn insert_into_file_without_trailing_newline() {
let content = "# config without newline";
let result = insert_source_block(content, "myapp", ". /path/env").unwrap();
assert!(result.contains("# config without newline\n# >>> onpath:myapp >>>"));
}
#[test]
fn crlf_insert_remove_roundtrip() {
let original = "# my config\r\nexport FOO=bar\r\n";
let inserted = insert_source_block(original, "myapp", ". /path/env").unwrap();
assert!(inserted.contains("# >>> onpath:myapp >>>"));
assert!(inserted.starts_with("# my config\r\n"));
let removed = remove_source_block(&inserted, "myapp").unwrap();
assert!(!removed.contains("onpath:myapp"));
assert!(removed.contains("FOO=bar"));
}
#[test]
fn bom_insert_remove_roundtrip() {
let bom = "\u{FEFF}";
let original = format!("{bom}# config\nexport BAR=baz\n");
let inserted = insert_source_block(&original, "myapp", ". /path/env").unwrap();
assert!(inserted.starts_with(bom));
assert!(inserted.contains("# >>> onpath:myapp >>>"));
let removed = remove_source_block(&inserted, "myapp").unwrap();
assert!(removed.starts_with(bom));
assert!(!removed.contains("onpath:myapp"));
assert!(removed.contains("BAR=baz"));
}
#[test]
fn backup_preserves_existing_backup() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.rc");
fs::write(&path, "original content").unwrap();
let backup_path = backup_file(&path).unwrap();
assert_eq!(
fs::read_to_string(&backup_path).unwrap(),
"original content"
);
fs::write(&path, "modified content").unwrap();
let backup_path2 = backup_file(&path).unwrap();
assert_eq!(backup_path, backup_path2);
assert_eq!(
fs::read_to_string(&backup_path).unwrap(),
"original content",
"backup should preserve original, not overwrite with modified"
);
}
#[cfg(unix)]
#[test]
fn backup_follows_symlinks() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("actual.rc");
let link = dir.path().join("link.rc");
fs::write(&target, "symlinked content").unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
let backup_path = backup_file(&link).unwrap();
assert!(backup_path.exists());
assert_eq!(
fs::read_to_string(&backup_path).unwrap(),
"symlinked content"
);
assert!(link.is_symlink());
}
#[test]
fn write_file_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let deep_path = dir.path().join("a").join("b").join("c").join("test.rc");
write_file(&deep_path, "test content").unwrap();
assert_eq!(std::fs::read_to_string(&deep_path).unwrap(), "test content");
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn rc_insert_remove_roundtrip(
content in "[a-zA-Z0-9 \n#=]{0,500}",
tool in "[a-z]{3,10}",
source_line in r#"[a-zA-Z0-9/."\- ]{5,80}"#,
) {
let inserted = insert_source_block(&content, &tool, &source_line);
if let Some(new_content) = inserted {
let removed = remove_source_block(&new_content, &tool);
prop_assert!(removed.is_some(), "remove should find what insert added");
let restored = removed.unwrap();
prop_assert_eq!(restored.trim_end(), content.trim_end());
}
}
}
}