Skip to main content

foundry_mcp/core/
edit_engine.rs

1use crate::core::backends::SpecContentStore;
2use crate::types::edit_commands::{
3    EditCommand, EditCommandError, EditCommandName, EditCommandTarget, EditSelector,
4    FileUpdateSummary, SelectorCandidate, TaskStatus,
5};
6use crate::types::spec::SpecFileType;
7use anyhow::{Result, anyhow};
8
9pub struct EditEngine;
10
11pub struct EditCommandsResult {
12    pub applied_count: usize,
13    pub skipped_idempotent_count: usize,
14    pub file_updates: Vec<FileUpdateSummary>,
15    pub errors: Vec<EditCommandError>,
16    pub next_steps: Vec<String>,
17    pub workflow_hints: Vec<String>,
18    pub preview_diff: Option<String>,
19}
20
21impl EditEngine {
22    pub async fn apply_edit_commands_with_store<S: SpecContentStore>(
23        project_name: &str,
24        spec_name: &str,
25        commands: &[EditCommand],
26        store: &S,
27    ) -> Result<EditCommandsResult> {
28        if commands.is_empty() {
29            return Err(anyhow!("commands must be a non-empty array"));
30        }
31
32        // Load current contents via SpecContentStore
33        let mut spec_content = store
34            .read_spec_file(project_name, spec_name, SpecFileType::Spec)
35            .await
36            .unwrap_or_else(|_| String::new());
37        let mut tasks_content = store
38            .read_spec_file(project_name, spec_name, SpecFileType::TaskList)
39            .await
40            .unwrap_or_else(|_| String::new());
41        let mut notes_content = store
42            .read_spec_file(project_name, spec_name, SpecFileType::Notes)
43            .await
44            .unwrap_or_else(|_| String::new());
45
46        let result = Self::process_edit_commands(
47            commands,
48            &mut spec_content,
49            &mut tasks_content,
50            &mut notes_content,
51        )?;
52
53        // Write back only if modified via SpecContentStore
54        if store
55            .is_file_modified(project_name, spec_name, SpecFileType::Spec, &spec_content)
56            .await?
57        {
58            store
59                .write_spec_file(project_name, spec_name, SpecFileType::Spec, &spec_content)
60                .await?;
61        }
62        if store
63            .is_file_modified(
64                project_name,
65                spec_name,
66                SpecFileType::TaskList,
67                &tasks_content,
68            )
69            .await?
70        {
71            store
72                .write_spec_file(
73                    project_name,
74                    spec_name,
75                    SpecFileType::TaskList,
76                    &tasks_content,
77                )
78                .await?;
79        }
80        if store
81            .is_file_modified(project_name, spec_name, SpecFileType::Notes, &notes_content)
82            .await?
83        {
84            store
85                .write_spec_file(project_name, spec_name, SpecFileType::Notes, &notes_content)
86                .await?;
87        }
88
89        Ok(result)
90    }
91
92    fn process_edit_commands(
93        commands: &[EditCommand],
94        spec_content: &mut String,
95        tasks_content: &mut String,
96        notes_content: &mut String,
97    ) -> Result<EditCommandsResult> {
98        let mut applied_total = 0usize;
99        let mut skipped_total = 0usize;
100        let mut file_updates: Vec<FileUpdateSummary> = vec![
101            FileUpdateSummary {
102                target: EditCommandTarget::Spec,
103                applied: 0,
104                skipped_idempotent: 0,
105                hints: None,
106            },
107            FileUpdateSummary {
108                target: EditCommandTarget::Tasks,
109                applied: 0,
110                skipped_idempotent: 0,
111                hints: None,
112            },
113            FileUpdateSummary {
114                target: EditCommandTarget::Notes,
115                applied: 0,
116                skipped_idempotent: 0,
117                hints: None,
118            },
119        ];
120        let mut errors: Vec<EditCommandError> = Vec::new();
121
122        for (idx, command) in commands.iter().enumerate() {
123            match (&command.target, &command.command, &command.selector) {
124                (
125                    EditCommandTarget::Tasks,
126                    EditCommandName::SetTaskStatus,
127                    EditSelector::TaskText { value, .. },
128                ) => {
129                    let status = command
130                        .status
131                        .clone()
132                        .ok_or_else(|| anyhow!("status is required for set_task_status"))?;
133                    match set_task_status(tasks_content, value, status) {
134                        Ok(EditOutcome {
135                            content,
136                            applied,
137                            skipped,
138                        }) => {
139                            *tasks_content = content;
140                            update_counts(
141                                file_updates.as_mut_slice(),
142                                EditCommandTarget::Tasks,
143                                applied,
144                                skipped,
145                            );
146                            applied_total += applied;
147                            skipped_total += skipped;
148                        }
149                        Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
150                            target: EditCommandTarget::Tasks,
151                            command_index: idx,
152                            message: "Ambiguous or no matching task_text selector".to_string(),
153                            candidates: Some(candidates),
154                        }),
155                    }
156                }
157                (
158                    EditCommandTarget::Tasks,
159                    EditCommandName::UpsertTask,
160                    EditSelector::TaskText { value, .. },
161                ) => {
162                    let content = command
163                        .content
164                        .clone()
165                        .ok_or_else(|| anyhow!("content is required for upsert_task"))?;
166                    match upsert_task(tasks_content, value, &content) {
167                        Ok(EditOutcome {
168                            content,
169                            applied,
170                            skipped,
171                        }) => {
172                            *tasks_content = content;
173                            update_counts(
174                                file_updates.as_mut_slice(),
175                                EditCommandTarget::Tasks,
176                                applied,
177                                skipped,
178                            );
179                            applied_total += applied;
180                            skipped_total += skipped;
181                        }
182                        Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
183                            target: EditCommandTarget::Tasks,
184                            command_index: idx,
185                            message: "Ambiguous task_text selector".to_string(),
186                            candidates: Some(candidates),
187                        }),
188                    }
189                }
190                (
191                    EditCommandTarget::Spec,
192                    EditCommandName::AppendToSection,
193                    EditSelector::Section { value },
194                )
195                | (
196                    EditCommandTarget::Notes,
197                    EditCommandName::AppendToSection,
198                    EditSelector::Section { value },
199                ) => {
200                    let content = command
201                        .content
202                        .clone()
203                        .ok_or_else(|| anyhow!("content is required for append_to_section"))?;
204                    let is_spec = matches!(command.target, EditCommandTarget::Spec);
205                    let current = if is_spec {
206                        &spec_content
207                    } else {
208                        &notes_content
209                    };
210                    match append_to_section(current, value, &content) {
211                        Ok(EditOutcome {
212                            content: new_content,
213                            applied,
214                            skipped,
215                        }) => {
216                            if is_spec {
217                                *spec_content = new_content;
218                            } else {
219                                *notes_content = new_content;
220                            }
221                            let target = if is_spec {
222                                EditCommandTarget::Spec
223                            } else {
224                                EditCommandTarget::Notes
225                            };
226                            update_counts(file_updates.as_mut_slice(), target, applied, skipped);
227                            applied_total += applied;
228                            skipped_total += skipped;
229                        }
230                        Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
231                            target: if is_spec {
232                                EditCommandTarget::Spec
233                            } else {
234                                EditCommandTarget::Notes
235                            },
236                            command_index: idx,
237                            message: "Section not found or ambiguous".to_string(),
238                            candidates: Some(candidates),
239                        }),
240                    }
241                }
242                (EditCommandTarget::Tasks, EditCommandName::AppendToSection, _) => {
243                    errors.push(EditCommandError {
244                        target: EditCommandTarget::Tasks,
245                        command_index: idx,
246                        message: "append_to_section is invalid for tasks".to_string(),
247                        candidates: None,
248                    })
249                }
250                (
251                    EditCommandTarget::Tasks,
252                    EditCommandName::RemoveListItem,
253                    EditSelector::TaskText { value, .. },
254                ) => match remove_list_item(tasks_content, value) {
255                    Ok(EditOutcome {
256                        content,
257                        applied,
258                        skipped,
259                    }) => {
260                        *tasks_content = content;
261                        update_counts(
262                            file_updates.as_mut_slice(),
263                            EditCommandTarget::Tasks,
264                            applied,
265                            skipped,
266                        );
267                        applied_total += applied;
268                        skipped_total += skipped;
269                    }
270                    Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
271                        target: EditCommandTarget::Tasks,
272                        command_index: idx,
273                        message: "List item not found or ambiguous".to_string(),
274                        candidates: Some(candidates),
275                    }),
276                },
277                (
278                    EditCommandTarget::Spec,
279                    EditCommandName::RemoveListItem,
280                    EditSelector::TaskText { value, .. },
281                )
282                | (
283                    EditCommandTarget::Notes,
284                    EditCommandName::RemoveListItem,
285                    EditSelector::TaskText { value, .. },
286                ) => {
287                    let is_spec = matches!(command.target, EditCommandTarget::Spec);
288                    let current = if is_spec {
289                        &spec_content
290                    } else {
291                        &notes_content
292                    };
293                    match remove_list_item(current, value) {
294                        Ok(EditOutcome {
295                            content: new_content,
296                            applied,
297                            skipped,
298                        }) => {
299                            if is_spec {
300                                *spec_content = new_content;
301                            } else {
302                                *notes_content = new_content;
303                            }
304                            let target = if is_spec {
305                                EditCommandTarget::Spec
306                            } else {
307                                EditCommandTarget::Notes
308                            };
309                            update_counts(file_updates.as_mut_slice(), target, applied, skipped);
310                            applied_total += applied;
311                            skipped_total += skipped;
312                        }
313                        Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
314                            target: if is_spec {
315                                EditCommandTarget::Spec
316                            } else {
317                                EditCommandTarget::Notes
318                            },
319                            command_index: idx,
320                            message: "List item not found or ambiguous".to_string(),
321                            candidates: Some(candidates),
322                        }),
323                    }
324                }
325                (
326                    EditCommandTarget::Spec,
327                    EditCommandName::RemoveFromSection,
328                    EditSelector::Section { value },
329                )
330                | (
331                    EditCommandTarget::Notes,
332                    EditCommandName::RemoveFromSection,
333                    EditSelector::Section { value },
334                ) => {
335                    let content_to_remove = command
336                        .content
337                        .clone()
338                        .ok_or_else(|| anyhow!("content is required for remove_from_section"))?;
339                    let is_spec = matches!(command.target, EditCommandTarget::Spec);
340                    let current = if is_spec {
341                        &spec_content
342                    } else {
343                        &notes_content
344                    };
345                    match remove_from_section(current, value, &content_to_remove) {
346                        Ok(EditOutcome {
347                            content: new_content,
348                            applied,
349                            skipped,
350                        }) => {
351                            if is_spec {
352                                *spec_content = new_content;
353                            } else {
354                                *notes_content = new_content;
355                            }
356                            let target = if is_spec {
357                                EditCommandTarget::Spec
358                            } else {
359                                EditCommandTarget::Notes
360                            };
361                            update_counts(file_updates.as_mut_slice(), target, applied, skipped);
362                            applied_total += applied;
363                            skipped_total += skipped;
364                        }
365                        Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
366                            target: if is_spec {
367                                EditCommandTarget::Spec
368                            } else {
369                                EditCommandTarget::Notes
370                            },
371                            command_index: idx,
372                            message: "Section not found or content not found in section"
373                                .to_string(),
374                            candidates: Some(candidates),
375                        }),
376                    }
377                }
378                (
379                    EditCommandTarget::Spec,
380                    EditCommandName::RemoveSection,
381                    EditSelector::Section { value },
382                )
383                | (
384                    EditCommandTarget::Notes,
385                    EditCommandName::RemoveSection,
386                    EditSelector::Section { value },
387                ) => {
388                    let is_spec = matches!(command.target, EditCommandTarget::Spec);
389                    let current = if is_spec {
390                        &spec_content
391                    } else {
392                        &notes_content
393                    };
394                    match remove_section(current, value) {
395                        Ok(EditOutcome {
396                            content: new_content,
397                            applied,
398                            skipped,
399                        }) => {
400                            if is_spec {
401                                *spec_content = new_content;
402                            } else {
403                                *notes_content = new_content;
404                            }
405                            let target = if is_spec {
406                                EditCommandTarget::Spec
407                            } else {
408                                EditCommandTarget::Notes
409                            };
410                            update_counts(file_updates.as_mut_slice(), target, applied, skipped);
411                            applied_total += applied;
412                            skipped_total += skipped;
413                        }
414                        Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
415                            target: if is_spec {
416                                EditCommandTarget::Spec
417                            } else {
418                                EditCommandTarget::Notes
419                            },
420                            command_index: idx,
421                            message: "Section not found or ambiguous".to_string(),
422                            candidates: Some(candidates),
423                        }),
424                    }
425                }
426                (
427                    EditCommandTarget::Tasks,
428                    EditCommandName::ReplaceListItem,
429                    EditSelector::TaskText { value, .. },
430                ) => {
431                    let new_content = command
432                        .content
433                        .clone()
434                        .ok_or_else(|| anyhow!("content is required for replace_list_item"))?;
435                    match replace_list_item(tasks_content, value, &new_content) {
436                        Ok(EditOutcome {
437                            content,
438                            applied,
439                            skipped,
440                        }) => {
441                            *tasks_content = content;
442                            update_counts(
443                                file_updates.as_mut_slice(),
444                                EditCommandTarget::Tasks,
445                                applied,
446                                skipped,
447                            );
448                            applied_total += applied;
449                            skipped_total += skipped;
450                        }
451                        Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
452                            target: EditCommandTarget::Tasks,
453                            command_index: idx,
454                            message: "List item not found or ambiguous".to_string(),
455                            candidates: Some(candidates),
456                        }),
457                    }
458                }
459                (
460                    EditCommandTarget::Spec,
461                    EditCommandName::ReplaceListItem,
462                    EditSelector::TaskText { value, .. },
463                )
464                | (
465                    EditCommandTarget::Notes,
466                    EditCommandName::ReplaceListItem,
467                    EditSelector::TaskText { value, .. },
468                ) => {
469                    let new_content = command
470                        .content
471                        .clone()
472                        .ok_or_else(|| anyhow!("content is required for replace_list_item"))?;
473                    let is_spec = matches!(command.target, EditCommandTarget::Spec);
474                    let current = if is_spec {
475                        &spec_content
476                    } else {
477                        &notes_content
478                    };
479                    match replace_list_item(current, value, &new_content) {
480                        Ok(EditOutcome {
481                            content: new_content,
482                            applied,
483                            skipped,
484                        }) => {
485                            if is_spec {
486                                *spec_content = new_content;
487                            } else {
488                                *notes_content = new_content;
489                            }
490                            let target = if is_spec {
491                                EditCommandTarget::Spec
492                            } else {
493                                EditCommandTarget::Notes
494                            };
495                            update_counts(file_updates.as_mut_slice(), target, applied, skipped);
496                            applied_total += applied;
497                            skipped_total += skipped;
498                        }
499                        Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
500                            target: if is_spec {
501                                EditCommandTarget::Spec
502                            } else {
503                                EditCommandTarget::Notes
504                            },
505                            command_index: idx,
506                            message: "List item not found or ambiguous".to_string(),
507                            candidates: Some(candidates),
508                        }),
509                    }
510                }
511                (
512                    EditCommandTarget::Spec,
513                    EditCommandName::ReplaceInSection,
514                    EditSelector::TextInSection { section, text },
515                )
516                | (
517                    EditCommandTarget::Notes,
518                    EditCommandName::ReplaceInSection,
519                    EditSelector::TextInSection { section, text },
520                ) => {
521                    let new_content = command
522                        .content
523                        .clone()
524                        .ok_or_else(|| anyhow!("content is required for replace_in_section"))?;
525                    let is_spec = matches!(command.target, EditCommandTarget::Spec);
526                    let current = if is_spec {
527                        &spec_content
528                    } else {
529                        &notes_content
530                    };
531                    match replace_in_section(current, section, text, &new_content) {
532                        Ok(EditOutcome {
533                            content: updated_content,
534                            applied,
535                            skipped,
536                        }) => {
537                            if is_spec {
538                                *spec_content = updated_content;
539                            } else {
540                                *notes_content = updated_content;
541                            }
542                            let target = if is_spec {
543                                EditCommandTarget::Spec
544                            } else {
545                                EditCommandTarget::Notes
546                            };
547                            update_counts(file_updates.as_mut_slice(), target, applied, skipped);
548                            applied_total += applied;
549                            skipped_total += skipped;
550                        }
551                        Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
552                            target: if is_spec {
553                                EditCommandTarget::Spec
554                            } else {
555                                EditCommandTarget::Notes
556                            },
557                            command_index: idx,
558                            message: "Section not found or old text not found in section"
559                                .to_string(),
560                            candidates: Some(candidates),
561                        }),
562                    }
563                }
564                (
565                    EditCommandTarget::Spec,
566                    EditCommandName::ReplaceSectionContent,
567                    EditSelector::Section { value },
568                )
569                | (
570                    EditCommandTarget::Notes,
571                    EditCommandName::ReplaceSectionContent,
572                    EditSelector::Section { value },
573                ) => {
574                    let new_content = command.content.clone().ok_or_else(|| {
575                        anyhow!("content is required for replace_section_content")
576                    })?;
577                    let is_spec = matches!(command.target, EditCommandTarget::Spec);
578                    let current = if is_spec {
579                        &spec_content
580                    } else {
581                        &notes_content
582                    };
583                    match replace_section_content(current, value, &new_content) {
584                        Ok(EditOutcome {
585                            content: updated_content,
586                            applied,
587                            skipped,
588                        }) => {
589                            if is_spec {
590                                *spec_content = updated_content;
591                            } else {
592                                *notes_content = updated_content;
593                            }
594                            let target = if is_spec {
595                                EditCommandTarget::Spec
596                            } else {
597                                EditCommandTarget::Notes
598                            };
599                            update_counts(file_updates.as_mut_slice(), target, applied, skipped);
600                            applied_total += applied;
601                            skipped_total += skipped;
602                        }
603                        Err(EditAmbiguity { candidates }) => errors.push(EditCommandError {
604                            target: if is_spec {
605                                EditCommandTarget::Spec
606                            } else {
607                                EditCommandTarget::Notes
608                            },
609                            command_index: idx,
610                            message: "Section not found or ambiguous".to_string(),
611                            candidates: Some(candidates),
612                        }),
613                    }
614                }
615                _ => errors.push(EditCommandError {
616                    target: command.target.clone(),
617                    command_index: idx,
618                    message: "Unsupported command/selector combination".to_string(),
619                    candidates: None,
620                }),
621            }
622        }
623
624        let active_file_updates: Vec<FileUpdateSummary> = file_updates
625            .into_iter()
626            .filter(|fu| fu.applied > 0 || fu.skipped_idempotent > 0)
627            .collect();
628
629        Ok(EditCommandsResult {
630            applied_count: applied_total,
631            skipped_idempotent_count: skipped_total,
632            file_updates: active_file_updates,
633            errors,
634            next_steps: vec!["Load updated spec with load_spec to verify changes".to_string()],
635            workflow_hints: vec![
636                "Always copy exact task text and headers from load_spec before editing".to_string(),
637            ],
638            preview_diff: None,
639        })
640    }
641}
642
643struct EditOutcome {
644    content: String,
645    applied: usize,
646    skipped: usize,
647}
648
649struct EditAmbiguity {
650    candidates: Vec<SelectorCandidate>,
651}
652
653fn normalize_task_text(line: &str) -> String {
654    let text = line.trim_start();
655    let text = text
656        .strip_prefix("- [ ] ")
657        .or_else(|| text.strip_prefix("- [x] "))
658        .unwrap_or(text)
659        .trim();
660    // Also normalize common list markers and numbered list prefixes so callers
661    // can match without including them when the remainder is unique.
662    let text = text.strip_prefix("- ").map_or_else(
663        || {
664            text.strip_prefix("* ")
665                .map_or_else(|| text, |stripped| stripped.trim_start())
666        },
667        |stripped| stripped.trim_start(),
668    );
669
670    // Strip numbered list prefix like "1. ", "23. ", preserving the remainder
671    // for matching purposes only. This improves selector ergonomics while
672    // replacement functions still preserve original prefixes/styles.
673    let text = {
674        let mut chars = text.chars().peekable();
675        let mut _idx = 0usize;
676        let mut saw_digit = false;
677        while let Some(c) = chars.peek() {
678            if c.is_ascii_digit() {
679                saw_digit = true;
680                _idx += 1;
681                chars.next();
682            } else {
683                break;
684            }
685        }
686        if saw_digit {
687            if let Some('.') = chars.peek() {
688                // Consume the dot
689                chars.next();
690                // Consume a single following space if present
691                if let Some(' ') = chars.peek() {
692                    chars.next();
693                }
694                // Remainder
695                chars.collect::<String>().trim_start().to_string()
696            } else {
697                // Not a numbered prefix, restore original
698                text.to_string()
699            }
700        } else {
701            text.to_string()
702        }
703    };
704
705    let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
706    normalized
707        .strip_suffix('.')
708        .unwrap_or(&normalized)
709        .to_string()
710}
711
712fn set_task_status(
713    current: &str,
714    task_text: &str,
715    status: TaskStatus,
716) -> Result<EditOutcome, EditAmbiguity> {
717    let desired_prefix = match status {
718        TaskStatus::Done => "- [x] ",
719        TaskStatus::Todo => "- [ ] ",
720    };
721    let wanted_norm = normalize_task_text(task_text);
722    let mut lines: Vec<String> = current.lines().map(|l| l.to_string()).collect();
723    let match_indices: Vec<usize> = lines
724        .iter()
725        .enumerate()
726        .filter_map(|(i, line)| {
727            if line.trim_start().starts_with("- [") && normalize_task_text(line) == wanted_norm {
728                Some(i)
729            } else {
730                None
731            }
732        })
733        .collect();
734    if match_indices.is_empty() {
735        return Err(EditAmbiguity {
736            candidates: task_candidates(current),
737        });
738    }
739    if match_indices.len() > 1 {
740        return Err(EditAmbiguity {
741            candidates: task_candidates(current),
742        });
743    }
744    let idx = match_indices[0];
745    let already = lines[idx].trim_start().starts_with(desired_prefix);
746    if already {
747        return Ok(EditOutcome {
748            content: current.to_string(),
749            applied: 0,
750            skipped: 1,
751        });
752    }
753    let normalized = normalize_task_text(&lines[idx]);
754    lines[idx] = format!("{}{}", desired_prefix, normalized);
755    Ok(EditOutcome {
756        content: lines.join("\n"),
757        applied: 1,
758        skipped: 0,
759    })
760}
761
762fn upsert_task(
763    current: &str,
764    task_text: &str,
765    new_task_line: &str,
766) -> Result<EditOutcome, EditAmbiguity> {
767    let wanted_norm = normalize_task_text(task_text);
768    let matches = current
769        .lines()
770        .filter(|line| normalize_task_text(line) == wanted_norm)
771        .count();
772    if matches > 1 {
773        return Err(EditAmbiguity {
774            candidates: task_candidates(current),
775        });
776    }
777    if matches == 1 {
778        return Ok(EditOutcome {
779            content: current.to_string(),
780            applied: 0,
781            skipped: 1,
782        });
783    }
784    let mut content = current.to_string();
785    if !content.is_empty() && !content.ends_with('\n') {
786        content.push('\n');
787    }
788    content.push_str(new_task_line);
789    Ok(EditOutcome {
790        content,
791        applied: 1,
792        skipped: 0,
793    })
794}
795
796fn append_to_section(
797    current: &str,
798    header: &str,
799    content_to_append: &str,
800) -> Result<EditOutcome, EditAmbiguity> {
801    let wanted = header.trim().to_lowercase();
802    let lines: Vec<&str> = current.lines().collect();
803    let header_indices: Vec<usize> = lines
804        .iter()
805        .enumerate()
806        .filter_map(|(i, l)| {
807            if is_header_line(l) && l.trim().to_lowercase() == wanted {
808                Some(i)
809            } else {
810                None
811            }
812        })
813        .collect();
814    if header_indices.is_empty() {
815        return Err(EditAmbiguity {
816            candidates: header_candidates(current),
817        });
818    }
819    if header_indices.len() > 1 {
820        return Err(EditAmbiguity {
821            candidates: header_candidates(current),
822        });
823    }
824    let start_idx = header_indices[0];
825    let mut end_idx = lines
826        .iter()
827        .enumerate()
828        .skip(start_idx + 1)
829        .find(|(_, line)| is_header_line(line))
830        .map(|(i, _)| i)
831        .unwrap_or(lines.len());
832    let section_body = lines[(start_idx + 1)..end_idx].join("\n");
833    if section_body.contains(content_to_append) {
834        return Ok(EditOutcome {
835            content: current.to_string(),
836            applied: 0,
837            skipped: 1,
838        });
839    }
840    let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
841    if end_idx > 0 && !new_lines[end_idx - 1].is_empty() {
842        new_lines.insert(end_idx, String::new());
843        end_idx += 1;
844    }
845    new_lines.insert(end_idx, content_to_append.to_string());
846    Ok(EditOutcome {
847        content: new_lines.join("\n"),
848        applied: 1,
849        skipped: 0,
850    })
851}
852
853fn is_header_line(line: &str) -> bool {
854    line.trim_start().starts_with('#')
855}
856
857fn header_candidates(current: &str) -> Vec<SelectorCandidate> {
858    current
859        .lines()
860        .enumerate()
861        .filter(|(_, l)| is_header_line(l))
862        .map(|(i, l)| SelectorCandidate {
863            selector_suggestion: EditSelector::Section {
864                value: l.trim().to_string(),
865            },
866            preview: preview_excerpt(current, i),
867        })
868        .collect()
869}
870
871fn task_candidates(current: &str) -> Vec<SelectorCandidate> {
872    current
873        .lines()
874        .enumerate()
875        .filter(|(_, l)| l.trim_start().starts_with("- ["))
876        .map(|(i, l)| SelectorCandidate {
877            selector_suggestion: EditSelector::TaskText {
878                value: normalize_task_text(l),
879                section_context: None,
880            },
881            preview: preview_excerpt(current, i),
882        })
883        .collect()
884}
885
886fn update_counts(
887    file_updates: &mut [FileUpdateSummary],
888    target: EditCommandTarget,
889    applied: usize,
890    skipped: usize,
891) {
892    if let Some(update) = file_updates
893        .iter_mut()
894        .find(|update| update.target == target)
895    {
896        update.applied += applied;
897        update.skipped_idempotent += skipped;
898    }
899}
900
901fn remove_list_item(current: &str, item_text: &str) -> Result<EditOutcome, EditAmbiguity> {
902    let wanted_norm = normalize_task_text(item_text);
903    let mut lines: Vec<String> = current.lines().map(|l| l.to_string()).collect();
904
905    // Find matching list items (tasks or regular list items)
906    let match_indices: Vec<usize> = lines
907        .iter()
908        .enumerate()
909        .filter_map(|(i, line)| {
910            let normalized = normalize_task_text(line);
911            if normalized == wanted_norm && is_list_item(line) {
912                Some(i)
913            } else {
914                None
915            }
916        })
917        .collect();
918
919    if match_indices.is_empty() {
920        return Err(EditAmbiguity {
921            candidates: list_item_candidates(current),
922        });
923    }
924    if match_indices.len() > 1 {
925        return Err(EditAmbiguity {
926            candidates: list_item_candidates(current),
927        });
928    }
929
930    let idx = match_indices[0];
931    lines.remove(idx);
932
933    Ok(EditOutcome {
934        content: lines.join("\n"),
935        applied: 1,
936        skipped: 0,
937    })
938}
939
940fn remove_from_section(
941    current: &str,
942    section_header: &str,
943    content_to_remove: &str,
944) -> Result<EditOutcome, EditAmbiguity> {
945    let wanted = section_header.trim().to_lowercase();
946    let lines: Vec<&str> = current.lines().collect();
947
948    // Find the target section
949    let header_indices: Vec<usize> = lines
950        .iter()
951        .enumerate()
952        .filter_map(|(i, l)| {
953            if is_header_line(l) && l.trim().to_lowercase() == wanted {
954                Some(i)
955            } else {
956                None
957            }
958        })
959        .collect();
960
961    if header_indices.is_empty() {
962        return Err(EditAmbiguity {
963            candidates: header_candidates(current),
964        });
965    }
966    if header_indices.len() > 1 {
967        return Err(EditAmbiguity {
968            candidates: header_candidates(current),
969        });
970    }
971
972    let start_idx = header_indices[0];
973    let end_idx = lines
974        .iter()
975        .enumerate()
976        .skip(start_idx + 1)
977        .find(|(_, line)| is_header_line(line))
978        .map(|(i, _)| i)
979        .unwrap_or(lines.len());
980
981    // Check if content exists in the section
982    let section_content = lines[(start_idx + 1)..end_idx].join("\n");
983    if !section_content.contains(content_to_remove) {
984        return Ok(EditOutcome {
985            content: current.to_string(),
986            applied: 0,
987            skipped: 1,
988        });
989    }
990
991    // Remove the content from the section
992    let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
993    let section_lines = new_lines[(start_idx + 1)..end_idx].to_vec();
994    let updated_section = section_lines
995        .join("\n")
996        .replace(content_to_remove, "")
997        .trim()
998        .to_string();
999
1000    // Replace section content
1001    new_lines.drain((start_idx + 1)..end_idx);
1002    if !updated_section.is_empty() {
1003        let replacement_lines: Vec<String> =
1004            updated_section.lines().map(|s| s.to_string()).collect();
1005        for (offset, line) in replacement_lines.iter().enumerate() {
1006            new_lines.insert(start_idx + 1 + offset, line.clone());
1007        }
1008    }
1009
1010    Ok(EditOutcome {
1011        content: new_lines.join("\n"),
1012        applied: 1,
1013        skipped: 0,
1014    })
1015}
1016
1017fn remove_section(current: &str, section_header: &str) -> Result<EditOutcome, EditAmbiguity> {
1018    let wanted = section_header.trim().to_lowercase();
1019    let lines: Vec<&str> = current.lines().collect();
1020
1021    // Find the target section
1022    let header_indices: Vec<usize> = lines
1023        .iter()
1024        .enumerate()
1025        .filter_map(|(i, l)| {
1026            if is_header_line(l) && l.trim().to_lowercase() == wanted {
1027                Some(i)
1028            } else {
1029                None
1030            }
1031        })
1032        .collect();
1033
1034    if header_indices.is_empty() {
1035        return Err(EditAmbiguity {
1036            candidates: header_candidates(current),
1037        });
1038    }
1039    if header_indices.len() > 1 {
1040        return Err(EditAmbiguity {
1041            candidates: header_candidates(current),
1042        });
1043    }
1044
1045    let start_idx = header_indices[0];
1046    let end_idx = lines
1047        .iter()
1048        .enumerate()
1049        .skip(start_idx + 1)
1050        .find(|(_, line)| is_header_line(line))
1051        .map(|(i, _)| i)
1052        .unwrap_or(lines.len());
1053
1054    // Remove the entire section (header + content)
1055    let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
1056    new_lines.drain(start_idx..end_idx);
1057
1058    Ok(EditOutcome {
1059        content: new_lines.join("\n"),
1060        applied: 1,
1061        skipped: 0,
1062    })
1063}
1064
1065fn is_list_item(line: &str) -> bool {
1066    let trimmed = line.trim_start();
1067    trimmed.starts_with("- ")
1068        || trimmed.starts_with("* ")
1069        || trimmed.chars().next().is_some_and(|c| c.is_ascii_digit())
1070}
1071
1072fn list_item_candidates(current: &str) -> Vec<SelectorCandidate> {
1073    current
1074        .lines()
1075        .enumerate()
1076        .filter(|(_, l)| is_list_item(l))
1077        .map(|(i, l)| SelectorCandidate {
1078            selector_suggestion: EditSelector::TaskText {
1079                value: normalize_task_text(l),
1080                section_context: None,
1081            },
1082            preview: preview_excerpt(current, i),
1083        })
1084        .collect()
1085}
1086
1087fn replace_list_item(
1088    current: &str,
1089    old_item_text: &str,
1090    new_item_text: &str,
1091) -> Result<EditOutcome, EditAmbiguity> {
1092    let wanted_norm = normalize_task_text(old_item_text);
1093    let mut lines: Vec<String> = current.lines().map(|l| l.to_string()).collect();
1094
1095    // Find matching list items
1096    let match_indices: Vec<usize> = lines
1097        .iter()
1098        .enumerate()
1099        .filter_map(|(i, line)| {
1100            let normalized = normalize_task_text(line);
1101            if normalized == wanted_norm && is_list_item(line) {
1102                Some(i)
1103            } else {
1104                None
1105            }
1106        })
1107        .collect();
1108
1109    if match_indices.is_empty() {
1110        return Err(EditAmbiguity {
1111            candidates: list_item_candidates(current),
1112        });
1113    }
1114    if match_indices.len() > 1 {
1115        return Err(EditAmbiguity {
1116            candidates: list_item_candidates(current),
1117        });
1118    }
1119
1120    let idx = match_indices[0];
1121
1122    // Check if already matches (idempotent)
1123    let current_normalized = normalize_task_text(&lines[idx]);
1124    let new_normalized = normalize_task_text(new_item_text);
1125    if current_normalized == new_normalized {
1126        return Ok(EditOutcome {
1127            content: current.to_string(),
1128            applied: 0,
1129            skipped: 1,
1130        });
1131    }
1132
1133    // Preserve the prefix (indentation and list marker style)
1134    let original_line = &lines[idx];
1135    let trimmed_start = original_line.trim_start();
1136    let indent = &original_line[..original_line.len() - trimmed_start.len()];
1137
1138    // Determine the list marker style from the original
1139    let new_line = if trimmed_start.starts_with("- [x] ") || trimmed_start.starts_with("- [ ] ") {
1140        // Task list format - preserve completion status
1141        let status_marker = if trimmed_start.starts_with("- [x] ") {
1142            "- [x] "
1143        } else {
1144            "- [ ] "
1145        };
1146        format!(
1147            "{}{}{}",
1148            indent,
1149            status_marker,
1150            normalize_task_text(new_item_text)
1151        )
1152    } else if trimmed_start.starts_with("- ") {
1153        format!("{}{}{}", indent, "- ", new_item_text.trim())
1154    } else if trimmed_start.starts_with("* ") {
1155        format!("{}{}{}", indent, "* ", new_item_text.trim())
1156    } else if trimmed_start
1157        .chars()
1158        .next()
1159        .is_some_and(|c| c.is_ascii_digit())
1160    {
1161        // Numbered list - try to preserve numbering
1162        let num_part = trimmed_start
1163            .chars()
1164            .take_while(|c| c.is_ascii_digit() || *c == '.')
1165            .collect::<String>();
1166        format!("{}{} {}", indent, num_part, new_item_text.trim())
1167    } else {
1168        // Fallback
1169        format!("{}{}", indent, new_item_text.trim())
1170    };
1171
1172    lines[idx] = new_line;
1173
1174    Ok(EditOutcome {
1175        content: lines.join("\n"),
1176        applied: 1,
1177        skipped: 0,
1178    })
1179}
1180
1181fn replace_in_section(
1182    current: &str,
1183    section_header: &str,
1184    old_text: &str,
1185    new_text: &str,
1186) -> Result<EditOutcome, EditAmbiguity> {
1187    let wanted = section_header.trim().to_lowercase();
1188    let lines: Vec<&str> = current.lines().collect();
1189
1190    // Find the target section
1191    let header_indices: Vec<usize> = lines
1192        .iter()
1193        .enumerate()
1194        .filter_map(|(i, l)| {
1195            if is_header_line(l) && l.trim().to_lowercase() == wanted {
1196                Some(i)
1197            } else {
1198                None
1199            }
1200        })
1201        .collect();
1202
1203    if header_indices.is_empty() {
1204        return Err(EditAmbiguity {
1205            candidates: header_candidates(current),
1206        });
1207    }
1208    if header_indices.len() > 1 {
1209        return Err(EditAmbiguity {
1210            candidates: header_candidates(current),
1211        });
1212    }
1213
1214    let start_idx = header_indices[0];
1215    let end_idx = lines
1216        .iter()
1217        .enumerate()
1218        .skip(start_idx + 1)
1219        .find(|(_, line)| is_header_line(line))
1220        .map(|(i, _)| i)
1221        .unwrap_or(lines.len());
1222
1223    // Check if old text exists in the section
1224    let section_content = lines[(start_idx + 1)..end_idx].join("\n");
1225    if !section_content.contains(old_text) {
1226        return Err(EditAmbiguity {
1227            candidates: header_candidates(current),
1228        });
1229    }
1230
1231    // Check if already replaced (idempotent)
1232    if section_content.contains(new_text) && !section_content.contains(old_text) {
1233        return Ok(EditOutcome {
1234            content: current.to_string(),
1235            applied: 0,
1236            skipped: 1,
1237        });
1238    }
1239
1240    // Replace the content in the section
1241    let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
1242    let updated_section = section_content.replace(old_text, new_text);
1243
1244    // Replace section content
1245    new_lines.drain((start_idx + 1)..end_idx);
1246    let replacement_lines: Vec<String> = updated_section.lines().map(|s| s.to_string()).collect();
1247    for (offset, line) in replacement_lines.iter().enumerate() {
1248        new_lines.insert(start_idx + 1 + offset, line.clone());
1249    }
1250
1251    Ok(EditOutcome {
1252        content: new_lines.join("\n"),
1253        applied: 1,
1254        skipped: 0,
1255    })
1256}
1257
1258fn replace_section_content(
1259    current: &str,
1260    section_header: &str,
1261    new_content: &str,
1262) -> Result<EditOutcome, EditAmbiguity> {
1263    let wanted = section_header.trim().to_lowercase();
1264    let lines: Vec<&str> = current.lines().collect();
1265
1266    // Find the target section
1267    let header_indices: Vec<usize> = lines
1268        .iter()
1269        .enumerate()
1270        .filter_map(|(i, l)| {
1271            if is_header_line(l) && l.trim().to_lowercase() == wanted {
1272                Some(i)
1273            } else {
1274                None
1275            }
1276        })
1277        .collect();
1278
1279    if header_indices.is_empty() {
1280        return Err(EditAmbiguity {
1281            candidates: header_candidates(current),
1282        });
1283    }
1284    if header_indices.len() > 1 {
1285        return Err(EditAmbiguity {
1286            candidates: header_candidates(current),
1287        });
1288    }
1289
1290    let start_idx = header_indices[0];
1291    let end_idx = lines
1292        .iter()
1293        .enumerate()
1294        .skip(start_idx + 1)
1295        .find(|(_, line)| is_header_line(line))
1296        .map(|(i, _)| i)
1297        .unwrap_or(lines.len());
1298
1299    // Check if already matches (idempotent)
1300    let current_section_content = lines[(start_idx + 1)..end_idx].join("\n");
1301    if current_section_content.trim() == new_content.trim() {
1302        return Ok(EditOutcome {
1303            content: current.to_string(),
1304            applied: 0,
1305            skipped: 1,
1306        });
1307    }
1308
1309    // Replace all content within the section, keeping the header
1310    let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
1311
1312    // Remove old section content
1313    new_lines.drain((start_idx + 1)..end_idx);
1314
1315    // Insert new content
1316    if !new_content.trim().is_empty() {
1317        let replacement_lines: Vec<String> = new_content.lines().map(|s| s.to_string()).collect();
1318        for (offset, line) in replacement_lines.iter().enumerate() {
1319            new_lines.insert(start_idx + 1 + offset, line.clone());
1320        }
1321    }
1322
1323    Ok(EditOutcome {
1324        content: new_lines.join("\n"),
1325        applied: 1,
1326        skipped: 0,
1327    })
1328}
1329
1330fn preview_excerpt(all: &str, idx: usize) -> String {
1331    let lines: Vec<&str> = all.lines().collect();
1332    let start = idx.saturating_sub(2);
1333    let end = (idx + 3).min(lines.len());
1334    lines[start..end].join("\n")
1335}