Skip to main content

figue/
to_args.rs

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/// Error type for converting a typed CLI value back into command-line arguments.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum ToArgsError {
14    /// Failed to build schema from the type's shape.
15    SchemaBuild(String),
16    /// Failed to serialize a value into an intermediate config tree.
17    Serialize(String),
18    /// Top-level value was not an object/map.
19    InvalidRootValue,
20    /// A required subcommand field contained a non-enum value.
21    InvalidSubcommandValue {
22        /// Effective field name of the subcommand field.
23        field_name: String,
24    },
25    /// An enum variant did not match any known subcommand.
26    UnknownSubcommandVariant {
27        /// Effective field name of the subcommand field.
28        field_name: String,
29        /// Variant name encountered in serialized data.
30        variant: String,
31    },
32    /// A counted flag had a negative count.
33    NegativeCount {
34        /// Effective field name of the counted argument.
35        arg_name: String,
36        /// Negative count encountered in serialized data.
37        count: i64,
38    },
39    /// A scalar argument value had an unsupported shape.
40    UnsupportedScalarValue {
41        /// Effective field name of the argument.
42        arg_name: String,
43    },
44    /// Failed to resolve the current executable path.
45    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
91/// Convert a typed CLI value into a vector of CLI arguments.
92///
93/// This uses figue's schema and Facet serialization metadata, so consumers do not
94/// need to hand-write ad-hoc `ToArgs` implementations for each command/subcommand.
95pub 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
101/// Convert a typed CLI value into a shell-friendly command argument string.
102///
103/// This is equivalent to [`to_os_args`] joined by spaces with lossy UTF-8 conversion.
104pub 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
113/// Convert a typed CLI value into a shell-friendly command string prefixed with
114/// the current executable path.
115pub 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
130/// Convenience trait for converting typed CLI values to argument vectors.
131pub trait ToArgs: Facet<'static> {
132    /// Convert this value into a vector of CLI arguments.
133    fn to_args(&self) -> Result<Vec<OsString>, ToArgsError> {
134        to_os_args(self)
135    }
136
137    /// Convert this value into a shell-friendly command argument string.
138    fn to_args_string(&self) -> Result<String, ToArgsError> {
139        to_args_string(self)
140    }
141
142    /// Convert this value into a shell-friendly command string prefixed with
143    /// the current executable path.
144    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}