use eyre::Result;
use std::path::{Path, PathBuf};
fn file_in(state_dir: &Path) -> PathBuf {
super::store::store_dir_in(state_dir).join("notices")
}
pub(crate) fn say(message: &str) {
let fresh = match said().lock() {
Ok(mut said) => said.insert(message.to_string()),
Err(_) => true,
};
if fresh {
warn!("{message}");
}
}
fn said() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
static SAID: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::OnceLock::new();
SAID.get_or_init(Default::default)
}
pub(crate) fn forget_in(state_dir: &Path, said: &[String]) -> Result<()> {
forget_from(&file_in(state_dir), said)
}
fn forget_from(path: &Path, said: &[String]) -> Result<()> {
if !path.exists() || said.is_empty() {
return Ok(());
}
let said: Vec<String> = said
.iter()
.map(|message| message.replace('\n', " "))
.collect();
let _lock = guard(path)?;
let Ok(kept) = std::fs::read_to_string(path) else {
return Ok(());
};
let remaining: Vec<&str> = kept
.lines()
.filter(|line| !said.iter().any(|message| message == line))
.collect();
match remaining.is_empty() {
true => crate::file::write_atomic(path, "")?,
false => crate::file::write_atomic(path, format!("{}\n", remaining.join("\n")))?,
}
Ok(())
}
pub(crate) fn record(message: &str) -> Result<()> {
record_in(&super::store::state_dir(), message)
}
pub(crate) fn record_in(state_dir: &Path, message: &str) -> Result<()> {
record_to(&file_in(state_dir), message)
}
fn record_to(path: &Path, message: &str) -> Result<()> {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let _lock = guard(path)?;
let line = message.replace('\n', " ");
if let Ok(kept) = std::fs::read_to_string(path)
&& kept.lines().any(|existing| existing == line)
{
return Ok(());
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(file, "{line}")?;
Ok(())
}
fn guard(path: &Path) -> Result<fslock::LockFile> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
crate::lock_file::LockFile::at(&path.with_extension("lock")).lock()
}
pub(crate) fn drain() {
drain_in(&super::store::state_dir());
}
pub(crate) fn drain_in(state_dir: &Path) {
for line in take(&file_in(state_dir)) {
say(&line);
}
}
fn take(path: &Path) -> Vec<String> {
if !path.exists() {
return vec![];
}
let Ok(_lock) = guard(path) else {
return vec![];
};
let Ok(text) = std::fs::read_to_string(path) else {
return vec![];
};
let _ = std::fs::remove_file(path);
text.lines()
.filter(|line| !line.trim().is_empty())
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_notice_is_kept_until_it_is_said_and_then_is_gone() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state/notices");
assert!(take(&path).is_empty(), "nothing recorded, nothing to say");
record_to(&path, "first").unwrap();
record_to(&path, "second\nwith a newline in it").unwrap();
assert_eq!(
take(&path),
vec![
"first".to_string(),
"second with a newline in it".to_string()
]
);
assert!(take(&path).is_empty());
record_to(&path, "third").unwrap();
assert_eq!(take(&path), vec!["third".to_string()]);
}
#[test]
fn a_notice_already_said_is_taken_back_and_its_neighbours_are_not() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state/notices");
forget_from(&path, &["anything".to_string()]).unwrap();
record_to(&path, "plaintext warning").unwrap();
record_to(&path, "an unrelated notice").unwrap();
forget_from(&path, &[]).unwrap();
assert_eq!(
take(&path),
vec![
"plaintext warning".to_string(),
"an unrelated notice".to_string()
]
);
record_to(&path, "plaintext warning").unwrap();
record_to(&path, "an unrelated notice").unwrap();
forget_from(&path, &["plaintext warning".to_string()]).unwrap();
assert_eq!(
take(&path),
vec!["an unrelated notice".to_string()],
"forgetting one said notice took its neighbour with it"
);
record_to(&path, "still waiting").unwrap();
forget_from(&path, &["never said".to_string()]).unwrap();
assert_eq!(take(&path), vec!["still waiting".to_string()]);
record_to(&path, "two\nlines").unwrap();
forget_from(&path, &["two\nlines".to_string()]).unwrap();
assert!(
take(&path).is_empty(),
"a folded notice could not be taken back"
);
}
#[test]
fn a_notice_belongs_to_the_store_it_was_recorded_in() {
let temp = tempfile::tempdir().unwrap();
let one = temp.path().join("one");
let two = temp.path().join("two");
record_in(&one, "from one").unwrap();
assert!(file_in(&one).starts_with(&one));
assert!(
take(&file_in(&two)).is_empty(),
"another store was told a notice that was not its own"
);
assert_eq!(take(&file_in(&one)), vec!["from one".to_string()]);
}
#[test]
fn asking_for_notices_creates_nothing() {
let temp = tempfile::tempdir().unwrap();
let state = temp.path().join("state");
let path = state.join("notices");
assert!(take(&path).is_empty());
assert!(!state.exists(), "the state directory was created by a read");
}
#[test]
fn a_standing_condition_is_recorded_once() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state/notices");
for _ in 0..5 {
record_to(&path, "a credential is saved in plaintext").unwrap();
}
record_to(&path, "something else").unwrap();
assert_eq!(
take(&path),
vec![
"a credential is saved in plaintext".to_string(),
"something else".to_string()
]
);
record_to(&path, "a credential is saved in plaintext").unwrap();
assert_eq!(
take(&path),
vec!["a credential is saved in plaintext".to_string()]
);
}
#[test]
fn nothing_recorded_during_a_drain_is_lost() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state/notices");
const COUNT: usize = 200;
let writer = {
let path = path.clone();
std::thread::spawn(move || {
for i in 0..COUNT {
record_to(&path, &format!("notice {i}")).unwrap();
}
})
};
let mut said = vec![];
let start = std::time::Instant::now();
while said.len() < COUNT && start.elapsed() < std::time::Duration::from_secs(30) {
said.extend(take(&path));
}
writer.join().unwrap();
said.extend(take(&path));
said.sort();
let mut expected: Vec<String> = (0..COUNT).map(|i| format!("notice {i}")).collect();
expected.sort();
assert_eq!(said, expected, "a notice was lost between the two");
}
}