Skip to main content

hara_native/
command.rs

1//! Shared application-command routing.
2//!
3//! The host-facing `std.native.Command` implementation and the native
4//! maintenance launcher deliberately share this model. The model knows how to
5//! validate routes and turn argv into a deterministic request; it does not
6//! know how a host invokes a handler or writes a response.
7
8use 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    /// Preserve all remaining argv values as positional arguments. This is
110    /// for delegated tools whose flags are owned by a downstream handler.
111    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/// A context-local command application. `H` is intentionally opaque so native
171/// hosts can use guest functions while the Rust CLI uses Rust handlers against
172/// exactly the same router.
173#[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}