1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
//! Task file I/O. Separated from `task.rs` to keep that file under the
//! 300-line cap and to localize the on-disk format in one place.
//!
//! Format rules (see docs/SPEC-orphan-branch-state.md ยง5):
//! - Top-level JSON keys sorted alphabetically.
//! - One key per line, value serialized compact on the same line.
//! - Trailing newline.
//! - Notes are NOT stored inside the task file; they live in an
//! append-only sibling `<id>.notes.jsonl`. This keeps concurrent
//! note appends from two workers text-mergeable.
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};
/// `.../bl-abc.json` -> `.../bl-abc.notes.jsonl`.
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)
}
/// Append a single note to the sibling notes file for a task. Uses
/// `O_APPEND` so two concurrent writers each add exactly one line without
/// racing on a read-modify-write cycle. Two lines at the end of a file
/// merge cleanly under stock `git merge` in the common case.
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)
}
/// Delete the sibling notes file if present. Used at task close / drop.
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 {
/// Load a task from `path`. Notes live in a sibling file; load handles
/// both the new layout (notes in sibling) and legacy task files whose
/// `notes` array is embedded in the JSON. Either way, the returned
/// `Task.notes` is the union of both sources.
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)
}
/// Persist a task to `path` in the text-mergeable format. Never writes
/// the notes sidecar content; use `append_note_to` for that. Does
/// ensure the notes sidecar exists as an empty file on first save, so
/// that concurrent first-append operations on divergent branches see
/// the file in the merge base and use git's union merge driver
/// instead of racing on add/add.
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(())
}
}
/// Serialize a task as a sorted-key, one-field-per-line JSON object with
/// compact single-line values and a trailing newline. The `notes` field is
/// excluded; notes live in the sibling file.
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;