use crate::colours::warn;
use crate::config::Config;
use crate::error::AppError;
use crate::note::Note;
use crate::search;
use crate::task::Task;
use chrono::Utc;
use serde_json;
use sled::Db;
use std::path::PathBuf;
use std::{env, fs, str};
use tantivy::{Index, IndexWriter, TantivyDocument};
pub fn open(config: Config) -> Result<Db, AppError> {
let db_path = match env::var("MEDI_DB_PATH") {
Ok(path_str) => PathBuf::from(path_str),
Err(_) => config.db_path.clone().unwrap_or_else(|| {
let mut path = dirs::home_dir().expect("Could not find home directory.");
path.push(".medi/medi_db");
path
}),
};
if let Some(parent) = db_path.parent() {
fs::create_dir_all(parent)?;
}
sled::open(db_path).map_err(AppError::from)
}
pub fn key_exists(db: &Db, key: &str) -> Result<bool, AppError> {
db.contains_key(key).map_err(AppError::from)
}
pub fn save_note(db: &Db, note: &Note) -> Result<(), AppError> {
let json_bytes = serde_json::to_vec(note)?;
db.insert(¬e.key, json_bytes)?;
db.flush()?;
Ok(())
}
pub fn save_note_with_index(db: &Db, note: &Note, index: &Index) -> Result<(), AppError> {
save_note(db, note)?;
let mut index_writer: tantivy::IndexWriter<tantivy::TantivyDocument> =
index.writer(50_000_000)?;
search::delete_note_from_index(¬e.key, &mut index_writer)?;
search::add_note_to_index(note, &mut index_writer)?;
index_writer.commit()?;
Ok(())
}
pub fn delete_note_with_index(db: &Db, key: &str, index: &Index) -> Result<(), AppError> {
match delete_note(db, key) {
Ok(()) => {
let mut index_writer: IndexWriter<TantivyDocument> = index.writer(50_000_000)?;
search::delete_note_from_index(key, &mut index_writer)?;
index_writer.commit()?;
Ok(())
}
Err(e) => Err(e),
}
}
pub fn get_note(db: &Db, key: &str) -> Result<Note, AppError> {
let value_ivec = db
.get(key)?
.ok_or_else(|| AppError::KeyNotFound(key.to_string()))?;
if value_ivec.is_empty() {
return Err(AppError::Database(format!(
"Note with key '{}' is empty",
key
)));
}
let note: Note = serde_json::from_slice(&value_ivec).map_err(AppError::from)?;
Ok(note)
}
pub fn delete_note(db: &Db, key: &str) -> Result<(), AppError> {
if !db.contains_key(key)? {
return Err(AppError::KeyNotFound(key.to_string()));
}
db.remove(key)?;
db.flush()?;
Ok(())
}
pub fn delete_tasks_for_note(db: &Db, note_key: &str) -> Result<usize, AppError> {
let tasks_to_delete: Vec<Task> = get_all_tasks(db)?
.into_iter()
.filter(|task| task.note_key == note_key)
.collect();
if tasks_to_delete.is_empty() {
return Ok(0);
}
let mut batch = sled::Batch::default();
for task in &tasks_to_delete {
let task_key = format!("tasks/{}", task.id);
batch.remove(task_key.as_bytes());
}
db.apply_batch(batch)?;
db.flush()?;
Ok(tasks_to_delete.len())
}
pub fn get_all_notes(db: &Db) -> Result<Vec<Note>, AppError> {
let mut notes = Vec::new();
for result in db.iter() {
let (key_bytes, value_bytes) = result?;
if key_bytes.starts_with(b"__") || key_bytes.starts_with(b"tasks/") {
continue;
}
match serde_json::from_slice::<Note>(&value_bytes) {
Ok(note) => notes.push(note),
Err(_) => {
if value_bytes.is_empty() {
if let Ok(key) = str::from_utf8(&key_bytes) {
notes.push(Note {
key: key.to_string(),
title: key.to_string(), content: String::new(),
tags: Vec::new(),
created_at: Utc::now(),
modified_at: Utc::now(),
});
}
} else if let Ok(key) = str::from_utf8(&key_bytes) {
warn(&format!(
"Warning: Skipping corrupted or outdated note with key '{}'.",
key
));
}
}
}
}
Ok(notes)
}
pub fn save_task(db: &Db, task: &Task) -> Result<(), AppError> {
let key = format!("tasks/{}", task.id);
let json_bytes = serde_json::to_vec(task)?;
db.insert(key, json_bytes)?;
db.flush()?;
Ok(())
}
pub fn delete_task(db: &Db, task_id: u64) -> Result<(), AppError> {
let key = format!("tasks/{}", task_id);
if !db.contains_key(&key)? {
return Err(AppError::KeyNotFound(key));
}
db.remove(key)?;
db.flush()?;
Ok(())
}
pub fn get_all_tasks(db: &Db) -> Result<Vec<Task>, AppError> {
db.scan_prefix("tasks/")
.values()
.map(|result| {
let value_bytes = result?;
let task: Task = serde_json::from_slice(&value_bytes)?;
Ok(task)
})
.collect()
}
pub fn get_next_task_id(db: &Db) -> Result<u64, AppError> {
const TASK_COUNTER_KEY: &[u8] = b"__counter__/tasks";
let new_id_bytes = db.update_and_fetch(TASK_COUNTER_KEY, |old_value| {
let old_id = match old_value {
Some(bytes) => {
let mut buf = [0u8; 8];
buf.copy_from_slice(bytes);
u64::from_le_bytes(buf)
}
None => 0,
};
let new_id = old_id + 1;
Some(new_id.to_le_bytes().to_vec())
})?;
let new_id = match new_id_bytes {
Some(ivec) => {
let mut buf = [0u8; 8];
buf.copy_from_slice(&ivec);
u64::from_le_bytes(buf)
}
None => {
return Err(AppError::Database(
"Failed to update task counter".to_string(),
))
}
};
Ok(new_id)
}
pub fn delete_all_tasks(db: &Db) -> Result<usize, AppError> {
let mut count = 0;
let keys_to_delete: Vec<_> = db
.scan_prefix("tasks/")
.keys()
.collect::<Result<Vec<_>, _>>()?;
let mut batch = sled::Batch::default();
for key in keys_to_delete {
batch.remove(key.clone());
count += 1;
}
db.apply_batch(batch)?;
db.flush()?;
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::note::Note;
use chrono::Utc;
use sled::Config;
#[test]
fn test_save_and_get_note_success() {
let config = Config::new().temporary(true);
let db = config.open().unwrap();
let key = "test-key".to_string();
let new_note = Note {
key: key.clone(),
title: "Test Title".to_string(),
tags: vec!["testing".to_string()],
content: "Mock note content".to_string(),
created_at: Utc::now(),
modified_at: Utc::now(),
};
let save_result = save_note(&db, &new_note);
assert!(save_result.is_ok());
let retrieved_note = get_note(&db, &key).unwrap();
assert_eq!(retrieved_note.content, "Mock note content");
assert_eq!(retrieved_note.tags, vec!["testing"]);
}
#[test]
fn test_get_all_notes_success() {
let config = Config::new().temporary(true);
let db = config.open().unwrap();
let note1 = Note {
key: "note-a".to_string(),
title: "Note A".to_string(),
content: "content a".to_string(),
tags: vec![],
created_at: Utc::now(),
modified_at: Utc::now(),
};
let note2 = Note {
key: "note-b".to_string(),
title: "Note B".to_string(),
content: "content b".to_string(),
tags: vec![],
created_at: Utc::now(),
modified_at: Utc::now(),
};
save_note(&db, ¬e1).unwrap();
save_note(&db, ¬e2).unwrap();
let all_notes = get_all_notes(&db).unwrap();
assert_eq!(all_notes.len(), 2);
assert!(all_notes.iter().any(|n| n.key == "note-a"));
}
#[test]
fn test_get_all_notes_empty_db() {
let config = Config::new().temporary(true);
let db = config.open().unwrap();
let all_notes = get_all_notes(&db).unwrap();
assert!(all_notes.is_empty());
}
#[test]
fn test_delete_note_success() {
let config = Config::new().temporary(true);
let db = config.open().expect("Failed to open temporary db");
let key = "test-delete-key";
db.insert(key, "content").unwrap();
let result = delete_note(&db, key);
assert!(result.is_ok());
assert!(!db.contains_key(key).unwrap());
}
#[test]
fn test_update_note_success() {
let config = Config::new().temporary(true);
let db = config.open().unwrap();
let key = "my-key".to_string();
let original_note = Note {
key: key.clone(),
title: "Original Title".to_string(),
content: "original content".to_string(),
tags: vec![],
created_at: Utc::now(),
modified_at: Utc::now(),
};
save_note(&db, &original_note).unwrap();
let updated_note = Note {
key: key.clone(),
title: "Updated Title".to_string(),
content: "updated content".to_string(),
tags: vec!["updated".to_string()],
created_at: original_note.created_at, modified_at: Utc::now(),
};
let result = save_note(&db, &updated_note);
assert!(result.is_ok());
let retrieved_note = get_note(&db, &key).unwrap();
assert_eq!(retrieved_note.content, "updated content");
assert_eq!(retrieved_note.title, "Updated Title");
assert_eq!(retrieved_note.tags, vec!["updated"]);
}
}