Compare commits

...
2 Commits
Author SHA1 Message Date
mathias 72e0c229f3 report which lines changed on up/down
set_local_db, toggle_after_line and set_dot_env now return a
FileChange (path + before/after line pairs) instead of (), and main
prints it after each toggle so 'up'/'down' shows exactly what the
program rewrote in each env file.
2026-09-17 11:00:17 +02:00
mathias 12b8701e00 support RCC_-prefixed env keys alongside legacy dotted keys
connections.env and local .env are transitioning from dotted keys
(ci.db.master.ip, connection) to upper-cased RCC_-prefixed keys
(RCC_CI_DB_MASTER_IP, RCC_CONNECTION). Both forms are matched and
toggled during the migration, so old branches keep working while
new ones use the new format.
2026-09-17 10:43:13 +02:00
2 changed files with 384 additions and 28 deletions
+382 -26
View File
@@ -1,4 +1,5 @@
use std::{ use std::{
fmt,
fs::{self, File}, fs::{self, File},
io::{BufRead, BufReader}, io::{BufRead, BufReader},
}; };
@@ -9,8 +10,27 @@ use crate::env::config::DevToolsConf;
pub const GLOBAL_CONNECTION_PATH: &str = "conf/global/connections.env"; pub const GLOBAL_CONNECTION_PATH: &str = "conf/global/connections.env";
const LOCAL_CONNECTION_PATH: &str = "conf/local/.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 fmt::Display for FileChange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.diffs.is_empty() {
return writeln!(f, "[!] {}: no changes", self.path);
}
writeln!(f, "[!] {} changed:", self.path)?;
for (before, after) in &self.diffs {
writeln!(f, " {} -> {}", before, after)?;
}
Ok(())
}
}
/// writes global connections /// 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!( let global_connections_env = format!(
"{}{}", "{}{}",
config.project_dir_as_string(), config.project_dir_as_string(),
@@ -21,35 +41,75 @@ pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<()> {
let reader: BufReader<File> = BufReader::new(file); let reader: BufReader<File> = BufReader::new(file);
let mut modified_lines: Vec<String> = Vec::new(); let mut modified_lines: Vec<String> = Vec::new();
let mut diffs: Vec<(String, String)> = Vec::new();
for line in reader.lines() { for line in reader.lines() {
let line_content = line?; let line_content = line?;
let modified = if start && connections_env_comment(&line_content) { let modified: Option<String> = if start && connections_env_comment(&line_content) {
format!("# {}", line_content) Some(format!("# {}", line_content))
} else if !start && should_commented_out(&line_content) { } else if !start && should_commented_out(&line_content) {
line_content[2..].to_string() Some(line_content[2..].to_string())
} else { } else {
line_content None
}; };
modified_lines.push(modified);
match modified {
Some(new_line) => {
diffs.push((line_content, new_line.clone()));
modified_lines.push(new_line);
}
None => modified_lines.push(line_content),
}
} }
fs::write(global_connections_env, modified_lines.join("\n")) fs::write(&global_connections_env, modified_lines.join("\n"))
.context("Cannot write global connections env")?; .context("Cannot write global connections env")?;
Ok(()) Ok(FileChange {
path: global_connections_env,
diffs,
})
} }
pub fn set_dot_env(start: bool, config: &DevToolsConf) -> Result<()> { pub fn set_dot_env(start: bool, config: &DevToolsConf) -> Result<FileChange> {
toggle_after_line("#docker", 4, start, config)?; let mut docker_change = toggle_after_line("#docker", 4, start, config)?;
toggle_after_line("#cidb", 2, !start, config)?; let cidb_change = toggle_after_line("#cidb", 2, !start, config)?;
Ok(()) docker_change.diffs.extend(cidb_change.diffs);
Ok(docker_change)
}
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 { pub(crate) fn connections_env_comment(line: &str) -> bool {
line.starts_with("ci.db.master.ip=") || line.starts_with("ci.db.master.port=") 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 { pub(crate) fn should_commented_out(line: &str) -> bool {
line.starts_with("# ci.db.master.ip=") || line.starts_with("# ci.db.master.port=") match line.strip_prefix("# ") {
Some(rest) => connections_env_comment(rest),
None => false,
}
} }
fn toggle_after_line( fn toggle_after_line(
@@ -57,7 +117,7 @@ fn toggle_after_line(
num_lines: usize, num_lines: usize,
activate: bool, activate: bool,
config: &DevToolsConf, config: &DevToolsConf,
) -> Result<()> { ) -> Result<FileChange> {
let local_connection_env = format!( let local_connection_env = format!(
"{}{}", "{}{}",
config.project_dir_as_string(), config.project_dir_as_string(),
@@ -68,6 +128,7 @@ fn toggle_after_line(
let reader: BufReader<File> = BufReader::new(file); let reader: BufReader<File> = BufReader::new(file);
let mut modified_lines: Vec<String> = Vec::new(); let mut modified_lines: Vec<String> = Vec::new();
let mut diffs: Vec<(String, String)> = Vec::new();
let mut relevant = false; let mut relevant = false;
let mut counter: usize = 0; let mut counter: usize = 0;
@@ -83,25 +144,47 @@ fn toggle_after_line(
continue; continue;
} }
if relevant { let modified: Option<String> = if relevant {
modified_lines.push(toggle(&line_content, activate));
counter += 1; counter += 1;
let new_line = toggle(&line_content, activate);
(new_line != line_content).then_some(new_line)
} else if let Some(key) = connection_key(&line_content) {
let value = if activate { "staging" } else { "ci" };
let new_line = format!("{}='{}'", key, value);
(new_line != line_content).then_some(new_line)
} else { } else {
if !activate && line_content.trim_start().starts_with("connection=") { None
modified_lines.push("connection='ci'".to_string()); };
continue;
} else if activate && line_content.trim_start().starts_with("connection=") { match modified {
modified_lines.push("connection='staging'".to_string()); Some(new_line) => {
continue; diffs.push((line_content, new_line.clone()));
modified_lines.push(new_line);
} }
modified_lines.push(line_content); None => modified_lines.push(line_content),
} }
} }
fs::write(local_connection_env, modified_lines.join("\n")) fs::write(&local_connection_env, modified_lines.join("\n"))
.context("Cannot write local connection env")?; .context("Cannot write local connection env")?;
Ok(()) Ok(FileChange {
path: local_connection_env,
diffs,
})
}
/// 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 { pub(crate) fn toggle(line: &str, activate: bool) -> String {
@@ -119,6 +202,228 @@ pub(crate) fn toggle(line: &str, activate: bool) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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_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 toggle_after_line_records_diffs_for_toggled_section() {
let dir = TempProjectDir::new("toggle_section");
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\nother=1",
)
.unwrap();
let change = toggle_after_line("#docker", 4, true, &config_for(&dir)).unwrap();
assert_eq!(
change.diffs,
vec![
(
"#DOCKER_TLS_VERIFY=1".to_string(),
"DOCKER_TLS_VERIFY=1".to_string()
),
(
"#DOCKER_CERT_PATH=/certs".to_string(),
"DOCKER_CERT_PATH=/certs".to_string()
),
]
);
}
#[test]
fn toggle_after_line_records_diffs_for_both_connection_keys() {
let dir = TempProjectDir::new("connection_keys");
fs::write(
dir.join(LOCAL_CONNECTION_PATH),
"#docker\nline1\nline2\nline3\nline4\nconnection='staging'\nRCC_CONNECTION='staging'",
)
.unwrap();
let change = toggle_after_line("#cidb", 2, false, &config_for(&dir)).unwrap();
assert_eq!(
change.diffs,
vec![
(
"connection='staging'".to_string(),
"connection='ci'".to_string()
),
(
"RCC_CONNECTION='staging'".to_string(),
"RCC_CONNECTION='ci'".to_string()
),
]
);
}
#[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 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] #[test]
fn toggle_activate_removes_leading_hash() { fn toggle_activate_removes_leading_hash() {
@@ -169,4 +474,55 @@ mod tests {
fn should_commented_out_ignores_uncommented() { fn should_commented_out_ignores_uncommented() {
assert!(!should_commented_out("ci.db.master.ip=127.0.0.1")); 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);
}
} }
+2 -2
View File
@@ -24,13 +24,13 @@ fn main() -> Result<()> {
start_docker_compose(&config.container_service, &config.container_dir, start)?; start_docker_compose(&config.container_service, &config.container_dir, start)?;
if args.legacy { if args.legacy {
println!("[!] Legacy mode active."); println!("[!] Legacy mode active.");
set_local_db(start, &config)?; print!("{}", set_local_db(start, &config)?);
toggle_index(start, &config)?; toggle_index(start, &config)?;
} else { } else {
println!("[!] Legacy mode deactivated."); println!("[!] Legacy mode deactivated.");
} }
set_dot_env(start, &config)?; print!("{}", set_dot_env(start, &config)?);
println!( println!(
"[!] Don't forget your interpreter! Project directory: {}", "[!] Don't forget your interpreter! Project directory: {}",