1use std::borrow::Cow;
12use std::fmt::Write as _;
13use std::ops::Range;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use rig_core::completion::ToolAnnotations;
18use rig_core::tool::{
19 FileChange, FileState, FilesChanged, HunkLine, Scope, Tool, ToolContext, ToolExecutionError,
20 Touches, args_schema, content_hash,
21};
22use schemars::JsonSchema;
23use serde::Deserialize;
24
25use crate::fs::{diff, io_error};
26use crate::workspace::{Local, Workspace, text_error, text_of};
27use crate::{Seen, blocking, clip};
28
29const MAX_LISTED: usize = 8;
32const MAX_CLOSEST: usize = 20;
35const MAX_CLOSEST_CHARS: usize = 300;
36
37#[derive(Clone, Debug)]
41pub struct Edit {
42 pub workspace: Arc<dyn Workspace>,
44 pub base: Option<PathBuf>,
47 pub confine: bool,
50 pub seen: Seen,
55}
56
57impl Default for Edit {
58 fn default() -> Self {
60 Self {
61 workspace: Arc::new(Local),
62 base: None,
63 confine: false,
64 seen: Seen::default(),
65 }
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
71#[serde(deny_unknown_fields)]
72pub struct EditArgs {
73 pub path: String,
75 #[schemars(length(min = 1))]
78 pub edits: Vec<Replacement>,
79}
80
81impl EditArgs {
82 pub fn new(path: impl Into<String>, edits: impl IntoIterator<Item = Replacement>) -> Self {
84 Self {
85 path: path.into(),
86 edits: edits.into_iter().collect(),
87 }
88 }
89}
90
91#[allow(missing_docs)]
93#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
94#[serde(deny_unknown_fields)]
95pub struct Replacement {
96 pub old_text: String,
98 pub new_text: String,
100 #[serde(default)]
102 pub replace_all: bool,
103}
104
105impl Replacement {
106 pub fn new(old_text: impl Into<String>, new_text: impl Into<String>) -> Self {
108 Self {
109 old_text: old_text.into(),
110 new_text: new_text.into(),
111 replace_all: false,
112 }
113 }
114
115 pub fn all(mut self) -> Self {
117 self.replace_all = true;
118 self
119 }
120}
121
122impl Tool for Edit {
123 const NAME: &'static str = "edit";
124 type Args = EditArgs;
125 type Output = String;
126 type Error = ToolExecutionError;
127
128 fn description(&self) -> String {
129 "Replace text in one file. Each entry of `edits` replaces `old_text` with `new_text`. \
130 Copy each `old_text` from the file as `read` showed it, with just enough lines around \
131 the change to match one place, or set `replace_all` to replace every exact match. \
132 Text with no exact match still matches line by line once trailing whitespace and \
133 typographic quotes, dashes and spaces are evened out, but only where exactly one place \
134 matches that way, and the result says so. Make all changes to one file in one call, \
135 one entry per change: the entries apply in order, each to the text the ones before it \
136 left, and all of them or none. Returns the lines that now hold the changed text, and \
137 those lines and a few around each as the file now holds them, so there is no need to \
138 read it again."
139 .to_owned()
140 }
141
142 fn parameters(&self) -> serde_json::Value {
143 args_schema::<EditArgs>()
144 }
145
146 fn annotations(&self) -> ToolAnnotations {
147 ToolAnnotations::default()
148 .with_destructive(true)
149 .with_idempotent(false)
150 .with_open_world(false)
151 }
152
153 fn footprint(&self, args: &EditArgs) -> Touches {
154 Touches::Writes(Scope::Paths(vec![
155 self.workspace.resolve(self.base.as_deref(), &args.path),
156 ]))
157 }
158
159 async fn call(
160 &self,
161 context: &mut ToolContext,
162 args: EditArgs,
163 ) -> Result<String, ToolExecutionError> {
164 let path = self
165 .workspace
166 .place(self.base.as_deref(), self.confine, &args.path)?;
167 let workspace = self.workspace.clone();
168 let seen = self.seen.clone();
169 let (output, change) = blocking(move || edit(&*workspace, &seen, path, args)).await?;
170 context.insert_result(FilesChanged(vec![change]))?;
171 Ok(output)
172 }
173}
174
175fn edit(
176 workspace: &dyn Workspace,
177 seen: &Seen,
178 resolved: PathBuf,
179 args: EditArgs,
180) -> Result<(String, FileChange), ToolExecutionError> {
181 let path = resolved.to_string_lossy();
182 let mut held = None;
185 if let Some((hash, text)) = seen.text(&resolved)
186 && let Ok(applied) = apply_to(&path, &text, &args.edits, false)
187 {
188 let now = workspace
189 .write_if(&resolved, applied.text.as_bytes(), &hash)
190 .map_err(|error| {
191 seen.forget(&resolved);
192 text_error(&path, error)
193 })?;
194 match now {
195 None => return Ok(reply(seen, &resolved, &args, applied)),
196 Some(now) => held = Some(text_of(now).map_err(|error| text_error(&path, error))?),
197 }
198 }
199 let raw = match held {
200 Some(raw) => raw,
201 None => workspace.read_text(&resolved)?,
202 };
203 let changed = seen
204 .hash(&resolved)
205 .is_some_and(|hash| hash != content_hash(raw.as_bytes()));
206 let applied = apply_to(&path, &raw, &args.edits, changed)?;
207 workspace
208 .write(&resolved, applied.text.as_bytes())
209 .map_err(|error| {
210 seen.forget(&resolved);
211 io_error(&path, error)
212 })?;
213 Ok(reply(seen, &resolved, &args, applied))
214}
215
216fn reply(seen: &Seen, resolved: &Path, args: &EditArgs, applied: Applied) -> (String, FileChange) {
219 seen.saw_whole(resolved, &applied.text);
220 let places = places(&applied.change);
221 let at = changed_at(&places, &applied.change, applied.replaced, args.edits.len());
222 let mut out = format!("Edited {}: {at}.", args.path);
223 for note in applied.notes {
224 let _ = write!(out, "\nNote: {note}");
225 }
226 out.push_str(&around(&applied.text, &places));
227 (out, applied.change)
228}
229
230const CONTEXT: usize = 3;
232const MAX_AROUND: usize = 60;
235const MAX_PLACE: usize = 30;
236
237fn around(text: &str, places: &[Place]) -> String {
241 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
242 let lines: Vec<&str> = text.lines().collect();
243 let mut runs: Vec<(usize, usize)> = Vec::new();
244 for place in places {
245 let (first, last) = match *place {
246 Place::Lines(first, last) => (first, last),
247 Place::Removed(at) => (at, at.saturating_sub(1)),
248 };
249 let first = first.saturating_sub(CONTEXT).max(1);
250 let last = (last + CONTEXT).min(lines.len());
251 match runs.last_mut() {
252 Some((_, end)) if first <= *end + 1 => *end = (*end).max(last),
253 _ if first <= last => runs.push((first, last)),
254 _ => {}
255 }
256 }
257 let mut out = String::new();
258 let mut shown = 0;
259 for (index, &(first, last)) in runs.iter().enumerate() {
260 if shown >= MAX_AROUND {
261 let _ = write!(
262 out,
263 "\n[{} more changed places not shown; read them to see them]",
264 runs.len() - index
265 );
266 break;
267 }
268 let _ = write!(out, "\n[lines {first}-{last}]");
269 let count = last - first + 1;
270 let half = MAX_PLACE / 2;
271 for (number, line) in (first..=last).zip(lines.iter().skip(first - 1)) {
272 if count > MAX_PLACE && number == first + half {
273 let _ = write!(out, "\n[… {} lines]", count - 2 * half);
274 }
275 if count > MAX_PLACE && number >= first + half && number <= last - half {
276 continue;
277 }
278 out.push('\n');
279 out.push_str(clip(line, MAX_CLOSEST_CHARS));
280 shown += 1;
281 }
282 }
283 out
284}
285
286fn changed_at(places: &[Place], change: &FileChange, replaced: usize, edits: usize) -> String {
292 let mut out = String::new();
293 if replaced != edits {
294 let _ = write!(out, "{replaced} replacements, ");
295 }
296 out.push_str(match places {
297 [Place::Removed(_)] => "line ",
298 [Place::Lines(first, last)] if first == last => "line ",
299 [Place::Removed(_), ..] => "",
301 _ => "lines ",
302 });
303 let listed: Vec<String> = places
304 .iter()
305 .take(MAX_LISTED)
306 .map(|place| match *place {
307 Place::Lines(first, last) if first == last => first.to_string(),
308 Place::Lines(first, last) => format!("{first}-{last}"),
309 Place::Removed(at) if places.len() == 1 => at.to_string(),
310 Place::Removed(at) => format!("line {at}"),
311 })
312 .collect();
313 out.push_str(&listed.join(", "));
314 let more = places.len().saturating_sub(MAX_LISTED);
315 if more > 0 {
316 let _ = write!(out, ", and {more} more");
317 }
318 let shift = change.added as i64 - change.removed as i64;
319 if shift != 0 {
320 let noun = if shift.abs() == 1 { "line" } else { "lines" };
321 let _ = write!(out, " ({shift:+} {noun})");
322 }
323 out
324}
325
326#[derive(Clone, Copy, Debug, PartialEq, Eq)]
328enum Place {
329 Lines(usize, usize),
331 Removed(usize),
333}
334
335fn places(change: &FileChange) -> Vec<Place> {
337 let mut places = Vec::new();
338 for hunk in &change.hunks {
339 let kept = |line: &HunkLine| matches!(line, HunkLine::Same(_));
340 let mut runs = hunk.lines.chunk_by(|a, b| kept(a) == kept(b)).peekable();
341 let mut at = hunk.new.start;
343 while let Some(run) = runs.next() {
344 if run.first().is_some_and(kept) {
345 at += run.len();
346 continue;
347 }
348 let added = run.iter().filter(|line| line.sign() == '+').count();
349 places.push(match added {
350 0 if runs.peek().is_none() => Place::Removed(at.saturating_sub(1).max(1)),
353 0 => Place::Removed(at),
354 added => Place::Lines(at, at + added - 1),
355 });
356 at += added;
357 }
358 }
359 places
360}
361
362#[derive(Clone, Debug, PartialEq, Eq)]
364pub struct Applied {
365 pub text: String,
368 pub change: FileChange,
371 pub replaced: usize,
373 pub notes: Vec<String>,
375}
376
377pub fn apply(path: &str, text: &str, edits: &[Replacement]) -> Result<Applied, ToolExecutionError> {
399 apply_to(path, text, edits, false)
400}
401
402fn apply_to(
405 path: &str,
406 text: &str,
407 edits: &[Replacement],
408 changed: bool,
409) -> Result<Applied, ToolExecutionError> {
410 if edits.is_empty() {
411 return Err(ToolExecutionError::invalid_args(format!(
412 "`edits` is empty, so `{path}` was not changed; give at least one \
413 {{\"old_text\", \"new_text\"}}"
414 )));
415 }
416 let whole = text;
417 let file = Decoded::new(text);
418 let text: &str = &file.text;
419 let count = edits.len();
420 let many = count > 1;
421 let mut edited = text.to_owned();
423 let mut applied: Vec<usize> = Vec::new();
424 let mut replaced = 0;
425 let mut notes = Vec::new();
426 let mut failed: Vec<String> = Vec::new();
428 let mut closest: Vec<String> = Vec::new();
430 for (index, replacement) in edits.iter().enumerate() {
431 let label = if many {
432 format!("edits[{index}]: ")
433 } else {
434 String::new()
435 };
436 let old = lf(&replacement.old_text);
437 let new = lf(&replacement.new_text);
438 if old.is_empty() {
439 failed.push(format!(
440 "{label}`old_text` is empty; to add text, replace a nearby line with itself plus the new text"
441 ));
442 continue;
443 }
444 if old == new {
445 failed.push(format!(
446 "{label}`old_text` and `new_text` are the same, so it changes nothing; drop it or fix `new_text`"
447 ));
448 continue;
449 }
450 match locate(&edited, &old, replacement.replace_all) {
451 Err(Miss::NotFound) => {
452 if let Some(earlier) = changed_by(text, edits, &applied, &old, replacement) {
453 failed.push(format!(
454 "{label}`old_text` was in the file, but edits[{earlier}], applied before \
455 it, changed that text; match the text as edits[{earlier}] leaves it, or \
456 make this change in edits[{earlier}]"
457 ));
458 continue;
459 }
460 let near = (!changed).then(|| nearest(&edited, &old)).flatten();
461 failed.push(format!(
462 "{label}{}",
463 not_found(path, changed, near.is_some())
464 ));
465 if let Some(near) = near {
466 let whose = if many {
467 format!("of edits[{index}] ")
468 } else {
469 String::new()
470 };
471 closest.push(format!(
472 "The closest text to the `old_text` {whose}is {}:\n{}",
473 near.lines, near.text
474 ));
475 }
476 }
477 Err(miss) => failed.push(format!("{label}{}", miss.explain())),
478 Ok(found) => {
479 if let Some(note) = found.note {
480 notes.push(format!("{label}{note}"));
481 }
482 replaced += found.spans.len();
483 edited = splice(&edited, &found.spans, &new);
484 applied.push(index);
485 }
486 }
487 }
488 if !failed.is_empty() {
489 let mut why = batch_failure(path, count, &failed);
490 for near in closest {
491 let _ = write!(why, "\n{near}");
492 }
493 return Err(ToolExecutionError::invalid_args(why));
494 }
495 if edited == text {
496 return Err(refused(
497 path,
498 "the edits leave the file as it was; check `new_text`".to_owned(),
499 ));
500 }
501 let change = diff(path, Some(text), Some(&edited))
502 .with_before_text(text)
503 .with_before(FileState::of(whole.as_bytes()));
504 let encoded = file.encode(edited);
505 Ok(Applied {
506 change: change.with_after(FileState::of(encoded.as_bytes())),
507 text: encoded,
508 replaced,
509 notes,
510 })
511}
512
513fn splice(text: &str, spans: &[Range<usize>], new: &str) -> String {
515 let mut out = String::with_capacity(text.len());
516 let mut at = 0;
517 for span in spans {
518 out.push_str(text.get(at..span.start).unwrap_or_default());
519 out.push_str(new);
520 at = span.end;
521 }
522 out.push_str(text.get(at..).unwrap_or_default());
523 out
524}
525
526fn changed_by(
530 text: &str,
531 edits: &[Replacement],
532 applied: &[usize],
533 old: &str,
534 replacement: &Replacement,
535) -> Option<usize> {
536 let mut before = text.to_owned();
537 for &index in applied {
538 if locate(&before, old, replacement.replace_all).is_err() {
539 return None;
540 }
541 let earlier = edits.get(index)?;
542 let found = locate(&before, &lf(&earlier.old_text), earlier.replace_all).ok()?;
543 before = splice(&before, &found.spans, &lf(&earlier.new_text));
544 if locate(&before, old, replacement.replace_all).is_err() {
545 return Some(index);
546 }
547 }
548 None
549}
550
551struct Decoded<'a> {
554 text: Cow<'a, str>,
555 bom: bool,
556 crlf: bool,
557}
558
559impl<'a> Decoded<'a> {
560 fn new(raw: &'a str) -> Self {
561 let (bom, body) = match raw.strip_prefix('\u{feff}') {
562 Some(body) => (true, body),
563 None => (false, raw),
564 };
565 let newlines = body.matches('\n').count();
568 let crlf = newlines > 0 && body.matches("\r\n").count() == newlines;
569 let text = if crlf {
570 Cow::Owned(body.replace("\r\n", "\n"))
571 } else {
572 Cow::Borrowed(body)
573 };
574 Self { text, bom, crlf }
575 }
576
577 fn encode(&self, text: String) -> String {
579 let body = if self.crlf {
580 text.replace('\n', "\r\n")
581 } else {
582 text
583 };
584 if self.bom {
585 format!("\u{feff}{body}")
586 } else {
587 body
588 }
589 }
590}
591
592fn lf(text: &str) -> String {
594 text.replace("\r\n", "\n")
595}
596
597fn refused(path: &str, why: String) -> ToolExecutionError {
599 ToolExecutionError::invalid_args(format!(
600 "{why}. `{path}` was not changed; fix the call and send all of its edits again."
601 ))
602}
603
604fn batch_failure(path: &str, count: usize, failed: &[String]) -> String {
608 let [why] = failed else {
609 return format!(
610 "{} of the {count} edits failed:\n{}.\nNo edit was applied, so `{path}` was not \
611 changed. Send all {count} edits again, with these fixed.",
612 failed.len(),
613 failed.join(".\n"),
614 );
615 };
616 if count == 1 {
617 return format!("{why}. `{path}` was not changed; fix the edit and send it again.");
618 }
619 format!(
620 "{why}. No edit was applied, so `{path}` was not changed. Send all {count} edits again, \
621 with this one fixed."
622 )
623}
624
625fn line_of(text: &str, at: usize) -> usize {
627 text.get(..at)
628 .map_or(0, |before| before.matches('\n').count())
629 + 1
630}
631
632struct Found {
634 spans: Vec<Range<usize>>,
635 note: Option<String>,
637}
638
639enum Miss {
641 NotFound,
643 Ambiguous { lines: Vec<usize>, loose: bool },
646}
647
648impl Miss {
649 fn explain(&self) -> String {
651 match self {
652 Miss::Ambiguous { lines, loose } => {
653 let listed: Vec<String> = lines
654 .iter()
655 .take(MAX_LISTED)
656 .map(ToString::to_string)
657 .collect();
658 let more = lines.len().saturating_sub(MAX_LISTED);
659 let more = if more > 0 {
660 format!(" and {more} more")
661 } else {
662 String::new()
663 };
664 format!(
665 "`old_text` {}matches {} places, starting at lines {}{more}; add the lines \
666 around the one to change so it matches once, or set `replace_all` to \
667 change every one",
668 if *loose {
669 "does not match exactly, and loosely "
670 } else {
671 ""
672 },
673 lines.len(),
674 listed.join(", ")
675 )
676 }
677 Miss::NotFound => not_found("the file", false, false),
678 }
679 }
680}
681
682fn not_found(path: &str, changed: bool, near: bool) -> String {
686 let what = "`old_text` was not found, not even ignoring trailing whitespace";
687 if changed {
688 format!(
689 "{what}: `{path}` changed since it was last read, so read it again and copy \
690 `old_text` from what it holds now"
691 )
692 } else if near {
693 format!("{what}; copy it from the closest text in the file, shown below")
694 } else {
695 format!("{what}, and nothing in the file is close to it; check the path and the text")
696 }
697}
698
699struct Near {
701 lines: String,
703 text: String,
705}
706
707fn nearest(text: &str, old: &str) -> Option<Near> {
711 let file = Lines::new(text);
712 let count = file.spans.len();
713 let wanted: Vec<String> = old
714 .strip_suffix('\n')
715 .unwrap_or(old)
716 .split('\n')
717 .map(loose_key)
718 .collect();
719 let size = wanted.len().min(count).max(1);
720 let keys: Vec<String> = (0..count)
721 .map(|index| loose_key(file.line(index)))
722 .collect();
723 let compared = size.min(MAX_CLOSEST);
726 let mut best: Option<(f64, usize)> = None;
727 for start in 0..=count.saturating_sub(size) {
728 let score: f64 = wanted
729 .iter()
730 .zip(keys.iter().skip(start))
731 .take(compared)
732 .map(|(want, have)| alike(want, have))
733 .sum::<f64>()
734 / compared as f64;
735 if best.is_none_or(|(most, _)| score > most) {
736 best = Some((score, start));
737 }
738 }
739 let (score, start) = best?;
740 if score < 0.5 {
741 return None;
742 }
743 let shown: Vec<String> = (start..start + size)
744 .take(MAX_CLOSEST)
745 .map(|index| clip(file.line(index), MAX_CLOSEST_CHARS).to_owned())
746 .collect();
747 let mut text = shown.join("\n");
748 if size > MAX_CLOSEST {
749 let _ = write!(text, "\n[… {} more lines]", size - MAX_CLOSEST);
750 }
751 let lines = match size {
752 1 => format!("line {}", start + 1),
753 _ => format!("lines {}-{}", start + 1, start + size),
754 };
755 Some(Near { lines, text })
756}
757
758fn alike(one: &str, other: &str) -> f64 {
761 let (one, other): (Vec<char>, Vec<char>) = (one.chars().collect(), other.chars().collect());
762 let longest = one.len().max(other.len());
763 if longest == 0 {
764 return 1.0;
765 }
766 let start = one.iter().zip(&other).take_while(|(a, b)| a == b).count();
767 let end = one
768 .iter()
769 .rev()
770 .zip(other.iter().rev())
771 .take(one.len().min(other.len()) - start)
772 .take_while(|(a, b)| a == b)
773 .count();
774 (start + end) as f64 / longest as f64
775}
776
777fn locate(text: &str, old: &str, replace_all: bool) -> Result<Found, Miss> {
781 let exact: Vec<Range<usize>> = text
782 .match_indices(old)
783 .map(|(start, matched)| start..start + matched.len())
784 .collect();
785 match exact.len() {
786 0 => {}
787 count if count == 1 || replace_all => {
788 return Ok(Found {
789 spans: exact,
790 note: None,
791 });
792 }
793 _ => {
794 return Err(Miss::Ambiguous {
795 lines: exact.iter().map(|span| line_of(text, span.start)).collect(),
796 loose: false,
797 });
798 }
799 }
800 if replace_all {
801 return Err(Miss::NotFound);
802 }
803 let lines = Lines::new(text);
804 let wanted: Vec<&str> = old.strip_suffix('\n').unwrap_or(old).split('\n').collect();
805 let start = match lines.windows(&wanted).as_slice() {
806 [] => return Err(Miss::NotFound),
807 [start] => *start,
808 starts => {
809 return Err(Miss::Ambiguous {
810 lines: starts.iter().map(|start| start + 1).collect(),
811 loose: true,
812 });
813 }
814 };
815 Ok(Found {
816 spans: vec![lines.span(start, wanted.len(), old.ends_with('\n'))],
817 note: Some(
818 "`old_text` matched only after evening out trailing whitespace and quote, dash or \
819 space characters; copy text exactly next time"
820 .to_owned(),
821 ),
822 })
823}
824
825fn loose_key(line: &str) -> String {
828 line.trim_end().chars().map(plain).collect()
829}
830
831fn plain(c: char) -> char {
833 match c {
834 '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
835 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
836 '\u{2010}'..='\u{2015}' | '\u{2212}' => '-',
837 '\u{00A0}' | '\u{2002}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' => ' ',
838 c => c,
839 }
840}
841
842struct Lines<'a> {
844 text: &'a str,
845 spans: Vec<(Range<usize>, usize)>,
847}
848
849impl<'a> Lines<'a> {
850 fn new(text: &'a str) -> Self {
851 let mut spans = Vec::new();
852 let mut start = 0;
853 for line in text.split_inclusive('\n') {
854 let end = start + line.len();
855 let content = end - usize::from(line.ends_with('\n'));
856 spans.push((start..content, end));
857 start = end;
858 }
859 Self { text, spans }
860 }
861
862 fn line(&self, index: usize) -> &'a str {
863 self.spans
864 .get(index)
865 .and_then(|(content, _)| self.text.get(content.clone()))
866 .unwrap_or_default()
867 }
868
869 fn windows(&self, wanted: &[&str]) -> Vec<usize> {
872 let keys: Vec<String> = (0..self.spans.len())
873 .map(|index| loose_key(self.line(index)))
874 .collect();
875 let wanted: Vec<String> = wanted.iter().map(|line| loose_key(line)).collect();
876 if wanted.iter().all(String::is_empty) {
877 return Vec::new();
878 }
879 keys.windows(wanted.len().max(1))
880 .enumerate()
881 .filter(|(_, window)| *window == wanted.as_slice())
882 .map(|(start, _)| start)
883 .collect()
884 }
885
886 fn span(&self, start: usize, count: usize, whole_lines: bool) -> Range<usize> {
889 let first = self
890 .spans
891 .get(start)
892 .map_or(self.text.len(), |(content, _)| content.start);
893 let last = self.spans.get(start + count - 1);
894 let end = match last {
895 Some((_, with_newline)) if whole_lines => *with_newline,
896 Some((content, _)) => content.end,
897 None => self.text.len(),
898 };
899 first..end
900 }
901}
902
903#[cfg(test)]
904mod tests;