use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestRecord {
pub id: String,
pub fragment: String,
pub description: String,
pub created_at: u64,
}
pub fn record(rec: RequestRecord) -> Result<()> {
record_at(&requests_path()?, rec)
}
pub fn all() -> Vec<RequestRecord> {
requests_path()
.ok()
.map(|p| all_from(&p))
.unwrap_or_default()
}
pub fn get(id: &str) -> Option<RequestRecord> {
requests_path().ok().and_then(|p| get_from(&p, id))
}
pub fn remove(id: &str) -> Result<()> {
remove_at(&requests_path()?, id)
}
fn record_at(path: &Path, rec: RequestRecord) -> Result<()> {
let mut all = load_from(path).unwrap_or_default();
all.retain(|r| r.id != rec.id);
all.push(rec);
save_to(path, &all)
}
fn all_from(path: &Path) -> Vec<RequestRecord> {
load_from(path).unwrap_or_default()
}
fn get_from(path: &Path, id: &str) -> Option<RequestRecord> {
all_from(path).into_iter().find(|r| r.id == id)
}
fn remove_at(path: &Path, id: &str) -> Result<()> {
let mut all = load_from(path).unwrap_or_default();
all.retain(|r| r.id != id);
save_to(path, &all)
}
fn load_from(path: &Path) -> Result<Vec<RequestRecord>> {
match std::fs::read_to_string(path) {
Ok(text) => serde_json::from_str(&text).context("parsing the dove requests ledger"),
Err(_) => Ok(Vec::new()), }
}
fn save_to(path: &Path, all: &[RequestRecord]) -> Result<()> {
std::fs::create_dir_all(path.parent().unwrap())?;
let text = serde_json::to_string_pretty(all).context("serializing the dove requests ledger")?;
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
set_private(path)
}
#[cfg(unix)]
fn set_private(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("chmod 600 {}", path.display()))
}
#[cfg(not(unix))]
fn set_private(_path: &Path) -> Result<()> {
Ok(())
}
fn requests_path() -> Result<PathBuf> {
if let Ok(x) = std::env::var("XDG_CONFIG_HOME") {
if !x.is_empty() {
return Ok(PathBuf::from(x).join("dove/requests.json"));
}
}
let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME is not set"))?;
Ok(PathBuf::from(home).join(".config/dove/requests.json"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_records_through_the_public_api_paths() {
let dir = std::env::temp_dir().join(format!("dove-req-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("requests.json");
let a = RequestRecord {
id: "aaa".into(),
fragment: "deadbeef".into(),
description: "invoice".into(),
created_at: 1_000,
};
let b = RequestRecord {
id: "bbb".into(),
fragment: "cafebabe".into(),
description: "vacation photo".into(),
created_at: 2_000,
};
assert!(all_from(&path).is_empty(), "fresh ledger starts empty");
record_at(&path, a.clone()).unwrap();
record_at(&path, b.clone()).unwrap();
let everything = all_from(&path);
assert_eq!(everything.len(), 2);
let got_a = get_from(&path, &a.id).unwrap();
assert_eq!(got_a.fragment, "deadbeef");
assert_eq!(got_a.description, "invoice");
assert_eq!(got_a.created_at, 1_000);
let got_b = get_from(&path, &b.id).unwrap();
assert_eq!(got_b.fragment, "cafebabe");
assert!(get_from(&path, "no-such-id").is_none());
let a_revised = RequestRecord {
description: "invoice (revised)".into(),
..a.clone()
};
record_at(&path, a_revised).unwrap();
assert_eq!(all_from(&path).len(), 2, "same id replaces, not appends");
assert_eq!(
get_from(&path, &a.id).unwrap().description,
"invoice (revised)"
);
remove_at(&path, &a.id).unwrap();
let remaining = all_from(&path);
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].id, b.id);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
let _ = std::fs::remove_dir_all(&dir);
}
}