implemented proper error handling, added README

This commit is contained in:
2026-06-26 12:30:19 +02:00
parent 649c08ad4b
commit 481e3a1dcc
8 changed files with 203 additions and 68 deletions
+2
View File
@@ -1,2 +1,4 @@
/target /target
/conf /conf
CLAUDE.md
.claude
+70
View File
@@ -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` |
| `<PROJECT>_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 <project> <up|down> [--legacy]
```
| 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` |
### 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 `<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.
## Tests
```bash
cargo test
```
+32 -2
View File
@@ -1,3 +1,4 @@
use anyhow::{bail, Result};
use clap::Parser; use clap::Parser;
#[derive(Parser, Default, Debug)] #[derive(Parser, Default, Debug)]
@@ -22,11 +23,40 @@ pub struct Arguments {
} }
impl Arguments { impl Arguments {
pub fn validate(&self) -> Result<(), Box<dyn std::error::Error>> { pub fn validate(&self) -> Result<()> {
if self.action == "up" || self.action == "down" { if self.action == "up" || self.action == "down" {
return Ok(()); 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"));
} }
} }
+71 -18
View File
@@ -3,14 +3,14 @@ use std::{
io::{BufRead, BufReader}, io::{BufRead, BufReader},
}; };
use anyhow::Context; use anyhow::{Context, Result};
use crate::env::config::DevToolsConf; 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";
/// writes global connections /// writes global connections
pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<(), Box<dyn std::error::Error>> { pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<()> {
let global_connections_env = format!( let global_connections_env = format!(
"{}{}", "{}{}",
config.project_dir_as_string(), config.project_dir_as_string(),
@@ -20,37 +20,35 @@ pub fn set_local_db(start: bool, config: &DevToolsConf) -> Result<(), Box<dyn st
.with_context(|| format!("could not read file `{}`", global_connections_env))?; .with_context(|| format!("could not read file `{}`", global_connections_env))?;
let reader: BufReader<File> = BufReader::new(file); let reader: BufReader<File> = BufReader::new(file);
let modified_lines: Vec<String> = reader let mut modified_lines: Vec<String> = Vec::new();
.lines() for line in reader.lines() {
.map(|line| { let line_content = line?;
let line_content: String = line.unwrap(); let modified = if start && connections_env_comment(&line_content) {
if start && connections_env_comment(&line_content) {
format!("# {}", line_content) format!("# {}", line_content)
} else if !start && should_commented_out(&line_content) { } else if !start && should_commented_out(&line_content) {
line_content[2..].to_string() line_content[2..].to_string()
} else { } else {
line_content line_content
};
modified_lines.push(modified);
} }
})
.collect();
fs::write(global_connections_env, modified_lines.join("\n")) fs::write(global_connections_env, modified_lines.join("\n"))
.expect("Cannot write global connections env"); .context("Cannot write global connections env")?;
Ok(()) Ok(())
} }
pub fn set_dot_env(start: bool, config: &DevToolsConf) -> Result<(), Box<dyn std::error::Error>> { pub fn set_dot_env(start: bool, config: &DevToolsConf) -> Result<()> {
toggle_after_line("#docker", 4, start, config)?; toggle_after_line("#docker", 4, start, config)?;
toggle_after_line("#cidb", 2, !start, config)?; toggle_after_line("#cidb", 2, !start, config)?;
Ok(()) 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=") 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=") 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, num_lines: usize,
activate: bool, activate: bool,
config: &DevToolsConf, config: &DevToolsConf,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<()> {
let local_connection_env = format!( let local_connection_env = format!(
"{}{}", "{}{}",
config.project_dir_as_string(), 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"))
fs::write(local_connection_env, modified_lines.join("\n")).expect(""); .context("Cannot write local connection env")?;
Ok(()) Ok(())
} }
fn toggle(line: &str, activate: bool) -> String { pub(crate) fn toggle(line: &str, activate: bool) -> String {
if activate { if activate {
return line.replacen('#', "", 1); return line.replacen('#', "", 1);
} }
@@ -117,3 +115,58 @@ fn toggle(line: &str, activate: bool) -> String {
line.to_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"));
}
}
+7 -14
View File
@@ -3,18 +3,15 @@ use std::{
process::{Command, ExitStatus}, process::{Command, ExitStatus},
}; };
use anyhow::{bail, Context, Result};
use directories::BaseDirs; use directories::BaseDirs;
fn is_container_running(container_name: &str) -> Result<bool, Box<dyn std::error::Error>> { fn is_container_running(container_name: &str) -> Result<bool> {
// Run the `docker ps` command and capture the output
let output = Command::new("docker") let output = Command::new("docker")
.args(["ps", "--format", "{{.Names}}"]) .args(["ps", "--format", "{{.Names}}"])
.output()?; .output()?;
// Convert the output to a string
let output_str = String::from_utf8_lossy(&output.stdout); 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); let running: bool = output_str.lines().any(|line| line.trim() == container_name);
Ok(running) Ok(running)
@@ -24,11 +21,11 @@ pub fn start_docker_compose(
service_name: &str, service_name: &str,
container_path: &str, container_path: &str,
start: bool, start: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<()> {
let base_dir: BaseDirs = BaseDirs::new().unwrap(); let base_dir: BaseDirs = BaseDirs::new().context("Could not determine home directory")?;
let absolute = base_dir.home_dir().join(container_path); let absolute = base_dir.home_dir().join(container_path);
let compose_path = Path::new(&absolute); 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)?; let running: bool = is_container_running(service_name)?;
@@ -49,9 +46,7 @@ pub fn start_docker_compose(
service_name service_name
); );
} else { } else {
return Err( bail!("Failed to start Docker Compose service '{}'.", service_name);
format!("Failed to start Docker Compose service '{}'.", service_name).into(),
);
} }
} }
@@ -67,9 +62,7 @@ pub fn start_docker_compose(
service_name service_name
); );
} else { } else {
return Err( bail!("Failed to stop Docker Compose service '{}'.", service_name);
format!("Failed to stop Docker Compose service '{}'.", service_name).into(),
);
} }
} }
+10 -26
View File
@@ -1,6 +1,7 @@
use std::env; use std::env;
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{Context, Result};
use directories::BaseDirs; use directories::BaseDirs;
use crate::arguments::Arguments; use crate::arguments::Arguments;
@@ -16,45 +17,28 @@ pub struct DevToolsConf {
impl DevToolsConf { impl DevToolsConf {
pub fn project_dir_as_string(&self) -> String { pub fn project_dir_as_string(&self) -> String {
self.project_directory self.project_directory.to_string_lossy().into_owned()
.as_os_str()
.to_str()
.unwrap()
.to_string()
} }
} }
pub fn load_config(arguments: &Arguments) -> Result<DevToolsConf, Box<dyn std::error::Error>> { pub fn load_config(arguments: &Arguments) -> Result<DevToolsConf> {
let base_dir: BaseDirs = match BaseDirs::new() { let base_dir: BaseDirs = BaseDirs::new().context("No config folder found.")?;
Some(dirs) => dirs,
None => {
return Err("No config folder found.".into());
}
};
let config_file_path: PathBuf = base_dir.config_dir().join("rcc/").join("dev"); let config_file_path: PathBuf = base_dir.config_dir().join("rcc/").join("dev");
match dotenv::from_path(config_file_path.as_path()) { dotenv::from_path(config_file_path.as_path())
Ok(env) => env, .with_context(|| format!("Could not load env file {}", config_file_path.display()))?;
Err(_) => {
return Err(format!(
"Could not load env file {}",
config_file_path.as_path().to_str().unwrap()
)
.into());
}
};
let container_service: String = env::var(DOCKER_SERVICE) let container_service: String = env::var(DOCKER_SERVICE)
.map_err(|e| format!("{} missing in env file. {}", DOCKER_SERVICE, e))?; .with_context(|| format!("{} missing in env file.", DOCKER_SERVICE))?;
let container_dir: String = let container_dir: String = env::var(DOCKER_DIR)
env::var(DOCKER_DIR).map_err(|e| format!("{} missing in env file. {}", DOCKER_DIR, e))?; .with_context(|| format!("{} missing in env file.", DOCKER_DIR))?;
let mut project_directory: PathBuf = env::current_dir()?; let mut project_directory: PathBuf = env::current_dir()?;
if !arguments.project.is_empty() { if !arguments.project.is_empty() {
let config_to_read = format!("{}_DIR", arguments.project.to_uppercase()); let config_to_read = format!("{}_DIR", arguments.project.to_uppercase());
let dir: String = env::var(&config_to_read) 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); project_directory = PathBuf::from(dir);
} }
+3 -1
View File
@@ -1,8 +1,10 @@
use std::process::Command; use std::process::Command;
use anyhow::Result;
use crate::{config::parse::GLOBAL_CONNECTION_PATH, env::config::DevToolsConf}; use crate::{config::parse::GLOBAL_CONNECTION_PATH, env::config::DevToolsConf};
pub fn toggle_index(start: bool, config: &DevToolsConf) -> Result<(), Box<dyn std::error::Error>> { pub fn toggle_index(start: bool, config: &DevToolsConf) -> Result<()> {
let change: &str = match start { let change: &str = match start {
true => "--assume-unchanged", true => "--assume-unchanged",
false => "--no-assume-unchanged", false => "--no-assume-unchanged",
+2 -1
View File
@@ -1,3 +1,4 @@
use anyhow::Result;
use container::docker::start_docker_compose; use container::docker::start_docker_compose;
use env::config::{load_config, DevToolsConf}; use env::config::{load_config, DevToolsConf};
@@ -14,7 +15,7 @@ mod container;
mod env; mod env;
mod git; mod git;
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<()> {
let args: Arguments = Arguments::parse(); let args: Arguments = Arguments::parse();
args.validate()?; args.validate()?;
let config: DevToolsConf = load_config(&args)?; let config: DevToolsConf = load_config(&args)?;