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 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 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 if value_taking_flags.contains(&arg_str) {
87 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 pos += 1;
96 continue;
97 }
98
99 if arg_str.starts_with('-') {
101 pos += 1;
102 continue;
103 }
104
105 break;
107 }
108
109 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 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 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 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}