From e0aa44fd107b6e96d29cae8c240f4280a70e52a9 Mon Sep 17 00:00:00 2001 From: kvillers Date: Tue, 5 May 2026 22:07:44 +0200 Subject: [PATCH] elevated logging features --- .github/copilot-instructions.md | 7 ++- src/main.rs | 81 +++++++++++++++------------------ src/tools/env.rs | 43 +++++++++++++++++ src/tools/logger.rs | 63 +++++++++++++++++++++++-- src/tools/mod.rs | 2 +- src/tools/request.rs | 11 +++-- 6 files changed, 152 insertions(+), 55 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 794e7a9..7631b2c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,6 +3,7 @@ ## Build, test, and lint - `cargo build` +- `cargo check` - **after every code edit/mod/remove/add, always run this to verify compilation** - `cargo run` - requires a local `.env` file with `ANTARES_USERCODE` and `ANTARES_PASSWORD` - `cargo test` - `cargo test ` to run one test @@ -32,8 +33,9 @@ This is a modular single-binary Rust utility that fetches Antares B2B product da 8. Log success/failure at each step **Logging:** -- All significant events logged to terminal and `log/{YYYY-MM-DD}.log` -- Specialized methods: `log_api_success()`, `log_api_failure()`, `log_export_success()`, `log_export_failure()` +- All significant events logged to terminal and `log/{YYYY-MM-DD}.log` with format: `[TIMESTAMP] [LEVEL] Message` +- Log levels: `DEBUG`, `INFO` (default), `WARN`, `ERROR`; control via `LOG_LEVEL` env var +- Methods: `.info()`, `.debug()`, `.warn()`, `.error()` for appropriate log levels - Directory created automatically if missing; logs appended daily by date ## Key conventions @@ -41,6 +43,7 @@ This is a modular single-binary Rust utility that fetches Antares B2B product da **Credentials:** - Load via `URL`, `ANTARES_USERCODE`, `ANTARES_PASSWORD`, and `OUT` env vars from `.env` - `URL` is the Antares B2B base URL; `OUT` is the output file path for the Excel export +- Use `--config /path/to/.env` CLI argument to load config from custom location (defaults to `.env` in current directory) - `.env.example` documents required variables - No hardcoded credentials; missing vars cause graceful failure with instructions - `PASSWORD` is wrapped in `Zeroizing` (from the `zeroize` crate) to securely wipe it from memory after use diff --git a/src/main.rs b/src/main.rs index 230bb4e..3590706 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ mod api; mod template; mod tools; -use zeroize::Zeroizing; use std::{ error::Error, fs, @@ -16,51 +15,36 @@ use api::make_url; use template::antares::{AntaresLogin, Antares}; use tools::{ Logger, - get_env, + load_env_config, make_request, export_to_excel }; -const URL_KEY: &str = "URL"; -const USERCODE_KEY: &str = "ANTARES_USERCODE"; -const PASSWORD_KEY: &str = "ANTARES_PASSWORD"; -const OUT_KEY: &str = "OUT"; - fn main() -> Result<(), Box> { let logger = Logger::init()?; - logger.log("Starting Antares data export"); + logger.info("Starting Antares data export"); - dotenv::dotenv().ok(); + // Parse CLI arguments for --config flag + let args: Vec = std::env::args().collect(); + let config_path = parse_config_arg(&args); - let antares_login = AntaresLogin{ - url: get_env(URL_KEY) - .map_err(|e| { - logger.log(&e); - e - })?, - usercode: get_env(USERCODE_KEY) - .map_err(|e| { - logger.log(&e); - e - })?, - password: Zeroizing::new( - get_env(PASSWORD_KEY) - .map_err(|e| { - logger.log(&e); - e - })? - ) - }; - - let out_path = get_env(OUT_KEY) + let env_config = load_env_config(config_path) .map_err(|e| { - logger.log(&e); + logger.error(&e); e })?; + let antares_login = AntaresLogin{ + url: env_config.url, + usercode: env_config.usercode, + password: env_config.password, + }; + + let out_path = env_config.out_path; + let response = match make_request(&make_url(antares_login), &logger) { Ok(resp) => { - logger.log("API request sent successfully"); + logger.info("API request sent successfully"); resp } Err(e) => { @@ -73,7 +57,7 @@ fn main() -> Result<(), Box> { let antares_file = PathBuf::from(temp_dir.clone()) .join("antares.json"); fs::create_dir_all(&temp_dir).map_err(|e| { - logger.log(&format!("Failed to create temp directory '{:#?}': {}", temp_dir, e)); + logger.error(&format!("Failed to create temp directory '{:#?}': {}", temp_dir, e)); Box::new(IO_Error::new(Other, e.to_string())) })?; @@ -84,15 +68,15 @@ fn main() -> Result<(), Box> { let pretty = match serde_json::to_string_pretty(&items) { Ok(pretty) => pretty, Err(e) => { - logger.log(&format!("Failed to serialize items: {}", e)); + logger.error(&format!("Failed to serialize items: {}", e)); return Err(Box::new(e)); } }; fs::write(&antares_file, &pretty).map_err(|e| { - logger.log(&format!("Failed to write {:#?}: {}", antares_file, e)); + logger.error(&format!("Failed to write {:#?}: {}", antares_file, e)); Box::new(IO_Error::new(Other, e.to_string())) })?; - logger.log(&format!("API call successful - Fetched {} items", items.len())); + logger.info(&format!("API call successful - Fetched {} items", items.len())); items } Err(_) => { @@ -101,21 +85,21 @@ fn main() -> Result<(), Box> { let pretty = match serde_json::to_string_pretty(&json_val) { Ok(pretty) => pretty, Err(e) => { - logger.log(&format!("Failed to serialize JSON: {}", e)); + logger.error(&format!("Failed to serialize JSON: {}", e)); return Err(Box::new(e)); } }; fs::write(&antares_file, &pretty).map_err(|e| { - logger.log(&format!("Failed to write {:#?}: {}", antares_file, e)); + logger.error(&format!("Failed to write {:#?}: {}", antares_file, e)); Box::new(IO_Error::new(Other, e.to_string())) })?; - logger.log("Response saved as JSON (schema mismatch)"); + logger.warn("Response saved as JSON (schema mismatch)"); } else { fs::write(&antares_file, &response).map_err(|e| { - logger.log(&format!("Failed to write {:#?}: {}", antares_file, e)); + logger.error(&format!("Failed to write {:#?}: {}", antares_file, e)); Box::new(IO_Error::new(Other, e.to_string())) })?; - logger.log("Response saved as raw text (not valid JSON)"); + logger.warn("Response saved as raw text (not valid JSON)"); } Vec::new() } @@ -124,14 +108,23 @@ fn main() -> Result<(), Box> { // Export to Excel match export_to_excel(&items, &out_path) { Ok(_) => { - logger.log(&format!("Export successful - {} rows exported to {}", items.len(), out_path)); + logger.info(&format!("Export successful - {} rows exported to {}", items.len(), out_path)); } Err(e) => { - logger.log(&format!("Export failed: {}", e)); + logger.error(&format!("Export failed: {}", e)); return Err(e); } } - logger.log("Export process completed successfully"); + logger.info("Export process completed successfully"); Ok(()) } + +fn parse_config_arg(args: &[String]) -> Option<&str> { + for i in 0..args.len() { + if args[i] == "--config" && i + 1 < args.len() { + return Some(&args[i + 1]); + } + } + None +} diff --git a/src/tools/env.rs b/src/tools/env.rs index 2d32def..bf668de 100644 --- a/src/tools/env.rs +++ b/src/tools/env.rs @@ -1,5 +1,7 @@ // .env handling use std::env; +use std::path::Path; +use zeroize::Zeroizing; pub fn get_env(key_name: &str) -> Result { match env::var(key_name) { @@ -10,4 +12,45 @@ pub fn get_env(key_name: &str) -> Result { e )) } +} + +pub struct EnvConfig { + pub url: String, + pub usercode: String, + pub password: Zeroizing, + pub out_path: String, +} + +pub fn load_env_config(config_path: Option<&str>) -> Result { + // Load .env from specified path or default to .env in current directory + if let Some(path) = config_path { + if !Path::new(path).exists() { + return Err(format!("Config file not found: {}", path)); + } + dotenv::from_filename(path).map_err(|e| { + format!("Failed to load config from {}: {}", path, e) + })?; + } else { + // Default: try to load .env from current directory + let default_path = ".env"; + if Path::new(default_path).exists() { + dotenv::dotenv().ok(); + } else { + return Err(format!( + "No .env file found in current directory. Use --config /path/to/.env to specify a custom path." + )); + } + } + + let url = get_env("URL")?; + let usercode = get_env("ANTARES_USERCODE")?; + let password = Zeroizing::new(get_env("ANTARES_PASSWORD")?); + let out_path = get_env("OUT")?; + + Ok(EnvConfig { + url, + usercode, + password, + out_path, + }) } \ No newline at end of file diff --git a/src/tools/logger.rs b/src/tools/logger.rs index d309bd7..de11857 100644 --- a/src/tools/logger.rs +++ b/src/tools/logger.rs @@ -6,12 +6,46 @@ use std::{ create_dir_all }, io::Write, - path::PathBuf + path::PathBuf, + env }; use chrono::Local; +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum LogLevel { + Debug = 0, + Info = 1, + Warn = 2, + Error = 3, +} + +impl LogLevel { + fn as_str(&self) -> &'static str { + match self { + LogLevel::Debug => "DEBUG", + LogLevel::Info => "INFO", + LogLevel::Warn => "WARN", + LogLevel::Error => "ERROR", + } + } + + fn from_env() -> Self { + match env::var("LOG_LEVEL") + .unwrap_or_else(|_| "INFO".to_string()) + .to_uppercase() + .as_str() + { + "DEBUG" => LogLevel::Debug, + "WARN" => LogLevel::Warn, + "ERROR" => LogLevel::Error, + _ => LogLevel::Info, + } + } +} + pub struct Logger { log_file: PathBuf, + min_level: LogLevel, } impl Logger { @@ -21,13 +55,18 @@ impl Logger { let today = Local::now().format("%Y-%m-%d").to_string(); let log_file = log_dir.join(format!("{}.log", today)); + let min_level = LogLevel::from_env(); - Ok(Logger { log_file }) + Ok(Logger { log_file, min_level }) } - pub fn log(&self, message: &str) { + pub fn log_with_level(&self, level: LogLevel, message: &str) { + if level < self.min_level { + return; + } + let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); - let formatted = format!("[{}] {}", timestamp, message); + let formatted = format!("[{}] [{}] {}", timestamp, level.as_str(), message); println!("{}", formatted); @@ -39,4 +78,20 @@ impl Logger { let _ = writeln!(file, "{}", formatted); } } + + pub fn debug(&self, message: &str) { + self.log_with_level(LogLevel::Debug, message); + } + + pub fn info(&self, message: &str) { + self.log_with_level(LogLevel::Info, message); + } + + pub fn warn(&self, message: &str) { + self.log_with_level(LogLevel::Warn, message); + } + + pub fn error(&self, message: &str) { + self.log_with_level(LogLevel::Error, message); + } } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 0d493aa..de5f595 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -4,6 +4,6 @@ pub mod env; pub mod request; pub use logger::Logger; -pub use env::get_env; +pub use env::load_env_config; pub use request::make_request; pub use excel::export_to_excel; diff --git a/src/tools/request.rs b/src/tools/request.rs index ea0949a..76dc695 100644 --- a/src/tools/request.rs +++ b/src/tools/request.rs @@ -22,25 +22,28 @@ pub fn get_client() -> Result { pub fn make_request(url: &str, logger: &Logger) -> Result> { let client = get_client() .map_err(|e| { - logger.log(&format!("Failed to create HTTP client: {}", e)); + logger.error(&format!("Failed to create HTTP client: {}", e)); Box::new(IO_Error::new(Other, e.to_string())) })?; + logger.debug(&format!("Making HTTP GET request to: {}", url)); + let response = client.get(url) .send() .map_err(|e| { - logger.log(&format!("HTTP request failed: {}", e)); + logger.error(&format!("HTTP request failed: {}", e)); Box::new(IO_Error::new(ConnectionRefused, e.to_string())) })?; if response.status().is_success() { + logger.debug(&format!("HTTP request successful with status: {}", response.status())); Ok(response.text().map_err(|e| { - logger.log(&format!("Failed to read response body: {}", e)); + logger.error(&format!("Failed to read response body: {}", e)); Box::new(IO_Error::new(ConnectionRefused, e.to_string())) })?) } else { let error_msg = format!("HTTP error: {:?}", response.status()); - logger.log(&error_msg); + logger.error(&error_msg); Err(Box::new(IO_Error::new(ConnectionRefused, error_msg))) } }