Skip to main content

speccoder_openspec/
write.rs

1//! The engine's two writes to a change's `tasks.md`: a one-byte tick and the
2//! convergence append. Both re-read the file and refuse when it
3//! changed since speccoder read it.
4
5use 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
20/// Ticks `task_id`: writes `x` into its `[ ]`, one byte, nothing else.
21///
22/// Refused when the task's line no longer hashes to `expected_line_hash`,
23/// when its box is anything but a single space (`[~]`, `[]`), and when it
24/// is a parent with an open nested task.
25pub 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    // The checkboxes indented below it are its nested tasks.
51    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
70/// Appends `## N. Convergence` with one open task `N.k` per item, N after
71/// the highest numbered group. The file must still hash to
72/// `expected_file_hash`; the old bytes stay an exact prefix. No items → the
73/// file is not touched. Returns the new IDs.
74pub 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        // One line each; a model's text never becomes structure.
109        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}