use crate::config::{cached_read_toml, invalidate_toml_cache};
use crate::error::{SnipError, SnipResult};
use crate::utils::config::{get_config_dir, get_snippets_path};
use crate::utils::toml_helpers::{fix_invalid_toml_escapes, quote_strings_containing_backslashes};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Snippets {
#[serde(rename = "snippets", alias = "Snippets", default)]
pub snippets: Vec<Snippet>,
#[serde(default = "Vec::new", skip_serializing_if = "Vec::is_empty")]
pub folders: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Snippet {
#[serde(rename = "id", alias = "Id", alias = "ID", default)]
pub id: String,
#[serde(alias = "Description", alias = "name", default)]
pub description: String,
#[serde(rename = "output", alias = "Output", default)]
pub output: String,
#[serde(
rename = "tag",
alias = "Tag",
alias = "Tags",
alias = "tags",
default,
skip_serializing_if = "Vec::is_empty"
)]
pub tags: Vec<String>,
#[serde(alias = "Command", alias = "cmd", default)]
pub command: String,
#[serde(default = "Vec::new", skip_serializing_if = "Vec::is_empty")]
pub folders: Vec<String>,
#[serde(default)]
pub favorite: bool,
#[serde(default)]
pub created_at: i64,
#[serde(default)]
pub updated_at: i64,
#[serde(default)]
pub device_id: String,
#[serde(default)]
pub deleted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LibraryConfig {
#[serde(default)]
pub libraries: Vec<LibraryMeta>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LibraryMeta {
pub filename: String,
#[serde(default)]
pub library_id: String,
#[serde(default)]
pub is_primary: bool,
#[serde(default)]
pub last_sync: Option<i64>,
#[serde(default)]
pub server_id: Option<String>,
}
impl LibraryMeta {
pub fn new(filename: &str) -> Self {
Self {
filename: filename.to_string(),
library_id: String::new(),
is_primary: false,
last_sync: None,
server_id: None,
}
}
}
fn validate_library_name(name: &str) -> Result<(), (&'static str, &'static str)> {
if name.is_empty() {
return Err(("Invalid library name", "Library name cannot be empty"));
}
if name.len() > 50 {
return Err((
"Invalid library name",
"Library name cannot exceed 50 characters",
));
}
if name.contains('/') || name.contains('\\') {
return Err((
"Invalid library name",
"Library name cannot contain slashes",
));
}
if name.contains('\0') {
return Err((
"Invalid library name",
"Library name cannot contain null bytes",
));
}
if name == "." || name == ".." || name.contains("..") {
return Err((
"Invalid library name",
"Library name cannot contain path traversal sequences",
));
}
Ok(())
}
impl Snippet {
pub fn new(description: String, command: String, tags: Vec<String>) -> SnipResult<Self> {
if command.trim().is_empty() {
return Err(SnipError::runtime_error(
"Empty command",
Some("Snippet command cannot be empty"),
));
}
if description.trim().is_empty() {
return Err(SnipError::runtime_error(
"Empty description",
Some("Snippet description cannot be empty"),
));
}
let now = chrono::Utc::now().timestamp();
Ok(Self {
id: String::new(),
description,
command,
tags,
output: String::new(),
folders: Vec::new(),
favorite: false,
created_at: now,
updated_at: now,
device_id: String::new(),
deleted: false,
})
}
}
pub struct LibraryManager {
config_dir: PathBuf,
libraries_dir: PathBuf,
premade_dir: PathBuf,
config: LibraryConfig,
}
impl LibraryManager {
pub fn new() -> SnipResult<Self> {
if let Err(e) = crate::utils::config::migrate_macos_config_dir() {
tracing::warn!(error = %e, "Failed to migrate config directory");
}
let config_dir = get_config_dir();
let libraries_dir = config_dir.join("libraries");
let premade_dir = config_dir.join("premade");
let config_path = config_dir.join("libraries.toml");
let config = if config_path.exists() {
let content = cached_read_toml(&config_path)?;
let content = fix_invalid_toml_escapes(&content);
match toml::from_str(&content) {
Ok(c) => c,
Err(e) => {
let backup = config_path.with_extension("toml.corrupt");
if let Err(copy_err) = fs::copy(&config_path, &backup) {
tracing::warn!(
config = %config_path.display(),
error = %e,
backup_error = %copy_err,
"Failed to parse config (backup also failed)"
);
} else {
tracing::warn!(
config = %config_path.display(),
error = %e,
backup = %backup.display(),
"Failed to parse config, backed up to file. Using defaults."
);
}
LibraryConfig::default()
}
}
} else {
LibraryConfig::default()
};
Ok(Self {
config_dir,
libraries_dir,
premade_dir,
config,
})
}
pub fn get_default_snippets_path() -> PathBuf {
get_snippets_path()
}
pub fn get_libraries_dir(&self) -> &PathBuf {
&self.libraries_dir
}
pub fn is_single_file_mode(&self) -> bool {
!self.libraries_dir.exists()
}
pub fn get_legacy_snippets_path(&self) -> PathBuf {
Self::get_default_snippets_path()
}
pub fn ensure_library_mode(&mut self) -> SnipResult<()> {
if self.is_single_file_mode() {
self.migrate_from_single_file()?;
}
Ok(())
}
pub fn init_libraries_dir(&self) -> SnipResult<()> {
if !self.libraries_dir.exists() {
fs::create_dir_all(&self.libraries_dir).map_err(|e| {
SnipError::io_error("create libraries directory", self.libraries_dir.clone(), e)
})?;
}
Ok(())
}
pub fn migrate_from_single_file(&mut self) -> SnipResult<()> {
let legacy_path = self.get_legacy_snippets_path();
if !legacy_path.exists() {
return Ok(());
}
self.init_libraries_dir()?;
let content = cached_read_toml(&legacy_path)?;
if content.trim().is_empty() {
return Ok(());
}
let new_path = self.libraries_dir.join("snippets.toml");
fs::copy(&legacy_path, &new_path)
.map_err(|e| SnipError::io_error("migrate snippets file", new_path.clone(), e))?;
let mut meta = LibraryMeta::new("snippets");
meta.is_primary = true;
self.config.libraries.push(meta);
self.save_config()?;
Ok(())
}
pub fn list_libraries(&self) -> Vec<&LibraryMeta> {
self.config.libraries.iter().collect()
}
pub fn get_primary_library(&self) -> Option<&LibraryMeta> {
self.config.libraries.iter().find(|l| l.is_primary)
}
pub fn get_library_by_filename(&self, filename: &str) -> Option<&LibraryMeta> {
self.config
.libraries
.iter()
.find(|l| l.filename == filename)
}
pub fn get_library_by_filename_mut(&mut self, filename: &str) -> Option<&mut LibraryMeta> {
self.config
.libraries
.iter_mut()
.find(|l| l.filename == filename)
}
pub fn create_library(&mut self, filename: &str) -> SnipResult<PathBuf> {
validate_library_name(filename)
.map_err(|(msg, detail)| SnipError::runtime_error(msg, Some(detail)))?;
self.init_libraries_dir()?;
let filename_lower = filename.to_lowercase();
let path = self.libraries_dir.join(format!("{filename}.toml"));
if path.exists() {
return Err(SnipError::runtime_error(
"Library already exists",
Some(&format!("File {} already exists", path.display())),
));
}
for lib in &self.config.libraries {
if lib.filename.to_lowercase() == filename_lower {
return Err(SnipError::runtime_error(
"Library already exists",
Some(&format!(
"A library with name '{filename}' already exists (case-insensitive duplicate)"
)),
));
}
}
let default_content = r#"# Snippet library
# Each snippet has: description, output, tag, command, folders, favorite
snippets = []
"#;
write_library_file(&path, default_content, filename)?;
let is_first = self.config.libraries.is_empty();
let mut meta = LibraryMeta::new(filename);
meta.is_primary = is_first;
self.config.libraries.push(meta);
self.save_config()?;
Ok(path)
}
pub fn delete_library(&mut self, filename: &str) -> SnipResult<()> {
let was_primary = self
.get_library_by_filename(filename)
.map(|l| l.is_primary)
.ok_or_else(|| SnipError::runtime_error("Library not found", Some(filename)))?;
let deleted_was_server = self
.get_library_by_filename(filename)
.map(|l| l.server_id.is_some())
.unwrap_or(false);
let path = self.libraries_dir.join(format!("{filename}.toml"));
self.config.libraries.retain(|l| l.filename != filename);
if was_primary && !self.config.libraries.is_empty() {
let promoted = if deleted_was_server {
self.config
.libraries
.iter()
.find(|l| l.server_id.is_some())
.or_else(|| self.config.libraries.first())
} else {
self.config.libraries.first()
};
if let Some(promoted_lib) = promoted
&& let Some(idx) = self
.config
.libraries
.iter()
.position(|l| l.filename == promoted_lib.filename)
{
self.config.libraries[idx].is_primary = true;
}
}
self.save_config()?;
if path.exists() {
fs::remove_file(&path)
.map_err(|e| SnipError::io_error("delete library file", path.clone(), e))?;
}
Ok(())
}
pub fn set_primary(&mut self, filename: &str) -> SnipResult<()> {
if !self
.config
.libraries
.iter()
.any(|lib| lib.filename == filename)
{
return Err(SnipError::runtime_error(
"Library not found",
Some(&format!("No library with filename '{filename}'")),
));
}
for lib in &mut self.config.libraries {
lib.is_primary = lib.filename == filename;
}
self.save_config()?;
Ok(())
}
pub fn update_library_id(&mut self, filename: &str, library_id: &str) -> SnipResult<()> {
if let Some(lib) = self.get_library_by_filename_mut(filename) {
lib.library_id = library_id.to_string();
self.save_config()?;
}
Ok(())
}
pub fn link_server_library(&mut self, filename: &str, server_id: &str) -> SnipResult<()> {
if let Some(lib) = self.get_library_by_filename_mut(filename) {
lib.library_id = server_id.to_string();
lib.server_id = Some(server_id.to_string());
self.save_config()?;
}
Ok(())
}
pub fn unlink_server_library(&mut self, filename: &str) -> SnipResult<()> {
if let Some(lib) = self.get_library_by_filename_mut(filename) {
lib.library_id.clear();
lib.server_id = None;
self.save_config()?;
}
Ok(())
}
pub fn add_existing_library(&mut self, filename: &str) -> SnipResult<()> {
validate_library_name(filename)
.map_err(|(title, detail)| SnipError::runtime_error(title, Some(detail)))?;
if self.get_library_by_filename(filename).is_some() {
return Ok(());
}
let meta = LibraryMeta {
filename: filename.to_string(),
library_id: String::new(),
is_primary: false,
last_sync: None,
server_id: None,
};
self.config.libraries.push(meta);
self.save_config()?;
Ok(())
}
pub fn update_last_sync(&mut self, filename: &str, timestamp: i64) -> SnipResult<()> {
if let Some(lib) = self.get_library_by_filename_mut(filename) {
lib.last_sync = Some(timestamp);
self.save_config()?;
}
Ok(())
}
pub fn add_server_library(
&mut self,
server_name: &str,
server_id: &str,
) -> SnipResult<PathBuf> {
let filename = server_name.to_lowercase().replace(' ', "-");
validate_library_name(&filename)
.map_err(|(title, detail)| SnipError::runtime_error(title, Some(detail)))?;
self.init_libraries_dir()?;
let path = self.libraries_dir.join(format!("{filename}.toml"));
if !path.exists() {
let default_content = "# Imported from server\n\nsnippets = []\n";
write_library_file(&path, default_content, &filename)?;
}
if let Some(existing) = self.get_library_by_filename_mut(&filename) {
existing.library_id = server_id.to_string();
existing.server_id = Some(server_id.to_string());
self.save_config()?;
return Ok(path);
}
let is_first = self.config.libraries.is_empty();
let mut meta = LibraryMeta::new(&filename);
meta.library_id = server_id.to_string();
meta.server_id = Some(server_id.to_string());
meta.is_primary = is_first;
self.config.libraries.push(meta);
self.save_config()?;
Ok(path)
}
pub fn init_premade_dir(&self) -> SnipResult<()> {
if !self.premade_dir.exists() {
fs::create_dir_all(&self.premade_dir).map_err(|e| {
SnipError::io_error("create premade directory", self.premade_dir.clone(), e)
})?;
}
Ok(())
}
pub fn get_premade_dir(&self) -> &PathBuf {
&self.premade_dir
}
pub fn premade_exists(&self, filename: &str) -> bool {
self.premade_dir.join(format!("{filename}.toml")).exists()
}
pub fn save_premade_library(&self, filename: &str, content: &str) -> SnipResult<PathBuf> {
self.init_premade_dir()?;
if filename.is_empty()
|| filename.contains('/')
|| filename.contains('\\')
|| filename.contains('\0')
|| filename.contains("..")
{
return Err(SnipError::runtime_error(
"Invalid premade library filename",
Some(filename),
));
}
let path = self.premade_dir.join(format!("{filename}.toml"));
let canonical_premade = self.premade_dir.canonicalize().map_err(|e| {
SnipError::io_error("resolve premade directory", self.premade_dir.clone(), e)
})?;
let canonical_path = path
.canonicalize()
.unwrap_or_else(|_| canonical_premade.join(format!("{filename}.toml")));
if !canonical_path.starts_with(&canonical_premade) {
return Err(SnipError::runtime_error(
"Invalid premade library path",
Some("Filename resolves outside premade directory"),
));
}
write_library_file(&path, content, filename)?;
Ok(path)
}
fn save_config(&mut self) -> SnipResult<()> {
let config_path = self.config_dir.join("libraries.toml");
let toml_str = toml::to_string_pretty(&self.config)
.map_err(|e| SnipError::toml_error("serialize libraries config", e))?;
let toml_str = quote_strings_containing_backslashes(&toml_str);
crate::utils::atomic::write_private_atomic(&config_path, &toml_str, "libraries")?;
invalidate_toml_cache(&config_path);
Ok(())
}
}
fn write_library_file(path: &Path, content: &str, temp_prefix: &str) -> SnipResult<()> {
crate::utils::atomic::write_private_atomic(path, content, temp_prefix)?;
invalidate_toml_cache(path);
Ok(())
}
pub fn load_library(path: &Path) -> SnipResult<Snippets> {
if !path.exists() {
return Ok(Snippets::default());
}
let content = cached_read_toml(path)?;
if content.is_empty() || content.trim().is_empty() {
return Ok(Snippets::default());
}
let fixed_content = fix_invalid_toml_escapes(&content);
let snippets: Snippets = match toml::from_str(&fixed_content) {
Ok(s) => s,
Err(e) => {
let backup_path = path.with_extension("toml.corrupt.bak");
if let Err(backup_err) = fs::copy(path, &backup_path) {
tracing::error!(
file = %path.display(),
error = %backup_err,
"Failed to parse TOML and could not create backup"
);
} else {
tracing::error!(
file = %path.display(),
backup = %backup_path.display(),
error = %e,
"Failed to parse TOML, backup saved"
);
}
Snippets::default()
}
};
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deduplicated: Vec<Snippet> = Vec::new();
for mut snippet in snippets.snippets {
if snippet.id.is_empty() {
snippet.id = uuid::Uuid::new_v4().to_string();
}
if seen_ids.contains(&snippet.id) {
snippet.id = uuid::Uuid::new_v4().to_string();
}
seen_ids.insert(snippet.id.clone());
deduplicated.push(snippet);
}
Ok(Snippets {
snippets: deduplicated,
folders: snippets.folders,
})
}
pub fn save_library(path: &Path, snippets: &Snippets) -> SnipResult<()> {
if let Err(e) = backup_library(path) {
tracing::warn!(error = %e, "Failed to create backup before save");
}
let mut sorted = snippets.clone();
sorted
.snippets
.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
let toml_str = toml::to_string_pretty(&sorted)
.map_err(|e| SnipError::toml_error("serialize snippets", e))?;
let toml_str = quote_strings_containing_backslashes(&toml_str);
let temp_prefix = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("snippets");
crate::utils::atomic::write_private_atomic(path, &toml_str, temp_prefix)?;
invalidate_toml_cache(path);
Ok(())
}
pub fn backup_library(path: &Path) -> SnipResult<Option<PathBuf>> {
if !path.exists() {
return Ok(None);
}
let backup_dir = path
.parent()
.ok_or_else(|| {
SnipError::runtime_error(
"backup path has no parent",
Some(&path.display().to_string()),
)
})?
.join("backups");
fs::create_dir_all(&backup_dir)
.map_err(|e| SnipError::io_error("create backup directory", backup_dir.clone(), e))?;
cleanup_old_backups(&backup_dir, path)?;
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S_%f");
let file_stem = path.file_stem().ok_or_else(|| {
SnipError::runtime_error(
"backup path has no file stem",
Some(&path.display().to_string()),
)
})?;
let backup_name = format!("{}.{}.toml.bak", file_stem.to_string_lossy(), timestamp);
let backup_path = backup_dir.join(backup_name);
fs::copy(path, &backup_path)
.map_err(|e| SnipError::io_error("create backup", backup_path.clone(), e))?;
Ok(Some(backup_path))
}
fn cleanup_old_backups(backup_dir: &Path, original_path: &Path) -> SnipResult<()> {
const MAX_BACKUPS_PER_LIBRARY: usize = 10;
let file_stem = match original_path.file_stem() {
Some(s) => s.to_string_lossy().to_string(),
None => return Ok(()),
};
let prefix = format!("{file_stem}.");
let mut backups: Vec<_> = fs::read_dir(backup_dir)
.map_err(|e| SnipError::io_error("read backup directory", backup_dir.to_path_buf(), e))?
.filter_map(|entry| entry.ok())
.filter(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
name.starts_with(&prefix) && name.ends_with(".toml.bak")
})
.filter_map(|entry| {
let metadata = entry.metadata().ok()?;
let modified = metadata.modified().ok()?;
Some((entry.path(), modified))
})
.collect();
backups.sort_by_key(|b| std::cmp::Reverse(b.1));
if backups.len() > MAX_BACKUPS_PER_LIBRARY {
for (path, _) in backups.into_iter().skip(MAX_BACKUPS_PER_LIBRARY) {
if let Err(e) = fs::remove_file(&path) {
tracing::warn!(
backup = %path.display(),
error = %e,
"Failed to remove old backup"
);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[cfg(unix)]
fn file_mode(path: &Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn test_pet_format_compatibility() {
let pet_toml = r#"
[[snippets]]
description = "git commit with message"
command = "git commit -m \"message\""
tag = ["git", "version-control"]
output = ""
[[snippets]]
description = "docker ps"
command = "docker ps"
tag = ["docker"]
output = ""
"#;
let snippets: Snippets = toml::from_str(pet_toml).unwrap();
assert_eq!(snippets.snippets.len(), 2);
assert_eq!(snippets.snippets[0].command, "git commit -m \"message\"");
assert_eq!(snippets.snippets[0].description, "git commit with message");
assert_eq!(snippets.snippets[0].tags, vec!["git", "version-control"]);
assert_eq!(snippets.snippets[1].command, "docker ps");
}
#[test]
fn test_legacy_snp_format_compatibility() {
let snp_toml = r#"
[[Snippets]]
Description = "git commit"
Output = ""
Tag = ["git"]
command = "git commit -m 'msg'"
"#;
let snippets: Snippets = toml::from_str(snp_toml).unwrap();
assert_eq!(snippets.snippets.len(), 1);
assert_eq!(snippets.snippets[0].command, "git commit -m 'msg'");
}
#[test]
fn test_snp_serializes_to_pet_table_name() {
let snippets = Snippets {
snippets: vec![Snippet {
description: "list files".to_string(),
command: "ls -la".to_string(),
tags: vec!["files".to_string()],
..Default::default()
}],
..Default::default()
};
let toml = toml::to_string(&snippets).unwrap();
assert!(toml.contains("[[snippets]]"));
assert!(toml.contains("tag = [\"files\"]"));
assert!(toml.contains("output = \"\""));
assert!(!toml.contains("[[Snippets]]"));
}
#[test]
fn test_library_save_load_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test_library.toml");
let snippets = Snippets {
snippets: vec![Snippet {
id: "test-id-1".to_string(),
description: "Test snippet".to_string(),
command: "echo hello".to_string(),
output: "".to_string(),
tags: vec!["test".to_string()],
folders: vec![],
favorite: false,
created_at: 1234567890,
updated_at: 1234567890,
device_id: "device1".to_string(),
deleted: false,
}],
folders: vec!["work".to_string()],
};
save_library(&path, &snippets).unwrap();
let loaded = load_library(&path).unwrap();
assert_eq!(loaded.snippets.len(), 1);
assert_eq!(loaded.snippets[0].description, "Test snippet");
assert_eq!(loaded.snippets[0].command, "echo hello");
}
#[test]
fn test_library_save_load_roundtrip_with_escaped_brackets() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test_library.toml");
let snippets = Snippets {
snippets: vec![Snippet {
id: "test-id-1".to_string(),
description: "Test with escaped brackets".to_string(),
command: "ping \\<website\\>".to_string(),
output: "".to_string(),
tags: vec!["test".to_string()],
folders: vec![],
favorite: false,
created_at: 1234567890,
updated_at: 1234567890,
device_id: "device1".to_string(),
deleted: false,
}],
folders: vec![],
};
save_library(&path, &snippets).unwrap();
let loaded = load_library(&path).unwrap();
assert_eq!(loaded.snippets.len(), 1);
assert_eq!(loaded.snippets[0].command, "ping \\<website\\>");
}
#[test]
fn test_library_load_with_invalid_escapes() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("invalid_escapes.toml");
std::fs::write(
&path,
r#"
[[Snippets]]
Id = "test-id"
Description = "Test snippet with invalid escapes"
Command = "sudo iptables-restore \< /path/to/rules"
"#,
)
.unwrap();
let loaded = load_library(&path).unwrap();
assert_eq!(loaded.snippets.len(), 1);
assert_eq!(
loaded.snippets[0].command,
r"sudo iptables-restore \< /path/to/rules"
);
}
#[test]
fn test_library_load_empty_file() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("empty.toml");
std::fs::write(&path, "").unwrap();
let loaded = load_library(&path).unwrap();
assert!(loaded.snippets.is_empty());
}
#[test]
fn test_library_backup_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("nonexistent.toml");
let backup_result = backup_library(&path).unwrap();
assert!(backup_result.is_none());
}
#[test]
fn test_snippet_serialization() {
let snippet = Snippet {
id: "test-id".to_string(),
description: "Test description".to_string(),
command: "echo test".to_string(),
output: "test output".to_string(),
tags: vec!["test".to_string()],
folders: vec!["work".to_string()],
favorite: true,
created_at: 1234567890,
updated_at: 1234567891,
device_id: "device-1".to_string(),
deleted: false,
};
let toml_str = toml::to_string_pretty(&snippet).unwrap();
assert!(toml_str.contains("test-id"));
assert!(toml_str.contains("Test description"));
assert!(toml_str.contains("echo test"));
}
#[test]
fn test_snippets_with_multiple_items() {
let snippets = Snippets {
snippets: vec![
Snippet {
id: "id1".to_string(),
description: "First".to_string(),
command: "cmd1".to_string(),
output: "".to_string(),
tags: vec![],
folders: vec![],
favorite: false,
created_at: 0,
updated_at: 0,
device_id: "".to_string(),
deleted: false,
},
Snippet {
id: "id2".to_string(),
description: "Second".to_string(),
command: "cmd2".to_string(),
output: "".to_string(),
tags: vec![],
folders: vec![],
favorite: false,
created_at: 0,
updated_at: 0,
device_id: "".to_string(),
deleted: false,
},
],
folders: vec!["work".to_string()],
};
let toml_str = toml::to_string_pretty(&snippets).unwrap();
assert!(toml_str.contains("id1"));
assert!(toml_str.contains("id2"));
assert!(toml_str.contains("work"));
}
#[test]
fn test_library_manager_new() {
let mgr = LibraryManager::new();
assert!(mgr.is_ok() || mgr.is_err());
}
#[test]
fn test_snippet_new_empty_command_fails() {
let result = Snippet::new("desc".to_string(), " ".to_string(), vec![]);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Empty command"));
}
#[test]
fn test_snippet_new_empty_description_fails() {
let result = Snippet::new(" ".to_string(), "echo hi".to_string(), vec![]);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Empty description")
);
}
#[test]
fn test_snippet_new_valid() {
let result = Snippet::new(
"desc".to_string(),
"echo hi".to_string(),
vec!["tag".to_string()],
);
assert!(result.is_ok());
let s = result.unwrap();
assert_eq!(s.description, "desc");
assert_eq!(s.command, "echo hi");
}
#[test]
fn test_validate_library_name_empty() {
assert!(validate_library_name("").is_err());
}
#[test]
fn test_validate_library_name_too_long() {
assert!(validate_library_name(&"a".repeat(51)).is_err());
}
#[test]
fn test_validate_library_name_slash() {
assert!(validate_library_name("foo/bar").is_err());
}
#[test]
fn test_validate_library_name_backslash() {
assert!(validate_library_name("foo\\bar").is_err());
}
#[test]
fn test_validate_library_name_null_byte() {
assert!(validate_library_name("foo\0bar").is_err());
}
#[test]
fn test_validate_library_name_dot() {
assert!(validate_library_name(".").is_err());
assert!(validate_library_name("..").is_err());
assert!(validate_library_name("my..lib").is_err());
}
#[test]
fn test_validate_library_name_valid() {
assert!(validate_library_name("my-library").is_ok());
assert!(validate_library_name("work snippets").is_ok());
}
#[test]
fn test_save_library_atomic_write() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("test.toml");
let snippets = Snippets {
snippets: vec![Snippet {
id: "atomic-test".to_string(),
description: "Atomic write test".to_string(),
command: "echo atomic".to_string(),
output: "".to_string(),
tags: vec![],
folders: vec![],
favorite: false,
created_at: 100,
updated_at: 100,
device_id: "d1".to_string(),
deleted: false,
}],
folders: vec![],
};
save_library(&path, &snippets).unwrap();
let loaded = load_library(&path).unwrap();
assert_eq!(loaded.snippets.len(), 1);
assert_eq!(loaded.snippets[0].id, "atomic-test");
let parent = path.parent().unwrap();
let has_tmp = std::fs::read_dir(parent)
.unwrap()
.filter_map(|e| e.ok())
.any(|e| e.path().extension().is_some_and(|ext| ext == "tmp"));
assert!(!has_tmp, "temp files should not remain after atomic rename");
}
#[test]
fn test_create_library_uses_private_atomic_write() {
let temp_dir = TempDir::new().unwrap();
let mut mgr = LibraryManager {
config_dir: temp_dir.path().to_path_buf(),
libraries_dir: temp_dir.path().join("libraries"),
premade_dir: temp_dir.path().join("premade"),
config: Default::default(),
};
let path = mgr.create_library("private").unwrap();
assert!(path.exists());
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains("snippets = []")
);
#[cfg(unix)]
assert_eq!(file_mode(&path), 0o600);
}
#[test]
fn test_add_server_library_uses_private_atomic_write() {
let temp_dir = TempDir::new().unwrap();
let mut mgr = LibraryManager {
config_dir: temp_dir.path().to_path_buf(),
libraries_dir: temp_dir.path().join("libraries"),
premade_dir: temp_dir.path().join("premade"),
config: Default::default(),
};
let path = mgr
.add_server_library("Shared Commands", "server-library-id")
.unwrap();
assert!(path.exists());
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains("Imported from server")
);
#[cfg(unix)]
assert_eq!(file_mode(&path), 0o600);
}
#[test]
fn test_save_config_invalidates_libraries_toml_cache() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().to_path_buf();
let libraries_dir = config_dir.join("libraries");
let premade_dir = config_dir.join("premade");
std::fs::create_dir_all(&libraries_dir).unwrap();
let config_path = config_dir.join("libraries.toml");
std::fs::write(
&config_path,
r#"
[[libraries]]
filename = "old"
library_id = ""
is_primary = true
"#,
)
.unwrap();
let cached_before = cached_read_toml(&config_path).unwrap();
assert!(cached_before.contains("old"));
let mut mgr = LibraryManager {
config_dir,
libraries_dir,
premade_dir,
config: LibraryConfig {
libraries: vec![LibraryMeta {
filename: "old".to_string(),
library_id: String::new(),
is_primary: true,
last_sync: None,
server_id: None,
}],
},
};
mgr.create_library("new").unwrap();
let cached_after = cached_read_toml(&config_path).unwrap();
assert!(cached_after.contains("old"));
assert!(cached_after.contains("new"));
}
#[test]
fn test_backup_library_names_do_not_collide() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("snippets.toml");
std::fs::write(&path, "test content").unwrap();
let first = backup_library(&path).unwrap().unwrap();
let second = backup_library(&path).unwrap().unwrap();
assert_ne!(first, second);
let backup_dir = temp_dir.path().join("backups");
let backup_count = std::fs::read_dir(backup_dir).unwrap().count();
assert_eq!(backup_count, 2);
}
#[test]
fn test_save_premade_library_path_traversal() {
let temp_dir = TempDir::new().unwrap();
let mgr = LibraryManager {
config_dir: temp_dir.path().to_path_buf(),
libraries_dir: temp_dir.path().join("libraries"),
premade_dir: temp_dir.path().join("premade"),
config: Default::default(),
};
assert!(
mgr.save_premade_library("../../etc/passwd", "content")
.is_err()
);
assert!(mgr.save_premade_library("../escape", "content").is_err());
assert!(mgr.save_premade_library("foo/bar", "content").is_err());
}
#[test]
fn test_save_premade_library_valid() {
let temp_dir = TempDir::new().unwrap();
let mgr = LibraryManager {
config_dir: temp_dir.path().to_path_buf(),
libraries_dir: temp_dir.path().join("libraries"),
premade_dir: temp_dir.path().join("premade"),
config: Default::default(),
};
let result = mgr.save_premade_library("valid-name", "test content");
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.exists());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "test content");
#[cfg(unix)]
assert_eq!(file_mode(&path), 0o600);
}
#[test]
fn test_deduplication_on_load() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("dup.toml");
let toml_content = r#"
[[Snippets]]
Id = "same-id"
Description = "First"
Command = "cmd1"
[[Snippets]]
Id = "same-id"
Description = "Second"
Command = "cmd2"
"#;
std::fs::write(&path, toml_content).unwrap();
let loaded = load_library(&path).unwrap();
assert_eq!(loaded.snippets.len(), 2);
assert_ne!(loaded.snippets[0].id, loaded.snippets[1].id);
}
}