Skip to main content

fabryk_cli/
config_handlers.rs

1//! Handler functions for config CLI commands.
2//!
3//! Implements generic config subcommands (`path`, `get`, `set`, `init`, `export`)
4//! parameterized over any type implementing [`ConfigManager`](fabryk_core::ConfigManager).
5//!
6//! Also provides TOML dotted-key helper functions that can be reused by
7//! downstream projects.
8
9use crate::cli::ConfigAction;
10use crate::config::FabrykConfig;
11use fabryk_core::traits::ConfigManager;
12use fabryk_core::{Error, Result};
13use std::path::PathBuf;
14
15// ============================================================================
16// Command dispatch (fabryk-specific, using FabrykConfig)
17// ============================================================================
18
19/// Handle a config subcommand using FabrykConfig.
20///
21/// This is the concrete dispatch function for fabryk-cli. For generic usage,
22/// call the individual `cmd_config_*` functions directly.
23pub fn handle_config_command(config_path: Option<&str>, action: ConfigAction) -> Result<()> {
24    match action {
25        ConfigAction::Path => cmd_config_path::<FabrykConfig>(config_path),
26        ConfigAction::Get { key } => cmd_config_get::<FabrykConfig>(config_path, &key),
27        ConfigAction::Set { key, value } => {
28            cmd_config_set::<FabrykConfig>(config_path, &key, &value)
29        }
30        ConfigAction::Init { file, force } => {
31            cmd_config_init::<FabrykConfig>(file.as_deref(), force)
32        }
33        ConfigAction::Export { docker_env } => {
34            let config = FabrykConfig::load(config_path)?;
35            cmd_config_export(&config, docker_env)
36        }
37    }
38}
39
40// ============================================================================
41// Generic command handlers
42// ============================================================================
43
44/// Show the resolved config file path.
45pub fn cmd_config_path<C: ConfigManager>(config_path: Option<&str>) -> Result<()> {
46    match C::resolve_config_path(config_path) {
47        Some(path) => {
48            let exists = path.exists();
49            println!("{}", path.display());
50            if !exists {
51                eprintln!(
52                    "(file does not exist — run `{} config init` to create it)",
53                    C::project_name()
54                );
55            }
56            Ok(())
57        }
58        None => Err(Error::config(
59            "Could not determine config directory for this platform",
60        )),
61    }
62}
63
64/// Get a configuration value by dotted key.
65pub fn cmd_config_get<C: ConfigManager>(config_path: Option<&str>, key: &str) -> Result<()> {
66    let config = C::load(config_path)?;
67    let value = toml::Value::try_from(&config).map_err(|e| Error::config(e.to_string()))?;
68    match get_nested_value(&value, key) {
69        Some(val) => {
70            println!("{}", format_toml_value(val));
71            Ok(())
72        }
73        None => Err(Error::config(format!(
74            "Key '{key}' not found in configuration"
75        ))),
76    }
77}
78
79/// Set a configuration value by dotted key in the config file.
80pub fn cmd_config_set<C: ConfigManager>(
81    config_path: Option<&str>,
82    key: &str,
83    value: &str,
84) -> Result<()> {
85    let path = C::resolve_config_path(config_path)
86        .ok_or_else(|| Error::config("Could not determine config directory"))?;
87
88    let mut doc: toml::Value = if path.exists() {
89        let content = std::fs::read_to_string(&path).map_err(|e| Error::io_with_path(e, &path))?;
90        toml::from_str(&content)
91            .map_err(|e| Error::config(format!("Failed to parse {}: {e}", path.display())))?
92    } else {
93        return Err(Error::config(format!(
94            "Config file does not exist at {}. Run `{} config init` first.",
95            path.display(),
96            C::project_name()
97        )));
98    };
99
100    set_nested_value(&mut doc, key, parse_value(value))?;
101
102    let toml_str = toml::to_string_pretty(&doc).map_err(|e| Error::config(e.to_string()))?;
103    std::fs::write(&path, toml_str).map_err(|e| Error::io_with_path(e, &path))?;
104
105    println!("Set {key} = {value} in {}", path.display());
106    Ok(())
107}
108
109/// Create a default configuration file.
110pub fn cmd_config_init<C: ConfigManager>(file: Option<&str>, force: bool) -> Result<()> {
111    let path = match file {
112        Some(p) => PathBuf::from(p),
113        None => C::default_config_path()
114            .ok_or_else(|| Error::config("Could not determine config directory"))?,
115    };
116
117    if path.exists() && !force {
118        return Err(Error::config(format!(
119            "Config file already exists at {}. Use --force to overwrite.",
120            path.display()
121        )));
122    }
123
124    if let Some(parent) = path.parent() {
125        std::fs::create_dir_all(parent).map_err(|e| Error::io_with_path(e, parent))?;
126    }
127
128    let config = C::default();
129    let toml_str = config.to_toml_string()?;
130    std::fs::write(&path, &toml_str).map_err(|e| Error::io_with_path(e, &path))?;
131
132    println!("Config file created at {}", path.display());
133    Ok(())
134}
135
136/// Export configuration as environment variables.
137pub fn cmd_config_export<C: ConfigManager>(config: &C, docker_env: bool) -> Result<()> {
138    let vars = config.to_env_vars()?;
139    for (key, value) in &vars {
140        if docker_env {
141            println!("--env {key}={value}");
142        } else {
143            println!("{key}={value}");
144        }
145    }
146    Ok(())
147}
148
149// ============================================================================
150// TOML dotted-key helpers (public for reuse)
151// ============================================================================
152
153/// Navigate a dotted key path in a TOML value tree.
154pub fn get_nested_value<'a>(value: &'a toml::Value, key: &str) -> Option<&'a toml::Value> {
155    let parts: Vec<&str> = key.split('.').collect();
156    let mut current = value;
157    for part in &parts {
158        current = current.as_table()?.get(*part)?;
159    }
160    Some(current)
161}
162
163/// Set a value at a dotted key path, creating intermediate tables as needed.
164pub fn set_nested_value(root: &mut toml::Value, key: &str, value: toml::Value) -> Result<()> {
165    let parts: Vec<&str> = key.split('.').collect();
166    let mut current = root;
167
168    for (i, part) in parts.iter().enumerate() {
169        if i == parts.len() - 1 {
170            let table = current
171                .as_table_mut()
172                .ok_or_else(|| Error::config("Cannot set key on a non-table value"))?;
173            table.insert(part.to_string(), value);
174            return Ok(());
175        }
176
177        let table = current
178            .as_table_mut()
179            .ok_or_else(|| Error::config("Cannot navigate into a non-table value"))?;
180        if !table.contains_key(*part) {
181            table.insert(part.to_string(), toml::Value::Table(toml::map::Map::new()));
182        }
183        current = table.get_mut(*part).unwrap();
184    }
185
186    Err(Error::config("Empty key path"))
187}
188
189/// Parse a string value into a TOML value, auto-detecting the type.
190///
191/// Priority: bool → integer → float → string.
192pub fn parse_value(s: &str) -> toml::Value {
193    if s == "true" {
194        return toml::Value::Boolean(true);
195    }
196    if s == "false" {
197        return toml::Value::Boolean(false);
198    }
199    if let Ok(i) = s.parse::<i64>() {
200        return toml::Value::Integer(i);
201    }
202    if let Ok(f) = s.parse::<f64>() {
203        return toml::Value::Float(f);
204    }
205    toml::Value::String(s.to_string())
206}
207
208/// Format a TOML value for display on stdout.
209pub fn format_toml_value(value: &toml::Value) -> String {
210    match value {
211        toml::Value::String(s) => s.clone(),
212        toml::Value::Integer(i) => i.to_string(),
213        toml::Value::Float(f) => f.to_string(),
214        toml::Value::Boolean(b) => b.to_string(),
215        toml::Value::Datetime(dt) => dt.to_string(),
216        toml::Value::Array(_) | toml::Value::Table(_) => {
217            toml::to_string_pretty(value).unwrap_or_else(|_| format!("{value:?}"))
218        }
219    }
220}
221
222// ============================================================================
223// Tests
224// ============================================================================
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    // ------------------------------------------------------------------------
231    // cmd_config_path tests
232    // ------------------------------------------------------------------------
233
234    #[test]
235    fn test_cmd_config_path_default() {
236        let result = cmd_config_path::<FabrykConfig>(None);
237        assert!(result.is_ok());
238    }
239
240    #[test]
241    fn test_cmd_config_path_explicit() {
242        let result = cmd_config_path::<FabrykConfig>(Some("/explicit/config.toml"));
243        assert!(result.is_ok());
244    }
245
246    // ------------------------------------------------------------------------
247    // cmd_config_get tests
248    // ------------------------------------------------------------------------
249
250    #[test]
251    fn test_cmd_config_get_simple_key() {
252        let dir = tempfile::TempDir::new().unwrap();
253        let path = dir.path().join("config.toml");
254        let config = FabrykConfig::default();
255        std::fs::write(&path, config.to_toml_string().unwrap()).unwrap();
256
257        let result = cmd_config_get::<FabrykConfig>(Some(path.to_str().unwrap()), "project_name");
258        assert!(result.is_ok());
259    }
260
261    #[test]
262    fn test_cmd_config_get_nested_key() {
263        let dir = tempfile::TempDir::new().unwrap();
264        let path = dir.path().join("config.toml");
265        let config = FabrykConfig::default();
266        std::fs::write(&path, config.to_toml_string().unwrap()).unwrap();
267
268        let result = cmd_config_get::<FabrykConfig>(Some(path.to_str().unwrap()), "server.port");
269        assert!(result.is_ok());
270    }
271
272    #[test]
273    fn test_cmd_config_get_missing_key() {
274        let dir = tempfile::TempDir::new().unwrap();
275        let path = dir.path().join("config.toml");
276        let config = FabrykConfig::default();
277        std::fs::write(&path, config.to_toml_string().unwrap()).unwrap();
278
279        let result =
280            cmd_config_get::<FabrykConfig>(Some(path.to_str().unwrap()), "nonexistent.key");
281        assert!(result.is_err());
282        assert!(result.unwrap_err().to_string().contains("not found"));
283    }
284
285    // ------------------------------------------------------------------------
286    // cmd_config_set tests
287    // ------------------------------------------------------------------------
288
289    #[test]
290    fn test_cmd_config_set_simple_key() {
291        let dir = tempfile::TempDir::new().unwrap();
292        let path = dir.path().join("config.toml");
293        let config = FabrykConfig::default();
294        std::fs::write(&path, config.to_toml_string().unwrap()).unwrap();
295
296        let result = cmd_config_set::<FabrykConfig>(
297            Some(path.to_str().unwrap()),
298            "project_name",
299            "new-name",
300        );
301        assert!(result.is_ok());
302
303        let content = std::fs::read_to_string(&path).unwrap();
304        assert!(content.contains("new-name"));
305    }
306
307    #[test]
308    fn test_cmd_config_set_nested_key() {
309        let dir = tempfile::TempDir::new().unwrap();
310        let path = dir.path().join("config.toml");
311        let config = FabrykConfig::default();
312        std::fs::write(&path, config.to_toml_string().unwrap()).unwrap();
313
314        let result =
315            cmd_config_set::<FabrykConfig>(Some(path.to_str().unwrap()), "server.port", "8080");
316        assert!(result.is_ok());
317
318        let content = std::fs::read_to_string(&path).unwrap();
319        assert!(content.contains("8080"));
320    }
321
322    #[test]
323    fn test_cmd_config_set_missing_file() {
324        let result =
325            cmd_config_set::<FabrykConfig>(Some("/nonexistent/config.toml"), "key", "value");
326        assert!(result.is_err());
327        assert!(result.unwrap_err().to_string().contains("does not exist"));
328    }
329
330    // ------------------------------------------------------------------------
331    // cmd_config_init tests
332    // ------------------------------------------------------------------------
333
334    #[test]
335    fn test_cmd_config_init_creates_file() {
336        let dir = tempfile::TempDir::new().unwrap();
337        let path = dir.path().join("fabryk").join("config.toml");
338
339        let result = cmd_config_init::<FabrykConfig>(Some(path.to_str().unwrap()), false);
340        assert!(result.is_ok());
341        assert!(path.exists());
342
343        let content = std::fs::read_to_string(&path).unwrap();
344        assert!(content.contains("project_name"));
345        assert!(content.contains("[server]"));
346    }
347
348    #[test]
349    fn test_cmd_config_init_no_overwrite() {
350        let dir = tempfile::TempDir::new().unwrap();
351        let path = dir.path().join("config.toml");
352        std::fs::write(&path, "existing").unwrap();
353
354        let result = cmd_config_init::<FabrykConfig>(Some(path.to_str().unwrap()), false);
355        assert!(result.is_err());
356        assert!(result.unwrap_err().to_string().contains("already exists"));
357    }
358
359    #[test]
360    fn test_cmd_config_init_force_overwrites() {
361        let dir = tempfile::TempDir::new().unwrap();
362        let path = dir.path().join("config.toml");
363        std::fs::write(&path, "old content").unwrap();
364
365        let result = cmd_config_init::<FabrykConfig>(Some(path.to_str().unwrap()), true);
366        assert!(result.is_ok());
367
368        let content = std::fs::read_to_string(&path).unwrap();
369        assert!(content.contains("project_name"));
370    }
371
372    // ------------------------------------------------------------------------
373    // cmd_config_export tests
374    // ------------------------------------------------------------------------
375
376    #[test]
377    fn test_cmd_config_export_env_vars() {
378        let config = FabrykConfig::default();
379        let result = cmd_config_export(&config, false);
380        assert!(result.is_ok());
381    }
382
383    #[test]
384    fn test_cmd_config_export_docker_env() {
385        let config = FabrykConfig::default();
386        let result = cmd_config_export(&config, true);
387        assert!(result.is_ok());
388    }
389
390    // ------------------------------------------------------------------------
391    // get_nested_value tests
392    // ------------------------------------------------------------------------
393
394    #[test]
395    fn test_get_nested_value_top_level() {
396        let val: toml::Value = toml::from_str("port = 8080").unwrap();
397        let result = get_nested_value(&val, "port");
398        assert_eq!(result, Some(&toml::Value::Integer(8080)));
399    }
400
401    #[test]
402    fn test_get_nested_value_nested() {
403        let val: toml::Value = toml::from_str("[server]\nport = 3000").unwrap();
404        let result = get_nested_value(&val, "server.port");
405        assert_eq!(result, Some(&toml::Value::Integer(3000)));
406    }
407
408    #[test]
409    fn test_get_nested_value_missing() {
410        let val: toml::Value = toml::from_str("port = 8080").unwrap();
411        assert!(get_nested_value(&val, "nonexistent").is_none());
412    }
413
414    #[test]
415    fn test_get_nested_value_deep_missing() {
416        let val: toml::Value = toml::from_str("[server]\nport = 3000").unwrap();
417        assert!(get_nested_value(&val, "server.nonexistent").is_none());
418    }
419
420    // ------------------------------------------------------------------------
421    // set_nested_value tests
422    // ------------------------------------------------------------------------
423
424    #[test]
425    fn test_set_nested_value_top_level() {
426        let mut val: toml::Value = toml::from_str("port = 8080").unwrap();
427        set_nested_value(&mut val, "port", toml::Value::Integer(9090)).unwrap();
428        assert_eq!(
429            get_nested_value(&val, "port"),
430            Some(&toml::Value::Integer(9090))
431        );
432    }
433
434    #[test]
435    fn test_set_nested_value_creates_section() {
436        let mut val = toml::Value::Table(toml::map::Map::new());
437        set_nested_value(&mut val, "server.port", toml::Value::Integer(3000)).unwrap();
438        assert_eq!(
439            get_nested_value(&val, "server.port"),
440            Some(&toml::Value::Integer(3000))
441        );
442    }
443
444    #[test]
445    fn test_set_nested_value_overwrites() {
446        let mut val: toml::Value = toml::from_str("[server]\nport = 3000").unwrap();
447        set_nested_value(&mut val, "server.port", toml::Value::Integer(8080)).unwrap();
448        assert_eq!(
449            get_nested_value(&val, "server.port"),
450            Some(&toml::Value::Integer(8080))
451        );
452    }
453
454    // ------------------------------------------------------------------------
455    // parse_value tests
456    // ------------------------------------------------------------------------
457
458    #[test]
459    fn test_parse_value_types() {
460        assert_eq!(parse_value("true"), toml::Value::Boolean(true));
461        assert_eq!(parse_value("false"), toml::Value::Boolean(false));
462        assert_eq!(parse_value("42"), toml::Value::Integer(42));
463        assert_eq!(parse_value("-7"), toml::Value::Integer(-7));
464        assert_eq!(parse_value("3.14"), toml::Value::Float(3.14));
465        assert_eq!(
466            parse_value("hello world"),
467            toml::Value::String("hello world".to_string())
468        );
469    }
470
471    // ------------------------------------------------------------------------
472    // format_toml_value tests
473    // ------------------------------------------------------------------------
474
475    #[test]
476    fn test_format_toml_value() {
477        assert_eq!(
478            format_toml_value(&toml::Value::String("hello".into())),
479            "hello"
480        );
481        assert_eq!(format_toml_value(&toml::Value::Integer(42)), "42");
482        assert_eq!(format_toml_value(&toml::Value::Float(3.14)), "3.14");
483        assert_eq!(format_toml_value(&toml::Value::Boolean(true)), "true");
484    }
485}