diff --git a/src/config/parse.rs b/src/config/parse.rs index 96d87fc..448169c 100644 --- a/src/config/parse.rs +++ b/src/config/parse.rs @@ -1,4 +1,5 @@ use std::{ + fmt, fs::{self, File}, io::{BufRead, BufReader}, }; @@ -9,8 +10,27 @@ 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 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 -pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<()> { +pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result { let global_connections_env = format!( "{}{}", config.project_dir_as_string(), @@ -21,27 +41,39 @@ pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<()> { let reader: BufReader = BufReader::new(file); let mut modified_lines: Vec = Vec::new(); + let mut diffs: Vec<(String, String)> = Vec::new(); for line in reader.lines() { let line_content = line?; - let modified = if start && connections_env_comment(&line_content) { - format!("# {}", line_content) + let modified: Option = if start && connections_env_comment(&line_content) { + Some(format!("# {}", line_content)) } else if !start && should_commented_out(&line_content) { - line_content[2..].to_string() + Some(line_content[2..].to_string()) } 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")?; - Ok(()) + 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(()) +pub fn set_dot_env(start: bool, config: &DevToolsConf) -> Result { + let mut docker_change = toggle_after_line("#docker", 4, start, config)?; + let cidb_change = toggle_after_line("#cidb", 2, !start, config)?; + docker_change.diffs.extend(cidb_change.diffs); + Ok(docker_change) } const CI_DB_MASTER_IP: &str = "ci.db.master.ip"; @@ -85,7 +117,7 @@ fn toggle_after_line( num_lines: usize, activate: bool, config: &DevToolsConf, -) -> Result<()> { +) -> Result { let local_connection_env = format!( "{}{}", config.project_dir_as_string(), @@ -96,6 +128,7 @@ fn toggle_after_line( let reader: BufReader = BufReader::new(file); let mut modified_lines: Vec = Vec::new(); + let mut diffs: Vec<(String, String)> = Vec::new(); let mut relevant = false; let mut counter: usize = 0; @@ -111,21 +144,34 @@ fn toggle_after_line( continue; } - if relevant { - modified_lines.push(toggle(&line_content, activate)); + let modified: Option = if relevant { 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" }; - modified_lines.push(format!("{}='{}'", key, value)); + let new_line = format!("{}='{}'", key, value); + (new_line != line_content).then_some(new_line) } else { - modified_lines.push(line_content); + None + }; + + 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(local_connection_env, modified_lines.join("\n")) + fs::write(&local_connection_env, modified_lines.join("\n")) .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 @@ -156,6 +202,228 @@ 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_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] fn toggle_activate_removes_leading_hash() { diff --git a/src/main.rs b/src/main.rs index a9d9a15..4ab3287 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,13 +24,13 @@ fn main() -> Result<()> { start_docker_compose(&config.container_service, &config.container_dir, start)?; if args.legacy { println!("[!] Legacy mode active."); - set_local_db(start, &config)?; + print!("{}", set_local_db(start, &config)?); toggle_index(start, &config)?; } else { println!("[!] Legacy mode deactivated."); } - set_dot_env(start, &config)?; + print!("{}", set_dot_env(start, &config)?); println!( "[!] Don't forget your interpreter! Project directory: {}",