rdsh 0.9.2

Rust port/shim for dsh: fast launcher, token saver, zero behavior breakage via passthrough
/// dsh-compatible launcher arg splitter (mirrors lib/bin.js semantics, read-only).
/// Launcher flags come first; first unrecognized token starts inner app-args.
/// `dsh <name>` abbreviates `dsh --profile <name>`.
pub enum Launcher {
    Help,
    Boot {
        profile: String,
        from_default: Option<String>,
        patches: Vec<String>,
        app_args: Vec<String>,
    },
    Dump {
        profile: String,
        from_default: Option<String>,
        kind: DumpKind,
        patches: Vec<String>,
    },
    Plugin {
        profile: String,
        pnpm_args: Vec<String>,
    },
    Error(String),
}

/// Which config dump the user asked for (mutually exclusive flags).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DumpKind {
    Config,
    DefaultConfig,
    Schema,
}

pub fn split_launcher_args(profile_flag: Option<String>, extra: Vec<String>) -> Launcher {
    if extra.first().is_some_and(|s| s.as_str() == "plugin") {
        let mut profile = profile_flag;
        let mut pnpm: Vec<String> = Vec::new();
        let mut it = extra.into_iter().skip(1);
        while let Some(a) = it.next() {
            if a == "--profile" {
                profile = it.next();
            } else if let Some(v) = a.strip_prefix("--profile=") {
                profile = Some(v.to_string());
            } else {
                pnpm.push(a);
                pnpm.extend(it);
                break;
            }
        }
        return match profile {
            Some(p) if !p.is_empty() && !pnpm.is_empty() => Launcher::Plugin {
                profile: p,
                pnpm_args: pnpm,
            },
            _ if pnpm.is_empty() => Launcher::Error(
                "error: plugin needs pnpm arguments to forward (e.g. add <package>)".into(),
            ),
            _ => Launcher::Error("error: --profile <name> is required".into()),
        };
    }
    let mut profile = profile_flag;
    let mut from_default: Option<String> = None;
    let mut patches: Vec<String> = Vec::new();
    let mut dump = false;
    let mut dump_default = false;
    let mut dump_schema = false;
    let mut app_args: Vec<String> = Vec::new();
    let mut positional: Vec<String> = Vec::new();
    let mut it = extra.into_iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "-h" | "--help" if profile.is_none() && positional.is_empty() => return Launcher::Help,
            "--profile" => {
                if profile.is_some() {
                    return Launcher::Error("error: select a profile only once".into());
                }
                profile = it.next();
                if profile.is_none() {
                    return Launcher::Error("error: --profile needs a name".into());
                }
            }
            "--from-default-profile" => match it.next() {
                Some(p) if !p.is_empty() => from_default = Some(p),
                _ => {
                    return Launcher::Error("error: --from-default-profile needs a name".into());
                }
            },
            "--patch" => match it.next() {
                Some(p) if !p.is_empty() => patches.push(p),
                _ => return Launcher::Error("error: --patch needs a path".into()),
            },
            "--dump-config" => dump = true,
            "--dump-default-config" => dump_default = true,
            "--dump-config-schema" => dump_schema = true,
            _ if a.starts_with("--profile=") => {
                if profile.is_some() {
                    return Launcher::Error("error: select a profile only once".into());
                }
                profile = a.strip_prefix("--profile=").map(|v| v.to_string());
            }
            _ if a.starts_with('-') => {
                app_args.push(a);
                app_args.extend(it);
                break;
            }
            _ => positional.push(a),
        }
    }
    if profile.is_none() {
        let mut pos = positional.into_iter();
        if let Some(p) = pos.next() {
            profile = Some(p);
            let rest: Vec<String> = pos.collect();
            if !rest.is_empty() {
                app_args = [rest, app_args].concat();
            }
        }
    } else if !positional.is_empty() {
        app_args = [positional, app_args].concat();
    }
    let profile = match profile {
        Some(p) if !p.is_empty() => p,
        _ => {
            if dump || dump_default || dump_schema {
                return Launcher::Error("error: --profile <name> is required".into());
            }
            return Launcher::Help;
        }
    };
    if profile.to_lowercase() == "desktop" {
        return Launcher::Error(
            "error: profile desktop is managed exclusively by the Electron application".into(),
        );
    }
    let dumps = [dump, dump_default, dump_schema]
        .iter()
        .copied()
        .filter(|x| *x)
        .count();
    if dumps > 1 {
        return Launcher::Error("error: --dump-config, --dump-default-config, and --dump-config-schema are mutually exclusive".into());
    }
    if dumps == 1 {
        if !app_args.is_empty() {
            return Launcher::Error("error: config dumps take no app arguments".into());
        }
        if dump_default && !patches.is_empty() {
            return Launcher::Error(
                "error: --dump-default-config prints the bundle layers and takes no --patch".into(),
            );
        }
        let kind = if dump {
            DumpKind::Config
        } else if dump_default {
            DumpKind::DefaultConfig
        } else {
            DumpKind::Schema
        };
        return Launcher::Dump {
            profile,
            from_default,
            kind,
            patches,
        };
    }
    if app_args.first().is_some_and(|s| s.as_str() == "--") {
        app_args.remove(0);
    }
    Launcher::Boot {
        profile,
        from_default,
        patches,
        app_args,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sv(v: &[&str]) -> Vec<String> {
        v.iter().map(ToString::to_string).collect()
    }

    #[test]
    fn positional_is_profile() {
        match split_launcher_args(None, sv(&["tui"])) {
            Launcher::Boot {
                profile, app_args, ..
            } => {
                assert_eq!(profile, "tui");
                assert!(app_args.is_empty());
            }
            _ => panic!("expected boot"),
        }
    }

    #[test]
    fn positional_profile_preserves_prompt() {
        match split_launcher_args(None, sv(&["headless", "run the tests"])) {
            Launcher::Boot {
                profile, app_args, ..
            } => {
                assert_eq!(profile, "headless");
                assert_eq!(app_args, sv(&["run the tests"]));
            }
            _ => panic!("expected boot"),
        }
    }

    #[test]
    fn positional_profile_preserves_empty_arguments_and_following_app_flags() {
        match split_launcher_args(None, sv(&["headless", "", "run", "--resume", "abc"])) {
            Launcher::Boot {
                profile, app_args, ..
            } => {
                assert_eq!(profile, "headless");
                assert_eq!(app_args, sv(&["", "run", "--resume", "abc"]));
            }
            _ => panic!("expected boot"),
        }
    }

    #[test]
    fn positional_profile_preserves_double_dash_boundary() {
        match split_launcher_args(None, sv(&["headless", "--", "run the tests"])) {
            Launcher::Boot {
                profile, app_args, ..
            } => {
                assert_eq!(profile, "headless");
                assert_eq!(app_args, sv(&["run the tests"]));
            }
            _ => panic!("expected boot"),
        }
    }

    #[test]
    fn unknown_flag_starts_app_args() {
        match split_launcher_args(Some("tui".into()), sv(&["--resume", "abc"])) {
            Launcher::Boot {
                profile, app_args, ..
            } => {
                assert_eq!(profile, "tui");
                assert_eq!(app_args, sv(&["--resume", "abc"]));
            }
            _ => panic!("expected boot"),
        }
    }

    #[test]
    fn rejects_desktop() {
        match split_launcher_args(None, sv(&["desktop"])) {
            Launcher::Error(_) => {}
            _ => panic!("expected error"),
        }
    }

    #[test]
    fn rejects_double_profile() {
        match split_launcher_args(Some("a".into()), sv(&["--profile", "b"])) {
            Launcher::Error(_) => {}
            _ => panic!("expected error"),
        }
    }

    #[test]
    fn rejects_dump_with_args() {
        match split_launcher_args(Some("tui".into()), sv(&["--dump-config", "--foo"])) {
            Launcher::Error(_) => {}
            _ => panic!("expected error"),
        }
    }

    #[test]
    fn abbreviated_profile_keeps_positional_args() {
        // `rdsh headless "run the tests"` must match explicit --profile.
        match split_launcher_args(None, sv(&["headless", "run the tests"])) {
            Launcher::Boot {
                profile, app_args, ..
            } => {
                assert_eq!(profile, "headless");
                assert_eq!(app_args, sv(&["run the tests"]));
            }
            _ => panic!("expected boot"),
        }
        match split_launcher_args(Some("headless".into()), sv(&["run the tests"])) {
            Launcher::Boot {
                profile, app_args, ..
            } => {
                assert_eq!(profile, "headless");
                assert_eq!(app_args, sv(&["run the tests"]));
            }
            _ => panic!("expected boot"),
        }
    }

    #[test]
    fn dump_flags_keep_their_kind() {
        for (flag, expected) in [
            ("--dump-config", DumpKind::Config),
            ("--dump-default-config", DumpKind::DefaultConfig),
            ("--dump-config-schema", DumpKind::Schema),
        ] {
            match split_launcher_args(Some("web".into()), sv(&[flag])) {
                Launcher::Dump { kind, .. } => assert_eq!(kind, expected),
                _ => panic!("expected dump for {flag}"),
            }
        }
    }

    #[test]
    fn abbreviated_profile_keeps_many_positional_args_in_order() {
        match split_launcher_args(None, sv(&["web", "hello", "world"])) {
            Launcher::Boot {
                profile, app_args, ..
            } => {
                assert_eq!(profile, "web");
                assert_eq!(app_args, sv(&["hello", "world"]));
            }
            _ => panic!("expected boot"),
        }
        // Empty-string and flag-like app args keep order after the profile.
        match split_launcher_args(None, sv(&["web", "", "--foo", "bar"])) {
            Launcher::Boot {
                profile, app_args, ..
            } => {
                assert_eq!(profile, "web");
                assert_eq!(app_args, sv(&["", "--foo", "bar"]));
            }
            _ => panic!("expected boot"),
        }
    }

    #[test]
    fn dump_keeps_from_default_profile() {
        match split_launcher_args(
            None,
            sv(&["rescue", "--from-default-profile", "web", "--dump-config"]),
        ) {
            Launcher::Dump {
                profile,
                from_default,
                kind,
                ..
            } => {
                assert_eq!(profile, "rescue");
                assert_eq!(from_default.as_deref(), Some("web"));
                assert_eq!(kind, DumpKind::Config);
            }
            _ => panic!("expected dump"),
        }
    }

    /// `--profile` と `--patch` は値欠落をエラーにするので、この旗も揃える。
    /// 黙って落とすと「テンプレート初期化なしの boot」に化ける。
    #[test]
    fn from_default_profile_without_value_is_rejected() {
        match split_launcher_args(None, sv(&["rescue", "--from-default-profile"])) {
            Launcher::Error(msg) => assert!(msg.contains("--from-default-profile"), "{msg}"),
            _ => panic!("expected an error for a value-less --from-default-profile"),
        }
    }

    #[test]
    fn plugin_needs_pnpm_args() {
        match split_launcher_args(Some("tui".into()), sv(&["plugin"])) {
            Launcher::Error(_) => {}
            _ => panic!("expected error"),
        }
    }
}