use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Instant;
use super::pipeline_error::elapsed_ms;
use super::profile_types::*;
#[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,
}
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Event {
FileOpen {
path: String,
#[serde(default)]
timestamp: u64,
},
NetConnect {
dest: String,
#[serde(default)]
timestamp: u64,
},
ProcExec {
path: String,
#[serde(default)]
timestamp: u64,
},
}
fn read_events(path: &PathBuf) -> Result<Vec<Event>> {
use std::io::{BufRead, BufReader};
let reader: Box<dyn BufRead> = if path.to_string_lossy() == "-" {
Box::new(BufReader::new(std::io::stdin()))
} else {
Box::new(BufReader::new(std::fs::File::open(path)?))
};
let mut events = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() || line.starts_with('#') {
continue;
}
if let Ok(e) = serde_json::from_str(&line) {
events.push(e);
}
}
Ok(events)
}
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)
}
#[derive(Debug, Default)]
struct RunData {
files: BTreeMap<String, RunEntry>,
network: BTreeMap<String, RunEntry>,
processes: BTreeMap<String, RunEntry>,
}
#[derive(Debug, Default)]
struct RunEntry {
timestamp: u64,
hits: u64,
}
fn aggregate_run(events: &[Event]) -> RunData {
let mut data = RunData::default();
for ev in events {
match ev {
Event::FileOpen { path, timestamp } => {
let e = data.files.entry(path.clone()).or_default();
e.hits += 1;
if *timestamp > e.timestamp {
e.timestamp = *timestamp;
}
}
Event::NetConnect { dest, timestamp } => {
let e = data.network.entry(dest.clone()).or_default();
e.hits += 1;
if *timestamp > e.timestamp {
e.timestamp = *timestamp;
}
}
Event::ProcExec { path, timestamp } => {
let e = data.processes.entry(path.clone()).or_default();
e.hits += 1;
if *timestamp > e.timestamp {
e.timestamp = *timestamp;
}
}
}
}
data
}
fn merge_run(profile: &mut Profile, run: &RunData) -> (usize, usize) {
let mut new_count = 0;
let mut updated_count = 0;
for (key, run_entry) in &run.files {
if let Some(entry) = profile.entries.files.get_mut(key) {
entry.merge_run(run_entry.timestamp, run_entry.hits);
updated_count += 1;
} else {
profile.entries.files.insert(
key.clone(),
ProfileEntry::new(run_entry.timestamp, run_entry.hits),
);
new_count += 1;
}
}
for (key, run_entry) in &run.network {
if let Some(entry) = profile.entries.network.get_mut(key) {
entry.merge_run(run_entry.timestamp, run_entry.hits);
updated_count += 1;
} else {
profile.entries.network.insert(
key.clone(),
ProfileEntry::new(run_entry.timestamp, run_entry.hits),
);
new_count += 1;
}
}
for (key, run_entry) in &run.processes {
if let Some(entry) = profile.entries.processes.get_mut(key) {
entry.merge_run(run_entry.timestamp, run_entry.hits);
updated_count += 1;
} else {
profile.entries.processes.insert(
key.clone(),
ProfileEntry::new(run_entry.timestamp, run_entry.hits),
);
new_count += 1;
}
}
(new_count, updated_count)
}
fn show_summary(profile: &Profile, top_n: usize) {
println!("Profile: {}", profile.name);
println!("Version: {}", profile.version);
if let Some(scope) = &profile.scope {
println!("Scope: {}", scope);
}
println!("Created: {}", profile.created_at);
println!("Updated: {}", profile.updated_at);
println!("Total runs: {}", profile.total_runs);
println!();
println!("Entries:");
println!(" Files: {}", profile.entries.files.len());
println!(" Network: {}", profile.entries.network.len());
println!(" Processes: {}", profile.entries.processes.len());
println!();
if profile.total_runs > 0 {
println!("Stability distribution (α=1.0):");
show_stability_distribution(&profile.entries.files, profile.total_runs, " Files");
show_stability_distribution(&profile.entries.network, profile.total_runs, " Network");
show_stability_distribution(
&profile.entries.processes,
profile.total_runs,
" Processes",
);
println!();
println!("Top {} most stable files:", top_n);
show_top_stable(&profile.entries.files, profile.total_runs, top_n);
if !profile.entries.network.is_empty() {
println!("\nTop {} most stable network destinations:", top_n);
show_top_stable(&profile.entries.network, profile.total_runs, top_n);
}
}
}
fn show_stability_distribution(
entries: &BTreeMap<String, ProfileEntry>,
total_runs: u32,
label: &str,
) {
if entries.is_empty() {
return;
}
let mut high = 0; let mut mid = 0; let mut low = 0;
for entry in entries.values() {
let s = stability_smoothed(entry.runs_seen, total_runs, DEFAULT_ALPHA);
if s >= 0.8 {
high += 1;
} else if s >= 0.6 {
mid += 1;
} else {
low += 1;
}
}
println!(
"{}: {} stable (≥0.8), {} medium (0.6-0.8), {} low (<0.6)",
label, high, mid, low
);
}
fn show_top_stable(entries: &BTreeMap<String, ProfileEntry>, total_runs: u32, n: usize) {
let mut sorted: Vec<_> = entries
.iter()
.map(|(k, v)| {
(
k,
v,
stability_smoothed(v.runs_seen, total_runs, DEFAULT_ALPHA),
)
})
.collect();
sorted.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap());
for (key, entry, stab) in sorted.into_iter().take(n) {
let key_short = if key.len() > 50 { &key[..50] } else { key };
println!(
" {:.2} ({:>2}/{:>2}) {}",
stab, entry.runs_seen, total_runs, key_short
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aggregate_dedup() {
let events = vec![
Event::FileOpen {
path: "/a".into(),
timestamp: 100,
},
Event::FileOpen {
path: "/a".into(),
timestamp: 200,
},
Event::FileOpen {
path: "/b".into(),
timestamp: 150,
},
];
let run = aggregate_run(&events);
assert_eq!(run.files.len(), 2);
assert_eq!(run.files["/a"].hits, 2);
assert_eq!(run.files["/a"].timestamp, 200);
}
#[test]
fn merge_new_entries() {
let mut profile = Profile::new("test", None);
let events = vec![Event::FileOpen {
path: "/a".into(),
timestamp: 100,
}];
let run = aggregate_run(&events);
let (new, updated) = merge_run(&mut profile, &run);
assert_eq!(new, 1);
assert_eq!(updated, 0);
assert_eq!(profile.entries.files["/a"].runs_seen, 1);
}
#[test]
fn merge_existing_entries() {
let mut profile = Profile::new("test", None);
profile
.entries
.files
.insert("/a".into(), ProfileEntry::new(100, 5));
let events = vec![
Event::FileOpen {
path: "/a".into(),
timestamp: 200,
},
Event::FileOpen {
path: "/a".into(),
timestamp: 200,
},
];
let run = aggregate_run(&events);
let (new, updated) = merge_run(&mut profile, &run);
assert_eq!(new, 0);
assert_eq!(updated, 1);
assert_eq!(profile.entries.files["/a"].runs_seen, 2);
assert_eq!(profile.entries.files["/a"].hits_total, 7); }
#[test]
fn scope_guard_mismatch() {
let mut p = Profile::new("test", Some("scope-A".into()));
let new_scope = Some("scope-B".to_string());
let res = enforce_scope(&mut p, new_scope.as_ref(), false);
assert!(res.is_err());
assert!(res.unwrap_err().to_string().contains("Scope mismatch"));
let res_force = enforce_scope(&mut p, new_scope.as_ref(), true);
assert!(res_force.is_ok());
assert_eq!(p.scope.as_deref(), Some("scope-A"));
}
#[test]
fn scope_guard_init() {
let mut p = Profile::new("test", None);
let new_scope = Some("scope-init".to_string());
assert!(enforce_scope(&mut p, new_scope.as_ref(), false).is_ok());
assert_eq!(p.scope.as_deref(), Some("scope-init"));
}
#[test]
fn scope_guard_noop() {
let mut p = Profile::new("test", Some("scope-A".into()));
assert!(enforce_scope(&mut p, Some(&"scope-A".to_string()), false).is_ok());
assert!(enforce_scope(&mut p, None, false).is_ok());
}
}