Compare commits
2 Commits
96bbb0061b
...
d96e637cbf
| Author | SHA1 | Date |
|---|---|---|
|
|
d96e637cbf | |
|
|
e0aa44fd10 |
|
|
@ -3,6 +3,7 @@
|
||||||
## Build, test, and lint
|
## Build, test, and lint
|
||||||
|
|
||||||
- `cargo build`
|
- `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 run` - requires a local `.env` file with `ANTARES_USERCODE` and `ANTARES_PASSWORD`
|
||||||
- `cargo test`
|
- `cargo test`
|
||||||
- `cargo test <test_name>` to run one test
|
- `cargo test <test_name>` 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
|
8. Log success/failure at each step
|
||||||
|
|
||||||
**Logging:**
|
**Logging:**
|
||||||
- All significant events logged to terminal and `log/{YYYY-MM-DD}.log`
|
- All significant events logged to terminal and `log/{YYYY-MM-DD}.log` with format: `[TIMESTAMP] [LEVEL] Message`
|
||||||
- Specialized methods: `log_api_success()`, `log_api_failure()`, `log_export_success()`, `log_export_failure()`
|
- 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
|
- Directory created automatically if missing; logs appended daily by date
|
||||||
|
|
||||||
## Key conventions
|
## Key conventions
|
||||||
|
|
@ -41,6 +43,7 @@ This is a modular single-binary Rust utility that fetches Antares B2B product da
|
||||||
**Credentials:**
|
**Credentials:**
|
||||||
- Load via `URL`, `ANTARES_USERCODE`, `ANTARES_PASSWORD`, and `OUT` env vars from `.env`
|
- 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
|
- `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
|
- `.env.example` documents required variables
|
||||||
- No hardcoded credentials; missing vars cause graceful failure with instructions
|
- No hardcoded credentials; missing vars cause graceful failure with instructions
|
||||||
- `PASSWORD` is wrapped in `Zeroizing<String>` (from the `zeroize` crate) to securely wipe it from memory after use
|
- `PASSWORD` is wrapped in `Zeroizing<String>` (from the `zeroize` crate) to securely wipe it from memory after use
|
||||||
|
|
|
||||||
81
src/main.rs
81
src/main.rs
|
|
@ -2,7 +2,6 @@ mod api;
|
||||||
mod template;
|
mod template;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
use std::{
|
use std::{
|
||||||
error::Error,
|
error::Error,
|
||||||
fs,
|
fs,
|
||||||
|
|
@ -16,51 +15,36 @@ use api::make_url;
|
||||||
use template::antares::{AntaresLogin, Antares};
|
use template::antares::{AntaresLogin, Antares};
|
||||||
use tools::{
|
use tools::{
|
||||||
Logger,
|
Logger,
|
||||||
get_env,
|
load_env_config,
|
||||||
make_request,
|
make_request,
|
||||||
export_to_excel
|
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<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
let logger = Logger::init()?;
|
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<String> = std::env::args().collect();
|
||||||
|
let config_path = parse_config_arg(&args);
|
||||||
|
|
||||||
let antares_login = AntaresLogin{
|
let env_config = load_env_config(config_path)
|
||||||
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)
|
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
logger.log(&e);
|
logger.error(&e);
|
||||||
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) {
|
let response = match make_request(&make_url(antares_login), &logger) {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
logger.log("API request sent successfully");
|
logger.info("API request sent successfully");
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -73,7 +57,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
let antares_file = PathBuf::from(temp_dir.clone())
|
let antares_file = PathBuf::from(temp_dir.clone())
|
||||||
.join("antares.json");
|
.join("antares.json");
|
||||||
fs::create_dir_all(&temp_dir).map_err(|e| {
|
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()))
|
Box::new(IO_Error::new(Other, e.to_string()))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|
@ -84,15 +68,15 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
let pretty = match serde_json::to_string_pretty(&items) {
|
let pretty = match serde_json::to_string_pretty(&items) {
|
||||||
Ok(pretty) => pretty,
|
Ok(pretty) => pretty,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
logger.log(&format!("Failed to serialize items: {}", e));
|
logger.error(&format!("Failed to serialize items: {}", e));
|
||||||
return Err(Box::new(e));
|
return Err(Box::new(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fs::write(&antares_file, &pretty).map_err(|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()))
|
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
|
items
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
|
@ -101,21 +85,21 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
let pretty = match serde_json::to_string_pretty(&json_val) {
|
let pretty = match serde_json::to_string_pretty(&json_val) {
|
||||||
Ok(pretty) => pretty,
|
Ok(pretty) => pretty,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
logger.log(&format!("Failed to serialize JSON: {}", e));
|
logger.error(&format!("Failed to serialize JSON: {}", e));
|
||||||
return Err(Box::new(e));
|
return Err(Box::new(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fs::write(&antares_file, &pretty).map_err(|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()))
|
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 {
|
} else {
|
||||||
fs::write(&antares_file, &response).map_err(|e| {
|
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()))
|
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()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
@ -124,14 +108,23 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
// Export to Excel
|
// Export to Excel
|
||||||
match export_to_excel(&items, &out_path) {
|
match export_to_excel(&items, &out_path) {
|
||||||
Ok(_) => {
|
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) => {
|
Err(e) => {
|
||||||
logger.log(&format!("Export failed: {}", e));
|
logger.error(&format!("Export failed: {}", e));
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log("Export process completed successfully");
|
logger.info("Export process completed successfully");
|
||||||
Ok(())
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
// .env handling
|
// .env handling
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::path::Path;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
pub fn get_env(key_name: &str) -> Result<String, String> {
|
pub fn get_env(key_name: &str) -> Result<String, String> {
|
||||||
match env::var(key_name) {
|
match env::var(key_name) {
|
||||||
|
|
@ -10,4 +12,45 @@ pub fn get_env(key_name: &str) -> Result<String, String> {
|
||||||
e
|
e
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EnvConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub usercode: String,
|
||||||
|
pub password: Zeroizing<String>,
|
||||||
|
pub out_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_env_config(config_path: Option<&str>) -> Result<EnvConfig, String> {
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -6,12 +6,46 @@ use std::{
|
||||||
create_dir_all
|
create_dir_all
|
||||||
},
|
},
|
||||||
io::Write,
|
io::Write,
|
||||||
path::PathBuf
|
path::PathBuf,
|
||||||
|
env
|
||||||
};
|
};
|
||||||
use chrono::Local;
|
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 {
|
pub struct Logger {
|
||||||
log_file: PathBuf,
|
log_file: PathBuf,
|
||||||
|
min_level: LogLevel,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Logger {
|
impl Logger {
|
||||||
|
|
@ -21,13 +55,18 @@ impl Logger {
|
||||||
|
|
||||||
let today = Local::now().format("%Y-%m-%d").to_string();
|
let today = Local::now().format("%Y-%m-%d").to_string();
|
||||||
let log_file = log_dir.join(format!("{}.log", today));
|
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 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);
|
println!("{}", formatted);
|
||||||
|
|
||||||
|
|
@ -39,4 +78,20 @@ impl Logger {
|
||||||
let _ = writeln!(file, "{}", formatted);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,6 @@ pub mod env;
|
||||||
pub mod request;
|
pub mod request;
|
||||||
|
|
||||||
pub use logger::Logger;
|
pub use logger::Logger;
|
||||||
pub use env::get_env;
|
pub use env::load_env_config;
|
||||||
pub use request::make_request;
|
pub use request::make_request;
|
||||||
pub use excel::export_to_excel;
|
pub use excel::export_to_excel;
|
||||||
|
|
|
||||||
|
|
@ -22,25 +22,28 @@ pub fn get_client() -> Result<Client, reqwest::Error> {
|
||||||
pub fn make_request(url: &str, logger: &Logger) -> Result<String, Box<dyn Error>> {
|
pub fn make_request(url: &str, logger: &Logger) -> Result<String, Box<dyn Error>> {
|
||||||
let client = get_client()
|
let client = get_client()
|
||||||
.map_err(|e| {
|
.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()))
|
Box::new(IO_Error::new(Other, e.to_string()))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
logger.debug(&format!("Making HTTP GET request to: {}", url));
|
||||||
|
|
||||||
let response = client.get(url)
|
let response = client.get(url)
|
||||||
.send()
|
.send()
|
||||||
.map_err(|e| {
|
.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()))
|
Box::new(IO_Error::new(ConnectionRefused, e.to_string()))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
|
logger.debug(&format!("HTTP request successful with status: {}", response.status()));
|
||||||
Ok(response.text().map_err(|e| {
|
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()))
|
Box::new(IO_Error::new(ConnectionRefused, e.to_string()))
|
||||||
})?)
|
})?)
|
||||||
} else {
|
} else {
|
||||||
let error_msg = format!("HTTP error: {:?}", response.status());
|
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)))
|
Err(Box::new(IO_Error::new(ConnectionRefused, error_msg)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue