use anyhow::{Context, Result};
use std::fs;
use std::path::PathBuf;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Snippet {
pub name: String,
pub sql: String,
pub path: PathBuf,
}
impl Snippet {
pub fn summary(&self) -> String {
let one: String = self
.sql
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("--"))
.collect::<Vec<_>>()
.join(" ");
one
}
}
pub fn dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".config"))
.join("easysql")
.join("snippets")
}
pub fn list() -> Vec<Snippet> {
list_in(&dir())
}
pub fn list_in(dir: &std::path::Path) -> Vec<Snippet> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
let mut out: Vec<Snippet> = entries
.filter_map(|e| {
let path = e.ok()?.path();
if path.extension()? != "sql" {
return None;
}
Some(Snippet {
name: path.file_stem()?.to_string_lossy().into_owned(),
sql: fs::read_to_string(&path).ok()?,
path,
})
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn get(name: &str) -> Option<Snippet> {
list().into_iter().find(|s| s.name == name)
}
pub fn save(name: &str, sql: &str) -> Result<PathBuf> {
save_in(&dir(), name, sql)
}
pub fn save_in(dir: &std::path::Path, name: &str, sql: &str) -> Result<PathBuf> {
let name = name.trim();
if name.is_empty() || name.contains(['/', '\\']) || name.starts_with('.') {
anyhow::bail!("a snippet name cannot be empty, contain a slash, or start with a dot");
}
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let path = dir.join(format!("{name}.sql"));
let body = match sql.ends_with('\n') {
true => sql.to_string(),
false => format!("{sql}\n"),
};
fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
pub fn delete(name: &str) -> Result<()> {
let path = dir().join(format!("{name}.sql"));
fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))
}
const START: &str = "-- easysql:start (generated from ~/.config/easysql/snippets, do not edit)";
const END: &str = "-- easysql:end";
pub fn psqlrc_path() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".psqlrc")
}
pub fn psqlrc_block(snips: &[Snippet]) -> String {
let mut out = String::from(START);
out.push('\n');
for s in snips {
let one = s.summary();
if one.is_empty() {
continue;
}
let escaped = one.replace('\\', "\\\\").replace('\'', "\\'");
out.push_str(&format!("\\set {} '{}'\n", s.name, escaped));
}
out.push_str(END);
out.push('\n');
out
}
pub fn sync_psqlrc() -> Result<PathBuf> {
let path = psqlrc_path();
let block = psqlrc_block(&list());
let existing = fs::read_to_string(&path).unwrap_or_default();
let body = splice(&existing, &block);
if body == existing {
return Ok(path);
}
if path.exists() {
crate::ini::backup(&path)?;
}
fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
pub fn splice(existing: &str, block: &str) -> String {
let mut out: Vec<String> = Vec::new();
let mut inside = false;
let mut replaced = false;
for line in existing.lines() {
if line.starts_with(START) {
inside = true;
out.push(block.trim_end().to_string());
replaced = true;
continue;
}
if inside {
if line.trim() == END {
inside = false;
}
continue;
}
out.push(line.to_string());
}
if !replaced {
if !out.is_empty() && !out.last().is_some_and(|l| l.trim().is_empty()) {
out.push(String::new());
}
out.push(block.trim_end().to_string());
}
let mut body = out.join("\n");
body.push('\n');
body
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
struct Temp(PathBuf);
impl Temp {
fn new() -> Temp {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path =
std::env::temp_dir().join(format!("easysql-snips-{}-{stamp}", std::process::id()));
fs::create_dir_all(&path).unwrap();
Temp(path)
}
}
impl Drop for Temp {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn a_name_that_would_escape_the_directory_is_refused() {
let t = Temp::new();
for bad in ["../../.bashrc", "a/b", "", " ", ".hidden"] {
assert!(
save_in(&t.0, bad, "select 1;").is_err(),
"should have refused {bad:?}"
);
}
assert!(save_in(&t.0, "fine", "select 1;").is_ok());
}
#[test]
fn the_psqlrc_block_is_one_set_per_snippet_with_quotes_escaped() {
let snips = vec![
Snippet {
name: "quoted".into(),
sql: "select 'it''s fine';".into(),
path: PathBuf::new(),
},
Snippet {
name: "multi".into(),
sql: "select 1\n -- a comment\n from x;".into(),
path: PathBuf::new(),
},
];
let block = psqlrc_block(&snips);
assert!(
block.contains(r"\set quoted 'select \'it\'\'s fine\';'"),
"{block}"
);
assert!(block.contains(r"\set multi 'select 1 from x;'"), "{block}");
assert!(
!block.contains("-- a comment"),
"comments are stripped: {block}"
);
}
#[test]
fn splicing_keeps_the_users_own_lines_and_replaces_only_our_block() {
let mine = "\\set PROMPT1 mine\n\\timing on\n";
let first = splice(
mine,
&psqlrc_block(&[Snippet {
name: "a".into(),
sql: "select 1;".into(),
path: PathBuf::new(),
}]),
);
assert!(first.contains("\\set PROMPT1 mine"));
assert!(first.contains("\\timing on"));
assert!(first.contains("\\set a 'select 1;'"));
let second = splice(
&first,
&psqlrc_block(&[Snippet {
name: "b".into(),
sql: "select 2;".into(),
path: PathBuf::new(),
}]),
);
assert_eq!(second.matches(START).count(), 1, "{second}");
assert!(second.contains("\\set b 'select 2;'"));
assert!(
!second.contains("\\set a "),
"the old block is gone: {second}"
);
assert!(
second.contains("\\timing on"),
"and the user's lines survive"
);
let again = splice(
&second,
&psqlrc_block(&[Snippet {
name: "b".into(),
sql: "select 2;".into(),
path: PathBuf::new(),
}]),
);
assert_eq!(again, second);
}
}