use std::fs::File;
use std::io::{
self,
BufRead,
BufReader,
IsTerminal,
Read,
Seek,
SeekFrom,
Write,
};
use std::process::Command;
use std::{
env,
fs,
};
use biip::Biip;
use dotenv::dotenv;
const HELP: &str = r#"Usage:
cat file | biip
biip [FILE ...] # read and redact one or more files
biip # open default editor for interactive input.
"#;
fn main() -> io::Result<()> {
dotenv().ok();
let stdin = io::stdin();
let mut stdout = io::stdout();
let mut stderr = io::stderr();
let biip = Biip::new();
let args: Vec<String> = env::args().skip(1).collect();
if args.iter().any(|a| a == "-h" || a == "--help") {
write!(stdout, "{}", HELP)?;
return Ok(());
}
if !args.is_empty() {
run_with_args(&args, &biip, &mut stdout, &mut stderr)?;
return Ok(());
}
if !stdin.is_terminal() {
run_with_piped_stdin(&stdin, &biip, &mut stdout)?;
return Ok(());
}
let editor = find_editor();
run_with_editor(&editor, &biip, &mut stdout, &mut stderr)
}
fn process_lines<R: BufRead>(
reader: R,
biip: &Biip,
out: &mut dyn Write,
) -> io::Result<()> {
for line_res in reader.lines() {
writeln!(out, "{}", biip.process(&line_res?))?;
}
Ok(())
}
fn run_with_args(
paths: &[String],
biip: &Biip,
out: &mut dyn Write,
err: &mut dyn Write,
) -> io::Result<()> {
let show_header = paths.len() > 1;
for path in paths {
process_file_path(path, show_header, biip, out, err)?;
}
Ok(())
}
fn process_file_path(
path: &str,
show_header: bool,
biip: &Biip,
out: &mut dyn Write,
err: &mut dyn Write,
) -> io::Result<()> {
let mut file = File::open(path)?;
if is_probably_binary(&mut file)? {
writeln!(err, "warning: binary file skipped: {}", path)?;
return Ok(());
}
file.seek(SeekFrom::Start(0))?;
if show_header {
writeln!(out, "─── {} ───", path)?;
}
let reader = BufReader::new(file);
process_lines(reader, biip, out)
}
fn run_with_piped_stdin(
stdin: &io::Stdin,
biip: &Biip,
out: &mut dyn Write,
) -> io::Result<()> {
process_lines(stdin.lock(), biip, out)
}
fn find_editor() -> String {
env::var("EDITOR").unwrap_or_else(|_| "vi".to_string())
}
fn run_with_editor(
editor: &str,
biip: &Biip,
out: &mut dyn Write,
err: &mut dyn Write,
) -> io::Result<()> {
let temp_path = env::temp_dir()
.join(format!("biip-interactive-{}.txt", std::process::id()));
File::create(&temp_path)?;
let tty = fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.ok();
let mut cmd = Command::new(&editor);
cmd.arg(&temp_path);
if let Some(tty_file) = tty {
cmd.stdin(tty_file.try_clone()?);
cmd.stdout(tty_file.try_clone()?);
cmd.stderr(tty_file);
}
let status = cmd.status();
let _cleanup = TempFileGuard {
path: temp_path.clone(),
};
match status {
Ok(status) if status.success() => {
let file = File::open(&temp_path)?;
let reader = BufReader::new(file);
process_lines(reader, biip, out)
}
Ok(_) => {
writeln!(err, "Editor closed without saving. Aborting.")?;
Ok(())
}
Err(e) => {
writeln!(
err,
"Failed to open editor '{}'. Is it in your $PATH?",
editor
)?;
Err(e)
}
}
}
struct TempFileGuard {
path: std::path::PathBuf,
}
impl Drop for TempFileGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn is_probably_binary(file: &mut File) -> io::Result<bool> {
let mut buf = [0u8; 8192];
let n = file.read(&mut buf)?;
let slice = &buf[..n];
if slice.is_empty() {
return Ok(false);
}
if slice.iter().any(|&b| b == 0) {
return Ok(true);
}
Ok(std::str::from_utf8(slice).is_err())
}
#[cfg(test)]
mod tests {
use std::fs;
use std::io::Cursor;
use std::path::PathBuf;
use super::*;
fn tmp_file_with(content: &[u8], name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("biip_test_{}_{}", name, std::process::id()));
fs::write(&p, content).expect("write temp file");
p
}
#[test]
fn test_is_probably_binary_detects_binary() {
let text_p = tmp_file_with(b"hello world", "text");
let bin_p = tmp_file_with(b"\x00\xFF\x00BIN", "bin");
let mut tf = File::open(&text_p).unwrap();
let mut bf = File::open(&bin_p).unwrap();
assert!(!is_probably_binary(&mut tf).unwrap());
assert!(is_probably_binary(&mut bf).unwrap());
let _ = fs::remove_file(text_p);
let _ = fs::remove_file(bin_p);
}
#[test]
fn test_run_with_args_single_file_omits_header() {
let text_p = tmp_file_with(b"hello user foo@bar.com", "single_text");
let biip = Biip::new();
let mut out = Vec::new();
let mut err = Vec::new();
run_with_args(
&vec![text_p.to_string_lossy().into()],
&biip,
&mut out,
&mut err,
)
.unwrap();
let so = String::from_utf8(out).unwrap();
assert!(!so.contains("─── "));
let _ = fs::remove_file(text_p);
}
#[test]
fn test_process_lines_redacts_email() {
let biip = Biip::new();
let input = b"email: foo@bar.com\n";
let reader = Cursor::new(&input[..]);
let mut out = Vec::new();
process_lines(reader, &biip, &mut out).unwrap();
let s = String::from_utf8(out).unwrap();
assert!(s.contains("•••@•••"));
}
#[test]
fn test_run_with_args_skips_binary_and_prints_header_for_text() {
let text_p = tmp_file_with(b"hello user foo@bar.com", "text2");
let bin_p = tmp_file_with(b"\x00\x00PNG", "bin2");
let biip = Biip::new();
let mut out = Vec::new();
let mut err = Vec::new();
run_with_args(
&vec![
text_p.to_string_lossy().into(),
bin_p.to_string_lossy().into(),
],
&biip,
&mut out,
&mut err,
)
.unwrap();
let so = String::from_utf8(out).unwrap();
let se = String::from_utf8(err).unwrap();
assert!(so.contains("─── ")); assert!(se.contains("warning: binary file skipped:"));
let _ = fs::remove_file(text_p);
let _ = fs::remove_file(bin_p);
}
#[test]
fn test_run_with_editor_success() {
let script_path = tmp_file_with(
b"#!/bin/sh\necho 'test@example.com' > \"$1\"",
"editor_success",
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&script_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_path, perms).unwrap();
}
let biip = Biip::new();
let mut out = Vec::new();
let mut err = Vec::new();
let result = run_with_editor(
&script_path.to_string_lossy(),
&biip,
&mut out,
&mut err,
);
assert!(result.is_ok());
let output = String::from_utf8(out).unwrap();
assert!(output.contains("•••@•••")); let _ = fs::remove_file(script_path);
}
#[test]
fn test_run_with_editor_non_success_exit() {
let script_path = tmp_file_with(b"#!/bin/sh\nexit 1", "editor_fail");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&script_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_path, perms).unwrap();
}
let biip = Biip::new();
let mut out = Vec::new();
let mut err = Vec::new();
let result = run_with_editor(
&script_path.to_string_lossy(),
&biip,
&mut out,
&mut err,
);
assert!(result.is_ok()); let output = String::from_utf8(out).unwrap();
assert!(output.is_empty()); let err_output = String::from_utf8(err).unwrap();
assert!(err_output.contains("Editor closed without saving"));
let _ = fs::remove_file(script_path);
}
#[test]
fn test_run_with_editor_nonexistent() {
let biip = Biip::new();
let mut out = Vec::new();
let mut err = Vec::new();
let result = run_with_editor(
"/nonexistent/editor/path/xyz123",
&biip,
&mut out,
&mut err,
);
assert!(result.is_err()); let err_output = String::from_utf8(err).unwrap();
assert!(err_output.contains("Failed to open editor"));
}
}