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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
//! A questionnaire the owner answered, read into the record.
//!
//! Before a plan is reworked the owner is handed a page: a handful of forks
//! with the options laid out, and a list of ideas to tick. They answer it,
//! the answers steer the next few months of a project - and then the page
//! is the only place any of it is written down. The plan that comes out
//! says what was decided; it does not say what it was decided *against*,
//! and that is the half worth keeping. A year later "why is `find` going
//! through nooma rather than FTS5" is answerable only by finding the
//! artifact again.
//!
//! So the answers come in as events. A fork becomes a decision naming the
//! option taken and the ones it beat. An idea taken becomes a wish, to be
//! sorted into the plan like any other. An idea left untaken becomes a
//! decision too - "not this, and here is when it was considered" - because
//! an idea nobody records is an idea that comes back every quarter.
use anyhow::{Context, Result, bail};
use serde::Deserialize;
/// A questionnaire, as the page that produced it writes one out.
#[derive(Debug, Deserialize)]
pub struct Answers {
/// What the page called itself, for the decision to cite.
#[serde(default)]
pub questionnaire: Option<String>,
/// The day it was answered, `2026-09-11`. The events are dated by it
/// rather than by the moment of the import: the decision happened when
/// the owner made it, and a record that says otherwise puts the
/// reasoning after the work it explains.
#[serde(default)]
pub answered: Option<String>,
#[serde(default)]
pub forks: Vec<Fork>,
#[serde(default)]
pub ideas: Vec<Idea>,
}
/// A choice between options, with one of them taken.
#[derive(Debug, Deserialize)]
pub struct Fork {
/// What the fork was about.
pub question: String,
/// The option the owner took.
pub chosen: String,
/// The options it was taken over. The point of the whole exercise.
#[serde(default)]
pub against: Vec<String>,
/// Why, in the owner's words or the page's.
#[serde(default)]
pub why: Option<String>,
}
/// An idea offered, taken or not.
#[derive(Debug, Deserialize)]
pub struct Idea {
pub idea: String,
/// Whether the owner ticked it.
#[serde(default)]
pub taken: bool,
#[serde(default)]
pub why: Option<String>,
}
/// An event the answers turn into.
pub struct Entry {
pub kind: &'static str,
pub body: String,
}
pub fn read(path: &std::path::Path) -> Result<Answers> {
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let answers: Answers = serde_json::from_str(&text).with_context(|| format!("{} is not a questionnaire rigger understands", path.display()))?;
if answers.forks.is_empty() && answers.ideas.is_empty() {
bail!("{} answers nothing: it has neither forks nor ideas", path.display());
}
Ok(answers)
}
impl Answers {
/// What the questionnaire is called in the text of an event.
fn cite(&self) -> String {
match (&self.questionnaire, &self.answered) {
(Some(name), Some(day)) => format!("{name}, {day}"),
(Some(name), None) => name.clone(),
(None, Some(day)) => format!("questionnaire of {day}"),
(None, None) => "a questionnaire".to_string(),
}
}
/// The events these answers become, in the order they are recorded.
pub fn entries(&self) -> Vec<Entry> {
let cite = self.cite();
let mut out = Vec::new();
for fork in &self.forks {
let mut body = format!("{}: {}", fork.question.trim(), fork.chosen.trim());
if !fork.against.is_empty() {
// The rejected options, spelt out. A decision that records
// only what was chosen is a decision nobody can revisit:
// the question it answered is gone with the page.
let against: Vec<&str> = fork.against.iter().map(|o| o.trim()).filter(|o| !o.is_empty()).collect();
if !against.is_empty() {
body.push_str(&format!("\n\nRather than: {}", against.join("; ")));
}
}
if let Some(why) = fork.why.as_deref().map(str::trim).filter(|w| !w.is_empty()) {
body.push_str(&format!("\n\n{why}"));
}
body.push_str(&format!("\n\nAnswered on {cite}."));
out.push(Entry { kind: "decision", body });
}
for idea in &self.ideas {
let text = idea.idea.trim();
if text.is_empty() {
continue;
}
let why = idea.why.as_deref().map(str::trim).filter(|w| !w.is_empty());
let body = if idea.taken {
// A wish, to be sorted into the plan like any other. Not a
// decision: an idea the owner took is work not yet placed.
match why {
Some(why) => format!("{text}\n\n{why}\n\nTaken on {cite}."),
None => format!("{text}\n\nTaken on {cite}."),
}
} else {
match why {
Some(why) => format!("Not taking: {text}\n\n{why}\n\nConsidered on {cite}."),
None => format!("Not taking: {text}\n\nConsidered on {cite}."),
}
};
out.push(Entry {
kind: if idea.taken { "wish" } else { "decision" },
body,
});
}
out
}
/// The day the events are dated by: the day it was answered, when the
/// page says so.
pub fn at(&self) -> Option<String> {
let day = self.answered.as_deref()?.trim();
// A date rigger writes elsewhere, so that these sit among the other
// events of that day rather than at the moment of the import.
//
// Checked by shape, not by counting: `11.09.2026` is also ten
// characters with eight digits in it, and counting alone let it
// through as `11.09.2026T00:00:00Z` - a timestamp nothing can read
// and every query would sort to the wrong end of the record.
let mut parts = day.split('-');
let ok = matches!(
(parts.next(), parts.next(), parts.next(), parts.next()),
(Some(y), Some(m), Some(d), None)
if y.len() == 4 && m.len() == 2 && d.len() == 2
&& [y, m, d].iter().all(|p| p.bytes().all(|b| b.is_ascii_digit()))
);
ok.then(|| format!("{day}T00:00:00Z"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(json: &str) -> Answers {
serde_json::from_str(json).unwrap()
}
#[test]
fn a_fork_records_what_it_was_decided_against() {
let answers = parse(
r#"{
"questionnaire": "plan review 03ab557f",
"answered": "2026-09-11",
"forks": [{
"question": "Where does knowledge of the code come from",
"chosen": "nooma as a library",
"against": ["a tree-sitter index of its own", "asking an assistant each time"],
"why": "One index for the line, not one per product."
}]
}"#,
);
let entries = answers.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].kind, "decision");
let body = &entries[0].body;
assert!(body.contains("nooma as a library"), "{body}");
assert!(
body.contains("Rather than: a tree-sitter index of its own; asking an assistant each time"),
"{body}"
);
assert!(body.contains("One index for the line"), "{body}");
assert!(body.contains("plan review 03ab557f, 2026-09-11"), "{body}");
}
/// An idea ticked is work not yet placed, so it is a wish; one left
/// unticked is a decision, or it comes back every quarter.
#[test]
fn a_taken_idea_is_a_wish_and_an_untaken_one_is_a_decision() {
let answers = parse(
r#"{
"answered": "2026-09-11",
"ideas": [
{"idea": "A registry of the line as public JSON", "taken": true},
{"idea": "A second binary for the alias", "taken": false, "why": "An alias is a link."}
]
}"#,
);
let entries = answers.entries();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].kind, "wish");
assert!(entries[0].body.starts_with("A registry of the line"), "{}", entries[0].body);
assert_eq!(entries[1].kind, "decision");
assert!(entries[1].body.starts_with("Not taking: A second binary"), "{}", entries[1].body);
assert!(entries[1].body.contains("An alias is a link."), "{}", entries[1].body);
}
/// The events belong to the day the owner answered, not to the day
/// somebody got round to importing the file.
#[test]
fn the_events_are_dated_by_the_day_it_was_answered() {
assert_eq!(
parse(r#"{"answered":"2026-09-11","ideas":[{"idea":"x"}]}"#).at().as_deref(),
Some("2026-09-11T00:00:00Z")
);
// Anything that is not a plain day is no date at all, and the
// import falls back to now rather than writing a broken timestamp.
assert_eq!(parse(r#"{"answered":"11.09.2026","ideas":[{"idea":"x"}]}"#).at(), None);
assert_eq!(parse(r#"{"ideas":[{"idea":"x"}]}"#).at(), None);
}
/// `taken` defaults to false, which is the safe way round: an idea
/// whose tick the page forgot to write becomes a decision not to take
/// it, which is visible, rather than a wish nobody asked for.
#[test]
fn an_idea_without_a_tick_is_not_taken() {
let answers = parse(r#"{"ideas":[{"idea":"something"}]}"#);
assert_eq!(answers.entries()[0].kind, "decision");
}
#[test]
fn a_questionnaire_with_no_name_still_cites_itself() {
let answers = parse(r#"{"answered":"2026-09-11","ideas":[{"idea":"x","taken":true}]}"#);
assert!(
answers.entries()[0].body.contains("questionnaire of 2026-09-11"),
"{}",
answers.entries()[0].body
);
}
}