Compare commits
7
Commits
481e3a1dcc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e97205c4bf | ||
|
|
6b6c031242 | ||
|
|
9f4a0dc446 | ||
|
|
3873987c19 | ||
|
|
36752606a6 | ||
|
|
72e0c229f3 | ||
|
|
12b8701e00 |
@@ -35,14 +35,14 @@ cargo install --path .
|
||||
## Usage
|
||||
|
||||
```
|
||||
dev_tools <project> <up|down> [--legacy]
|
||||
dev_tools <project> <up|down> [-v|--verbose]
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
|---|---|
|
||||
| `project` | Project key (matches `<PROJECT>_DIR` in the config, case-insensitive) |
|
||||
| `up` / `down` | Start or stop the environment |
|
||||
| `-l`, `--legacy` | Enable legacy mode: also toggles `conf/global/connections.env` and runs `git update-index` |
|
||||
| `-v`, `--verbose` | Print the actual before/after lines changed in the env files (values may include secrets, e.g. connection strings). Without it, only a line count is printed. |
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -53,15 +53,15 @@ dev_tools foo up
|
||||
# Stop it
|
||||
dev_tools foo down
|
||||
|
||||
# Start with legacy env handling
|
||||
dev_tools foo up --legacy
|
||||
# See exactly which lines were changed
|
||||
dev_tools foo up -v
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
1. Starts or stops the Docker Compose service defined by `DOCKER_SERVICE` / `DOCKER_DIR`.
|
||||
2. Toggles database connection entries in `<PROJECT_DIR>/conf/local/.env` between `staging` and CI values.
|
||||
3. With `--legacy`: additionally modifies `conf/global/connections.env` and marks it with `git update-index --assume-unchanged` to avoid accidental commits.
|
||||
2. `conf/global/connections.env`: `up` toggles it to the CI credentials and marks it `git update-index --assume-unchanged` to avoid accidental commits; `down` unmarks it and restores it to its committed state by reading the committed blob with `git cat-file` (rather than computing the reverse toggle or running `git checkout`, which would fire the repo's `post-checkout` hook), so it's guaranteed byte-for-byte identical to HEAD.
|
||||
3. Toggles database connection entries in `<PROJECT_DIR>/conf/local/.env`: `up` switches the connection to `ci`, `down` switches it to `staging`.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
+5
-5
@@ -15,11 +15,11 @@ pub struct Arguments {
|
||||
pub action: String,
|
||||
|
||||
#[arg(
|
||||
short = 'l',
|
||||
long = "legacy",
|
||||
help = "Legacy mode for old env reading."
|
||||
short,
|
||||
long,
|
||||
help = "Show which lines were changed (values may include secrets)"
|
||||
)]
|
||||
pub legacy: bool,
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
impl Arguments {
|
||||
@@ -40,7 +40,7 @@ mod tests {
|
||||
Arguments {
|
||||
project: "test".to_string(),
|
||||
action: action.to_string(),
|
||||
legacy: false,
|
||||
verbose: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+519
-61
@@ -1,7 +1,5 @@
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::{BufRead, BufReader},
|
||||
};
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
@@ -9,99 +7,215 @@ use crate::env::config::DevToolsConf;
|
||||
pub const GLOBAL_CONNECTION_PATH: &str = "conf/global/connections.env";
|
||||
const LOCAL_CONNECTION_PATH: &str = "conf/local/.env";
|
||||
|
||||
/// The lines a toggle operation rewrote in a given file, as (before, after) pairs.
|
||||
pub struct FileChange {
|
||||
pub path: String,
|
||||
pub diffs: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl FileChange {
|
||||
fn no_changes_message(&self) -> String {
|
||||
format!("[!] {}: no changes", self.path)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FileChange {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if self.diffs.is_empty() {
|
||||
return writeln!(f, "{}", self.no_changes_message());
|
||||
}
|
||||
writeln!(f, "[!] {} changed:", self.path)?;
|
||||
for (before, after) in &self.diffs {
|
||||
writeln!(f, " {} -> {}", before, after)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl FileChange {
|
||||
/// Line-count-only summary that never prints line contents, since those
|
||||
/// can contain secrets (e.g. connection strings). Use `Display` (gated
|
||||
/// behind `-v`/`--verbose` in `main`) to show the actual before/after lines.
|
||||
pub fn summary(&self) -> String {
|
||||
if self.diffs.is_empty() {
|
||||
return format!("{}\n", self.no_changes_message());
|
||||
}
|
||||
format!(
|
||||
"[!] {} changed: {} line(s) (use -v to show)\n",
|
||||
self.path,
|
||||
self.diffs.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// writes global connections
|
||||
pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<()> {
|
||||
pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<FileChange> {
|
||||
let global_connections_env = format!(
|
||||
"{}{}",
|
||||
config.project_dir_as_string(),
|
||||
GLOBAL_CONNECTION_PATH
|
||||
);
|
||||
let file: File = File::open(&global_connections_env)
|
||||
let content = fs::read_to_string(&global_connections_env)
|
||||
.with_context(|| format!("could not read file `{}`", global_connections_env))?;
|
||||
let reader: BufReader<File> = BufReader::new(file);
|
||||
let trailing_newline = content.ends_with('\n');
|
||||
|
||||
let mut modified_lines: Vec<String> = Vec::new();
|
||||
for line in reader.lines() {
|
||||
let line_content = line?;
|
||||
let modified = if start && connections_env_comment(&line_content) {
|
||||
format!("# {}", line_content)
|
||||
} else if !start && should_commented_out(&line_content) {
|
||||
line_content[2..].to_string()
|
||||
let mut diffs: Vec<(String, String)> = Vec::new();
|
||||
for line_content in content.lines() {
|
||||
let modified: Option<String> = if start && connections_env_comment(line_content) {
|
||||
Some(format!("# {}", line_content))
|
||||
} else if !start && should_commented_out(line_content) {
|
||||
Some(line_content[2..].to_string())
|
||||
} else {
|
||||
line_content
|
||||
None
|
||||
};
|
||||
modified_lines.push(modified);
|
||||
|
||||
match modified {
|
||||
Some(new_line) => {
|
||||
diffs.push((line_content.to_string(), new_line.clone()));
|
||||
modified_lines.push(new_line);
|
||||
}
|
||||
None => modified_lines.push(line_content.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(global_connections_env, modified_lines.join("\n"))
|
||||
.context("Cannot write global connections env")?;
|
||||
Ok(())
|
||||
let mut output = modified_lines.join("\n");
|
||||
if trailing_newline {
|
||||
output.push('\n');
|
||||
}
|
||||
fs::write(&global_connections_env, output).context("Cannot write global connections env")?;
|
||||
Ok(FileChange {
|
||||
path: global_connections_env,
|
||||
diffs,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_dot_env(start: bool, config: &DevToolsConf) -> Result<()> {
|
||||
toggle_after_line("#docker", 4, start, config)?;
|
||||
toggle_after_line("#cidb", 2, !start, config)?;
|
||||
Ok(())
|
||||
}
|
||||
const DOCKER_SECTION: &str = "#docker";
|
||||
const DOCKER_SECTION_LINES: usize = 4;
|
||||
const CIDB_SECTION: &str = "#cidb";
|
||||
const CIDB_SECTION_LINES: usize = 2;
|
||||
|
||||
pub(crate) fn connections_env_comment(line: &str) -> bool {
|
||||
line.starts_with("ci.db.master.ip=") || line.starts_with("ci.db.master.port=")
|
||||
}
|
||||
|
||||
pub(crate) fn should_commented_out(line: &str) -> bool {
|
||||
line.starts_with("# ci.db.master.ip=") || line.starts_with("# ci.db.master.port=")
|
||||
}
|
||||
|
||||
fn toggle_after_line(
|
||||
target_line_prefix: &str,
|
||||
num_lines: usize,
|
||||
activate: bool,
|
||||
config: &DevToolsConf,
|
||||
) -> Result<()> {
|
||||
/// Toggles the `#docker` and `#cidb` sections and the `connection` line of
|
||||
/// `conf/local/.env` in a single read/write pass. `#docker` follows `start`
|
||||
/// directly; `#cidb` and the connection line follow its inverse (`up` means
|
||||
/// the local docker db is reachable, so `#cidb`'s CI credentials switch off
|
||||
/// and the connection moves to `ci`; `down` is the reverse, ending on
|
||||
/// `staging`). Every line outside both sections is checked against
|
||||
/// `connection_key` exactly once, so it can't be toggled twice.
|
||||
pub fn set_dot_env(start: bool, config: &DevToolsConf) -> Result<FileChange> {
|
||||
let local_connection_env = format!(
|
||||
"{}{}",
|
||||
config.project_dir_as_string(),
|
||||
LOCAL_CONNECTION_PATH
|
||||
);
|
||||
let file: File = File::open(&local_connection_env)
|
||||
let content = fs::read_to_string(&local_connection_env)
|
||||
.with_context(|| format!("could not read file `{}`", local_connection_env))?;
|
||||
let reader: BufReader<File> = BufReader::new(file);
|
||||
let trailing_newline = content.ends_with('\n');
|
||||
|
||||
let mut modified_lines: Vec<String> = Vec::new();
|
||||
let mut relevant = false;
|
||||
let mut counter: usize = 0;
|
||||
let mut diffs: Vec<(String, String)> = Vec::new();
|
||||
let mut section: Option<(bool, usize)> = None;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line_content = line?;
|
||||
for line_content in content.lines() {
|
||||
let trimmed = line_content.trim_start();
|
||||
|
||||
if counter == num_lines {
|
||||
relevant = false;
|
||||
if let Some((_, remaining)) = section {
|
||||
if remaining == 0 {
|
||||
section = None;
|
||||
}
|
||||
if line_content.trim_start().starts_with(target_line_prefix) {
|
||||
relevant = true;
|
||||
modified_lines.push(line_content);
|
||||
}
|
||||
|
||||
if trimmed.starts_with(DOCKER_SECTION) {
|
||||
section = Some((start, DOCKER_SECTION_LINES));
|
||||
modified_lines.push(line_content.to_string());
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with(CIDB_SECTION) {
|
||||
section = Some((!start, CIDB_SECTION_LINES));
|
||||
modified_lines.push(line_content.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
if relevant {
|
||||
modified_lines.push(toggle(&line_content, activate));
|
||||
counter += 1;
|
||||
let modified: Option<String> = if let Some((activate, remaining)) = &mut section {
|
||||
let new_line = toggle(line_content, *activate);
|
||||
*remaining -= 1;
|
||||
(new_line != line_content).then_some(new_line)
|
||||
} else {
|
||||
if !activate && line_content.trim_start().starts_with("connection=") {
|
||||
modified_lines.push("connection='ci'".to_string());
|
||||
continue;
|
||||
} else if activate && line_content.trim_start().starts_with("connection=") {
|
||||
modified_lines.push("connection='staging'".to_string());
|
||||
continue;
|
||||
connection_key(line_content).and_then(|key| {
|
||||
let value = if start { "ci" } else { "staging" };
|
||||
let new_line = format!("{}='{}'", key, value);
|
||||
(new_line != line_content).then_some(new_line)
|
||||
})
|
||||
};
|
||||
|
||||
match modified {
|
||||
Some(new_line) => {
|
||||
diffs.push((line_content.to_string(), new_line.clone()));
|
||||
modified_lines.push(new_line);
|
||||
}
|
||||
modified_lines.push(line_content);
|
||||
None => modified_lines.push(line_content.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(local_connection_env, modified_lines.join("\n"))
|
||||
.context("Cannot write local connection env")?;
|
||||
let mut output = modified_lines.join("\n");
|
||||
if trailing_newline {
|
||||
output.push('\n');
|
||||
}
|
||||
fs::write(&local_connection_env, output).context("Cannot write local connection env")?;
|
||||
|
||||
Ok(())
|
||||
Ok(FileChange {
|
||||
path: local_connection_env,
|
||||
diffs,
|
||||
})
|
||||
}
|
||||
|
||||
const CI_DB_MASTER_IP: &str = "ci.db.master.ip";
|
||||
const CI_DB_MASTER_PORT: &str = "ci.db.master.port";
|
||||
const CONNECTION: &str = "connection";
|
||||
|
||||
const RCC_CI_DB_MASTER_IP: &str = "RCC_CI_DB_MASTER_IP";
|
||||
const RCC_CI_DB_MASTER_PORT: &str = "RCC_CI_DB_MASTER_PORT";
|
||||
const RCC_CONNECTION: &str = "RCC_CONNECTION";
|
||||
|
||||
/// Converts a legacy dotted key (`ci.db.master.ip`) into its `RCC_`-prefixed
|
||||
/// replacement (`RCC_CI_DB_MASTER_IP`), as used by the new env file format.
|
||||
/// Test-only: keeps the `RCC_*` consts above honest (see `rcc_key_matches_const`)
|
||||
/// — the hot paths below use the consts directly to avoid allocating per line.
|
||||
#[cfg(test)]
|
||||
fn rcc_key(dotted_key: &str) -> String {
|
||||
format!("RCC_{}", dotted_key.to_uppercase().replace('.', "_"))
|
||||
}
|
||||
|
||||
fn starts_with_env_key(line: &str, key: &str) -> bool {
|
||||
line.strip_prefix(key)
|
||||
.is_some_and(|rest| rest.starts_with('='))
|
||||
}
|
||||
|
||||
pub(crate) fn connections_env_comment(line: &str) -> bool {
|
||||
starts_with_env_key(line, CI_DB_MASTER_IP)
|
||||
|| starts_with_env_key(line, RCC_CI_DB_MASTER_IP)
|
||||
|| starts_with_env_key(line, CI_DB_MASTER_PORT)
|
||||
|| starts_with_env_key(line, RCC_CI_DB_MASTER_PORT)
|
||||
}
|
||||
|
||||
pub(crate) fn should_commented_out(line: &str) -> bool {
|
||||
match line.strip_prefix("# ") {
|
||||
Some(rest) => connections_env_comment(rest),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `connection` key as it appears on the given line (either the
|
||||
/// legacy `connection` or the new `RCC_CONNECTION`), if the line sets it.
|
||||
fn connection_key(line: &str) -> Option<&'static str> {
|
||||
let trimmed = line.trim_start();
|
||||
if starts_with_env_key(trimmed, CONNECTION) {
|
||||
Some(CONNECTION)
|
||||
} else if starts_with_env_key(trimmed, RCC_CONNECTION) {
|
||||
Some(RCC_CONNECTION)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn toggle(line: &str, activate: bool) -> String {
|
||||
@@ -119,6 +233,299 @@ pub(crate) fn toggle(line: &str, activate: bool) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// An isolated `conf/global`/`conf/local` tree under the OS temp dir, for
|
||||
/// exercising `set_local_db`/`set_dot_env` against real files without
|
||||
/// touching this repo's own `conf/` directory. Namespaced by process id
|
||||
/// so concurrent `cargo test` runs can't collide on the same path, and
|
||||
/// removed on drop so no stale directories accumulate.
|
||||
struct TempProjectDir(PathBuf);
|
||||
|
||||
impl TempProjectDir {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"dev_tools_parse_test_{}_{}",
|
||||
std::process::id(),
|
||||
name
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(dir.join("conf/global")).unwrap();
|
||||
fs::create_dir_all(dir.join("conf/local")).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for TempProjectDir {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempProjectDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn config_for(dir: &Path) -> DevToolsConf {
|
||||
DevToolsConf {
|
||||
container_service: String::new(),
|
||||
container_dir: String::new(),
|
||||
project_directory: PathBuf::from(format!("{}/", dir.display())),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_local_db_records_diffs_when_commenting_out() {
|
||||
let dir = TempProjectDir::new("comment_out");
|
||||
fs::write(
|
||||
dir.join(GLOBAL_CONNECTION_PATH),
|
||||
"ci.db.master.ip=127.0.0.1\nci.db.master.port=5432\nci.db.master.user=admin\nRCC_CI_DB_MASTER_IP=127.0.0.1",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let change = set_local_db(true, &config_for(&dir)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
change.diffs,
|
||||
vec![
|
||||
(
|
||||
"ci.db.master.ip=127.0.0.1".to_string(),
|
||||
"# ci.db.master.ip=127.0.0.1".to_string()
|
||||
),
|
||||
(
|
||||
"ci.db.master.port=5432".to_string(),
|
||||
"# ci.db.master.port=5432".to_string()
|
||||
),
|
||||
(
|
||||
"RCC_CI_DB_MASTER_IP=127.0.0.1".to_string(),
|
||||
"# RCC_CI_DB_MASTER_IP=127.0.0.1".to_string()
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_local_db_preserves_trailing_newline() {
|
||||
let dir = TempProjectDir::new("trailing_newline_global");
|
||||
let path = dir.join(GLOBAL_CONNECTION_PATH);
|
||||
fs::write(&path, "ci.db.master.user=admin\n").unwrap();
|
||||
|
||||
set_local_db(true, &config_for(&dir)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).unwrap(),
|
||||
"ci.db.master.user=admin\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_local_db_preserves_missing_trailing_newline() {
|
||||
let dir = TempProjectDir::new("no_trailing_newline_global");
|
||||
let path = dir.join(GLOBAL_CONNECTION_PATH);
|
||||
fs::write(&path, "ci.db.master.user=admin").unwrap();
|
||||
|
||||
set_local_db(true, &config_for(&dir)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).unwrap(),
|
||||
"ci.db.master.user=admin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_local_db_records_diffs_when_uncommenting() {
|
||||
let dir = TempProjectDir::new("uncomment");
|
||||
fs::write(
|
||||
dir.join(GLOBAL_CONNECTION_PATH),
|
||||
"# ci.db.master.ip=127.0.0.1\n# ci.db.master.port=5432\nci.db.master.user=admin",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let change = set_local_db(false, &config_for(&dir)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
change.diffs,
|
||||
vec![
|
||||
(
|
||||
"# ci.db.master.ip=127.0.0.1".to_string(),
|
||||
"ci.db.master.ip=127.0.0.1".to_string()
|
||||
),
|
||||
(
|
||||
"# ci.db.master.port=5432".to_string(),
|
||||
"ci.db.master.port=5432".to_string()
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_dot_env_keeps_trailing_newline_when_modifying() {
|
||||
let dir = TempProjectDir::new("trailing_newline_local_modify");
|
||||
let path = dir.join(LOCAL_CONNECTION_PATH);
|
||||
fs::write(&path, "connection='ci'\n").unwrap();
|
||||
|
||||
set_dot_env(false, &config_for(&dir)).unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "connection='staging'\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_dot_env_preserves_trailing_newline_when_unmodified() {
|
||||
let dir = TempProjectDir::new("trailing_newline_local_noop");
|
||||
let path = dir.join(LOCAL_CONNECTION_PATH);
|
||||
fs::write(&path, "OTHER_KEY=value\n").unwrap();
|
||||
|
||||
set_dot_env(true, &config_for(&dir)).unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "OTHER_KEY=value\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_dot_env_preserves_missing_trailing_newline() {
|
||||
let dir = TempProjectDir::new("no_trailing_newline_local");
|
||||
let path = dir.join(LOCAL_CONNECTION_PATH);
|
||||
fs::write(&path, "OTHER_KEY=value").unwrap();
|
||||
|
||||
set_dot_env(true, &config_for(&dir)).unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "OTHER_KEY=value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_dot_env_merges_diffs_from_both_sections() {
|
||||
let dir = TempProjectDir::new("merge_sections");
|
||||
fs::write(
|
||||
dir.join(LOCAL_CONNECTION_PATH),
|
||||
"#docker\nDOCKER_HOST=tcp://localhost:2375\n#DOCKER_TLS_VERIFY=1\n#DOCKER_CERT_PATH=/certs\nDOCKER_API_VERSION=1.41\n#cidb\nci.db.master.ip=127.0.0.1\nci.db.master.port=5432\nconnection='staging'\nRCC_CONNECTION='staging'",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let change = set_dot_env(true, &config_for(&dir)).unwrap();
|
||||
|
||||
assert!(change.path.ends_with(LOCAL_CONNECTION_PATH));
|
||||
assert_eq!(change.diffs.len(), 6);
|
||||
for expected in [
|
||||
("#DOCKER_TLS_VERIFY=1", "DOCKER_TLS_VERIFY=1"),
|
||||
("#DOCKER_CERT_PATH=/certs", "DOCKER_CERT_PATH=/certs"),
|
||||
("ci.db.master.ip=127.0.0.1", "#ci.db.master.ip=127.0.0.1"),
|
||||
("ci.db.master.port=5432", "#ci.db.master.port=5432"),
|
||||
("connection='staging'", "connection='ci'"),
|
||||
("RCC_CONNECTION='staging'", "RCC_CONNECTION='ci'"),
|
||||
] {
|
||||
assert!(
|
||||
change
|
||||
.diffs
|
||||
.contains(&(expected.0.to_string(), expected.1.to_string())),
|
||||
"missing diff {:?}",
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_dot_env_merges_diffs_from_both_sections_on_stop() {
|
||||
let dir = TempProjectDir::new("merge_sections_stop");
|
||||
fs::write(
|
||||
dir.join(LOCAL_CONNECTION_PATH),
|
||||
"#docker\nDOCKER_HOST=tcp://localhost:2375\n#DOCKER_TLS_VERIFY=1\n#DOCKER_CERT_PATH=/certs\nDOCKER_API_VERSION=1.41\n#cidb\n#ci.db.master.ip=127.0.0.1\n#ci.db.master.port=5432\nconnection='ci'\nRCC_CONNECTION='ci'",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let change = set_dot_env(false, &config_for(&dir)).unwrap();
|
||||
|
||||
assert_eq!(change.diffs.len(), 6);
|
||||
for expected in [
|
||||
(
|
||||
"DOCKER_HOST=tcp://localhost:2375",
|
||||
"#DOCKER_HOST=tcp://localhost:2375",
|
||||
),
|
||||
("DOCKER_API_VERSION=1.41", "#DOCKER_API_VERSION=1.41"),
|
||||
("#ci.db.master.ip=127.0.0.1", "ci.db.master.ip=127.0.0.1"),
|
||||
("#ci.db.master.port=5432", "ci.db.master.port=5432"),
|
||||
("connection='ci'", "connection='staging'"),
|
||||
("RCC_CONNECTION='ci'", "RCC_CONNECTION='staging'"),
|
||||
] {
|
||||
assert!(
|
||||
change
|
||||
.diffs
|
||||
.contains(&(expected.0.to_string(), expected.1.to_string())),
|
||||
"missing diff {:?}",
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_change_display_no_diffs() {
|
||||
let change = FileChange {
|
||||
path: "some/path".to_string(),
|
||||
diffs: vec![],
|
||||
};
|
||||
assert_eq!(format!("{}", change), "[!] some/path: no changes\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_change_display_with_diffs() {
|
||||
let change = FileChange {
|
||||
path: "some/path".to_string(),
|
||||
diffs: vec![(
|
||||
"connection='ci'".to_string(),
|
||||
"connection='staging'".to_string(),
|
||||
)],
|
||||
};
|
||||
assert_eq!(
|
||||
format!("{}", change),
|
||||
"[!] some/path changed:\n connection='ci' -> connection='staging'\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_change_display_with_multiple_diffs() {
|
||||
let change = FileChange {
|
||||
path: "some/path".to_string(),
|
||||
diffs: vec![
|
||||
("a=1".to_string(), "#a=1".to_string()),
|
||||
("b=2".to_string(), "#b=2".to_string()),
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
format!("{}", change),
|
||||
"[!] some/path changed:\n a=1 -> #a=1\n b=2 -> #b=2\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_change_summary_no_diffs() {
|
||||
let change = FileChange {
|
||||
path: "some/path".to_string(),
|
||||
diffs: vec![],
|
||||
};
|
||||
assert_eq!(change.summary(), "[!] some/path: no changes\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_change_summary_hides_values() {
|
||||
let change = FileChange {
|
||||
path: "some/path".to_string(),
|
||||
diffs: vec![
|
||||
(
|
||||
"connection='ci'".to_string(),
|
||||
"connection='staging'".to_string(),
|
||||
),
|
||||
("a=1".to_string(), "#a=1".to_string()),
|
||||
],
|
||||
};
|
||||
let summary = change.summary();
|
||||
assert_eq!(
|
||||
summary,
|
||||
"[!] some/path changed: 2 line(s) (use -v to show)\n"
|
||||
);
|
||||
assert!(!summary.contains("connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_activate_removes_leading_hash() {
|
||||
@@ -169,4 +576,55 @@ mod tests {
|
||||
fn should_commented_out_ignores_uncommented() {
|
||||
assert!(!should_commented_out("ci.db.master.ip=127.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rcc_key_converts_dotted_key() {
|
||||
assert_eq!(rcc_key("ci.db.master.ip"), "RCC_CI_DB_MASTER_IP");
|
||||
assert_eq!(rcc_key("connection"), "RCC_CONNECTION");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rcc_key_matches_const() {
|
||||
assert_eq!(rcc_key(CI_DB_MASTER_IP), RCC_CI_DB_MASTER_IP);
|
||||
assert_eq!(rcc_key(CI_DB_MASTER_PORT), RCC_CI_DB_MASTER_PORT);
|
||||
assert_eq!(rcc_key(CONNECTION), RCC_CONNECTION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connections_env_comment_matches_rcc_ip() {
|
||||
assert!(connections_env_comment("RCC_CI_DB_MASTER_IP=127.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connections_env_comment_matches_rcc_port() {
|
||||
assert!(connections_env_comment("RCC_CI_DB_MASTER_PORT=5432"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_commented_out_matches_rcc_ip() {
|
||||
assert!(should_commented_out("# RCC_CI_DB_MASTER_IP=127.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_commented_out_matches_rcc_port() {
|
||||
assert!(should_commented_out("# RCC_CI_DB_MASTER_PORT=5432"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_key_matches_legacy() {
|
||||
assert_eq!(connection_key("connection='ci'"), Some("connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_key_matches_rcc() {
|
||||
assert_eq!(
|
||||
connection_key("RCC_CONNECTION='ci'"),
|
||||
Some("RCC_CONNECTION")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_key_ignores_other() {
|
||||
assert_eq!(connection_key("connection_pool=5"), None);
|
||||
}
|
||||
}
|
||||
|
||||
+197
-11
@@ -1,9 +1,25 @@
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{ensure, Context, Result};
|
||||
|
||||
use crate::{config::parse::GLOBAL_CONNECTION_PATH, env::config::DevToolsConf};
|
||||
|
||||
/// Runs `git <args>` in `dir` and errors if the process didn't exit
|
||||
/// successfully, so a failed checkout/update-index can't be mistaken for one
|
||||
/// that succeeded.
|
||||
fn run_git(dir: &str, args: &[&str]) -> Result<()> {
|
||||
let status = Command::new("git").current_dir(dir).args(args).status()?;
|
||||
|
||||
ensure!(
|
||||
status.success(),
|
||||
"git {} failed with {status}",
|
||||
args.join(" ")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn toggle_index(start: bool, config: &DevToolsConf) -> Result<()> {
|
||||
let change: &str = match start {
|
||||
true => "--assume-unchanged",
|
||||
@@ -15,17 +31,187 @@ pub fn toggle_index(start: bool, config: &DevToolsConf) -> Result<()> {
|
||||
GLOBAL_CONNECTION_PATH
|
||||
);
|
||||
|
||||
Command::new("git")
|
||||
.current_dir(config.project_dir_as_string())
|
||||
.args(["update-index", change, &project_path])
|
||||
.status()?;
|
||||
run_git(
|
||||
&config.project_dir_as_string(),
|
||||
&["update-index", change, &project_path],
|
||||
)
|
||||
}
|
||||
|
||||
if !start {
|
||||
Command::new("git")
|
||||
.current_dir(config.project_dir_as_string())
|
||||
.args(["checkout", &project_path])
|
||||
.status()?;
|
||||
}
|
||||
/// Restores `connections.env` to its committed state, used on `down` instead
|
||||
/// of computing the reverse toggle so the file ends up byte-for-byte
|
||||
/// identical to HEAD, with no risk of a parsing mismatch. Reads the
|
||||
/// committed blob with `git cat-file` and writes it directly rather than
|
||||
/// running `git checkout`, since `checkout` fires `post-checkout` even for a
|
||||
/// single-file checkout and `cat-file` (plumbing, no worktree update) never
|
||||
/// triggers hooks.
|
||||
pub fn restore_connections_env(config: &DevToolsConf) -> Result<()> {
|
||||
let project_dir = config.project_dir_as_string();
|
||||
let project_path = format!("{project_dir}{GLOBAL_CONNECTION_PATH}");
|
||||
|
||||
let output = Command::new("git")
|
||||
.current_dir(&project_dir)
|
||||
.args(["cat-file", "-p", &format!("HEAD:{GLOBAL_CONNECTION_PATH}")])
|
||||
.output()?;
|
||||
|
||||
ensure!(
|
||||
output.status.success(),
|
||||
"git cat-file -p HEAD:{GLOBAL_CONNECTION_PATH} failed with {}",
|
||||
output.status
|
||||
);
|
||||
|
||||
fs::write(&project_path, output.stdout).context("Cannot write connections.env")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// An isolated git repository under the OS temp dir, for exercising
|
||||
/// `toggle_index`/`restore_connections_env` against a real `git`
|
||||
/// process without touching this repo's own history. Namespaced by
|
||||
/// process id so concurrent `cargo test` runs can't collide, and
|
||||
/// removed on drop so no stale directories accumulate.
|
||||
struct TempGitRepo(PathBuf);
|
||||
|
||||
impl TempGitRepo {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"dev_tools_git_test_{}_{}",
|
||||
std::process::id(),
|
||||
name
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(dir.join("conf/global")).unwrap();
|
||||
|
||||
let run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.current_dir(&dir)
|
||||
.args(args)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
};
|
||||
run(&["init", "-q"]);
|
||||
run(&["config", "user.email", "test@example.com"]);
|
||||
run(&["config", "user.name", "test"]);
|
||||
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
fn commit_connections_env(&self, content: &str) {
|
||||
fs::write(self.join(GLOBAL_CONNECTION_PATH), content).unwrap();
|
||||
let run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.current_dir(&self.0)
|
||||
.args(args)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
};
|
||||
run(&["add", GLOBAL_CONNECTION_PATH]);
|
||||
run(&["commit", "-q", "-m", "connections.env"]);
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for TempGitRepo {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempGitRepo {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn config_for(dir: &Path) -> DevToolsConf {
|
||||
DevToolsConf {
|
||||
container_service: String::new(),
|
||||
container_dir: String::new(),
|
||||
project_directory: PathBuf::from(format!("{}/", dir.display())),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_connections_env_reverts_uncommitted_changes() {
|
||||
let repo = TempGitRepo::new("restore_ok");
|
||||
repo.commit_connections_env("ci.db.user=\"m.rothenhaeusler\"\n");
|
||||
fs::write(repo.join(GLOBAL_CONNECTION_PATH), "corrupted content\n").unwrap();
|
||||
|
||||
restore_connections_env(&config_for(&repo)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(repo.join(GLOBAL_CONNECTION_PATH)).unwrap(),
|
||||
"ci.db.user=\"m.rothenhaeusler\"\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_connections_env_does_not_trigger_post_checkout_hook() {
|
||||
let repo = TempGitRepo::new("no_hook");
|
||||
repo.commit_connections_env("ci.db.user=\"m.rothenhaeusler\"\n");
|
||||
fs::write(repo.join(GLOBAL_CONNECTION_PATH), "corrupted content\n").unwrap();
|
||||
|
||||
let marker = repo.join("hook-ran");
|
||||
let hooks_dir = repo.join(".git/hooks");
|
||||
fs::create_dir_all(&hooks_dir).unwrap();
|
||||
let hook_path = hooks_dir.join("post-checkout");
|
||||
fs::write(
|
||||
&hook_path,
|
||||
format!("#!/bin/sh\ntouch {}\n", marker.display()),
|
||||
)
|
||||
.unwrap();
|
||||
let mut perms = fs::metadata(&hook_path).unwrap().permissions();
|
||||
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
|
||||
fs::set_permissions(&hook_path, perms).unwrap();
|
||||
|
||||
restore_connections_env(&config_for(&repo)).unwrap();
|
||||
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"restore_connections_env must not trigger post-checkout hooks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_connections_env_fails_when_not_a_git_repo() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"dev_tools_git_test_{}_not_a_repo",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(dir.join("conf/global")).unwrap();
|
||||
fs::write(dir.join(GLOBAL_CONNECTION_PATH), "content\n").unwrap();
|
||||
|
||||
let result = restore_connections_env(&config_for(&dir));
|
||||
|
||||
assert!(result.is_err());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_index_marks_file_assume_unchanged() {
|
||||
let repo = TempGitRepo::new("toggle_index");
|
||||
repo.commit_connections_env("content\n");
|
||||
|
||||
toggle_index(true, &config_for(&repo)).unwrap();
|
||||
|
||||
let output = Command::new("git")
|
||||
.current_dir(&*repo)
|
||||
.args(["ls-files", "-v", GLOBAL_CONNECTION_PATH])
|
||||
.output()
|
||||
.unwrap();
|
||||
let listing = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(
|
||||
listing.starts_with('h'),
|
||||
"expected lower-case assume-unchanged flag, got: {listing}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+59
-10
@@ -4,8 +4,8 @@ use env::config::{load_config, DevToolsConf};
|
||||
|
||||
use crate::{
|
||||
arguments::Arguments,
|
||||
config::parse::{set_dot_env, set_local_db},
|
||||
git::command::toggle_index,
|
||||
config::parse::{set_dot_env, set_local_db, FileChange},
|
||||
git::command::{restore_connections_env, toggle_index},
|
||||
};
|
||||
use clap::Parser;
|
||||
|
||||
@@ -15,6 +15,54 @@ mod container;
|
||||
mod env;
|
||||
mod git;
|
||||
|
||||
/// Line-count summary by default, since a full diff can contain secrets
|
||||
/// (e.g. connection strings); `-v`/`--verbose` opts into the diff.
|
||||
fn file_change_output(change: &FileChange, verbose: bool) -> String {
|
||||
if verbose {
|
||||
change.to_string()
|
||||
} else {
|
||||
change.summary()
|
||||
}
|
||||
}
|
||||
|
||||
fn print_file_change(change: &FileChange, verbose: bool) {
|
||||
print!("{}", file_change_output(change, verbose));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn change_with_diff() -> FileChange {
|
||||
FileChange {
|
||||
path: "some/path".to_string(),
|
||||
diffs: vec![(
|
||||
"connection='ci'".to_string(),
|
||||
"connection='staging'".to_string(),
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_change_output_hides_values_by_default() {
|
||||
let output = file_change_output(&change_with_diff(), false);
|
||||
assert_eq!(
|
||||
output,
|
||||
"[!] some/path changed: 1 line(s) (use -v to show)\n"
|
||||
);
|
||||
assert!(!output.contains("connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_change_output_shows_values_when_verbose() {
|
||||
let output = file_change_output(&change_with_diff(), true);
|
||||
assert_eq!(
|
||||
output,
|
||||
"[!] some/path changed:\n connection='ci' -> connection='staging'\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Arguments = Arguments::parse();
|
||||
args.validate()?;
|
||||
@@ -22,15 +70,16 @@ fn main() -> Result<()> {
|
||||
let start: bool = args.action == "up";
|
||||
|
||||
start_docker_compose(&config.container_service, &config.container_dir, start)?;
|
||||
if args.legacy {
|
||||
println!("[!] Legacy mode active.");
|
||||
set_local_db(start, &config)?;
|
||||
toggle_index(start, &config)?;
|
||||
} else {
|
||||
println!("[!] Legacy mode deactivated.");
|
||||
}
|
||||
|
||||
set_dot_env(start, &config)?;
|
||||
if start {
|
||||
print_file_change(&set_local_db(start, &config)?, args.verbose);
|
||||
}
|
||||
toggle_index(start, &config)?;
|
||||
if !start {
|
||||
restore_connections_env(&config)?;
|
||||
println!("[!] conf/global/connections.env restored to committed state.");
|
||||
}
|
||||
print_file_change(&set_dot_env(start, &config)?, args.verbose);
|
||||
|
||||
println!(
|
||||
"[!] Don't forget your interpreter! Project directory: {}",
|
||||
|
||||
Reference in New Issue
Block a user