Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/target
.env*
101 changes: 100 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ It helps you lint, format, and switch between environment blocks — like `dev`

* **Block-based structure** — group related variables into labeled sections
* **Switch between environments** — move a block (e.g., `prod_database`) to the bottom to make it active
* **Tags** — annotate blocks with resource tags like `[db]` or `[smtp]`, so one environment name can cover several resources
* **Lint & format** — check for malformed lines, duplicates, and inconsistent formatting
* **Pipe-friendly** — read from stdin or directly modify files in place
* **Human-readable output** — no noise, just clean `.env` management
Expand Down Expand Up @@ -53,6 +54,7 @@ Commands:
lint Check for syntax and linting errors
format Pretty-format the file
pick <block> Reorder the file by moving the specified block down
(use --tag/-t to narrow the match when several blocks share a name)

Input modes:
- If data is piped in, envmn reads from standard input and writes to standard output.
Expand Down Expand Up @@ -139,6 +141,94 @@ Now, all the `DB_*` variables from `prod_database` override the ones from `dev_d

---

## Tags

Block headers can carry **tags** in square brackets:

```bash
#@ local [db]
DB_HOST=localhost
##

#@ local [smtp]
MAILGUN_API_KEY=key-xyz123456789
##

#@ remote [db]
DB_HOST=example.com
##
```

The block name is the environment (`local`, `remote`) and the tags describe the resource (`db`, `smtp`).
Tag names follow the same rules as block names: lowercase letters, digits, and underscores, not starting with a digit.

Two blocks may share a name as long as their tags differ. `list` shows tags next to each block name:

```
Blocks (4):
- default
- local [db]
- local [smtp]
- remote [db]
```

### Picking with tags

Blocks that share a tag form a **group** competing for the same variables, and the **last block of a group is the active one**. Picking a tagged block doesn't send it to the bottom of the file — it slides to just **after the last block of its group**, so related blocks stay together and the rest of the file doesn't move:

```bash
envmn pick remote --tag db .env # 'remote [db]' slides right after the last db block
envmn pick remote .env # picks ALL blocks named remote, each within its own group
```

A bare `pick <name>` switches the whole environment at once: every block with that name becomes active within its group. Use `--tag` (repeatable) to switch a single resource. Picking a block that is already active is a no-op. Untagged blocks keep the classic behavior and move to the bottom of the file.

Every tagged pick reports what changed on stderr (stdout stays clean for piping), so switches that happen implicitly — e.g. a multi-tag block activating a resource you didn't name — are always visible:

```
$ envmn pick local --tag db .env
db: 'local [db]' now active (was 'remote [db]')
$ envmn pick local --tag db .env
'local' is already active
```

### Recommended layout

`envmn` is opinionated: give each block **one resource tag**, and use the block *name* to bundle resources into an environment:

```bash
#@ remote [db]
DB_HOST=example.com
##

#@ remote [cache]
CACHE_HOST=example.com
##
```

`envmn pick remote` then switches db and cache together, and `envmn pick other --tag cache` later swaps just the cache — any mix of environments is a sequence of picks.

Multiple tags on one block (`#@ remote [db, cache]`) are supported and mean "these resources always switch together as one unit": picking such a block slides it after *all* blocks it shares a tag with, activating everything it defines. Prefer split single-tag blocks unless you really want that all-or-nothing behavior, since a multi-tag block duplicates the variables of every group it belongs to.

### Reserved tags

Tags of the form `__name__` are reserved for special meanings. The only one recognized today is
`__encrypted__`: it marks a block whose **values** are ciphertext. Keys stay plaintext — keys are
rarely the secret, values are — so encrypted blocks still parse, list, format, and lint like any
other block:

```bash
#@ local [smtp, __encrypted__]
MAILGUN_API_KEY=08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136
MAILGUN_DOMAIN=eeda8bcff5d676460d8ea84bd0e941e4bf5ac466aa21e2512cc1a870e89fb17d
##
```

Encryption and decryption themselves are not implemented yet; the tag currently documents that the
values are not usable as-is.

---

## Other Commands

### Lint
Expand All @@ -149,6 +239,15 @@ Check for syntax and formatting errors:
envmn lint .env
```

For tagged files, lint also checks **variable symmetry**: blocks sharing a resource tag compete for the same variables, so they should define the same keys. Single-tag blocks are the ground truth for their tag; a multi-tag block is checked against the union of its tags — extra keys are only flagged when every one of its tags has a single-tag block to learn from. Findings are advisory: they print as warnings on stderr and the exit code stays 0.

```
warning: 'remote [db]' is missing variable 'DB_PORT' defined by its group
warning: 'remote [db, cache]' defines variable 'FOO' that belongs to none of its tags
```

Encrypted blocks are checked too: their keys stay plaintext, only their values are ciphertext.

### Format

Reformat and clean up your `.env` file:
Expand Down Expand Up @@ -179,7 +278,7 @@ envmn help

`envmn` parses `.env` files using a small Rust engine that:

* **Detects labeled blocks** marked with `#@ block_name` and closed by `##`
* **Detects labeled blocks** marked with `#@ block_name` (optionally tagged: `#@ block_name [tag1, tag2]`) and closed by `##`
* **Normalizes variable lines** (trims whitespace, fixes quoting issues)
* **Validates** each variable name and key/value format
* **Applies block precedence**: variables from later blocks overwrite earlier definitions
Expand Down
10 changes: 7 additions & 3 deletions src/cli/args.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use clap::{Parser, Subcommand};
use crate::cli::Source;
use clap::{Parser, Subcommand};
use std::io::{IsTerminal, Read, stdin};

