Skip to main content

teamy_figue/
config_value.rs

1//! Configuration value with span tracking throughout the tree.
2
3use std::string::String;
4use std::sync::Arc;
5use std::vec::Vec;
6
7use facet::Facet;
8use facet_reflect::Span;
9use indexmap::IndexMap;
10
11use crate::path::Path;
12use crate::provenance::{ConfigFile, Provenance};
13
14/// A value with full provenance tracking
15#[derive(Debug, Clone, Facet)]
16#[facet(metadata_container)]
17pub struct Sourced<T> {
18    /// The wrapped value.
19    pub value: T,
20    /// The source span (offset and length), populated during deserialization.
21    #[facet(metadata = "span")]
22    pub span: Option<Span>,
23    /// Full provenance information (user-managed metadata, not filled by deserializer).
24    #[facet(metadata = "other")]
25    pub provenance: Option<Provenance>,
26}
27
28impl<T> Sourced<T> {
29    /// Create a new Sourced value with no provenance.
30    pub fn new(value: T) -> Self {
31        Self {
32            value,
33            span: None,
34            provenance: None,
35        }
36    }
37
38    /// Create a new Sourced value with provenance.
39    pub fn with_provenance(value: T, provenance: Provenance) -> Self {
40        let span = match &provenance {
41            Provenance::File { offset, len, .. } => Some(Span::new(*offset, *len)),
42            _ => None,
43        };
44        Self {
45            value,
46            span,
47            provenance: Some(provenance),
48        }
49    }
50
51    /// Set the provenance from a config file, using the span if available.
52    pub fn set_file_provenance(&mut self, file: Arc<ConfigFile>, key_path: impl Into<String>) {
53        if let Some(span) = self.span {
54            self.provenance = Some(Provenance::file(
55                file,
56                key_path,
57                span.offset as usize,
58                span.len as usize,
59            ));
60        }
61    }
62}
63
64/// Type alias for the object map type used in ConfigValue.
65/// Keys are original field names (from target_path / struct definition).
66/// The ConfigValueParser translates to effective names when emitting events.
67pub type ObjectMap = IndexMap<String, ConfigValue, std::hash::RandomState>;
68
69/// An enum value with variant name and fields.
70#[derive(Debug, Clone, Facet)]
71pub struct EnumValue {
72    /// The variant name (kebab-case for CLI, as provided for config files).
73    pub variant: String,
74    /// Fields of the variant (empty for unit variants).
75    pub fields: ObjectMap,
76}
77
78/// A configuration value with full provenance tracking at every level.
79#[derive(Debug, Clone, Facet)]
80#[repr(u8)]
81#[facet(untagged)]
82pub enum ConfigValue {
83    /// A null value.
84    Null(Sourced<()>),
85    /// A boolean value.
86    Bool(Sourced<bool>),
87    /// An integer value.
88    Integer(Sourced<i64>),
89    /// A floating-point value.
90    Float(Sourced<f64>),
91    /// A string value.
92    String(Sourced<String>),
93    /// An array of values.
94    Array(Sourced<Vec<ConfigValue>>),
95    /// An object/map of key-value pairs.
96    Object(Sourced<ObjectMap>),
97    /// An enum value (subcommand or enum field in config).
98    Enum(Sourced<EnumValue>),
99}
100
101pub trait ConfigValueVisitor {
102    fn enter_value(&mut self, _path: &Path, _value: &ConfigValue) {}
103    fn exit_value(&mut self, _path: &Path, _value: &ConfigValue) {}
104}
105
106/// Mutable visitor for transforming ConfigValue trees.
107pub trait ConfigValueVisitorMut {
108    /// Called for each value, allowing mutation.
109    /// Called before recursing into children.
110    fn visit_value(&mut self, _path: &Path, _value: &mut ConfigValue) {}
111}
112
113impl ConfigValue {
114    /// Visit all ConfigValue nodes in depth-first order.
115    pub fn visit(&self, visitor: &mut impl ConfigValueVisitor, path: &mut Path) {
116        visitor.enter_value(path, self);
117        match self {
118            ConfigValue::Array(arr) => {
119                for (i, item) in arr.value.iter().enumerate() {
120                    path.push(i.to_string());
121                    item.visit(visitor, path);
122                    path.pop();
123                }
124            }
125            ConfigValue::Object(obj) => {
126                for (key, value) in &obj.value {
127                    path.push(key.clone());
128                    value.visit(visitor, path);
129                    path.pop();
130                }
131            }
132            _ => {}
133        }
134        visitor.exit_value(path, self);
135    }
136
137    /// Visit all ConfigValue nodes mutably in depth-first order.
138    pub fn visit_mut(&mut self, visitor: &mut impl ConfigValueVisitorMut, path: &mut Path) {
139        visitor.visit_value(path, self);
140        match self {
141            ConfigValue::Array(arr) => {
142                for (i, item) in arr.value.iter_mut().enumerate() {
143                    path.push(i.to_string());
144                    item.visit_mut(visitor, path);
145                    path.pop();
146                }
147            }
148            ConfigValue::Object(obj) => {
149                for (key, value) in obj.value.iter_mut() {
150                    path.push(key.clone());
151                    value.visit_mut(visitor, path);
152                    path.pop();
153                }
154            }
155            ConfigValue::Enum(e) => {
156                for (key, value) in e.value.fields.iter_mut() {
157                    path.push(key.clone());
158                    value.visit_mut(visitor, path);
159                    path.pop();
160                }
161            }
162            _ => {}
163        }
164    }
165
166    /// Get the span of this value (regardless of variant).
167    pub fn span(&self) -> Option<Span> {
168        match self {
169            ConfigValue::Null(s) => s.span,
170            ConfigValue::Bool(s) => s.span,
171            ConfigValue::Integer(s) => s.span,
172            ConfigValue::Float(s) => s.span,
173            ConfigValue::String(s) => s.span,
174            ConfigValue::Array(s) => s.span,
175            ConfigValue::Object(s) => s.span,
176            ConfigValue::Enum(s) => s.span,
177        }
178    }
179
180    /// Get a mutable reference to the span of this value.
181    pub fn span_mut(&mut self) -> &mut Option<Span> {
182        match self {
183            ConfigValue::Null(s) => &mut s.span,
184            ConfigValue::Bool(s) => &mut s.span,
185            ConfigValue::Integer(s) => &mut s.span,
186            ConfigValue::Float(s) => &mut s.span,
187            ConfigValue::String(s) => &mut s.span,
188            ConfigValue::Array(s) => &mut s.span,
189            ConfigValue::Object(s) => &mut s.span,
190            ConfigValue::Enum(s) => &mut s.span,
191        }
192    }
193
194    /// Get the provenance of this value (regardless of variant).
195    pub fn provenance(&self) -> Option<&Provenance> {
196        match self {
197            ConfigValue::Null(s) => s.provenance.as_ref(),
198            ConfigValue::Bool(s) => s.provenance.as_ref(),
199            ConfigValue::Integer(s) => s.provenance.as_ref(),
200            ConfigValue::Float(s) => s.provenance.as_ref(),
201            ConfigValue::String(s) => s.provenance.as_ref(),
202            ConfigValue::Array(s) => s.provenance.as_ref(),
203            ConfigValue::Object(s) => s.provenance.as_ref(),
204            ConfigValue::Enum(s) => s.provenance.as_ref(),
205        }
206    }
207
208    /// Navigate to a value by path.
209    pub fn get_by_path(&self, path: &Path) -> Option<&ConfigValue> {
210        let mut current = self;
211        for segment in path {
212            match current {
213                ConfigValue::Object(obj) => {
214                    current = obj.value.get(segment)?;
215                }
216                ConfigValue::Array(arr) => {
217                    let index: usize = segment.parse().ok()?;
218                    current = arr.value.get(index)?;
219                }
220                _ => return None,
221            }
222        }
223        Some(current)
224    }
225
226    /// Navigate to a value by path (mutable).
227    pub fn get_by_path_mut(&mut self, path: &Path) -> Option<&mut ConfigValue> {
228        let mut current = self;
229        for segment in path {
230            match current {
231                ConfigValue::Object(obj) => {
232                    current = obj.value.get_mut(segment)?;
233                }
234                ConfigValue::Array(arr) => {
235                    let index: usize = segment.parse().ok()?;
236                    current = arr.value.get_mut(index)?;
237                }
238                _ => return None,
239            }
240        }
241        Some(current)
242    }
243
244    /// Recursively set file provenance on this value and all nested values.
245    ///
246    /// This should be called after parsing a config file to populate provenance
247    /// on the entire tree.
248    pub fn set_file_provenance_recursive(&mut self, file: &Arc<ConfigFile>, path: &str) {
249        match self {
250            ConfigValue::Null(s) => s.set_file_provenance(file.clone(), path),
251            ConfigValue::Bool(s) => s.set_file_provenance(file.clone(), path),
252            ConfigValue::Integer(s) => s.set_file_provenance(file.clone(), path),
253            ConfigValue::Float(s) => s.set_file_provenance(file.clone(), path),
254            ConfigValue::String(s) => s.set_file_provenance(file.clone(), path),
255            ConfigValue::Array(s) => {
256                s.set_file_provenance(file.clone(), path);
257                for (i, item) in s.value.iter_mut().enumerate() {
258                    let item_path = if path.is_empty() {
259                        format!("{i}")
260                    } else {
261                        format!("{path}[{i}]")
262                    };
263                    item.set_file_provenance_recursive(file, &item_path);
264                }
265            }
266            ConfigValue::Object(s) => {
267                s.set_file_provenance(file.clone(), path);
268                for (key, value) in s.value.iter_mut() {
269                    let key_path = if path.is_empty() {
270                        key.clone()
271                    } else {
272                        format!("{path}.{key}")
273                    };
274                    value.set_file_provenance_recursive(file, &key_path);
275                }
276            }
277            ConfigValue::Enum(s) => {
278                s.set_file_provenance(file.clone(), path);
279                for (key, value) in s.value.fields.iter_mut() {
280                    let key_path = if path.is_empty() {
281                        key.clone()
282                    } else {
283                        format!("{path}.{key}")
284                    };
285                    value.set_file_provenance_recursive(file, &key_path);
286                }
287            }
288        }
289    }
290
291    /// Extract the selected subcommand path from a ConfigValue.
292    ///
293    /// Given a ConfigValue representing parsed CLI args, this extracts the chain of
294    /// selected subcommands. For example, if the user ran `myapp repo clone --help`,
295    /// this would return `["Repo", "Clone"]` (using variant names, not CLI names).
296    ///
297    /// The `subcommand_field_name` is the name of the field that holds the subcommand
298    /// at the root level (e.g., "command" for `#[facet(args::subcommand)] command: Command`).
299    pub fn extract_subcommand_path(&self, subcommand_field_name: &str) -> Vec<String> {
300        let mut path = Vec::new();
301        self.extract_subcommand_path_recursive(subcommand_field_name, &mut path);
302        path
303    }
304
305    fn extract_subcommand_path_recursive(&self, field_name: &str, path: &mut Vec<String>) {
306        // Look for the subcommand field in this value
307        let subcommand_value = match self {
308            ConfigValue::Object(obj) => obj.value.get(field_name),
309            _ => None,
310        };
311
312        if let Some(ConfigValue::Enum(enum_val)) = subcommand_value {
313            // Add this variant to the path
314            path.push(enum_val.value.variant.clone());
315
316            // Check if this variant has a nested subcommand
317            // Look for a field that itself contains an Enum (indicating nested subcommand)
318            for (_key, value) in &enum_val.value.fields {
319                if let ConfigValue::Enum(nested) = value {
320                    // Found a nested subcommand - add it and recurse
321                    path.push(nested.value.variant.clone());
322                    // Check if this nested enum has further nested subcommands
323                    for (_k, v) in &nested.value.fields {
324                        if matches!(v, ConfigValue::Enum(_)) {
325                            // Recurse with a synthetic object containing this enum
326                            let mut synthetic = ObjectMap::default();
327                            synthetic.insert("__nested".to_string(), v.clone());
328                            let obj = ConfigValue::Object(Sourced::new(synthetic));
329                            obj.extract_subcommand_path_recursive("__nested", path);
330                        }
331                    }
332                    break;
333                }
334            }
335        }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use facet_core::Facet;
343
344    #[test]
345    fn test_unit_is_scalar() {
346        let shape = <() as Facet>::SHAPE;
347        assert!(
348            shape.scalar_type().is_some(),
349            "() should have a scalar type: {:?}",
350            shape.scalar_type()
351        );
352    }
353
354    #[test]
355    fn test_sourced_unit_unwraps_to_scalar() {
356        let shape = <Sourced<()> as Facet>::SHAPE;
357        assert!(
358            shape.is_metadata_container(),
359            "Sourced<()> should be a metadata container"
360        );
361
362        let inner = facet_reflect::get_metadata_container_value_shape(shape);
363        assert!(inner.is_some(), "should get inner shape from Sourced<()>");
364
365        let inner = inner.unwrap();
366        assert!(
367            inner.scalar_type().is_some(),
368            "inner shape should be scalar (unit): {:?}",
369            inner.scalar_type()
370        );
371    }
372
373    #[test]
374    fn test_null_variant_classification() {
375        use facet_core::Facet;
376        use facet_solver::VariantsByFormat;
377
378        let shape = <ConfigValue as Facet>::SHAPE;
379        let variants = VariantsByFormat::from_shape(shape).expect("should get variants");
380
381        // Check that we have a scalar variant for Null
382        let null_variant = variants
383            .scalar_variants
384            .iter()
385            .find(|(v, _)| v.name == "Null");
386        assert!(
387            null_variant.is_some(),
388            "Null should be in scalar_variants. Found: {:?}",
389            variants
390                .scalar_variants
391                .iter()
392                .map(|(v, _)| v.name)
393                .collect::<Vec<_>>()
394        );
395
396        let (_, inner_shape) = null_variant.unwrap();
397        assert!(
398            inner_shape.scalar_type().is_some(),
399            "Null's inner_shape should have a scalar type: {:?}",
400            inner_shape.scalar_type()
401        );
402        assert_eq!(
403            inner_shape.scalar_type(),
404            Some(facet_core::ScalarType::Unit),
405            "Null's inner_shape should be Unit type"
406        );
407    }
408
409    #[test]
410    fn test_sourced_is_metadata_container() {
411        let shape = <Sourced<i64> as Facet>::SHAPE;
412        assert!(
413            shape.is_metadata_container(),
414            "Sourced<i64> should be a metadata container"
415        );
416
417        let inner = facet_reflect::get_metadata_container_value_shape(shape);
418        assert!(inner.is_some(), "should get inner shape");
419
420        let inner = inner.unwrap();
421        assert!(
422            inner.scalar_type().is_some(),
423            "inner shape should be scalar (i64)"
424        );
425    }
426
427    #[test]
428    fn test_parse_null() {
429        let json = "null";
430        let value: ConfigValue = facet_json::from_str(json).expect("should parse null");
431        assert!(matches!(value, ConfigValue::Null(_)));
432    }
433
434    #[test]
435    fn test_parse_bool_true() {
436        let json = "true";
437        let value: ConfigValue = facet_json::from_str(json).expect("should parse true");
438        assert!(matches!(value, ConfigValue::Bool(ref s) if s.value));
439    }
440
441    #[test]
442    fn test_parse_bool_false() {
443        let json = "false";
444        let value: ConfigValue = facet_json::from_str(json).expect("should parse false");
445        assert!(matches!(value, ConfigValue::Bool(ref s) if !s.value));
446    }
447
448    #[test]
449    fn test_parse_integer() {
450        let json = "42";
451        let value: ConfigValue = facet_json::from_str(json).expect("should parse integer");
452        assert!(matches!(value, ConfigValue::Integer(ref s) if s.value == 42));
453    }
454
455    #[test]
456    fn test_parse_negative_integer() {
457        let json = "-123";
458        let value: ConfigValue = facet_json::from_str(json).expect("should parse negative integer");
459        assert!(matches!(value, ConfigValue::Integer(ref s) if s.value == -123));
460    }
461
462    #[test]
463    fn test_parse_float() {
464        let json = "3.5";
465        let value: ConfigValue = facet_json::from_str(json).expect("should parse float");
466        assert!(matches!(value, ConfigValue::Float(ref s) if (s.value - 3.5).abs() < 0.001));
467    }
468
469    #[test]
470    fn test_parse_string() {
471        let json = r#""hello""#;
472        let value: ConfigValue = facet_json::from_str(json).expect("should parse string");
473        assert!(matches!(value, ConfigValue::String(ref s) if s.value == "hello"));
474    }
475
476    #[test]
477    fn test_parse_empty_string() {
478        let json = r#""""#;
479        let value: ConfigValue = facet_json::from_str(json).expect("should parse empty string");
480        assert!(matches!(value, ConfigValue::String(ref s) if s.value.is_empty()));
481    }
482
483    #[test]
484    fn test_parse_array() {
485        let json = r#"[1, 2, 3]"#;
486        let value: ConfigValue = facet_json::from_str(json).expect("should parse array");
487        assert!(matches!(value, ConfigValue::Array(ref s) if s.value.len() == 3));
488    }
489
490    #[test]
491    fn test_parse_empty_array() {
492        let json = "[]";
493        let value: ConfigValue = facet_json::from_str(json).expect("should parse empty array");
494        assert!(matches!(value, ConfigValue::Array(ref s) if s.value.is_empty()));
495    }
496
497    #[test]
498    fn test_parse_object() {
499        let json = r#"{"name": "hello", "count": 42}"#;
500        let value: ConfigValue = facet_json::from_str(json).expect("should parse object");
501        assert!(matches!(value, ConfigValue::Object(_)));
502    }
503
504    #[test]
505    fn test_parse_empty_object() {
506        let json = "{}";
507        let value: ConfigValue = facet_json::from_str(json).expect("should parse empty object");
508        assert!(matches!(value, ConfigValue::Object(ref s) if s.value.is_empty()));
509    }
510
511    #[test]
512    fn test_parse_nested_object() {
513        let json = r#"{"outer": {"inner": 42}}"#;
514        let value: ConfigValue = facet_json::from_str(json).expect("should parse nested object");
515        assert!(matches!(value, ConfigValue::Object(_)));
516    }
517
518    #[test]
519    fn test_parse_mixed_array() {
520        let json = r#"[1, "two", true, null]"#;
521        let value: ConfigValue = facet_json::from_str(json).expect("should parse mixed array");
522        if let ConfigValue::Array(arr) = value {
523            assert_eq!(arr.value.len(), 4);
524            assert!(matches!(arr.value[0], ConfigValue::Integer(_)));
525            assert!(matches!(arr.value[1], ConfigValue::String(_)));
526            assert!(matches!(arr.value[2], ConfigValue::Bool(_)));
527            assert!(matches!(arr.value[3], ConfigValue::Null(_)));
528        } else {
529            panic!("expected array");
530        }
531    }
532
533    // === Sourced<T> tests (experimental provenance tracking) ===
534
535    #[test]
536    fn test_sourced_deserialize_integer() {
537        // Test that Sourced<i64> deserializes correctly with #[facet(skip)] on provenance
538        let json = "42";
539        let result: Result<Sourced<i64>, _> = facet_json::from_str(json);
540        assert!(
541            result.is_ok(),
542            "Sourced<i64> should deserialize: {:?}",
543            result.err()
544        );
545        let sourced = result.unwrap();
546        assert_eq!(sourced.value, 42);
547        assert!(
548            sourced.provenance.is_none(),
549            "provenance should be None after deserialization"
550        );
551    }
552
553    #[test]
554    fn test_sourced_deserialize_string() {
555        let json = r#""hello""#;
556        let result: Result<Sourced<String>, _> = facet_json::from_str(json);
557        assert!(
558            result.is_ok(),
559            "Sourced<String> should deserialize: {:?}",
560            result.err()
561        );
562        let sourced = result.unwrap();
563        assert_eq!(sourced.value, "hello");
564        assert!(sourced.provenance.is_none());
565    }
566
567    #[test]
568    fn test_sourced_with_provenance() {
569        let file = Arc::new(ConfigFile::new("config.json", r#"{"port": 8080}"#));
570        let sourced = Sourced::with_provenance(8080i64, Provenance::file(file, "port", 9, 4));
571
572        assert_eq!(sourced.value, 8080);
573        assert!(sourced.provenance.is_some());
574        assert!(sourced.provenance.as_ref().unwrap().is_file());
575        // Span should be derived from provenance
576        assert_eq!(sourced.span, Some(Span::new(9, 4)));
577    }
578
579    #[test]
580    fn test_sourced_set_file_provenance() {
581        // Simulate what happens after deserialization: we have a span, then add file provenance
582        let mut sourced = Sourced {
583            value: 8080i64,
584            span: Some(Span::new(9, 4)),
585            provenance: None,
586        };
587
588        let file = Arc::new(ConfigFile::new("config.json", r#"{"port": 8080}"#));
589        sourced.set_file_provenance(file.clone(), "port");
590
591        assert!(sourced.provenance.is_some());
592        if let Some(Provenance::File {
593            file: f,
594            key_path,
595            offset,
596            len,
597        }) = &sourced.provenance
598        {
599            assert_eq!(f.path.as_str(), "config.json");
600            assert_eq!(key_path, "port");
601            assert_eq!(*offset, 9);
602            assert_eq!(*len, 4);
603        } else {
604            panic!("expected File provenance");
605        }
606    }
607
608    #[test]
609    fn test_set_file_provenance_recursive() {
610        // Parse a nested JSON object
611        let json = r#"{"port": 8080, "smtp": {"host": "mail.example.com", "port": 587}}"#;
612        let mut value: ConfigValue = facet_json::from_str(json).expect("should parse");
613
614        // Create a config file and set provenance recursively
615        let file = Arc::new(ConfigFile::new("config.json", json));
616        value.set_file_provenance_recursive(&file, "");
617
618        // Check root object has provenance
619        if let ConfigValue::Object(ref obj) = value {
620            assert!(obj.provenance.is_some());
621
622            // Check "port" has provenance with correct key_path
623            if let Some(ConfigValue::Integer(port)) = obj.value.get("port") {
624                assert!(port.provenance.is_some());
625                if let Some(Provenance::File { key_path, .. }) = &port.provenance {
626                    assert_eq!(key_path, "port");
627                } else {
628                    panic!("expected File provenance for port");
629                }
630            } else {
631                panic!("expected port field");
632            }
633
634            // Check nested "smtp.host" has correct key_path
635            if let Some(ConfigValue::Object(smtp)) = obj.value.get("smtp") {
636                assert!(smtp.provenance.is_some());
637                if let Some(Provenance::File { key_path, .. }) = &smtp.provenance {
638                    assert_eq!(key_path, "smtp");
639                }
640
641                if let Some(ConfigValue::String(host)) = smtp.value.get("host") {
642                    assert!(host.provenance.is_some());
643                    if let Some(Provenance::File { key_path, .. }) = &host.provenance {
644                        assert_eq!(key_path, "smtp.host");
645                    } else {
646                        panic!("expected File provenance for smtp.host");
647                    }
648                } else {
649                    panic!("expected smtp.host field");
650                }
651            } else {
652                panic!("expected smtp field");
653            }
654        } else {
655            panic!("expected object");
656        }
657    }
658
659    /// Helper to create a test ConfigValue object
660    fn cv_object(fields: impl IntoIterator<Item = (&'static str, ConfigValue)>) -> ConfigValue {
661        let map: ObjectMap = fields
662            .into_iter()
663            .map(|(k, v)| (k.to_string(), v))
664            .collect();
665        ConfigValue::Object(Sourced::new(map))
666    }
667
668    /// Helper to create a test ConfigValue enum
669    fn cv_enum(
670        variant: &str,
671        fields: impl IntoIterator<Item = (&'static str, ConfigValue)>,
672    ) -> ConfigValue {
673        let map: ObjectMap = fields
674            .into_iter()
675            .map(|(k, v)| (k.to_string(), v))
676            .collect();
677        ConfigValue::Enum(Sourced::new(EnumValue {
678            variant: variant.to_string(),
679            fields: map,
680        }))
681    }
682
683    fn cv_string(s: &str) -> ConfigValue {
684        ConfigValue::String(Sourced::new(s.to_string()))
685    }
686
687    fn cv_bool(b: bool) -> ConfigValue {
688        ConfigValue::Bool(Sourced::new(b))
689    }
690
691    #[test]
692    fn test_extract_subcommand_path_single_level() {
693        // Simulates: myapp install foo
694        // Structure: { command: Enum("Install", { package: "foo" }) }
695        let cli_value = cv_object([(
696            "command",
697            cv_enum("Install", [("package", cv_string("foo"))]),
698        )]);
699
700        let path = cli_value.extract_subcommand_path("command");
701        assert_eq!(path, vec!["Install"]);
702    }
703
704    #[test]
705    fn test_extract_subcommand_path_two_levels() {
706        // Simulates: myapp repo clone https://example.com
707        // Structure: { command: Enum("Repo", { action: Enum("Clone", { url: "..." }) }) }
708        let cli_value = cv_object([(
709            "command",
710            cv_enum(
711                "Repo",
712                [(
713                    "action",
714                    cv_enum("Clone", [("url", cv_string("https://example.com"))]),
715                )],
716            ),
717        )]);
718
719        let path = cli_value.extract_subcommand_path("command");
720        assert_eq!(path, vec!["Repo", "Clone"]);
721    }
722
723    #[test]
724    fn test_extract_subcommand_path_three_levels() {
725        // Simulates: myapp cloud aws s3 upload
726        // Structure: { command: Enum("Cloud", { provider: Enum("Aws", { service: Enum("S3", { action: Enum("Upload", {}) }) }) }) }
727        let cli_value = cv_object([(
728            "command",
729            cv_enum(
730                "Cloud",
731                [(
732                    "provider",
733                    cv_enum(
734                        "Aws",
735                        [(
736                            "service",
737                            cv_enum("S3", [("action", cv_enum("Upload", []))]),
738                        )],
739                    ),
740                )],
741            ),
742        )]);
743
744        let path = cli_value.extract_subcommand_path("command");
745        assert_eq!(path, vec!["Cloud", "Aws", "S3", "Upload"]);
746    }
747
748    #[test]
749    fn test_extract_subcommand_path_no_subcommand() {
750        // No subcommand field
751        let cli_value = cv_object([("verbose", cv_bool(true))]);
752
753        let path = cli_value.extract_subcommand_path("command");
754        assert!(path.is_empty());
755    }
756
757    #[test]
758    fn test_extract_subcommand_path_with_other_fields() {
759        // Simulates: myapp --verbose install foo
760        // Structure: { verbose: true, command: Enum("Install", { package: "foo" }) }
761        let cli_value = cv_object([
762            ("verbose", cv_bool(true)),
763            (
764                "command",
765                cv_enum("Install", [("package", cv_string("foo"))]),
766            ),
767        ]);
768
769        let path = cli_value.extract_subcommand_path("command");
770        assert_eq!(path, vec!["Install"]);
771    }
772}