use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::config;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalysisEstimate {
pub tracked_path_count: usize,
pub inventory_bytes: u128,
pub estimated_peak_memory_bytes: u128,
pub estimated_peak_memory_low_bytes: u128,
pub estimated_peak_memory_high_bytes: u128,
pub runtime_overhead_bytes: u128,
pub confidence: String,
pub memory_budget_bytes: u128,
pub estimated_cache_bytes: u128,
pub estimated_report_bytes: u128,
pub estimated_relationship_count: u128,
pub estimated_inode_count: u128,
pub estimated_seconds: u128,
pub estimated_seconds_cold: u128,
pub estimated_seconds_warm: u128,
pub cache_assumptions: Vec<String>,
pub estimated_history_commit_count: u64,
pub path_breadth: usize,
pub dominant_subsystem: Option<String>,
pub symlink_count: usize,
}
pub fn current_rss_bytes() -> Option<u64> {
#[cfg(target_os = "linux")]
{
let status = fs::read_to_string("/proc/self/status").ok()?;
let kib = status
.lines()
.find_map(|line| line.strip_prefix("VmRSS:"))?
.split_whitespace()
.next()?
.parse::<u64>()
.ok()?;
kib.checked_mul(1024)
}
#[cfg(target_os = "macos")]
{
let mut usage = std::mem::MaybeUninit::<libc::rusage>::uninit();
if unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) } != 0 {
return None;
}
u64::try_from(unsafe { usage.assume_init() }.ru_maxrss).ok()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
None
}
}
pub fn build(repo_root: &Path, paths: &[String], config_value: &Value) -> AnalysisEstimate {
let mut subsystem_bytes = BTreeMap::<String, u128>::new();
let mut symlink_count = 0usize;
let inventory_bytes = paths
.iter()
.map(|path| {
let absolute = repo_root.join(path);
let bytes = fs::symlink_metadata(&absolute).ok().map_or(0, |metadata| {
if metadata.file_type().is_symlink() {
symlink_count += 1;
fs::read_link(&absolute)
.ok()
.map(|target| target.as_os_str().len() as u128)
.unwrap_or_default()
} else {
metadata.len() as u128
}
});
let subsystem = path.split('/').next().unwrap_or(".").to_string();
*subsystem_bytes.entry(subsystem).or_default() += bytes;
bytes
})
.sum::<u128>();
let dominant_subsystem = subsystem_bytes
.into_iter()
.max_by(|left, right| left.1.cmp(&right.1).then_with(|| right.0.cmp(&left.0)))
.map(|(path, _)| path);
let estimated_history_commit_count = Command::new("git")
.current_dir(repo_root)
.args(["rev-list", "--count", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.and_then(|value| value.trim().parse::<u64>().ok())
.unwrap_or_default();
let path_count = paths.len() as u128;
let pair_limit =
config::pointer_u64(config_value, "/organization/max_pairs_per_file", 20) as u128;
let graph_bytes = path_count.saturating_mul(pair_limit).saturating_mul(256);
let history_bytes = path_count
.saturating_mul(1_024)
.saturating_add(estimated_history_commit_count as u128 * 384);
let tokenizer_bytes = inventory_bytes.saturating_mul(2);
let report_bytes = inventory_bytes
.saturating_div(2)
.saturating_add(path_count.saturating_mul(4_096));
let runtime_overhead_bytes = 64_u128 * 1024 * 1024;
let estimated_peak_memory_bytes = inventory_bytes
.saturating_add(tokenizer_bytes)
.saturating_add(graph_bytes)
.saturating_add(history_bytes)
.saturating_add(report_bytes)
.saturating_add(runtime_overhead_bytes);
let estimated_seconds_warm = inventory_bytes
.div_ceil(4 * 1024 * 1024)
.saturating_add(path_count.div_ceil(2_000))
.saturating_add((estimated_history_commit_count as u128).div_ceil(5_000))
.max(6);
let estimated_seconds_cold = estimated_seconds_warm.saturating_mul(2).max(10);
AnalysisEstimate {
tracked_path_count: paths.len(),
inventory_bytes,
estimated_peak_memory_bytes,
estimated_peak_memory_low_bytes: estimated_peak_memory_bytes.saturating_mul(75) / 100,
estimated_peak_memory_high_bytes: estimated_peak_memory_bytes.saturating_mul(175) / 100,
runtime_overhead_bytes,
confidence: if paths.is_empty() {
"low"
} else if estimated_history_commit_count == 0 {
"moderate"
} else {
"calibrated_heuristic"
}
.to_string(),
memory_budget_bytes: config::pointer_u64(config_value, "/resources/memory_budget_mb", 1024)
as u128
* 1024
* 1024,
estimated_cache_bytes: inventory_bytes
.saturating_div(3)
.saturating_add(path_count * 512),
estimated_report_bytes: report_bytes,
estimated_relationship_count: path_count.saturating_mul(pair_limit).saturating_div(2),
estimated_inode_count: path_count.saturating_add(16),
estimated_seconds: estimated_seconds_cold,
estimated_seconds_cold,
estimated_seconds_warm,
cache_assumptions: vec![
"cold assumes no reusable content-token or Git-evidence cache entries".to_string(),
"warm assumes unchanged tracked content and compatible analyzer/config cache keys"
.to_string(),
"explicit --ephemeral and --no-cache scans use the cold estimate".to_string(),
],
estimated_history_commit_count,
path_breadth: paths
.iter()
.map(|path| path.split('/').next().unwrap_or("."))
.collect::<std::collections::BTreeSet<_>>()
.len(),
dominant_subsystem,
symlink_count,
}
}
#[cfg(test)]
mod tests {
use std::time::Instant;
use tempfile::tempdir;
use super::{build, current_rss_bytes};
use crate::config;
#[test]
fn thirty_thousand_path_preflight_is_bounded_and_reports_breadth() {
let repository = tempdir().unwrap();
let paths = (0..30_000)
.map(|index| format!("package-{}/src/file-{index}.rs", index % 30))
.collect::<Vec<_>>();
let started = Instant::now();
let estimate = build(repository.path(), &paths, &config::default_config());
assert_eq!(estimate.tracked_path_count, 30_000);
assert_eq!(estimate.path_breadth, 30);
assert!(estimate.estimated_peak_memory_bytes > 0);
assert!(estimate.estimated_report_bytes > 0);
assert!(
started.elapsed().as_secs() < 10,
"30k preflight exceeded 10 seconds"
);
}
#[test]
fn one_thousand_and_one_hundred_thousand_path_fixtures_scale_monotonically() {
let repository = tempdir().unwrap();
let estimate_for = |count| {
let paths = (0..count)
.map(|index| format!("package-{}/src/file-{index}.rs", index % 100))
.collect::<Vec<_>>();
build(repository.path(), &paths, &config::default_config())
};
let small = estimate_for(1_000);
let large = estimate_for(100_000);
assert_eq!(small.tracked_path_count, 1_000);
assert_eq!(large.tracked_path_count, 100_000);
assert!(large.estimated_peak_memory_low_bytes > small.estimated_peak_memory_high_bytes);
assert!(large.estimated_seconds >= small.estimated_seconds);
assert!(large.estimated_seconds_cold >= large.estimated_seconds_warm);
assert_eq!(large.path_breadth, 100);
}
#[test]
fn representative_small_repository_estimate_keeps_a_conservative_range() {
let repository = tempdir().unwrap();
let paths = (0..291)
.map(|index| format!("src/file-{index}.rs"))
.collect::<Vec<_>>();
let estimate = build(repository.path(), &paths, &config::default_config());
assert!(estimate.estimated_peak_memory_high_bytes >= 88 * 1024 * 1024);
assert!(estimate.estimated_seconds_cold >= 10);
assert!(estimate.estimated_seconds_warm >= 6);
assert!(
estimate.estimated_peak_memory_low_bytes < estimate.estimated_peak_memory_high_bytes
);
}
#[test]
fn preflight_performance_contract_gates_wall_rss_report_cache_and_relationships() {
let repository = tempdir().unwrap();
let started_rss = current_rss_bytes().unwrap_or_default();
for (count, maximum_seconds) in [(1_000usize, 2u64), (30_000, 10), (100_000, 20)] {
let paths = (0..count)
.map(|index| format!("package-{}/src/file-{index}.rs", index % 100))
.collect::<Vec<_>>();
let started = Instant::now();
let estimate = build(repository.path(), &paths, &config::default_config());
assert!(
started.elapsed().as_secs() < maximum_seconds,
"{count}-path preflight exceeded {maximum_seconds} seconds"
);
assert!(estimate.estimated_report_bytes <= count as u128 * 4_096);
assert!(estimate.estimated_cache_bytes <= count as u128 * 512);
assert!(estimate.estimated_relationship_count <= count as u128 * 10);
}
let peak_delta = current_rss_bytes()
.unwrap_or(started_rss)
.saturating_sub(started_rss);
assert!(
peak_delta <= 512 * 1024 * 1024,
"synthetic preflight fixtures used {} MiB additional RSS",
peak_delta.div_ceil(1024 * 1024)
);
}
#[test]
fn supported_hosts_expose_a_nonzero_rss_measurement() {
#[cfg(any(target_os = "linux", target_os = "macos"))]
assert!(current_rss_bytes().is_some_and(|bytes| bytes > 0));
}
}