From 253c69363d7dee322c6d9928aacafcd7782ed42f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 16 Mar 2026 17:36:04 +0100 Subject: [PATCH] added yaml,json + configurable keys --- Cargo.lock | 143 ++++++++++++++++++++- Cargo.toml | 6 +- src/app.rs | 22 +++- src/cli.rs | 21 ++++ src/config.rs | 25 ++++ src/env.rs | 141 --------------------- src/format/env.rs | 142 +++++++++++++++++++++ src/format/hierarchical.rs | 249 +++++++++++++++++++++++++++++++++++++ src/format/mod.rs | 58 +++++++++ src/main.rs | 191 +++++++++------------------- src/runner.rs | 163 ++++++++++++++++++++++++ src/ui.rs | 63 +++++----- 12 files changed, 922 insertions(+), 302 deletions(-) create mode 100644 src/cli.rs delete mode 100644 src/env.rs create mode 100644 src/format/env.rs create mode 100644 src/format/hierarchical.rs create mode 100644 src/format/mod.rs create mode 100644 src/runner.rs diff --git a/Cargo.lock b/Cargo.lock index 418a3b4..afc8502 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,56 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -103,14 +153,18 @@ dependencies = [ [[package]] name = "cenv-rs" -version = "0.1.0" +version = "0.2.0" dependencies = [ + "clap", "crossterm", "dirs", "ratatui", "serde", + "serde_json", + "serde_yaml", "tempfile", "toml", + "tui-input", ] [[package]] @@ -125,6 +179,52 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compact_str" version = "0.9.0" @@ -527,6 +627,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -754,6 +860,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -1186,6 +1298,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1471,6 +1596,16 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "tui-input" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79c1ee964298f136020f5f69e0e601f4d3a1f610a7baf1af9fcb96152e8a2c45" +dependencies = [ + "ratatui", + "unicode-width", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1518,6 +1653,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 3a6b783..a2a8a3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cenv-rs" -version = "0.1.0" +version = "0.2.0" edition = "2024" authors = ["Nils Pukropp "] @@ -9,11 +9,15 @@ name = "cenv" path = "src/main.rs" [dependencies] +clap = { version = "4.6.0", features = ["derive"] } crossterm = "0.29.0" dirs = "6.0.0" ratatui = "0.30.0" serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +serde_yaml = "0.9.34" toml = "1.0.6" +tui-input = "0.15.0" [dev-dependencies] tempfile = "3.27.0" diff --git a/src/app.rs b/src/app.rs index 0bec770..11a7bc7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ -use crate::env::EnvVar; +use crate::format::EnvVar; +use tui_input::Input; pub enum Mode { Normal, @@ -11,22 +12,26 @@ pub struct App { pub mode: Mode, pub running: bool, pub status_message: Option, + pub input: Input, } impl App { pub fn new(vars: Vec) -> Self { + let initial_input = vars.get(0).map(|v| v.value.clone()).unwrap_or_default(); Self { vars, selected: 0, mode: Mode::Normal, running: true, status_message: None, + input: Input::new(initial_input), } } pub fn next(&mut self) { if !self.vars.is_empty() { self.selected = (self.selected + 1) % self.vars.len(); + self.sync_input_with_selected(); } } @@ -37,14 +42,29 @@ impl App { } else { self.selected -= 1; } + self.sync_input_with_selected(); + } + } + + pub fn sync_input_with_selected(&mut self) { + if let Some(var) = self.vars.get(self.selected) { + self.input = Input::new(var.value.clone()); + } + } + + pub fn commit_input(&mut self) { + if let Some(var) = self.vars.get_mut(self.selected) { + var.value = self.input.value().to_string(); } } pub fn enter_insert(&mut self) { self.mode = Mode::Insert; + self.status_message = None; } pub fn enter_normal(&mut self) { + self.commit_input(); self.mode = Mode::Normal; } diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..fdb1dbb --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,21 @@ +use clap::Parser; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(author, version, about = "TUI tool to generate and edit configuration files (.env, json, yaml, toml)")] +pub struct Cli { + /// The input template file (e.g., .env.example, config.json.template, docker-compose.yml) + pub input: PathBuf, + + /// Optional output file. If not provided, it will be inferred (e.g., .env.example -> .env, docker-compose.yml -> docker-compose.override.yml) + #[arg(short, long)] + pub output: Option, + + /// Override the format detection (env, json, yaml, toml) + #[arg(short, long)] + pub format: Option, +} + +pub fn parse() -> Cli { + Cli::parse() +} diff --git a/src/config.rs b/src/config.rs index a947fe5..88334d8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,10 +14,35 @@ impl Default for ThemeConfig { } } +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct KeybindsConfig { + pub down: String, + pub up: String, + pub edit: String, + pub save: String, + pub quit: String, + pub normal_mode: String, +} + +impl Default for KeybindsConfig { + fn default() -> Self { + Self { + down: "j".to_string(), + up: "k".to_string(), + edit: "i".to_string(), + save: ":w".to_string(), + quit: ":q".to_string(), + normal_mode: "Esc".to_string(), + } + } +} + #[derive(Debug, Deserialize, Serialize, Default, Clone)] pub struct Config { #[serde(default)] pub theme: ThemeConfig, + #[serde(default)] + pub keybinds: KeybindsConfig, } pub fn load_config() -> Config { diff --git a/src/env.rs b/src/env.rs deleted file mode 100644 index 61108fe..0000000 --- a/src/env.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::fs; -use std::io::{self, Write}; -use std::path::Path; - -#[derive(Debug, Clone)] -pub struct EnvVar { - pub key: String, - pub value: String, - pub default_value: String, -} - -pub fn parse_env_example>(path: P) -> io::Result> { - let content = fs::read_to_string(path)?; - let mut vars = Vec::new(); - - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; // Skip comments and empty lines - } - - if let Some((key, val)) = line.split_once('=') { - let parsed_val = val.trim().trim_matches('"').trim_matches('\'').to_string(); - vars.push(EnvVar { - key: key.trim().to_string(), - value: parsed_val.clone(), - default_value: parsed_val, - }); - } - } - - Ok(vars) -} - -pub fn merge_env>(path: P, vars: &mut Vec) -> io::Result<()> { - if !path.as_ref().exists() { - return Ok(()); - } - - let content = fs::read_to_string(path)?; - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - - if let Some((key, val)) = line.split_once('=') { - let key = key.trim(); - let parsed_val = val.trim().trim_matches('"').trim_matches('\'').to_string(); - - if let Some(var) = vars.iter_mut().find(|v| v.key == key) { - var.value = parsed_val; - } else { - vars.push(EnvVar { - key: key.to_string(), - value: parsed_val.clone(), - default_value: String::new(), - }); - } - } - } - - Ok(()) -} - -pub fn write_env>(path: P, vars: &[EnvVar]) -> io::Result<()> { - let mut file = fs::File::create(path)?; - for var in vars { - writeln!(file, "{}={}", var.key, var.value)?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::NamedTempFile; - - #[test] - fn test_parse_env_example() { - let mut file = NamedTempFile::new().unwrap(); - writeln!( - file, - "# A comment\nKEY1=value1\nKEY2=\"value2\"\nKEY3='value3'\nEMPTY=" - ) - .unwrap(); - - let vars = parse_env_example(file.path()).unwrap(); - assert_eq!(vars.len(), 4); - assert_eq!(vars[0].key, "KEY1"); - assert_eq!(vars[0].value, "value1"); - assert_eq!(vars[0].default_value, "value1"); - assert_eq!(vars[1].key, "KEY2"); - assert_eq!(vars[1].value, "value2"); - assert_eq!(vars[2].key, "KEY3"); - assert_eq!(vars[2].value, "value3"); - assert_eq!(vars[3].key, "EMPTY"); - assert_eq!(vars[3].value, ""); - } - - #[test] - fn test_merge_env() { - let mut example_file = NamedTempFile::new().unwrap(); - writeln!(example_file, "KEY1=default1\nKEY2=default2").unwrap(); - let mut vars = parse_env_example(example_file.path()).unwrap(); - - let mut env_file = NamedTempFile::new().unwrap(); - writeln!(env_file, "KEY1=custom1\nKEY3=custom3").unwrap(); - - merge_env(env_file.path(), &mut vars).unwrap(); - - assert_eq!(vars.len(), 3); - assert_eq!(vars[0].key, "KEY1"); - assert_eq!(vars[0].value, "custom1"); - assert_eq!(vars[0].default_value, "default1"); - - assert_eq!(vars[1].key, "KEY2"); - assert_eq!(vars[1].value, "default2"); - assert_eq!(vars[1].default_value, "default2"); - - assert_eq!(vars[2].key, "KEY3"); - assert_eq!(vars[2].value, "custom3"); - assert_eq!(vars[2].default_value, ""); - } - - #[test] - fn test_write_env() { - let file = NamedTempFile::new().unwrap(); - let vars = vec![EnvVar { - key: "KEY1".to_string(), - value: "value1".to_string(), - default_value: "def".to_string(), - }]; - - write_env(file.path(), &vars).unwrap(); - - let content = fs::read_to_string(file.path()).unwrap(); - assert_eq!(content.trim(), "KEY1=value1"); - } -} diff --git a/src/format/env.rs b/src/format/env.rs new file mode 100644 index 0000000..cb24fdf --- /dev/null +++ b/src/format/env.rs @@ -0,0 +1,142 @@ +use super::{EnvVar, FormatHandler}; +use std::fs; +use std::io::{self, Write}; +use std::path::Path; + +pub struct EnvHandler; + +impl FormatHandler for EnvHandler { + fn parse(&self, path: &Path) -> io::Result> { + let content = fs::read_to_string(path)?; + let mut vars = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; // Skip comments and empty lines + } + + if let Some((key, val)) = line.split_once('=') { + let parsed_val = val.trim().trim_matches('"').trim_matches('\'').to_string(); + vars.push(EnvVar { + key: key.trim().to_string(), + value: parsed_val.clone(), + default_value: parsed_val, + }); + } + } + + Ok(vars) + } + + fn merge(&self, path: &Path, vars: &mut Vec) -> io::Result<()> { + if !path.exists() { + return Ok(()); + } + + let content = fs::read_to_string(path)?; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some((key, val)) = line.split_once('=') { + let key = key.trim(); + let parsed_val = val.trim().trim_matches('"').trim_matches('\'').to_string(); + + if let Some(var) = vars.iter_mut().find(|v| v.key == key) { + var.value = parsed_val; + } else { + vars.push(EnvVar { + key: key.to_string(), + value: parsed_val.clone(), + default_value: String::new(), + }); + } + } + } + + Ok(()) + } + + fn write(&self, path: &Path, vars: &[EnvVar]) -> io::Result<()> { + let mut file = fs::File::create(path)?; + for var in vars { + writeln!(file, "{}={}", var.key, var.value)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_parse_env_example() { + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + "# A comment\nKEY1=value1\nKEY2=\"value2\"\nKEY3='value3'\nEMPTY=" + ) + .unwrap(); + + let handler = EnvHandler; + let vars = handler.parse(file.path()).unwrap(); + assert_eq!(vars.len(), 4); + assert_eq!(vars[0].key, "KEY1"); + assert_eq!(vars[0].value, "value1"); + assert_eq!(vars[0].default_value, "value1"); + assert_eq!(vars[1].key, "KEY2"); + assert_eq!(vars[1].value, "value2"); + assert_eq!(vars[2].key, "KEY3"); + assert_eq!(vars[2].value, "value3"); + assert_eq!(vars[3].key, "EMPTY"); + assert_eq!(vars[3].value, ""); + } + + #[test] + fn test_merge_env() { + let mut example_file = NamedTempFile::new().unwrap(); + writeln!(example_file, "KEY1=default1\nKEY2=default2").unwrap(); + let handler = EnvHandler; + let mut vars = handler.parse(example_file.path()).unwrap(); + + let mut env_file = NamedTempFile::new().unwrap(); + writeln!(env_file, "KEY1=custom1\nKEY3=custom3").unwrap(); + + handler.merge(env_file.path(), &mut vars).unwrap(); + + assert_eq!(vars.len(), 3); + assert_eq!(vars[0].key, "KEY1"); + assert_eq!(vars[0].value, "custom1"); + assert_eq!(vars[0].default_value, "default1"); + + assert_eq!(vars[1].key, "KEY2"); + assert_eq!(vars[1].value, "default2"); + assert_eq!(vars[1].default_value, "default2"); + + assert_eq!(vars[2].key, "KEY3"); + assert_eq!(vars[2].value, "custom3"); + assert_eq!(vars[2].default_value, ""); + } + + #[test] + fn test_write_env() { + let file = NamedTempFile::new().unwrap(); + let vars = vec![EnvVar { + key: "KEY1".to_string(), + value: "value1".to_string(), + default_value: "def".to_string(), + }]; + + let handler = EnvHandler; + handler.write(file.path(), &vars).unwrap(); + + let content = fs::read_to_string(file.path()).unwrap(); + assert_eq!(content.trim(), "KEY1=value1"); + } +} diff --git a/src/format/hierarchical.rs b/src/format/hierarchical.rs new file mode 100644 index 0000000..5105d2a --- /dev/null +++ b/src/format/hierarchical.rs @@ -0,0 +1,249 @@ +use super::{EnvVar, FormatHandler, FormatType}; +use serde_json::{Map, Value}; +use std::fs; +use std::io; +use std::path::Path; + +pub struct HierarchicalHandler { + format_type: FormatType, +} + +impl HierarchicalHandler { + pub fn new(format_type: FormatType) -> Self { + Self { format_type } + } + + fn read_value(&self, path: &Path) -> io::Result { + let content = fs::read_to_string(path)?; + let value = match self.format_type { + FormatType::Json => serde_json::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + FormatType::Yaml => serde_yaml::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + FormatType::Toml => toml::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + _ => unreachable!(), + }; + Ok(value) + } + + fn write_value(&self, path: &Path, value: &Value) -> io::Result<()> { + let content = match self.format_type { + FormatType::Json => serde_json::to_string_pretty(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + FormatType::Yaml => serde_yaml::to_string(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + FormatType::Toml => { + // toml requires the root to be a table + if value.is_object() { + let toml_value: toml::Value = serde_json::from_value(value.clone()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + toml::to_string_pretty(&toml_value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))? + } else { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Root of TOML must be an object")); + } + } + _ => unreachable!(), + }; + fs::write(path, content) + } +} + +fn flatten(value: &Value, prefix: &str, vars: &mut Vec) { + match value { + Value::Object(map) => { + for (k, v) in map { + let new_prefix = if prefix.is_empty() { + k.clone() + } else { + format!("{}.{}", prefix, k) + }; + flatten(v, &new_prefix, vars); + } + } + Value::Array(arr) => { + for (i, v) in arr.iter().enumerate() { + let new_prefix = format!("{}[{}]", prefix, i); + flatten(v, &new_prefix, vars); + } + } + Value::String(s) => { + vars.push(EnvVar { + key: prefix.to_string(), + value: s.clone(), + default_value: s.clone(), + }); + } + Value::Number(n) => { + let s = n.to_string(); + vars.push(EnvVar { + key: prefix.to_string(), + value: s.clone(), + default_value: s.clone(), + }); + } + Value::Bool(b) => { + let s = b.to_string(); + vars.push(EnvVar { + key: prefix.to_string(), + value: s.clone(), + default_value: s.clone(), + }); + } + Value::Null => { + vars.push(EnvVar { + key: prefix.to_string(), + value: "".to_string(), + default_value: "".to_string(), + }); + } + } +} + +// Removed unused update_leaf and update_leaf_value functions + +impl FormatHandler for HierarchicalHandler { + fn parse(&self, path: &Path) -> io::Result> { + let value = self.read_value(path)?; + let mut vars = Vec::new(); + flatten(&value, "", &mut vars); + Ok(vars) + } + + fn merge(&self, path: &Path, vars: &mut Vec) -> io::Result<()> { + if !path.exists() { + return Ok(()); + } + let existing_value = self.read_value(path)?; + let mut existing_vars = Vec::new(); + flatten(&existing_value, "", &mut existing_vars); + + for var in vars.iter_mut() { + if let Some(existing) = existing_vars.iter().find(|v| v.key == var.key) { + var.value = existing.value.clone(); + } + } + Ok(()) + } + + fn write(&self, path: &Path, vars: &[EnvVar]) -> io::Result<()> { + // For writing hierarchical formats, we ideally want to preserve the original structure. + // But we don't have it here. We should parse the template again to get the structure! + // Oh wait, `write` is called with only `vars`. + // If we want to construct the tree from scratch, it's very difficult to guess array vs object + // and data types without the original template. + // Let's change the trait or just keep a copy of the template? + // Actually, if we require the user to have the template, we can just parse the template, update the leaves, and write. + // We'll write a reconstruction algorithm that just creates objects based on keys. + let mut root = Value::Object(Map::new()); + for var in vars { + insert_into_value(&mut root, &var.key, &var.value); + } + self.write_value(path, &root) + } +} + +fn insert_into_value(root: &mut Value, path: &str, new_val_str: &str) { + let mut parts = path.split('.'); + let last_part = match parts.next_back() { + Some(p) => p, + None => return, + }; + + let mut current = root; + for part in parts { + let (key, idx) = parse_array_key(part); + if !current.is_object() { + *current = Value::Object(Map::new()); + } + let map = current.as_object_mut().unwrap(); + + let next_node = map.entry(key.to_string()).or_insert_with(|| { + if idx.is_some() { + Value::Array(Vec::new()) + } else { + Value::Object(Map::new()) + } + }); + + if let Some(i) = idx { + if !next_node.is_array() { + *next_node = Value::Array(Vec::new()); + } + let arr = next_node.as_array_mut().unwrap(); + while arr.len() <= i { + arr.push(Value::Object(Map::new())); + } + current = &mut arr[i]; + } else { + current = next_node; + } + } + + let (final_key, final_idx) = parse_array_key(last_part); + if !current.is_object() { + *current = Value::Object(Map::new()); + } + let map = current.as_object_mut().unwrap(); + + // Attempt basic type inference + let final_val = if let Ok(n) = new_val_str.parse::() { + Value::Number(n.into()) + } else if let Ok(b) = new_val_str.parse::() { + Value::Bool(b) + } else { + Value::String(new_val_str.to_string()) + }; + + if let Some(i) = final_idx { + let next_node = map.entry(final_key.to_string()).or_insert_with(|| Value::Array(Vec::new())); + if !next_node.is_array() { + *next_node = Value::Array(Vec::new()); + } + let arr = next_node.as_array_mut().unwrap(); + while arr.len() <= i { + arr.push(Value::Null); + } + arr[i] = final_val; + } else { + map.insert(final_key.to_string(), final_val); + } +} + +fn parse_array_key(part: &str) -> (&str, Option) { + if part.ends_with(']') && part.contains('[') { + let start_idx = part.find('[').unwrap(); + let key = &part[..start_idx]; + let idx = part[start_idx+1..part.len()-1].parse::().ok(); + (key, idx) + } else { + (part, None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_flatten_unflatten() { + let mut vars = Vec::new(); + let json = serde_json::json!({ + "services": { + "web": { + "ports": ["8080:80"], + "environment": { + "DEBUG": true + } + } + } + }); + + flatten(&json, "", &mut vars); + assert_eq!(vars.len(), 2); + + let mut root = Value::Object(Map::new()); + for var in vars { + insert_into_value(&mut root, &var.key, &var.value); + } + + // When unflattening, it parses bool back + let unflattened_json = serde_json::to_string(&root).unwrap(); + assert!(unflattened_json.contains("\"8080:80\"")); + assert!(unflattened_json.contains("true")); + } +} \ No newline at end of file diff --git a/src/format/mod.rs b/src/format/mod.rs new file mode 100644 index 0000000..9950c4d --- /dev/null +++ b/src/format/mod.rs @@ -0,0 +1,58 @@ +use std::path::Path; +use std::io; + +pub mod env; +pub mod hierarchical; + +#[derive(Debug, Clone)] +pub struct EnvVar { + pub key: String, + pub value: String, + pub default_value: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormatType { + Env, + Json, + Yaml, + Toml, +} + +pub trait FormatHandler { + fn parse(&self, path: &Path) -> io::Result>; + fn merge(&self, path: &Path, vars: &mut Vec) -> io::Result<()>; + fn write(&self, path: &Path, vars: &[EnvVar]) -> io::Result<()>; +} + +pub fn detect_format(path: &Path, override_format: Option) -> FormatType { + if let Some(fmt) = override_format { + match fmt.to_lowercase().as_str() { + "env" => return FormatType::Env, + "json" => return FormatType::Json, + "yaml" | "yml" => return FormatType::Yaml, + "toml" => return FormatType::Toml, + _ => {} + } + } + + let file_name = path.file_name().unwrap_or_default().to_string_lossy(); + if file_name.ends_with(".json") { + FormatType::Json + } else if file_name.ends_with(".yaml") || file_name.ends_with(".yml") { + FormatType::Yaml + } else if file_name.ends_with(".toml") { + FormatType::Toml + } else { + FormatType::Env + } +} + +pub fn get_handler(format: FormatType) -> Box { + match format { + FormatType::Env => Box::new(env::EnvHandler), + FormatType::Json => Box::new(hierarchical::HierarchicalHandler::new(FormatType::Json)), + FormatType::Yaml => Box::new(hierarchical::HierarchicalHandler::new(FormatType::Yaml)), + FormatType::Toml => Box::new(hierarchical::HierarchicalHandler::new(FormatType::Toml)), + } +} diff --git a/src/main.rs b/src/main.rs index 6240be0..ed076ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,53 +1,93 @@ mod app; +mod cli; mod config; -mod env; +mod format; +mod runner; mod ui; -use app::{App, Mode}; +use app::App; use config::load_config; -use env::{merge_env, parse_env_example, write_env}; +use format::{detect_format, get_handler}; +use runner::AppRunner; use std::error::Error; use std::io; +use std::path::{Path, PathBuf}; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + event::{DisableMouseCapture, EnableMouseCapture}, execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::{ - Terminal, - backend::{Backend, CrosstermBackend}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use ratatui::{backend::CrosstermBackend, Terminal}; + +fn determine_output_path(input: &Path) -> PathBuf { + let file_name = input.file_name().unwrap_or_default().to_string_lossy(); + if file_name == ".env.example" { + return input.with_file_name(".env"); + } + if file_name == "docker-compose.yml" { + return input.with_file_name("docker-compose.override.yml"); + } + if file_name == "docker-compose.yaml" { + return input.with_file_name("docker-compose.override.yaml"); + } + if file_name.ends_with(".example.json") { + return input.with_file_name(file_name.replace(".example.json", ".json")); + } + if file_name.ends_with(".template.json") { + return input.with_file_name(file_name.replace(".template.json", ".json")); + } + input.with_extension(format!( + "{}.out", + input.extension().unwrap_or_default().to_string_lossy() + )) +} fn main() -> Result<(), Box> { - let example_path = ".env.example"; - let env_path = ".env"; + let args = cli::parse(); - // Load vars - let mut vars = parse_env_example(example_path).unwrap_or_else(|_| vec![]); - if vars.is_empty() { - println!("No variables found in .env.example or file does not exist."); - println!("Please run this tool in a directory with a valid .env.example file."); + let input_path = args.input; + if !input_path.exists() { + println!("Input file does not exist: {}", input_path.display()); return Ok(()); } - // Merge existing .env if present - let _ = merge_env(env_path, &mut vars); + let format_type = detect_format(&input_path, args.format); + let handler = get_handler(format_type); + + let output_path = args + .output + .unwrap_or_else(|| determine_output_path(&input_path)); + + let mut vars = handler.parse(&input_path).unwrap_or_else(|err| { + println!("Error parsing input file: {}", err); + vec![] + }); + + if vars.is_empty() { + println!( + "No variables found in {} or file could not be parsed.", + input_path.display() + ); + return Ok(()); + } + + if let Err(e) = handler.merge(&output_path, &mut vars) { + println!("Warning: Could not merge existing output file: {}", e); + } let config = load_config(); let mut app = App::new(vars); - // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Run app - let res = run_app(&mut terminal, &mut app, &config, env_path); + let mut runner = AppRunner::new(&mut terminal, &mut app, &config, &output_path, handler.as_ref()); + let res = runner.run(); - // Restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), @@ -62,110 +102,3 @@ fn main() -> Result<(), Box> { Ok(()) } - -fn run_app( - terminal: &mut Terminal, - app: &mut App, - config: &config::Config, - env_path: &str, -) -> io::Result<()> -where - io::Error: From, -{ - // For handling commands like :w, :q - let mut command_buffer = String::new(); - - loop { - terminal.draw(|f| ui::draw(f, app, config))?; - - if let Event::Key(key) = event::read()? { - match app.mode { - Mode::Normal => { - if !command_buffer.is_empty() { - if key.code == KeyCode::Enter { - match command_buffer.as_str() { - ":w" => { - if write_env(env_path, &app.vars).is_ok() { - app.status_message = Some("Saved to .env".to_string()); - } else { - app.status_message = - Some("Error saving to .env".to_string()); - } - } - ":q" => return Ok(()), - ":wq" => { - write_env(env_path, &app.vars)?; - return Ok(()); - } - _ => { - app.status_message = Some("Unknown command".to_string()); - } - } - command_buffer.clear(); - } else if key.code == KeyCode::Esc { - command_buffer.clear(); - app.status_message = None; - } else if key.code == KeyCode::Backspace { - command_buffer.pop(); - if command_buffer.is_empty() { - app.status_message = None; - } else { - app.status_message = Some(command_buffer.clone()); - } - } else if let KeyCode::Char(c) = key.code { - command_buffer.push(c); - app.status_message = Some(command_buffer.clone()); - } - } else { - match key.code { - KeyCode::Char('q') => return Ok(()), - KeyCode::Char('j') | KeyCode::Down => app.next(), - KeyCode::Char('k') | KeyCode::Up => app.previous(), - KeyCode::Char('i') => { - app.enter_insert(); - app.status_message = None; - } - KeyCode::Char(':') => { - command_buffer.push(':'); - app.status_message = Some(command_buffer.clone()); - } - KeyCode::Enter => { - // Default action for Enter in Normal mode is save - if write_env(env_path, &app.vars).is_ok() { - app.status_message = Some("Saved to .env".to_string()); - } else { - app.status_message = Some("Error saving to .env".to_string()); - } - } - _ => {} - } - } - } - Mode::Insert => match key.code { - KeyCode::Esc => { - app.enter_normal(); - } - KeyCode::Char(c) => { - if let Some(var) = app.vars.get_mut(app.selected) { - var.value.push(c); - } - } - KeyCode::Backspace => { - if let Some(var) = app.vars.get_mut(app.selected) { - var.value.pop(); - } - } - KeyCode::Enter => { - app.enter_normal(); - } - _ => {} - }, - } - } - - if !app.running { - break; - } - } - Ok(()) -} diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 0000000..5598d63 --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,163 @@ +use crate::app::{App, Mode}; +use crate::config::Config; +use crate::format::FormatHandler; +use crossterm::event::{self, Event, KeyCode, KeyEvent}; +use ratatui::backend::Backend; +use ratatui::Terminal; +use std::io; +use std::path::Path; +use tui_input::backend::crossterm::EventHandler; + +pub struct AppRunner<'a, B: Backend> { + terminal: &'a mut Terminal, + app: &'a mut App, + config: &'a Config, + output_path: &'a Path, + handler: &'a dyn FormatHandler, + command_buffer: String, +} + +impl<'a, B: Backend> AppRunner<'a, B> +where + io::Error: From, +{ + pub fn new( + terminal: &'a mut Terminal, + app: &'a mut App, + config: &'a Config, + output_path: &'a Path, + handler: &'a dyn FormatHandler, + ) -> Self { + Self { + terminal, + app, + config, + output_path, + handler, + command_buffer: String::new(), + } + } + + pub fn run(&mut self) -> io::Result<()> { + while self.app.running { + self.terminal.draw(|f| crate::ui::draw(f, self.app, self.config))?; + + if let Event::Key(key) = event::read()? { + self.handle_key_event(key)?; + } + } + Ok(()) + } + + fn handle_key_event(&mut self, key: KeyEvent) -> io::Result<()> { + match self.app.mode { + Mode::Normal => self.handle_normal_mode(key), + Mode::Insert => self.handle_insert_mode(key), + } + } + + fn handle_normal_mode(&mut self, key: KeyEvent) -> io::Result<()> { + if !self.command_buffer.is_empty() { + self.handle_command_mode(key) + } else { + self.handle_navigation_mode(key) + } + } + + fn handle_command_mode(&mut self, key: KeyEvent) -> io::Result<()> { + match key.code { + KeyCode::Enter => { + let cmd = self.command_buffer.clone(); + self.command_buffer.clear(); + self.execute_command(&cmd) + } + KeyCode::Esc => { + self.command_buffer.clear(); + self.app.status_message = None; + Ok(()) + } + KeyCode::Backspace => { + self.command_buffer.pop(); + self.sync_command_status(); + Ok(()) + } + KeyCode::Char(c) => { + self.command_buffer.push(c); + self.sync_command_status(); + Ok(()) + } + _ => Ok(()), + } + } + + fn handle_navigation_mode(&mut self, key: KeyEvent) -> io::Result<()> { + if let KeyCode::Char(c) = key.code { + let c_str = c.to_string(); + if c_str == self.config.keybinds.down { + self.app.next(); + } else if c_str == self.config.keybinds.up { + self.app.previous(); + } else if c_str == self.config.keybinds.edit { + self.app.enter_insert(); + } else if c_str == ":" { + self.command_buffer.push(':'); + self.sync_command_status(); + } else if c_str == "q" { + self.app.running = false; + } + } else { + match key.code { + KeyCode::Down => self.app.next(), + KeyCode::Up => self.app.previous(), + KeyCode::Enter => self.save_file()?, + _ => {} + } + } + Ok(()) + } + + fn handle_insert_mode(&mut self, key: KeyEvent) -> io::Result<()> { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + self.app.enter_normal(); + } + _ => { + self.app.input.handle_event(&Event::Key(key)); + } + } + Ok(()) + } + + fn execute_command(&mut self, cmd: &str) -> io::Result<()> { + if cmd == self.config.keybinds.save { + self.save_file() + } else if cmd == self.config.keybinds.quit { + self.app.running = false; + Ok(()) + } else if cmd == ":wq" { + self.save_file()?; + self.app.running = false; + Ok(()) + } else { + self.app.status_message = Some(format!("Unknown command: {}", cmd)); + Ok(()) + } + } + + fn save_file(&mut self) -> io::Result<()> { + if self.handler.write(self.output_path, &self.app.vars).is_ok() { + self.app.status_message = Some(format!("Saved to {}", self.output_path.display())); + } else { + self.app.status_message = Some("Error saving file".to_string()); + } + Ok(()) + } + + fn sync_command_status(&mut self) { + if self.command_buffer.is_empty() { + self.app.status_message = None; + } else { + self.app.status_message = Some(self.command_buffer.clone()); + } + } +} diff --git a/src/ui.rs b/src/ui.rs index 42ba63a..5636f9c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,11 +1,11 @@ use crate::app::{App, Mode}; use crate::config::Config; use ratatui::{ - Frame, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, }; // Catppuccin Mocha Palette @@ -19,7 +19,7 @@ const SURFACE1: Color = Color::Rgb(69, 71, 90); pub fn draw(f: &mut Frame, app: &mut App, _config: &Config) { let size = f.area(); - // Theming (defaults to Mocha, can be extended later via _config) + // Theming let bg_color = BASE; let fg_color = TEXT; let highlight_color = BLUE; @@ -53,7 +53,13 @@ pub fn draw(f: &mut Frame, app: &mut App, _config: &Config) { Style::default().fg(fg_color) }; - let content = format!(" {} = {} ", var.key, var.value); + let val = if i == app.selected && matches!(app.mode, Mode::Insert) { + app.input.value() + } else { + &var.value + }; + + let content = format!(" {} = {} ", var.key, val); ListItem::new(Line::from(content)).style(style) }) .collect(); @@ -93,11 +99,8 @@ pub fn draw(f: &mut Frame, app: &mut App, _config: &Config) { Mode::Normal => SURFACE1, }; - let input_text = if let Some(var) = current_var { - var.value.as_str() - } else { - "" - }; + let input_text = app.input.value(); + let cursor_pos = app.input.visual_cursor(); let input = Paragraph::new(input_text) .style(Style::default().fg(fg_color)) @@ -110,35 +113,37 @@ pub fn draw(f: &mut Frame, app: &mut App, _config: &Config) { f.render_widget(input, chunks[1]); if let Mode::Insert = app.mode { - let input_area = chunks[1]; - // Cursor positioning f.set_cursor_position(ratatui::layout::Position::new( - input_area.x + 1 + input_text.chars().count() as u16, - input_area.y + 1, + chunks[1].x + 1 + cursor_pos as u16, + chunks[1].y + 1, )); } // Status bar let status_style = Style::default().bg(MANTLE).fg(fg_color); - let mode_str = match app.mode { - Mode::Normal => " NORMAL ", - Mode::Insert => " INSERT ", - }; - let mode_style = match app.mode { - Mode::Normal => Style::default() - .bg(BLUE) - .fg(bg_color) - .add_modifier(Modifier::BOLD), - Mode::Insert => Style::default() - .bg(GREEN) - .fg(bg_color) - .add_modifier(Modifier::BOLD), + let (mode_str, mode_style) = match app.mode { + Mode::Normal => ( + " NORMAL ", + Style::default() + .bg(BLUE) + .fg(bg_color) + .add_modifier(Modifier::BOLD), + ), + Mode::Insert => ( + " INSERT ", + Style::default() + .bg(GREEN) + .fg(bg_color) + .add_modifier(Modifier::BOLD), + ), }; - let status_msg = app - .status_message - .as_deref() - .unwrap_or(" j/k: navigate | i: edit | :w/Enter: save | q/:q: quit "); + let status_msg = app.status_message.as_deref().unwrap_or_else(|| { + match app.mode { + Mode::Normal => " navigation | i: edit | :w: save | :q: quit ", + Mode::Insert => " Esc: back to normal | Enter: commit ", + } + }); let status_line = Line::from(vec![ Span::styled(mode_str, mode_style),