use crate::error::{BallError, Result};
use crate::task::{validate_id, Note, Task};
use chrono::Utc;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
pub(crate) fn notes_path_for(task_path: &Path) -> PathBuf {
let stem = task_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("task");
let parent = task_path.parent().unwrap_or_else(|| Path::new("."));
parent.join(format!("{stem}.notes.jsonl"))
}
fn load_notes_file(path: &Path) -> Result<Vec<Note>> {
if !path.exists() {
return Ok(Vec::new());
}
let s = fs::read_to_string(path)?;
let mut notes = Vec::new();
for line in s.lines() {
if line.trim().is_empty() {
continue;
}
let n: Note = serde_json::from_str(line)
.map_err(|e| BallError::InvalidTask(format!("{}: {}", path.display(), e)))?;
notes.push(n);
}
Ok(notes)
}
pub fn append_note_to(task_path: &Path, author: &str, text: &str) -> Result<Note> {
let note = Note {
ts: Utc::now(),
author: author.to_string(),
text: text.to_string(),
extra: std::collections::BTreeMap::new(),
};
let notes_path = notes_path_for(task_path);
if let Some(parent) = notes_path.parent() {
fs::create_dir_all(parent)?;
}
let mut line = serde_json::to_string(¬e)?;
line.push('\n');
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(¬es_path)?;
f.write_all(line.as_bytes())?;
Ok(note)
}
pub(crate) fn delete_notes_file(task_path: &Path) -> Result<()> {
let notes_path = notes_path_for(task_path);
if notes_path.exists() {
fs::remove_file(¬es_path)?;
}
Ok(())
}
impl Task {
pub fn load(path: &Path) -> Result<Self> {
let s = fs::read_to_string(path)?;
let mut t: Task = serde_json::from_str(&s)
.map_err(|e| BallError::InvalidTask(format!("{}: {}", path.display(), e)))?;
validate_id(&t.id)?;
let sidecar = load_notes_file(¬es_path_for(path))?;
t.notes.extend(sidecar);
Ok(t)
}
pub fn save(&self, path: &Path) -> Result<()> {
let body = serialize_mergeable(self)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, body.as_bytes())?;
fs::rename(&tmp, path)?;
let notes_path = notes_path_for(path);
if !notes_path.exists() {
fs::write(¬es_path, "")?;
}
Ok(())
}
}
pub(crate) fn serialize_mergeable(task: &Task) -> Result<String> {
let v = serde_json::to_value(task)?;
let obj = v
.as_object()
.ok_or_else(|| BallError::InvalidTask("task did not serialize to an object".into()))?;
let mut keys: Vec<&String> = obj.keys().filter(|k| *k != "notes").collect();
keys.sort();
let mut s = String::from("{\n");
for (i, k) in keys.iter().enumerate() {
let key_json = serde_json::to_string(k)?;
let val_json = serde_json::to_string(&obj[*k])?;
s.push_str(" ");
s.push_str(&key_json);
s.push_str(": ");
s.push_str(&val_json);
if i + 1 < keys.len() {
s.push(',');
}
s.push('\n');
}
s.push_str("}\n");
Ok(s)
}
#[cfg(test)]
#[path = "task_io_tests.rs"]
mod tests;