use clap::{ArgAction, Parser, ValueEnum};
use serde::Deserialize;
const DEFAULT_INTERVAL_SECS: u64 = 1;
const DEFAULT_CONFIG_FILE: &str = "resource-tracker.toml";
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
pub enum OutputFormat {
Json,
Csv,
}
#[derive(Debug, Default, Deserialize)]
struct TomlConfig {
job: Option<TomlJob>,
tracker: Option<TomlTracker>,
}
#[derive(Debug, Deserialize)]
struct TomlJob {
name: Option<String>,
pid: Option<i32>,
}
#[derive(Debug, Deserialize)]
struct TomlTracker {
interval_secs: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct JobMetadata {
pub project_name: Option<String>,
pub job_name: Option<String>,
pub stage_name: Option<String>,
pub task_name: Option<String>,
pub team: Option<String>,
pub env: Option<String>,
pub language: Option<String>,
pub orchestrator: Option<String>,
pub executor: Option<String>,
pub external_run_id: Option<String>,
pub container_image: Option<String>,
pub tags: Vec<String>,
pub command: Vec<String>,
}
#[derive(Debug, Parser)]
#[command(
name = "resource-tracker",
about = "Lightweight Linux resource & GPU tracker.\n\n\
Shell-wrapper mode: resource-tracker [FLAGS] -- <command> [args...]\n\
The tracker will spawn <command>, monitor it, and exit when it exits.",
version
)]
struct Cli {
#[arg(short = 'p', long, value_name = "PID")]
pid: Option<i32>,
#[arg(short = 'i', long, value_name = "SECS")]
interval: Option<u64>,
#[arg(short = 'c', long, value_name = "FILE", default_value = DEFAULT_CONFIG_FILE)]
config: String,
#[arg(short = 'f', long, value_name = "FORMAT", default_value = "json")]
format: OutputFormat,
#[arg(short = 'o', long, value_name = "FILE", env = "TRACKER_OUTPUT")]
output: Option<String>,
#[arg(long, env = "TRACKER_QUIET")]
quiet: bool,
#[arg(long, value_name = "NAME", env = "TRACKER_PROJECT_NAME")]
project_name: Option<String>,
#[arg(short = 'n', long, value_name = "NAME", env = "TRACKER_JOB_NAME")]
job_name: Option<String>,
#[arg(long, value_name = "NAME", env = "TRACKER_STAGE_NAME")]
stage_name: Option<String>,
#[arg(long, value_name = "NAME", env = "TRACKER_TASK_NAME")]
task_name: Option<String>,
#[arg(long, value_name = "NAME", env = "TRACKER_TEAM")]
team: Option<String>,
#[arg(long, value_name = "ENV", env = "TRACKER_ENV")]
env: Option<String>,
#[arg(long, value_name = "LANG", env = "TRACKER_LANGUAGE")]
language: Option<String>,
#[arg(long, value_name = "NAME", env = "TRACKER_ORCHESTRATOR")]
orchestrator: Option<String>,
#[arg(long, value_name = "NAME", env = "TRACKER_EXECUTOR")]
executor: Option<String>,
#[arg(long, value_name = "ID", env = "TRACKER_EXTERNAL_RUN_ID")]
external_run_id: Option<String>,
#[arg(long, value_name = "IMAGE", env = "TRACKER_CONTAINER_IMAGE")]
container_image: Option<String>,
#[arg(long = "tag", value_name = "KEY=VALUE", action = ArgAction::Append)]
tags: Vec<String>,
#[arg(
trailing_var_arg = true,
allow_hyphen_values = true,
value_name = "COMMAND"
)]
command: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Config {
pub pid: Option<i32>,
pub interval_secs: u64,
pub format: OutputFormat,
pub output_file: Option<String>,
pub quiet: bool,
pub metadata: JobMetadata,
pub command: Vec<String>,
}
impl Config {
pub fn load() -> Self {
let cli = Cli::parse();
let toml: TomlConfig = std::fs::read_to_string(&cli.config)
.ok()
.and_then(|s| toml::from_str(&s).ok())
.unwrap_or_default();
let interval_secs = cli
.interval
.or_else(|| toml.tracker.as_ref().and_then(|t| t.interval_secs))
.unwrap_or(DEFAULT_INTERVAL_SECS);
if interval_secs == 0 {
eprintln!("error: --interval must be >= 1 (got 0)");
std::process::exit(1);
}
let pid = cli.pid.or_else(|| toml.job.as_ref().and_then(|j| j.pid));
let metadata = JobMetadata {
project_name: cli.project_name,
job_name: cli
.job_name
.or_else(|| toml.job.as_ref().and_then(|j| j.name.clone())),
stage_name: cli.stage_name,
task_name: cli.task_name,
team: cli.team,
env: cli.env,
language: cli.language,
orchestrator: cli.orchestrator,
executor: cli.executor,
external_run_id: cli.external_run_id,
container_image: cli.container_image,
tags: cli.tags,
command: cli.command.clone(),
};
Config {
pid,
interval_secs,
format: cli.format,
output_file: cli.output,
quiet: cli.quiet,
metadata,
command: cli.command,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_toml_config_deserializes() {
let toml_str = r#"
[job]
name = "benchmark"
pid = 12345
[tracker]
interval_secs = 5
"#;
let cfg: TomlConfig = toml::from_str(toml_str).expect("TOML parse failed");
let job = cfg.job.as_ref().expect("job section missing");
assert_eq!(job.name.as_deref(), Some("benchmark"));
assert_eq!(job.pid, Some(12345));
let tracker = cfg.tracker.as_ref().expect("tracker section missing");
assert_eq!(tracker.interval_secs, Some(5));
}
#[test]
fn test_toml_config_default_is_all_none() {
let cfg = TomlConfig::default();
assert!(cfg.job.is_none(), "job must be None in default TomlConfig");
assert!(
cfg.tracker.is_none(),
"tracker must be None in default TomlConfig"
);
}
#[test]
fn test_job_metadata_default_all_none() {
let m = JobMetadata::default();
assert!(m.project_name.is_none());
assert!(m.job_name.is_none());
assert!(m.stage_name.is_none());
assert!(m.task_name.is_none());
assert!(m.team.is_none());
assert!(m.env.is_none());
assert!(m.language.is_none());
assert!(m.orchestrator.is_none());
assert!(m.executor.is_none());
assert!(m.external_run_id.is_none());
assert!(m.container_image.is_none());
assert!(
m.tags.is_empty(),
"tags must be empty in default JobMetadata"
);
}
#[test]
fn test_output_format_equality() {
assert_eq!(OutputFormat::Json, OutputFormat::Json);
assert_eq!(OutputFormat::Csv, OutputFormat::Csv);
assert_ne!(OutputFormat::Json, OutputFormat::Csv);
}
#[test]
fn test_toml_config_ignores_unknown_keys() {
let toml_str = r#"
[job]
name = "run1"
unknown_field = "ignored"
"#;
let result: Result<TomlConfig, _> = toml::from_str(toml_str);
let _ = result; }
}