1use std::{
10 ffi::OsString,
11 fs,
12 io::Write,
13 path::{Path, PathBuf},
14 time::Instant,
15};
16
17use serde::{Deserialize, Serialize};
18
19use crate::workspace::canonicalize_simplified;
20use crate::{
21 evidence_archive::write_archive,
22 frontend_protocol::validate_frontend_report_request,
23 integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
24 lifecycle::{
25 ProjectLock, finalize_published_run, note_kept_evidence, publish_run,
26 recover_abandoned_runs, remove_stored_tree_deferred,
27 },
28 orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
29 process_supervision::{CommandSpec, SupervisionOptions},
30 ruby_evidence::{RubyFrontendRun, build_ruby_frontend_run},
31 ruby_project::{PreparedRubyProject, prepare_ruby_project, ruby_integrity_inputs},
32 run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
33};
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase", deny_unknown_fields)]
37pub struct DirectRubyRunRequest {
38 pub root: PathBuf,
39 pub command: Vec<String>,
40 pub run_id: String,
41 pub started_at: String,
42}
43
44#[derive(Debug, Clone, PartialEq)]
45pub struct DirectRubyRunResult {
46 pub run_id: String,
47 pub run_directory: PathBuf,
48 pub exit_code: i32,
49 pub tests: usize,
50 pub source_files: usize,
51 pub interpreters: usize,
52 pub ruby_versions: Vec<String>,
53 pub recovered_runs: Vec<String>,
54 pub metadata: RunMetadata,
55}
56
57fn elapsed_ms(started: Instant) -> f64 {
58 (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
59}
60
61fn embedded_runtime_files() -> [(&'static str, &'static [u8]); 5] {
62 [
63 (
64 "supercov_runtime.rb",
65 include_bytes!("../runtime-assets/ruby/supercov_runtime.rb"),
66 ),
67 (
68 "supercov_rspec.rb",
69 include_bytes!("../runtime-assets/ruby/supercov_rspec.rb"),
70 ),
71 (
72 "supercov_minitest.rb",
73 include_bytes!("../runtime-assets/ruby/supercov_minitest.rb"),
74 ),
75 (
76 "supercov_testunit.rb",
77 include_bytes!("../runtime-assets/ruby/supercov_testunit.rb"),
78 ),
79 (
80 "supercov_cucumber.rb",
81 include_bytes!("../runtime-assets/ruby/supercov_cucumber.rb"),
82 ),
83 ]
84}
85
86fn write_runtime(directory: &Path) -> Result<(), String> {
87 fs::create_dir_all(directory).map_err(|error| format!("{}: {error}", directory.display()))?;
88 for (name, contents) in embedded_runtime_files() {
89 let path = directory.join(name);
90 fs::write(&path, contents).map_err(|error| format!("{}: {error}", path.display()))?;
91 }
92 Ok(())
93}
94
95fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
96 for entry in fs::read_dir(source).map_err(|error| format!("{}: {error}", source.display()))? {
97 let entry = entry.map_err(|error| error.to_string())?;
98 let target = destination.join(entry.file_name());
99 if entry
100 .file_type()
101 .map_err(|error| error.to_string())?
102 .is_dir()
103 {
104 fs::create_dir_all(&target).map_err(|error| error.to_string())?;
105 copy_tree(&entry.path(), &target)?;
106 } else {
107 fs::copy(entry.path(), &target).map_err(|error| error.to_string())?;
108 }
109 }
110 Ok(())
111}
112
113fn environment(
114 root: &Path,
115 run_id: &str,
116 runtime_directory: &Path,
117 plan_path: &Path,
118 evidence_directory: &Path,
119) -> Vec<(OsString, OsString)> {
120 let mut variables = std::env::vars_os().collect::<Vec<_>>();
121 let mut take = |key: &str| {
122 let position = variables.iter().position(|(name, _)| name == key);
123 position.map(|index| variables.remove(index).1)
124 };
125 let mut rubyopt = OsString::from("-r");
128 rubyopt.push(runtime_directory.join("supercov_runtime.rb"));
129 if let Some(existing) = take("RUBYOPT").filter(|existing| !existing.is_empty()) {
130 rubyopt.push(" ");
131 rubyopt.push(existing);
132 }
133 for key in [
134 "SUPERCOV_RUBY_PLAN",
135 "SUPERCOV_RUBY_EVIDENCE_DIR",
136 "SUPERCOV_RUBY_SOURCE_ROOT",
137 "SUPERCOV_RUN_ID",
138 "SUPERCOV_PROJECT_ROOT",
139 "SUPERCOV_CONTEXT",
140 "SUPERCOV_RUBY_WORKER",
141 ] {
142 take(key);
143 }
144 variables.extend([
145 ("RUBYOPT".into(), rubyopt),
146 (
147 "SUPERCOV_RUBY_PLAN".into(),
148 plan_path.as_os_str().to_owned(),
149 ),
150 (
151 "SUPERCOV_RUBY_EVIDENCE_DIR".into(),
152 evidence_directory.as_os_str().to_owned(),
153 ),
154 ("SUPERCOV_RUN_ID".into(), run_id.into()),
155 ("SUPERCOV_PROJECT_ROOT".into(), root.as_os_str().to_owned()),
156 (
157 BOOTSNAP_COMPILE_CACHE.0.into(),
158 BOOTSNAP_COMPILE_CACHE.1.into(),
159 ),
160 ]);
161 variables
162}
163
164const BOOTSNAP_COMPILE_CACHE: (&str, &str) = ("DISABLE_BOOTSNAP_COMPILE_CACHE", "1");
171
172pub fn current_ruby_integrity(
175 root: &Path,
176 command: &[String],
177 source_roots: Option<&[String]>,
178) -> Result<crate::run_store::RunIntegrity, String> {
179 let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
180 let mut files = crate::ruby_project::discover_ruby_files(&root)?;
181 match source_roots.filter(|roots| !roots.is_empty()) {
184 Some(configured) => {
185 crate::source_discovery::ExplicitSourceRoots::resolve(&root, configured)
186 .map_err(|error| error.to_string())?
187 .narrow(&mut files.sources, &mut files.excluded, String::as_str);
188 }
189 None => crate::source_discovery::leave_out_what_lies_beside(
190 &mut files.sources,
191 &mut files.excluded,
192 ),
193 }
194 let mut inputs = ruby_integrity_inputs(&files, command);
195 crate::source_discovery::fold_roots_into_configuration(
196 &mut inputs.execution_configuration,
197 source_roots,
198 );
199 create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_ruby())
200 .map_err(|error| error.to_string())
201}
202
203pub fn run_direct_ruby(
204 request: &DirectRubyRunRequest,
205 diagnostics: &mut dyn Write,
206) -> Result<DirectRubyRunResult, String> {
207 if request.command.is_empty() {
208 return Err("test command must not be empty".into());
209 }
210 let total_started = Instant::now();
211 let initialization_started = Instant::now();
212 let root = canonicalize_simplified(&request.root)
213 .map_err(|error| format!("{}: {error}", request.root.display()))?;
214 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
215 .map_err(|error| error.to_string())?;
216 let initialization_ms = elapsed_ms(initialization_started);
217 let work_directory = root.join(".supercov/work").join(&request.run_id);
218 let measured = std::cell::Cell::new(false);
222 let result = (|| {
223 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
224 .map_err(|error| error.to_string())?;
225 if !recovered_runs.is_empty() {
226 writeln!(
227 diagnostics,
228 "[supercov] recovered abandoned run(s): {}",
229 recovered_runs.join(", ")
230 )
231 .map_err(|error| error.to_string())?;
232 }
233
234 let adapter_started = Instant::now();
235 let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
236 let roots = crate::source_discovery::ExplicitSourceRoots::from_environment(&root, &ambient)
237 .map_err(|error| error.to_string())?;
238 let project: PreparedRubyProject = prepare_ruby_project(&root, roots.as_ref())?;
239 let source_roots = crate::source_discovery::configured_source_roots(
240 &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
241 );
242 let mut integrity_inputs = ruby_integrity_inputs(&project.files, &request.command);
243 crate::source_discovery::fold_roots_into_configuration(
244 &mut integrity_inputs.execution_configuration,
245 source_roots.as_deref(),
246 );
247 let integrity = create_explicit_run_integrity(
248 &root,
249 &integrity_inputs,
250 &FrontendIntegrityInputs::embedded_ruby(),
251 )
252 .map_err(|error| error.to_string())?;
253 let source_inputs = crate::source_capture::capture(
254 &root,
255 "ruby",
256 ruby_integrity_inputs(&project.files, &request.command).assertion_paths(),
257 )?;
258 let ruby_directory = work_directory.join("ruby");
259 let runtime_directory = ruby_directory.join("runtime");
260 let evidence_directory = ruby_directory.join("evidence");
261 let plan_path = ruby_directory.join("plan.rb");
266 write_runtime(&runtime_directory)?;
267 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
268 fs::write(
269 &plan_path,
270 ruby_literal(&serde_json::to_value(&project.plan).map_err(|error| error.to_string())?)
271 .into_bytes(),
272 )
273 .map_err(|error| format!("{}: {error}", plan_path.display()))?;
274 writeln!(
275 diagnostics,
276 "[supercov] detected Ruby; measuring {} source file(s) in place through Ruby's Coverage module and load-time probes",
277 project.plan.files.len()
278 )
279 .map_err(|error| error.to_string())?;
280 for (file, reason) in &project.unparseable {
281 writeln!(
282 diagnostics,
283 "[supercov] could not parse {file}: {reason}; it carries no obligations"
284 )
285 .map_err(|error| error.to_string())?;
286 }
287 let adapter_setup_ms = elapsed_ms(adapter_started);
288
289 let test_started = Instant::now();
290 let plan = ExecutionPlan {
291 preparation: Vec::new(),
292 test: ExecutionPhase {
293 name: "test".into(),
294 kind: PhaseKind::Test,
295 command: CommandSpec {
296 program: request.command[0].clone().into(),
297 arguments: request.command[1..].iter().map(OsString::from).collect(),
298 cwd: root.clone(),
299 environment: Some(environment(
300 &root,
301 &request.run_id,
302 &runtime_directory,
303 &plan_path,
304 &evidence_directory,
305 )),
306 captured_output: None,
307 console: None,
308 },
309 },
310 };
311 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
312 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
313 .map_err(|error| error.to_string())?;
314 let test_command_ms = elapsed_ms(test_started);
315 if let Some(signal) = execution.interrupted_signal {
316 return Err(format!(
317 "the test command was interrupted by {signal:?}; no run was published"
318 ));
319 }
320
321 if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
322 let debug_directory = root.join(".supercov/ruby-debug").join(&request.run_id);
324 fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
325 copy_tree(&ruby_directory, &debug_directory)?;
326 }
327 let publication_started = Instant::now();
328 measured.set(true);
329 let run: RubyFrontendRun = match build_ruby_frontend_run(
330 &project.manifest,
331 &evidence_directory,
332 &request.run_id,
333 &request.started_at,
334 execution.exit_code,
335 &root,
336 ) {
337 Err(
342 crate::ruby_evidence::RubyEvidenceError::NoTests
343 | crate::ruby_evidence::RubyEvidenceError::NoInterpreter,
344 ) if execution.exit_code != 0 => {
345 measured.set(false);
346 return Err(format!(
347 "the test command exited {} before any test reported an outcome; its own output above says why, and no run was published",
348 execution.exit_code
349 ));
350 }
351 Err(
355 error @ (crate::ruby_evidence::RubyEvidenceError::UnsupportedRuby(_)
356 | crate::ruby_evidence::RubyEvidenceError::NoTests
357 | crate::ruby_evidence::RubyEvidenceError::NoInterpreter),
358 ) => {
359 measured.set(false);
360 return Err(error.to_string());
361 }
362 result => result.map_err(|error| error.to_string())?,
363 };
364 validate_frontend_report_request(&run.declaration, &run.request)
365 .map_err(|error| error.to_string())?;
366 let archive_path = work_directory.join("evidence.raw.gz");
367 let entries = run.archive_entries().map_err(|error| error.to_string())?;
368 let entries = crate::source_capture::append(entries, &source_inputs)?;
369 let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
370 remove_stored_tree_deferred(&root, &ruby_directory).map_err(|error| error.to_string())?;
371 let evidence_publication_ms = elapsed_ms(publication_started);
372 let timings = RunTimings {
373 initialization_ms,
374 workspace_preparation_ms: 0.0,
375 adapter_setup_ms,
376 instrumented_build_ms: 0.0,
377 test_command_ms,
378 evidence_publication_ms,
379 };
380 let metadata = RunMetadata {
381 id: request.run_id.clone(),
382 started_at: request.started_at.clone(),
383 duration_ms: elapsed_ms(total_started),
384 command: request.command.clone(),
385 test_exit_code: Some(execution.exit_code),
386 integrity,
387 raw_evidence: RawEvidenceMetadata {
388 schema_version: raw.schema_version,
389 format: raw.format.into(),
390 file: raw.file.into(),
391 files: raw.files,
392 uncompressed_bytes: raw.uncompressed_bytes,
393 compressed_bytes: raw.compressed_bytes,
394 },
395 isolated_build: None,
396 instrumented_build_cache: None,
397 timings: Some(timings),
398 merged: None,
399 parents: None,
400 source_roots: source_roots.clone(),
401 copy: None,
402 merged_across: None,
403 recorded_by: None,
404 companions: None,
405 };
406 let run_directory =
407 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
408 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
409 Ok(DirectRubyRunResult {
410 run_id: request.run_id.clone(),
411 run_directory,
412 exit_code: execution.exit_code,
413 tests: run.tests,
414 source_files: project.plan.files.len(),
415 interpreters: run.interpreters,
416 ruby_versions: run.ruby_versions,
417 recovered_runs,
418 metadata,
419 })
420 })();
421 let result = result.map_err(|error| {
422 if !measured.get() {
423 return error;
424 }
425 note_kept_evidence(
426 &root,
427 &work_directory.join("ruby/evidence"),
428 &request.run_id,
429 error,
430 )
431 });
432 if result.is_err() {
433 let _ = remove_stored_tree_deferred(&root, &work_directory);
434 }
435 let release = lock.release().map_err(|error| error.to_string());
436 match (result, release) {
437 (Ok(result), Ok(())) => Ok(result),
438 (Err(error), _) => Err(error),
439 (Ok(_), Err(error)) => Err(error),
440 }
441}
442
443pub struct RubyServer {
448 project: PreparedRubyProject,
449 integrity: crate::run_store::RunIntegrity,
450 source_inputs: crate::source_manifest::Inputs,
451 source_roots: Option<Vec<String>>,
452 directory: PathBuf,
453}
454
455impl RubyServer {
456 pub fn prepare(
460 root: &Path,
461 workspace: &Path,
462 run_id: &str,
463 command: &[String],
464 ) -> Result<Option<Self>, String> {
465 let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
466 let Ok(roots) =
468 crate::source_discovery::ExplicitSourceRoots::from_environment(root, &ambient)
469 else {
470 return Ok(None);
471 };
472 let project = prepare_ruby_project(root, roots.as_ref())?;
473 if project.plan.files.is_empty() {
474 return Ok(None);
475 }
476 let source_roots = crate::source_discovery::configured_source_roots(&ambient);
477 let mut integrity_inputs = ruby_integrity_inputs(&project.files, command);
478 crate::source_discovery::fold_roots_into_configuration(
479 &mut integrity_inputs.execution_configuration,
480 source_roots.as_deref(),
481 );
482 let integrity = create_explicit_run_integrity(
483 root,
484 &integrity_inputs,
485 &FrontendIntegrityInputs::embedded_ruby(),
486 )
487 .map_err(|error| error.to_string())?;
488 let source_inputs = crate::source_capture::capture(
489 root,
490 "ruby",
491 ruby_integrity_inputs(&project.files, command).assertion_paths(),
492 )?;
493 let directory = root.join(".supercov/work").join(run_id).join("ruby");
494 write_runtime(&directory.join("runtime"))?;
495 fs::create_dir_all(directory.join("evidence")).map_err(|error| error.to_string())?;
496 let mut plan = serde_json::to_value(&project.plan).map_err(|error| error.to_string())?;
497 plan["root"] = workspace.display().to_string().into();
498 let plan_path = directory.join("plan.rb");
499 fs::write(&plan_path, ruby_literal(&plan).into_bytes())
500 .map_err(|error| format!("{}: {error}", plan_path.display()))?;
501 Ok(Some(Self {
502 project,
503 integrity,
504 source_inputs,
505 source_roots,
506 directory,
507 }))
508 }
509
510 pub fn source_files(&self) -> usize {
511 self.project.plan.files.len()
512 }
513
514 pub fn environment(&self) -> std::collections::BTreeMap<String, String> {
517 let mut rubyopt = format!(
518 "-r{}",
519 self.directory.join("runtime/supercov_runtime.rb").display()
520 );
521 if let Some(existing) = std::env::var("RUBYOPT")
522 .ok()
523 .filter(|value| !value.is_empty())
524 {
525 rubyopt.push(' ');
526 rubyopt.push_str(&existing);
527 }
528 std::collections::BTreeMap::from([
529 ("RUBYOPT".into(), rubyopt),
530 (
531 "SUPERCOV_RUBY_PLAN".into(),
532 self.directory.join("plan.rb").display().to_string(),
533 ),
534 (
535 "SUPERCOV_RUBY_EVIDENCE_DIR".into(),
536 self.directory.join("evidence").display().to_string(),
537 ),
538 (
539 "SUPERCOV_RUBY_SOURCE_ROOT".into(),
540 self.project.root.display().to_string(),
541 ),
542 (
543 BOOTSNAP_COMPILE_CACHE.0.into(),
544 BOOTSNAP_COMPILE_CACHE.1.into(),
545 ),
546 ])
547 }
548
549 pub fn publish(
553 self,
554 root: &Path,
555 evidence_run_id: &str,
556 metadata: RunMetadata,
557 served: &[crate::served_tests::ServedTest],
558 diagnostics: &mut dyn Write,
559 ) -> Result<Option<(PathBuf, RunMetadata)>, String> {
560 let evidence_directory = self.directory.join("evidence");
561 let result = (|| {
562 if served.is_empty() {
563 return Ok(None);
564 }
565 let mut run = match crate::ruby_evidence::build_ruby_server_run(
566 &self.project.manifest,
567 &evidence_directory,
568 evidence_run_id,
569 &metadata.started_at,
570 metadata.test_exit_code.unwrap_or_default(),
571 &self.project.root,
572 served,
573 ) {
574 Err(
575 crate::ruby_evidence::RubyEvidenceError::NoInterpreter
576 | crate::ruby_evidence::RubyEvidenceError::NoTests,
577 ) => return Ok(None),
578 result => result.map_err(|error| error.to_string())?,
579 };
580 let answered = run.request.raw_results.iter().any(|raw| {
581 raw.role == "test" && raw.runtime.iter().any(|snapshot| !snapshot.hits.is_empty())
582 });
583 if !answered {
584 writeln!(
585 diagnostics,
586 "[supercov] a Ruby process ran, and no request a test sent reached measured Ruby code; no Ruby run was published"
587 )
588 .map_err(|error| error.to_string())?;
589 return Ok(None);
590 }
591 validate_frontend_report_request(&run.declaration, &run.request)
592 .map_err(|error| error.to_string())?;
593 run.request.run_id = metadata.id.clone();
596 for scope in run
597 .request
598 .raw_results
599 .iter_mut()
600 .filter_map(|raw| raw.scope.as_mut())
601 {
602 scope.run_id = metadata.id.clone();
603 }
604 let archive_path = root
605 .join(".supercov/work")
606 .join(&metadata.id)
607 .join("ruby-evidence.raw.gz");
608 if let Some(parent) = archive_path.parent() {
609 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
610 }
611 let entries = run.archive_entries().map_err(|error| error.to_string())?;
612 let entries = crate::source_capture::append(entries, &self.source_inputs)?;
613 let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
614 let tests = run.tests;
615 drop(run);
616 let metadata = RunMetadata {
617 integrity: self.integrity.clone(),
618 raw_evidence: RawEvidenceMetadata {
619 schema_version: raw.schema_version,
620 format: raw.format.into(),
621 file: raw.file.into(),
622 files: raw.files,
623 uncompressed_bytes: raw.uncompressed_bytes,
624 compressed_bytes: raw.compressed_bytes,
625 },
626 source_roots: self.source_roots.clone(),
627 ..metadata
628 };
629 let run_directory =
630 publish_run(root, &metadata, &archive_path).map_err(|error| error.to_string())?;
631 writeln!(
632 diagnostics,
633 "[supercov] Ruby server coverage: {tests} test(s) across {} source file(s)",
634 self.project.plan.files.len()
635 )
636 .map_err(|error| error.to_string())?;
637 Ok(Some((run_directory, metadata)))
638 })();
639 let result = result.map_err(|error| {
640 note_kept_evidence(
641 root,
642 &evidence_directory,
643 &format!("{evidence_run_id}-ruby"),
644 error,
645 )
646 });
647 let _ = remove_stored_tree_deferred(root, &self.directory);
648 result
649 }
650}
651
652pub(crate) fn ruby_literal(value: &serde_json::Value) -> String {
657 fn write(value: &serde_json::Value, out: &mut String) {
658 match value {
659 serde_json::Value::Null => out.push_str("nil"),
660 serde_json::Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),
661 serde_json::Value::Number(number) => out.push_str(&number.to_string()),
662 serde_json::Value::String(text) => write_string(text, out),
663 serde_json::Value::Array(items) => {
664 out.push('[');
665 for (index, item) in items.iter().enumerate() {
666 if index > 0 {
667 out.push(',');
668 }
669 write(item, out);
670 }
671 out.push(']');
672 }
673 serde_json::Value::Object(entries) => {
674 out.push('{');
675 for (index, (key, item)) in entries.iter().enumerate() {
676 if index > 0 {
677 out.push(',');
678 }
679 write_string(key, out);
680 out.push_str("=>");
681 write(item, out);
682 }
683 out.push('}');
684 }
685 }
686 }
687
688 fn write_string(text: &str, out: &mut String) {
689 let json = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
690 out.push_str(&json.replace('#', "\\#"));
691 }
692
693 let mut out = String::new();
694 write(value, &mut out);
695 out.push('\n');
696 out
697}
698
699#[cfg(test)]
700mod literal_tests {
701 use super::ruby_literal;
702
703 #[test]
704 fn ruby_literal_keeps_strings_inert_and_keys_as_strings() {
705 let value = serde_json::json!({
706 "a": [1, 2.5, -3, true, false, null],
707 "text": "quote \" backslash \\ interpolation #{x} tab \t unicode \u{e9}",
708 "nested": {"k": []}
709 });
710 let literal = ruby_literal(&value);
711 assert_eq!(
712 literal,
713 "{\"a\"=>[1,2.5,-3,true,false,nil],\"text\"=>\"quote \\\" backslash \\\\ interpolation \\#{x} tab \\t unicode \u{e9}\",\"nested\"=>{\"k\"=>[]}}\n"
714 );
715 }
716}