Skip to main content

teamy_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(
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}