holoconf_core/
resolver.rs

1//! Resolver architecture per ADR-002
2//!
3//! Resolvers are functions or objects that resolve interpolation expressions
4//! like `${env:VAR}` to actual values.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use crate::error::{Error, Result};
10use crate::value::Value;
11
12/// A resolved value with optional sensitivity metadata
13#[derive(Debug, Clone)]
14pub struct ResolvedValue {
15    /// The actual resolved value
16    pub value: Value,
17    /// Whether this value is sensitive (should be redacted in logs/exports)
18    pub sensitive: bool,
19}
20
21impl ResolvedValue {
22    /// Create a non-sensitive resolved value
23    pub fn new(value: impl Into<Value>) -> Self {
24        Self {
25            value: value.into(),
26            sensitive: false,
27        }
28    }
29
30    /// Create a sensitive resolved value
31    pub fn sensitive(value: impl Into<Value>) -> Self {
32        Self {
33            value: value.into(),
34            sensitive: true,
35        }
36    }
37}
38
39impl From<Value> for ResolvedValue {
40    fn from(value: Value) -> Self {
41        ResolvedValue::new(value)
42    }
43}
44
45impl From<String> for ResolvedValue {
46    fn from(s: String) -> Self {
47        ResolvedValue::new(Value::String(s))
48    }
49}
50
51impl From<&str> for ResolvedValue {
52    fn from(s: &str) -> Self {
53        ResolvedValue::new(Value::String(s.to_string()))
54    }
55}
56
57/// Context provided to resolvers during resolution
58#[derive(Debug, Clone)]
59pub struct ResolverContext {
60    /// The path in the config where this resolution is happening
61    pub config_path: String,
62    /// The config root (for self-references)
63    pub config_root: Option<Arc<Value>>,
64    /// The base path for relative file paths
65    pub base_path: Option<std::path::PathBuf>,
66    /// Resolution stack for circular reference detection
67    pub resolution_stack: Vec<String>,
68}
69
70impl ResolverContext {
71    /// Create a new resolver context
72    pub fn new(config_path: impl Into<String>) -> Self {
73        Self {
74            config_path: config_path.into(),
75            config_root: None,
76            base_path: None,
77            resolution_stack: Vec::new(),
78        }
79    }
80
81    /// Set the config root for self-references
82    pub fn with_config_root(mut self, root: Arc<Value>) -> Self {
83        self.config_root = Some(root);
84        self
85    }
86
87    /// Set the base path for file resolution
88    pub fn with_base_path(mut self, path: std::path::PathBuf) -> Self {
89        self.base_path = Some(path);
90        self
91    }
92
93    /// Check if resolving a path would cause a circular reference
94    pub fn would_cause_cycle(&self, path: &str) -> bool {
95        self.resolution_stack.contains(&path.to_string())
96    }
97
98    /// Push a path onto the resolution stack
99    pub fn push_resolution(&mut self, path: &str) {
100        self.resolution_stack.push(path.to_string());
101    }
102
103    /// Pop a path from the resolution stack
104    pub fn pop_resolution(&mut self) {
105        self.resolution_stack.pop();
106    }
107
108    /// Get the resolution chain for error reporting
109    pub fn get_resolution_chain(&self) -> Vec<String> {
110        self.resolution_stack.clone()
111    }
112}
113
114/// Trait for resolver implementations
115pub trait Resolver: Send + Sync {
116    /// Resolve an interpolation expression
117    ///
118    /// # Arguments
119    /// * `args` - Positional arguments from the interpolation
120    /// * `kwargs` - Keyword arguments from the interpolation
121    /// * `ctx` - Resolution context
122    fn resolve(
123        &self,
124        args: &[String],
125        kwargs: &HashMap<String, String>,
126        ctx: &ResolverContext,
127    ) -> Result<ResolvedValue>;
128
129    /// Get the name of this resolver
130    fn name(&self) -> &str;
131}
132
133/// A simple function-based resolver
134pub struct FnResolver<F>
135where
136    F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
137        + Send
138        + Sync,
139{
140    name: String,
141    func: F,
142}
143
144impl<F> FnResolver<F>
145where
146    F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
147        + Send
148        + Sync,
149{
150    /// Create a new function-based resolver
151    pub fn new(name: impl Into<String>, func: F) -> Self {
152        Self {
153            name: name.into(),
154            func,
155        }
156    }
157}
158
159impl<F> Resolver for FnResolver<F>
160where
161    F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
162        + Send
163        + Sync,
164{
165    fn resolve(
166        &self,
167        args: &[String],
168        kwargs: &HashMap<String, String>,
169        ctx: &ResolverContext,
170    ) -> Result<ResolvedValue> {
171        (self.func)(args, kwargs, ctx)
172    }
173
174    fn name(&self) -> &str {
175        &self.name
176    }
177}
178
179/// Registry of available resolvers
180pub struct ResolverRegistry {
181    resolvers: HashMap<String, Arc<dyn Resolver>>,
182}
183
184impl Default for ResolverRegistry {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190impl ResolverRegistry {
191    /// Create a new empty registry
192    pub fn new() -> Self {
193        Self {
194            resolvers: HashMap::new(),
195        }
196    }
197
198    /// Create a registry with the standard built-in resolvers
199    pub fn with_builtins() -> Self {
200        let mut registry = Self::new();
201        registry.register_builtin_resolvers();
202        registry
203    }
204
205    /// Register the built-in resolvers (env, file, http)
206    fn register_builtin_resolvers(&mut self) {
207        // Environment variable resolver
208        self.register(Arc::new(FnResolver::new("env", env_resolver)));
209        // File resolver
210        self.register(Arc::new(FnResolver::new("file", file_resolver)));
211        // HTTP resolver (disabled by default for security)
212        self.register(Arc::new(FnResolver::new("http", http_resolver)));
213    }
214
215    /// Register a resolver
216    pub fn register(&mut self, resolver: Arc<dyn Resolver>) {
217        self.resolvers.insert(resolver.name().to_string(), resolver);
218    }
219
220    /// Register a function as a resolver
221    pub fn register_fn<F>(&mut self, name: impl Into<String>, func: F)
222    where
223        F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
224            + Send
225            + Sync
226            + 'static,
227    {
228        let name = name.into();
229        self.register(Arc::new(FnResolver::new(name, func)));
230    }
231
232    /// Get a resolver by name
233    pub fn get(&self, name: &str) -> Option<&Arc<dyn Resolver>> {
234        self.resolvers.get(name)
235    }
236
237    /// Check if a resolver is registered
238    pub fn contains(&self, name: &str) -> bool {
239        self.resolvers.contains_key(name)
240    }
241
242    /// Resolve an interpolation using the appropriate resolver
243    pub fn resolve(
244        &self,
245        resolver_name: &str,
246        args: &[String],
247        kwargs: &HashMap<String, String>,
248        ctx: &ResolverContext,
249    ) -> Result<ResolvedValue> {
250        let resolver = self
251            .resolvers
252            .get(resolver_name)
253            .ok_or_else(|| Error::unknown_resolver(resolver_name, Some(ctx.config_path.clone())))?;
254
255        resolver.resolve(args, kwargs, ctx)
256    }
257}
258
259/// Built-in environment variable resolver
260///
261/// Usage:
262///   ${env:VAR_NAME}                      - Get env var (error if not set)
263///   ${env:VAR_NAME,default}              - Get env var with default
264///   ${env:VAR_NAME,sensitive=true}       - Mark as sensitive for redaction
265///   ${env:VAR_NAME,default,sensitive=true} - Both default and sensitive
266fn env_resolver(
267    args: &[String],
268    kwargs: &HashMap<String, String>,
269    ctx: &ResolverContext,
270) -> Result<ResolvedValue> {
271    if args.is_empty() {
272        return Err(Error::parse("env resolver requires a variable name")
273            .with_path(ctx.config_path.clone()));
274    }
275
276    let var_name = &args[0];
277    let default_value = args.get(1);
278
279    // Check if sensitive=true is set in kwargs
280    let is_sensitive = kwargs
281        .get("sensitive")
282        .map(|v| v.eq_ignore_ascii_case("true"))
283        .unwrap_or(false);
284
285    match std::env::var(var_name) {
286        Ok(value) => {
287            let resolved_value = Value::String(value);
288            if is_sensitive {
289                Ok(ResolvedValue::sensitive(resolved_value))
290            } else {
291                Ok(ResolvedValue::new(resolved_value))
292            }
293        }
294        Err(_) => {
295            if let Some(default) = default_value {
296                let resolved_value = Value::String(default.clone());
297                if is_sensitive {
298                    Ok(ResolvedValue::sensitive(resolved_value))
299                } else {
300                    Ok(ResolvedValue::new(resolved_value))
301                }
302            } else {
303                Err(Error::env_not_found(
304                    var_name,
305                    Some(ctx.config_path.clone()),
306                ))
307            }
308        }
309    }
310}
311
312/// Built-in file resolver
313fn file_resolver(
314    args: &[String],
315    kwargs: &HashMap<String, String>,
316    ctx: &ResolverContext,
317) -> Result<ResolvedValue> {
318    use std::path::Path;
319
320    if args.is_empty() {
321        return Err(
322            Error::parse("file resolver requires a file path").with_path(ctx.config_path.clone())
323        );
324    }
325
326    let file_path_str = &args[0];
327    let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("auto");
328
329    // Resolve relative paths based on context base path
330    let file_path = if Path::new(file_path_str).is_relative() {
331        if let Some(base) = &ctx.base_path {
332            base.join(file_path_str)
333        } else {
334            std::path::PathBuf::from(file_path_str)
335        }
336    } else {
337        std::path::PathBuf::from(file_path_str)
338    };
339
340    // Read the file
341    let content = std::fs::read_to_string(&file_path)
342        .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?;
343
344    // Determine parse mode
345    let actual_parse_mode = if parse_mode == "auto" {
346        // Detect from extension
347        match file_path.extension().and_then(|e| e.to_str()) {
348            Some("yaml") | Some("yml") => "yaml",
349            Some("json") => "json",
350            _ => "text",
351        }
352    } else {
353        parse_mode
354    };
355
356    // Parse content based on mode
357    match actual_parse_mode {
358        "yaml" => {
359            let value: Value = serde_yaml::from_str(&content).map_err(|e| {
360                Error::parse(format!("Failed to parse YAML: {}", e))
361                    .with_path(ctx.config_path.clone())
362            })?;
363            Ok(ResolvedValue::new(value))
364        }
365        "json" => {
366            let value: Value = serde_json::from_str(&content).map_err(|e| {
367                Error::parse(format!("Failed to parse JSON: {}", e))
368                    .with_path(ctx.config_path.clone())
369            })?;
370            Ok(ResolvedValue::new(value))
371        }
372        _ => {
373            // Default to text mode (including explicit "text")
374            Ok(ResolvedValue::new(Value::String(content)))
375        }
376    }
377}
378
379/// Built-in HTTP resolver
380///
381/// This resolver is registered but disabled by default for security.
382/// To enable HTTP resolution, set allow_http=true in ConfigOptions.
383///
384/// When the `http` feature is not enabled, this always returns an error.
385fn http_resolver(
386    args: &[String],
387    _kwargs: &HashMap<String, String>,
388    ctx: &ResolverContext,
389) -> Result<ResolvedValue> {
390    if args.is_empty() {
391        return Err(Error::parse("http resolver requires a URL").with_path(ctx.config_path.clone()));
392    }
393
394    let url = &args[0];
395
396    // The http resolver is always disabled by default for security
397    // Users must enable it explicitly via ConfigOptions.allow_http
398    // This is just a placeholder that always returns an error
399    // The actual HTTP fetching is done in the Config when allow_http is true
400
401    Err(Error {
402        kind: crate::error::ErrorKind::Resolver(crate::error::ResolverErrorKind::HttpDisabled),
403        path: Some(ctx.config_path.clone()),
404        source_location: None,
405        help: Some(format!(
406            "Enable HTTP resolver with Config.load(..., allow_http=True)\nURL: {}",
407            url
408        )),
409        cause: None,
410    })
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn test_env_resolver_with_value() {
419        std::env::set_var("HOLOCONF_TEST_VAR", "test_value");
420
421        let ctx = ResolverContext::new("test.path");
422        let args = vec!["HOLOCONF_TEST_VAR".to_string()];
423        let kwargs = HashMap::new();
424
425        let result = env_resolver(&args, &kwargs, &ctx).unwrap();
426        assert_eq!(result.value.as_str(), Some("test_value"));
427        assert!(!result.sensitive);
428
429        std::env::remove_var("HOLOCONF_TEST_VAR");
430    }
431
432    #[test]
433    fn test_env_resolver_with_default() {
434        // Make sure the var doesn't exist
435        std::env::remove_var("HOLOCONF_NONEXISTENT_VAR");
436
437        let ctx = ResolverContext::new("test.path");
438        let args = vec![
439            "HOLOCONF_NONEXISTENT_VAR".to_string(),
440            "default_value".to_string(),
441        ];
442        let kwargs = HashMap::new();
443
444        let result = env_resolver(&args, &kwargs, &ctx).unwrap();
445        assert_eq!(result.value.as_str(), Some("default_value"));
446    }
447
448    #[test]
449    fn test_env_resolver_missing_no_default() {
450        std::env::remove_var("HOLOCONF_MISSING_VAR");
451
452        let ctx = ResolverContext::new("test.path");
453        let args = vec!["HOLOCONF_MISSING_VAR".to_string()];
454        let kwargs = HashMap::new();
455
456        let result = env_resolver(&args, &kwargs, &ctx);
457        assert!(result.is_err());
458    }
459
460    #[test]
461    fn test_env_resolver_sensitive_kwarg() {
462        std::env::set_var("HOLOCONF_SENSITIVE_VAR", "secret_value");
463
464        let ctx = ResolverContext::new("test.path");
465        let args = vec!["HOLOCONF_SENSITIVE_VAR".to_string()];
466        let mut kwargs = HashMap::new();
467        kwargs.insert("sensitive".to_string(), "true".to_string());
468
469        let result = env_resolver(&args, &kwargs, &ctx).unwrap();
470        assert_eq!(result.value.as_str(), Some("secret_value"));
471        assert!(result.sensitive);
472
473        std::env::remove_var("HOLOCONF_SENSITIVE_VAR");
474    }
475
476    #[test]
477    fn test_env_resolver_sensitive_false() {
478        std::env::set_var("HOLOCONF_NON_SENSITIVE", "public_value");
479
480        let ctx = ResolverContext::new("test.path");
481        let args = vec!["HOLOCONF_NON_SENSITIVE".to_string()];
482        let mut kwargs = HashMap::new();
483        kwargs.insert("sensitive".to_string(), "false".to_string());
484
485        let result = env_resolver(&args, &kwargs, &ctx).unwrap();
486        assert_eq!(result.value.as_str(), Some("public_value"));
487        assert!(!result.sensitive);
488
489        std::env::remove_var("HOLOCONF_NON_SENSITIVE");
490    }
491
492    #[test]
493    fn test_env_resolver_sensitive_with_default() {
494        std::env::remove_var("HOLOCONF_SENSITIVE_DEFAULT");
495
496        let ctx = ResolverContext::new("test.path");
497        let args = vec![
498            "HOLOCONF_SENSITIVE_DEFAULT".to_string(),
499            "default_secret".to_string(),
500        ];
501        let mut kwargs = HashMap::new();
502        kwargs.insert("sensitive".to_string(), "true".to_string());
503
504        let result = env_resolver(&args, &kwargs, &ctx).unwrap();
505        assert_eq!(result.value.as_str(), Some("default_secret"));
506        assert!(result.sensitive);
507    }
508
509    #[test]
510    fn test_resolver_registry() {
511        let registry = ResolverRegistry::with_builtins();
512
513        assert!(registry.contains("env"));
514        assert!(!registry.contains("nonexistent"));
515    }
516
517    #[test]
518    fn test_custom_resolver() {
519        let mut registry = ResolverRegistry::new();
520
521        registry.register_fn("custom", |args, _kwargs, _ctx| {
522            let value = args.first().cloned().unwrap_or_default();
523            Ok(ResolvedValue::new(Value::String(format!(
524                "custom:{}",
525                value
526            ))))
527        });
528
529        let ctx = ResolverContext::new("test");
530        let result = registry
531            .resolve("custom", &["arg".to_string()], &HashMap::new(), &ctx)
532            .unwrap();
533
534        assert_eq!(result.value.as_str(), Some("custom:arg"));
535    }
536
537    #[test]
538    fn test_resolved_value_sensitivity() {
539        let non_sensitive = ResolvedValue::new("public");
540        assert!(!non_sensitive.sensitive);
541
542        let sensitive = ResolvedValue::sensitive("secret");
543        assert!(sensitive.sensitive);
544    }
545
546    #[test]
547    fn test_resolver_context_cycle_detection() {
548        let mut ctx = ResolverContext::new("root");
549        ctx.push_resolution("a");
550        ctx.push_resolution("b");
551
552        assert!(ctx.would_cause_cycle("a"));
553        assert!(ctx.would_cause_cycle("b"));
554        assert!(!ctx.would_cause_cycle("c"));
555
556        ctx.pop_resolution();
557        assert!(!ctx.would_cause_cycle("b"));
558    }
559
560    #[test]
561    fn test_file_resolver() {
562        use std::io::Write;
563
564        // Create a temporary file
565        let temp_dir = std::env::temp_dir();
566        let test_file = temp_dir.join("holoconf_test_file.txt");
567        {
568            let mut file = std::fs::File::create(&test_file).unwrap();
569            writeln!(file, "test content").unwrap();
570        }
571
572        let mut ctx = ResolverContext::new("test.path");
573        ctx.base_path = Some(temp_dir.clone());
574
575        let args = vec!["holoconf_test_file.txt".to_string()];
576        let mut kwargs = HashMap::new();
577        kwargs.insert("parse".to_string(), "text".to_string());
578
579        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
580        assert!(result.value.as_str().unwrap().contains("test content"));
581        assert!(!result.sensitive);
582
583        // Cleanup
584        std::fs::remove_file(test_file).ok();
585    }
586
587    #[test]
588    fn test_file_resolver_yaml() {
589        use std::io::Write;
590
591        // Create a temporary YAML file
592        let temp_dir = std::env::temp_dir();
593        let test_file = temp_dir.join("holoconf_test.yaml");
594        {
595            let mut file = std::fs::File::create(&test_file).unwrap();
596            writeln!(file, "key: value").unwrap();
597            writeln!(file, "number: 42").unwrap();
598        }
599
600        let mut ctx = ResolverContext::new("test.path");
601        ctx.base_path = Some(temp_dir.clone());
602
603        let args = vec!["holoconf_test.yaml".to_string()];
604        let kwargs = HashMap::new();
605
606        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
607        assert!(result.value.is_mapping());
608
609        // Cleanup
610        std::fs::remove_file(test_file).ok();
611    }
612
613    #[test]
614    fn test_file_resolver_not_found() {
615        let ctx = ResolverContext::new("test.path");
616        let args = vec!["nonexistent_file.txt".to_string()];
617        let kwargs = HashMap::new();
618
619        let result = file_resolver(&args, &kwargs, &ctx);
620        assert!(result.is_err());
621    }
622
623    #[test]
624    fn test_registry_with_file() {
625        let registry = ResolverRegistry::with_builtins();
626        assert!(registry.contains("file"));
627    }
628
629    #[test]
630    fn test_http_resolver_disabled() {
631        let ctx = ResolverContext::new("test.path");
632        let args = vec!["https://example.com/config.yaml".to_string()];
633        let kwargs = HashMap::new();
634
635        let result = http_resolver(&args, &kwargs, &ctx);
636        assert!(result.is_err());
637
638        let err = result.unwrap_err();
639        let display = format!("{}", err);
640        assert!(display.contains("HTTP resolver is disabled"));
641    }
642
643    #[test]
644    fn test_registry_with_http() {
645        let registry = ResolverRegistry::with_builtins();
646        assert!(registry.contains("http"));
647    }
648
649    // Additional edge case tests for improved coverage
650
651    #[test]
652    fn test_env_resolver_no_args() {
653        let ctx = ResolverContext::new("test.path");
654        let args = vec![];
655        let kwargs = HashMap::new();
656
657        let result = env_resolver(&args, &kwargs, &ctx);
658        assert!(result.is_err());
659        let err = result.unwrap_err();
660        assert!(err.to_string().contains("requires"));
661    }
662
663    #[test]
664    fn test_file_resolver_no_args() {
665        let ctx = ResolverContext::new("test.path");
666        let args = vec![];
667        let kwargs = HashMap::new();
668
669        let result = file_resolver(&args, &kwargs, &ctx);
670        assert!(result.is_err());
671        let err = result.unwrap_err();
672        assert!(err.to_string().contains("requires"));
673    }
674
675    #[test]
676    fn test_http_resolver_no_args() {
677        let ctx = ResolverContext::new("test.path");
678        let args = vec![];
679        let kwargs = HashMap::new();
680
681        let result = http_resolver(&args, &kwargs, &ctx);
682        assert!(result.is_err());
683        let err = result.unwrap_err();
684        assert!(err.to_string().contains("requires"));
685    }
686
687    #[test]
688    fn test_unknown_resolver() {
689        let registry = ResolverRegistry::with_builtins();
690        let ctx = ResolverContext::new("test.path");
691
692        let result = registry.resolve("unknown_resolver", &[], &HashMap::new(), &ctx);
693        assert!(result.is_err());
694        let err = result.unwrap_err();
695        assert!(err.to_string().contains("unknown_resolver"));
696    }
697
698    #[test]
699    fn test_resolved_value_from_traits() {
700        let from_value: ResolvedValue = Value::String("test".to_string()).into();
701        assert_eq!(from_value.value.as_str(), Some("test"));
702        assert!(!from_value.sensitive);
703
704        let from_string: ResolvedValue = "hello".to_string().into();
705        assert_eq!(from_string.value.as_str(), Some("hello"));
706
707        let from_str: ResolvedValue = "world".into();
708        assert_eq!(from_str.value.as_str(), Some("world"));
709    }
710
711    #[test]
712    fn test_resolver_context_with_base_path() {
713        let ctx = ResolverContext::new("test").with_base_path(std::path::PathBuf::from("/tmp"));
714        assert_eq!(ctx.base_path, Some(std::path::PathBuf::from("/tmp")));
715    }
716
717    #[test]
718    fn test_resolver_context_with_config_root() {
719        use std::sync::Arc;
720        let root = Arc::new(Value::String("root".to_string()));
721        let ctx = ResolverContext::new("test").with_config_root(root.clone());
722        assert!(ctx.config_root.is_some());
723    }
724
725    #[test]
726    fn test_resolver_context_resolution_chain() {
727        let mut ctx = ResolverContext::new("root");
728        ctx.push_resolution("a");
729        ctx.push_resolution("b");
730        ctx.push_resolution("c");
731
732        let chain = ctx.get_resolution_chain();
733        assert_eq!(chain, vec!["a", "b", "c"]);
734    }
735
736    #[test]
737    fn test_registry_get_resolver() {
738        let registry = ResolverRegistry::with_builtins();
739
740        let env_resolver = registry.get("env");
741        assert!(env_resolver.is_some());
742        assert_eq!(env_resolver.unwrap().name(), "env");
743
744        let missing = registry.get("nonexistent");
745        assert!(missing.is_none());
746    }
747
748    #[test]
749    fn test_registry_default() {
750        let registry = ResolverRegistry::default();
751        // Default registry is empty
752        assert!(!registry.contains("env"));
753    }
754
755    #[test]
756    fn test_fn_resolver_name() {
757        let resolver = FnResolver::new("my_resolver", |_, _, _| Ok(ResolvedValue::new("test")));
758        assert_eq!(resolver.name(), "my_resolver");
759    }
760
761    #[test]
762    fn test_file_resolver_json() {
763        use std::io::Write;
764
765        // Create a temporary JSON file
766        let temp_dir = std::env::temp_dir();
767        let test_file = temp_dir.join("holoconf_test.json");
768        {
769            let mut file = std::fs::File::create(&test_file).unwrap();
770            writeln!(file, r#"{{"key": "value", "number": 42}}"#).unwrap();
771        }
772
773        let mut ctx = ResolverContext::new("test.path");
774        ctx.base_path = Some(temp_dir.clone());
775
776        let args = vec!["holoconf_test.json".to_string()];
777        let kwargs = HashMap::new();
778
779        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
780        assert!(result.value.is_mapping());
781
782        // Cleanup
783        std::fs::remove_file(test_file).ok();
784    }
785
786    #[test]
787    fn test_file_resolver_absolute_path() {
788        use std::io::Write;
789
790        // Create a temporary file
791        let temp_dir = std::env::temp_dir();
792        let test_file = temp_dir.join("holoconf_abs_test.txt");
793        {
794            let mut file = std::fs::File::create(&test_file).unwrap();
795            writeln!(file, "absolute path content").unwrap();
796        }
797
798        let ctx = ResolverContext::new("test.path");
799        // No base path - using absolute path directly
800        let args = vec![test_file.to_string_lossy().to_string()];
801        let mut kwargs = HashMap::new();
802        kwargs.insert("parse".to_string(), "text".to_string());
803
804        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
805        assert!(result
806            .value
807            .as_str()
808            .unwrap()
809            .contains("absolute path content"));
810
811        // Cleanup
812        std::fs::remove_file(test_file).ok();
813    }
814
815    #[test]
816    fn test_file_resolver_invalid_yaml() {
817        use std::io::Write;
818
819        // Create a temporary file with invalid YAML
820        let temp_dir = std::env::temp_dir();
821        let test_file = temp_dir.join("holoconf_invalid.yaml");
822        {
823            let mut file = std::fs::File::create(&test_file).unwrap();
824            writeln!(file, "key: [invalid").unwrap();
825        }
826
827        let mut ctx = ResolverContext::new("test.path");
828        ctx.base_path = Some(temp_dir.clone());
829
830        let args = vec!["holoconf_invalid.yaml".to_string()];
831        let kwargs = HashMap::new();
832
833        let result = file_resolver(&args, &kwargs, &ctx);
834        assert!(result.is_err());
835        let err = result.unwrap_err();
836        assert!(err.to_string().contains("parse") || err.to_string().contains("YAML"));
837
838        // Cleanup
839        std::fs::remove_file(test_file).ok();
840    }
841
842    #[test]
843    fn test_file_resolver_invalid_json() {
844        use std::io::Write;
845
846        // Create a temporary file with invalid JSON
847        let temp_dir = std::env::temp_dir();
848        let test_file = temp_dir.join("holoconf_invalid.json");
849        {
850            let mut file = std::fs::File::create(&test_file).unwrap();
851            writeln!(file, "{{invalid json}}").unwrap();
852        }
853
854        let mut ctx = ResolverContext::new("test.path");
855        ctx.base_path = Some(temp_dir.clone());
856
857        let args = vec!["holoconf_invalid.json".to_string()];
858        let kwargs = HashMap::new();
859
860        let result = file_resolver(&args, &kwargs, &ctx);
861        assert!(result.is_err());
862        let err = result.unwrap_err();
863        assert!(err.to_string().contains("parse") || err.to_string().contains("JSON"));
864
865        // Cleanup
866        std::fs::remove_file(test_file).ok();
867    }
868
869    #[test]
870    fn test_file_resolver_unknown_extension() {
871        use std::io::Write;
872
873        // Create a temporary file with unknown extension (treated as text)
874        let temp_dir = std::env::temp_dir();
875        let test_file = temp_dir.join("holoconf_test.xyz");
876        {
877            let mut file = std::fs::File::create(&test_file).unwrap();
878            writeln!(file, "plain text content").unwrap();
879        }
880
881        let mut ctx = ResolverContext::new("test.path");
882        ctx.base_path = Some(temp_dir.clone());
883
884        let args = vec!["holoconf_test.xyz".to_string()];
885        let kwargs = HashMap::new();
886
887        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
888        // Unknown extension defaults to text mode
889        assert!(result
890            .value
891            .as_str()
892            .unwrap()
893            .contains("plain text content"));
894
895        // Cleanup
896        std::fs::remove_file(test_file).ok();
897    }
898}