speccoder_openspec/
write.rs1use std::path::Path;
6
7use crate::hash::sha256_hex;
8use crate::tasks::{self, checkbox};
9
10fn read(path: &Path) -> Result<String, String> {
11 std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))
12}
13
14fn replace(path: &Path, bytes: &[u8], suffix: &str) -> Result<(), String> {
15 let tmp = path.with_extension(format!("md.{suffix}"));
16 std::fs::write(&tmp, bytes).map_err(|e| format!("{}: {e}", tmp.display()))?;
17 std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))
18}
19
20pub fn tick(tasks_path: &Path, task_id: &str, expected_line_hash: &str) -> Result<(), String> {
26 let text = read(tasks_path)?;
27 let (parsed, _) = tasks::parse("tasks.md", &text);
28 let task = parsed
29 .tasks
30 .iter()
31 .find(|t| t.id == task_id)
32 .ok_or_else(|| format!("{task_id} is not in {}", tasks_path.display()))?;
33 if task.done {
34 return Err(format!("{task_id} is already ticked"));
35 }
36 if task.line_hash != expected_line_hash {
37 return Err(format!(
38 "{task_id}'s line changed since the attempt began; not ticked"
39 ));
40 }
41 let [start, end] = task.byte_range;
42 let line = &text[start..end];
43 let cb = checkbox(line).ok_or_else(|| format!("{task_id} has no checkbox"))?;
44 if !cb.tickable() {
45 return Err(format!(
46 "{task_id}'s box is `[{}]`, not `[ ]`; speccoder ticks only an open box",
47 cb.inner
48 ));
49 }
50 let mut open_child = false;
52 for l in text[end..].lines().skip(1) {
53 if l.trim().is_empty() {
54 continue;
55 }
56 match checkbox(l) {
57 Some(child) if child.indent > cb.indent => open_child |= !child.done(),
58 _ => break,
59 }
60 }
61 if open_child {
62 return Err(format!("{task_id} has an open nested task; tick it first"));
63 }
64 let at = start + cb.inner_start;
65 let mut bytes = text.into_bytes();
66 bytes[at] = b'x';
67 replace(tasks_path, &bytes, "speccoder-tick")
68}
69
70pub fn append_convergence(
75 tasks_path: &Path,
76 expected_file_hash: &str,
77 items: &[String],
78) -> Result<Vec<String>, String> {
79 if items.is_empty() {
80 return Ok(Vec::new());
81 }
82 let text = read(tasks_path)?;
83 if sha256_hex(text.as_bytes()) != expected_file_hash {
84 return Err(format!(
85 "{} changed since convergence began; nothing appended",
86 tasks_path.display()
87 ));
88 }
89 let (parsed, _) = tasks::parse("tasks.md", &text);
90 let group = parsed
91 .phases
92 .iter()
93 .filter_map(|p| {
94 let digits: String = p.title.chars().take_while(char::is_ascii_digit).collect();
95 digits.parse::<u32>().ok()
96 })
97 .max()
98 .unwrap_or(0)
99 + 1;
100 let mut section = String::new();
101 if !text.is_empty() && !text.ends_with('\n') {
102 section.push('\n');
103 }
104 section.push_str(&format!("\n## {group}. Convergence\n\n"));
105 let mut ids = Vec::new();
106 for (k, item) in items.iter().enumerate() {
107 let id = format!("{group}.{}", k + 1);
108 let line = item.split_whitespace().collect::<Vec<_>>().join(" ");
110 let line = line.trim_start_matches(['-', '*', ' ']);
111 let line = line.strip_prefix("[ ]").unwrap_or(line).trim();
112 section.push_str(&format!("- [ ] {id} {line}\n"));
113 ids.push(id);
114 }
115 let new = format!("{text}{section}");
116 let (reparsed, _) = tasks::parse("tasks.md", &new);
117 let same = parsed.tasks.iter().all(|old| {
118 reparsed
119 .tasks
120 .iter()
121 .any(|t| t.id == old.id && t.line_hash == old.line_hash && t.done == old.done)
122 });
123 let added = ids
124 .iter()
125 .all(|id| reparsed.tasks.iter().any(|t| &t.id == id && !t.done));
126 if !new.starts_with(&text) || !same || !added {
127 return Err(
128 "the convergence group would change existing tasks; nothing appended".to_owned(),
129 );
130 }
131 replace(tasks_path, new.as_bytes(), "speccoder-append")?;
132 Ok(ids)
133}