Skip to main content

speccoder_kiro/
write.rs

1//! The engine's writes to a spec's `tasks.md`: a tick (with its parent
2//! when it was the last required sub-task) and the convergence append.
3//! Both re-read the file and refuse when it changed since speccoder
4//! read it.
5
6use 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
21/// Where `x` goes in the box of the line at `range`.
22fn 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
28/// Ticks `task_id`: `x` into its `[ ]`; and into its parent's when every
29/// required sibling is ticked now. Nothing else changes.
30///
31/// # Errors
32///
33/// The task is not open, its line changed since it was read, or the file
34/// cannot be written.
35pub 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
70/// Ticks every open required task and every open parent (never an
71/// optional one): a bugfix spec's task list, closed by the bug's verified
72/// fix. Returns the ids ticked, in file order; nothing else changes.
73///
74/// # Errors
75///
76/// The file cannot be read or written.
77pub 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
105/// Appends `- [ ] N. Convergence` with one open sub-task `N.k` per item,
106/// N after the highest top-level number. The file must still hash to
107/// `expected_file_hash`; the old bytes stay an exact prefix.
108///
109/// # Errors
110///
111/// The file changed since convergence began, the append would change an
112/// existing task, or the file cannot be written.
113pub 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        // One line each; a model's text never becomes structure.
149        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}