1use std::path::Path;
7
8use crate::hash::sha256_hex;
9use crate::parse;
10
11fn read(path: &Path) -> Result<String, String> {
12 std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))
13}
14
15fn replace(path: &Path, bytes: &[u8], suffix: &str) -> Result<(), String> {
16 let tmp = path.with_extension(format!("md.{suffix}"));
17 std::fs::write(&tmp, bytes).map_err(|e| format!("{}: {e}", tmp.display()))?;
18 std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))
19}
20
21fn box_at(text: &str, range: [usize; 2]) -> Option<usize> {
23 let line = &text[range[0]..range[1]];
24 let open = line.find("[ ]")?;
25 Some(range[0] + open + 1)
26}
27
28pub fn tick(path: &Path, task_id: &str, expected_line_hash: &str) -> Result<(), String> {
36 let text = read(path)?;
37 let (t, _) = parse::tasks("tasks.md", &text);
38 let task = t
39 .parsed
40 .tasks
41 .iter()
42 .find(|x| x.id == task_id)
43 .ok_or_else(|| format!("{task_id} is not in {}", path.display()))?;
44 if task.done {
45 return Err(format!("{task_id} is already ticked"));
46 }
47 if task.line_hash != expected_line_hash {
48 return Err(format!(
49 "{task_id}'s line changed since the attempt began; not ticked"
50 ));
51 }
52 let at = box_at(&text, task.byte_range)
53 .ok_or_else(|| format!("{task_id}'s box is not `[ ]`; speccoder ticks only an open box"))?;
54 let mut bytes = text.clone().into_bytes();
55 bytes[at] = b'x';
56 let done = |id: &str| id == task_id || t.parsed.tasks.iter().any(|x| x.id == id && x.done);
57 if let Some(parent) = task_id
58 .rsplit_once('.')
59 .and_then(|(p, _)| t.parents.iter().find(|x| x.id == p))
60 && !parent.done
61 && parent.children.iter().all(|c| done(c))
62 && let Some((range, _)) = parent.line
63 && let Some(p_at) = box_at(&text, range)
64 {
65 bytes[p_at] = b'x';
66 }
67 replace(path, &bytes, "speccoder-tick")
68}
69
70pub fn close_all(path: &Path) -> Result<Vec<String>, String> {
78 let text = read(path)?;
79 let (t, _) = parse::tasks("tasks.md", &text);
80 let mut boxes: Vec<(usize, String)> = t
81 .parsed
82 .tasks
83 .iter()
84 .filter(|x| !x.done)
85 .filter_map(|x| Some((box_at(&text, x.byte_range)?, x.id.clone())))
86 .chain(
87 t.parents
88 .iter()
89 .filter(|p| !p.done)
90 .filter_map(|p| Some((box_at(&text, p.line.as_ref()?.0)?, p.id.clone()))),
91 )
92 .collect();
93 if boxes.is_empty() {
94 return Ok(Vec::new());
95 }
96 boxes.sort();
97 let mut bytes = text.into_bytes();
98 for (at, _) in &boxes {
99 bytes[*at] = b'x';
100 }
101 replace(path, &bytes, "speccoder-tick")?;
102 Ok(boxes.into_iter().map(|(_, id)| id).collect())
103}
104
105pub fn append_convergence(
114 path: &Path,
115 expected_file_hash: &str,
116 items: &[String],
117) -> Result<Vec<String>, String> {
118 if items.is_empty() {
119 return Ok(Vec::new());
120 }
121 let text = read(path)?;
122 if sha256_hex(text.as_bytes()) != expected_file_hash {
123 return Err(format!(
124 "{} changed since convergence began; nothing appended",
125 path.display()
126 ));
127 }
128 let (t, _) = parse::tasks("tasks.md", &text);
129 let top = t
130 .parsed
131 .tasks
132 .iter()
133 .map(|x| x.id.as_str())
134 .chain(t.parents.iter().map(|p| p.id.as_str()))
135 .chain(t.optional.iter().map(String::as_str))
136 .filter_map(|id| id.split('.').next()?.parse::<u32>().ok())
137 .max()
138 .unwrap_or(0)
139 + 1;
140 let mut section = String::new();
141 if !text.is_empty() && !text.ends_with('\n') {
142 section.push('\n');
143 }
144 section.push_str(&format!("\n- [ ] {top}. Convergence\n"));
145 let mut ids = Vec::new();
146 for (k, item) in items.iter().enumerate() {
147 let id = format!("{top}.{}", k + 1);
148 let line = item.split_whitespace().collect::<Vec<_>>().join(" ");
150 let line = line.trim_start_matches(['-', '*', ' ']);
151 let line = line.strip_prefix("[ ]").unwrap_or(line).trim();
152 section.push_str(&format!(" - [ ] {id} {line}\n"));
153 ids.push(id);
154 }
155 let new = format!("{text}{section}");
156 let (again, _) = parse::tasks("tasks.md", &new);
157 let same = t.parsed.tasks.iter().all(|old| {
158 again
159 .parsed
160 .tasks
161 .iter()
162 .any(|x| x.id == old.id && x.line_hash == old.line_hash && x.done == old.done)
163 });
164 let added = ids
165 .iter()
166 .all(|id| again.parsed.tasks.iter().any(|x| &x.id == id && !x.done));
167 if !new.starts_with(&text) || !same || !added {
168 return Err(
169 "the convergence group would change existing tasks; nothing appended".to_owned(),
170 );
171 }
172 replace(path, new.as_bytes(), "speccoder-append")?;
173 Ok(ids)
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 const TASKS: &str =
181 "- [ ] 2. Rules\n - [ ] 2.1 One\n - [x] 2.2 Two\n - [ ]* 2.3 Optional\n- [ ] 3. Three\n";
182
183 fn file(text: &str) -> (tempfile::TempDir, std::path::PathBuf) {
184 let dir = tempfile::tempdir().unwrap();
185 let p = dir.path().join("tasks.md");
186 std::fs::write(&p, text).unwrap();
187 (dir, p)
188 }
189
190 #[test]
191 fn the_last_required_sub_task_closes_its_parent() {
192 let (_d, p) = file(TASKS);
193 let (t, _) = parse::tasks("tasks.md", TASKS);
194 let hash = t
195 .parsed
196 .tasks
197 .iter()
198 .find(|x| x.id == "2.1")
199 .unwrap()
200 .line_hash
201 .clone();
202 tick(&p, "2.1", &hash).unwrap();
203 assert_eq!(
204 std::fs::read_to_string(&p).unwrap(),
205 "- [x] 2. Rules\n - [x] 2.1 One\n - [x] 2.2 Two\n - [ ]* 2.3 Optional\n- [ ] 3. Three\n"
206 );
207 assert!(tick(&p, "2.1", &hash).is_err(), "never twice");
208 }
209
210 #[test]
211 fn a_closed_bugfix_ticks_every_required_box_and_no_optional_one() {
212 let (_d, p) = file(TASKS);
213 assert_eq!(close_all(&p).unwrap(), ["2", "2.1", "3"]);
214 assert_eq!(
215 std::fs::read_to_string(&p).unwrap(),
216 "- [x] 2. Rules\n - [x] 2.1 One\n - [x] 2.2 Two\n - [ ]* 2.3 Optional\n- [x] 3. Three\n"
217 );
218 assert!(close_all(&p).unwrap().is_empty(), "nothing left to close");
219 }
220
221 #[test]
222 fn a_changed_line_is_not_ticked() {
223 let (_d, p) = file(TASKS);
224 assert!(
225 tick(&p, "3", "not-the-hash")
226 .unwrap_err()
227 .contains("changed")
228 );
229 }
230
231 #[test]
232 fn convergence_is_the_next_number() {
233 let (_d, p) = file(TASKS);
234 let ids = append_convergence(
235 &p,
236 &sha256_hex(TASKS.as_bytes()),
237 &["Handle an empty title".to_owned()],
238 )
239 .unwrap();
240 assert_eq!(ids, ["4.1"]);
241 let text = std::fs::read_to_string(&p).unwrap();
242 assert!(text.starts_with(TASKS));
243 assert!(
244 text.ends_with("\n- [ ] 4. Convergence\n - [ ] 4.1 Handle an empty title\n"),
245 "{text}"
246 );
247 }
248}