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/README.md b/README.md index f48f9fa..8beaf80 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,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 ` 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 @@ -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: @@ -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 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/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/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/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..1569eed 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 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__"; 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/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..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,10 +19,10 @@ 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 } => 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..a941c68 100644 --- a/src/parser/engine/pick.rs +++ b/src/parser/engine/pick.rs @@ -5,9 +5,29 @@ 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) { + 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| 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/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/src/parser/parser.rs b/src/parser/parser.rs index 76ff5e3..026e5d8 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; @@ -26,10 +26,36 @@ 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), + ); + 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, + ))); + } + } + } Some(Block { name, .. }) => { return Err(Error::ParsingError(ParsingErrors::NestedBlock( idx as u16, @@ -44,7 +70,10 @@ impl Parser { ))); } validate_block_name(idx as u16, name)?; - self.current_block = Some(Block::new(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() { Some(block) => block, diff --git a/src/parser/tokens/block.rs b/src/parser/tokens/block.rs index 85949ed..b9d247d 100644 --- a/src/parser/tokens/block.rs +++ b/src/parser/tokens/block.rs @@ -2,6 +2,7 @@ use crate::error::{Error, ParsingErrors}; 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; use indexmap::IndexSet; use std::fmt::{Display, Formatter}; use std::hash::{Hash, Hasher}; @@ -9,6 +10,7 @@ use std::hash::{Hash, Hasher}; #[derive(Clone, Debug, Eq)] pub struct Block { pub name: String, + pub tags: IndexSet, lines: IndexSet, } @@ -16,12 +18,22 @@ impl Block { pub fn default() -> Self { Block { name: DEFAULT_BLOCK_NAME.to_string(), + tags: IndexSet::new(), lines: IndexSet::new(), } } + #[allow(dead_code)] // used by tests 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(), } } @@ -37,6 +49,30 @@ impl Block { pub fn add_comment(&mut self, comment: &str) { self.lines.insert(Line::Comment(comment.to_string())); } + 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())) + } + 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 { @@ -57,7 +93,7 @@ impl Display for Block { "{0} {2}\n{3}\n{1}", BLOCK_START_SYMBOL, BLOCK_END_SYMBOL, - self.name, + self.identifier(), self.lines .iter() .map(ToString::to_string) @@ -70,13 +106,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 +127,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); @@ -133,6 +173,78 @@ mod tests { } } + #[cfg(test)] + mod tags { + use super::*; + use crate::parser::constants::ENCRYPTED_BLOCK_TAG; + + #[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 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"); + 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)] mod display { use super::*; @@ -152,6 +264,17 @@ 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 block_with_variables() { let variable = Variable::new("KEY", "value"); diff --git a/src/parser/tokens/document.rs b/src/parser/tokens/document.rs index d01a02c..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)] @@ -19,25 +19,34 @@ 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::>() } + /// 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() } @@ -55,19 +64,48 @@ 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); - 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); } } + 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) } } @@ -135,20 +173,70 @@ 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 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(); + 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 +266,144 @@ 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"); } + + 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_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(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(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, "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_by_name_picks_all_matches() { + // remote[db] and remote[smtp] both slide after their groups + let mut doc = Document::new(); + 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(tagged("local", &["smtp", "__encrypted__"])) + .unwrap(); + 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(tagged("test", &["db"])).unwrap(); + doc.pick("test", &["nope".to_string()]).unwrap(); + } } #[cfg(test)] diff --git a/src/parser/validators/mod.rs b/src/parser/validators/mod.rs index 1dcb9f0..1ffed65 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::{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 new file mode 100644 index 0000000..c2e47ad --- /dev/null +++ b/src/parser/validators/tag_validator.rs @@ -0,0 +1,89 @@ +use crate::error::{Error, NamingErrors}; +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)); + } + + 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 is_reserved_tag(tag) && !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(); + } +} 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..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 @@ -20,4 +96,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 +} diff --git a/tests/tags_tests.rs b/tests/tags_tests.rs new file mode 100644 index 0000000..f07fb89 --- /dev/null +++ b/tests/tags_tests.rs @@ -0,0 +1,306 @@ +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__] +MAILGUN_API_KEY=08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136 +MAILGUN_DOMAIN=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_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("db") + .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 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, + "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" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("db: 'local [db]' now active (was 'remote [db]')")); +} + +#[test] +fn pick_by_name_switches_whole_environment() { + 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(), + "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); + // 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] +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_already_active_block_is_noop() { + 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) + ); + // 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); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("'remote' is already active")); +} + +#[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 server_pos = content.find("#@ server").unwrap(); + let local_smtp_pos = content.find("#@ local [smtp, __encrypted__]").unwrap(); + assert!( + 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] +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( + "MAILGUN_API_KEY=08debe3d42ade91671f783a784fbed31dd3e897abae5439be07f885887217136" + )); + assert!(content.contains( + "MAILGUN_DOMAIN=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 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"); + + 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")); +}