mod aggregate;
mod display;
mod input;
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde::Serialize;
use std::path::PathBuf;
use std::time::Instant;
use crate::cli::commands::pipeline_error::elapsed_ms;
use crate::cli::commands::profile_types::*;
use aggregate::{aggregate_run, merge_run};
use display::show_summary;
use input::read_events;
pub use input::Event;
#[derive(Args, Debug, Clone)]
#[command(about = "Manage multi-run profiles for stability analysis")]
pub struct ProfileArgs {
#[command(subcommand)]
pub cmd: ProfileCmd,
}
#[derive(Subcommand, Debug, Clone)]
pub enum ProfileCmd {
Init(InitArgs),
Update(UpdateArgs),
Show(ShowArgs),
}
#[derive(Args, Debug, Clone)]
pub struct InitArgs {
#[arg(short, long, default_value = "assay-profile.yaml")]
pub output: PathBuf,
#[arg(long, default_value = "default")]
pub name: String,
#[arg(long)]
pub scope: Option<String>,
}
#[derive(Args, Debug, Clone)]
pub struct UpdateArgs {
#[arg(long)]
pub profile: PathBuf,
#[arg(short, long)]
pub input: PathBuf,
#[arg(long)]
pub run_id: String,
#[arg(long)]
pub strict: bool,
#[arg(long)]
pub scope: Option<String>,
#[arg(long)]
pub force: bool,
#[arg(short, long)]
pub verbose: bool,
}
#[derive(Args, Debug, Clone)]
pub struct ShowArgs {
#[arg(long)]
pub profile: PathBuf,
#[arg(long, default_value = "summary")]
pub format: String,
#[arg(long, default_value_t = 10)]
pub top: usize,
}
pub fn run(args: ProfileArgs) -> Result<i32> {
match args.cmd {
ProfileCmd::Init(a) => cmd_init(a),
ProfileCmd::Update(a) => cmd_update(a),
ProfileCmd::Show(a) => cmd_show(a),
}
}
#[derive(Debug, Serialize)]
struct ProfilePerfMetrics {
load_profile_ms: u64,
read_events_ms: u64,
aggregate_ms: u64,
merge_ms: u64,
save_profile_ms: u64,
profile_store_ms: u64,
total_ms: u64,
run_entries: usize,
run_id_window_len: usize,
run_id_digest_window_len: usize,
run_id_memory_bytes: u64,
}
fn cmd_init(args: InitArgs) -> Result<i32> {
if args.output.exists() {
anyhow::bail!("profile already exists: {}", args.output.display());
}
let profile = Profile::new(&args.name, args.scope);
save_profile(&profile, &args.output)?;
eprintln!("Created profile: {}", args.output.display());
Ok(0)
}
fn enforce_scope(profile: &mut Profile, new_scope: Option<&String>, force: bool) -> Result<()> {
if let Some(ref current_scope) = profile.scope {
if let Some(scope) = new_scope {
if current_scope != scope {
if force {
eprintln!(
"WARNING: Scope mismatch (profile='{}', update='{}'). Forcing update.",
current_scope, scope
);
} else {
anyhow::bail!(
"Scope mismatch: profile scope is '{}' but update scope is '{}'. \
This prevents accidentally merging runs from different configurations. \
Use --force to override.",
current_scope,
scope
);
}
}
}
} else if let Some(scope) = new_scope {
eprintln!("Setting profile scope to '{}'", scope);
profile.scope = Some(scope.clone());
}
Ok(())
}
fn cmd_update(args: UpdateArgs) -> Result<i32> {
let total_start = Instant::now();
let load_start = Instant::now();
let mut profile = load_profile(&args.profile)
.with_context(|| format!("failed to load profile: {}", args.profile.display()))?;
let load_profile_ms = elapsed_ms(load_start);
enforce_scope(&mut profile, args.scope.as_ref(), args.force)?;
if profile.has_run(&args.run_id) {
if args.strict {
anyhow::bail!("run_id '{}' already merged (strict mode)", args.run_id);
}
eprintln!("Skipping: run_id '{}' already merged", args.run_id);
return Ok(0);
}
let read_start = Instant::now();
let events = read_events(&args.input)?;
let read_events_ms = elapsed_ms(read_start);
if events.is_empty() {
eprintln!("Warning: no events in input");
}
let aggregate_start = Instant::now();
let run_data = aggregate_run(&events);
let aggregate_ms = elapsed_ms(aggregate_start);
let run_entries = run_data.files.len() + run_data.network.len() + run_data.processes.len();
if args.verbose {
eprintln!(
"Run {}: {} files, {} network, {} processes",
args.run_id,
run_data.files.len(),
run_data.network.len(),
run_data.processes.len()
);
}
let merge_start = Instant::now();
let (new_count, updated_count) = merge_run(&mut profile, &run_data);
let merge_ms = elapsed_ms(merge_start);
profile.total_runs += 1;
let run_id_digest_evicted = profile.add_run_id(args.run_id.clone());
profile.updated_at = chrono::Utc::now().to_rfc3339();
let save_start = Instant::now();
save_profile(&profile, &args.profile)?;
let save_profile_ms = elapsed_ms(save_start);
let profile_store_ms = load_profile_ms
.saturating_add(merge_ms)
.saturating_add(save_profile_ms);
let total_ms = elapsed_ms(total_start);
let perf = ProfilePerfMetrics {
load_profile_ms,
read_events_ms,
aggregate_ms,
merge_ms,
save_profile_ms,
profile_store_ms,
total_ms,
run_entries,
run_id_window_len: profile.run_ids.len(),
run_id_digest_window_len: profile.run_id_digests.len(),
run_id_memory_bytes: profile.run_id_memory_bytes_estimate(),
};
eprintln!(
"Updated profile: {} total runs, {} new entries, {} updated",
profile.total_runs, new_count, updated_count
);
if args.verbose || profile_store_ms >= 500 {
eprintln!(
"profile-perf: load={}ms read={}ms aggregate={}ms merge={}ms save={}ms store={}ms total={}ms entries={}",
perf.load_profile_ms,
perf.read_events_ms,
perf.aggregate_ms,
perf.merge_ms,
perf.save_profile_ms,
perf.profile_store_ms,
perf.total_ms,
perf.run_entries
);
}
if perf.load_profile_ms > 500 {
eprintln!(
"WARNING: profile load is slow ({}ms > 500ms trigger)",
perf.load_profile_ms
);
}
if perf.merge_ms > 1_000 {
eprintln!(
"WARNING: profile merge is slow ({}ms > 1000ms trigger)",
perf.merge_ms
);
}
if run_id_digest_evicted {
eprintln!(
"WARNING: run-id digest window is full ({} entries); old run-id dedupe evidence will be evicted over time",
perf.run_id_digest_window_len
);
}
if let Ok(path) = std::env::var("ASSAY_PROFILE_PERF_JSON") {
let json = serde_json::to_string_pretty(&perf)?;
std::fs::write(&path, json)
.with_context(|| format!("failed to write profile perf json: {}", path))?;
eprintln!("Wrote profile perf metrics: {}", path);
}
Ok(0)
}
fn cmd_show(args: ShowArgs) -> Result<i32> {
let profile = load_profile(&args.profile)?;
match args.format.as_str() {
"json" => println!("{}", serde_json::to_string_pretty(&profile)?),
"yaml" => println!("{}", serde_yaml::to_string(&profile)?),
_ => show_summary(&profile, args.top),
}
Ok(0)
}
#[cfg(test)]
mod tests;