This commit is contained in:
2026-03-16 15:46:24 +01:00
commit daffc65039
11 changed files with 2566 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build:
name: Build and Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
- name: Build
run: cargo build --release
- name: Create Release
id: create_release
uses: https://github.com/actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref_name }}
release_name: Release ${{ github.ref_name }}
draft: false
prerelease: false
- name: Upload Release Asset
uses: https://github.com/actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./target/release/cenv
asset_name: cenv-${{ github.ref_name }}-linux-amd64
asset_content_type: application/octet-stream

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

1864
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "cenv-rs"
version = "0.1.0"
edition = "2024"
authors = ["Nils Pukropp <nils@narl.io>"]
[[bin]]
name = "cenv"
path = "src/main.rs"
[dependencies]
crossterm = "0.29.0"
dirs = "6.0.0"
ratatui = "0.30.0"
serde = { version = "1.0.228", features = ["derive"] }
toml = "1.0.6"
[dev-dependencies]
tempfile = "3.27.0"

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The cenv-rs Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
# cenv-rs
cenv-rs is a Rust-based Terminal User Interface (TUI) tool designed to help developers interactively generate `.env` files from `.env.example` templates. With a focus on speed and usability, it features Vim-like keybindings and out-of-the-box support for theming, defaulting to the Catppuccin Mocha palette.
## Features
- Parse `.env.example` files to extract keys, default values, and comments.
- Vim-like keybindings for quick navigation and editing.
- Built-in theming support with Catppuccin Mocha as the default.
- Configurable through a standard TOML file.
## Installation
Ensure you have Rust and Cargo installed, then run:
```sh
cargo install --path .
```
Alternatively, you can build from source:
```sh
git clone <repository_url>
cd cenv-rs
cargo build --release
```
## Usage
Navigate to a directory containing a `.env.example` file and run:
```sh
cenv-rs
```
### Keybindings
- **Normal Mode**
- `j` / `Down`: Move selection down
- `k` / `Up`: Move selection up
- `i`: Edit the value of the currently selected key (Enter Insert Mode)
- `:w` or `Enter`: Save the current configuration to `.env`
- `q` or `:q`: Quit the application without saving
- `Esc`: Clear current prompt or return from actions
- **Insert Mode**
- Type your value for the selected key.
- `Esc`: Return to Normal Mode
## Configuration
cenv-rs can be configured using a `config.toml` file located in your user configuration directory (e.g., `~/.config/cenv-rs/config.toml` on Linux/macOS).
Example configuration:
```toml
[theme]
# Default theme is "catppuccin_mocha"
name = "catppuccin_mocha"
```
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.

55
src/app.rs Normal file
View File

@@ -0,0 +1,55 @@
use crate::env::EnvVar;
pub enum Mode {
Normal,
Insert,
}
pub struct App {
pub vars: Vec<EnvVar>,
pub selected: usize,
pub mode: Mode,
pub running: bool,
pub status_message: Option<String>,
}
impl App {
pub fn new(vars: Vec<EnvVar>) -> Self {
Self {
vars,
selected: 0,
mode: Mode::Normal,
running: true,
status_message: None,
}
}
pub fn next(&mut self) {
if !self.vars.is_empty() {
self.selected = (self.selected + 1) % self.vars.len();
}
}
pub fn previous(&mut self) {
if !self.vars.is_empty() {
if self.selected == 0 {
self.selected = self.vars.len() - 1;
} else {
self.selected -= 1;
}
}
}
pub fn enter_insert(&mut self) {
self.mode = Mode::Insert;
}
pub fn enter_normal(&mut self) {
self.mode = Mode::Normal;
}
#[allow(dead_code)]
pub fn quit(&mut self) {
self.running = false;
}
}

37
src/config.rs Normal file
View File

