Skip to main content

mesh_llm_cli/parser/
normalization.rs

1use std::ffi::OsString;
2
3use super::commands::Cli;
4
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6pub enum RuntimeSurface {
7    Serve,
8    Client,
9}
10
11#[derive(Clone, Debug)]
12pub struct NormalizedRuntimeArgs {
13    pub original: Vec<OsString>,
14    pub normalized: Vec<OsString>,
15    pub explicit_surface: Option<RuntimeSurface>,
16}
17
18pub fn normalize_runtime_surface_args<I, S>(args: I) -> NormalizedRuntimeArgs
19where
20    I: IntoIterator<Item = S>,
21    S: Into<OsString>,
22{
23    let original: Vec<OsString> = args.into_iter().map(Into::into).collect();
24    let mut normalized = original.clone();
25    let mut explicit_surface = None;
26
27    // Skip leading global flags to find the pseudo-subcommand position.
28    // Recognized value-taking flags: --log-format, --mesh-discovery-mode, --max-vram,
29    // --llama-flavor, --device, --tensor-split, --bind-port, --bind-ip, --max-clients,
30    // --port, --console, --swarm-capture, --draft-max, --ctx-size.
31    // Boolean flags: --help-advanced, --auto, --client, --local-model-only, --headless, --publish,
32    // --plugin, --auto-update, --no-draft, --split, --no-enumerate-host, --listen-all,
33    // --no-console, --owner-required.
34    let value_taking_flags = [
35        "--log-format",
36        "--mesh-discovery-mode",
37        "--max-vram",
38        "--llama-flavor",
39        "--device",
40        "--tensor-split",
41        "--bind-port",
42        "--bind-ip",
43        "--max-clients",
44        "--port",
45        "--console",
46        "--swarm-capture",
47        "--draft-max",
48        "--ctx-size",
49        "--model",
50        "--gguf",
51        "--mmproj",
52        "--join",
53        "--discover",
54        "--mesh-name",
55        "--region",
56        "--name",
57        "--plugin",
58        "--draft",
59        "--bin-dir",
60        "--relay",
61        "--relay-auth",
62        "--nostr-relay",
63        "--config",
64        "--owner-key",
65        "--control-bind",
66        "--control-advertise-addr",
67        "--node-label",
68        "--trust-policy",
69        "--trust-owner",
70    ];
71
72    let mut pos = 1;
73    while pos < original.len() {
74        let arg_str = original.get(pos).and_then(|arg| arg.to_str()).unwrap_or("");
75
76        // Check for --flag=value form
77        if let Some(eq_idx) = arg_str.find('=') {
78            let flag_part = &arg_str[..eq_idx];
79            if value_taking_flags.contains(&flag_part) {
80                pos += 1;
81                continue;
82            }
83        }
84
85        // Check for --flag value form
86        if value_taking_flags.contains(&arg_str) {
87            // Advance by 2 if next token exists and doesn't start with '-'
88            if let Some(next) = original.get(pos + 1).and_then(|arg| arg.to_str())
89                && !next.starts_with('-')
90            {
91                pos += 2;
92                continue;
93            }
94            // If next doesn't exist or starts with '-', advance by 1 (let Clap handle the error)
95            pos += 1;
96            continue;
97        }
98
99        // If it starts with '-' but isn't a recognized flag, it's likely a parse error or unknown flag
100        if arg_str.starts_with('-') {
101            pos += 1;
102            continue;
103        }
104
105        // Found the first positional argument (serve/client/other subcommand)
106        break;
107    }
108
109    // Now apply the serve/client normalization logic at the discovered position
110    match original.get(pos).and_then(|arg| arg.to_str()) {
111        Some("serve") => match original.get(pos + 1).and_then(|arg| arg.to_str()) {
112            Some(arg) if arg.starts_with('-') => {
113                normalized.remove(pos);
114                explicit_surface = Some(RuntimeSurface::Serve);
115            }
116            None => {
117                normalized.remove(pos);
118                explicit_surface = Some(RuntimeSurface::Serve);
119            }
120            _ => {}
121        },
122        Some("client") => {
123            normalized.remove(pos);
124            normalized.insert(pos, OsString::from("--client"));
125            explicit_surface = Some(RuntimeSurface::Client);
126        }
127        _ => {}
128    }
129
130    NormalizedRuntimeArgs {
131        original,
132        normalized,
133        explicit_surface,
134    }
135}
136
137pub fn legacy_runtime_surface_warning(
138    cli: &Cli,
139    original_args: &[OsString],
140    explicit_surface: Option<RuntimeSurface>,
141) -> Option<String> {
142    if explicit_surface.is_some() || cli.command.is_some() {
143        return None;
144    }
145
146    if cli.client {
147        return Some(format!(
148            "⚠️ top-level `--client` now maps to `mesh-llm client`.\n  Please use: {}",
149            suggested_client_command(original_args)
150        ));
151    }
152
153    if !cli.model.is_empty() || !cli.gguf.is_empty() || cli.mmproj.is_some() {
154        return Some(format!(
155            "⚠️ top-level serving flags now map to `mesh-llm serve`.\n  Please use: {}",
156            suggested_serve_command(original_args)
157        ));
158    }
159
160    None
161}
162
163fn suggested_serve_command(original_args: &[OsString]) -> String {
164    let mut args = Vec::with_capacity(original_args.len() + 1);
165    if let Some(program) = original_args.first() {
166        args.push(program.clone());
167    } else {
168        args.push(OsString::from("mesh-llm"));
169    }
170    args.push(OsString::from("serve"));
171    args.extend(original_args.iter().skip(1).cloned());
172    shell_join(&args)
173}
174
175fn suggested_client_command(original_args: &[OsString]) -> String {
176    let mut args = Vec::with_capacity(original_args.len());
177    if let Some(program) = original_args.first() {
178        args.push(program.clone());
179    } else {
180        args.push(OsString::from("mesh-llm"));
181    }
182    args.push(OsString::from("client"));
183    let mut skipped_client = false;
184    for arg in original_args.iter().skip(1) {
185        if !skipped_client && arg.to_string_lossy() == "--client" {
186            skipped_client = true;
187            continue;
188        }
189        args.push(arg.clone());
190    }
191    shell_join(&args)
192}
193
194fn shell_join(args: &[OsString]) -> String {
195    args.iter().map(shell_display).collect::<Vec<_>>().join(" ")
196}
197
198fn shell_display(arg: &OsString) -> String {
199    let text = arg.to_string_lossy();
200    if text.is_empty() {
201        "\"\"".into()
202    } else if text
203        .chars()
204        .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '\\'))
205    {
206        format!("{text:?}")
207    } else {
208        text.into_owned()
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::parser::{Cli, Command, MeshDiscoveryMode};
216    use clap::Parser;
217    use mesh_llm_events::LogFormat;
218    use std::ffi::OsString;
219    use std::path::PathBuf;
220
221    #[test]
222    fn normalize_runtime_surface_args_rewrites_serve_invocation() {
223        let normalized = normalize_runtime_surface_args([
224            "mesh-llm",
225            "serve",
226            "--auto",
227            "--model",
228            "Qwen3-8B-Q4_K_M",
229        ]);
230
231        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Serve));
232        assert_eq!(
233            normalized.normalized,
234            vec!["mesh-llm", "--auto", "--model", "Qwen3-8B-Q4_K_M"]
235                .into_iter()
236                .map(OsString::from)
237                .collect::<Vec<_>>()
238        );
239    }
240
241    #[test]
242    fn normalize_runtime_surface_args_bare_serve_loads_default_config() {
243        let normalized = normalize_runtime_surface_args(["mesh-llm", "serve"]);
244
245        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Serve));
246        assert_eq!(
247            normalized.normalized,
248            vec!["mesh-llm"]
249                .into_iter()
250                .map(OsString::from)
251                .collect::<Vec<_>>()
252        );
253    }
254
255    #[test]
256    fn normalize_runtime_surface_args_rewrites_client_invocation() {
257        let normalized =
258            normalize_runtime_surface_args(["mesh-llm", "client", "--auto", "--port", "9337"]);
259
260        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Client));
261        assert_eq!(
262            normalized.normalized,
263            vec!["mesh-llm", "--client", "--auto", "--port", "9337"]
264                .into_iter()
265                .map(OsString::from)
266                .collect::<Vec<_>>()
267        );
268    }
269
270    #[test]
271    fn normalize_runtime_surface_args_treats_relay_auth_as_value_taking_before_serve() {
272        // Regression: --relay-auth carries a `URL=TOKEN` value, so the
273        // pseudo-subcommand scanner must skip the value and still discover
274        // `serve` (or `client`) as the runtime surface. If --relay-auth is not
275        // in the value-taking list the scanner stops at the token and Clap
276        // sees a malformed command.
277        let normalized = normalize_runtime_surface_args([
278            "mesh-llm",
279            "--relay-auth",
280            "https://gated.example/=token",
281            "serve",
282            "--relay",
283            "https://gated.example/",
284            "--auto",
285        ]);
286
287        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Serve));
288        assert_eq!(
289            normalized.normalized,
290            vec![
291                "mesh-llm",
292                "--relay-auth",
293                "https://gated.example/=token",
294                "--relay",
295                "https://gated.example/",
296                "--auto",
297            ]
298            .into_iter()
299            .map(OsString::from)
300            .collect::<Vec<_>>()
301        );
302
303        // And the resulting argv must actually parse cleanly through Clap so
304        // the relay-auth value reaches `Cli::relay_auth`.
305        let cli = Cli::try_parse_from(&normalized.normalized).expect("clap parse");
306        assert_eq!(
307            cli.relay_auth,
308            vec![("https://gated.example/".to_string(), "token".to_string())],
309        );
310    }
311
312    #[test]
313    fn normalize_runtime_surface_args_relay_auth_before_client_invocation() {
314        // Same regression but for the `client` surface, including a token
315        // containing `=` (NIP-98-style base64 padding).
316        let normalized = normalize_runtime_surface_args([
317            "mesh-llm",
318            "--relay-auth",
319            "https://gated.example/=eyJhbGciOiJFZERTQSJ9.payload==",
320            "client",
321            "--auto",
322        ]);
323
324        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Client));
325        let cli = Cli::try_parse_from(&normalized.normalized).expect("clap parse");
326        assert!(cli.client, "client surface flag should be set");
327        assert_eq!(
328            cli.relay_auth,
329            vec![(
330                "https://gated.example/".to_string(),
331                "eyJhbGciOiJFZERTQSJ9.payload==".to_string()
332            )],
333        );
334    }
335
336    #[test]
337    fn normalize_runtime_surface_args_keeps_non_runtime_subcommands() {
338        let normalized = normalize_runtime_surface_args(["mesh-llm", "download", "foo"]);
339
340        assert_eq!(normalized.explicit_surface, None);
341        assert_eq!(
342            normalized.normalized,
343            vec!["mesh-llm", "download", "foo"]
344                .into_iter()
345                .map(OsString::from)
346                .collect::<Vec<_>>()
347        );
348    }
349
350    #[test]
351    fn legacy_runtime_surface_warning_for_top_level_serve_flags() {
352        let normalized =
353            normalize_runtime_surface_args(["mesh-llm", "--auto", "--model", "Qwen3-8B-Q4_K_M"]);
354        let cli = Cli::parse_from(normalized.normalized.clone());
355
356        let warning =
357            legacy_runtime_surface_warning(&cli, &normalized.original, normalized.explicit_surface)
358                .expect("warning should be present");
359
360        assert!(warning.contains("mesh-llm serve --auto --model Qwen3-8B-Q4_K_M"));
361    }
362
363    #[test]
364    fn legacy_runtime_surface_warning_for_top_level_client_flag() {
365        let normalized = normalize_runtime_surface_args(["mesh-llm", "--auto", "--client"]);
366        let cli = Cli::parse_from(normalized.normalized.clone());
367
368        let warning =
369            legacy_runtime_surface_warning(&cli, &normalized.original, normalized.explicit_surface)
370                .expect("warning should be present");
371
372        assert!(warning.contains("mesh-llm client --auto"));
373    }
374
375    #[test]
376    fn explicit_runtime_surface_suppresses_legacy_warning() {
377        let normalized = normalize_runtime_surface_args(["mesh-llm", "client", "--auto"]);
378        let cli = Cli::parse_from(normalized.normalized.clone());
379
380        assert!(
381            legacy_runtime_surface_warning(&cli, &normalized.original, normalized.explicit_surface)
382                .is_none()
383        );
384    }
385
386    #[test]
387    fn cli_accepts_headless_flag_for_serve_surface() {
388        let args = vec!["mesh-llm", "serve", "--headless", "--auto"];
389        let normalized = normalize_runtime_surface_args(args);
390        let cli = Cli::try_parse_from(&normalized.normalized).unwrap();
391        assert!(cli.headless);
392    }
393
394    #[test]
395    fn cli_accepts_headless_flag_for_client_surface() {
396        let args = vec!["mesh-llm", "client", "--headless", "--auto"];
397        let normalized = normalize_runtime_surface_args(args);
398        let cli = Cli::try_parse_from(&normalized.normalized).unwrap();
399        assert!(cli.headless);
400    }
401
402    #[test]
403    fn cli_accepts_swarm_capture_flag_for_client_surface() {
404        let args = vec![
405            "mesh-llm",
406            "client",
407            "--swarm-capture",
408            "/tmp/mesh-capture",
409            "--auto",
410        ];
411        let normalized = normalize_runtime_surface_args(args);
412        let cli = Cli::try_parse_from(&normalized.normalized).unwrap();
413
414        assert!(cli.client);
415        assert_eq!(cli.swarm_capture, Some(PathBuf::from("/tmp/mesh-capture")));
416    }
417
418    #[test]
419    fn cli_accepts_global_swarm_capture_before_client() {
420        let normalized = normalize_runtime_surface_args([
421            "mesh-llm",
422            "--swarm-capture",
423            "/tmp/mesh-capture",
424            "client",
425            "--auto",
426        ]);
427        let cli = Cli::parse_from(normalized.normalized);
428
429        assert!(cli.client);
430        assert_eq!(cli.swarm_capture, Some(PathBuf::from("/tmp/mesh-capture")));
431        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Client));
432    }
433
434    #[test]
435    fn legacy_no_console_remains_ignored_in_headless_tests() {
436        let args = vec!["mesh-llm", "serve", "--no-console"];
437        let normalized = normalize_runtime_surface_args(args);
438        let cli = Cli::try_parse_from(&normalized.normalized).unwrap();
439        assert!(
440            !cli.headless,
441            "--no-console must not activate headless mode"
442        );
443    }
444
445    #[test]
446    fn local_model_only_is_an_explicit_serve_topology() {
447        let args = vec![
448            "mesh-llm",
449            "serve",
450            "--local-model-only",
451            "--model",
452            "/models/model.gguf",
453        ];
454        let normalized = normalize_runtime_surface_args(args);
455        let cli = Cli::try_parse_from(&normalized.normalized).unwrap();
456
457        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Serve));
458        assert!(cli.local_model_only);
459        assert!(!cli.client);
460    }
461
462    #[test]
463    fn unknown_top_level_command_is_captured_for_plugin_dispatch() {
464        let normalized = normalize_runtime_surface_args([
465            "mesh-llm",
466            "goose-next",
467            "--model",
468            "auto",
469            "--",
470            "prompt.txt",
471        ]);
472        let cli = Cli::parse_from(normalized.normalized);
473
474        match cli.command.expect("external plugin command expected") {
475            Command::ExternalPlugin(args) => {
476                assert_eq!(
477                    args,
478                    vec![
479                        OsString::from("goose-next"),
480                        OsString::from("--model"),
481                        OsString::from("auto"),
482                        OsString::from("--"),
483                        OsString::from("prompt.txt"),
484                    ]
485                );
486            }
487            other => panic!("unexpected command: {other:?}"),
488        }
489    }
490
491    #[test]
492    fn cli_defaults_log_format_to_pretty() {
493        let normalized = normalize_runtime_surface_args(["mesh-llm", "serve", "--auto"]);
494        let cli = Cli::parse_from(normalized.normalized);
495
496        assert_eq!(cli.log_format, LogFormat::Pretty);
497    }
498
499    #[test]
500    fn cli_accepts_json_log_format() {
501        let normalized =
502            normalize_runtime_surface_args(["mesh-llm", "serve", "--log-format", "json", "--auto"]);
503        let cli = Cli::parse_from(normalized.normalized);
504
505        assert_eq!(cli.log_format, LogFormat::Json);
506    }
507
508    #[test]
509    fn cli_accepts_global_log_format_before_serve() {
510        let normalized =
511            normalize_runtime_surface_args(["mesh-llm", "--log-format", "json", "serve", "--auto"]);
512        let cli = Cli::parse_from(normalized.normalized);
513
514        assert_eq!(cli.log_format, LogFormat::Json);
515        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Serve));
516    }
517
518    #[test]
519    fn cli_accepts_global_log_format_before_serve_with_model() {
520        let normalized = normalize_runtime_surface_args([
521            "mesh-llm",
522            "--log-format",
523            "json",
524            "serve",
525            "--model",
526            "Qwen3-8B-Q4_K_M",
527        ]);
528        let cli = Cli::parse_from(normalized.normalized);
529
530        assert_eq!(cli.log_format, LogFormat::Json);
531        assert_eq!(cli.model, vec![std::path::PathBuf::from("Qwen3-8B-Q4_K_M")]);
532        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Serve));
533    }
534
535    #[test]
536    fn cli_accepts_global_log_format_equals_before_serve() {
537        let normalized =
538            normalize_runtime_surface_args(["mesh-llm", "--log-format=json", "serve", "--auto"]);
539        let cli = Cli::parse_from(normalized.normalized);
540
541        assert_eq!(cli.log_format, LogFormat::Json);
542        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Serve));
543    }
544
545    #[test]
546    fn cli_accepts_global_log_format_before_client() {
547        let normalized = normalize_runtime_surface_args([
548            "mesh-llm",
549            "--log-format",
550            "json",
551            "client",
552            "--auto",
553        ]);
554        let cli = Cli::parse_from(normalized.normalized);
555
556        assert_eq!(cli.log_format, LogFormat::Json);
557        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Client));
558    }
559
560    #[test]
561    fn cli_accepts_global_bind_ip_before_serve() {
562        let normalized = normalize_runtime_surface_args([
563            "mesh-llm",
564            "--bind-ip",
565            "10.1.2.3",
566            "serve",
567            "--bind-port",
568            "47916",
569        ]);
570        let cli = Cli::parse_from(normalized.normalized);
571
572        assert_eq!(cli.bind_ip, Some("10.1.2.3".parse().unwrap()));
573        assert_eq!(cli.bind_port, Some(47916));
574        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Serve));
575    }
576
577    #[test]
578    fn cli_accepts_global_mesh_discovery_mode_before_serve() {
579        let normalized = normalize_runtime_surface_args([
580            "mesh-llm",
581            "--mesh-discovery-mode",
582            "mdns",
583            "serve",
584            "--auto",
585        ]);
586        let cli = Cli::parse_from(normalized.normalized);
587
588        assert_eq!(cli.mesh_discovery_mode, MeshDiscoveryMode::Mdns);
589        assert_eq!(normalized.explicit_surface, Some(RuntimeSurface::Serve));
590    }
591
592    #[test]
593    fn cli_defaults_mesh_discovery_mode_to_nostr() {
594        let normalized = normalize_runtime_surface_args(["mesh-llm", "serve", "--auto"]);
595        let cli = Cli::parse_from(normalized.normalized);
596
597        assert_eq!(cli.mesh_discovery_mode, MeshDiscoveryMode::Nostr);
598    }
599
600    #[test]
601    fn cli_accepts_mdns_discovery_mode_for_runtime_surfaces() {
602        let normalized =
603            normalize_runtime_surface_args(["mesh-llm", "client", "--mesh-discovery-mode", "mdns"]);
604        let cli = Cli::parse_from(normalized.normalized);
605
606        assert!(cli.client);
607        assert_eq!(cli.mesh_discovery_mode, MeshDiscoveryMode::Mdns);
608    }
609}