1use clap::{Args, Subcommand};
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6#[derive(Clone, Debug, clap::ValueEnum, PartialEq)]
7pub enum PluginType {
8 Processor,
9 Bean,
10}
11
12impl fmt::Display for PluginType {
13 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14 match self {
15 PluginType::Processor => write!(f, "processor"),
16 PluginType::Bean => write!(f, "bean"),
17 }
18 }
19}
20
21#[derive(Subcommand, Debug)]
22pub enum PluginAction {
23 New(PluginNewArgs),
24 Build(PluginBuildArgs),
25}
26
27#[derive(Args, Debug)]
28pub struct PluginNewArgs {
29 pub name: String,
30 #[arg(long, value_name = "TYPE", default_value_t = PluginType::Processor)]
31 pub r#type: PluginType,
32 #[arg(long)]
33 pub force: bool,
34}
35
36#[derive(Args, Debug)]
37pub struct PluginBuildArgs {
38 pub path: Option<String>,
39 #[arg(long)]
40 pub debug: bool,
41}
42
43pub fn run_plugin(action: PluginAction) {
44 match action {
45 PluginAction::New(args) => run_plugin_new(args),
46 PluginAction::Build(args) => run_plugin_build(args),
47 }
48}
49
50fn run_plugin_new(args: PluginNewArgs) {
51 let PluginNewArgs {
52 name,
53 force,
54 r#type: plugin_type,
55 } = args;
56
57 if !name
58 .chars()
59 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
60 {
61 eprintln!(
62 "Error: plugin name must contain only alphanumeric characters, hyphens, or underscores"
63 );
64 std::process::exit(1);
65 }
66
67 let files = match plugin_type {
68 PluginType::Bean => crate::template::bean::bean_files(&name),
69 PluginType::Processor => crate::template::processor::processor_files(&name),
70 };
71 let target = Path::new(&name);
72
73 if target.exists() && !force {
74 let is_non_empty = target.read_dir().is_ok_and(|mut d| d.next().is_some());
75 if is_non_empty {
76 eprintln!(
77 "Directory '{}' already exists and is not empty. Use --force to overwrite.",
78 name
79 );
80 std::process::exit(1);
81 }
82 }
83
84 std::fs::create_dir_all(target).unwrap_or_else(|e| {
85 eprintln!("Failed to create directory '{}': {}", name, e);
86 std::process::exit(1);
87 });
88
89 for file in &files {
90 let file_path = target.join(&file.path);
91 if let Some(parent) = file_path.parent() {
92 std::fs::create_dir_all(parent).unwrap_or_else(|e| {
93 eprintln!("Failed to create directory '{}': {}", parent.display(), e);
94 std::process::exit(1);
95 });
96 }
97 std::fs::write(&file_path, &file.content).unwrap_or_else(|e| {
98 eprintln!("Failed to write '{}': {}", file_path.display(), e);
99 std::process::exit(1);
100 });
101 }
102
103 let type_label = match plugin_type {
104 PluginType::Bean => "bean",
105 PluginType::Processor => "processor",
106 };
107 println!("Created camel {} plugin '{}'\n", type_label, name);
108 println!("Next steps:");
109 println!(" cd {}", name);
110 println!(" camel plugin build");
111}
112
113fn run_plugin_build(args: PluginBuildArgs) {
114 let plugin_dir = match args.path {
115 Some(ref p) => {
116 let canonical = std::path::Path::new(p).canonicalize().unwrap_or_else(|e| {
117 eprintln!("Error: cannot resolve path '{}': {e}", p);
118 std::process::exit(1);
119 });
120 if !canonical.join("Cargo.toml").exists() {
121 eprintln!(
122 "Error: '{}' does not contain a Cargo.toml",
123 canonical.display()
124 );
125 std::process::exit(1);
126 }
127 canonical
128 }
129 None => {
130 let cwd = std::env::current_dir().unwrap_or_else(|e| {
131 eprintln!("Error: failed to get current directory: {e}");
132 std::process::exit(1);
133 });
134 let canonical = cwd.canonicalize().unwrap_or_else(|e| {
135 eprintln!("Error: cannot resolve current directory: {e}");
136 std::process::exit(1);
137 });
138 if !canonical.join("Cargo.toml").exists() {
139 eprintln!(
140 "Error: current directory '{}' does not contain a Cargo.toml",
141 canonical.display()
142 );
143 std::process::exit(1);
144 }
145 canonical
146 }
147 };
148
149 let cargo_toml_path = plugin_dir.join("Cargo.toml");
150 let cargo_toml = std::fs::read_to_string(&cargo_toml_path).unwrap_or_else(|e| {
151 eprintln!(
152 "Error: failed to read '{}': {}",
153 cargo_toml_path.display(),
154 e
155 );
156 std::process::exit(1);
157 });
158
159 let parsed: toml::Value = toml::from_str(&cargo_toml).unwrap_or_else(|e| {
160 eprintln!(
161 "Error: failed to parse '{}': {}",
162 cargo_toml_path.display(),
163 e
164 );
165 std::process::exit(1);
166 });
167
168 let plugin_name = parsed
169 .get("package")
170 .and_then(|pkg| pkg.get("name"))
171 .and_then(toml::Value::as_str)
172 .map(str::to_string)
173 .unwrap_or_else(|| {
174 eprintln!(
175 "Error: missing [package].name in '{}'",
176 cargo_toml_path.display()
177 );
178 std::process::exit(1);
179 });
180
181 let mut cmd = Command::new("cargo");
182 cmd.arg("build")
183 .arg("--target")
184 .arg("wasm32-wasip2")
185 .current_dir(&plugin_dir);
186
187 if !args.debug {
188 cmd.arg("--release");
189 }
190
191 let status = cmd.status().unwrap_or_else(|e| {
192 eprintln!("Error: failed to execute build command: {e}");
193 std::process::exit(1);
194 });
195
196 if !status.success() {
197 eprintln!("Error: build failed");
198 std::process::exit(1);
199 }
200
201 let built_wasm = build_output_path(&plugin_dir, &plugin_name, args.debug);
202 if !built_wasm.exists() {
203 eprintln!("Error: built wasm not found at '{}'", built_wasm.display());
204 std::process::exit(1);
205 }
206
207 let camel_root = find_camel_root(&plugin_dir).unwrap_or_else(|e| {
208 eprintln!("Error: {e}");
209 std::process::exit(1);
210 });
211
212 let plugins_dir_relative = resolve_plugins_dir(&camel_root).unwrap_or_else(|e| {
213 eprintln!("Error: {e}");
214 std::process::exit(1);
215 });
216 let plugins_dir = camel_root.join(&plugins_dir_relative);
217
218 std::fs::create_dir_all(&plugins_dir).unwrap_or_else(|e| {
219 eprintln!(
220 "Error: failed to create plugins directory '{}': {}",
221 plugins_dir.display(),
222 e
223 );
224 std::process::exit(1);
225 });
226
227 let installed_wasm = plugins_dir.join(format!("{plugin_name}.wasm"));
228 std::fs::copy(&built_wasm, &installed_wasm).unwrap_or_else(|e| {
229 eprintln!(
230 "Error: failed to copy '{}' to '{}': {}",
231 built_wasm.display(),
232 installed_wasm.display(),
233 e
234 );
235 std::process::exit(1);
236 });
237
238 println!("Built and installed plugin '{}'", plugin_name);
239 println!(" source: {}", built_wasm.display());
240 println!(" installed: {}", installed_wasm.display());
241}
242
243pub fn find_camel_root(start: &Path) -> Result<PathBuf, String> {
244 for dir in start.ancestors() {
245 if dir.join("Camel.toml").exists() {
246 return Ok(dir.to_path_buf());
247 }
248 let workspace_cargo = dir.join("Cargo.toml");
249 if workspace_cargo.exists() {
250 let contents = std::fs::read_to_string(&workspace_cargo)
251 .map_err(|e| format!("failed to read '{}': {}", workspace_cargo.display(), e))?;
252 let parsed: toml::Value = toml::from_str(&contents)
253 .map_err(|e| format!("failed to parse '{}': {}", workspace_cargo.display(), e))?;
254 if parsed.get("workspace").is_some() {
255 return Ok(dir.to_path_buf());
256 }
257 }
258 }
259
260 Err(format!(
261 "could not find Camel.toml or workspace Cargo.toml from '{}'",
262 start.display()
263 ))
264}
265
266pub fn build_output_path(dir: &Path, plugin_name: &str, debug: bool) -> PathBuf {
267 let profile = if debug { "debug" } else { "release" };
268 let wasm_name = plugin_name.replace('-', "_");
269 dir.join("target")
270 .join("wasm32-wasip2")
271 .join(profile)
272 .join(format!("{wasm_name}.wasm"))
273}
274
275pub fn validate_plugins_dir(camel_root: &Path, dir: &str) -> Result<(), String> {
280 let trimmed = dir.trim();
281 if trimmed.is_empty() {
282 return Err("plugins_dir must not be empty".to_string());
283 }
284
285 let path = Path::new(trimmed);
286 if path.is_absolute() {
287 return Err(format!(
288 "plugins_dir must be a relative path, got '{}'",
289 dir
290 ));
291 }
292
293 for component in path.components() {
295 if matches!(component, std::path::Component::ParentDir) {
296 return Err(format!("plugins_dir must not contain '..', got '{}'", dir));
297 }
298 }
299
300 let canonical_root = camel_root
302 .canonicalize()
303 .map_err(|e| format!("failed to canonicalize project root: {e}"))?;
304
305 let candidate = camel_root.join(trimmed);
306
307 if let Ok(canonical_candidate) = candidate.canonicalize() {
309 if !canonical_candidate.starts_with(&canonical_root) {
310 return Err("plugins_dir resolves outside project root".to_string());
311 }
312 return Ok(());
313 }
314
315 let mut ancestor = candidate.as_path();
317 let mut suffix = PathBuf::new();
318 loop {
319 if ancestor.exists() {
320 match ancestor.canonicalize() {
321 Ok(canonical_ancestor) => {
322 let resolved = canonical_ancestor.join(&suffix);
323 if !resolved.starts_with(&canonical_root) {
324 return Err("plugins_dir resolves outside project root".to_string());
325 }
326 return Ok(());
327 }
328 Err(e) => {
329 return Err(format!(
330 "failed to canonicalize ancestor '{}': {e}",
331 ancestor.display()
332 ));
333 }
334 }
335 }
336 if let Some(parent) = ancestor.parent() {
337 if let Some(file_name) = ancestor.file_name() {
338 let mut new_suffix = PathBuf::from(file_name);
340 if !suffix.as_os_str().is_empty() {
341 new_suffix.push(&suffix);
342 }
343 suffix = new_suffix;
344 ancestor = parent;
345 } else {
346 return Err("no existing ancestor found for plugins_dir".to_string());
347 }
348 } else {
349 return Err("no existing ancestor found for plugins_dir".to_string());
350 }
351 }
352}
353
354pub fn resolve_plugins_dir(camel_root: &Path) -> Result<PathBuf, String> {
358 let toml_path = camel_root.join("Camel.toml");
359 if toml_path.exists() {
360 let contents = std::fs::read_to_string(&toml_path)
361 .map_err(|e| format!("failed to read '{}': {e}", toml_path.display()))?;
362 let parsed: toml::Value = toml::from_str(&contents)
363 .map_err(|e| format!("failed to parse '{}': {e}", toml_path.display()))?;
364
365 if let Some(plugins_dir) = parsed
366 .get("default")
367 .and_then(|d| d.get("components"))
368 .and_then(|c| c.get("wasm"))
369 .and_then(|w| w.get("plugins_dir"))
370 .and_then(toml::Value::as_str)
371 {
372 validate_plugins_dir(camel_root, plugins_dir)?;
373 return Ok(PathBuf::from(plugins_dir));
374 }
375 }
376
377 validate_plugins_dir(camel_root, "plugins")?;
379 Ok(PathBuf::from("plugins"))
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use clap::Parser;
386 use tempfile::tempdir;
387
388 #[derive(Parser)]
389 struct TestCli {
390 #[command(subcommand)]
391 action: PluginAction,
392 }
393
394 #[test]
395 fn plugin_action_parses_new_with_force() {
396 let cli = TestCli::try_parse_from(["test", "new", "my-plugin", "--force"])
397 .expect("expected parse success");
398 match cli.action {
399 PluginAction::New(args) => {
400 assert_eq!(args.name, "my-plugin");
401 assert!(args.force);
402 assert_eq!(args.r#type, PluginType::Processor);
403 }
404 _ => panic!("expected PluginAction::New"),
405 }
406 }
407
408 #[test]
409 fn plugin_action_parses_new_bean_type() {
410 let cli = TestCli::try_parse_from(["test", "new", "my-bean", "--type", "bean"])
411 .expect("expected parse success");
412 match cli.action {
413 PluginAction::New(args) => {
414 assert_eq!(args.name, "my-bean");
415 assert_eq!(args.r#type, PluginType::Bean);
416 }
417 _ => panic!("expected PluginAction::New"),
418 }
419 }
420
421 #[test]
422 fn plugin_action_default_type_is_processor() {
423 let cli =
424 TestCli::try_parse_from(["test", "new", "my-proc"]).expect("expected parse success");
425 match cli.action {
426 PluginAction::New(args) => {
427 assert_eq!(args.name, "my-proc");
428 assert_eq!(args.r#type, PluginType::Processor);
429 }
430 _ => panic!("expected PluginAction::New"),
431 }
432 }
433
434 #[test]
435 fn plugin_action_parses_build_debug() {
436 let cli =
437 TestCli::try_parse_from(["test", "build", "--debug"]).expect("expected parse success");
438 match cli.action {
439 PluginAction::Build(args) => {
440 assert!(args.debug);
441 }
442 _ => panic!("expected PluginAction::Build"),
443 }
444 }
445
446 #[test]
447 fn plugin_build_accepts_optional_path() {
448 let cli = TestCli::try_parse_from(["test", "build", "my-plugin/"])
449 .expect("expected parse success");
450 match cli.action {
451 PluginAction::Build(args) => {
452 assert_eq!(args.path.as_deref(), Some("my-plugin/"));
453 }
454 _ => panic!("expected PluginAction::Build"),
455 }
456 }
457
458 #[test]
459 fn plugin_build_defaults_path_to_none() {
460 let cli = TestCli::try_parse_from(["test", "build"]).expect("expected parse success");
461 match cli.action {
462 PluginAction::Build(args) => {
463 assert!(args.path.is_none());
464 }
465 _ => panic!("expected PluginAction::Build"),
466 }
467 }
468
469 #[test]
470 fn plugin_action_rejects_missing_name() {
471 let result = TestCli::try_parse_from(["test", "new"]);
472 assert!(result.is_err());
473 }
474
475 #[test]
476 fn plugin_type_display_values() {
477 assert_eq!(PluginType::Processor.to_string(), "processor");
478 assert_eq!(PluginType::Bean.to_string(), "bean");
479 }
480
481 #[test]
482 fn plugin_action_rejects_invalid_type() {
483 let result = TestCli::try_parse_from(["test", "new", "my-plugin", "--type", "unknown"]);
484 assert!(result.is_err());
485 }
486
487 #[test]
488 fn find_camel_root_finds_camel_toml() {
489 let root = tempdir().expect("tempdir");
490 std::fs::write(root.path().join("Camel.toml"), "name = \"x\"\n").expect("write");
491 let nested = root.path().join("a").join("b");
492 std::fs::create_dir_all(&nested).expect("mkdir");
493
494 let found = find_camel_root(&nested).expect("find root");
495 assert_eq!(found, root.path());
496 }
497
498 #[test]
499 fn find_camel_root_finds_workspace_cargo_toml() {
500 let root = tempdir().expect("tempdir");
501 std::fs::write(
502 root.path().join("Cargo.toml"),
503 "[workspace]\nmembers = []\n",
504 )
505 .expect("write");
506 let nested = root.path().join("x").join("y");
507 std::fs::create_dir_all(&nested).expect("mkdir");
508
509 let found = find_camel_root(&nested).expect("find root");
510 assert_eq!(found, root.path());
511 }
512
513 #[test]
514 fn find_camel_root_errors_without_markers() {
515 let root = tempdir().expect("tempdir");
516 let nested = root.path().join("one").join("two");
517 std::fs::create_dir_all(&nested).expect("mkdir");
518
519 let err = find_camel_root(&nested).expect_err("expected error");
520 assert!(err.contains("could not find Camel.toml or workspace Cargo.toml"));
521 }
522
523 #[test]
524 fn find_camel_root_prefers_nearest_ancestor_marker() {
525 let root = tempdir().expect("tempdir");
526 std::fs::write(root.path().join("Camel.toml"), "name = \"x\"\n").expect("write");
527 let mid = root.path().join("mid");
528 std::fs::create_dir_all(&mid).expect("mkdir");
529 std::fs::write(mid.join("Cargo.toml"), "[workspace]\nmembers = []\n").expect("write");
530 let nested = mid.join("deep");
531 std::fs::create_dir_all(&nested).expect("mkdir");
532
533 let found = find_camel_root(&nested).expect("find root");
534 assert_eq!(found, mid);
535 }
536
537 #[test]
538 fn find_camel_root_returns_parse_error_for_invalid_workspace_toml() {
539 let root = tempdir().expect("tempdir");
540 std::fs::write(root.path().join("Cargo.toml"), "[workspace\ninvalid").expect("write");
541 let nested = root.path().join("x").join("y");
542 std::fs::create_dir_all(&nested).expect("mkdir");
543
544 let err = find_camel_root(&nested).expect_err("expected error");
545 assert!(err.contains("failed to parse"));
546 assert!(err.contains("Cargo.toml"));
547 }
548
549 #[test]
550 fn find_camel_root_ignores_non_workspace_cargo_toml() {
551 let root = tempdir().expect("tempdir");
552 std::fs::write(root.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").expect("write");
553 let nested = root.path().join("a").join("b");
554 std::fs::create_dir_all(&nested).expect("mkdir");
555
556 let err = find_camel_root(&nested).expect_err("expected error");
557 assert!(err.contains("could not find Camel.toml or workspace Cargo.toml"));
558 }
559
560 #[test]
561 fn find_camel_root_returns_read_error_for_unreadable_workspace_marker() {
562 let root = tempdir().expect("tempdir");
563 let cargo_as_dir = root.path().join("Cargo.toml");
564 std::fs::create_dir_all(&cargo_as_dir).expect("mkdir");
565 let nested = root.path().join("x").join("y");
566 std::fs::create_dir_all(&nested).expect("mkdir");
567
568 let err = find_camel_root(&nested).expect_err("expected read error");
569 assert!(err.contains("failed to read"), "got: {err}");
570 assert!(err.contains("Cargo.toml"), "got: {err}");
571 }
572
573 #[test]
574 fn build_output_path_release() {
575 let dir = Path::new("/tmp/project");
576 let path = build_output_path(dir, "my-plugin", false);
577 assert!(
578 path.ends_with(Path::new("target/wasm32-wasip2/release/my_plugin.wasm")),
579 "got: {}",
580 path.display()
581 );
582 }
583
584 #[test]
585 fn build_output_path_debug() {
586 let dir = Path::new("/tmp/project");
587 let path = build_output_path(dir, "my-plugin", true);
588 assert!(
589 path.ends_with(Path::new("target/wasm32-wasip2/debug/my_plugin.wasm")),
590 "got: {}",
591 path.display()
592 );
593 }
594
595 #[test]
596 fn build_output_path_keeps_existing_underscores() {
597 let dir = Path::new("/tmp/project");
598 let path = build_output_path(dir, "my_plugin", false);
599 assert!(
600 path.ends_with(Path::new("target/wasm32-wasip2/release/my_plugin.wasm")),
601 "got: {}",
602 path.display()
603 );
604 }
605
606 #[test]
607 fn build_output_path_replaces_all_hyphens() {
608 let dir = Path::new("/tmp/project");
609 let path = build_output_path(dir, "my-super-plugin", true);
610 assert!(
611 path.ends_with(Path::new("target/wasm32-wasip2/debug/my_super_plugin.wasm")),
612 "got: {}",
613 path.display()
614 );
615 }
616
617 #[test]
619 fn validate_plugins_dir_rejects_absolute_path() {
620 let root = tempfile::tempdir().expect("tempdir");
621 let err = super::validate_plugins_dir(root.path(), "/tmp/plugins").unwrap_err();
622 assert!(err.contains("relative path"), "got: {err}");
623 }
624
625 #[test]
626 fn validate_plugins_dir_rejects_parentdir_component() {
627 let root = tempfile::tempdir().expect("tempdir");
628 let err = super::validate_plugins_dir(root.path(), "../other").unwrap_err();
629 assert!(err.contains("'..'"), "got: {err}");
630 }
631
632 #[test]
633 fn validate_plugins_dir_rejects_parentdir_mid_path() {
634 let root = tempfile::tempdir().expect("tempdir");
635 let err = super::validate_plugins_dir(root.path(), "foo/../bar").unwrap_err();
636 assert!(err.contains("'..'"), "got: {err}");
637 }
638
639 #[test]
640 fn validate_plugins_dir_rejects_empty_string() {
641 let root = tempfile::tempdir().expect("tempdir");
642 let err = super::validate_plugins_dir(root.path(), "").unwrap_err();
643 assert!(err.contains("empty"), "got: {err}");
644 }
645
646 #[test]
647 fn validate_plugins_dir_accepts_simple_relative() {
648 let root = tempfile::tempdir().expect("tempdir");
649 super::validate_plugins_dir(root.path(), "plugins").expect("should accept");
650 }
651
652 #[test]
653 fn validate_plugins_dir_accepts_nested_relative() {
654 let root = tempfile::tempdir().expect("tempdir");
655 super::validate_plugins_dir(root.path(), ".camel/plugins").expect("should accept");
656 }
657
658 #[cfg(unix)]
659 #[test]
660 fn validate_plugins_dir_rejects_symlink_escape() {
661 let root = tempfile::tempdir().expect("tempdir");
662 let outside = tempfile::tempdir().expect("tempdir outside");
663 let link = root.path().join("link");
664 std::os::unix::fs::symlink(outside.path(), &link).expect("symlink");
665 let err = super::validate_plugins_dir(root.path(), "link/escape").unwrap_err();
666 assert!(err.contains("outside project root"), "got: {err}");
667 }
668
669 #[cfg(unix)]
670 #[test]
671 fn validate_plugins_dir_rejects_symlink_escape_missing_target() {
672 let root = tempfile::tempdir().expect("tempdir");
673 let outside = tempfile::tempdir().expect("tempdir outside");
674 let link = root.path().join("link");
675 std::os::unix::fs::symlink(outside.path(), &link).expect("symlink");
676 let err = super::validate_plugins_dir(root.path(), "link/sub/deep").unwrap_err();
677 assert!(err.contains("outside project root"), "got: {err}");
678 }
679
680 #[test]
682 fn resolve_plugins_dir_defaults_to_plugins_when_no_camel_toml() {
683 let root = tempfile::tempdir().expect("tempdir");
684 let dir = super::resolve_plugins_dir(root.path()).expect("should resolve");
685 assert_eq!(dir, std::path::PathBuf::from("plugins"));
686 }
687
688 #[test]
689 fn resolve_plugins_dir_reads_default_components_wasm_plugins_dir() {
690 let root = tempfile::tempdir().expect("tempdir");
691 std::fs::write(
692 root.path().join("Camel.toml"),
693 "[default.components.wasm]\nplugins_dir = \".camel/plugins\"\n",
694 )
695 .expect("write config");
696 let dir = super::resolve_plugins_dir(root.path()).expect("should resolve");
697 assert_eq!(dir, std::path::PathBuf::from(".camel/plugins"));
698 }
699
700 #[test]
701 fn resolve_plugins_dir_returns_error_on_invalid_toml() {
702 let root = tempfile::tempdir().expect("tempdir");
703 std::fs::write(root.path().join("Camel.toml"), "[invalid\n").expect("write config");
704 let err = super::resolve_plugins_dir(root.path()).unwrap_err();
705 assert!(err.contains("failed to parse"), "got: {err}");
706 }
707
708 #[test]
709 fn resolve_plugins_dir_rejects_invalid_plugins_dir_from_config() {
710 let root = tempfile::tempdir().expect("tempdir");
711 std::fs::write(
712 root.path().join("Camel.toml"),
713 "[default.components.wasm]\nplugins_dir = \"/absolute/path\"\n",
714 )
715 .expect("write config");
716 let err = super::resolve_plugins_dir(root.path()).unwrap_err();
717 assert!(err.contains("relative path"), "got: {err}");
718 }
719}