1use std::ffi::OsString;
2
3use facet_core::Facet;
4use facet_core::ScalarType as FacetScalarType;
5use heck::ToKebabCase;
6
7use crate::config_value::{ConfigValue, ObjectMap};
8use crate::config_value_parser::ConfigValueSerializer;
9use crate::schema::{ArgKind, ArgLevelSchema, ArgSchema, Schema, ValueSchema};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum ToArgsError {
14 SchemaBuild(String),
16 Serialize(String),
18 InvalidRootValue,
20 InvalidSubcommandValue {
22 field_name: String,
24 },
25 UnknownSubcommandVariant {
27 field_name: String,
29 variant: String,
31 },
32 NegativeCount {
34 arg_name: String,
36 count: i64,
38 },
39 UnsupportedScalarValue {
41 arg_name: String,
43 },
44 CurrentExe(String),
46}
47
48impl core::fmt::Display for ToArgsError {
49 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
50 match self {
51 ToArgsError::SchemaBuild(message) => write!(f, "failed to build schema: {message}"),
52 ToArgsError::Serialize(message) => {
53 write!(f, "failed to serialize CLI value: {message}")
54 }
55 ToArgsError::InvalidRootValue => {
56 write!(f, "top-level value must serialize to an object")
57 }
58 ToArgsError::InvalidSubcommandValue { field_name } => {
59 write!(
60 f,
61 "subcommand field `{field_name}` must serialize to an enum"
62 )
63 }
64 ToArgsError::UnknownSubcommandVariant {
65 field_name,
66 variant,
67 } => {
68 write!(
69 f,
70 "unknown subcommand variant `{variant}` for field `{field_name}`"
71 )
72 }
73 ToArgsError::NegativeCount { arg_name, count } => {
74 write!(
75 f,
76 "counted argument `{arg_name}` cannot have negative count `{count}`"
77 )
78 }
79 ToArgsError::UnsupportedScalarValue { arg_name } => {
80 write!(f, "argument `{arg_name}` has an unsupported scalar value")
81 }
82 ToArgsError::CurrentExe(message) => {
83 write!(f, "failed to resolve current executable: {message}")
84 }
85 }
86 }
87}
88
89impl std::error::Error for ToArgsError {}
90
91pub fn to_os_args<T: Facet<'static> + ?Sized>(value: &T) -> Result<Vec<OsString>, ToArgsError> {
96 let schema = Schema::from_shape(T::SHAPE)
97 .map_err(|error| ToArgsError::SchemaBuild(error.to_string()))?;
98 to_os_args_with_schema(value, &schema)
99}
100
101pub fn to_args_string<T: Facet<'static> + ?Sized>(value: &T) -> Result<String, ToArgsError> {
105 let args = to_os_args(value)?;
106 Ok(args
107 .iter()
108 .map(|arg| arg.to_string_lossy().to_string())
109 .collect::<Vec<_>>()
110 .join(" "))
111}
112
113pub fn to_args_string_with_current_exe<T: Facet<'static> + ?Sized>(
116 value: &T,
117) -> Result<String, ToArgsError> {
118 let exe =
119 std::env::current_exe().map_err(|error| ToArgsError::CurrentExe(error.to_string()))?;
120 let exe_display = exe.to_string_lossy().to_string();
121 let args = to_args_string(value)?;
122
123 if args.is_empty() {
124 Ok(exe_display)
125 } else {
126 Ok(format!("{exe_display} {args}"))
127 }
128}
129
130pub trait ToArgs: Facet<'static> {
132 fn to_args(&self) -> Result<Vec<OsString>, ToArgsError> {
134 to_os_args(self)
135 }
136
137 fn to_args_string(&self) -> Result<String, ToArgsError> {
139 to_args_string(self)
140 }
141
142 fn to_args_string_with_current_exe(&self) -> Result<String, ToArgsError> {
145 to_args_string_with_current_exe(self)
146 }
147}
148
149impl<T: Facet<'static>> ToArgs for T {}
150
151pub(crate) fn to_os_args_with_schema<T: Facet<'static> + ?Sized>(
152 value: &T,
153 schema: &Schema,
154) -> Result<Vec<OsString>, ToArgsError> {
155 let config_value = serialize_to_config_value(value)?;
156 let ConfigValue::Object(root) = config_value else {
157 return Err(ToArgsError::InvalidRootValue);
158 };
159
160 let mut args = Vec::new();
161 encode_level(schema.args(), &root.value, &mut args)?;
162 Ok(args)
163}
164
165fn serialize_to_config_value<T: Facet<'static> + ?Sized>(
166 value: &T,
167) -> Result<ConfigValue, ToArgsError> {
168 let mut serializer = ConfigValueSerializer::new();
169 facet_format::serialize_root(&mut serializer, facet_reflect::Peek::new(value))
170 .map_err(|error| ToArgsError::Serialize(error.to_string()))?;
171 Ok(serializer.finish())
172}
173
174fn encode_level(
175 level: &ArgLevelSchema,
176 values: &ObjectMap,
177 args: &mut Vec<OsString>,
178) -> Result<(), ToArgsError> {
179 for (name, schema) in level.args() {
180 if !matches!(schema.kind(), ArgKind::Named { .. }) {
181 continue;
182 }
183 let Some(value) = values.get(name) else {
184 continue;
185 };
186 encode_named_arg(name, schema, value, args)?;
187 }
188
189 let mut emitted_positional_separator = false;
190 for (name, schema) in level.args() {
191 if !matches!(schema.kind(), ArgKind::Positional) {
192 continue;
193 }
194 let Some(value) = values.get(name) else {
195 continue;
196 };
197 encode_positional_arg(
198 name,
199 schema,
200 value,
201 args,
202 &mut emitted_positional_separator,
203 )?;
204 }
205
206 if let Some(field_name) = level.subcommand_field_name()
207 && let Some(value) = values.get(field_name)
208 {
209 if matches!(value, ConfigValue::Null(_)) {
210 return Ok(());
211 }
212
213 let Some((variant_name, variant_fields)) = as_enum_variant(value) else {
214 return Err(ToArgsError::InvalidSubcommandValue {
215 field_name: field_name.to_string(),
216 });
217 };
218
219 let branch = level
220 .subcommands()
221 .values()
222 .find(|candidate| candidate.effective_name() == variant_name)
223 .ok_or_else(|| ToArgsError::UnknownSubcommandVariant {
224 field_name: field_name.to_string(),
225 variant: variant_name.to_string(),
226 })?;
227
228 args.push(branch.cli_name().to_string().into());
229 encode_level(branch.args(), variant_fields, args)?;
230 }
231
232 Ok(())
233}
234
235fn encode_named_arg(
236 name: &str,
237 schema: &ArgSchema,
238 value: &ConfigValue,
239 args: &mut Vec<OsString>,
240) -> Result<(), ToArgsError> {
241 let flag = format!("--{}", name.to_kebab_case());
242
243 if matches!(value, ConfigValue::Null(_)) {
244 return Ok(());
245 }
246
247 if schema.kind().is_counted() {
248 let ConfigValue::Integer(count) = value else {
249 return Err(ToArgsError::UnsupportedScalarValue {
250 arg_name: name.to_string(),
251 });
252 };
253
254 if count.value < 0 {
255 return Err(ToArgsError::NegativeCount {
256 arg_name: name.to_string(),
257 count: count.value,
258 });
259 }
260
261 for _ in 0..count.value {
262 args.push(flag.clone().into());
263 }
264 return Ok(());
265 }
266
267 if schema.value().inner_if_option().is_bool() {
268 if let ConfigValue::Bool(bool_value) = value
269 && bool_value.value
270 {
271 args.push(flag.into());
272 }
273 return Ok(());
274 }
275
276 if schema.multiple() {
277 let ConfigValue::Array(array) = value else {
278 return Err(ToArgsError::UnsupportedScalarValue {
279 arg_name: name.to_string(),
280 });
281 };
282
283 for item in &array.value {
284 if matches!(item, ConfigValue::Null(_)) {
285 continue;
286 }
287
288 args.push(flag.clone().into());
289 args.push(value_to_cli_token(name, item, Some(schema.value().inner_if_option()))?.into());
290 }
291
292 return Ok(());
293 }
294
295 args.push(flag.into());
296 args.push(value_to_cli_token(name, value, Some(schema.value().inner_if_option()))?.into());
297 Ok(())
298}
299
300fn encode_positional_arg(
301 name: &str,
302 schema: &ArgSchema,
303 value: &ConfigValue,
304 args: &mut Vec<OsString>,
305 emitted_positional_separator: &mut bool,
306) -> Result<(), ToArgsError> {
307 match value {
308 ConfigValue::Null(_) => Ok(()),
309 ConfigValue::Array(array) => {
310 for item in &array.value {
311 if matches!(item, ConfigValue::Null(_)) {
312 continue;
313 }
314 let token = value_to_cli_token(name, item, Some(schema.value().inner_if_option()))?;
315 maybe_emit_positional_separator(args, &token, emitted_positional_separator);
316 args.push(token.into());
317 }
318 Ok(())
319 }
320 _ => {
321 let token = value_to_cli_token(name, value, Some(schema.value().inner_if_option()))?;
322 maybe_emit_positional_separator(args, &token, emitted_positional_separator);
323 args.push(token.into());
324 Ok(())
325 }
326 }
327}
328
329fn maybe_emit_positional_separator(
330 args: &mut Vec<OsString>,
331 token: &str,
332 emitted_positional_separator: &mut bool,
333) {
334 if !*emitted_positional_separator && (token == "--" || token.starts_with('-')) {
335 args.push("--".into());
336 *emitted_positional_separator = true;
337 }
338}
339
340fn value_to_cli_token(
341 name: &str,
342 value: &ConfigValue,
343 value_schema: Option<&ValueSchema>,
344) -> Result<String, ToArgsError> {
345 match value {
346 ConfigValue::Bool(sourced) => Ok(sourced.value.to_string()),
347 ConfigValue::Integer(sourced) => Ok(integer_to_cli_token(sourced.value, value_schema)),
348 ConfigValue::Float(sourced) => Ok(sourced.value.to_string()),
349 ConfigValue::String(sourced) => Ok(sourced.value.clone()),
350 ConfigValue::Enum(sourced) if sourced.value.fields.is_empty() => {
351 Ok(sourced.value.variant.to_kebab_case())
352 }
353 ConfigValue::Object(sourced) if sourced.value.len() == 1 => Ok(sourced
354 .value
355 .first()
356 .map(|(variant, _)| variant.to_kebab_case())
357 .unwrap_or_default()),
358 _ => Err(ToArgsError::UnsupportedScalarValue {
359 arg_name: name.to_string(),
360 }),
361 }
362}
363
364fn integer_to_cli_token(value: i64, value_schema: Option<&ValueSchema>) -> String {
365 let scalar = match value_schema {
366 Some(ValueSchema::Leaf(leaf)) => leaf.shape.scalar_type(),
367 _ => None,
368 };
369
370 match scalar {
371 Some(FacetScalarType::U8) => (value as u8).to_string(),
372 Some(FacetScalarType::U16) => (value as u16).to_string(),
373 Some(FacetScalarType::U32) => (value as u32).to_string(),
374 Some(FacetScalarType::U64) => (value as u64).to_string(),
375 Some(FacetScalarType::U128) => ((value as u64) as u128).to_string(),
376 Some(FacetScalarType::USize) => (value as usize).to_string(),
377 _ => value.to_string(),
378 }
379}
380
381fn as_enum_variant(value: &ConfigValue) -> Option<(&str, &ObjectMap)> {
382 match value {
383 ConfigValue::Enum(sourced) => Some((&sourced.value.variant, &sourced.value.fields)),
384 ConfigValue::String(sourced) => Some((&sourced.value, empty_object_map())),
385 ConfigValue::Object(sourced) if sourced.value.len() == 1 => {
386 let (variant_name, payload) = sourced.value.first()?;
387 match payload {
388 ConfigValue::Object(variant_fields) => Some((variant_name, &variant_fields.value)),
389 ConfigValue::Null(_) => Some((variant_name, empty_object_map())),
390 _ => None,
391 }
392 }
393 _ => None,
394 }
395}
396
397fn empty_object_map() -> &'static ObjectMap {
398 static EMPTY: std::sync::OnceLock<ObjectMap> = std::sync::OnceLock::new();
399 EMPTY.get_or_init(Default::default)
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate as args;
406 use crate::config_value::{EnumValue, Sourced};
407 use facet::Facet;
408 use indexmap::indexmap;
409
410 #[derive(Facet, Debug, PartialEq)]
411 #[repr(u8)]
412 enum Command {
413 Build {
414 #[facet(args::named)]
415 release: bool,
416 },
417 }
418
419 #[derive(Facet, Debug, PartialEq)]
420 struct Cli {
421 #[facet(args::named)]
422 verbose: bool,
423
424 #[facet(args::subcommand)]
425 command: Command,
426 }
427
428 #[derive(Facet, Debug, PartialEq)]
429 struct UnsignedCli {
430 #[facet(args::named)]
431 limit: usize,
432 }
433
434 #[derive(Facet, Debug, PartialEq)]
435 struct DashPositionalCli {
436 #[facet(args::positional)]
437 query: String,
438 }
439
440 #[test]
441 fn to_args_roundtrip_basic() {
442 let cli = Cli {
443 verbose: true,
444 command: Command::Build { release: true },
445 };
446
447 let args = to_os_args(&cli).expect("to_args should succeed");
448 let args_as_str = args
449 .iter()
450 .map(|arg| arg.to_string_lossy().to_string())
451 .collect::<Vec<_>>();
452
453 let parsed: Cli =
454 crate::from_slice(&args_as_str.iter().map(String::as_str).collect::<Vec<_>>())
455 .into_result()
456 .expect("roundtrip parse should succeed")
457 .get_silent();
458
459 assert_eq!(cli, parsed);
460 }
461
462 #[test]
463 fn to_args_string_joins_arguments() {
464 let cli = Cli {
465 verbose: true,
466 command: Command::Build { release: true },
467 };
468
469 let args_string = to_args_string(&cli).expect("to_args_string should succeed");
470 assert!(args_string.contains("--verbose"));
471 assert!(args_string.contains("build"));
472 assert!(args_string.contains("--release"));
473 }
474
475 #[test]
476 fn to_args_string_with_current_exe_prefixes_command() {
477 let cli = Cli {
478 verbose: false,
479 command: Command::Build { release: false },
480 };
481
482 let command = to_args_string_with_current_exe(&cli)
483 .expect("to_args_string_with_current_exe should succeed");
484 let exe_display = std::env::current_exe()
485 .expect("current_exe should resolve")
486 .to_string_lossy()
487 .to_string();
488
489 assert!(command.starts_with(&exe_display));
490 assert!(command.contains("build"));
491 }
492
493 #[test]
494 fn to_args_roundtrips_large_usize_values() {
495 if usize::BITS < 64 {
496 return;
497 }
498
499 let cli = UnsignedCli {
500 limit: usize::MAX,
501 };
502
503 let args = to_os_args(&cli).expect("to_args should succeed for large usize values");
504 let args_as_str = args
505 .iter()
506 .map(|arg| arg.to_string_lossy().to_string())
507 .collect::<Vec<_>>();
508
509 assert!(
510 args_as_str.contains(&usize::MAX.to_string()),
511 "generated args should preserve the original unsigned value"
512 );
513
514 let parsed: UnsignedCli =
515 crate::from_slice(&args_as_str.iter().map(String::as_str).collect::<Vec<_>>())
516 .into_result()
517 .expect("roundtrip parse should succeed")
518 .get_silent();
519
520 assert_eq!(cli, parsed);
521 }
522
523 #[test]
524 fn to_args_inserts_separator_for_dash_prefixed_positionals() {
525 let cli = DashPositionalCli {
526 query: "-0".to_string(),
527 };
528
529 let args = to_os_args(&cli).expect("to_args should succeed");
530 let args_as_str = args
531 .iter()
532 .map(|arg| arg.to_string_lossy().to_string())
533 .collect::<Vec<_>>();
534
535 assert_eq!(args_as_str, vec!["--", "-0"]);
536
537 let parsed: DashPositionalCli =
538 crate::from_slice(&args_as_str.iter().map(String::as_str).collect::<Vec<_>>())
539 .into_result()
540 .expect("roundtrip parse should succeed")
541 .get_silent();
542
543 assert_eq!(cli, parsed);
544 }
545
546 #[test]
547 fn to_args_fails_for_unknown_subcommand_variant() {
548 let schema = Schema::from_shape(Cli::SHAPE).expect("schema should be valid");
549
550 let mut root = indexmap! {};
551 root.insert(
552 "command".to_string(),
553 ConfigValue::Enum(Sourced::new(EnumValue {
554 variant: "Unknown".to_string(),
555 fields: indexmap! {},
556 })),
557 );
558
559 let mut args = Vec::new();
560 let error = encode_level(schema.args(), &root, &mut args).expect_err("should fail");
561
562 assert!(matches!(
563 error,
564 ToArgsError::UnknownSubcommandVariant { .. }
565 ));
566 }
567
568 #[test]
569 fn to_args_fails_for_unknown_string_subcommand_value() {
570 let schema = Schema::from_shape(Cli::SHAPE).expect("schema should be valid");
571
572 let mut root = indexmap! {};
573 root.insert(
574 "command".to_string(),
575 ConfigValue::String(Sourced::new("build".to_string())),
576 );
577
578 let mut args = Vec::new();
579 let error = encode_level(schema.args(), &root, &mut args).expect_err("should fail");
580
581 assert!(matches!(
582 error,
583 ToArgsError::UnknownSubcommandVariant { .. }
584 ));
585 }
586}