#[derive(Parser)]
Expand All @@ -15,14 +15,15 @@ Examples:
cat .env | envmn lint
envmn format .env
envmn pick database_block .env > out.env
envmn pick local --tag db .env
envmn --version

For more information, visit: https://github.com/devark28/envmn")]
pub struct Args {
/// Display the current version
#[arg(short, long)]
pub version: bool,

#[command(subcommand)]
pub command: Option<ArgCommands>,
}
Expand All @@ -48,6 +49,9 @@ pub enum ArgCommands {
Pick {
/// Block name to move
block: String,
/// Narrow the match by tag (repeatable)
#[arg(short = 't', long = "tag")]
tags: Vec<String>,
/// File to modify (defaults to .env)
file: Option<String>,
},
Expand All @@ -70,4 +74,4 @@ impl Args {
};
(Self::parse(), stdin_input)
}
}
}
17 changes: 13 additions & 4 deletions src/cli/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@ pub struct Cli {

#[derive(Clone, Debug)]
pub enum Commands {
Version { name: String, version: String },
Version {
name: String,
version: String,
},
Lint,
Format,
List,
Pick { block_name: String },
Pick {
block_name: String,
tags: Vec<String>,
},
}

impl Cli {
Expand Down Expand Up @@ -54,8 +60,11 @@ impl Cli {
ArgCommands::List { file } => {
(Commands::List, Some(Self::resolve_input(file, stdin_input)))
}
ArgCommands::Pick { block, file } => (
Commands::Pick { block_name: block },
ArgCommands::Pick { block, tags, file } => (
Commands::Pick {
block_name: block,
tags,
},
Some(Self::resolve_input(file, stdin_input)),
),
ArgCommands::Version => (
Expand Down
2 changes: 1 addition & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
pub mod args;
mod cli;
mod constants;
mod source;
pub mod args;

pub use cli::Cli;
pub use cli::Commands;
Expand Down
24 changes: 24 additions & 0 deletions src/error/naming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ use std::fmt::{Display, Formatter};
pub enum NamingErrors {
BlockNameEmpty,
VariableNameEmpty,
TagNameEmpty,
BlockContainsInvalidCharacter(u16, String),
VariableContainsInvalidCharacter(u16, String),
TagContainsInvalidCharacter(u16, String),
StartsWithInvalidCharacter(u16, String),
TagStartsWithInvalidCharacter(u16, String),
UnknownReservedTag(u16, String),
}

impl Display for NamingErrors {
Expand Down Expand Up @@ -39,6 +43,26 @@ impl Display for NamingErrors {
line + 1
)
}
NamingErrors::TagNameEmpty => {
write!(f, "Tag name can not be empty")
}
NamingErrors::TagContainsInvalidCharacter(line, invalid_char) => {
write!(
f,
"Line {0}: Tag name contains invalid characters '{invalid_char}'",
line + 1
)
}
NamingErrors::TagStartsWithInvalidCharacter(line, invalids) => {
write!(
f,
"Line {0}: Tag name starts with invalid character '{invalids}'",
line + 1
)
}
NamingErrors::UnknownReservedTag(line, tag) => {
write!(f, "Line {0}: Unknown reserved tag '{tag}'", line + 1)
}
}
}
}
4 changes: 4 additions & 0 deletions src/error/parsing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub enum ParsingErrors {
ReservedWord(u16, String),
DuplicateBlock(String),
DuplicateVariable(String, String),
MalFormedTags(u16),
}

impl Display for ParsingErrors {
Expand Down Expand Up @@ -46,6 +47,9 @@ impl Display for ParsingErrors {
"Duplicate variable '{name}' found in block '{token_name}'"
)
}
ParsingErrors::MalFormedTags(line) => {
write!(f, "Line {0}: Malformed tags for block", line + 1)
}
}
}
}
6 changes: 4 additions & 2 deletions src/parser/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@ pub const BLOCK_END_SYMBOL: &str = "##";
pub const KV_DELIMITER: &str = "=";
pub const COMMENT_SYMBOL: &str = "#";
pub const DEFAULT_BLOCK_NAME: &str = "default";
/*pub const BLOCK_NAME_START_PAT: &str = r"^[^a-z_]";
pub const BLOCK_NAME_MID_PAT: &str = r"[^a-z_0-9]";*/
pub const TAGS_START_SYMBOL: &str = "[";
pub const TAGS_END_SYMBOL: &str = "]";
// TODO: use this encrypted block tag constant to detect encrypted blocks for decryption
pub const ENCRYPTED_BLOCK_TAG: &str = "__encrypted__";
Loading
Loading