@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
use std::fs;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ThemeConfig {
pub name: String,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
name: "catppuccin_mocha".to_string(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct Config {
#[serde(default)]
pub theme: ThemeConfig,
}
pub fn load_config() -> Config {
if let Some(mut config_dir) = dirs::config_dir() {
config_dir.push("cenv-rs");
config_dir.push("config.toml");
if config_dir.exists() {
if let Ok(content) = fs::read_to_string(config_dir) {
if let Ok(config) = toml::from_str(&content) {
return config;
}
}
}
}
Config::default()
}

141
src/env.rs Normal file
View File

@@ -0,0 +1,141 @@
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<P: AsRef<Path>>(path: P) -> io::Result<Vec<EnvVar>> {
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<P: AsRef<Path>>(path: P, vars: &mut Vec<EnvVar>) -> 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<P: AsRef<Path>>(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");
}
}

171
src/main.rs Normal file
View File

@@ -0,0 +1,171 @@
mod app;
mod config;
mod env;
mod ui;
use app::{App, Mode};
use config::load_config;
use env::{merge_env, parse_env_example, write_env};
use std::error::Error;
use std::io;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Terminal,
backend::{Backend, CrosstermBackend},
};
fn main() -> Result<(), Box<dyn Error>> {
let example_path = ".env.example";
let env_path = ".env";
// 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.");
return Ok(());
}
// Merge existing .env if present
let _ = merge_env(env_path, &mut vars);
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);
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
Ok(())
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
config: &config::Config,
env_path: &str,
) -> io::Result<()>
where
io::Error: From<B::Error>,
{
// 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(())
}

150
src/ui.rs Normal file
View File

@@ -0,0 +1,150 @@
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},
};
// Catppuccin Mocha Palette
const MANTLE: Color = Color::Rgb(24, 24, 37);
const BASE: Color = Color::Rgb(30, 30, 46);
const TEXT: Color = Color::Rgb(205, 214, 244);
const BLUE: Color = Color::Rgb(137, 180, 250);
const GREEN: Color = Color::Rgb(166, 227, 161);
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)
let bg_color = BASE;
let fg_color = TEXT;
let highlight_color = BLUE;
let insert_color = GREEN;
// Background
let block = Block::default().style(Style::default().bg(bg_color).fg(fg_color));
f.render_widget(block, size);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), // List
Constraint::Length(3), // Input area
Constraint::Length(1), // Status bar
])
.split(size);
// List
let items: Vec<ListItem> = app
.vars
.iter()
.enumerate()
.map(|(i, var)| {
let style = if i == app.selected {
Style::default()
.fg(bg_color)
.bg(highlight_color)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(fg_color)
};
let content = format!(" {} = {} ", var.key, var.value);
ListItem::new(Line::from(content)).style(style)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Environment Variables ")
.border_style(Style::default().fg(SURFACE1)),
)
.highlight_style(
Style::default()
.fg(bg_color)
.bg(highlight_color)
.add_modifier(Modifier::BOLD),
);
let mut state = ListState::default();
state.select(Some(app.selected));
f.render_stateful_widget(list, chunks[0], &mut state);
// Input Area
let current_var = app.vars.get(app.selected);
let input_title = if let Some(var) = current_var {
if var.default_value.is_empty() {
format!(" Editing: {} ", var.key)
} else {
format!(" Editing: {} (Default: {}) ", var.key, var.default_value)
}
} else {
" Input ".to_string()
};
let input_color = match app.mode {
Mode::Insert => insert_color,
Mode::Normal => SURFACE1,
};
let input_text = if let Some(var) = current_var {
var.value.as_str()
} else {
""
};
let input = Paragraph::new(input_text)
.style(Style::default().fg(fg_color))
.block(
Block::default()
.borders(Borders::ALL)
.title(input_title)
.border_style(Style::default().fg(input_color)),
);
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,
));
}
// 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 status_msg = app
.status_message
.as_deref()
.unwrap_or(" j/k: navigate | i: edit | :w/Enter: save | q/:q: quit ");
let status_line = Line::from(vec![
Span::styled(mode_str, mode_style),
Span::styled(format!(" {} ", status_msg), status_style),
]);
let status = Paragraph::new(status_line).style(status_style);
f.render_widget(status, chunks[2]);
}