1use crate::core::{filesystem, spec};
2use crate::types::edit_commands::{
3 EditCommand, EditCommandError, EditCommandName, EditCommandTarget, EditSelector,
4 FileUpdateSummary, SelectorCandidate, TaskStatus,
5};
6use anyhow::{Result, anyhow};
7
8pub struct EditEngine;
9
10pub struct EditCommandsResult {
11 pub applied_count: usize,
12 pub skipped_idempotent_count: usize,
13 pub file_updates: Vec<FileUpdateSummary>,
14 pub errors: Vec<EditCommandError>,
15 pub next_steps: Vec<String>,
16 pub workflow_hints: Vec<String>,
17 pub preview_diff: Option<String>,
18}
19
20impl EditEngine {
21 pub fn apply_edit_commands(
22 project_name: &str,
23 spec_name: &str,
24 commands: &[EditCommand],
25 ) -> Result<EditCommandsResult> {
26 if commands.is_empty() {
27 return Err(anyhow!("commands must be a non-empty array"));
28 }
29
30 let mut spec_content =
32 read_file_or_empty(&spec::get_spec_file_path(project_name, spec_name)?)?;
33 let mut tasks_content =
34 read_file_or_empty(&spec::get_task_list_file_path(project_name, spec_name)?)?;
35 let mut notes_content =
36 read_file_or_empty(&spec::get_notes_file_path(project_name, spec_name)?)?;
37
38 let mut applied_total = 0usize;
39 let mut skipped_total = 0usize;
40 let mut file_updates: Vec<FileUpdateSummary> = vec![
41 FileUpdateSummary {
42 target: EditCommandTarget::Spec,
43 applied: 0,
44 skipped_idempotent: 0,
45 hints: None,
46 },
47 FileUpdateSummary {
48 target: EditCommandTarget::Tasks,
49 applied: 0,
50 skipped_idempotent: 0,
51 hints: None,
52 },
53 FileUpdateSummary {
54 target: EditCommandTarget::Notes,
55 applied: 0,
56 skipped_idempotent: 0,
57 hints: None,
58 },
59 ];
60 let mut errors: Vec<EditCommandError> = Vec::new();
61
62 for (idx, command) in commands.iter().enumerate() {
63 match (&command.target, &command.command, &command.selector) {
64 (
65 EditCommandTarget::Tasks,
66 EditCommandName::SetTaskStatus,
67 EditSelector::TaskText { value },
68 ) => {
69 let status = command
70 .status
71 .clone()
72 .ok_or_else(|| anyhow!("status is required for set_task_status"))?;
73 match set_task_status(&tasks_content, value, status) {
74 Ok(EditOutcome {
75 content,
76 applied,
77 skipped,
78 }) => {
79 tasks_content = content;
80 update_counts(
81 file_updates.as_mut_slice(),
82 EditCommandTarget::Tasks,
83 applied,
84 skipped,
85 );
86 applied_total += applied;
87 skipped_total += skipped;
88 }
89 Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
90 target: EditCommandTarget::Tasks,
91 command_index: idx,
92 message: "Ambiguous or no matching task_text selector".to_string(),
93 candidates: Some(candidates),
94 }),
95 }
96 }
97 (
98 EditCommandTarget::Tasks,
99 EditCommandName::UpsertTask,
100 EditSelector::TaskText { value },
101 ) => {
102 let content = command
103 .content
104 .clone()
105 .ok_or_else(|| anyhow!("content is required for upsert_task"))?;
106 match upsert_task(&tasks_content, value, &content) {
107 Ok(EditOutcome {
108 content,
109 applied,
110 skipped,
111 }) => {
112 tasks_content = content;
113 update_counts(
114 file_updates.as_mut_slice(),
115 EditCommandTarget::Tasks,
116 applied,
117 skipped,
118 );
119 applied_total += applied;
120 skipped_total += skipped;
121 }
122 Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
123 target: EditCommandTarget::Tasks,
124 command_index: idx,
125 message: "Ambiguous task_text selector".to_string(),
126 candidates: Some(candidates),
127 }),
128 }
129 }
130 (
131 EditCommandTarget::Spec,
132 EditCommandName::AppendToSection,
133 EditSelector::Section { value },
134 )
135 | (
136 EditCommandTarget::Notes,
137 EditCommandName::AppendToSection,
138 EditSelector::Section { value },
139 ) => {
140 let content = command
141 .content
142 .clone()
143 .ok_or_else(|| anyhow!("content is required for append_to_section"))?;
144 let is_spec = matches!(command.target, EditCommandTarget::Spec);
145 let current = if is_spec {
146 &spec_content
147 } else {
148 ¬es_content
149 };
150 match append_to_section(current, value, &content) {
151 Ok(EditOutcome {
152 content: new_content,
153 applied,
154 skipped,
155 }) => {
156 if is_spec {
157 spec_content = new_content;
158 } else {
159 notes_content = new_content;
160 }
161 let target = if is_spec {
162 EditCommandTarget::Spec
163 } else {
164 EditCommandTarget::Notes
165 };
166 update_counts(file_updates.as_mut_slice(), target, applied, skipped);
167 applied_total += applied;
168 skipped_total += skipped;
169 }
170 Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
171 target: if is_spec {
172 EditCommandTarget::Spec
173 } else {
174 EditCommandTarget::Notes
175 },
176 command_index: idx,
177 message: "Section not found or ambiguous".to_string(),
178 candidates: Some(candidates),
179 }),
180 }
181 }
182 (EditCommandTarget::Tasks, EditCommandName::AppendToSection, _) => {
183 errors.push(EditCommandError {
184 target: EditCommandTarget::Tasks,
185 command_index: idx,
186 message: "append_to_section is invalid for tasks".to_string(),
187 candidates: None,
188 })
189 }
190 _ => errors.push(EditCommandError {
191 target: command.target.clone(),
192 command_index: idx,
193 message: "Unsupported command/selector combination".to_string(),
194 candidates: None,
195 }),
196 }
197 }
198
199 if is_modified(
201 &spec::get_spec_file_path(project_name, spec_name)?,
202 &spec_content,
203 )? {
204 filesystem::write_file_atomic(
205 &spec::get_spec_file_path(project_name, spec_name)?,
206 &spec_content,
207 )?;
208 }
209 if is_modified(
210 &spec::get_task_list_file_path(project_name, spec_name)?,
211 &tasks_content,
212 )? {
213 filesystem::write_file_atomic(
214 &spec::get_task_list_file_path(project_name, spec_name)?,
215 &tasks_content,
216 )?;
217 }
218 if is_modified(
219 &spec::get_notes_file_path(project_name, spec_name)?,
220 ¬es_content,
221 )? {
222 filesystem::write_file_atomic(
223 &spec::get_notes_file_path(project_name, spec_name)?,
224 ¬es_content,
225 )?;
226 }
227
228 let active_file_updates: Vec<FileUpdateSummary> = file_updates
229 .into_iter()
230 .filter(|fu| fu.applied > 0 || fu.skipped_idempotent > 0)
231 .collect();
232
233 Ok(EditCommandsResult {
234 applied_count: applied_total,
235 skipped_idempotent_count: skipped_total,
236 file_updates: active_file_updates,
237 errors,
238 next_steps: vec!["Load updated spec with load_spec to verify changes".to_string()],
239 workflow_hints: vec![
240 "Always copy exact task text and headers from load_spec before editing".to_string(),
241 ],
242 preview_diff: None,
243 })
244 }
245}
246
247struct EditOutcome {
248 content: String,
249 applied: usize,
250 skipped: usize,
251}
252
253struct EditAmbiguity {
254 candidates: Vec<SelectorCandidate>,
255}
256
257fn read_file_or_empty(path: &std::path::Path) -> Result<String> {
258 filesystem::read_file(path).or_else(|_| Ok(String::new()))
259}
260
261fn is_modified(path: &std::path::Path, new_content: &str) -> Result<bool> {
262 filesystem::read_file(path).map_or_else(
263 |_| Ok(!new_content.is_empty()),
264 |existing| Ok(existing != new_content),
265 )
266}
267
268fn normalize_task_text(line: &str) -> String {
269 let text = line.trim_start();
270 let text = text
271 .strip_prefix("- [ ] ")
272 .or_else(|| text.strip_prefix("- [x] "))
273 .unwrap_or(text)
274 .trim();
275
276 let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
277 normalized
278 .strip_suffix('.')
279 .unwrap_or(&normalized)
280 .to_string()
281}
282
283fn set_task_status(
284 current: &str,
285 task_text: &str,
286 status: TaskStatus,
287) -> Result<EditOutcome, EditAmbiguity> {
288 let desired_prefix = match status {
289 TaskStatus::Done => "- [x] ",
290 TaskStatus::Todo => "- [ ] ",
291 };
292 let wanted_norm = normalize_task_text(task_text);
293 let mut lines: Vec<String> = current.lines().map(|l| l.to_string()).collect();
294 let match_indices: Vec<usize> = lines
295 .iter()
296 .enumerate()
297 .filter_map(|(i, line)| {
298 if line.trim_start().starts_with("- [") && normalize_task_text(line) == wanted_norm {
299 Some(i)
300 } else {
301 None
302 }
303 })
304 .collect();
305 if match_indices.is_empty() {
306 return Err(EditAmbiguity {
307 candidates: task_candidates(current),
308 });
309 }
310 if match_indices.len() > 1 {
311 return Err(EditAmbiguity {
312 candidates: task_candidates(current),
313 });
314 }
315 let idx = match_indices[0];
316 let already = lines[idx].trim_start().starts_with(desired_prefix);
317 if already {
318 return Ok(EditOutcome {
319 content: current.to_string(),
320 applied: 0,
321 skipped: 1,
322 });
323 }
324 let normalized = normalize_task_text(&lines[idx]);
325 lines[idx] = format!("{}{}", desired_prefix, normalized);
326 Ok(EditOutcome {
327 content: lines.join("\n"),
328 applied: 1,
329 skipped: 0,
330 })
331}
332
333fn upsert_task(
334 current: &str,
335 task_text: &str,
336 new_task_line: &str,
337) -> Result<EditOutcome, EditAmbiguity> {
338 let wanted_norm = normalize_task_text(task_text);
339 let matches = current
340 .lines()
341 .filter(|line| normalize_task_text(line) == wanted_norm)
342 .count();
343 if matches > 1 {
344 return Err(EditAmbiguity {
345 candidates: task_candidates(current),
346 });
347 }
348 if matches == 1 {
349 return Ok(EditOutcome {
350 content: current.to_string(),
351 applied: 0,
352 skipped: 1,
353 });
354 }
355 let mut content = current.to_string();
356 if !content.is_empty() && !content.ends_with('\n') {
357 content.push('\n');
358 }
359 content.push_str(new_task_line);
360 Ok(EditOutcome {
361 content,
362 applied: 1,
363 skipped: 0,
364 })
365}
366
367fn append_to_section(
368 current: &str,
369 header: &str,
370 content_to_append: &str,
371) -> Result<EditOutcome, EditAmbiguity> {
372 let wanted = header.trim().to_lowercase();
373 let lines: Vec<&str> = current.lines().collect();
374 let header_indices: Vec<usize> = lines
375 .iter()
376 .enumerate()
377 .filter_map(|(i, l)| {
378 if is_header_line(l) && l.trim().to_lowercase() == wanted {
379 Some(i)
380 } else {
381 None
382 }
383 })
384 .collect();
385 if header_indices.is_empty() {
386 return Err(EditAmbiguity {
387 candidates: header_candidates(current),
388 });
389 }
390 if header_indices.len() > 1 {
391 return Err(EditAmbiguity {
392 candidates: header_candidates(current),
393 });
394 }
395 let start_idx = header_indices[0];
396 let mut end_idx = lines
397 .iter()
398 .enumerate()
399 .skip(start_idx + 1)
400 .find(|(_, line)| is_header_line(line))
401 .map(|(i, _)| i)
402 .unwrap_or(lines.len());
403 let section_body = lines[(start_idx + 1)..end_idx].join("\n");
404 if section_body.contains(content_to_append) {
405 return Ok(EditOutcome {
406 content: current.to_string(),
407 applied: 0,
408 skipped: 1,
409 });
410 }
411 let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
412 if end_idx > 0 && !new_lines[end_idx - 1].is_empty() {
413 new_lines.insert(end_idx, String::new());
414 end_idx += 1;
415 }
416 new_lines.insert(end_idx, content_to_append.to_string());
417 Ok(EditOutcome {
418 content: new_lines.join("\n"),
419 applied: 1,
420 skipped: 0,
421 })
422}
423
424fn is_header_line(line: &str) -> bool {
425 line.trim_start().starts_with('#')
426}
427
428fn header_candidates(current: &str) -> Vec<SelectorCandidate> {
429 current
430 .lines()
431 .enumerate()
432 .filter(|(_, l)| is_header_line(l))
433 .map(|(i, l)| SelectorCandidate {
434 selector_suggestion: EditSelector::Section {
435 value: l.trim().to_string(),
436 },
437 preview: preview_excerpt(current, i),
438 })
439 .collect()
440}
441
442fn task_candidates(current: &str) -> Vec<SelectorCandidate> {
443 current
444 .lines()
445 .enumerate()
446 .filter(|(_, l)| l.trim_start().starts_with("- ["))
447 .map(|(i, l)| SelectorCandidate {
448 selector_suggestion: EditSelector::TaskText {
449 value: normalize_task_text(l),
450 },
451 preview: preview_excerpt(current, i),
452 })
453 .collect()
454}
455
456fn update_counts(
457 file_updates: &mut [FileUpdateSummary],
458 target: EditCommandTarget,
459 applied: usize,
460 skipped: usize,
461) {
462 if let Some(update) = file_updates
463 .iter_mut()
464 .find(|update| update.target == target)
465 {
466 update.applied += applied;
467 update.skipped_idempotent += skipped;
468 }
469}
470
471fn preview_excerpt(all: &str, idx: usize) -> String {
472 let lines: Vec<&str> = all.lines().collect();
473 let start = idx.saturating_sub(2);
474 let end = (idx + 3).min(lines.len());
475 lines[start..end].join("\n")
476}