// main.rs --- Pmacs editor entry point. //! Pmacs binary entry point. //! //! Parses command-line arguments and dispatches to [`pmacs::editor::run`]. //! //! # Command-line surface //! //! ```text //! pmacs [-nw|--no-window] [--help] [--version] [FILE] //! ``` //! //! * `FILE` (positional, optional): file to open. Without one, the editor //! opens an empty `*scratch*` buffer. //! * `-nw` / `--no-window`: select the terminal (TUI) frontend explicitly. //! This is the *only* frontend pmacs ships in v0.1, so the flag is //! currently a no-op marker — but it's parsed and recorded now so that //! when a GUI frontend lands in M4 ("The Service Layer"), `pmacs` with //! no flags will default to the GUI and `pmacs -nw` will keep launching //! the TUI exactly as it does today. This mirrors GNU Emacs's //! `emacs -nw` and Doom's behavior, and lets users wire `pmacs -nw` into //! `EDITOR=` / git hooks today without their config breaking when the //! GUI ships. //! * `--help` / `-h`, `--version` / `-V`: standard. //! //! Anything else is a usage error and exits 2. //! //! # Frontend selection (planning note for M4) //! //! When the GUI lands, the entry-point split looks like this: //! //! ```ignore //! match selected_frontend(&args) { //! Frontend::Tui => editor::run_tui(file), //! Frontend::Gui => editor::run_gui(file), //! } //! ``` //! //! Selection precedence (high to low): //! 1. Explicit `-nw` / `--no-window` → TUI. //! 2. Explicit `--gui` (future) → GUI. //! 3. `PMACS_FRONTEND=tui|gui` env var. //! 4. `$DISPLAY` / `$WAYLAND_DISPLAY` present and a GUI build was linked //! in → GUI; otherwise → TUI. //! 5. Fallback: TUI. //! //! `editor::run` stays as the canonical TUI entry point. The split //! happens in `main`, not deeper, so the rest of the codebase stays //! frontend-agnostic at the [`pmacs::frontend`] trait surface. use std::path::PathBuf; use std::process::ExitCode; use pmacs::protocol::{AttachTarget, AttachTargetError}; const USAGE: &str = "\ usage: pmacs [-nw|--no-window] [--help] [--version] [FILE] pmacs --daemon [--socket NAME|PATH] pmacs --attach [--socket NAME|PATH] pmacs --attach pmacs --daemon-attach [--socket NAME|PATH] -nw, --no-window select the TUI frontend explicitly (currently the only frontend; reserved for the M4 GUI rollout, where `pmacs` will default to the GUI and `-nw` will keep launching the TUI) --daemon run as a foreground daemon listening on a Unix socket; supervised by the user (systemd, tmux, `nohup &`, etc.) --attach connect to a running daemon as a frontend; F12 detaches without killing the daemon. With a positional , attach over the named transport instead of the local socket. --daemon-attach far-side bridge: connect to the local daemon and forward bytes between stdin/stdout and the socket. Used by remote transports (SSH, etc.); does not take over the terminal. --socket NAME|PATH bare name → /pmacs/NAME.sock; absolute or relative path → used as-is. Default: `default`. -h, --help print this message and exit -V, --version print the version and exit attach shorthand: pmacs --attach mac-studio ssh to host mac-studio pmacs --attach user@host ssh as user pmacs --attach ssh:user@host/research ssh, target instance `research` pmacs --attach local:/tmp/foo.sock explicit local socket path pmacs --attach tls:host:port#cert.pem TLS (parses; activation in v0.2) A bare hostname is interpreted as `ssh:`. Use `local:` or `--socket` for local-socket attaches. "; /// Frontend the user asked for. Only `Tui` is implemented in v0.1; /// `GuiAuto` records "the user did not force TUI" so future builds /// can dispatch to a GUI frontend without touching the parsing layer. #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum FrontendChoice { /// Explicit `-nw` / `--no-window`. Always TUI. Tui, /// Default. v0.1: TUI. M4+: TUI when no display is available, GUI otherwise. Auto, } #[derive(Debug, PartialEq, Eq)] struct CliArgs { mode: Mode, } /// Top-level dispatch mode chosen by the CLI flags. #[derive(Debug, PartialEq, Eq)] enum Mode { /// Plain `pmacs` (or with `-nw` / a file): run a fresh in-process /// TUI. The default for v0.1 — no daemon-attach magic. Local { file: Option, frontend: FrontendChoice, }, /// `pmacs --daemon [--socket ...]`: run a foreground daemon on a /// Unix socket, supervised by the user. Daemon { socket: Option }, /// `pmacs --attach [...]`: connect to a daemon and pump the /// local terminal. The form depends on whether the user gave a /// positional `` or relied on `--socket NAME` / the /// default local socket. Attach(AttachMode), /// `pmacs --daemon-attach [--socket ...]`: byte-bridge stdin/stdout /// to the local daemon. The far-side mode used by SSH and other /// remote transports — does not take over the terminal. DaemonAttach { socket: Option }, } /// Sub-form of [`Mode::Attach`]. /// /// `LocalSocket` defers path resolution to dispatch time so /// `parse_args` stays pure of `$XDG_RUNTIME_DIR`. `Target` carries /// an already-parsed [`AttachTarget`] for the positional form. #[derive(Debug, PartialEq, Eq)] enum AttachMode { /// `--attach [--socket NAME|PATH]` — local-socket form. The /// `Option` matches the previous `Mode::Attach { socket }` /// shape; resolution to a `PathBuf` happens at dispatch. LocalSocket(Option), /// `--attach ` — positional form. The bare-hostname /// shorthand has already been applied (a positional without a /// kind prefix becomes an `ssh:` target before parse). Target(AttachTarget), } #[derive(Debug)] enum CliResult { Run(CliArgs), Help, Version, Error(String), } /// Build the [`AttachMode`] for a given `(positional, socket)` pair. /// /// Returns `Err(msg)` if the combination is invalid (positional + /// `--socket`, non-UTF-8 positional, or unparseable target). The /// caller wraps `Ok(mode)` in `CliResult::Run` and `Err(msg)` in /// `CliResult::Error`. fn build_attach_mode(file: Option, socket: Option) -> Result { let Some(positional) = file else { return Ok(AttachMode::LocalSocket(socket)); }; if socket.is_some() { return Err("--attach and --socket cannot be combined".into()); } let target_str = positional .to_str() .ok_or_else(|| "attach must be valid UTF-8".to_string())? .to_string(); parse_attach_target_with_shorthand(&target_str) .map(AttachMode::Target) .map_err(|e| format!("invalid attach target {target_str:?}: {e}")) } /// Parse an `--attach ` positional, applying the /// bare-hostname → `ssh:` shorthand. /// /// The general [`AttachTarget::parse`] grammar requires a kind /// prefix (`ssh:`, `local:`, `tls:`, `custom:`), but the spec calls /// for `pmacs --attach mac-studio` to default to SSH. This helper /// inserts the prefix when the input has no `:` at all. Inputs that /// already contain a colon are passed through to the strict parser /// unchanged, so `local:/tmp/x.sock`, `ssh:user@host/name`, etc. /// continue to work. /// /// IPv6 literals like `[::1]` are not supported as bare hostnames /// — the strict parser would reject them anyway. v0.1 SSH config /// aliases and IPv4 / DNS hostnames are the supported shapes. fn parse_attach_target_with_shorthand(s: &str) -> Result { if s.contains(':') { AttachTarget::parse(s) } else { AttachTarget::parse(&format!("ssh:{s}")) } } fn parse_args(args: &[String]) -> CliResult { let mut file: Option = None; let mut frontend = FrontendChoice::Auto; let mut daemon = false; let mut attach = false; let mut daemon_attach = false; let mut socket: Option = None; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.as_str() { "-nw" | "--no-window" => frontend = FrontendChoice::Tui, "--daemon" => daemon = true, "--attach" => attach = true, "--daemon-attach" => daemon_attach = true, "--socket" => match iter.next() { Some(s) => socket = Some(s.clone()), None => return CliResult::Error("--socket requires a value".into()), }, "-h" | "--help" => return CliResult::Help, "-V" | "--version" => return CliResult::Version, "--" => { // Treat the rest as positional, even if they look like flags. if let Some(p) = iter.next() { if file.is_some() { return CliResult::Error("multiple files not yet supported".into()); } file = Some(PathBuf::from(p)); } if iter.next().is_some() { return CliResult::Error("multiple files not yet supported".into()); } } flag if flag.starts_with('-') => { return CliResult::Error(format!("unknown option: {flag}")); } path => { if file.is_some() { return CliResult::Error("multiple files not yet supported".into()); } file = Some(PathBuf::from(path)); } } } let mode_flags = u8::from(daemon) + u8::from(attach) + u8::from(daemon_attach); if mode_flags > 1 { return CliResult::Error( "--daemon, --attach, and --daemon-attach are mutually exclusive".into(), ); } if daemon { if file.is_some() { return CliResult::Error("--daemon does not take a file argument".into()); } if frontend == FrontendChoice::Tui { return CliResult::Error("--daemon and --no-window are mutually exclusive".into()); } return CliResult::Run(CliArgs { mode: Mode::Daemon { socket }, }); } if attach { return match build_attach_mode(file, socket) { Ok(mode) => CliResult::Run(CliArgs { mode: Mode::Attach(mode), }), Err(msg) => CliResult::Error(msg), }; } if daemon_attach { if file.is_some() { return CliResult::Error("--daemon-attach does not take a file argument".into()); } if frontend == FrontendChoice::Tui { return CliResult::Error( "--daemon-attach and --no-window are mutually exclusive".into(), ); } return CliResult::Run(CliArgs { mode: Mode::DaemonAttach { socket }, }); } CliResult::Run(CliArgs { mode: Mode::Local { file, frontend }, }) } fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); match parse_args(&args) { CliResult::Help => { print!("{USAGE}"); ExitCode::SUCCESS } CliResult::Version => { println!("pmacs {}", env!("CARGO_PKG_VERSION")); ExitCode::SUCCESS } CliResult::Error(msg) => { eprintln!("pmacs: {msg}"); eprint!("{USAGE}"); ExitCode::from(2) } CliResult::Run(parsed) => match parsed.mode { // FrontendChoice::Auto and ::Tui both run the TUI in v0.1. // The match is structured this way deliberately so that // when a GUI frontend lands the second arm becomes // `editor::run_gui(file)` without touching parsing. Mode::Local { file, frontend: FrontendChoice::Tui | FrontendChoice::Auto, } => match pmacs::editor::run(file) { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("pmacs: {e}"); ExitCode::FAILURE } }, Mode::Daemon { socket } => { let socket_path = pmacs::socket_path::resolve_socket_path(socket.as_deref()); // The user-provided NAME (no slashes) becomes the // instance's `instance_name` for display in // `Hello.instance_identity`. Absolute / relative paths // don't have a natural name; treat as None. let instance_name = match socket { Some(s) if !s.contains('/') => Some(s), _ => None, }; match pmacs::daemon::run_daemon(socket_path, instance_name) { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("pmacs: {e}"); ExitCode::FAILURE } } } Mode::Attach(AttachMode::LocalSocket(socket)) => { let socket_path = pmacs::socket_path::resolve_socket_path(socket.as_deref()); match pmacs::attach::run_attach(socket_path) { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("pmacs: {e}"); ExitCode::FAILURE } } } Mode::Attach(AttachMode::Target(target)) => { use pmacs::attach_dispatch::{AttachDispatch, dispatch_attach}; match dispatch_attach(Some(target)) { AttachDispatch::RunAttachLocalSocket(path) => { match pmacs::attach::run_attach(path) { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("pmacs: {e}"); ExitCode::FAILURE } } } AttachDispatch::RunAttachSsh(ssh_target) => { match pmacs::attach::run_attach_ssh(ssh_target) { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("pmacs: {e}"); ExitCode::FAILURE } } } d @ AttachDispatch::DeferredInV01 { .. } => { eprintln!( "pmacs: {}", d.deferred_message() .expect("DeferredInV01 always has a message"), ); ExitCode::FAILURE } AttachDispatch::RunLocal => { // RunLocal is the None-target case from // post-init dispatcher; CLI always supplies // Some(target) here. unreachable!("CLI dispatch always provides Some(target)") } } } Mode::DaemonAttach { socket } => { let socket_path = pmacs::socket_path::resolve_socket_path(socket.as_deref()); match pmacs::daemon_attach::run_daemon_attach(socket_path) { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("pmacs: {e}"); ExitCode::FAILURE } } } }, } } #[cfg(test)] mod tests { use super::*; fn args(slice: &[&str]) -> Vec { slice.iter().map(|s| (*s).to_string()).collect() } fn local_mode(parsed: CliArgs) -> (Option, FrontendChoice) { match parsed.mode { Mode::Local { file, frontend } => (file, frontend), other => panic!("expected Local mode, got {other:?}"), } } #[test] fn no_args_runs_with_no_file_in_auto_frontend() { match parse_args(&args(&[])) { CliResult::Run(p) => { let (file, frontend) = local_mode(p); assert!(file.is_none()); assert_eq!(frontend, FrontendChoice::Auto); } other => panic!("expected Run; got {other:?}"), } } #[test] fn nw_flag_selects_tui() { for flag in &["-nw", "--no-window"] { match parse_args(&args(&[flag])) { CliResult::Run(p) => { let (_, frontend) = local_mode(p); assert_eq!(frontend, FrontendChoice::Tui); } other => panic!("expected Run; got {other:?}"), } } } #[test] fn nw_flag_with_file_works_in_either_order() { for line in &[vec!["-nw", "README.md"], vec!["README.md", "-nw"]] { match parse_args(&args(line)) { CliResult::Run(p) => { let (file, frontend) = local_mode(p); assert_eq!(frontend, FrontendChoice::Tui); assert_eq!(file, Some(PathBuf::from("README.md"))); } other => panic!("expected Run for {line:?}; got {other:?}"), } } } #[test] fn unknown_flag_errors() { match parse_args(&args(&["--bogus"])) { CliResult::Error(m) => assert!(m.contains("--bogus")), other => panic!("expected Error; got {other:?}"), } } #[test] fn help_short_and_long() { for f in &["-h", "--help"] { assert!(matches!(parse_args(&args(&[f])), CliResult::Help)); } } #[test] fn version_short_and_long() { for f in &["-V", "--version"] { assert!(matches!(parse_args(&args(&[f])), CliResult::Version)); } } #[test] fn double_dash_treats_following_arg_as_path() { match parse_args(&args(&["--", "-nw"])) { CliResult::Run(p) => { let (file, frontend) = local_mode(p); // Without `--`, `-nw` would have been the flag; with // `--` it's a literal filename. assert_eq!(file, Some(PathBuf::from("-nw"))); assert_eq!(frontend, FrontendChoice::Auto); } other => panic!("expected Run; got {other:?}"), } } #[test] fn multiple_files_rejected() { match parse_args(&args(&["a.txt", "b.txt"])) { CliResult::Error(m) => assert!(m.contains("multiple")), other => panic!("expected Error; got {other:?}"), } } #[test] fn daemon_flag_selects_daemon_mode_with_no_socket() { match parse_args(&args(&["--daemon"])) { CliResult::Run(CliArgs { mode: Mode::Daemon { socket }, }) => assert!(socket.is_none()), other => panic!("expected Daemon; got {other:?}"), } } #[test] fn daemon_with_socket_name() { match parse_args(&args(&["--daemon", "--socket", "research"])) { CliResult::Run(CliArgs { mode: Mode::Daemon { socket }, }) => assert_eq!(socket, Some("research".into())), other => panic!("expected Daemon with socket; got {other:?}"), } } #[test] fn daemon_with_absolute_socket_path() { match parse_args(&args(&["--daemon", "--socket", "/tmp/foo.sock"])) { CliResult::Run(CliArgs { mode: Mode::Daemon { socket }, }) => assert_eq!(socket, Some("/tmp/foo.sock".into())), other => panic!("expected Daemon; got {other:?}"), } } #[test] fn daemon_rejects_file_argument() { match parse_args(&args(&["--daemon", "README.md"])) { CliResult::Error(m) => assert!(m.contains("file")), other => panic!("expected Error; got {other:?}"), } } #[test] fn daemon_rejects_nw_flag() { // `--daemon` and `-nw` make no sense together: daemon has no // controlling terminal to render into. match parse_args(&args(&["--daemon", "-nw"])) { CliResult::Error(m) => assert!(m.contains("mutually")), other => panic!("expected Error; got {other:?}"), } } #[test] fn socket_without_value_errors() { match parse_args(&args(&["--socket"])) { CliResult::Error(m) => assert!(m.contains("--socket")), other => panic!("expected Error; got {other:?}"), } } #[test] fn attach_flag_selects_attach_mode_with_no_socket() { match parse_args(&args(&["--attach"])) { CliResult::Run(CliArgs { mode: Mode::Attach(AttachMode::LocalSocket(socket)), }) => assert!(socket.is_none()), other => panic!("expected Attach(LocalSocket); got {other:?}"), } } #[test] fn attach_with_socket_name() { match parse_args(&args(&["--attach", "--socket", "research"])) { CliResult::Run(CliArgs { mode: Mode::Attach(AttachMode::LocalSocket(socket)), }) => assert_eq!(socket, Some("research".into())), other => panic!("expected Attach(LocalSocket); got {other:?}"), } } // ----------------------------------------------------------------- // M5.7d — --attach positional form // ----------------------------------------------------------------- #[test] fn attach_with_bare_hostname_is_ssh_shorthand() { match parse_args(&args(&["--attach", "mac-studio"])) { CliResult::Run(CliArgs { mode: Mode::Attach(AttachMode::Target(AttachTarget::Ssh { host, user, instance_name, })), }) => { assert_eq!(host, "mac-studio"); assert!(user.is_none()); assert!(instance_name.is_none()); } other => panic!("expected Attach(Target(Ssh)); got {other:?}"), } } #[test] fn attach_with_user_at_host_is_ssh_with_user() { match parse_args(&args(&["--attach", "alice@workstation"])) { CliResult::Run(CliArgs { mode: Mode::Attach(AttachMode::Target(AttachTarget::Ssh { host, user, instance_name, })), }) => { assert_eq!(host, "workstation"); assert_eq!(user, Some("alice".into())); assert!(instance_name.is_none()); } other => panic!("expected Attach(Target(Ssh)); got {other:?}"), } } #[test] fn attach_with_explicit_ssh_kind_carries_instance_name() { match parse_args(&args(&["--attach", "ssh:bob@workstation/research"])) { CliResult::Run(CliArgs { mode: Mode::Attach(AttachMode::Target(AttachTarget::Ssh { host, user, instance_name, })), }) => { assert_eq!(host, "workstation"); assert_eq!(user, Some("bob".into())); assert_eq!(instance_name, Some("research".into())); } other => panic!("expected Attach(Target(Ssh)); got {other:?}"), } } #[test] fn attach_with_local_kind_is_local_socket_path() { match parse_args(&args(&["--attach", "local:/tmp/foo.sock"])) { CliResult::Run(CliArgs { mode: Mode::Attach(AttachMode::Target(AttachTarget::LocalSocket(p))), }) => assert_eq!(p, PathBuf::from("/tmp/foo.sock")), other => panic!("expected Attach(Target(LocalSocket)); got {other:?}"), } } #[test] fn attach_target_and_socket_flag_are_mutually_exclusive() { match parse_args(&args(&["--attach", "mac-studio", "--socket", "research"])) { CliResult::Error(m) => assert!( m.contains("--attach ") && m.contains("--socket"), "expected mutual-exclusion error, got: {m}", ), other => panic!("expected Error; got {other:?}"), } } #[test] fn attach_with_unknown_kind_yields_parse_error() { match parse_args(&args(&["--attach", "telnet:host:23"])) { CliResult::Error(m) => assert!( m.contains("invalid attach target") && m.contains("telnet"), "expected target-parse error, got: {m}", ), other => panic!("expected Error; got {other:?}"), } } #[test] fn attach_with_malformed_tls_yields_parse_error() { // tls: requires `endpoint#cert.pem`; missing `#` is a parse // error from AttachTarget::parse. match parse_args(&args(&["--attach", "tls:host:9999"])) { CliResult::Error(m) => assert!( m.contains("invalid attach target"), "expected target-parse error, got: {m}", ), other => panic!("expected Error; got {other:?}"), } } #[test] fn daemon_and_attach_mutually_exclusive() { match parse_args(&args(&["--daemon", "--attach"])) { CliResult::Error(m) => assert!(m.contains("mutually")), other => panic!("expected Error; got {other:?}"), } } #[test] fn daemon_attach_flag_selects_daemon_attach_mode_with_no_socket() { match parse_args(&args(&["--daemon-attach"])) { CliResult::Run(CliArgs { mode: Mode::DaemonAttach { socket }, }) => assert!(socket.is_none()), other => panic!("expected DaemonAttach; got {other:?}"), } } #[test] fn daemon_attach_with_socket_name() { match parse_args(&args(&["--daemon-attach", "--socket", "research"])) { CliResult::Run(CliArgs { mode: Mode::DaemonAttach { socket }, }) => assert_eq!(socket, Some("research".into())), other => panic!("expected DaemonAttach; got {other:?}"), } } #[test] fn daemon_attach_rejects_file_argument() { match parse_args(&args(&["--daemon-attach", "README.md"])) { CliResult::Error(m) => assert!(m.contains("file")), other => panic!("expected Error; got {other:?}"), } } #[test] fn daemon_attach_and_no_window_mutually_exclusive() { match parse_args(&args(&["--daemon-attach", "-nw"])) { CliResult::Error(m) => assert!(m.contains("mutually")), other => panic!("expected Error; got {other:?}"), } } #[test] fn daemon_attach_excludes_daemon_and_attach() { for combo in &[ vec!["--daemon", "--daemon-attach"], vec!["--attach", "--daemon-attach"], vec!["--daemon", "--attach", "--daemon-attach"], ] { let v: Vec = combo.iter().map(|s| (*s).to_string()).collect(); match parse_args(&v) { CliResult::Error(m) => assert!( m.contains("mutually exclusive"), "combo {combo:?} expected mutually-exclusive error, got: {m}", ), other => panic!("combo {combo:?}: expected Error; got {other:?}"), } } } }