1use std::collections::{BTreeMap, HashSet};
9use std::fmt;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct CommandError {
13 pub code: &'static str,
14 pub message: String,
15}
16
17impl CommandError {
18 pub fn new(code: &'static str, message: impl Into<String>) -> Self {
19 Self {
20 code,
21 message: message.into(),
22 }
23 }
24}
25
26impl fmt::Display for CommandError {
27 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28 write!(formatter, "{}: {}", self.code, self.message)
29 }
30}
31
32impl std::error::Error for CommandError {}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AppConfig {
36 pub id: String,
37 pub desc: String,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41pub struct RouteHandle(u64);
42
43impl RouteHandle {
44 pub fn from_id(id: u64) -> Self {
45 Self(id)
46 }
47
48 pub fn id(self) -> u64 {
49 self.0
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum OptionKind {
55 Boolean,
56 String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum ParsedValue {
61 Boolean(bool),
62 String(String),
63 Strings(Vec<String>),
64}
65
66impl ParsedValue {
67 fn type_name(&self) -> &'static str {
68 match self {
69 Self::Boolean(_) => "boolean",
70 Self::String(_) => "string",
71 Self::Strings(_) => "strings",
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct OptionSpec {
78 pub id: String,
79 pub long: Option<String>,
80 pub short: Option<char>,
81 pub kind: OptionKind,
82 pub many: bool,
83 pub default: Option<ParsedValue>,
84}
85
86impl OptionSpec {
87 pub fn long_name(&self) -> String {
88 self.long
89 .clone()
90 .unwrap_or_else(|| format!("--{}", self.id))
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ArgumentSpec {
96 pub id: String,
97 pub required: bool,
98 pub many: bool,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct RouteSpec {
103 pub id: String,
104 pub path: Vec<String>,
105 pub aliases: Vec<Vec<String>>,
106 pub desc: String,
107 pub options: Vec<OptionSpec>,
108 pub arguments: Vec<ArgumentSpec>,
109 pub passthrough: bool,
112}
113
114#[derive(Debug, Clone)]
115pub struct Route<H> {
116 pub spec: RouteSpec,
117 pub handler: H,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Request {
122 pub app_id: String,
123 pub route: RouteHandle,
124 pub route_id: String,
125 pub route_path: Vec<String>,
126 pub argv: Vec<String>,
127 pub arguments: BTreeMap<String, ParsedValue>,
128 pub options: BTreeMap<String, ParsedValue>,
129 generation: u64,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Response {
134 pub stdout: String,
135 pub stderr: String,
136 pub exit: i64,
137}
138
139impl Response {
140 pub fn checked(self) -> Result<Self, CommandError> {
141 if !(0..=255).contains(&self.exit) {
142 return Err(CommandError::new(
143 ":command/invalid-response",
144 ":exit must be an integer between 0 and 255",
145 ));
146 }
147 Ok(self)
148 }
149
150 pub fn failure(exit: i64, error: impl fmt::Display) -> Self {
151 Self {
152 stdout: String::new(),
153 stderr: format!("{error}\n"),
154 exit,
155 }
156 }
157}
158
159#[derive(Debug, Clone)]
160struct StoredRoute<H> {
161 handle: RouteHandle,
162 route: Route<H>,
163}
164
165#[derive(Debug, Clone)]
166pub struct Snapshot<H> {
167 routes: Vec<StoredRoute<H>>,
168}
169
170#[derive(Debug, Clone)]
174pub struct App<H> {
175 config: AppConfig,
176 routes: Vec<StoredRoute<H>>,
177 next_handle: u64,
178 generation: u64,
179 closed: bool,
180}
181
182impl<H> App<H> {
183 pub fn create(config: AppConfig) -> Result<Self, CommandError> {
184 validate_config(&config)?;
185 Ok(Self {
186 config,
187 routes: Vec::new(),
188 next_handle: 1,
189 generation: 0,
190 closed: false,
191 })
192 }
193
194 pub fn config(&self) -> AppConfig {
195 self.config.clone()
196 }
197
198 pub fn closed(&self) -> bool {
199 self.closed
200 }
201
202 pub fn routes(&self) -> Result<Vec<RouteSpec>, CommandError> {
203 self.require_open()?;
204 Ok(self
205 .routes
206 .iter()
207 .map(|stored| stored.route.spec.clone())
208 .collect())
209 }
210
211 pub fn install(&mut self, route: Route<H>) -> Result<RouteHandle, CommandError> {
212 self.require_open()?;
213 validate_route(&route.spec)?;
214 let new_paths = route_paths(&route.spec);
215 for existing in &self.routes {
216 for existing_path in route_paths(&existing.route.spec) {
217 if new_paths
218 .iter()
219 .any(|candidate| candidate == &existing_path)
220 {
221 return Err(CommandError::new(
222 ":command/route-conflict",
223 format!(
224 "route {} conflicts with {} at {}",
225 route.spec.id,
226 existing.route.spec.id,
227 display_path(&existing_path)
228 ),
229 ));
230 }
231 }
232 }
233 if self
234 .routes
235 .iter()
236 .any(|stored| stored.route.spec.id == route.spec.id)
237 {
238 return Err(CommandError::new(
239 ":command/route-conflict",
240 format!("route id is already installed: {}", route.spec.id),
241 ));
242 }
243 let handle = RouteHandle(self.next_handle);
244 self.next_handle += 1;
245 self.routes.push(StoredRoute { handle, route });
246 self.generation += 1;
247 Ok(handle)
248 }
249
250 pub fn uninstall(&mut self, handle: RouteHandle) -> Result<bool, CommandError> {
251 self.require_open()?;
252 let Some(index) = self.routes.iter().position(|route| route.handle == handle) else {
253 return Ok(false);
254 };
255 self.routes.remove(index);
256 self.generation += 1;
257 Ok(true)
258 }
259
260 pub fn snapshot(&self) -> Result<Snapshot<H>, CommandError>
261 where
262 H: Clone,
263 {
264 self.require_open()?;
265 Ok(Snapshot {
266 routes: self.routes.clone(),
267 })
268 }
269
270 pub fn restore(&mut self, snapshot: Snapshot<H>) -> Result<(), CommandError> {
271 self.require_open()?;
272 self.routes = snapshot.routes;
273 self.next_handle = self
274 .routes
275 .iter()
276 .map(|stored| stored.handle.id())
277 .max()
278 .unwrap_or(0)
279 + 1;
280 self.generation += 1;
281 Ok(())
282 }
283
284 pub fn reset(&mut self) -> Result<(), CommandError> {
285 self.require_open()?;
286 if !self.routes.is_empty() {
287 self.routes.clear();
288 self.generation += 1;
289 }
290 Ok(())
291 }
292
293 pub fn close(&mut self) {
294 if !self.closed {
295 self.routes.clear();
296 self.generation += 1;
297 self.closed = true;
298 }
299 }
300
301 pub fn parse(&self, argv: Vec<String>) -> Result<Request, CommandError> {
302 self.require_open()?;
303 let (stored, path_length) = self.match_route(&argv)?;
304 let (options, arguments) = parse_route_arguments(&stored.route.spec, &argv[path_length..])?;
305 Ok(Request {
306 app_id: self.config.id.clone(),
307 route: stored.handle,
308 route_id: stored.route.spec.id.clone(),
309 route_path: stored.route.spec.path.clone(),
310 argv,
311 arguments,
312 options,
313 generation: self.generation,
314 })
315 }
316
317 pub fn handler(&self, request: &Request) -> Result<&H, CommandError> {
318 self.require_open()?;
319 if request.app_id != self.config.id {
320 return Err(CommandError::new(
321 ":command/foreign-request",
322 "request belongs to a different application",
323 ));
324 }
325 if request.generation != self.generation {
326 return Err(CommandError::new(
327 ":command/stale-request",
328 "routes changed after this request was parsed",
329 ));
330 }
331 self.routes
332 .iter()
333 .find(|stored| stored.handle == request.route)
334 .map(|stored| &stored.route.handler)
335 .ok_or_else(|| {
336 CommandError::new(
337 ":command/stale-request",
338 "the matched route is no longer installed",
339 )
340 })
341 }
342
343 pub fn run<F>(&self, argv: Vec<String>, invoke: F) -> Response
344 where
345 F: FnOnce(&H, &Request) -> Result<Response, CommandError>,
346 {
347 let request = match self.parse(argv) {
348 Ok(request) => request,
349 Err(error) => return Response::failure(2, error),
350 };
351 let handler = match self.handler(&request) {
352 Ok(handler) => handler,
353 Err(error) => return Response::failure(1, error),
354 };
355 match invoke(handler, &request).and_then(Response::checked) {
356 Ok(response) => response,
357 Err(error) => Response::failure(1, error),
358 }
359 }
360
361 fn require_open(&self) -> Result<(), CommandError> {
362 (!self.closed)
363 .then_some(())
364 .ok_or_else(|| CommandError::new(":command/closed", "application is closed"))
365 }
366
367 fn match_route(&self, argv: &[String]) -> Result<(&StoredRoute<H>, usize), CommandError> {
368 let mut matched: Option<(&StoredRoute<H>, usize)> = None;
369 for stored in &self.routes {
370 for candidate in route_paths(&stored.route.spec) {
371 if candidate.is_empty() {
372 if argv.is_empty() {
373 matched = Some((stored, 0));
374 }
375 continue;
376 }
377 if argv.starts_with(&candidate) {
378 let replace = matched
379 .as_ref()
380 .is_none_or(|(_, length)| candidate.len() > *length);
381 if replace {
382 matched = Some((stored, candidate.len()));
383 }
384 }
385 }
386 }
387 matched.ok_or_else(|| {
388 CommandError::new(
389 ":command/unknown-route",
390 match argv.first() {
391 Some(value) => format!("unknown command: {value}"),
392 None => "no root route is installed".into(),
393 },
394 )
395 })
396 }
397}
398
399fn validate_config(config: &AppConfig) -> Result<(), CommandError> {
400 if config.id.trim().is_empty() {
401 return Err(CommandError::new(
402 ":command/invalid-app",
403 ":id must be non-empty",
404 ));
405 }
406 if config.desc.trim().is_empty() {
407 return Err(CommandError::new(
408 ":command/invalid-app",
409 ":desc must be non-empty",
410 ));
411 }
412 Ok(())
413}
414
415fn validate_route(route: &RouteSpec) -> Result<(), CommandError> {
416 if route.id.trim().is_empty() {
417 return Err(CommandError::new(
418 ":command/invalid-route",
419 ":id must be non-empty",
420 ));
421 }
422 if route.desc.trim().is_empty() {
423 return Err(CommandError::new(
424 ":command/invalid-route",
425 ":desc must be non-empty",
426 ));
427 }
428 for path in route_paths(route) {
429 if path.iter().any(|part| part.trim().is_empty()) {
430 return Err(CommandError::new(
431 ":command/invalid-route",
432 ":path and :aliases may not contain empty segments",
433 ));
434 }
435 }
436 let mut option_ids = HashSet::new();
437 let mut option_long = HashSet::new();
438 let mut option_short = HashSet::new();
439 for option in &route.options {
440 if option.id.trim().is_empty() || !option_ids.insert(option.id.clone()) {
441 return Err(CommandError::new(
442 ":command/invalid-route",
443 "option ids must be unique and non-empty",
444 ));
445 }
446 let long = option.long_name();
447 if !long.starts_with("--") || long.len() <= 2 || !option_long.insert(long.clone()) {
448 return Err(CommandError::new(
449 ":command/invalid-route",
450 format!("invalid or duplicate long option: {long}"),
451 ));
452 }
453 if let Some(short) = option.short {
454 if !option_short.insert(short) {
455 return Err(CommandError::new(
456 ":command/invalid-route",
457 format!("duplicate short option: -{short}"),
458 ));
459 }
460 }
461 if option.many && matches!(option.kind, OptionKind::Boolean) {
462 return Err(CommandError::new(
463 ":command/invalid-route",
464 "boolean options may not be :many?",
465 ));
466 }
467 if let Some(default) = &option.default {
468 let valid = match option.kind {
469 OptionKind::Boolean => matches!(default, ParsedValue::Boolean(_)),
470 OptionKind::String if option.many => matches!(default, ParsedValue::Strings(_)),
471 OptionKind::String => matches!(default, ParsedValue::String(_)),
472 };
473 if !valid {
474 return Err(CommandError::new(
475 ":command/invalid-route",
476 format!(
477 "default for option {} has type {}",
478 option.id,
479 default.type_name()
480 ),
481 ));
482 }
483 }
484 }
485 if route.passthrough && !route.options.is_empty() {
486 return Err(CommandError::new(
487 ":command/invalid-route",
488 ":passthrough? routes may not declare options",
489 ));
490 }
491 let mut argument_ids = HashSet::new();
492 for (index, argument) in route.arguments.iter().enumerate() {
493 if argument.id.trim().is_empty() || !argument_ids.insert(argument.id.clone()) {
494 return Err(CommandError::new(
495 ":command/invalid-route",
496 "argument ids must be unique and non-empty",
497 ));
498 }
499 if argument.many && index + 1 != route.arguments.len() {
500 return Err(CommandError::new(
501 ":command/invalid-route",
502 "only the final positional argument may be :many?",
503 ));
504 }
505 }
506 Ok(())
507}
508
509fn route_paths(route: &RouteSpec) -> Vec<Vec<String>> {
510 let mut paths = vec![route.path.clone()];
511 paths.extend(route.aliases.clone());
512 paths
513}
514
515fn display_path(path: &[String]) -> String {
516 if path.is_empty() {
517 "<root>".into()
518 } else {
519 path.join(" ")
520 }
521}
522
523fn parse_route_arguments(
524 route: &RouteSpec,
525 argv: &[String],
526) -> Result<(BTreeMap<String, ParsedValue>, BTreeMap<String, ParsedValue>), CommandError> {
527 let mut options = BTreeMap::new();
528 for option in &route.options {
529 let default = option.default.clone().unwrap_or_else(|| match option.kind {
530 OptionKind::Boolean => ParsedValue::Boolean(false),
531 OptionKind::String if option.many => ParsedValue::Strings(Vec::new()),
532 OptionKind::String => ParsedValue::String(String::new()),
533 });
534 options.insert(option.id.clone(), default);
535 }
536 if route.passthrough {
537 return Ok((options, parse_positionals(route, argv.to_vec())?));
538 }
539 let mut positional = Vec::new();
540 let mut supplied = HashSet::new();
541 let mut options_enabled = true;
542 let mut index = 0;
543 while index < argv.len() {
544 let value = &argv[index];
545 if options_enabled && value == "--" {
546 options_enabled = false;
547 index += 1;
548 continue;
549 }
550 if options_enabled && value.starts_with("--") && value.len() > 2 {
551 let (name, inline) = value
552 .split_once('=')
553 .map_or((value.as_str(), None), |(name, value)| {
554 (name, Some(value.to_owned()))
555 });
556 let option = route
557 .options
558 .iter()
559 .find(|option| option.long_name() == name)
560 .ok_or_else(|| {
561 CommandError::new(":command/unknown-option", format!("unknown option: {name}"))
562 })?;
563 index = parse_option(option, inline, argv, index, &mut options, &mut supplied)?;
564 continue;
565 }
566 if options_enabled && value.starts_with('-') && value.len() == 2 {
567 let short = value.chars().nth(1).expect("two-character option");
568 let option = route
569 .options
570 .iter()
571 .find(|option| option.short == Some(short))
572 .ok_or_else(|| {
573 CommandError::new(
574 ":command/unknown-option",
575 format!("unknown option: -{short}"),
576 )
577 })?;
578 index = parse_option(option, None, argv, index, &mut options, &mut supplied)?;
579 continue;
580 }
581 positional.push(value.clone());
582 index += 1;
583 }
584 Ok((options, parse_positionals(route, positional)?))
585}
586
587fn parse_positionals(
588 route: &RouteSpec,
589 positional: Vec<String>,
590) -> Result<BTreeMap<String, ParsedValue>, CommandError> {
591 let mut arguments = BTreeMap::new();
592 let mut cursor = 0;
593 for argument in &route.arguments {
594 if argument.many {
595 let values = positional[cursor..].to_vec();
596 if argument.required && values.is_empty() {
597 return Err(CommandError::new(
598 ":command/missing-argument",
599 format!("missing argument: {}", argument.id),
600 ));
601 }
602 arguments.insert(argument.id.clone(), ParsedValue::Strings(values));
603 cursor = positional.len();
604 break;
605 }
606 let value = positional.get(cursor).cloned();
607 match value {
608 Some(value) => {
609 arguments.insert(argument.id.clone(), ParsedValue::String(value));
610 cursor += 1;
611 }
612 None if argument.required => {
613 return Err(CommandError::new(
614 ":command/missing-argument",
615 format!("missing argument: {}", argument.id),
616 ));
617 }
618 None => {
619 arguments.insert(argument.id.clone(), ParsedValue::String(String::new()));
620 }
621 }
622 }
623 if cursor != positional.len() {
624 return Err(CommandError::new(
625 ":command/unexpected-argument",
626 format!("unexpected argument: {}", positional[cursor]),
627 ));
628 }
629 Ok(arguments)
630}
631
632fn parse_option(
633 option: &OptionSpec,
634 inline: Option<String>,
635 argv: &[String],
636 index: usize,
637 output: &mut BTreeMap<String, ParsedValue>,
638 supplied: &mut HashSet<String>,
639) -> Result<usize, CommandError> {
640 match option.kind {
641 OptionKind::Boolean => {
642 if inline.is_some() {
643 return Err(CommandError::new(
644 ":command/invalid-option",
645 format!("{} does not accept a value", option.long_name()),
646 ));
647 }
648 if !supplied.insert(option.id.clone()) {
649 return Err(CommandError::new(
650 ":command/duplicate-option",
651 format!("{} may be supplied only once", option.long_name()),
652 ));
653 }
654 output.insert(option.id.clone(), ParsedValue::Boolean(true));
655 Ok(index + 1)
656 }
657 OptionKind::String => {
658 let (value, next) = match inline {
659 Some(value) => (value, index + 1),
660 None => {
661 let value = argv.get(index + 1).cloned().ok_or_else(|| {
662 CommandError::new(
663 ":command/missing-option-value",
664 format!("{} requires a value", option.long_name()),
665 )
666 })?;
667 (value, index + 2)
668 }
669 };
670 if option.many {
671 let mut values = match output.remove(&option.id) {
672 Some(ParsedValue::Strings(values)) => values,
673 Some(_) => unreachable!("validated option defaults"),
674 None => Vec::new(),
675 };
676 values.push(value);
677 output.insert(option.id.clone(), ParsedValue::Strings(values));
678 } else {
679 if !supplied.insert(option.id.clone()) {
680 return Err(CommandError::new(
681 ":command/duplicate-option",
682 format!("{} may be supplied only once", option.long_name()),
683 ));
684 }
685 output.insert(option.id.clone(), ParsedValue::String(value));
686 }
687 Ok(next)
688 }
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 fn app() -> App<String> {
697 App::create(AppConfig {
698 id: "demo/app".into(),
699 desc: "Demo app".into(),
700 })
701 .unwrap()
702 }
703
704 fn route(id: &str, path: &[&str]) -> Route<String> {
705 Route {
706 spec: RouteSpec {
707 id: id.into(),
708 path: path.iter().map(|part| (*part).into()).collect(),
709 aliases: Vec::new(),
710 desc: format!("{id} route"),
711 options: Vec::new(),
712 arguments: Vec::new(),
713 passthrough: false,
714 },
715 handler: id.into(),
716 }
717 }
718
719 #[test]
720 fn parses_nested_routes_and_normalizes_options() {
721 let mut app = app();
722 let mut test = route("test", &["test"]);
723 test.spec.options = vec![
724 OptionSpec {
725 id: "watch".into(),
726 long: None,
727 short: Some('w'),
728 kind: OptionKind::Boolean,
729 many: false,
730 default: None,
731 },
732 OptionSpec {
733 id: "namespace".into(),
734 long: None,
735 short: Some('n'),
736 kind: OptionKind::String,
737 many: true,
738 default: None,
739 },
740 ];
741 test.spec.arguments = vec![ArgumentSpec {
742 id: "files".into(),
743 required: false,
744 many: true,
745 }];
746 app.install(test).unwrap();
747 app.install(route("bundle-run", &["bundle", "run"]))
748 .unwrap();
749
750 let request = app
751 .parse(vec![
752 "test".into(),
753 "-w".into(),
754 "--namespace=demo.core".into(),
755 "-n".into(),
756 "demo.util".into(),
757 "--".into(),
758 "--not-an-option".into(),
759 ])
760 .unwrap();
761 assert_eq!(request.route_id, "test");
762 assert_eq!(
763 request.options.get("watch"),
764 Some(&ParsedValue::Boolean(true))
765 );
766 assert_eq!(
767 request.options.get("namespace"),
768 Some(&ParsedValue::Strings(vec![
769 "demo.core".into(),
770 "demo.util".into()
771 ]))
772 );
773 assert_eq!(
774 request.arguments.get("files"),
775 Some(&ParsedValue::Strings(vec!["--not-an-option".into()]))
776 );
777 assert_eq!(
778 app.parse(vec!["bundle".into(), "run".into()])
779 .unwrap()
780 .route_id,
781 "bundle-run"
782 );
783 }
784
785 #[test]
786 fn collisions_lifecycle_and_stale_requests_are_deterministic() {
787 let mut app = app();
788 let handle = app.install(route("test", &["test"])).unwrap();
789 let collision = app.install(route("again", &["test"])).unwrap_err();
790 assert_eq!(collision.code, ":command/route-conflict");
791 let request = app.parse(vec!["test".into()]).unwrap();
792 let snapshot = app.snapshot().unwrap();
793 assert!(app.uninstall(handle).unwrap());
794 assert!(!app.uninstall(handle).unwrap());
795 assert_eq!(
796 app.handler(&request).unwrap_err().code,
797 ":command/stale-request"
798 );
799 app.restore(snapshot).unwrap();
800 assert_eq!(app.routes().unwrap().len(), 1);
801 app.reset().unwrap();
802 app.reset().unwrap();
803 app.close();
804 app.close();
805 assert!(app.closed());
806 assert_eq!(app.routes().unwrap_err().code, ":command/closed");
807 }
808
809 #[test]
810 fn run_maps_parse_and_handler_errors_to_cli_responses() {
811 let mut app = app();
812 app.install(route("ok", &["ok"])).unwrap();
813 assert_eq!(
814 app.run(vec!["missing".into()], |_, _| unreachable!()),
815 Response {
816 stdout: String::new(),
817 stderr: ":command/unknown-route: unknown command: missing\n".into(),
818 exit: 2,
819 }
820 );
821 assert_eq!(
822 app.run(vec!["ok".into()], |_, _| {
823 Ok(Response {
824 stdout: "ok".into(),
825 stderr: String::new(),
826 exit: 0,
827 })
828 }),
829 Response {
830 stdout: "ok".into(),
831 stderr: String::new(),
832 exit: 0,
833 }
834 );
835 assert_eq!(
836 app.run(vec!["ok".into()], |_, _| {
837 Ok(Response {
838 stdout: String::new(),
839 stderr: String::new(),
840 exit: 256,
841 })
842 })
843 .exit,
844 1
845 );
846 }
847
848 #[test]
849 fn passthrough_routes_preserve_delegated_flags_and_restore_advances_handles() {
850 let mut app = app();
851 let mut delegated = route("delegated", &["delegate"]);
852 delegated.spec.passthrough = true;
853 delegated.spec.arguments = vec![ArgumentSpec {
854 id: "argv".into(),
855 required: false,
856 many: true,
857 }];
858 let handle = app.install(delegated).unwrap();
859 let snapshot = app.snapshot().unwrap();
860 app.reset().unwrap();
861 app.restore(snapshot).unwrap();
862 let later = app.install(route("later", &["later"])).unwrap();
863 assert!(later.id() > handle.id());
864 assert_eq!(
865 app.parse(vec![
866 "delegate".into(),
867 "--owned-by-handler".into(),
868 "value".into()
869 ])
870 .unwrap()
871 .arguments
872 .get("argv"),
873 Some(&ParsedValue::Strings(vec![
874 "--owned-by-handler".into(),
875 "value".into()
876 ]))
877 );
878
879 let mut duplicate = route("duplicate", &["duplicate"]);
880 duplicate.spec.options = vec![OptionSpec {
881 id: "name".into(),
882 long: None,
883 short: None,
884 kind: OptionKind::String,
885 many: false,
886 default: Some(ParsedValue::String(String::new())),
887 }];
888 app.install(duplicate).unwrap();
889 assert_eq!(
890 app.parse(vec![
891 "duplicate".into(),
892 "--name=".into(),
893 "--name".into(),
894 "again".into(),
895 ])
896 .unwrap_err()
897 .code,
898 ":command/duplicate-option"
899 );
900 }
901}