use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum SnippetError {
#[error("invalid snippet name '{0}': use lowercase letters, digits, '-', or '_' only")]
InvalidName(String),
#[error("{0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, SnippetError>;
pub struct SnippetStore {
pub root: PathBuf,
}
impl SnippetStore {
pub const fn new(root: PathBuf) -> Self {
Self { root }
}
pub fn default_root() -> PathBuf {
directories::ProjectDirs::from("dev", "narwhal", "narwhal").map_or_else(
|| PathBuf::from(".").join("narwhal").join("snippets"),
|dirs| dirs.config_dir().join("snippets"),
)
}
pub fn save(&self, name: &str, sql: &str) -> Result<()> {
Self::validate_name(name)?;
std::fs::create_dir_all(&self.root)?;
let tmp_path = self.root.join(format!(".{name}.sql.tmp"));
let final_path = self.root.join(format!("{name}.sql"));
std::fs::write(&tmp_path, sql)?;
std::fs::rename(&tmp_path, &final_path)?;
Ok(())
}
pub fn load(&self, name: &str) -> Result<String> {
Self::validate_name(name)?;
let path = self.root.join(format!("{name}.sql"));
Ok(std::fs::read_to_string(path)?)
}
pub fn remove(&self, name: &str) -> Result<()> {
Self::validate_name(name)?;
let path = self.root.join(format!("{name}.sql"));
std::fs::remove_file(path)?;
Ok(())
}
pub fn list(&self) -> Result<Vec<String>> {
if !self.root.exists() {
return Ok(Vec::new());
}
let mut names: Vec<String> = std::fs::read_dir(&self.root)?
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("sql") {
path.file_stem()?
.to_str()
.map(std::borrow::ToOwned::to_owned)
} else {
None
}
})
.collect();
names.sort();
Ok(names)
}
fn validate_name(name: &str) -> Result<()> {
let ok = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
if ok {
Ok(())
} else {
Err(SnippetError::InvalidName(name.into()))
}
}
}