1use std::path::Path;
39
40use tracing::warn;
41
42use super::super::manifest::{load_manifest, Manifest, ManifestEntry};
43use super::super::project::resolve_project_root;
44use super::super::resolve::packages_dir;
45use super::super::source::PackageSource;
46use super::super::AppService;
47use super::repair::{
48 collect_path_missing, collect_unattached_dangling_symlinks, symlink_dangling_suggestion,
49 ProjectPathSource,
50};
51
52#[derive(Debug)]
54enum DoctorOutcome {
55 Healthy,
57 SymlinkDangling { reason: String, suggestion: String },
59 InstalledMissing { reason: String, suggestion: String },
61 IncompletePkg {
64 missing_subs: Vec<String>,
65 suggestion: String,
66 },
67}
68
69#[derive(Default)]
71struct DoctorBuckets {
72 healthy: Vec<serde_json::Value>,
73 installed_missing: Vec<serde_json::Value>,
74 symlink_dangling: Vec<serde_json::Value>,
75 path_missing: Vec<serde_json::Value>,
76 incomplete_pkg: Vec<serde_json::Value>,
77}
78
79impl DoctorBuckets {
80 fn any_matched(&self) -> bool {
81 !self.healthy.is_empty()
82 || !self.installed_missing.is_empty()
83 || !self.symlink_dangling.is_empty()
84 || !self.path_missing.is_empty()
85 || !self.incomplete_pkg.is_empty()
86 }
87
88 fn into_json(self) -> String {
89 serde_json::json!({
93 "healthy": self.healthy,
94 "incomplete_pkg": self.incomplete_pkg,
95 "installed_missing": self.installed_missing,
96 "symlink_dangling": self.symlink_dangling,
97 "path_missing": self.path_missing,
98 })
99 .to_string()
100 }
101}
102
103fn extract_required_subs(lua_src: &str, pkg_name: &str) -> Vec<String> {
117 let mut subs = Vec::new();
118 let prefix = format!("{pkg_name}.");
119 let mut remaining = lua_src;
120
121 while let Some(pos) = remaining.find("require") {
122 remaining = &remaining[pos + "require".len()..];
123
124 let trimmed = remaining.trim_start_matches([' ', '\t']);
126
127 if !trimmed.starts_with('(') {
129 continue;
130 }
131 let after_paren = &trimmed[1..];
132 let after_paren = after_paren.trim_start_matches([' ', '\t']);
133
134 let quote = match after_paren.chars().next() {
136 Some(q @ '"') | Some(q @ '\'') => q,
137 _ => continue,
138 };
139 let content = &after_paren[1..];
140 let end = match content.find(quote) {
141 Some(i) => i,
142 None => continue,
143 };
144 let module = &content[..end];
145
146 if let Some(sub) = module.strip_prefix(&prefix) {
147 if !sub.is_empty() && !sub.contains('.') {
148 subs.push(sub.to_string());
150 }
151 }
152 }
153
154 subs.sort();
155 subs.dedup();
156 subs
157}
158
159fn incomplete_pkg_suggestion(name: &str, is_symlink: bool) -> String {
162 if is_symlink {
163 format!("Re-run alc_pkg_link <path> to re-link {name:?} with the complete source directory")
164 } else {
165 format!(
166 "Run alc_pkg_install --force {name:?} to reinstall {name:?} with all submodule files"
167 )
168 }
169}
170
171fn installed_missing_suggestion(name: &str, entry_source: &PackageSource) -> String {
182 match entry_source {
183 PackageSource::Bundled { .. } => {
184 "alc_init (reinstalls bundled packages from the algocline binary)".to_string()
185 }
186 PackageSource::Path { path } => {
187 format!("alc_pkg_install({path:?}) to reinstall {name:?} from local path")
188 }
189 PackageSource::Git { url, .. } => {
190 format!("alc_pkg_install({url:?}) to reinstall {name:?} from Git")
191 }
192 PackageSource::Installed => {
193 format!(
194 "alc_pkg_install <path-or-url> to re-record source for {name:?} \
195 (legacy 'installed' marker carries no path)"
196 )
197 }
198 PackageSource::Unknown => {
199 format!(
200 "alc_hub_reindex then alc_pkg_install <path-or-url> for {name:?} \
201 (source unknown — legacy entry)"
202 )
203 }
204 }
205}
206
207fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
209 match outcome {
210 DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
211 "name": name,
212 })),
213 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
214 buckets.symlink_dangling.push(serde_json::json!({
215 "name": name,
216 "kind": "symlink_dangling",
217 "reason": reason,
218 "suggestion": suggestion,
219 }))
220 }
221 DoctorOutcome::InstalledMissing { reason, suggestion } => {
222 buckets.installed_missing.push(serde_json::json!({
223 "name": name,
224 "kind": "installed_missing",
225 "reason": reason,
226 "suggestion": suggestion,
227 }))
228 }
229 DoctorOutcome::IncompletePkg {
230 missing_subs,
231 suggestion,
232 } => buckets.incomplete_pkg.push(serde_json::json!({
233 "name": name,
234 "kind": "incomplete_pkg",
235 "missing_subs": missing_subs,
236 "suggestion": suggestion,
237 })),
238 }
239}
240
241fn check_incomplete(name: &str, dest: &Path, is_symlink: bool) -> Option<DoctorOutcome> {
253 let init_lua = dest.join("init.lua");
254 let src = match std::fs::read_to_string(&init_lua) {
255 Ok(s) => s,
256 Err(e) => {
257 warn!(
258 error = %e,
259 path = %init_lua.display(),
260 "could not read init.lua for incomplete check; skipping"
261 );
262 return None;
263 }
264 };
265
266 let required_subs = extract_required_subs(&src, name);
267 if required_subs.is_empty() {
268 return None;
269 }
270
271 let missing: Vec<String> = required_subs
272 .into_iter()
273 .filter(|sub| {
274 let as_file = dest.join(format!("{sub}.lua"));
275 let as_dir = dest.join(sub).join("init.lua");
276 !as_file.exists() && !as_dir.exists()
277 })
278 .collect();
279
280 if missing.is_empty() {
281 return None;
282 }
283
284 Some(DoctorOutcome::IncompletePkg {
285 missing_subs: missing,
286 suggestion: incomplete_pkg_suggestion(name, is_symlink),
287 })
288}
289
290fn classify_installed(name: &str, entry: &ManifestEntry, pkg_dir: &Path) -> DoctorOutcome {
298 let dest = pkg_dir.join(name);
299
300 let is_symlink = dest
301 .symlink_metadata()
302 .map(|m| m.file_type().is_symlink())
303 .unwrap_or(false);
304 if is_symlink {
305 let target_alive = match dest.try_exists() {
307 Ok(v) => v,
308 Err(e) => {
309 warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
310 false
311 }
312 };
313 if target_alive {
314 if let Some(incomplete) = check_incomplete(name, &dest, true) {
316 return incomplete;
317 }
318 return DoctorOutcome::Healthy;
319 }
320 let link_target = match dest.read_link() {
321 Ok(t) => t.display().to_string(),
322 Err(e) => {
323 warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
324 "<unknown>".to_string()
325 }
326 };
327 return DoctorOutcome::SymlinkDangling {
328 reason: format!("symlink target missing: {link_target}"),
329 suggestion: symlink_dangling_suggestion(name),
330 };
331 }
332
333 if dest.exists() {
334 if let Some(incomplete) = check_incomplete(name, &dest, false) {
336 return incomplete;
337 }
338 return DoctorOutcome::Healthy;
339 }
340
341 DoctorOutcome::InstalledMissing {
342 reason: format!("installed directory missing: {}", dest.display()),
343 suggestion: installed_missing_suggestion(name, &entry.source),
344 }
345}
346
347fn run_manifest_pass(
351 manifest: &Manifest,
352 target_filter: Option<&str>,
353 pkg_dir: &Path,
354 buckets: &mut DoctorBuckets,
355) {
356 if let Some(target) = target_filter {
357 if let Some(entry) = manifest.packages.get(target) {
358 let outcome = classify_installed(target, entry, pkg_dir);
359 push_doctor_outcome(target, outcome, buckets);
360 }
361 return;
362 }
363 for (pkg_name, entry) in &manifest.packages {
364 let outcome = classify_installed(pkg_name, entry, pkg_dir);
365 push_doctor_outcome(pkg_name, outcome, buckets);
366 }
367}
368
369fn run_unattached_symlink_pass(
373 pkg_dir: &Path,
374 target_filter: Option<&str>,
375 manifest: &Manifest,
376 buckets: &mut DoctorBuckets,
377) {
378 let mut scratch: Vec<serde_json::Value> = Vec::new();
379 collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
380 buckets.symlink_dangling.extend(scratch);
381}
382
383fn run_path_missing_pass(
387 resolved_root: Option<&Path>,
388 target_filter: Option<&str>,
389 buckets: &mut DoctorBuckets,
390) {
391 let Some(root) = resolved_root else {
392 return;
393 };
394 let mut scratch: Vec<serde_json::Value> = Vec::new();
395 collect_path_missing(
396 root,
397 target_filter,
398 "project",
399 &mut scratch,
400 ProjectPathSource::Toml,
401 );
402 collect_path_missing(
403 root,
404 target_filter,
405 "variant",
406 &mut scratch,
407 ProjectPathSource::Local,
408 );
409 buckets.path_missing.extend(scratch);
410}
411
412impl AppService {
413 pub async fn pkg_doctor(
431 &self,
432 name: Option<String>,
433 project_root: Option<String>,
434 ) -> Result<String, String> {
435 let app_dir = self.log_config.app_dir();
436 let manifest = load_manifest(&app_dir)?;
437 let pkg_dir = packages_dir(&app_dir);
438 let resolved_root = resolve_project_root(project_root.as_deref());
439 let target_filter = name.as_deref();
440
441 let mut buckets = DoctorBuckets::default();
442 run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets);
443 run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
444 run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
445
446 if let Some(target) = target_filter {
447 if !buckets.any_matched() {
448 return Err(format!(
449 "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
450 ));
451 }
452 }
453
454 Ok(buckets.into_json())
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use std::path::PathBuf;
462
463 fn mk_entry(source: &str) -> ManifestEntry {
467 ManifestEntry {
468 version: None,
469 source: PackageSource::Path {
470 path: source.to_string(),
471 },
472 installed_at: "2026-01-01T00:00:00Z".to_string(),
473 updated_at: "2026-01-01T00:00:00Z".to_string(),
474 }
475 }
476
477 #[test]
478 fn classify_installed_healthy_dir() {
479 let tmp = tempfile::tempdir().unwrap();
480 let pkg_dir = tmp.path();
481 std::fs::create_dir(pkg_dir.join("p")).unwrap();
482
483 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
484 assert!(matches!(outcome, DoctorOutcome::Healthy));
485 }
486
487 #[test]
488 fn classify_installed_missing_dir() {
489 let tmp = tempfile::tempdir().unwrap();
490 let pkg_dir = tmp.path();
491
492 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
493 match outcome {
494 DoctorOutcome::InstalledMissing { reason, suggestion } => {
495 assert!(
496 reason.contains("installed directory missing"),
497 "reason = {reason}"
498 );
499 assert!(
500 suggestion.contains("alc_pkg_install"),
501 "suggestion = {suggestion}"
502 );
503 assert!(
504 suggestion.contains("/src/p"),
505 "suggestion carries source: {suggestion}"
506 );
507 }
508 _ => panic!("expected InstalledMissing"),
509 }
510 }
511
512 #[test]
513 #[cfg(unix)]
514 fn classify_installed_symlink_dangling() {
515 use std::os::unix::fs::symlink;
516
517 let tmp = tempfile::tempdir().unwrap();
518 let pkg_dir = tmp.path();
519 let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
520 symlink(&dangling_target, pkg_dir.join("p")).unwrap();
521
522 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
523 match outcome {
524 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
525 assert!(reason.contains("symlink target missing"), "{reason}");
526 assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
527 }
528 _ => panic!("expected SymlinkDangling"),
529 }
530 }
531
532 #[test]
533 #[cfg(unix)]
534 fn classify_installed_symlink_alive() {
535 use std::os::unix::fs::symlink;
536
537 let tmp = tempfile::tempdir().unwrap();
538 let real_target = tmp.path().join("real_target_dir");
539 std::fs::create_dir(&real_target).unwrap();
540
541 let pkg_dir = tmp.path().join("pkgs");
542 std::fs::create_dir(&pkg_dir).unwrap();
543 symlink(&real_target, pkg_dir.join("q")).unwrap();
544
545 let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir);
546 assert!(matches!(outcome, DoctorOutcome::Healthy));
547 }
548
549 #[test]
550 fn buckets_into_json_emits_all_five_keys() {
551 let mut b = DoctorBuckets::default();
557 b.healthy.push(serde_json::json!({"name": "h"}));
558 b.installed_missing
559 .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
560 b.symlink_dangling
561 .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
562 b.path_missing
563 .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
564 b.incomplete_pkg
565 .push(serde_json::json!({"name": "c", "kind": "incomplete_pkg"}));
566
567 let out = b.into_json();
568 let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
569 let obj = parsed.as_object().expect("JSON object");
570 assert!(obj.contains_key("healthy"));
571 assert!(obj.contains_key("installed_missing"));
572 assert!(obj.contains_key("symlink_dangling"));
573 assert!(obj.contains_key("path_missing"));
574 assert!(obj.contains_key("incomplete_pkg"));
575 assert_eq!(obj.len(), 5, "exactly five top-level buckets: {out}");
576
577 assert_eq!(obj["healthy"][0]["name"], "h");
578 assert_eq!(obj["installed_missing"][0]["name"], "i");
579 assert_eq!(obj["symlink_dangling"][0]["name"], "s");
580 assert_eq!(obj["path_missing"][0]["name"], "p");
581 assert_eq!(obj["incomplete_pkg"][0]["name"], "c");
582 }
583
584 #[test]
585 fn any_matched_tracks_all_buckets() {
586 let mut b = DoctorBuckets::default();
587 assert!(!b.any_matched());
588 b.healthy.push(serde_json::json!({"name": "h"}));
589 assert!(b.any_matched());
590
591 let mut b = DoctorBuckets::default();
592 b.installed_missing.push(serde_json::json!({}));
593 assert!(b.any_matched());
594
595 let mut b = DoctorBuckets::default();
596 b.symlink_dangling.push(serde_json::json!({}));
597 assert!(b.any_matched());
598
599 let mut b = DoctorBuckets::default();
600 b.path_missing.push(serde_json::json!({}));
601 assert!(b.any_matched());
602
603 let mut b = DoctorBuckets::default();
604 b.incomplete_pkg.push(serde_json::json!({}));
605 assert!(b.any_matched());
606 }
607
608 #[test]
609 fn installed_missing_suggestion_shape() {
610 let git = PackageSource::Git {
611 url: "github.com/foo/bar".to_string(),
612 rev: None,
613 };
614 let s = installed_missing_suggestion("ucb", &git);
615 assert!(s.contains("alc_pkg_install"), "{s}");
616 assert!(s.contains("\"ucb\""), "{s}");
617 assert!(s.contains("github.com/foo/bar"), "{s}");
618 }
619
620 #[test]
625 fn installed_missing_suggestion_routes_bundled_to_alc_init() {
626 let bundled = PackageSource::Bundled { collection: None };
627 let s = installed_missing_suggestion("ucb", &bundled);
628 assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
629 assert!(
630 !s.contains("alc_pkg_install"),
631 "bundled must NOT suggest alc_pkg_install: {s}"
632 );
633 }
634
635 #[test]
642 fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
643 let local = PackageSource::Path {
644 path: "/abs/path/to/src".to_string(),
645 };
646 let s = installed_missing_suggestion("local_pkg", &local);
647 assert!(s.contains("alc_pkg_install"), "{s}");
648 assert!(s.contains("/abs/path/to/src"), "{s}");
649 }
650
651 #[test]
655 fn installed_missing_suggestion_routes_unknown_to_reindex() {
656 let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
657 assert!(
658 s.contains("alc_hub_reindex"),
659 "Unknown must suggest alc_hub_reindex: {s}"
660 );
661 }
662
663 #[test]
666 fn extract_subs_double_quote() {
667 let src = r#"
668local M = {}
669local check = require("mypkg.check")
670local t = require("mypkg.t")
671return M
672"#;
673 let subs = extract_required_subs(src, "mypkg");
674 assert_eq!(subs, vec!["check", "t"]);
675 }
676
677 #[test]
678 fn extract_subs_single_quote() {
679 let src = "local x = require('mypkg.sub')";
680 let subs = extract_required_subs(src, "mypkg");
681 assert_eq!(subs, vec!["sub"]);
682 }
683
684 #[test]
685 fn extract_subs_ignores_other_packages() {
686 let src = r#"
687local x = require("other.sub")
688local y = require("mypkg.mine")
689"#;
690 let subs = extract_required_subs(src, "mypkg");
691 assert_eq!(subs, vec!["mine"]);
692 }
693
694 #[test]
695 fn extract_subs_deduplicates() {
696 let src = r#"
697local a = require("mypkg.check")
698local b = require("mypkg.check")
699"#;
700 let subs = extract_required_subs(src, "mypkg");
701 assert_eq!(subs, vec!["check"]);
702 }
703
704 #[test]
705 fn extract_subs_ignores_dynamic_require() {
706 let src = r#"local x = require(mod_name)"#;
708 let subs = extract_required_subs(src, "mypkg");
709 assert!(subs.is_empty(), "dynamic require must be ignored: {subs:?}");
710 }
711
712 #[test]
713 fn extract_subs_ignores_nested_dots() {
714 let src = r#"local x = require("mypkg.sub.deeper")"#;
716 let subs = extract_required_subs(src, "mypkg");
717 assert!(
718 subs.is_empty(),
719 "nested dotted require must be ignored: {subs:?}"
720 );
721 }
722
723 #[test]
724 fn extract_subs_empty_for_no_require() {
725 let src = r#"local M = {} return M"#;
726 let subs = extract_required_subs(src, "mypkg");
727 assert!(subs.is_empty());
728 }
729
730 #[test]
733 fn check_incomplete_returns_none_when_all_subs_present_as_lua() {
734 let tmp = tempfile::tempdir().unwrap();
735 let dest = tmp.path().join("mypkg");
736 std::fs::create_dir(&dest).unwrap();
737 std::fs::write(
738 dest.join("init.lua"),
739 r#"local c = require("mypkg.check") return {}"#,
740 )
741 .unwrap();
742 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
743
744 assert!(check_incomplete("mypkg", &dest, false).is_none());
745 }
746
747 #[test]
748 fn check_incomplete_returns_none_when_sub_is_dir_init() {
749 let tmp = tempfile::tempdir().unwrap();
750 let dest = tmp.path().join("mypkg");
751 std::fs::create_dir(&dest).unwrap();
752 std::fs::write(
753 dest.join("init.lua"),
754 r#"local c = require("mypkg.sub") return {}"#,
755 )
756 .unwrap();
757 std::fs::create_dir(dest.join("sub")).unwrap();
759 std::fs::write(dest.join("sub").join("init.lua"), "return {}").unwrap();
760
761 assert!(check_incomplete("mypkg", &dest, false).is_none());
762 }
763
764 #[test]
765 fn check_incomplete_detects_missing_sub() {
766 let tmp = tempfile::tempdir().unwrap();
767 let dest = tmp.path().join("mypkg");
768 std::fs::create_dir(&dest).unwrap();
769 std::fs::write(
770 dest.join("init.lua"),
771 r#"
772local check = require("mypkg.check")
773local t = require("mypkg.t")
774return {}
775"#,
776 )
777 .unwrap();
778 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
780
781 let outcome = check_incomplete("mypkg", &dest, false).expect("should detect incomplete");
782 match outcome {
783 DoctorOutcome::IncompletePkg {
784 missing_subs,
785 suggestion,
786 } => {
787 assert_eq!(missing_subs, vec!["t"], "missing_subs: {missing_subs:?}");
788 assert!(
789 suggestion.contains("alc_pkg_install"),
790 "non-symlink suggestion: {suggestion}"
791 );
792 }
793 _ => panic!("expected IncompletePkg"),
794 }
795 }
796
797 #[test]
798 fn check_incomplete_suggestion_uses_link_for_symlink() {
799 let tmp = tempfile::tempdir().unwrap();
800 let dest = tmp.path().join("mypkg");
801 std::fs::create_dir(&dest).unwrap();
802 std::fs::write(
803 dest.join("init.lua"),
804 r#"local x = require("mypkg.missing") return {}"#,
805 )
806 .unwrap();
807 let outcome = check_incomplete("mypkg", &dest, true).expect("should detect incomplete");
810 match outcome {
811 DoctorOutcome::IncompletePkg { suggestion, .. } => {
812 assert!(
813 suggestion.contains("alc_pkg_link"),
814 "symlink suggestion: {suggestion}"
815 );
816 }
817 _ => panic!("expected IncompletePkg"),
818 }
819 }
820
821 #[test]
822 fn check_incomplete_returns_none_when_no_init_lua() {
823 let tmp = tempfile::tempdir().unwrap();
825 let dest = tmp.path().join("mypkg");
826 std::fs::create_dir(&dest).unwrap();
827
828 assert!(check_incomplete("mypkg", &dest, false).is_none());
829 }
830
831 #[test]
832 fn classify_installed_incomplete_pkg() {
833 let tmp = tempfile::tempdir().unwrap();
835 let pkg_dir = tmp.path();
836 let dest = pkg_dir.join("mypkg");
837 std::fs::create_dir(&dest).unwrap();
838 std::fs::write(
839 dest.join("init.lua"),
840 r#"local x = require("mypkg.sub") return {}"#,
841 )
842 .unwrap();
843 let outcome = classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir);
846 match outcome {
847 DoctorOutcome::IncompletePkg {
848 missing_subs,
849 suggestion,
850 } => {
851 assert_eq!(missing_subs, vec!["sub"]);
852 assert!(suggestion.contains("alc_pkg_install"), "{suggestion}");
853 }
854 _ => panic!("expected IncompletePkg, got {outcome:?}"),
855 }
856 }
857
858 #[test]
859 fn classify_installed_healthy_when_all_subs_present() {
860 let tmp = tempfile::tempdir().unwrap();
862 let pkg_dir = tmp.path();
863 let dest = pkg_dir.join("mypkg");
864 std::fs::create_dir(&dest).unwrap();
865 std::fs::write(
866 dest.join("init.lua"),
867 r#"local x = require("mypkg.sub") return {}"#,
868 )
869 .unwrap();
870 std::fs::write(dest.join("sub.lua"), "return {}").unwrap();
871
872 let outcome = classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir);
873 assert!(
874 matches!(outcome, DoctorOutcome::Healthy),
875 "expected Healthy, got {outcome:?}"
876 );
877 }
878}