use std::collections::BTreeMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BufferEntry {
pub text: String,
pub created_at: u64,
}
#[derive(Debug, Default)]
pub struct BufferStore {
buffers: BTreeMap<String, BufferEntry>,
next_seq: u64,
}
impl BufferStore {
pub const MAX_BUFFERS: usize = 100;
pub const MAX_BYTES: usize = 16 * 1024 * 1024;
pub const DEFAULT_NAME: &'static str = "";
pub fn new() -> Self {
Self::default()
}
pub fn set(
&mut self,
name: impl Into<String>,
text: impl Into<String>,
) -> Result<(), SetError> {
let text = text.into();
if text.len() > Self::MAX_BYTES {
return Err(SetError::TooLarge {
name: name.into(),
size: text.len(),
cap: Self::MAX_BYTES,
});
}
let name = name.into();
let seq = self.next_seq();
let is_new = !self.buffers.contains_key(&name);
if is_new && self.buffers.len() >= Self::MAX_BUFFERS {
self.evict_oldest();
}
self.buffers.insert(
name,
BufferEntry {
text,
created_at: seq,
},
);
Ok(())
}
pub fn get(&self, name: &str) -> Option<&BufferEntry> {
self.buffers.get(name)
}
pub fn default_buffer(&self) -> Option<&BufferEntry> {
self.get(Self::DEFAULT_NAME)
}
pub fn delete(&mut self, name: &str) -> Option<BufferEntry> {
self.buffers.remove(name)
}
pub fn list(&self) -> impl Iterator<Item = (&str, &BufferEntry)> {
self.buffers.iter().map(|(k, v)| (k.as_str(), v))
}
pub fn len(&self) -> usize {
self.buffers.len()
}
pub fn is_empty(&self) -> bool {
self.buffers.is_empty()
}
pub fn save(&self, name: &str, path: &Path, truncate: bool) -> Result<usize, SaveError> {
let entry = self.get(name).ok_or_else(|| SaveError::NoSuchBuffer {
name: name.to_string(),
})?;
let mut opts = OpenOptions::new();
opts.write(true).create(true);
if truncate {
opts.truncate(true);
} else {
opts.create_new(true);
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path).map_err(|e| SaveError::Io {
path: path.to_path_buf(),
source: e,
})?;
f.write_all(entry.text.as_bytes())
.map_err(|e| SaveError::Io {
path: path.to_path_buf(),
source: e,
})?;
Ok(entry.text.len())
}
fn next_seq(&mut self) -> u64 {
let seq = self.next_seq;
self.next_seq = self.next_seq.wrapping_add(1);
seq
}
fn evict_oldest(&mut self) {
let oldest = self
.buffers
.iter()
.min_by_key(|(_, v)| v.created_at)
.map(|(k, _)| k.clone());
if let Some(k) = oldest {
self.buffers.remove(&k);
}
}
}
#[derive(Debug)]
pub enum SetError {
TooLarge {
name: String,
size: usize,
cap: usize,
},
}
impl std::fmt::Display for SetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooLarge { name, size, cap } => write!(
f,
"buffer '{name}' rejected: {size} bytes exceeds {cap} byte cap"
),
}
}
}
impl std::error::Error for SetError {}
#[derive(Debug)]
pub enum SaveError {
NoSuchBuffer {
name: String,
},
Io {
path: std::path::PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for SaveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoSuchBuffer { name } => write!(f, "no buffer named '{name}'"),
Self::Io { path, source } => write!(f, "writing {}: {source}", path.display()),
}
}
}
impl std::error::Error for SaveError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_and_get_default_buffer() {
let mut s = BufferStore::new();
s.set(BufferStore::DEFAULT_NAME, "hello").unwrap();
assert_eq!(s.default_buffer().unwrap().text, "hello");
}
#[test]
fn named_buffer_round_trip() {
let mut s = BufferStore::new();
s.set("foo", "alpha").unwrap();
s.set("bar", "beta").unwrap();
assert_eq!(s.get("foo").unwrap().text, "alpha");
assert_eq!(s.get("bar").unwrap().text, "beta");
assert_eq!(s.len(), 2);
}
#[test]
fn list_returns_alphabetic_order() {
let mut s = BufferStore::new();
s.set("zeta", "z").unwrap();
s.set("alpha", "a").unwrap();
s.set("mu", "m").unwrap();
let names: Vec<&str> = s.list().map(|(n, _)| n).collect();
assert_eq!(names, vec!["alpha", "mu", "zeta"]);
}
#[test]
fn delete_removes_named_buffer() {
let mut s = BufferStore::new();
s.set("foo", "x").unwrap();
let evicted = s.delete("foo").unwrap();
assert_eq!(evicted.text, "x");
assert!(s.get("foo").is_none());
assert!(s.delete("missing").is_none());
}
#[test]
fn replace_existing_buffer_preserves_count() {
let mut s = BufferStore::new();
s.set("foo", "first").unwrap();
s.set("foo", "second").unwrap();
assert_eq!(s.len(), 1);
assert_eq!(s.get("foo").unwrap().text, "second");
}
#[test]
fn oversize_payload_is_rejected() {
let mut s = BufferStore::new();
let big = "a".repeat(BufferStore::MAX_BYTES + 1);
let err = s.set("foo", big).unwrap_err();
match err {
SetError::TooLarge { name, size, cap } => {
assert_eq!(name, "foo");
assert_eq!(size, BufferStore::MAX_BYTES + 1);
assert_eq!(cap, BufferStore::MAX_BYTES);
}
}
assert!(s.is_empty());
}
#[test]
fn payload_at_cap_is_accepted() {
let mut s = BufferStore::new();
let exact = "x".repeat(BufferStore::MAX_BYTES);
s.set("foo", exact).expect("at-cap payload accepted");
assert_eq!(s.get("foo").unwrap().text.len(), BufferStore::MAX_BYTES);
}
#[test]
fn evicts_oldest_when_buffer_count_cap_hit() {
let mut s = BufferStore::new();
for i in 0..BufferStore::MAX_BUFFERS {
s.set(format!("buf{i:03}"), format!("v{i}")).unwrap();
}
assert_eq!(s.len(), BufferStore::MAX_BUFFERS);
s.set("overflow", "new").unwrap();
assert_eq!(s.len(), BufferStore::MAX_BUFFERS);
assert!(s.get("buf000").is_none());
assert_eq!(s.get("overflow").unwrap().text, "new");
assert!(s.get("buf001").is_some());
}
#[test]
fn replacing_named_buffer_at_cap_does_not_evict() {
let mut s = BufferStore::new();
for i in 0..BufferStore::MAX_BUFFERS {
s.set(format!("buf{i:03}"), format!("v{i}")).unwrap();
}
s.set("buf050", "updated").unwrap();
assert_eq!(s.len(), BufferStore::MAX_BUFFERS);
assert!(s.get("buf000").is_some());
assert_eq!(s.get("buf050").unwrap().text, "updated");
}
#[test]
fn save_writes_file_with_0600_mode() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("clip.txt");
let mut s = BufferStore::new();
s.set("foo", "payload").unwrap();
let n = s.save("foo", &path, true).unwrap();
assert_eq!(n, "payload".len());
let body = std::fs::read_to_string(&path).unwrap();
assert_eq!(body, "payload");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "file must be private (0600)");
}
}
#[test]
fn save_unknown_buffer_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing.txt");
let s = BufferStore::new();
let err = s.save("nope", &path, true).unwrap_err();
match err {
SaveError::NoSuchBuffer { name } => assert_eq!(name, "nope"),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn save_refuses_to_clobber_when_truncate_false() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("preexisting.txt");
std::fs::write(&path, "do not overwrite").unwrap();
let mut s = BufferStore::new();
s.set("foo", "new").unwrap();
let err = s.save("foo", &path, false).unwrap_err();
assert!(matches!(err, SaveError::Io { .. }));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "do not overwrite");
}
}