foundry_mcp/core/
edit_engine.rs

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        // Load current contents
31        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                        &notes_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        // Write back only if modified
200        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            &notes_content,
221        )? {
222            filesystem::write_file_atomic(
223                &spec::get_notes_file_path(project_name, spec_name)?,
224                &notes_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}