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 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 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, ¬es_content)
82 .await?
83 {
84 store
85 .write_spec_file(project_name, spec_name, SpecFileType::Notes, ¬es_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 ¬es_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 ¬es_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 ¬es_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 ¬es_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 ¬es_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 ¬es_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 ¬es_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 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 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 chars.next();
690 if let Some(' ') = chars.peek() {
692 chars.next();
693 }
694 chars.collect::<String>().trim_start().to_string()
696 } else {
697 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 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 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 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 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 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 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 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 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 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 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 let new_line = if trimmed_start.starts_with("- [x] ") || trimmed_start.starts_with("- [ ] ") {
1140 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 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 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 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 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 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 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 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 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 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 let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
1311
1312 new_lines.drain((start_idx + 1)..end_idx);
1314
1315 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}