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(name, schema, value, args, &mut emitted_positional_separator)?;
198 }
199
200 if let Some(field_name) = level.subcommand_field_name()
201 && let Some(value) = values.get(field_name)
202 {
203 if matches!(value, ConfigValue::Null(_)) {
204 return Ok(());
205 }
206
207 let Some((variant_name, variant_fields)) = as_enum_variant(value) else {
208 return Err(ToArgsError::InvalidSubcommandValue {
209 field_name: field_name.to_string(),
210 });
211 };
212
213 let branch = level
214 .subcommands()
215 .values()
216 .find(|candidate| candidate.effective_name() == variant_name)
217 .ok_or_else(|| ToArgsError::UnknownSubcommandVariant {
218 field_name: field_name.to_string(),
219 variant: variant_name.to_string(),
220 })?;
221
222 args.push(branch.cli_name().to_string().into());
223 encode_level(branch.args(), variant_fields, args)?;
224 }
225
226 Ok(())
227}
228
229fn encode_named_arg(
230 name: &str,
231 schema: &ArgSchema,
232 value: &ConfigValue,
233 args: &mut Vec<OsString>,
234) -> Result<(), ToArgsError> {
235 let flag = format!("--{}", name.to_kebab_case());
236
237 if matches!(value, ConfigValue::Null(_)) {
238 return Ok(());
239 }
240
241 if schema.kind().is_counted() {
242 let ConfigValue::Integer(count) = value else {
243 return Err(ToArgsError::UnsupportedScalarValue {
244 arg_name: name.to_string(),
245 });
246 };
247
248 if count.value < 0 {
249 return Err(ToArgsError::NegativeCount {
250 arg_name: name.to_string(),
251 count: count.value,
252 });
253 }
254
255 for _ in 0..count.value {
256 args.push(flag.clone().into());
257 }
258 return Ok(());
259 }
260
261 if schema.value().inner_if_option().is_bool() {
262 if let ConfigValue::Bool(bool_value) = value
263 && bool_value.value
264 {
265 args.push(flag.into());
266 }
267 return Ok(());
268 }
269
270 if schema.multiple() {
271 let ConfigValue::Array(array) = value else {
272 return Err(ToArgsError::UnsupportedScalarValue {
273 arg_name: name.to_string(),
274 });
275 };
276
277 for item in &array.value {
278 if matches!(item, ConfigValue::Null(_)) {
279 continue;
280 }
281
282 args.push(flag.clone().into());
283 args.push(
284 value_to_cli_token(name, item, Some(schema.value().inner_if_option()))?.into(),
285 );
286 }
287
288 return Ok(());
289 }
290
291 args.push(flag.into());
292 args.push(value_to_cli_token(name, value, Some(schema.value().inner_if_option()))?.into());
293 Ok(())
294}
295
296fn encode_positional_arg(
297 name: &str,
298 schema: &ArgSchema,
299 value: &ConfigValue,
300 args: &mut Vec<OsString>,
301 emitted_positional_separator: &mut bool,
302) -> Result<(), ToArgsError> {
303 match value {
304 ConfigValue::Null(_) => Ok(()),
305 ConfigValue::Array(array) => {
306 for item in &array.value {
307 if matches!(item, ConfigValue::Null(_)) {
308 continue;
309 }
310 let token = value_to_cli_token(name, item, Some(schema.value().inner_if_option()))?;
311 maybe_emit_positional_separator(args, &token, emitted_positional_separator);
312 args.push(token.into());
313 }
314 Ok(())
315 }
316 _ => {
317 let token = value_to_cli_token(name, value, Some(schema.value().inner_if_option()))?;
318 maybe_emit_positional_separator(args, &token, emitted_positional_separator);
319 args.push(token.into());
320 Ok(())
321 }
322 }
323}
324
325fn maybe_emit_positional_separator(
326 args: &mut Vec<OsString>,
327 token: &str,
328 emitted_positional_separator: &mut bool,
329) {
330 if !*emitted_positional_separator && (token == "--" || token.starts_with('-')) {
331 args.push("--".into());
332 *emitted_positional_separator = true;
333 }
334}
335
336fn value_to_cli_token(
337 name: &str,
338 value: &ConfigValue,
339 value_schema: Option<&ValueSchema>,
340) -> Result<String, ToArgsError> {
341 match value {
342 ConfigValue::Bool(sourced) => Ok(sourced.value.to_string()),
343 ConfigValue::Integer(sourced) => Ok(integer_to_cli_token(sourced.value, value_schema)),
344 ConfigValue::Float(sourced) => Ok(sourced.value.to_string()),
345 ConfigValue::String(sourced) => Ok(sourced.value.clone()),
346 ConfigValue::Enum(sourced) if sourced.value.fields.is_empty() => {
347 Ok(sourced.value.variant.to_kebab_case())
348 }
349 ConfigValue::Object(sourced) if sourced.value.len() == 1 => Ok(sourced
350 .value
351 .first()
352 .map(|(variant, _)| variant.to_kebab_case())
353 .unwrap_or_default()),
354 _ => Err(ToArgsError::UnsupportedScalarValue {
355 arg_name: name.to_string(),
356 }),
357 }
358}
359
360fn integer_to_cli_token(value: i64, value_schema: Option<&ValueSchema>) -> String {
361 let scalar = match value_schema {
362 Some(ValueSchema::Leaf(leaf)) => leaf.shape.scalar_type(),
363 _ => None,
364 };
365
366 match scalar {
367 Some(FacetScalarType::U8) => (value as u8).to_string(),
368 Some(FacetScalarType::U16) => (value as u16).to_string(),
369 Some(FacetScalarType::U32) => (value as u32).to_string(),
370 Some(FacetScalarType::U64) => (value as u64).to_string(),
371 Some(FacetScalarType::U128) => ((value as u64) as u128).to_string(),
372 Some(FacetScalarType::USize) => (value as usize).to_string(),
373 _ => value.to_string(),
374 }
375}
376
377fn as_enum_variant(value: &ConfigValue) -> Option<(&str, &ObjectMap)> {
378 match value {
379 ConfigValue::Enum(sourced) => Some((&sourced.value.variant, &sourced.value.fields)),
380 ConfigValue::String(sourced) => Some((&sourced.value, empty_object_map())),
381 ConfigValue::Object(sourced) if sourced.value.len() == 1 => {
382 let (variant_name, payload) = sourced.value.first()?;
383 match payload {
384 ConfigValue::Object(variant_fields) => Some((variant_name, &variant_fields.value)),
385 ConfigValue::Null(_) => Some((variant_name, empty_object_map())),
386 _ => None,
387 }
388 }
389 _ => None,
390 }
391}
392
393fn empty_object_map() -> &'static ObjectMap {
394 static EMPTY: std::sync::OnceLock<ObjectMap> = std::sync::OnceLock::new();
395 EMPTY.get_or_init(Default::default)
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate as args;
402 use crate::config_value::{EnumValue, Sourced};
403 use facet::Facet;
404 use indexmap::indexmap;
405
406 #[derive(Facet, Debug, PartialEq)]
407 #[repr(u8)]
408 enum Command {
409 Build {
410 #[facet(args::named)]
411 release: bool,
412 },
413 }
414
415 #[derive(Facet, Debug, PartialEq)]
416 struct Cli {
417 #[facet(args::named)]
418 verbose: bool,
419
420 #[facet(args::subcommand)]
421 command: Command,
422 }
423
424 #[derive(Facet, Debug, PartialEq)]
425 struct UnsignedCli {
426 #[facet(args::named)]
427 limit: usize,
428 }
429
430 #[derive(Facet, Debug, PartialEq)]
431 struct DashPositionalCli {
432 #[facet(args::positional)]
433 query: String,
434 }
435
436 #[test]
437 fn to_args_roundtrip_basic() {
438 let cli = Cli {
439 verbose: true,
440 command: Command::Build { release: true },
441 };
442
443 let args = to_os_args(&cli).expect("to_args should succeed");
444 let args_as_str = args
445 .iter()
446 .map(|arg| arg.to_string_lossy().to_string())
447 .collect::<Vec<_>>();
448
449 let parsed: Cli =
450 crate::from_slice(&args_as_str.iter().map(String::as_str).collect::<Vec<_>>())
451 .into_result()
452 .expect("roundtrip parse should succeed")
453 .get_silent();
454
455 assert_eq!(cli, parsed);
456 }
457
458 #[test]
459 fn to_args_string_joins_arguments() {
460 let cli = Cli {
461 verbose: true,
462 command: Command::Build { release: true },
463 };
464
465 let args_string = to_args_string(&cli).expect("to_args_string should succeed");
466 assert!(args_string.contains("--verbose"));
467 assert!(args_string.contains("build"));
468 assert!(args_string.contains("--release"));
469 }
470
471 #[test]
472 fn to_args_string_with_current_exe_prefixes_command() {
473 let cli = Cli {
474 verbose: false,
475 command: Command::Build { release: false },
476 };
477
478 let command = to_args_string_with_current_exe(&cli)
479 .expect("to_args_string_with_current_exe should succeed");
480 let exe_display = std::env::current_exe()
481 .expect("current_exe should resolve")
482 .to_string_lossy()
483 .to_string();
484
485 assert!(command.starts_with(&exe_display));
486 assert!(command.contains("build"));
487 }
488
489 #[test]
490 fn to_args_roundtrips_large_usize_values() {
491 if usize::BITS < 64 {
492 return;
493 }
494
495 let cli = UnsignedCli { limit: usize::MAX };
496
497 let args = to_os_args(&cli).expect("to_args should succeed for large usize values");
498 let args_as_str = args
499 .iter()
500 .map(|arg| arg.to_string_lossy().to_string())
501 .collect::<Vec<_>>();
502
503 assert!(
504 args_as_str.contains(&usize::MAX.to_string()),
505 "generated args should preserve the original unsigned value"
506 );
507
508 let parsed: UnsignedCli =
509 crate::from_slice(&args_as_str.iter().map(String::as_str).collect::<Vec<_>>())
510 .into_result()
511 .expect("roundtrip parse should succeed")
512 .get_silent();
513
514 assert_eq!(cli, parsed);
515 }
516
517 #[test]
518 fn to_args_inserts_separator_for_dash_prefixed_positionals() {
519 let cli = DashPositionalCli {
520 query: "-0".to_string(),
521 };
522
523 let args = to_os_args(&cli).expect("to_args should succeed");
524 let args_as_str = args
525 .iter()
526 .map(|arg| arg.to_string_lossy().to_string())
527 .collect::<Vec<_>>();
528
529 assert_eq!(args_as_str, vec!["--", "-0"]);
530
531 let parsed: DashPositionalCli =
532 crate::from_slice(&args_as_str.iter().map(String::as_str).collect::<Vec<_>>())
533 .into_result()
534 .expect("roundtrip parse should succeed")
535 .get_silent();
536
537 assert_eq!(cli, parsed);
538 }
539
540 #[test]
541 fn to_args_fails_for_unknown_subcommand_variant() {
542 let schema = Schema::from_shape(Cli::SHAPE).expect("schema should be valid");
543
544 let mut root = indexmap! {};
545 root.insert(
546 "command".to_string(),
547 ConfigValue::Enum(Sourced::new(EnumValue {
548 variant: "Unknown".to_string(),
549 fields: indexmap! {},
550 })),
551 );
552
553 let mut args = Vec::new();
554 let error = encode_level(schema.args(), &root, &mut args).expect_err("should fail");
555
556 assert!(matches!(
557 error,
558 ToArgsError::UnknownSubcommandVariant { .. }
559 ));
560 }
561
562 #[test]
563 fn to_args_fails_for_unknown_string_subcommand_value() {
564 let schema = Schema::from_shape(Cli::SHAPE).expect("schema should be valid");
565
566 let mut root = indexmap! {};
567 root.insert(
568 "command".to_string(),
569 ConfigValue::String(Sourced::new("build".to_string())),
570 );
571
572 let mut args = Vec::new();
573 let error = encode_level(schema.args(), &root, &mut args).expect_err("should fail");
574
575 assert!(matches!(
576 error,
577 ToArgsError::UnknownSubcommandVariant { .. }
578 ));
579 }
580}