use std::path::{Component, Path, PathBuf};
use thiserror::Error;
pub const CONTENT_YANK_MAX_BYTES: u64 = 1024 * 1024;
const BINARY_PROBE_BYTES: usize = 8192;
#[derive(Debug, Error)]
pub enum ClipboardError {
#[error("path is not valid UTF-8")]
NotUtf8,
#[error("clipboard error: {0}")]
Arboard(#[from] arboard::Error),
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("binary file — nothing to yank")]
BinaryContent,
#[error("file is {size} bytes, over the {limit}-byte yank limit")]
TooLarge {
size: u64,
limit: u64,
},
}
pub fn absolute_path_text(entry_path: &Path) -> Result<String, ClipboardError> {
let simplified = crate::pathfmt::simplified(entry_path);
Ok(simplified
.to_str()
.ok_or(ClipboardError::NotUtf8)?
.to_owned())
}
pub fn relative_path_text(entry_path: &Path, base: &Path) -> Result<String, ClipboardError> {
let relative = relative_to(entry_path, base);
let rel = relative.as_deref().unwrap_or(entry_path);
Ok(rel.to_str().ok_or(ClipboardError::NotUtf8)?.to_owned())
}
fn relative_to(target: &Path, base: &Path) -> Option<PathBuf> {
if target.is_absolute() != base.is_absolute() {
return None;
}
let mut target_parts = target.components();
let mut base_parts = base.components();
let mut result: Vec<Component> = Vec::new();
loop {
match (target_parts.next(), base_parts.next()) {
(None, None) => break,
(Some(t), None) => {
result.push(t);
result.extend(target_parts.by_ref());
break;
}
(None, Some(_)) => result.push(Component::ParentDir),
(Some(t), Some(b)) if result.is_empty() && t == b => {}
(Some(_), Some(Component::ParentDir)) => return None,
(Some(t), Some(b))
if matches!(t, Component::Prefix(_) | Component::RootDir)
|| matches!(b, Component::Prefix(_) | Component::RootDir) =>
{
return None;
}
(Some(t), Some(_)) => {
result.push(Component::ParentDir);
result.extend(base_parts.by_ref().map(|_| Component::ParentDir));
result.push(t);
result.extend(target_parts.by_ref());
break;
}
}
}
if result.is_empty() {
result.push(Component::CurDir);
}
Some(result.iter().map(|c| c.as_os_str()).collect())
}
pub fn filename_text(entry_path: &Path) -> Result<String, ClipboardError> {
Ok(entry_path
.file_name()
.and_then(|n| n.to_str())
.ok_or(ClipboardError::NotUtf8)?
.to_owned())
}
pub fn content_text(path: &Path, show_hidden: bool) -> Result<String, ClipboardError> {
let metadata = std::fs::metadata(path)?;
if metadata.is_dir() {
return directory_listing_text(path, show_hidden);
}
let size = metadata.len();
if size > CONTENT_YANK_MAX_BYTES {
return Err(ClipboardError::TooLarge {
size,
limit: CONTENT_YANK_MAX_BYTES,
});
}
let bytes = std::fs::read(path)?;
let probe = &bytes[..bytes.len().min(BINARY_PROBE_BYTES)];
if content_inspector::inspect(probe).is_binary() {
return Err(ClipboardError::BinaryContent);
}
String::from_utf8(bytes).map_err(|_| ClipboardError::NotUtf8)
}
fn directory_listing_text(path: &Path, show_hidden: bool) -> Result<String, ClipboardError> {
let mut names: Vec<String> = Vec::new();
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let name = entry
.file_name()
.to_str()
.ok_or(ClipboardError::NotUtf8)?
.to_owned();
if name.starts_with('.') && !show_hidden {
continue;
}
let is_dir = entry.metadata().map(|m| m.is_dir()).unwrap_or(false);
names.push(if is_dir { format!("{name}/") } else { name });
}
names.sort_by(|a, b| {
let a_dir = a.ends_with('/');
let b_dir = b.ends_with('/');
match (a_dir, b_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.to_lowercase().cmp(&b.to_lowercase()),
}
});
Ok(names.join("\n"))
}
pub fn set_clipboard(text: &str) -> Result<(), ClipboardError> {
let mut clipboard = arboard::Clipboard::new()?;
clipboard.set_text(text.to_owned())?;
Ok(())
}
pub const YANK_SUMMARY_MAX_CHARS: usize = 40;
pub fn summarize(yank: &str) -> String {
let first_line = yank.split('\n').next().unwrap_or("");
let mut visible = first_line.chars().filter(|c| !c.is_control());
let mut summary: String = visible.by_ref().take(YANK_SUMMARY_MAX_CHARS).collect();
if visible.next().is_some() || first_line.len() < yank.len() {
summary.push('…');
}
summary
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn absolute_path_returns_full_path() {
let p = PathBuf::from("/home/user/project/main.rs");
let s = absolute_path_text(&p).unwrap();
assert_eq!(s, "/home/user/project/main.rs");
}
fn norm(s: &str) -> String {
s.replace('\\', "/")
}
#[test]
fn relative_path_strips_base() {
let base = PathBuf::from("/home/user/project");
let entry = PathBuf::from("/home/user/project/src/main.rs");
let s = relative_path_text(&entry, &base).unwrap();
assert_eq!(norm(&s), "src/main.rs");
}
#[test]
fn relative_path_climbs_out_of_base_with_parent_segments() {
let base = PathBuf::from("/home/user/project");
let entry = PathBuf::from("/home/user/notes/todo.md");
let s = relative_path_text(&entry, &base).unwrap();
assert_eq!(norm(&s), "../notes/todo.md");
}
#[test]
fn relative_path_of_base_itself_is_current_dir() {
let base = PathBuf::from("/home/user/project");
let s = relative_path_text(&base, &base).unwrap();
assert_eq!(s, ".");
}
#[test]
fn relative_path_falls_back_to_absolute_for_unrelatable_paths() {
let base = PathBuf::from("project");
let entry = PathBuf::from("/home/user/project/main.rs");
let s = relative_path_text(&entry, &base).unwrap();
assert_eq!(s, "/home/user/project/main.rs");
}
#[cfg(windows)]
#[test]
fn relative_path_falls_back_to_absolute_across_windows_drives() {
let base = PathBuf::from(r"C:\Users\dev");
let entry = PathBuf::from(r"D:\data\notes.md");
let s = relative_path_text(&entry, &base).unwrap();
assert_eq!(s, r"D:\data\notes.md");
}
#[test]
fn content_of_text_file_is_the_file_text() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hello.txt");
std::fs::write(&file, b"line one\nline two\n").unwrap();
let s = content_text(&file, false).unwrap();
assert_eq!(s, "line one\nline two\n");
}
#[test]
fn content_of_directory_is_a_sorted_listing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("b.txt"), b"").unwrap();
std::fs::write(dir.path().join("a.txt"), b"").unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
let s = content_text(dir.path(), false).unwrap();
assert_eq!(s, "sub/\na.txt\nb.txt");
}
#[test]
fn content_of_directory_honours_show_hidden() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("visible.txt"), b"").unwrap();
std::fs::write(dir.path().join(".hidden"), b"").unwrap();
assert_eq!(content_text(dir.path(), false).unwrap(), "visible.txt");
assert_eq!(
content_text(dir.path(), true).unwrap(),
".hidden\nvisible.txt"
);
}
#[test]
fn content_of_binary_file_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("blob.bin");
std::fs::write(&file, [0u8, 1, 2, 0, 3, 0]).unwrap();
assert!(matches!(
content_text(&file, false),
Err(ClipboardError::BinaryContent)
));
}
#[test]
fn content_over_the_size_limit_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("big.txt");
let big = vec![b'a'; (CONTENT_YANK_MAX_BYTES + 1) as usize];
std::fs::write(&file, big).unwrap();
assert!(matches!(
content_text(&file, false),
Err(ClipboardError::TooLarge { .. })
));
}
#[test]
fn content_of_missing_path_is_an_io_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.txt");
assert!(matches!(
content_text(&missing, false),
Err(ClipboardError::Io(_))
));
}
#[test]
fn filename_returns_only_file_name() {
let p = PathBuf::from("/home/user/project/main.rs");
let s = filename_text(&p).unwrap();
assert_eq!(s, "main.rs");
}
#[test]
fn filename_of_root_path_is_not_utf8_error() {
let p = PathBuf::from("/");
assert!(matches!(filename_text(&p), Err(ClipboardError::NotUtf8)));
}
#[test]
fn summarize_passes_short_paths_through() {
assert_eq!(summarize("src/main.rs"), "src/main.rs");
}
#[test]
fn summarize_keeps_only_the_first_line() {
assert_eq!(summarize("fn main() {\n todo!()\n}\n"), "fn main() {…");
}
#[test]
fn summarize_truncates_a_long_line() {
let long = "a".repeat(YANK_SUMMARY_MAX_CHARS * 2);
let summary = summarize(&long);
assert_eq!(summary.chars().count(), YANK_SUMMARY_MAX_CHARS + 1);
assert!(summary.ends_with('…'));
}
#[test]
fn summarize_drops_control_characters() {
assert_eq!(summarize("a\tb\rc"), "abc");
}
}