use crate::corrupt::corrupt;
use crate::error::DecayError;
use crate::format::{Header, ImageDimensions, HEADER_SIZE};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
const IMAGE_EXTENSION_PREFIX: &str = "idcy";
const TEXT_EXTENSION_PREFIX: &str = "tdcy";
const RGBA_BYTES_PER_PIXEL: usize = 4;
fn parse_x_from_filename(path: &Path) -> Result<f64, DecayError> {
let filename = path.to_string_lossy().into_owned();
let no_x = || DecayError::FilenameNoX {
filename: filename.clone(),
};
let extension = path.extension().and_then(|raw| raw.to_str()).ok_or_else(no_x)?;
let digits = extension
.strip_prefix(IMAGE_EXTENSION_PREFIX)
.or_else(|| extension.strip_prefix(TEXT_EXTENSION_PREFIX))
.ok_or_else(no_x)?;
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(no_x());
}
let value: u32 = digits.parse().map_err(|_| no_x())?;
if value == 0 {
return Err(DecayError::XNotPositive {
value: f64::from(value),
});
}
Ok(f64::from(value))
}
fn ensure_writable(path: &Path) -> Result<(), DecayError> {
let metadata = std::fs::metadata(path).map_err(|error| DecayError::Io {
context: format!("open: stat '{}'", path.display()),
source: error,
})?;
if metadata.permissions().readonly() {
return Err(DecayError::ReadOnly {
path: path.display().to_string(),
});
}
Ok(())
}
fn decay_in_place(path: &Path) -> Result<(Header, Vec<u8>), DecayError> {
let x = parse_x_from_filename(path)?;
ensure_writable(path)?;
let mut file_bytes = std::fs::read(path).map_err(|error| DecayError::Io {
context: format!("open: read '{}'", path.display()),
source: error,
})?;
let header = Header::read(&file_bytes)?;
corrupt(&mut file_bytes[HEADER_SIZE..], header.file_type, x);
std::fs::write(path, &file_bytes).map_err(|error| DecayError::Io {
context: format!("open: write corrupted payload to '{}'", path.display()),
source: error,
})?;
Ok((header, file_bytes))
}
pub fn open_file(path: &Path) -> Result<(), DecayError> {
let (header, file_bytes) = decay_in_place(path)?;
let payload = &file_bytes[HEADER_SIZE..];
match header.dimensions {
Some(dimensions) => display_image(payload, dimensions),
None => display_text(payload),
}
}
fn display_text(payload: &[u8]) -> Result<(), DecayError> {
let text = String::from_utf8_lossy(payload);
print!("{}", text);
if std::io::stdout().is_terminal() {
return Ok(());
}
let viewer_path = temporary_output_path("txt");
std::fs::write(&viewer_path, text.as_bytes()).map_err(|error| DecayError::Io {
context: format!("open: write display text '{}'", viewer_path.display()),
source: error,
})?;
open_in_default_app(&viewer_path)
}
fn display_image(payload: &[u8], dimensions: ImageDimensions) -> Result<(), DecayError> {
let expected = (dimensions.width as usize)
.saturating_mul(dimensions.height as usize)
.saturating_mul(RGBA_BYTES_PER_PIXEL);
let image = image::RgbaImage::from_raw(dimensions.width, dimensions.height, payload.to_vec())
.ok_or(DecayError::PayloadSizeMismatch {
expected,
found: payload.len(),
})?;
let viewer_path = temporary_output_path("png");
image.save(&viewer_path).map_err(|error| DecayError::ImageEncode {
context: format!(
"open: encode display png '{}': {}",
viewer_path.display(),
error
),
})?;
open_in_default_app(&viewer_path)
}
fn temporary_output_path(extension: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("decayfmt_view_{}.{}", nanos, extension))
}
fn open_in_default_app(path: &Path) -> Result<(), DecayError> {
let spawn_result = if cfg!(target_os = "windows") {
Command::new("cmd")
.args(["/C", "start", ""])
.arg(path)
.spawn()
} else if cfg!(target_os = "macos") {
Command::new("open").arg(path).spawn()
} else {
Command::new("xdg-open").arg(path).spawn()
};
spawn_result.map(|_child| ()).map_err(|error| DecayError::Io {
context: format!("open: launch default viewer for '{}'", path.display()),
source: error,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::encode::encode_file;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_temp_path(suffix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("decayfmt_open_test_{}_{}", nanos, suffix))
}
#[test]
fn x_is_parsed_from_image_and_text_extensions() {
assert_eq!(
parse_x_from_filename(Path::new("photo.idcy3")).expect("idcy3 parses"),
3.0
);
assert_eq!(
parse_x_from_filename(Path::new("note.tdcy12")).expect("tdcy12 parses"),
12.0
);
}
#[test]
fn missing_or_malformed_x_is_refused() {
for name in ["plain.png", "note.txt", "no_extension", "photo.idcy", "photo.idcyx"] {
assert!(
matches!(
parse_x_from_filename(Path::new(name)),
Err(DecayError::FilenameNoX { .. })
),
"'{}' should yield FilenameNoX",
name
);
}
}
#[test]
fn zero_x_is_refused_as_not_positive() {
assert!(matches!(
parse_x_from_filename(Path::new("photo.idcy0")),
Err(DecayError::XNotPositive { .. })
));
}
#[test]
fn open_changes_the_payload_on_disk() {
let source = vec![b'a'; 4096];
let input = unique_temp_path("source.txt");
let decay_file = unique_temp_path("note.tdcy5");
fs::write(&input, &source).expect("write test source");
encode_file(&input, &decay_file, 5.0).expect("encode should succeed");
let before = fs::read(&decay_file).expect("read encoded file");
let payload_before = before[HEADER_SIZE..].to_vec();
decay_in_place(&decay_file).expect("decay should succeed");
let after = fs::read(&decay_file).expect("read opened file");
let payload_after = &after[HEADER_SIZE..];
assert_ne!(
payload_after, payload_before,
"payload must differ on disk after open"
);
let _ = fs::remove_file(&input);
let _ = fs::remove_file(&decay_file);
}
#[allow(clippy::permissions_set_readonly_false)]
#[test]
fn read_only_file_is_refused() {
let input = unique_temp_path("source.txt");
let decay_file = unique_temp_path("note.tdcy3");
fs::write(&input, b"some text").expect("write test source");
encode_file(&input, &decay_file, 3.0).expect("encode should succeed");
let mut permissions = fs::metadata(&decay_file)
.expect("stat decay file")
.permissions();
permissions.set_readonly(true);
fs::set_permissions(&decay_file, permissions).expect("set read-only");
assert!(
matches!(decay_in_place(&decay_file), Err(DecayError::ReadOnly { .. })),
"a read-only file must be refused"
);
let mut permissions = fs::metadata(&decay_file)
.expect("stat decay file")
.permissions();
permissions.set_readonly(false);
let _ = fs::set_permissions(&decay_file, permissions);
let _ = fs::remove_file(&input);
let _ = fs::remove_file(&decay_file);
}
#[test]
fn wrong_magic_is_refused() {
let decay_file = unique_temp_path("bogus.tdcy3");
fs::write(&decay_file, [0u8; 32]).expect("write bogus file");
assert!(
matches!(decay_in_place(&decay_file), Err(DecayError::WrongMagic { .. })),
"a file without the magic bytes must be refused"
);
let _ = fs::remove_file(&decay_file);
}
#[test]
fn filename_without_x_is_refused_before_touching_the_file() {
let missing = Path::new("this_file_does_not_exist.txt");
assert!(matches!(
decay_in_place(missing),
Err(DecayError::FilenameNoX { .. })
));
}
}