From 9dc4f38868151c7ef4c6801507a3fc3c87f1ed03 Mon Sep 17 00:00:00 2001 From: devark28 Date: Wed, 19 Nov 2025 18:00:18 +0200 Subject: [PATCH 01/16] feat(parser): support tags in block definitions - Add `tags` parsing capability for blocks with start and end symbols (`[`, `]`). - Introduce `ParsingErrors::MalFormedTags` to handle tag-related parsing errors. - Update `Block` structure to include tags using `IndexSet`. - Extend `Block` creation methods to support tag initialization. - Enhance `Display` implementation to format tags properly. - Modify hashing and equality checks to account for tags. - Update block parser to validate and handle tags. --- .gitignore | 1 + src/error/parsing.rs | 4 ++++ src/parser/constants.rs | 2 ++ src/parser/parser.rs | 34 +++++++++++++++++++++++++++++----- src/parser/tokens/block.rs | 27 +++++++++++++++++++++++---- 5 files changed, 59 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index ea8c4bf..a5fcb59 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.env* \ No newline at end of file diff --git a/src/error/parsing.rs b/src/error/parsing.rs index 1edbec3..69f6f65 100644 --- a/src/error/parsing.rs +++ b/src/error/parsing.rs @@ -9,6 +9,7 @@ pub enum ParsingErrors { ReservedWord(u16, String), DuplicateBlock(String), DuplicateVariable(String, String), + MalFormedTags(u16), } impl Display for ParsingErrors { @@ -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) + } } } } diff --git a/src/parser/constants.rs b/src/parser/constants.rs index e942613..fa6ab8a 100644 --- a/src/parser/constants.rs +++ b/src/parser/constants.rs @@ -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 TAGS_START_SYMBOL: &str = "["; +pub const TAGS_END_SYMBOL: &str = "]"; /*pub const BLOCK_NAME_START_PAT: &str = r"^[^a-z_]"; pub const BLOCK_NAME_MID_PAT: &str = r"[^a-z_0-9]";*/ diff --git a/src/parser/parser.rs b/src/parser/parser.rs index 76ff5e3..a2fe08c 100644 --- a/src/parser/parser.rs +++ b/src/parser/parser.rs @@ -26,10 +26,33 @@ impl Parser { let lines = input.lines(); for (idx, line) in lines.enumerate() { if line.starts_with(constants::BLOCK_START_SYMBOL) { - let name = match self.current_block.as_ref() { - None => line - .trim_start_matches(constants::BLOCK_START_SYMBOL) - .trim(), + let (name, tags) = match self.current_block.as_ref() { + None => { + let identifier = line + .trim_start_matches(constants::BLOCK_START_SYMBOL) + .trim(); + let (start_tags_idx, end_tags_idx) = ( + identifier.find(constants::TAGS_START_SYMBOL), + identifier.find(constants::TAGS_END_SYMBOL), + ); + let tags: Vec = match (start_tags_idx, end_tags_idx) { + (Some(start), Some(end)) => identifier[start + 1..end] + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + (None, None) => Vec::new(), + _ => { + return Err(Error::ParsingError(ParsingErrors::MalFormedTags( + idx as u16, + ))); + } + }; + ( + identifier[..start_tags_idx.unwrap_or(identifier.len())].trim(), + tags, + ) + } Some(Block { name, .. }) => { return Err(Error::ParsingError(ParsingErrors::NestedBlock( idx as u16, @@ -44,7 +67,7 @@ impl Parser { ))); } validate_block_name(idx as u16, name)?; - self.current_block = Some(Block::new(name)); + self.current_block = Some(Block::new_with_tags(name, tags)); } else if line.starts_with(constants::BLOCK_END_SYMBOL) { let block = match self.current_block.take() { Some(block) => block, @@ -73,6 +96,7 @@ impl Parser { self.get_working_block_mut()?.add_variable(variable)?; } } + dbg!(self.document.clone()); Ok(self.document) } pub fn parse_file(self, file_path: &str) -> Result { diff --git a/src/parser/tokens/block.rs b/src/parser/tokens/block.rs index 85949ed..d7461f7 100644 --- a/src/parser/tokens/block.rs +++ b/src/parser/tokens/block.rs @@ -9,6 +9,7 @@ use std::hash::{Hash, Hasher}; #[derive(Clone, Debug, Eq)] pub struct Block { pub name: String, + pub tags: IndexSet, lines: IndexSet, } @@ -16,12 +17,21 @@ impl Block { pub fn default() -> Self { Block { name: DEFAULT_BLOCK_NAME.to_string(), + tags: IndexSet::new(), lines: IndexSet::new(), } } pub fn new(name: &str) -> Self { Block { name: name.to_string(), + tags: IndexSet::new(), + lines: IndexSet::new(), + } + } + pub fn new_with_tags(name: &str, tags: Vec) -> Self { + Block { + name: name.to_string(), + tags: tags.into_iter().collect(), lines: IndexSet::new(), } } @@ -54,7 +64,7 @@ impl Display for Block { } else { write!( f, - "{0} {2}\n{3}\n{1}", + "{0} {2}{4}\n{3}\n{1}", BLOCK_START_SYMBOL, BLOCK_END_SYMBOL, self.name, @@ -62,7 +72,12 @@ impl Display for Block { .iter() .map(ToString::to_string) .collect::>() - .join("\n") + .join("\n"), + if self.tags.len() > 0 { + format!(" [{}]", self.tags.clone().into_iter().collect::>().join(", ")) + } else { + String::new() + } ) } } @@ -70,13 +85,16 @@ impl Display for Block { impl PartialEq for Block { fn eq(&self, other: &Self) -> bool { - self.name == other.name + self.name == other.name && self.tags == other.tags } } impl Hash for Block { fn hash(&self, state: &mut H) { - self.name.hash(state) + self.name.hash(state); + let mut sorted_tags = self.tags.iter().collect::>(); + sorted_tags.sort(); + sorted_tags.iter().for_each(|tag| tag.hash(state)); } } @@ -88,6 +106,7 @@ mod tests { fn raw_and_new_interop() { let v1 = Block { name: DEFAULT_BLOCK_NAME.to_string(), + tags: IndexSet::new(), lines: IndexSet::new(), }; let v2 = Block::new(DEFAULT_BLOCK_NAME); From 69ab6c6d215a4f00cae64661088d2eeff59fbaab Mon Sep 17 00:00:00 2001 From: devark28 Date: Wed, 19 Nov 2025 18:10:21 +0200 Subject: [PATCH 02/16] chore(parser): add TODO comments for future features and tests - Introduce TODO note for handling encrypted block tags with constant `ENCRYPTED_BLOCK_TAG`. - Add TODOs for testing tag operations including uniqueness, addition, and display behavior. --- src/parser/constants.rs | 9 +++++++-- src/parser/tokens/block.rs | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/parser/constants.rs b/src/parser/constants.rs index fa6ab8a..4ffd748 100644 --- a/src/parser/constants.rs +++ b/src/parser/constants.rs @@ -5,5 +5,10 @@ pub const COMMENT_SYMBOL: &str = "#"; pub const DEFAULT_BLOCK_NAME: &str = "default"; pub const TAGS_START_SYMBOL: &str = "["; pub const TAGS_END_SYMBOL: &str = "]"; -/*pub const BLOCK_NAME_START_PAT: &str = r"^[^a-z_]"; -pub const BLOCK_NAME_MID_PAT: &str = r"[^a-z_0-9]";*/ +/* +TODO: use this encrypted block tag constant to: + - detect encrypted blocks and store their plain body without parsing them + - detect encrypted blocks for displaying raw encrypted body + - detect encrypted blocks for decryption + */ +pub const ENCRYPTED_BLOCK_TAG: &str = "__encrypted__"; diff --git a/src/parser/tokens/block.rs b/src/parser/tokens/block.rs index d7461f7..bc13206 100644 --- a/src/parser/tokens/block.rs +++ b/src/parser/tokens/block.rs @@ -98,6 +98,12 @@ impl Hash for Block { } } +/* +TODO: add tests for tags operations + - add tag to named block + - tags contribute to uniqueness of block + - proper display of tags + */ #[cfg(test)] mod tests { use super::*; From 8c40013ac4e7fc605cf923949bd5bdfbf426a8f9 Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 00:19:58 +0200 Subject: [PATCH 03/16] feat(parser): validate tag names and preserve __encrypted__ block bodies raw --- src/error/naming.rs | 24 +++++ src/parser/constants.rs | 7 +- src/parser/parser.rs | 14 ++- src/parser/tokens/block.rs | 130 ++++++++++++++++++++++--- src/parser/tokens/line.rs | 15 +++ src/parser/validators/mod.rs | 2 + src/parser/validators/tag_validator.rs | 88 +++++++++++++++++ 7 files changed, 257 insertions(+), 23 deletions(-) create mode 100644 src/parser/validators/tag_validator.rs diff --git a/src/error/naming.rs b/src/error/naming.rs index 2ddb80b..1a8464a 100644 --- a/src/error/naming.rs +++ b/src/error/naming.rs @@ -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 { @@ -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) + } } } } diff --git a/src/parser/constants.rs b/src/parser/constants.rs index 4ffd748..1569eed 100644 --- a/src/parser/constants.rs +++ b/src/parser/constants.rs @@ -5,10 +5,5 @@ pub const COMMENT_SYMBOL: &str = "#"; pub const DEFAULT_BLOCK_NAME: &str = "default"; pub const TAGS_START_SYMBOL: &str = "["; pub const TAGS_END_SYMBOL: &str = "]"; -/* -TODO: use this encrypted block tag constant to: - - detect encrypted blocks and store their plain body without parsing them - - detect encrypted blocks for displaying raw encrypted body - - detect encrypted blocks for decryption - */ +// TODO: use this encrypted block tag constant to detect encrypted blocks for decryption pub const ENCRYPTED_BLOCK_TAG: &str = "__encrypted__"; diff --git a/src/parser/parser.rs b/src/parser/parser.rs index a2fe08c..2449aa8 100644 --- a/src/parser/parser.rs +++ b/src/parser/parser.rs @@ -3,7 +3,7 @@ use crate::parser::constants; use crate::parser::tokens::Block; use crate::parser::tokens::Document; use crate::parser::tokens::variable::Variable; -use crate::parser::validators::{validate_block_name, validate_variable_name}; +use crate::parser::validators::{validate_block_name, validate_tag, validate_variable_name}; use std::fs; use std::ops::Deref; @@ -67,6 +67,9 @@ impl Parser { ))); } validate_block_name(idx as u16, name)?; + for tag in &tags { + validate_tag(idx as u16, tag)?; + } self.current_block = Some(Block::new_with_tags(name, tags)); } else if line.starts_with(constants::BLOCK_END_SYMBOL) { let block = match self.current_block.take() { @@ -78,6 +81,14 @@ impl Parser { } }; self.document.add_block(block)?; + } else if self + .current_block + .as_ref() + .is_some_and(|block| block.is_encrypted()) + { + if line.trim().len() > 0 { + self.get_working_block_mut()?.add_raw(line); + } } else if line.starts_with(constants::COMMENT_SYMBOL) { let comment = line .trim_start_matches(constants::COMMENT_SYMBOL) @@ -96,7 +107,6 @@ impl Parser { self.get_working_block_mut()?.add_variable(variable)?; } } - dbg!(self.document.clone()); Ok(self.document) } pub fn parse_file(self, file_path: &str) -> Result { diff --git a/src/parser/tokens/block.rs b/src/parser/tokens/block.rs index bc13206..c186ab4 100644 --- a/src/parser/tokens/block.rs +++ b/src/parser/tokens/block.rs @@ -1,5 +1,7 @@ use crate::error::{Error, ParsingErrors}; -use crate::parser::constants::{BLOCK_END_SYMBOL, BLOCK_START_SYMBOL, DEFAULT_BLOCK_NAME}; +use crate::parser::constants::{ + BLOCK_END_SYMBOL, BLOCK_START_SYMBOL, DEFAULT_BLOCK_NAME, ENCRYPTED_BLOCK_TAG, +}; use crate::parser::tokens::line::Line; use crate::parser::tokens::variable::Variable; use indexmap::IndexSet; @@ -21,6 +23,7 @@ impl Block { lines: IndexSet::new(), } } + #[allow(dead_code)] // used by tests pub fn new(name: &str) -> Self { Block { name: name.to_string(), @@ -47,6 +50,23 @@ impl Block { pub fn add_comment(&mut self, comment: &str) { self.lines.insert(Line::Comment(comment.to_string())); } + pub fn add_raw(&mut self, line: &str) { + self.lines.insert(Line::Raw(line.to_string())); + } + pub fn is_encrypted(&self) -> bool { + self.tags.contains(ENCRYPTED_BLOCK_TAG) + } + pub fn identifier(&self) -> String { + if self.tags.is_empty() { + self.name.clone() + } else { + format!( + "{} [{}]", + self.name, + self.tags.iter().cloned().collect::>().join(", ") + ) + } + } } impl Display for Block { @@ -64,20 +84,15 @@ impl Display for Block { } else { write!( f, - "{0} {2}{4}\n{3}\n{1}", + "{0} {2}\n{3}\n{1}", BLOCK_START_SYMBOL, BLOCK_END_SYMBOL, - self.name, + self.identifier(), self.lines .iter() .map(ToString::to_string) .collect::>() - .join("\n"), - if self.tags.len() > 0 { - format!(" [{}]", self.tags.clone().into_iter().collect::>().join(", ")) - } else { - String::new() - } + .join("\n") ) } } @@ -98,12 +113,6 @@ impl Hash for Block { } } -/* -TODO: add tests for tags operations - - add tag to named block - - tags contribute to uniqueness of block - - proper display of tags - */ #[cfg(test)] mod tests { use super::*; @@ -156,6 +165,70 @@ mod tests { block.add_variable(Variable::new("KEY", "value")).unwrap(); block.add_variable(Variable::new("KEY", "value")).unwrap(); } + + #[test] + fn add_raw_line() { + let mut block = Block::new("test"); + block.add_raw("08debe3d42ade916"); + assert_eq!(block.lines.len(), 1); + assert!(matches!(block.lines.first().unwrap(), Line::Raw(_))); + } + + #[test] + fn add_same_raw_line() { + let mut block = Block::new("test"); + block.add_raw("08debe3d42ade916"); + block.add_raw("08debe3d42ade916"); + assert_eq!(block.lines.len(), 2); + } + } + + #[cfg(test)] + mod tags { + use super::*; + + #[test] + fn new_with_tags_stores_tags() { + let block = Block::new_with_tags("test", vec!["db".to_string(), "smtp".to_string()]); + assert_eq!(block.tags.len(), 2); + assert!(block.tags.contains("db")); + assert!(block.tags.contains("smtp")); + } + + #[test] + fn tags_contribute_to_uniqueness() { + let untagged = Block::new("test"); + let tagged = Block::new_with_tags("test", vec!["db".to_string()]); + let other_tag = Block::new_with_tags("test", vec!["smtp".to_string()]); + assert_ne!(untagged, tagged); + assert_ne!(tagged, other_tag); + } + + #[test] + fn tag_order_does_not_affect_equality() { + let block1 = Block::new_with_tags("test", vec!["db".to_string(), "smtp".to_string()]); + let block2 = Block::new_with_tags("test", vec!["smtp".to_string(), "db".to_string()]); + assert_eq!(block1, block2); + } + + #[test] + fn is_encrypted() { + let plain = Block::new_with_tags("test", vec!["db".to_string()]); + let encrypted = Block::new_with_tags( + "test", + vec!["db".to_string(), ENCRYPTED_BLOCK_TAG.to_string()], + ); + assert!(!plain.is_encrypted()); + assert!(encrypted.is_encrypted()); + } + + #[test] + fn identifier_with_and_without_tags() { + let untagged = Block::new("test"); + let tagged = Block::new_with_tags("test", vec!["db".to_string(), "smtp".to_string()]); + assert_eq!(untagged.identifier(), "test"); + assert_eq!(tagged.identifier(), "test [db, smtp]"); + } } #[cfg(test)] @@ -177,6 +250,33 @@ mod tests { ); } + #[test] + fn tagged_block() { + let mut block = + Block::new_with_tags("test", vec!["db".to_string(), "smtp".to_string()]); + block.add_variable(Variable::new("KEY", "value")).unwrap(); + assert_eq!( + block.to_string(), + format!("{BLOCK_START_SYMBOL} test [db, smtp]\nKEY=value\n{BLOCK_END_SYMBOL}") + ); + } + + #[test] + fn encrypted_block_displays_raw_lines_verbatim() { + let mut block = Block::new_with_tags( + "test", + vec!["smtp".to_string(), ENCRYPTED_BLOCK_TAG.to_string()], + ); + block.add_raw("08debe3d42ade916"); + block.add_raw("eeda8bcff5d67646"); + assert_eq!( + block.to_string(), + format!( + "{BLOCK_START_SYMBOL} test [smtp, {ENCRYPTED_BLOCK_TAG}]\n08debe3d42ade916\needa8bcff5d67646\n{BLOCK_END_SYMBOL}" + ) + ); + } + #[test] fn block_with_variables() { let variable = Variable::new("KEY", "value"); diff --git a/src/parser/tokens/line.rs b/src/parser/tokens/line.rs index f950614..41bf67d 100644 --- a/src/parser/tokens/line.rs +++ b/src/parser/tokens/line.rs @@ -5,6 +5,7 @@ use std::fmt::{Display, Formatter}; pub enum Line { Comment(String), Variable(Variable), + Raw(String), } impl Display for Line { @@ -12,6 +13,7 @@ impl Display for Line { match self { Line::Comment(comment) => write!(f, "# {comment}"), Line::Variable(variable) => write!(f, "{variable}"), + Line::Raw(raw) => write!(f, "{raw}"), } } } @@ -44,4 +46,17 @@ mod tests { let line2 = Line::Comment("comment".to_string()); assert_ne!(line1, line2); } + + #[test] + fn line_inequality_by_raw() { + let line1 = Line::Raw("08debe3d42ade916".to_string()); + let line2 = Line::Raw("08debe3d42ade916".to_string()); + assert_ne!(line1, line2); + } + + #[test] + fn raw_line_displays_verbatim() { + let line = Line::Raw("08debe3d42ade916".to_string()); + assert_eq!(line.to_string(), "08debe3d42ade916"); + } } diff --git a/src/parser/validators/mod.rs b/src/parser/validators/mod.rs index 1dcb9f0..9ef9df1 100644 --- a/src/parser/validators/mod.rs +++ b/src/parser/validators/mod.rs @@ -1,5 +1,7 @@ mod block_name_validator; +mod tag_validator; mod variable_name_validator; pub use block_name_validator::validate_block_name; +pub use tag_validator::validate_tag; pub use variable_name_validator::validate_variable_name; diff --git a/src/parser/validators/tag_validator.rs b/src/parser/validators/tag_validator.rs new file mode 100644 index 0000000..6c883cf --- /dev/null +++ b/src/parser/validators/tag_validator.rs @@ -0,0 +1,88 @@ +use crate::error::{Error, NamingErrors}; +use crate::parser::constants::ENCRYPTED_BLOCK_TAG; + +const RESERVED_TAGS: [&str; 1] = [ENCRYPTED_BLOCK_TAG]; + +pub fn validate_tag(line: u16, tag: &str) -> Result<(), Error> { + if tag.is_empty() { + return Err(Error::NamingError(NamingErrors::TagNameEmpty)); + } + + let mut chars = tag.chars(); + let first = chars.next().unwrap(); + + // First character: must be lowercase letter or underscore + if !first.is_ascii_lowercase() && first != '_' { + return Err(Error::NamingError( + NamingErrors::TagStartsWithInvalidCharacter(line, first.to_string()), + )); + } + + // Remaining characters: must be lowercase letters, digits, or underscores + if let Some(invalid_char) = + chars.find(|&c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')) + { + return Err(Error::NamingError( + NamingErrors::TagContainsInvalidCharacter(line, invalid_char.to_string()), + )); + } + + // Dunder form (__x__) is reserved for special tags + if tag.len() > 4 + && tag.starts_with("__") + && tag.ends_with("__") + && !RESERVED_TAGS.contains(&tag) + { + return Err(Error::NamingError(NamingErrors::UnknownReservedTag( + line, + tag.to_string(), + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contains_underscore_and_alphanumeric() { + validate_tag(1, "valid_tag_1").unwrap(); + } + + #[test] + fn known_reserved_tag() { + validate_tag(1, ENCRYPTED_BLOCK_TAG).unwrap(); + } + + #[test] + #[should_panic] + fn empty() { + validate_tag(1, "").unwrap(); + } + + #[test] + #[should_panic] + fn starts_with_digit() { + validate_tag(1, "1invalid_tag").unwrap(); + } + + #[test] + #[should_panic] + fn contains_special_character() { + validate_tag(1, "invalid-tag").unwrap(); + } + + #[test] + #[should_panic] + fn contains_uppercase_letters() { + validate_tag(1, "INVALID_TAG").unwrap(); + } + + #[test] + #[should_panic] + fn unknown_reserved_tag() { + validate_tag(1, "__secret__").unwrap(); + } +} From 69886cf0d03861d3ae60ee9dec5555bff8137dc2 Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 00:20:31 +0200 Subject: [PATCH 04/16] feat(cli): support picking and listing blocks by tags --- src/cli/args.rs | 10 ++- src/cli/cli.rs | 17 +++- src/error/access.rs | 12 +++ src/parser/engine/list.rs | 2 +- src/parser/engine/mod.rs | 2 +- src/parser/engine/pick.rs | 4 +- src/parser/tokens/document.rs | 141 +++++++++++++++++++++++++++------- 7 files changed, 150 insertions(+), 38 deletions(-) diff --git a/src/cli/args.rs b/src/cli/args.rs index 696a477..6959d44 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -1,5 +1,5 @@ -use clap::{Parser, Subcommand}; use crate::cli::Source; +use clap::{Parser, Subcommand}; use std::io::{IsTerminal, Read, stdin}; #[derive(Parser)] @@ -15,6 +15,7 @@ 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")] @@ -22,7 +23,7 @@ pub struct Args { /// Display the current version #[arg(short, long)] pub version: bool, - + #[command(subcommand)] pub command: Option, } @@ -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, /// File to modify (defaults to .env) file: Option, }, @@ -70,4 +74,4 @@ impl Args { }; (Self::parse(), stdin_input) } -} \ No newline at end of file +} diff --git a/src/cli/cli.rs b/src/cli/cli.rs index bf2d9eb..eda4abe 100644 --- a/src/cli/cli.rs +++ b/src/cli/cli.rs @@ -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, + }, } impl Cli { @@ -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 => ( diff --git a/src/error/access.rs b/src/error/access.rs index 336a0cd..cde194d 100644 --- a/src/error/access.rs +++ b/src/error/access.rs @@ -4,6 +4,7 @@ use std::fmt::{Display, Formatter}; pub enum AccessErrors { FileError(String, String), BlockNotFound(String), + AmbiguousBlock(String, Vec), DefaultBlockNotMovable, } @@ -16,6 +17,17 @@ impl Display for AccessErrors { AccessErrors::BlockNotFound(block_name) => { write!(f, "Block '{block_name}' was not found") } + AccessErrors::AmbiguousBlock(block_name, candidates) => { + write!( + f, + "Multiple blocks named '{block_name}' match; narrow with --tag:\n{}", + candidates + .iter() + .map(|c| format!(" - {c}")) + .collect::>() + .join("\n") + ) + } AccessErrors::DefaultBlockNotMovable => { write!(f, "default block is not movable") } diff --git a/src/parser/engine/list.rs b/src/parser/engine/list.rs index b7cb413..cd6102a 100644 --- a/src/parser/engine/list.rs +++ b/src/parser/engine/list.rs @@ -8,7 +8,7 @@ impl Engine { self.document .get_blocks() .iter() - .map(|b| format!("- {}", b.name)) + .map(|b| format!("- {}", b.identifier())) .collect::>() .join("\n") ) diff --git a/src/parser/engine/mod.rs b/src/parser/engine/mod.rs index 54e1859..21c1e0b 100644 --- a/src/parser/engine/mod.rs +++ b/src/parser/engine/mod.rs @@ -21,7 +21,7 @@ impl Engine { Commands::Lint => Ok(()), Commands::List => Ok(self.process_list_cmd()), Commands::Format => Ok(self.process_format_cmd()), - Commands::Pick { block_name } => Ok(self.process_pick_cmd(block_name)), + Commands::Pick { block_name, tags } => Ok(self.process_pick_cmd(block_name, tags)), _ => Err(Error::CliError(CliErrors::NoOperationFound)), } } diff --git a/src/parser/engine/pick.rs b/src/parser/engine/pick.rs index 82b5413..8983aae 100644 --- a/src/parser/engine/pick.rs +++ b/src/parser/engine/pick.rs @@ -5,8 +5,8 @@ use std::fs; use std::process::exit; impl Engine { - pub fn process_pick_cmd(mut self, block_name: String) { - match self.document.pick(block_name.as_str()) { + pub fn process_pick_cmd(mut self, block_name: String, tags: Vec) { + match self.document.pick(block_name.as_str(), &tags) { Ok(document) => { let Some(input) = &self.cli.input else { eprintln!("{}", CliErrors::NoInputFound); diff --git a/src/parser/tokens/document.rs b/src/parser/tokens/document.rs index d01a02c..2e7fc12 100644 --- a/src/parser/tokens/document.rs +++ b/src/parser/tokens/document.rs @@ -19,21 +19,20 @@ impl Document { pub fn add_block(&mut self, block: Block) -> Result<(), Error> { if !self.blocks.insert(block.clone()) { return Err(Error::ParsingError(ParsingErrors::DuplicateBlock( - block.name, + block.identifier(), ))); } Ok(()) } - pub fn get_index(&self, name: &str) -> Option { - match self - .blocks - .get_index_of(&Block::new(name)) - .ok_or(Error::AccessError(AccessErrors::BlockNotFound( - name.to_string(), - ))) { - Ok(index) => Some(index), - Err(_) => None, - } + pub fn find_indices(&self, name: &str, tags: &[String]) -> Vec { + self.blocks + .iter() + .enumerate() + .filter(|(_, block)| { + block.name == name && tags.iter().all(|tag| block.tags.contains(tag)) + }) + .map(|(index, _)| index) + .collect() } pub fn get_blocks(&self) -> Vec<&Block> { self.blocks.iter().collect::>() @@ -55,18 +54,30 @@ impl Document { } impl Document { - pub fn pick(&mut self, name: &str) -> Result<&Self, Error> { + pub fn pick(&mut self, name: &str, tags: &[String]) -> Result<&Self, Error> { if name == DEFAULT_BLOCK_NAME { return Err(Error::AccessError(AccessErrors::DefaultBlockNotMovable)); } - match self.get_index(name) { - None => Err(Error::AccessError(AccessErrors::BlockNotFound( - name.to_string(), - ))), - Some(index) => { - self.blocks.move_index(index, self.blocks.len() - 1); + match self.find_indices(name, tags).as_slice() { + [] => { + let query = if tags.is_empty() { + name.to_string() + } else { + format!("{} [{}]", name, tags.join(", ")) + }; + Err(Error::AccessError(AccessErrors::BlockNotFound(query))) + } + [index] => { + self.blocks.move_index(*index, self.blocks.len() - 1); Ok(self) } + indices => Err(Error::AccessError(AccessErrors::AmbiguousBlock( + name.to_string(), + indices + .iter() + .map(|&index| self.blocks[index].identifier()) + .collect(), + ))), } } } @@ -135,20 +146,50 @@ mod tests { } #[test] - fn get_block_index_by_name() { + fn find_block_indices_by_name() { let mut doc = Document::new(); doc.add_block(Block::new("test")).unwrap(); - let index = doc.get_index("test"); - assert!(index.is_some()); - assert_eq!(index.unwrap(), 1); + assert_eq!(doc.find_indices("test", &[]), vec![1]); } #[test] - #[should_panic] - fn get_non_existing_block_index() { + fn find_non_existing_block_indices() { let doc = Document::new(); - let index = doc.get_index("test"); - index.unwrap(); + assert!(doc.find_indices("test", &[]).is_empty()); + } + + #[test] + fn find_tagged_block_by_name_only() { + let mut doc = Document::new(); + doc.add_block(Block::new_with_tags("test", vec!["db".to_string()])) + .unwrap(); + assert_eq!(doc.find_indices("test", &[]), vec![1]); + } + + #[test] + fn find_indices_by_tag_subset() { + let mut doc = Document::new(); + doc.add_block(Block::new_with_tags("test", vec!["db".to_string()])) + .unwrap(); + doc.add_block(Block::new_with_tags( + "test", + vec!["smtp".to_string(), "backup".to_string()], + )) + .unwrap(); + assert_eq!(doc.find_indices("test", &[]), vec![1, 2]); + assert_eq!(doc.find_indices("test", &["db".to_string()]), vec![1]); + assert_eq!(doc.find_indices("test", &["smtp".to_string()]), vec![2]); + assert!(doc.find_indices("test", &["nope".to_string()]).is_empty()); + } + + #[test] + fn same_name_blocks_with_different_tags_coexist() { + let mut doc = Document::new(); + doc.add_block(Block::new_with_tags("test", vec!["db".to_string()])) + .unwrap(); + doc.add_block(Block::new_with_tags("test", vec!["smtp".to_string()])) + .unwrap(); + assert_eq!(doc.blocks_len(), 3); } #[test] @@ -178,9 +219,55 @@ mod tests { let mut doc = Document::new(); doc.add_block(Block::new("test")).unwrap(); doc.add_block(Block::new("test1")).unwrap(); - doc.pick("test").unwrap(); + doc.pick("test", &[]).unwrap(); + assert_eq!(doc.blocks.last().unwrap().name, "test"); + } + + #[test] + fn pick_unique_tagged_block_by_name_only() { + let mut doc = Document::new(); + doc.add_block(Block::new_with_tags("test", vec!["db".to_string()])) + .unwrap(); + doc.add_block(Block::new("test1")).unwrap(); + doc.pick("test", &[]).unwrap(); assert_eq!(doc.blocks.last().unwrap().name, "test"); } + + #[test] + fn pick_block_by_tag() { + let mut doc = Document::new(); + doc.add_block(Block::new_with_tags("test", vec!["db".to_string()])) + .unwrap(); + doc.add_block(Block::new_with_tags("test", vec!["smtp".to_string()])) + .unwrap(); + doc.pick("test", &["db".to_string()]).unwrap(); + let last = doc.blocks.last().unwrap(); + assert_eq!(last.name, "test"); + assert!(last.tags.contains("db")); + } + + #[test] + fn pick_ambiguous_block_fails() { + let mut doc = Document::new(); + doc.add_block(Block::new_with_tags("test", vec!["db".to_string()])) + .unwrap(); + doc.add_block(Block::new_with_tags("test", vec!["smtp".to_string()])) + .unwrap(); + let error = doc.pick("test", &[]).unwrap_err(); + assert!(matches!( + error, + Error::AccessError(AccessErrors::AmbiguousBlock(_, _)) + )); + } + + #[test] + #[should_panic] + fn pick_non_existing_tag_fails() { + let mut doc = Document::new(); + doc.add_block(Block::new_with_tags("test", vec!["db".to_string()])) + .unwrap(); + doc.pick("test", &["nope".to_string()]).unwrap(); + } } #[cfg(test)] From 5d4f898690c377d4c9813a225f775f6d08a98793 Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 00:20:54 +0200 Subject: [PATCH 05/16] test(tags): add integration tests for tagged blocks --- tests/tags_tests.rs | 201 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 tests/tags_tests.rs diff --git a/tests/tags_tests.rs b/tests/tags_tests.rs new file mode 100644 index 0000000..7d29f33 --- /dev/null +++ b/tests/tags_tests.rs @@ -0,0 +1,201 @@ +mod common; + +use common::{create_test_env_file, get_binary_path}; +use std::fs; +use std::process::Command; + +const TAGGED_CONTENT: &str = r#"API_URL=https://api.example.com + +#@ local [db] +DB_HOST=localhost +DB_PORT=5432 +## + +#@ remote [db] +DB_HOST=example.com +DB_PORT=5432 +## + +#@ server +SERVER_PORT="8080" +## + +#@ local [smtp, __encrypted__] +08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136 +eeda8bcff5d676460d8ea84bd0e941e4bf5ac466aa21e2512cc1a870e89fb17d +## +"#; + +#[test] +fn list_shows_tags() { + let temp_file = create_test_env_file(TAGGED_CONTENT); + + let output = Command::new(get_binary_path()) + .arg("list") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("- local [db]")); + assert!(stdout.contains("- remote [db]")); + assert!(stdout.contains("- server")); + assert!(stdout.contains("- local [smtp, __encrypted__]")); +} + +#[test] +fn pick_with_tag_selects_matching_block() { + let temp_file = create_test_env_file(TAGGED_CONTENT); + + let output = Command::new(get_binary_path()) + .arg("pick") + .arg("local") + .arg("--tag") + .arg("smtp") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let content = fs::read_to_string(temp_file.path()).unwrap(); + let smtp_pos = content.find("#@ local [smtp, __encrypted__]").unwrap(); + let db_pos = content.find("#@ local [db]").unwrap(); + let server_pos = content.find("#@ server").unwrap(); + assert!(smtp_pos > db_pos, "picked block should move to the bottom"); + assert!( + smtp_pos > server_pos, + "picked block should move to the bottom" + ); +} + +#[test] +fn pick_ambiguous_block_errors_with_candidates() { + let temp_file = create_test_env_file(TAGGED_CONTENT); + + let output = Command::new(get_binary_path()) + .arg("pick") + .arg("local") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--tag")); + assert!(stderr.contains("local [db]")); + assert!(stderr.contains("local [smtp, __encrypted__]")); +} + +#[test] +fn pick_with_unknown_tag_errors() { + let temp_file = create_test_env_file(TAGGED_CONTENT); + + let output = Command::new(get_binary_path()) + .arg("pick") + .arg("local") + .arg("--tag") + .arg("nope") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("was not found")); +} + +#[test] +fn pick_unique_tagged_block_by_name_only() { + let temp_file = create_test_env_file(TAGGED_CONTENT); + + let output = Command::new(get_binary_path()) + .arg("pick") + .arg("remote") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let content = fs::read_to_string(temp_file.path()).unwrap(); + let remote_pos = content.find("#@ remote [db]").unwrap(); + let server_pos = content.find("#@ server").unwrap(); + assert!( + remote_pos > server_pos, + "picked block should move to the bottom" + ); +} + +#[test] +fn format_round_trips_encrypted_block() { + let temp_file = create_test_env_file(TAGGED_CONTENT); + + let output = Command::new(get_binary_path()) + .arg("format") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let content = fs::read_to_string(temp_file.path()).unwrap(); + assert!(content.contains("#@ local [smtp, __encrypted__]")); + assert!(content.contains("08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136")); + assert!(content.contains("eeda8bcff5d676460d8ea84bd0e941e4bf5ac466aa21e2512cc1a870e89fb17d")); +} + +#[test] +fn malformed_tags_error() { + let temp_file = create_test_env_file("#@ local [db\nKEY=value\n##\n"); + + let output = Command::new(get_binary_path()) + .arg("lint") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.to_lowercase().contains("tags")); +} + +#[test] +fn invalid_tag_character_error() { + let temp_file = create_test_env_file("#@ local [DB]\nKEY=value\n##\n"); + + let output = Command::new(get_binary_path()) + .arg("lint") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Tag name")); +} + +#[test] +fn unknown_reserved_tag_error() { + let temp_file = create_test_env_file("#@ local [__secret__]\nKEY=value\n##\n"); + + let output = Command::new(get_binary_path()) + .arg("lint") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Unknown reserved tag")); +} From 952aa59432f4ca162e2ce327bf7e2a9fc4fb6759 Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 00:22:11 +0200 Subject: [PATCH 06/16] style: apply cargo fmt --- src/cli/mod.rs | 2 +- src/parser/mod.rs | 6 +++--- tests/common/mod.rs | 2 +- tests/format_tests.rs | 6 +++--- tests/lint_tests.rs | 2 +- tests/list_tests.rs | 4 ++-- tests/pick_tests.rs | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index bb92947..ed9f9b5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,7 +1,7 @@ +pub mod args; mod cli; mod constants; mod source; -pub mod args; pub use cli::Cli; pub use cli::Commands; diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 0caef25..9537aa0 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1,8 +1,8 @@ -mod parser; mod constants; +mod engine; +mod parser; mod tokens; mod validators; -mod engine; -pub use parser::Parser; pub use engine::Engine; +pub use parser::Parser; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index b031181..e0ff433 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -29,4 +29,4 @@ pub fn run_command(args: &[&str]) -> std::process::Output { .args(args) .output() .expect("Failed to execute command") -} \ No newline at end of file +} diff --git a/tests/format_tests.rs b/tests/format_tests.rs index 723efcd..25c8dc6 100644 --- a/tests/format_tests.rs +++ b/tests/format_tests.rs @@ -21,9 +21,9 @@ VAR=test VAR=test ## "#; - + let mut temp_file = create_test_env_file(test_content); - + let output = Command::new(get_binary_path()) .arg("format") .arg(temp_file.path()) @@ -89,4 +89,4 @@ fn stdin_takes_priority_over_file() { assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("STDIN_VAR")); -} \ No newline at end of file +} diff --git a/tests/lint_tests.rs b/tests/lint_tests.rs index b3308f1..b874208 100644 --- a/tests/lint_tests.rs +++ b/tests/lint_tests.rs @@ -20,4 +20,4 @@ VAR=test .expect("Failed to execute command"); assert!(output.status.success()); -} \ No newline at end of file +} diff --git a/tests/list_tests.rs b/tests/list_tests.rs index 2413def..5d270e1 100644 --- a/tests/list_tests.rs +++ b/tests/list_tests.rs @@ -1,6 +1,6 @@ mod common; -use common::{create_test_env_file}; +use common::create_test_env_file; #[test] fn list_command_with_file() { @@ -29,4 +29,4 @@ API_KEY=secret let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("database_block")); assert!(stdout.contains("api_block")); -} \ No newline at end of file +} diff --git a/tests/pick_tests.rs b/tests/pick_tests.rs index bb8bbd3..1267fd3 100644 --- a/tests/pick_tests.rs +++ b/tests/pick_tests.rs @@ -61,4 +61,4 @@ VAR=test let _stderr = String::from_utf8_lossy(&output.stderr); assert!(_stderr.contains("was not found")); -} \ No newline at end of file +} From f15852980c6627f82d3f77c5dae12b3ffa4eca3d Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 00:22:32 +0200 Subject: [PATCH 07/16] docs: document tags, --tag picking and __encrypted__ blocks --- README.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f48f9fa..7fbd73c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -53,6 +54,7 @@ Commands: lint Check for syntax and linting errors format Pretty-format the file pick 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. @@ -139,6 +141,63 @@ 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] +``` + +When several blocks share a name, narrow `pick` with `--tag` (repeatable): + +```bash +envmn pick local --tag db .env # picks 'local [db]' +envmn pick local .env # error: ambiguous, lists the candidates +``` + +A bare `pick ` still works whenever exactly one block matches the name. + +### Reserved tags + +Tags of the form `__name__` are reserved for special meanings. The only one recognized today is +`__encrypted__`: it marks a block whose body is ciphertext rather than `KEY=VALUE` pairs. +`envmn` preserves the body of such blocks verbatim (no parsing, linting, or reformatting of its lines): + +```bash +#@ local [smtp, __encrypted__] +08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136 +eeda8bcff5d676460d8ea84bd0e941e4bf5ac466aa21e2512cc1a870e89fb17d +## +``` + +Encryption and decryption themselves are not implemented yet. + +--- + ## Other Commands ### Lint @@ -179,7 +238,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 From 9b6bb424edac13721c72ffaafcd02eb5f8f1e5e7 Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 10:53:37 +0200 Subject: [PATCH 08/16] feat(parser): add resource tag helpers for block grouping --- src/parser/tokens/block.rs | 42 ++++++++++++++++++++++++++ src/parser/validators/mod.rs | 2 +- src/parser/validators/tag_validator.rs | 11 ++++--- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/parser/tokens/block.rs b/src/parser/tokens/block.rs index c186ab4..a77e915 100644 --- a/src/parser/tokens/block.rs +++ b/src/parser/tokens/block.rs @@ -4,6 +4,7 @@ use crate::parser::constants::{ }; use crate::parser::tokens::line::Line; use crate::parser::tokens::variable::Variable; +use crate::parser::validators::is_reserved_tag; use indexmap::IndexSet; use std::fmt::{Display, Formatter}; use std::hash::{Hash, Hasher}; @@ -56,6 +57,13 @@ impl Block { pub fn is_encrypted(&self) -> bool { self.tags.contains(ENCRYPTED_BLOCK_TAG) } + pub fn resource_tags(&self) -> impl Iterator { + self.tags.iter().filter(|tag| !is_reserved_tag(tag)) + } + pub fn shares_resource_tag(&self, other: &Block) -> bool { + self.resource_tags() + .any(|tag| other.tags.contains(tag.as_str())) + } pub fn identifier(&self) -> String { if self.tags.is_empty() { self.name.clone() @@ -222,6 +230,40 @@ mod tests { assert!(encrypted.is_encrypted()); } + #[test] + fn resource_tags_exclude_reserved() { + let block = Block::new_with_tags( + "test", + vec!["db".to_string(), ENCRYPTED_BLOCK_TAG.to_string()], + ); + let resources: Vec<&String> = block.resource_tags().collect(); + assert_eq!(resources, vec!["db"]); + } + + #[test] + fn shares_resource_tag() { + let db = Block::new_with_tags("local", vec!["db".to_string()]); + let db_cache = + Block::new_with_tags("remote", vec!["db".to_string(), "cache".to_string()]); + let smtp = Block::new_with_tags("local", vec!["smtp".to_string()]); + assert!(db.shares_resource_tag(&db_cache)); + assert!(db_cache.shares_resource_tag(&db)); + assert!(!db.shares_resource_tag(&smtp)); + } + + #[test] + fn reserved_tags_are_not_shared_resources() { + let a = Block::new_with_tags( + "local", + vec!["db".to_string(), ENCRYPTED_BLOCK_TAG.to_string()], + ); + let b = Block::new_with_tags( + "remote", + vec!["smtp".to_string(), ENCRYPTED_BLOCK_TAG.to_string()], + ); + assert!(!a.shares_resource_tag(&b)); + } + #[test] fn identifier_with_and_without_tags() { let untagged = Block::new("test"); diff --git a/src/parser/validators/mod.rs b/src/parser/validators/mod.rs index 9ef9df1..1ffed65 100644 --- a/src/parser/validators/mod.rs +++ b/src/parser/validators/mod.rs @@ -3,5 +3,5 @@ mod tag_validator; mod variable_name_validator; pub use block_name_validator::validate_block_name; -pub use tag_validator::validate_tag; +pub use tag_validator::{is_reserved_tag, validate_tag}; pub use variable_name_validator::validate_variable_name; diff --git a/src/parser/validators/tag_validator.rs b/src/parser/validators/tag_validator.rs index 6c883cf..c2e47ad 100644 --- a/src/parser/validators/tag_validator.rs +++ b/src/parser/validators/tag_validator.rs @@ -3,6 +3,11 @@ use crate::parser::constants::ENCRYPTED_BLOCK_TAG; const RESERVED_TAGS: [&str; 1] = [ENCRYPTED_BLOCK_TAG]; +/// Dunder-form tags (`__x__`) carry special meaning instead of naming a resource +pub fn is_reserved_tag(tag: &str) -> bool { + tag.len() > 4 && tag.starts_with("__") && tag.ends_with("__") +} + pub fn validate_tag(line: u16, tag: &str) -> Result<(), Error> { if tag.is_empty() { return Err(Error::NamingError(NamingErrors::TagNameEmpty)); @@ -28,11 +33,7 @@ pub fn validate_tag(line: u16, tag: &str) -> Result<(), Error> { } // Dunder form (__x__) is reserved for special tags - if tag.len() > 4 - && tag.starts_with("__") - && tag.ends_with("__") - && !RESERVED_TAGS.contains(&tag) - { + if is_reserved_tag(tag) && !RESERVED_TAGS.contains(&tag) { return Err(Error::NamingError(NamingErrors::UnknownReservedTag( line, tag.to_string(), From 8260f705e92266db22d7db83b97b495bbe44c16f Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 10:54:02 +0200 Subject: [PATCH 09/16] feat(pick): slide picked blocks after their tag group and pick all name matches --- src/error/access.rs | 12 --- src/parser/tokens/document.rs | 188 ++++++++++++++++++++++++++-------- tests/tags_tests.rs | 72 ++++++++++--- 3 files changed, 202 insertions(+), 70 deletions(-) diff --git a/src/error/access.rs b/src/error/access.rs index cde194d..336a0cd 100644 --- a/src/error/access.rs +++ b/src/error/access.rs @@ -4,7 +4,6 @@ use std::fmt::{Display, Formatter}; pub enum AccessErrors { FileError(String, String), BlockNotFound(String), - AmbiguousBlock(String, Vec), DefaultBlockNotMovable, } @@ -17,17 +16,6 @@ impl Display for AccessErrors { AccessErrors::BlockNotFound(block_name) => { write!(f, "Block '{block_name}' was not found") } - AccessErrors::AmbiguousBlock(block_name, candidates) => { - write!( - f, - "Multiple blocks named '{block_name}' match; narrow with --tag:\n{}", - candidates - .iter() - .map(|c| format!(" - {c}")) - .collect::>() - .join("\n") - ) - } AccessErrors::DefaultBlockNotMovable => { write!(f, "default block is not movable") } diff --git a/src/parser/tokens/document.rs b/src/parser/tokens/document.rs index 2e7fc12..6e85a72 100644 --- a/src/parser/tokens/document.rs +++ b/src/parser/tokens/document.rs @@ -58,27 +58,44 @@ impl Document { if name == DEFAULT_BLOCK_NAME { return Err(Error::AccessError(AccessErrors::DefaultBlockNotMovable)); } - match self.find_indices(name, tags).as_slice() { - [] => { - let query = if tags.is_empty() { - name.to_string() - } else { - format!("{} [{}]", name, tags.join(", ")) - }; - Err(Error::AccessError(AccessErrors::BlockNotFound(query))) - } - [index] => { - self.blocks.move_index(*index, self.blocks.len() - 1); - Ok(self) + let indices = self.find_indices(name, tags); + if indices.is_empty() { + let query = if tags.is_empty() { + name.to_string() + } else { + format!("{} [{}]", name, tags.join(", ")) + }; + return Err(Error::AccessError(AccessErrors::BlockNotFound(query))); + } + // Process in file order so picked blocks keep their relative order + let picked: Vec = indices.iter().map(|&i| self.blocks[i].clone()).collect(); + for block in &picked { + let from = self + .blocks + .get_index_of(block) + .expect("picked block is in the document"); + let to = self.destination_index(from); + if to > from { + self.blocks.move_index(from, to); } - indices => Err(Error::AccessError(AccessErrors::AmbiguousBlock( - name.to_string(), - indices - .iter() - .map(|&index| self.blocks[index].identifier()) - .collect(), - ))), } + Ok(self) + } + + /// Where a picked block becomes active: right after the last block sharing + /// any of its resource tags, or the end of the file for untagged blocks + fn destination_index(&self, from: usize) -> usize { + let block = &self.blocks[from]; + if block.resource_tags().next().is_none() { + return self.blocks.len() - 1; + } + self.blocks + .iter() + .enumerate() + .filter(|(_, other)| other.shares_resource_tag(block)) + .map(|(index, _)| index) + .max() + .unwrap_or(from) } } @@ -223,49 +240,138 @@ mod tests { assert_eq!(doc.blocks.last().unwrap().name, "test"); } + fn tagged(name: &str, tags: &[&str]) -> Block { + Block::new_with_tags(name, tags.iter().map(ToString::to_string).collect()) + } + + fn names(doc: &Document) -> Vec<&str> { + doc.blocks.iter().map(|b| b.name.as_str()).collect() + } + #[test] - fn pick_unique_tagged_block_by_name_only() { + fn pick_tagged_block_slides_after_its_group() { + // local[db], other, remote[db], server — picking local[db] must land + // right after remote[db], not at the bottom let mut doc = Document::new(); - doc.add_block(Block::new_with_tags("test", vec!["db".to_string()])) - .unwrap(); - doc.add_block(Block::new("test1")).unwrap(); - doc.pick("test", &[]).unwrap(); - assert_eq!(doc.blocks.last().unwrap().name, "test"); + doc.add_block(tagged("local", &["db"])).unwrap(); + doc.add_block(Block::new("other")).unwrap(); + doc.add_block(tagged("remote", &["db"])).unwrap(); + doc.add_block(Block::new("server")).unwrap(); + doc.pick("local", &[]).unwrap(); + assert_eq!( + names(&doc), + vec![DEFAULT_BLOCK_NAME, "other", "remote", "local", "server"] + ); + } + + #[test] + fn pick_already_active_tagged_block_is_noop() { + let mut doc = Document::new(); + doc.add_block(tagged("local", &["db"])).unwrap(); + doc.add_block(tagged("remote", &["db"])).unwrap(); + doc.add_block(Block::new("server")).unwrap(); + doc.pick("remote", &[]).unwrap(); + assert_eq!( + names(&doc), + vec![DEFAULT_BLOCK_NAME, "local", "remote", "server"] + ); } #[test] fn pick_block_by_tag() { let mut doc = Document::new(); - doc.add_block(Block::new_with_tags("test", vec!["db".to_string()])) - .unwrap(); - doc.add_block(Block::new_with_tags("test", vec!["smtp".to_string()])) - .unwrap(); - doc.pick("test", &["db".to_string()]).unwrap(); + doc.add_block(tagged("local", &["db"])).unwrap(); + doc.add_block(tagged("local", &["smtp"])).unwrap(); + doc.add_block(tagged("remote", &["db"])).unwrap(); + doc.pick("local", &["db".to_string()]).unwrap(); let last = doc.blocks.last().unwrap(); - assert_eq!(last.name, "test"); + assert_eq!(last.name, "local"); assert!(last.tags.contains("db")); + // the smtp block stayed ahead of the db group + assert!(doc.blocks[1].tags.contains("smtp")); } #[test] - fn pick_ambiguous_block_fails() { + fn pick_by_name_picks_all_matches() { + // remote[db] and remote[smtp] both slide after their groups let mut doc = Document::new(); - doc.add_block(Block::new_with_tags("test", vec!["db".to_string()])) + doc.add_block(tagged("remote", &["db"])).unwrap(); + doc.add_block(tagged("remote", &["smtp"])).unwrap(); + doc.add_block(tagged("local", &["db"])).unwrap(); + doc.add_block(tagged("local", &["smtp"])).unwrap(); + doc.pick("remote", &[]).unwrap(); + let identifiers: Vec = doc.blocks.iter().map(|b| b.identifier()).collect(); + assert_eq!( + identifiers, + vec![ + DEFAULT_BLOCK_NAME.to_string(), + "local [db]".to_string(), + "remote [db]".to_string(), + "local [smtp]".to_string(), + "remote [smtp]".to_string(), + ] + ); + } + + #[test] + fn pick_multi_tag_block_slides_after_union_of_groups() { + // remote[db, cache] must land after both local[db] and other[cache] + let mut doc = Document::new(); + doc.add_block(tagged("remote", &["db", "cache"])).unwrap(); + doc.add_block(tagged("local", &["db"])).unwrap(); + doc.add_block(tagged("other", &["cache"])).unwrap(); + doc.add_block(Block::new("server")).unwrap(); + doc.pick("remote", &[]).unwrap(); + assert_eq!( + names(&doc), + vec![DEFAULT_BLOCK_NAME, "local", "other", "remote", "server"] + ); + } + + #[test] + fn pick_single_tag_block_leaves_multi_tag_other_groups_alone() { + // picking local[db] past remote[db, cache] must not change the + // active cache block + let mut doc = Document::new(); + doc.add_block(tagged("local", &["db"])).unwrap(); + doc.add_block(tagged("other", &["cache"])).unwrap(); + doc.add_block(tagged("remote", &["db", "cache"])).unwrap(); + doc.pick("local", &[]).unwrap(); + assert_eq!( + names(&doc), + vec![DEFAULT_BLOCK_NAME, "other", "remote", "local"] + ); + } + + #[test] + fn reserved_tags_do_not_group_blocks() { + // both blocks are __encrypted__ but resource tags differ, so + // picking one must not slide it after the other + let mut doc = Document::new(); + doc.add_block(tagged("local", &["db", "__encrypted__"])) .unwrap(); - doc.add_block(Block::new_with_tags("test", vec!["smtp".to_string()])) + doc.add_block(tagged("local", &["smtp", "__encrypted__"])) .unwrap(); - let error = doc.pick("test", &[]).unwrap_err(); - assert!(matches!( - error, - Error::AccessError(AccessErrors::AmbiguousBlock(_, _)) - )); + doc.pick("local", &["db".to_string()]).unwrap(); + assert!(doc.blocks[1].tags.contains("db")); + assert!(doc.blocks[2].tags.contains("smtp")); + } + + #[test] + fn pick_untagged_block_moves_to_bottom() { + let mut doc = Document::new(); + doc.add_block(Block::new("test")).unwrap(); + doc.add_block(Block::new("test1")).unwrap(); + doc.add_block(Block::new("test2")).unwrap(); + doc.pick("test", &[]).unwrap(); + assert_eq!(doc.blocks.last().unwrap().name, "test"); } #[test] #[should_panic] fn pick_non_existing_tag_fails() { let mut doc = Document::new(); - doc.add_block(Block::new_with_tags("test", vec!["db".to_string()])) - .unwrap(); + doc.add_block(tagged("test", &["db"])).unwrap(); doc.pick("test", &["nope".to_string()]).unwrap(); } } diff --git a/tests/tags_tests.rs b/tests/tags_tests.rs index 7d29f33..c11f2c9 100644 --- a/tests/tags_tests.rs +++ b/tests/tags_tests.rs @@ -45,14 +45,14 @@ fn list_shows_tags() { } #[test] -fn pick_with_tag_selects_matching_block() { +fn pick_with_tag_slides_block_after_its_group() { let temp_file = create_test_env_file(TAGGED_CONTENT); let output = Command::new(get_binary_path()) .arg("pick") .arg("local") .arg("--tag") - .arg("smtp") + .arg("db") .arg(temp_file.path()) .output() .expect("Failed to execute command"); @@ -63,18 +63,21 @@ fn pick_with_tag_selects_matching_block() { String::from_utf8_lossy(&output.stderr) ); let content = fs::read_to_string(temp_file.path()).unwrap(); - let smtp_pos = content.find("#@ local [smtp, __encrypted__]").unwrap(); - let db_pos = content.find("#@ local [db]").unwrap(); + let local_db_pos = content.find("#@ local [db]").unwrap(); + let remote_db_pos = content.find("#@ remote [db]").unwrap(); let server_pos = content.find("#@ server").unwrap(); - assert!(smtp_pos > db_pos, "picked block should move to the bottom"); assert!( - smtp_pos > server_pos, - "picked block should move to the bottom" + local_db_pos > remote_db_pos, + "picked block should slide after its group" + ); + assert!( + local_db_pos < server_pos, + "picked block should stay with its group, not move to the bottom" ); } #[test] -fn pick_ambiguous_block_errors_with_candidates() { +fn pick_by_name_switches_whole_environment() { let temp_file = create_test_env_file(TAGGED_CONTENT); let output = Command::new(get_binary_path()) @@ -84,11 +87,21 @@ fn pick_ambiguous_block_errors_with_candidates() { .output() .expect("Failed to execute command"); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("--tag")); - assert!(stderr.contains("local [db]")); - assert!(stderr.contains("local [smtp, __encrypted__]")); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let content = fs::read_to_string(temp_file.path()).unwrap(); + let local_db_pos = content.find("#@ local [db]").unwrap(); + let remote_db_pos = content.find("#@ remote [db]").unwrap(); + let server_pos = content.find("#@ server").unwrap(); + let local_smtp_pos = content.find("#@ local [smtp, __encrypted__]").unwrap(); + // local [db] became active within its group, without leaving it + assert!(local_db_pos > remote_db_pos); + assert!(local_db_pos < server_pos); + // local [smtp] was already the only smtp block, so it stayed last + assert!(local_smtp_pos > server_pos); } #[test] @@ -110,7 +123,7 @@ fn pick_with_unknown_tag_errors() { } #[test] -fn pick_unique_tagged_block_by_name_only() { +fn pick_already_active_block_is_noop() { let temp_file = create_test_env_file(TAGGED_CONTENT); let output = Command::new(get_binary_path()) @@ -120,17 +133,42 @@ fn pick_unique_tagged_block_by_name_only() { .output() .expect("Failed to execute command"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + // remote [db] is already the last block of its group, so order is unchanged + let content = fs::read_to_string(temp_file.path()).unwrap(); + let local_db_pos = content.find("#@ local [db]").unwrap(); + let remote_db_pos = content.find("#@ remote [db]").unwrap(); + let server_pos = content.find("#@ server").unwrap(); + assert!(local_db_pos < remote_db_pos); + assert!(remote_db_pos < server_pos); +} + +#[test] +fn pick_untagged_block_moves_to_bottom() { + let temp_file = create_test_env_file(TAGGED_CONTENT); + + let output = Command::new(get_binary_path()) + .arg("pick") + .arg("server") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + assert!( output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr) ); let content = fs::read_to_string(temp_file.path()).unwrap(); - let remote_pos = content.find("#@ remote [db]").unwrap(); let server_pos = content.find("#@ server").unwrap(); + let local_smtp_pos = content.find("#@ local [smtp, __encrypted__]").unwrap(); assert!( - remote_pos > server_pos, - "picked block should move to the bottom" + server_pos > local_smtp_pos, + "untagged blocks keep legacy move-to-bottom behavior" ); } From 665a24fa7b33199a60c8354fab944a99bb68a2ec Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 10:54:16 +0200 Subject: [PATCH 10/16] docs: document slide-on-pick semantics and recommended single-tag layout --- README.md | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7fbd73c..ba84713 100644 --- a/README.md +++ b/README.md @@ -172,14 +172,34 @@ Blocks (4): - remote [db] ``` -When several blocks share a name, narrow `pick` with `--tag` (repeatable): +### 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 local --tag db .env # picks 'local [db]' -envmn pick local .env # error: ambiguous, lists the candidates +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 ` still works whenever exactly one block matches the name. +A bare `pick ` 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. + +### 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 From 1fc91a6513f4e01a4be7994cc4926e4c665ff3cd Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 13:38:44 +0200 Subject: [PATCH 11/16] feat(pick): report active block changes per tag on stderr --- src/parser/engine/pick.rs | 24 ++++++++++++++++++++++++ src/parser/tokens/document.rs | 32 +++++++++++++++++++++++++++++++- tests/tags_tests.rs | 22 ++++++++++++++++++---- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/parser/engine/pick.rs b/src/parser/engine/pick.rs index 8983aae..f1d7529 100644 --- a/src/parser/engine/pick.rs +++ b/src/parser/engine/pick.rs @@ -6,8 +6,32 @@ use std::process::exit; impl Engine { pub fn process_pick_cmd(mut self, block_name: String, tags: Vec) { + let active_before = self.document.active_by_tag(); + let picked_tagged_block = self + .document + .find_indices(block_name.as_str(), &tags) + .iter() + .any(|&index| { + self.document.get_blocks()[index] + .resource_tags() + .next() + .is_some() + }); match self.document.pick(block_name.as_str(), &tags) { Ok(document) => { + let mut changed = false; + for (tag, active) in document.active_by_tag() { + match active_before.get(&tag) { + Some(previous) if *previous != active => { + eprintln!("{tag}: '{active}' now active (was '{previous}')"); + changed = true; + } + _ => (), + } + } + if !changed && picked_tagged_block { + eprintln!("'{block_name}' is already active"); + } let Some(input) = &self.cli.input else { eprintln!("{}", CliErrors::NoInputFound); exit(1); diff --git a/src/parser/tokens/document.rs b/src/parser/tokens/document.rs index 6e85a72..d82438d 100644 --- a/src/parser/tokens/document.rs +++ b/src/parser/tokens/document.rs @@ -1,8 +1,8 @@ use crate::error::{AccessErrors, Error, ParsingErrors}; use crate::parser::constants::DEFAULT_BLOCK_NAME; use crate::parser::tokens::block::Block; -use indexmap::IndexSet; use indexmap::set::MutableValues; +use indexmap::{IndexMap, IndexSet}; use std::fmt::{Display, Formatter}; #[derive(Clone, Debug, PartialEq, Eq)] @@ -37,6 +37,16 @@ impl Document { pub fn get_blocks(&self) -> Vec<&Block> { self.blocks.iter().collect::>() } + /// The active block per resource tag: the last block carrying the tag wins + pub fn active_by_tag(&self) -> IndexMap { + let mut active = IndexMap::new(); + for block in &self.blocks { + for tag in block.resource_tags() { + active.insert(tag.clone(), block.identifier()); + } + } + active + } pub fn blocks_len(&self) -> usize { self.blocks.len() } @@ -199,6 +209,26 @@ mod tests { assert!(doc.find_indices("test", &["nope".to_string()]).is_empty()); } + #[test] + fn active_by_tag_is_last_block_per_tag() { + let mut doc = Document::new(); + doc.add_block(Block::new_with_tags("local", vec!["db".to_string()])) + .unwrap(); + doc.add_block(Block::new_with_tags("remote", vec!["db".to_string()])) + .unwrap(); + doc.add_block(Block::new_with_tags( + "local", + vec!["smtp".to_string(), "__encrypted__".to_string()], + )) + .unwrap(); + doc.add_block(Block::new("server")).unwrap(); + let active = doc.active_by_tag(); + assert_eq!(active.get("db").unwrap(), "remote [db]"); + assert_eq!(active.get("smtp").unwrap(), "local [smtp, __encrypted__]"); + assert!(active.get("__encrypted__").is_none()); + assert_eq!(active.len(), 2); + } + #[test] fn same_name_blocks_with_different_tags_coexist() { let mut doc = Document::new(); diff --git a/tests/tags_tests.rs b/tests/tags_tests.rs index c11f2c9..02eece4 100644 --- a/tests/tags_tests.rs +++ b/tests/tags_tests.rs @@ -21,8 +21,8 @@ SERVER_PORT="8080" ## #@ local [smtp, __encrypted__] -08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136 -eeda8bcff5d676460d8ea84bd0e941e4bf5ac466aa21e2512cc1a870e89fb17d +MAILGUN_API_KEY=08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136 +MAILGUN_DOMAIN=eeda8bcff5d676460d8ea84bd0e941e4bf5ac466aa21e2512cc1a870e89fb17d ## "#; @@ -74,6 +74,8 @@ fn pick_with_tag_slides_block_after_its_group() { local_db_pos < server_pos, "picked block should stay with its group, not move to the bottom" ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("db: 'local [db]' now active (was 'remote [db]')")); } #[test] @@ -102,6 +104,10 @@ fn pick_by_name_switches_whole_environment() { assert!(local_db_pos < server_pos); // local [smtp] was already the only smtp block, so it stayed last assert!(local_smtp_pos > server_pos); + // only the tag whose active block changed is reported + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("db: 'local [db]' now active (was 'remote [db]')")); + assert!(!stderr.contains("smtp:")); } #[test] @@ -145,6 +151,8 @@ fn pick_already_active_block_is_noop() { let server_pos = content.find("#@ server").unwrap(); assert!(local_db_pos < remote_db_pos); assert!(remote_db_pos < server_pos); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("'remote' is already active")); } #[test] @@ -170,6 +178,8 @@ fn pick_untagged_block_moves_to_bottom() { server_pos > local_smtp_pos, "untagged blocks keep legacy move-to-bottom behavior" ); + // untagged picks keep the classic silent behavior + assert!(output.stderr.is_empty()); } #[test] @@ -189,8 +199,12 @@ fn format_round_trips_encrypted_block() { ); let content = fs::read_to_string(temp_file.path()).unwrap(); assert!(content.contains("#@ local [smtp, __encrypted__]")); - assert!(content.contains("08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136")); - assert!(content.contains("eeda8bcff5d676460d8ea84bd0e941e4bf5ac466aa21e2512cc1a870e89fb17d")); + assert!(content.contains( + "MAILGUN_API_KEY=08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136" + )); + assert!(content.contains( + "MAILGUN_DOMAIN=eeda8bcff5d676460d8ea84bd0e941e4bf5ac466aa21e2512cc1a870e89fb17d" + )); } #[test] From 7b4919d7cce46818131ee3bbeb45092f2a65b5ef Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 13:39:23 +0200 Subject: [PATCH 12/16] refactor(parser): parse __encrypted__ blocks as plain keys with ciphertext values --- src/parser/parser.rs | 8 ----- src/parser/tokens/block.rs | 60 +++++--------------------------------- src/parser/tokens/line.rs | 15 ---------- 3 files changed, 8 insertions(+), 75 deletions(-) diff --git a/src/parser/parser.rs b/src/parser/parser.rs index 2449aa8..a92caa4 100644 --- a/src/parser/parser.rs +++ b/src/parser/parser.rs @@ -81,14 +81,6 @@ impl Parser { } }; self.document.add_block(block)?; - } else if self - .current_block - .as_ref() - .is_some_and(|block| block.is_encrypted()) - { - if line.trim().len() > 0 { - self.get_working_block_mut()?.add_raw(line); - } } else if line.starts_with(constants::COMMENT_SYMBOL) { let comment = line .trim_start_matches(constants::COMMENT_SYMBOL) diff --git a/src/parser/tokens/block.rs b/src/parser/tokens/block.rs index a77e915..b9d247d 100644 --- a/src/parser/tokens/block.rs +++ b/src/parser/tokens/block.rs @@ -1,7 +1,5 @@ use crate::error::{Error, ParsingErrors}; -use crate::parser::constants::{ - BLOCK_END_SYMBOL, BLOCK_START_SYMBOL, DEFAULT_BLOCK_NAME, ENCRYPTED_BLOCK_TAG, -}; +use crate::parser::constants::{BLOCK_END_SYMBOL, BLOCK_START_SYMBOL, DEFAULT_BLOCK_NAME}; use crate::parser::tokens::line::Line; use crate::parser::tokens::variable::Variable; use crate::parser::validators::is_reserved_tag; @@ -51,15 +49,15 @@ impl Block { pub fn add_comment(&mut self, comment: &str) { self.lines.insert(Line::Comment(comment.to_string())); } - pub fn add_raw(&mut self, line: &str) { - self.lines.insert(Line::Raw(line.to_string())); - } - pub fn is_encrypted(&self) -> bool { - self.tags.contains(ENCRYPTED_BLOCK_TAG) - } pub fn resource_tags(&self) -> impl Iterator { self.tags.iter().filter(|tag| !is_reserved_tag(tag)) } + pub fn variable_keys(&self) -> impl Iterator { + self.lines.iter().filter_map(|line| match line { + Line::Variable(variable) => Some(&variable.key), + _ => None, + }) + } pub fn shares_resource_tag(&self, other: &Block) -> bool { self.resource_tags() .any(|tag| other.tags.contains(tag.as_str())) @@ -173,27 +171,12 @@ mod tests { block.add_variable(Variable::new("KEY", "value")).unwrap(); block.add_variable(Variable::new("KEY", "value")).unwrap(); } - - #[test] - fn add_raw_line() { - let mut block = Block::new("test"); - block.add_raw("08debe3d42ade916"); - assert_eq!(block.lines.len(), 1); - assert!(matches!(block.lines.first().unwrap(), Line::Raw(_))); - } - - #[test] - fn add_same_raw_line() { - let mut block = Block::new("test"); - block.add_raw("08debe3d42ade916"); - block.add_raw("08debe3d42ade916"); - assert_eq!(block.lines.len(), 2); - } } #[cfg(test)] mod tags { use super::*; + use crate::parser::constants::ENCRYPTED_BLOCK_TAG; #[test] fn new_with_tags_stores_tags() { @@ -219,17 +202,6 @@ mod tests { assert_eq!(block1, block2); } - #[test] - fn is_encrypted() { - let plain = Block::new_with_tags("test", vec!["db".to_string()]); - let encrypted = Block::new_with_tags( - "test", - vec!["db".to_string(), ENCRYPTED_BLOCK_TAG.to_string()], - ); - assert!(!plain.is_encrypted()); - assert!(encrypted.is_encrypted()); - } - #[test] fn resource_tags_exclude_reserved() { let block = Block::new_with_tags( @@ -303,22 +275,6 @@ mod tests { ); } - #[test] - fn encrypted_block_displays_raw_lines_verbatim() { - let mut block = Block::new_with_tags( - "test", - vec!["smtp".to_string(), ENCRYPTED_BLOCK_TAG.to_string()], - ); - block.add_raw("08debe3d42ade916"); - block.add_raw("eeda8bcff5d67646"); - assert_eq!( - block.to_string(), - format!( - "{BLOCK_START_SYMBOL} test [smtp, {ENCRYPTED_BLOCK_TAG}]\n08debe3d42ade916\needa8bcff5d67646\n{BLOCK_END_SYMBOL}" - ) - ); - } - #[test] fn block_with_variables() { let variable = Variable::new("KEY", "value"); diff --git a/src/parser/tokens/line.rs b/src/parser/tokens/line.rs index 41bf67d..f950614 100644 --- a/src/parser/tokens/line.rs +++ b/src/parser/tokens/line.rs @@ -5,7 +5,6 @@ use std::fmt::{Display, Formatter}; pub enum Line { Comment(String), Variable(Variable), - Raw(String), } impl Display for Line { @@ -13,7 +12,6 @@ impl Display for Line { match self { Line::Comment(comment) => write!(f, "# {comment}"), Line::Variable(variable) => write!(f, "{variable}"), - Line::Raw(raw) => write!(f, "{raw}"), } } } @@ -46,17 +44,4 @@ mod tests { let line2 = Line::Comment("comment".to_string()); assert_ne!(line1, line2); } - - #[test] - fn line_inequality_by_raw() { - let line1 = Line::Raw("08debe3d42ade916".to_string()); - let line2 = Line::Raw("08debe3d42ade916".to_string()); - assert_ne!(line1, line2); - } - - #[test] - fn raw_line_displays_verbatim() { - let line = Line::Raw("08debe3d42ade916".to_string()); - assert_eq!(line.to_string(), "08debe3d42ade916"); - } } From 856f9e8ad254e2f4e408350e1f0d4e5fdc947248 Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 13:39:49 +0200 Subject: [PATCH 13/16] feat(lint): warn on variable asymmetry within tag groups --- src/parser/engine/lint.rs | 140 ++++++++++++++++++++++++++++++++++++++ src/parser/engine/mod.rs | 3 +- tests/lint_tests.rs | 76 +++++++++++++++++++++ 3 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 src/parser/engine/lint.rs diff --git a/src/parser/engine/lint.rs b/src/parser/engine/lint.rs new file mode 100644 index 0000000..9cdfe07 --- /dev/null +++ b/src/parser/engine/lint.rs @@ -0,0 +1,140 @@ +use crate::parser::engine::Engine; +use crate::parser::tokens::Block; +use indexmap::{IndexMap, IndexSet}; + +impl Engine { + pub fn process_lint_cmd(self) { + for warning in symmetry_warnings(&self.document.get_blocks()) { + eprintln!("warning: {warning}"); + } + } +} + +/// 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; multi-tag blocks are checked against the union of their tags. +fn symmetry_warnings(blocks: &[&Block]) -> Vec { + let mut warnings = Vec::new(); + + // Canonical variable set per tag, derived from single-tag blocks + let mut canonical: IndexMap<&String, IndexSet<&String>> = IndexMap::new(); + for block in blocks { + if let [tag] = block.resource_tags().collect::>().as_slice() { + canonical + .entry(tag) + .or_default() + .extend(block.variable_keys()); + } + } + + for block in blocks { + let tags: Vec<&String> = block.resource_tags().collect(); + if tags.is_empty() { + continue; + } + let keys: IndexSet<&String> = block.variable_keys().collect(); + let mut expected: IndexSet<&String> = IndexSet::new(); + let mut all_tags_derivable = true; + for tag in &tags { + match canonical.get(*tag) { + Some(tag_keys) => expected.extend(tag_keys.iter().copied()), + None => all_tags_derivable = false, + } + } + for key in &expected { + if !keys.contains(*key) { + warnings.push(format!( + "'{}' is missing variable '{key}' defined by its group", + block.identifier() + )); + } + } + // Extra keys are only decidable when every tag has a ground truth + if all_tags_derivable && tags.len() > 1 { + for key in &keys { + if !expected.contains(*key) { + warnings.push(format!( + "'{}' defines variable '{key}' that belongs to none of its tags", + block.identifier() + )); + } + } + } + } + + warnings +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::tokens::variable::Variable; + + fn block(name: &str, tags: &[&str], keys: &[&str]) -> Block { + let mut block = Block::new_with_tags(name, tags.iter().map(ToString::to_string).collect()); + for key in keys { + block.add_variable(Variable::new(key, "value")).unwrap(); + } + block + } + + #[test] + fn symmetric_group_has_no_warnings() { + let local = block("local", &["db"], &["DB_HOST", "DB_PORT"]); + let remote = block("remote", &["db"], &["DB_HOST", "DB_PORT"]); + assert!(symmetry_warnings(&[&local, &remote]).is_empty()); + } + + #[test] + fn missing_variable_in_group_is_warned() { + let local = block("local", &["db"], &["DB_HOST", "DB_PORT"]); + let remote = block("remote", &["db"], &["DB_HOST"]); + let warnings = symmetry_warnings(&[&local, &remote]); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("'remote [db]' is missing variable 'DB_PORT'")); + } + + #[test] + fn multi_tag_block_is_checked_against_union_of_its_tags() { + let local = block("local", &["db"], &["DB_HOST"]); + let other = block("other", &["cache"], &["CACHE_HOST"]); + let remote = block("remote", &["db", "cache"], &["DB_HOST", "FOO"]); + let warnings = symmetry_warnings(&[&local, &other, &remote]); + assert_eq!(warnings.len(), 2); + assert!( + warnings + .iter() + .any(|w| w.contains("'remote [db, cache]' is missing variable 'CACHE_HOST'")) + ); + assert!(warnings.iter().any(|w| w.contains( + "'remote [db, cache]' defines variable 'FOO' that belongs to none of its tags" + ))); + } + + #[test] + fn extra_keys_not_warned_when_a_tag_has_no_ground_truth() { + // no single-tag [cache] block exists, so FOO might belong to cache + let local = block("local", &["db"], &["DB_HOST"]); + let remote = block("remote", &["db", "cache"], &["DB_HOST", "FOO"]); + assert!(symmetry_warnings(&[&local, &remote]).is_empty()); + } + + #[test] + fn encrypted_blocks_are_checked_by_their_plain_keys() { + // values are ciphertext but keys stay plain, so symmetry still applies + let local = block("local", &["db"], &["DB_HOST", "DB_PASSWORD"]); + let encrypted = block("remote", &["db", "__encrypted__"], &["DB_HOST"]); + let warnings = symmetry_warnings(&[&local, &encrypted]); + assert_eq!(warnings.len(), 1); + assert!( + warnings[0].contains("'remote [db, __encrypted__]' is missing variable 'DB_PASSWORD'") + ); + } + + #[test] + fn untagged_blocks_are_ignored() { + let server = block("server", &[], &["SERVER_PORT"]); + let other = block("other", &[], &["OTHER"]); + assert!(symmetry_warnings(&[&server, &other]).is_empty()); + } +} diff --git a/src/parser/engine/mod.rs b/src/parser/engine/mod.rs index 21c1e0b..6dd2e2a 100644 --- a/src/parser/engine/mod.rs +++ b/src/parser/engine/mod.rs @@ -1,4 +1,5 @@ mod format; +mod lint; mod list; mod pick; mod version; @@ -18,7 +19,7 @@ impl Engine { } pub fn process(self) -> Result<(), Error> { match self.cli.command.clone() { - Commands::Lint => Ok(()), + Commands::Lint => Ok(self.process_lint_cmd()), Commands::List => Ok(self.process_list_cmd()), Commands::Format => Ok(self.process_format_cmd()), Commands::Pick { block_name, tags } => Ok(self.process_pick_cmd(block_name, tags)), diff --git a/tests/lint_tests.rs b/tests/lint_tests.rs index b874208..e427077 100644 --- a/tests/lint_tests.rs +++ b/tests/lint_tests.rs @@ -2,6 +2,82 @@ mod common; use common::create_test_env_file; +#[test] +fn lint_warns_on_asymmetric_group() { + let test_content = r#"#@ local [db] +DB_HOST=localhost +DB_PORT=5432 +## + +#@ remote [db] +DB_HOST=example.com +## +"#; + + let temp_file = create_test_env_file(test_content); + + let output = std::process::Command::new(common::get_binary_path()) + .arg("lint") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + // symmetry findings are advisory: warn on stderr, still exit 0 + assert!(output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("warning: 'remote [db]' is missing variable 'DB_PORT'")); +} + +#[test] +fn lint_symmetric_tagged_file_is_clean() { + let test_content = r#"#@ local [db] +DB_HOST=localhost +## + +#@ remote [db] +DB_HOST=example.com +## +"#; + + let temp_file = create_test_env_file(test_content); + + let output = std::process::Command::new(common::get_binary_path()) + .arg("lint") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); +} + +#[test] +fn lint_checks_encrypted_blocks_by_their_plain_keys() { + let test_content = r#"#@ local [db] +DB_HOST=localhost +DB_PASSWORD=hunter2 +## + +#@ remote [db, __encrypted__] +DB_HOST=08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136 +## +"#; + + let temp_file = create_test_env_file(test_content); + + let output = std::process::Command::new(common::get_binary_path()) + .arg("lint") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("warning: 'remote [db, __encrypted__]' is missing variable 'DB_PASSWORD'") + ); +} + #[test] fn lint_command_with_file() { let test_content = r#"KEY=value From 18d672d999306bad85eee57723e22ee57c2ca78e Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 13:40:05 +0200 Subject: [PATCH 14/16] docs: document pick stderr summary, symmetry lint and value-level encryption --- README.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ba84713..8beaf80 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,15 @@ envmn pick remote .env # picks ALL blocks named remote, each within it A bare `pick ` 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: @@ -204,17 +213,19 @@ Multiple tags on one block (`#@ remote [db, cache]`) are supported and mean "the ### Reserved tags Tags of the form `__name__` are reserved for special meanings. The only one recognized today is -`__encrypted__`: it marks a block whose body is ciphertext rather than `KEY=VALUE` pairs. -`envmn` preserves the body of such blocks verbatim (no parsing, linting, or reformatting of its lines): +`__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__] -08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136 -eeda8bcff5d676460d8ea84bd0e941e4bf5ac466aa21e2512cc1a870e89fb17d +MAILGUN_API_KEY=08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136 +MAILGUN_DOMAIN=eeda8bcff5d676460d8ea84bd0e941e4bf5ac466aa21e2512cc1a870e89fb17d ## ``` -Encryption and decryption themselves are not implemented yet. +Encryption and decryption themselves are not implemented yet; the tag currently documents that the +values are not usable as-is. --- @@ -228,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: From 588487497eca7d80d00ec14f919d930b983c6c03 Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 15:13:43 +0200 Subject: [PATCH 15/16] fix(parser): reject reversed brackets, trailing content and empty tag items in headers --- src/parser/parser.rs | 27 ++++++++++++---------- tests/tags_tests.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/parser/parser.rs b/src/parser/parser.rs index a92caa4..026e5d8 100644 --- a/src/parser/parser.rs +++ b/src/parser/parser.rs @@ -35,23 +35,26 @@ impl Parser { identifier.find(constants::TAGS_START_SYMBOL), identifier.find(constants::TAGS_END_SYMBOL), ); - let tags: Vec = match (start_tags_idx, end_tags_idx) { - (Some(start), Some(end)) => identifier[start + 1..end] - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(), - (None, None) => Vec::new(), + match (start_tags_idx, end_tags_idx) { + (Some(start), Some(end)) if start < end => { + if !identifier[end + 1..].trim().is_empty() { + return Err(Error::ParsingError(ParsingErrors::MalFormedTags( + idx as u16, + ))); + } + let tags = identifier[start + 1..end] + .split(',') + .map(|s| s.trim().to_string()) + .collect(); + (identifier[..start].trim(), tags) + } + (None, None) => (identifier, Vec::new()), _ => { return Err(Error::ParsingError(ParsingErrors::MalFormedTags( idx as u16, ))); } - }; - ( - identifier[..start_tags_idx.unwrap_or(identifier.len())].trim(), - tags, - ) + } } Some(Block { name, .. }) => { return Err(Error::ParsingError(ParsingErrors::NestedBlock( diff --git a/tests/tags_tests.rs b/tests/tags_tests.rs index 02eece4..f07fb89 100644 --- a/tests/tags_tests.rs +++ b/tests/tags_tests.rs @@ -222,6 +222,59 @@ fn malformed_tags_error() { assert!(stderr.to_lowercase().contains("tags")); } +#[test] +fn reversed_tag_brackets_error() { + // regression: this used to panic with a byte-range slice error + let temp_file = create_test_env_file("#@ local ]db[\nKEY=value\n##\n"); + + let output = Command::new(get_binary_path()) + .arg("lint") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Malformed tags")); +} + +#[test] +fn trailing_content_after_tags_error() { + // regression: content after ']' used to be silently discarded + let temp_file = create_test_env_file("#@ local [db] junk\nKEY=value\n##\n"); + + let output = Command::new(get_binary_path()) + .arg("lint") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Malformed tags")); +} + +#[test] +fn empty_tag_item_error() { + // regression: '[db,]' and '[]' used to be silently normalized + for content in [ + "#@ local [db,]\nKEY=value\n##\n", + "#@ local []\nKEY=value\n##\n", + ] { + let temp_file = create_test_env_file(content); + + let output = Command::new(get_binary_path()) + .arg("lint") + .arg(temp_file.path()) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Tag name can not be empty")); + } +} + #[test] fn invalid_tag_character_error() { let temp_file = create_test_env_file("#@ local [DB]\nKEY=value\n##\n"); From 70abfaa09d3d6ca8aa95e0057d91a1648b393313 Mon Sep 17 00:00:00 2001 From: devark28 Date: Sun, 5 Jul 2026 15:14:08 +0200 Subject: [PATCH 16/16] perf(pick): reuse the blocks vec instead of reallocating per matched block --- src/parser/engine/pick.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/parser/engine/pick.rs b/src/parser/engine/pick.rs index f1d7529..a941c68 100644 --- a/src/parser/engine/pick.rs +++ b/src/parser/engine/pick.rs @@ -7,16 +7,12 @@ use std::process::exit; impl Engine { pub fn process_pick_cmd(mut self, block_name: String, tags: Vec) { let active_before = self.document.active_by_tag(); + let blocks = self.document.get_blocks(); let picked_tagged_block = self .document .find_indices(block_name.as_str(), &tags) .iter() - .any(|&index| { - self.document.get_blocks()[index] - .resource_tags() - .next() - .is_some() - }); + .any(|&index| blocks[index].resource_tags().next().is_some()); match self.document.pick(block_name.as_str(), &tags) { Ok(document) => { let mut changed = false;