diff --git a/.gitignore b/.gitignore index 487bfb5..9769234 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target /conf +CLAUDE.md +.claude diff --git a/README.md b/README.md new file mode 100644 index 0000000..1781b18 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# dev_tools + +CLI tool to start and stop a local RCC test environment. It manages a Docker Compose service and toggles environment variable files between local and CI database configurations. + +## Prerequisites + +- Rust toolchain (stable) +- Docker with Compose plugin + +## Configuration + +The tool reads from a dotenv-style file at `~/.config/rcc/dev`. The following variables are required: + +| Variable | Description | +|---|---| +| `DOCKER_SERVICE` | Name of the Docker container to check if it is running | +| `DOCKER_DIR` | Path to the Docker Compose directory, relative to `$HOME` | +| `_DIR` | Absolute path to the project directory (one per project, e.g. `FOO_DIR`) | + +Example `~/.config/rcc/dev`: +``` +DOCKER_SERVICE=my-container +DOCKER_DIR=repos/docker/local +FOO_DIR=/home/user/repos/foo +``` + +## Build & Install + +```bash +cargo build --release +# or install directly into ~/.cargo/bin +cargo install --path . +``` + +## Usage + +``` +dev_tools [--legacy] +``` + +| Argument | Description | +|---|---| +| `project` | Project key (matches `_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` | + +### Examples + +```bash +# Start the environment for project "foo" +dev_tools foo up + +# Stop it +dev_tools foo down + +# Start with legacy env handling +dev_tools foo up --legacy +``` + +## What it does + +1. Starts or stops the Docker Compose service defined by `DOCKER_SERVICE` / `DOCKER_DIR`. +2. Toggles database connection entries in `/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. + +## Tests + +```bash +cargo test +``` diff --git a/src/arguments.rs b/src/arguments.rs index a17bfb6..8566b84 100644 --- a/src/arguments.rs +++ b/src/arguments.rs @@ -1,3 +1,4 @@ +use anyhow::{bail, Result}; use clap::Parser; #[derive(Parser, Default, Debug)] @@ -22,11 +23,40 @@ pub struct Arguments { } impl Arguments { - pub fn validate(&self) -> Result<(), Box> { + pub fn validate(&self) -> Result<()> { if self.action == "up" || self.action == "down" { return Ok(()); } - Err("Action values are up or down.".into()) + bail!("Action values are up or down.") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args_with_action(action: &str) -> Arguments { + Arguments { + project: "test".to_string(), + action: action.to_string(), + legacy: false, + } + } + + #[test] + fn validate_accepts_up() { + assert!(args_with_action("up").validate().is_ok()); + } + + #[test] + fn validate_accepts_down() { + assert!(args_with_action("down").validate().is_ok()); + } + + #[test] + fn validate_rejects_invalid() { + let err = args_with_action("start").validate().unwrap_err(); + assert!(err.to_string().contains("up or down")); } } diff --git a/src/config/parse.rs b/src/config/parse.rs index b6fc67b..56472f9 100644 --- a/src/config/parse.rs +++ b/src/config/parse.rs @@ -3,14 +3,14 @@ use std::{ io::{BufRead, BufReader}, }; -use anyhow::Context; +use anyhow::{Context, Result}; use crate::env::config::DevToolsConf; pub const GLOBAL_CONNECTION_PATH: &str = "conf/global/connections.env"; const LOCAL_CONNECTION_PATH: &str = "conf/local/.env"; /// writes global connections -pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<(), Box> { +pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<()> { let global_connections_env = format!( "{}{}", config.project_dir_as_string(), @@ -20,37 +20,35 @@ pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<(), Box = BufReader::new(file); - let modified_lines: Vec = reader - .lines() - .map(|line| { - let line_content: String = line.unwrap(); - - if start && connections_env_comment(&line_content) { - format!("# {}", line_content) - } else if !start && should_commented_out(&line_content) { - line_content[2..].to_string() - } else { - line_content - } - }) - .collect(); + let mut modified_lines: Vec = 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() + } else { + line_content + }; + modified_lines.push(modified); + } fs::write(global_connections_env, modified_lines.join("\n")) - .expect("Cannot write global connections env"); + .context("Cannot write global connections env")?; Ok(()) } -pub fn set_dot_env(start: bool, config: &DevToolsConf) -> Result<(), Box> { +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(()) } -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=") } -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=") } @@ -59,7 +57,7 @@ fn toggle_after_line( num_lines: usize, activate: bool, config: &DevToolsConf, -) -> Result<(), Box> { +) -> Result<()> { let local_connection_env = format!( "{}{}", config.project_dir_as_string(), @@ -100,13 +98,13 @@ fn toggle_after_line( } } - // Write the modified content back to the output file - fs::write(local_connection_env, modified_lines.join("\n")).expect(""); + fs::write(local_connection_env, modified_lines.join("\n")) + .context("Cannot write local connection env")?; Ok(()) } -fn toggle(line: &str, activate: bool) -> String { +pub(crate) fn toggle(line: &str, activate: bool) -> String { if activate { return line.replacen('#', "", 1); } @@ -117,3 +115,58 @@ fn toggle(line: &str, activate: bool) -> String { line.to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toggle_activate_removes_leading_hash() { + assert_eq!(toggle("#value=1", true), "value=1"); + } + + #[test] + fn toggle_activate_no_hash_unchanged() { + assert_eq!(toggle("value=1", true), "value=1"); + } + + #[test] + fn toggle_deactivate_adds_hash() { + assert_eq!(toggle("value=1", false), "#value=1"); + } + + #[test] + fn toggle_deactivate_already_hashed_is_noop() { + assert_eq!(toggle("#value=1", false), "#value=1"); + } + + #[test] + fn connections_env_comment_matches_ip() { + assert!(connections_env_comment("ci.db.master.ip=127.0.0.1")); + } + + #[test] + fn connections_env_comment_matches_port() { + assert!(connections_env_comment("ci.db.master.port=5432")); + } + + #[test] + fn connections_env_comment_ignores_other() { + assert!(!connections_env_comment("ci.db.master.user=admin")); + } + + #[test] + fn should_commented_out_matches_ip() { + assert!(should_commented_out("# ci.db.master.ip=127.0.0.1")); + } + + #[test] + fn should_commented_out_matches_port() { + assert!(should_commented_out("# ci.db.master.port=5432")); + } + + #[test] + fn should_commented_out_ignores_uncommented() { + assert!(!should_commented_out("ci.db.master.ip=127.0.0.1")); + } +} diff --git a/src/container/docker.rs b/src/container/docker.rs index e3a716d..60d9459 100644 --- a/src/container/docker.rs +++ b/src/container/docker.rs @@ -3,18 +3,15 @@ use std::{ process::{Command, ExitStatus}, }; +use anyhow::{bail, Context, Result}; use directories::BaseDirs; -fn is_container_running(container_name: &str) -> Result> { - // Run the `docker ps` command and capture the output +fn is_container_running(container_name: &str) -> Result { let output = Command::new("docker") .args(["ps", "--format", "{{.Names}}"]) .output()?; - // Convert the output to a string let output_str = String::from_utf8_lossy(&output.stdout); - - // Check if the container name is in the list of running containers let running: bool = output_str.lines().any(|line| line.trim() == container_name); Ok(running) @@ -24,11 +21,11 @@ pub fn start_docker_compose( service_name: &str, container_path: &str, start: bool, -) -> Result<(), Box> { - let base_dir: BaseDirs = BaseDirs::new().unwrap(); +) -> Result<()> { + let base_dir: BaseDirs = BaseDirs::new().context("Could not determine home directory")?; let absolute = base_dir.home_dir().join(container_path); let compose_path = Path::new(&absolute); - println!("{}", compose_path.to_str().unwrap()); + println!("{}", compose_path.to_str().context("Invalid path")?); let running: bool = is_container_running(service_name)?; @@ -49,9 +46,7 @@ pub fn start_docker_compose( service_name ); } else { - return Err( - format!("Failed to start Docker Compose service '{}'.", service_name).into(), - ); + bail!("Failed to start Docker Compose service '{}'.", service_name); } } @@ -67,9 +62,7 @@ pub fn start_docker_compose( service_name ); } else { - return Err( - format!("Failed to stop Docker Compose service '{}'.", service_name).into(), - ); + bail!("Failed to stop Docker Compose service '{}'.", service_name); } } diff --git a/src/env/config.rs b/src/env/config.rs index e8498f2..a4e7809 100644 --- a/src/env/config.rs +++ b/src/env/config.rs @@ -1,6 +1,7 @@ use std::env; use std::path::PathBuf; +use anyhow::{Context, Result}; use directories::BaseDirs; use crate::arguments::Arguments; @@ -16,45 +17,28 @@ pub struct DevToolsConf { impl DevToolsConf { pub fn project_dir_as_string(&self) -> String { - self.project_directory - .as_os_str() - .to_str() - .unwrap() - .to_string() + self.project_directory.to_string_lossy().into_owned() } } -pub fn load_config(arguments: &Arguments) -> Result> { - let base_dir: BaseDirs = match BaseDirs::new() { - Some(dirs) => dirs, - None => { - return Err("No config folder found.".into()); - } - }; +pub fn load_config(arguments: &Arguments) -> Result { + let base_dir: BaseDirs = BaseDirs::new().context("No config folder found.")?; let config_file_path: PathBuf = base_dir.config_dir().join("rcc/").join("dev"); - match dotenv::from_path(config_file_path.as_path()) { - Ok(env) => env, - Err(_) => { - return Err(format!( - "Could not load env file {}", - config_file_path.as_path().to_str().unwrap() - ) - .into()); - } - }; + dotenv::from_path(config_file_path.as_path()) + .with_context(|| format!("Could not load env file {}", config_file_path.display()))?; let container_service: String = env::var(DOCKER_SERVICE) - .map_err(|e| format!("{} missing in env file. {}", DOCKER_SERVICE, e))?; - let container_dir: String = - env::var(DOCKER_DIR).map_err(|e| format!("{} missing in env file. {}", DOCKER_DIR, e))?; + .with_context(|| format!("{} missing in env file.", DOCKER_SERVICE))?; + let container_dir: String = env::var(DOCKER_DIR) + .with_context(|| format!("{} missing in env file.", DOCKER_DIR))?; let mut project_directory: PathBuf = env::current_dir()?; if !arguments.project.is_empty() { let config_to_read = format!("{}_DIR", arguments.project.to_uppercase()); let dir: String = env::var(&config_to_read) - .map_err(|e| format!("{} missing in env file. {}", &config_to_read, e))?; + .with_context(|| format!("{} missing in env file.", &config_to_read))?; project_directory = PathBuf::from(dir); } diff --git a/src/git/command.rs b/src/git/command.rs index 4ef7a26..bb80487 100644 --- a/src/git/command.rs +++ b/src/git/command.rs @@ -1,8 +1,10 @@ use std::process::Command; +use anyhow::Result; + use crate::{config::parse::GLOBAL_CONNECTION_PATH, env::config::DevToolsConf}; -pub fn toggle_index(start: bool, config: &DevToolsConf) -> Result<(), Box> { +pub fn toggle_index(start: bool, config: &DevToolsConf) -> Result<()> { let change: &str = match start { true => "--assume-unchanged", false => "--no-assume-unchanged", diff --git a/src/main.rs b/src/main.rs index 39dbcd2..a9d9a15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +use anyhow::Result; use container::docker::start_docker_compose; use env::config::{load_config, DevToolsConf}; @@ -14,7 +15,7 @@ mod container; mod env; mod git; -fn main() -> Result<(), Box> { +fn main() -> Result<()> { let args: Arguments = Arguments::parse(); args.validate()?; let config: DevToolsConf = load_config(&args)?;