1use chrono::{DateTime, TimeZone, Utc};
4use ito_common::fs::{FileSystem, StdFs};
5use ito_common::match_::nearest_matches;
6use ito_common::paths;
7use ito_domain::changes::{
8 Change, ChangeRepository as DomainChangeRepository, ChangeStatus, ChangeSummary,
9 ChangeTargetResolution, ResolveTargetOptions, Spec, extract_module_id, parse_change_id,
10 parse_module_id,
11};
12use ito_domain::discovery;
13use ito_domain::errors::{DomainError, DomainResult};
14use ito_domain::tasks::TaskRepository as DomainTaskRepository;
15use regex::Regex;
16use std::collections::BTreeSet;
17use std::path::Path;
18
19use crate::front_matter;
20use crate::task_repository::FsTaskRepository;
21
22pub struct FsChangeRepository<'a, F: FileSystem = StdFs> {
24 ito_path: &'a Path,
25 task_repo: FsTaskRepository<'a>,
26 fs: F,
27}
28
29impl<'a> FsChangeRepository<'a, StdFs> {
30 pub fn new(ito_path: &'a Path) -> Self {
32 Self::with_fs(ito_path, StdFs)
33 }
34}
35
36impl<'a, F: FileSystem> FsChangeRepository<'a, F> {
37 pub fn with_fs(ito_path: &'a Path, fs: F) -> Self {
39 Self::with_task_repo(ito_path, FsTaskRepository::new(ito_path), fs)
40 }
41
42 pub fn with_task_repo(ito_path: &'a Path, task_repo: FsTaskRepository<'a>, fs: F) -> Self {
47 Self {
48 ito_path,
49 task_repo,
50 fs,
51 }
52 }
53
54 pub fn resolve_target(&self, input: &str) -> ChangeTargetResolution {
56 DomainChangeRepository::resolve_target(self, input)
57 }
58
59 pub fn resolve_target_with_options(
61 &self,
62 input: &str,
63 options: ResolveTargetOptions,
64 ) -> ChangeTargetResolution {
65 DomainChangeRepository::resolve_target_with_options(self, input, options)
66 }
67
68 pub fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
70 DomainChangeRepository::suggest_targets(self, input, max)
71 }
72
73 pub fn exists(&self, id: &str) -> bool {
75 DomainChangeRepository::exists(self, id)
76 }
77
78 pub fn get(&self, id: &str) -> DomainResult<Change> {
80 DomainChangeRepository::get(self, id)
81 }
82
83 pub fn list(&self) -> DomainResult<Vec<ChangeSummary>> {
85 DomainChangeRepository::list(self)
86 }
87
88 pub fn list_by_module(&self, module_id: &str) -> DomainResult<Vec<ChangeSummary>> {
90 DomainChangeRepository::list_by_module(self, module_id)
91 }
92
93 pub fn list_incomplete(&self) -> DomainResult<Vec<ChangeSummary>> {
95 DomainChangeRepository::list_incomplete(self)
96 }
97
98 pub fn list_complete(&self) -> DomainResult<Vec<ChangeSummary>> {
100 DomainChangeRepository::list_complete(self)
101 }
102
103 pub fn get_summary(&self, id: &str) -> DomainResult<ChangeSummary> {
105 DomainChangeRepository::get_summary(self, id)
106 }
107
108 fn changes_dir(&self) -> std::path::PathBuf {
109 paths::changes_dir(self.ito_path)
110 }
111
112 fn list_change_dir_names(&self, include_archived: bool) -> Vec<String> {
113 let mut out = discovery::list_change_dir_names(&self.fs, self.ito_path).unwrap_or_default();
114
115 if include_archived {
116 let archive_dir = self.changes_dir().join("archive");
117 let archived = discovery::list_dir_names(&self.fs, &archive_dir).unwrap_or_default();
118 out.extend(archived);
119 }
120
121 out.sort();
122 out.dedup();
123 out
124 }
125
126 fn split_canonical_change_id<'b>(&self, name: &'b str) -> Option<(String, String, &'b str)> {
127 let (module_id, change_num) = parse_change_id(name)?;
128 let slug = name.split_once('_').map(|(_id, s)| s).unwrap_or("");
129 Some((module_id, change_num, slug))
130 }
131
132 fn tokenize_query(&self, input: &str) -> Vec<String> {
133 input
134 .split_whitespace()
135 .map(|s| s.trim().to_lowercase())
136 .filter(|s| !s.is_empty())
137 .collect()
138 }
139
140 fn normalized_slug_text(&self, slug: &str) -> String {
141 let mut out = String::new();
142 for ch in slug.chars() {
143 if ch.is_ascii_alphanumeric() {
144 out.push(ch.to_ascii_lowercase());
145 } else {
146 out.push(' ');
147 }
148 }
149 out
150 }
151
152 fn slug_matches_tokens(&self, slug: &str, tokens: &[String]) -> bool {
153 if tokens.is_empty() {
154 return false;
155 }
156 let text = self.normalized_slug_text(slug);
157 for token in tokens {
158 if !text.contains(token) {
159 return false;
160 }
161 }
162 true
163 }
164
165 fn is_numeric_module_selector(&self, input: &str) -> bool {
166 let trimmed = input.trim();
167 !trimmed.is_empty() && trimmed.chars().all(|ch| ch.is_ascii_digit())
168 }
169
170 fn extract_two_numbers_as_change_id(&self, input: &str) -> Option<(String, String)> {
171 let re = Regex::new(r"\d+").ok()?;
172 let mut parts: Vec<&str> = Vec::new();
173 for m in re.find_iter(input) {
174 parts.push(m.as_str());
175 if parts.len() > 2 {
176 return None;
177 }
178 }
179 if parts.len() != 2 {
180 return None;
181 }
182 let parsed = format!("{}-{}", parts[0], parts[1]);
183 parse_change_id(&parsed)
184 }
185
186 fn resolve_unique_change_id(&self, input: &str) -> DomainResult<String> {
187 match self.resolve_target(input) {
188 ChangeTargetResolution::Unique(id) => Ok(id),
189 ChangeTargetResolution::Ambiguous(matches) => {
190 Err(DomainError::ambiguous_target("change", input, &matches))
191 }
192 ChangeTargetResolution::NotFound => Err(DomainError::not_found("change", input)),
193 }
194 }
195
196 fn read_optional_file(&self, path: &Path) -> DomainResult<Option<String>> {
197 if self.fs.is_file(path) {
198 let content = self
199 .fs
200 .read_to_string(path)
201 .map_err(|source| DomainError::io("reading optional file", source))?;
202 Ok(Some(content))
203 } else {
204 Ok(None)
205 }
206 }
207
208 fn load_specs(&self, change_path: &Path) -> DomainResult<Vec<Spec>> {
209 let specs_dir = change_path.join("specs");
210 if !self.fs.is_dir(&specs_dir) {
211 return Ok(Vec::new());
212 }
213
214 let mut specs = Vec::new();
215 for name in discovery::list_dir_names(&self.fs, &specs_dir)? {
216 let spec_file = specs_dir.join(&name).join("spec.md");
217 if self.fs.is_file(&spec_file) {
218 let content = self
219 .fs
220 .read_to_string(&spec_file)
221 .map_err(|source| DomainError::io("reading spec file", source))?;
222 specs.push(Spec { name, content });
223 }
224 }
225
226 specs.sort_by(|a, b| a.name.cmp(&b.name));
227 Ok(specs)
228 }
229
230 fn has_specs(&self, change_path: &Path) -> bool {
231 let specs_dir = change_path.join("specs");
232 if !self.fs.is_dir(&specs_dir) {
233 return false;
234 }
235
236 discovery::list_dir_names(&self.fs, &specs_dir)
237 .map(|entries| {
238 entries
239 .into_iter()
240 .any(|name| self.fs.is_file(&specs_dir.join(name).join("spec.md")))
241 })
242 .unwrap_or(false)
243 }
244
245 fn validate_artifact_front_matter(
252 &self,
253 content: &str,
254 expected_change_id: &str,
255 ) -> DomainResult<()> {
256 let parsed = front_matter::parse(content).map_err(|e| {
257 DomainError::io(
258 "parsing front matter",
259 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
260 )
261 })?;
262
263 let Some(fm) = &parsed.front_matter else {
264 return Ok(());
265 };
266
267 front_matter::validate_id("change_id", fm.change_id.as_deref(), expected_change_id)
269 .map_err(|e| {
270 DomainError::io(
271 "front matter validation",
272 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
273 )
274 })?;
275
276 front_matter::validate_integrity(fm, &parsed.body).map_err(|e| {
278 DomainError::io(
279 "front matter integrity",
280 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
281 )
282 })?;
283
284 Ok(())
285 }
286
287 fn get_last_modified(&self, change_path: &Path) -> DomainResult<DateTime<Utc>> {
288 let mut latest = Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap();
289
290 for entry in walkdir::WalkDir::new(change_path)
291 .into_iter()
292 .filter_map(|e| e.ok())
293 {
294 if let Ok(metadata) = entry.metadata()
295 && let Ok(modified) = metadata.modified()
296 {
297 let dt: DateTime<Utc> = modified.into();
298 if dt > latest {
299 latest = dt;
300 }
301 }
302 }
303
304 Ok(latest)
305 }
306}
307
308impl<'a, F: FileSystem> DomainChangeRepository for FsChangeRepository<'a, F> {
309 fn resolve_target_with_options(
310 &self,
311 input: &str,
312 options: ResolveTargetOptions,
313 ) -> ChangeTargetResolution {
314 let names = self.list_change_dir_names(options.include_archived);
315 if names.is_empty() {
316 return ChangeTargetResolution::NotFound;
317 }
318
319 let input = input.trim();
320 if input.is_empty() {
321 return ChangeTargetResolution::NotFound;
322 }
323
324 if names.iter().any(|name| name == input) {
325 return ChangeTargetResolution::Unique(input.to_string());
326 }
327
328 let mut numeric_matches: BTreeSet<String> = BTreeSet::new();
329 let numeric_selector =
330 parse_change_id(input).or_else(|| self.extract_two_numbers_as_change_id(input));
331 if let Some((module_id, change_num)) = numeric_selector {
332 let numeric_prefix = format!("{module_id}-{change_num}");
333 let with_separator = format!("{numeric_prefix}_");
334 for name in &names {
335 if name == &numeric_prefix || name.starts_with(&with_separator) {
336 numeric_matches.insert(name.clone());
337 }
338 }
339 if !numeric_matches.is_empty() {
340 let numeric_matches: Vec<String> = numeric_matches.into_iter().collect();
341 if numeric_matches.len() == 1 {
342 return ChangeTargetResolution::Unique(numeric_matches[0].clone());
343 }
344 return ChangeTargetResolution::Ambiguous(numeric_matches);
345 }
346 }
347
348 if let Some((module, query)) = input.split_once(':') {
349 let query = query.trim();
350 if !query.is_empty() {
351 let module_id = parse_module_id(module);
352 let tokens = self.tokenize_query(query);
353 let mut scoped_matches: BTreeSet<String> = BTreeSet::new();
354 for name in &names {
355 let Some((name_module, _name_change, slug)) =
356 self.split_canonical_change_id(name)
357 else {
358 continue;
359 };
360 if name_module != module_id {
361 continue;
362 }
363 if self.slug_matches_tokens(slug, &tokens) {
364 scoped_matches.insert(name.clone());
365 }
366 }
367
368 if scoped_matches.is_empty() {
369 return ChangeTargetResolution::NotFound;
370 }
371 let scoped_matches: Vec<String> = scoped_matches.into_iter().collect();
372 if scoped_matches.len() == 1 {
373 return ChangeTargetResolution::Unique(scoped_matches[0].clone());
374 }
375 return ChangeTargetResolution::Ambiguous(scoped_matches);
376 }
377 }
378
379 if self.is_numeric_module_selector(input) {
380 let module_id = parse_module_id(input);
381 let mut module_matches: BTreeSet<String> = BTreeSet::new();
382 for name in &names {
383 let Some((name_module, _name_change, _slug)) = self.split_canonical_change_id(name)
384 else {
385 continue;
386 };
387 if name_module == module_id {
388 module_matches.insert(name.clone());
389 }
390 }
391
392 if !module_matches.is_empty() {
393 let module_matches: Vec<String> = module_matches.into_iter().collect();
394 if module_matches.len() == 1 {
395 return ChangeTargetResolution::Unique(module_matches[0].clone());
396 }
397 return ChangeTargetResolution::Ambiguous(module_matches);
398 }
399 }
400
401 let mut matches: BTreeSet<String> = BTreeSet::new();
402 for name in &names {
403 if name.starts_with(input) {
404 matches.insert(name.clone());
405 }
406 }
407
408 if matches.is_empty() {
409 let tokens = self.tokenize_query(input);
410 for name in &names {
411 let Some((_module, _change, slug)) = self.split_canonical_change_id(name) else {
412 continue;
413 };
414 if self.slug_matches_tokens(slug, &tokens) {
415 matches.insert(name.clone());
416 }
417 }
418 }
419
420 if matches.is_empty() {
421 return ChangeTargetResolution::NotFound;
422 }
423
424 let matches: Vec<String> = matches.into_iter().collect();
425 if matches.len() == 1 {
426 return ChangeTargetResolution::Unique(matches[0].clone());
427 }
428
429 ChangeTargetResolution::Ambiguous(matches)
430 }
431
432 fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
433 let input = input.trim().to_lowercase();
434 if input.is_empty() || max == 0 {
435 return Vec::new();
436 }
437
438 let names = self.list_change_dir_names(false);
439 let canonical_names: Vec<String> = names
440 .iter()
441 .filter_map(|name| {
442 self.split_canonical_change_id(name)
443 .map(|(_module, _change, _slug)| name.clone())
444 })
445 .collect();
446 let mut scored: Vec<(usize, String)> = Vec::new();
447 let tokens = self.tokenize_query(&input);
448
449 for name in &canonical_names {
450 let lower = name.to_lowercase();
451 let mut score = 0;
452
453 if lower.starts_with(&input) {
454 score = score.max(100);
455 }
456 if lower.contains(&input) {
457 score = score.max(80);
458 }
459
460 let Some((_module, _change, slug)) = self.split_canonical_change_id(name) else {
461 continue;
462 };
463 if !tokens.is_empty() && self.slug_matches_tokens(slug, &tokens) {
464 score = score.max(70);
465 }
466
467 if let Some((module_id, change_num)) = parse_change_id(&input) {
468 let numeric_prefix = format!("{module_id}-{change_num}");
469 if name.starts_with(&numeric_prefix) {
470 score = score.max(95);
471 }
472 }
473
474 if score > 0 {
475 scored.push((score, name.clone()));
476 }
477 }
478
479 scored.sort_by(|(a_score, a_name), (b_score, b_name)| {
480 b_score.cmp(a_score).then_with(|| a_name.cmp(b_name))
481 });
482
483 let mut out: Vec<String> = scored
484 .into_iter()
485 .map(|(_score, name)| name)
486 .take(max)
487 .collect();
488
489 if out.len() < max {
490 let nearest = nearest_matches(&input, &canonical_names, max * 2);
491 for candidate in nearest {
492 if out.iter().any(|existing| existing == &candidate) {
493 continue;
494 }
495 out.push(candidate);
496 if out.len() == max {
497 break;
498 }
499 }
500 }
501
502 out
503 }
504
505 fn exists(&self, id: &str) -> bool {
506 let resolution = self.resolve_target(id);
507 match resolution {
508 ChangeTargetResolution::Unique(_) => true,
509 ChangeTargetResolution::Ambiguous(_) => false,
510 ChangeTargetResolution::NotFound => false,
511 }
512 }
513
514 fn get(&self, id: &str) -> DomainResult<Change> {
515 let actual_id = self.resolve_unique_change_id(id)?;
516 let path = self.changes_dir().join(&actual_id);
517
518 let proposal = self.read_optional_file(&path.join("proposal.md"))?;
519 let design = self.read_optional_file(&path.join("design.md"))?;
520
521 if let Some(content) = &proposal {
524 self.validate_artifact_front_matter(content, &actual_id)?;
525 }
526 if let Some(content) = &design {
527 self.validate_artifact_front_matter(content, &actual_id)?;
528 }
529
530 let specs = self.load_specs(&path)?;
531 let tasks = self.task_repo.load_tasks(&actual_id)?;
532 let last_modified = self.get_last_modified(&path)?;
533
534 Ok(Change {
535 id: actual_id.clone(),
536 module_id: extract_module_id(&actual_id),
537 path,
538 proposal,
539 design,
540 specs,
541 tasks,
542 last_modified,
543 })
544 }
545
546 fn list(&self) -> DomainResult<Vec<ChangeSummary>> {
547 let changes_dir = self.changes_dir();
548 if !self.fs.is_dir(&changes_dir) {
549 return Ok(Vec::new());
550 }
551
552 let mut summaries = Vec::new();
553 for name in discovery::list_change_dir_names(&self.fs, self.ito_path)? {
554 let summary = self.get_summary(&name)?;
555 summaries.push(summary);
556 }
557
558 summaries.sort_by(|a, b| a.id.cmp(&b.id));
559 Ok(summaries)
560 }
561
562 fn list_by_module(&self, module_id: &str) -> DomainResult<Vec<ChangeSummary>> {
563 let normalized_id = parse_module_id(module_id);
564 let all = self.list()?;
565 Ok(all
566 .into_iter()
567 .filter(|c| c.module_id.as_deref() == Some(&normalized_id))
568 .collect())
569 }
570
571 fn list_incomplete(&self) -> DomainResult<Vec<ChangeSummary>> {
572 let all = self.list()?;
573 Ok(all
574 .into_iter()
575 .filter(|c| c.status() == ChangeStatus::InProgress)
576 .collect())
577 }
578
579 fn list_complete(&self) -> DomainResult<Vec<ChangeSummary>> {
580 let all = self.list()?;
581 Ok(all
582 .into_iter()
583 .filter(|c| c.status() == ChangeStatus::Complete)
584 .collect())
585 }
586
587 fn get_summary(&self, id: &str) -> DomainResult<ChangeSummary> {
588 let actual_id = self.resolve_unique_change_id(id)?;
589 let path = self.changes_dir().join(&actual_id);
590
591 let progress = self.task_repo.get_progress(&actual_id)?;
592 let completed_tasks = progress.complete as u32;
593 let shelved_tasks = progress.shelved as u32;
594 let in_progress_tasks = progress.in_progress as u32;
595 let pending_tasks = progress.pending as u32;
596 let total_tasks = progress.total as u32;
597 let last_modified = self.get_last_modified(&path)?;
598
599 let has_proposal = self.fs.is_file(&path.join("proposal.md"));
600 let has_design = self.fs.is_file(&path.join("design.md"));
601 let has_specs = self.has_specs(&path);
602 let has_tasks = total_tasks > 0;
603 let module_id = extract_module_id(&actual_id);
604
605 Ok(ChangeSummary {
606 id: actual_id,
607 module_id,
608 completed_tasks,
609 shelved_tasks,
610 in_progress_tasks,
611 pending_tasks,
612 total_tasks,
613 last_modified,
614 has_proposal,
615 has_design,
616 has_specs,
617 has_tasks,
618 })
619 }
620}
621
622pub type ChangeRepository<'a> = FsChangeRepository<'a, StdFs>;
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use std::fs;
629 use tempfile::TempDir;
630
631 fn setup_test_ito(tmp: &TempDir) -> std::path::PathBuf {
632 let ito_path = tmp.path().join(".ito");
633 fs::create_dir_all(ito_path.join("changes")).unwrap();
634 ito_path
635 }
636
637 fn create_change(ito_path: &Path, id: &str, with_tasks: bool) {
638 let change_dir = ito_path.join("changes").join(id);
639 fs::create_dir_all(&change_dir).unwrap();
640 fs::write(change_dir.join("proposal.md"), "# Proposal\n").unwrap();
641 fs::write(change_dir.join("design.md"), "# Design\n").unwrap();
642
643 let specs_dir = change_dir.join("specs").join("test-spec");
644 fs::create_dir_all(&specs_dir).unwrap();
645 fs::write(specs_dir.join("spec.md"), "## Requirements\n").unwrap();
646
647 if with_tasks {
648 fs::write(
649 change_dir.join("tasks.md"),
650 "# Tasks\n- [x] Task 1\n- [ ] Task 2\n",
651 )
652 .unwrap();
653 }
654 }
655
656 fn create_archived_change(ito_path: &Path, id: &str) {
657 let archive_dir = ito_path.join("changes").join("archive").join(id);
658 fs::create_dir_all(&archive_dir).unwrap();
659 fs::write(archive_dir.join("proposal.md"), "# Archived\n").unwrap();
660 }
661
662 #[test]
663 fn exists_and_get_work() {
664 let tmp = TempDir::new().unwrap();
665 let ito_path = setup_test_ito(&tmp);
666 create_change(&ito_path, "005-01_test", true);
667
668 let repo = FsChangeRepository::new(&ito_path);
669 assert!(repo.exists("005-01_test"));
670 assert!(!repo.exists("999-99_missing"));
671
672 let change = repo.get("005-01_test").unwrap();
673 assert_eq!(change.id, "005-01_test");
674 assert_eq!(change.task_progress(), (1, 2));
675 assert!(change.proposal.is_some());
676 assert!(change.design.is_some());
677 assert_eq!(change.specs.len(), 1);
678 }
679
680 #[test]
681 fn list_skips_archive_dir() {
682 let tmp = TempDir::new().unwrap();
683 let ito_path = setup_test_ito(&tmp);
684 create_change(&ito_path, "005-01_first", true);
685 create_archived_change(&ito_path, "005-99_old");
686
687 let repo = FsChangeRepository::new(&ito_path);
688 let changes = repo.list().unwrap();
689
690 assert_eq!(changes.len(), 1);
691 assert_eq!(changes[0].id, "005-01_first");
692 }
693
694 #[test]
695 fn resolve_target_reports_ambiguity() {
696 let tmp = TempDir::new().unwrap();
697 let ito_path = setup_test_ito(&tmp);
698 create_change(&ito_path, "001-12_first-change", false);
699 create_change(&ito_path, "001-12_follow-up", false);
700
701 let repo = FsChangeRepository::new(&ito_path);
702 assert_eq!(
703 repo.resolve_target("1-12"),
704 ChangeTargetResolution::Ambiguous(vec![
705 "001-12_first-change".to_string(),
706 "001-12_follow-up".to_string(),
707 ])
708 );
709 }
710
711 #[test]
712 fn resolve_target_module_scoped_query() {
713 let tmp = TempDir::new().unwrap();
714 let ito_path = setup_test_ito(&tmp);
715 create_change(&ito_path, "001-12_setup-wizard", false);
716 create_change(&ito_path, "002-12_setup-wizard", false);
717
718 let repo = FsChangeRepository::new(&ito_path);
719 assert_eq!(
720 repo.resolve_target("1:setup"),
721 ChangeTargetResolution::Unique("001-12_setup-wizard".to_string())
722 );
723 assert_eq!(
724 repo.resolve_target("2:setup"),
725 ChangeTargetResolution::Unique("002-12_setup-wizard".to_string())
726 );
727 }
728
729 #[test]
730 fn resolve_target_includes_archive_when_requested() {
731 let tmp = TempDir::new().unwrap();
732 let ito_path = setup_test_ito(&tmp);
733 create_archived_change(&ito_path, "001-12_setup-wizard");
734
735 let repo = FsChangeRepository::new(&ito_path);
736 assert_eq!(
737 repo.resolve_target("1-12"),
738 ChangeTargetResolution::NotFound
739 );
740
741 assert_eq!(
742 repo.resolve_target_with_options(
743 "1-12",
744 ResolveTargetOptions {
745 include_archived: true,
746 }
747 ),
748 ChangeTargetResolution::Unique("001-12_setup-wizard".to_string())
749 );
750 }
751
752 #[test]
753 fn suggest_targets_prioritizes_slug_matches() {
754 let tmp = TempDir::new().unwrap();
755 let ito_path = setup_test_ito(&tmp);
756 create_change(&ito_path, "001-12_setup-wizard", false);
757 create_change(&ito_path, "001-13_setup-service", false);
758 create_change(&ito_path, "002-01_other-work", false);
759
760 let repo = FsChangeRepository::new(&ito_path);
761 let suggestions = repo.suggest_targets("setup", 2);
762 assert_eq!(
763 suggestions,
764 vec!["001-12_setup-wizard", "001-13_setup-service"]
765 );
766 }
767}