pub mod artifacts;
pub mod cache;
pub mod check_id;
pub mod checks;
pub mod cli;
pub mod config;
pub mod git;
pub mod heuristics;
pub mod mcp;
pub mod mdrender;
pub mod output;
pub mod paths;
pub mod policy;
pub mod proc;
pub mod regression;
pub mod scope;
pub mod state;
pub mod storage;
pub mod tui;
pub use cli::{Cli, CliCommand, CompletionsArgs, OpenArgs, RunsArgs, ScopeArgs, StateArgs};
pub use config::Config;
use anyhow::Result;
use std::time::Instant;
pub struct App {
pub config: Config,
pub repo: git::Repository,
pub(crate) start_time: Instant,
}
impl App {
fn should_emit_human_stdout(&self) -> bool {
!self.config.json && !self.config.quiet
}
pub fn new(cli: &Cli) -> Result<Self> {
let config = Config::from_cli(cli)?;
Self::from_config(config)
}
pub fn from_config(config: Config) -> Result<Self> {
let repo = git::Repository::open(&config.repo_root)?;
Ok(Self {
config,
repo,
start_time: Instant::now(),
})
}
pub async fn run(&self) -> Result<output::Report> {
use colored::Colorize;
let emit_human_stdout = self.should_emit_human_stdout();
if emit_human_stdout {
println!(
"{}",
"=== prview - PR Review & Artifact Generator ==="
.cyan()
.bold()
);
println!();
}
self.repo.prepare_refs(&self.config)?;
let target = self.repo.resolve_target(&self.config)?;
let bases = if self.config.current_only {
Vec::new()
} else {
self.repo.resolve_bases(&self.config)?
};
if emit_human_stdout {
output::print_config(&self.config, &target, &bases);
}
if self.config.update_mode
&& let Some(prev_run) = self.find_previous_run()?
{
let prev_head = self.read_previous_head(&prev_run)?;
let current_head = target.commit_id.clone();
if commit_ids_match(&prev_head, ¤t_head) {
if emit_human_stdout {
println!(
"{} No new commits since last run (HEAD: {})",
"ℹ".blue(),
git::short_sha(¤t_head)
);
println!("{} Previous artifacts: {}", "ℹ".blue(), prev_run.display());
println!("{} Nothing to update.", "ℹ".blue());
}
return Ok(output::Report {
target: target.name.clone(),
bases: bases.iter().map(|b| b.name.clone()).collect(),
diffs: vec![],
checks: vec![],
heuristics: None,
artifacts_dir: prev_run,
duration: self.start_time.elapsed(),
unchanged: true,
});
}
if emit_human_stdout {
println!(
"{} Found previous run, updating incrementally...",
"ℹ".blue()
);
}
}
let diffs = self.repo.generate_diffs(&target, &bases)?;
let (check_results, skipped_checks) = if self.config.update_mode {
let mut update_config = self.config.clone();
let any_skipped = !self.config.run_tests || !self.config.run_security;
if !self.config.run_tests {
update_config.run_tests = false;
}
update_config.run_bundle = false;
if !self.config.run_security {
update_config.run_security = false;
}
if emit_human_stdout && any_skipped {
println!("{}", " Skipping heavy checks (--update mode)".yellow());
}
checks::run_all(&update_config).await?
} else {
checks::run_all(&self.config).await?
};
let heuristics_result = if self.config.remote_mode || self.config.remote_only {
self.run_heuristics_with_snapshots(&target, &bases).await?
} else {
heuristics::run_all(&self.config, None).await?
};
let artifacts_dir = artifacts::generate(artifacts::GenerateInput {
config: &self.config,
diffs: &diffs,
checks: &check_results,
heuristics: Some(&heuristics_result),
resolved_target: &target,
resolved_bases: &bases,
run_start: self.start_time,
skipped_checks,
})?;
let report = output::Report {
target: target.name.clone(),
bases: bases.iter().map(|b| b.name.clone()).collect(),
diffs,
checks: check_results,
heuristics: Some(heuristics_result),
artifacts_dir,
duration: self.start_time.elapsed(),
unchanged: false,
};
if emit_human_stdout {
output::print_summary(&report);
}
Ok(report)
}
pub(crate) async fn run_heuristics_with_snapshots(
&self,
target: &git::ResolvedRef,
bases: &[git::ResolvedRef],
) -> Result<heuristics::HeuristicsResult> {
if !self.config.run_heuristics {
return Ok(heuristics::HeuristicsResult::default());
}
use colored::Colorize;
let emit = self.should_emit_human_stdout();
let config = self.config.clone();
let target_snap = match self.repo.create_snapshot(&target.commit_id) {
Ok(snap) => {
if emit {
println!(
" {} Snapshot (target): {} → {}",
"ℹ".blue(),
git::short_sha(&target.commit_id),
snap.path.display()
);
}
Some(snap)
}
Err(e) => {
if emit {
eprintln!(
" {} Snapshot failed for target {}: {} — falling back to working tree",
"âš ".yellow(),
git::short_sha(&target.commit_id),
e
);
}
None
}
};
let analysis_root = target_snap.as_ref().map(|s| s.path.as_path());
let mut result = heuristics::run_all(&config, analysis_root).await?;
if should_compute_snapshot_regression(&self.config)
&& let Some(base) = bases.first()
{
match self.repo.create_snapshot(&base.commit_id) {
Ok(base_snap) => {
if emit {
println!(
" {} Snapshot (base): {} → {}",
"ℹ".blue(),
git::short_sha(&base.commit_id),
base_snap.path.display()
);
}
match heuristics::run_all(&config, Some(&base_snap.path)).await {
Ok(base_result) => {
match heuristics::compute_delta_checked(
&base_result,
&result,
&base.commit_id,
&target.commit_id,
) {
Some(regression) => {
if emit {
let symbol = if regression.regression_detected {
"âš ".yellow()
} else if regression.improvement_detected {
"✓".green()
} else {
"─".dimmed()
};
println!(
" {} Regression: dead_exports={:+}, cycles={:+}, unused_symbols={:+}",
symbol,
regression.dead_exports_delta,
regression.cycles_delta,
regression.unused_symbols_delta(),
);
}
result.regression = Some(regression);
}
None => {
if emit {
println!(
" {} Regression: loctree signal unavailable — skipped",
"â—‹".dimmed(),
);
}
}
}
}
Err(e) => {
if emit {
eprintln!(
" {} Base heuristics failed: {} — skipping regression",
"âš ".yellow(),
e
);
}
}
}
}
Err(e) => {
if emit {
eprintln!(
" {} Snapshot failed for base {}: {} — skipping regression",
"âš ".yellow(),
git::short_sha(&base.commit_id),
e
);
}
}
}
}
Ok(result)
}
pub async fn run_watch(&self) -> Result<()> {
use colored::Colorize;
use std::time::Duration;
let emit_human_stdout = self.should_emit_human_stdout();
let mut last_hash = String::new();
if emit_human_stdout {
println!("{}", "=== prview Watch Mode ===".cyan().bold());
println!("{} Monitoring for changes (Ctrl+C to stop)...", "ℹ".blue());
println!();
}
self.run_watch_iteration(&mut last_hash, emit_human_stdout)
.await?;
match self.init_repo_watcher() {
Ok((_watcher, mut receiver)) => {
if emit_human_stdout {
println!(
"{} Filesystem watcher active (hash fallback every 30s)",
"ℹ".blue()
);
}
let debounce_window = Duration::from_millis(350);
let fallback_interval = Duration::from_secs(30);
loop {
tokio::select! {
maybe_signal = receiver.recv() => match maybe_signal {
Some(WatchSignal::FilesChanged) => {
tokio::time::sleep(debounce_window).await;
self.drain_watch_queue(&mut receiver, emit_human_stdout);
self.run_watch_iteration(&mut last_hash, emit_human_stdout).await?;
}
Some(WatchSignal::WatchError(err)) => {
if emit_human_stdout {
eprintln!(
"{} Watcher error: {} — checking repo state anyway",
"âš ".yellow(),
err
);
}
self.run_watch_iteration(&mut last_hash, emit_human_stdout).await?;
}
None => {
if emit_human_stdout {
eprintln!(
"{} Watcher channel closed — falling back to 5s polling",
"âš ".yellow()
);
}
return self
.run_watch_polling(
&mut last_hash,
Duration::from_secs(5),
emit_human_stdout,
)
.await;
}
},
_ = tokio::time::sleep(fallback_interval) => {
self.run_watch_iteration(&mut last_hash, emit_human_stdout).await?;
}
}
}
}
Err(err) => {
if emit_human_stdout {
eprintln!(
"{} Filesystem watcher unavailable: {} — falling back to 5s polling",
"âš ".yellow(),
err
);
}
self.run_watch_polling(&mut last_hash, Duration::from_secs(5), emit_human_stdout)
.await
}
}
}
async fn run_quick(&self) -> Result<output::Report> {
let run_started_at = Instant::now();
let target = self.repo.resolve_target(&self.config)?;
let bases = if self.config.current_only {
Vec::new()
} else {
self.repo.resolve_bases(&self.config)?
};
let diffs = self.repo.generate_diffs(&target, &bases)?;
let artifacts_dir = artifacts::generate(artifacts::GenerateInput {
config: &self.config,
diffs: &diffs,
checks: &[],
heuristics: None,
resolved_target: &target,
resolved_bases: &bases,
run_start: run_started_at,
skipped_checks: vec![],
})?;
Ok(output::Report {
target: target.name.clone(),
bases: bases.iter().map(|b| b.name.clone()).collect(),
diffs,
checks: vec![],
heuristics: None,
artifacts_dir,
duration: run_started_at.elapsed(),
unchanged: false,
})
}
fn get_repo_state_hash(&self) -> Result<String> {
use crate::git::git_cmd;
let head = git_cmd()
.args(["rev-parse", "HEAD"])
.current_dir(&self.config.repo_root)
.output()?;
let status = git_cmd()
.args(["status", "--porcelain"])
.current_dir(&self.config.repo_root)
.output()?;
let diff = git_cmd()
.args(["diff", "--no-ext-diff", "--stat"])
.current_dir(&self.config.repo_root)
.output()?;
let head_str = String::from_utf8_lossy(&head.stdout);
let status_str = String::from_utf8_lossy(&status.stdout);
let diff_str = String::from_utf8_lossy(&diff.stdout);
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(status_str.as_bytes());
hasher.update(diff_str.as_bytes());
let status_hash = format!("{:x}", hasher.finalize());
Ok(format!("{}:{}", head_str.trim(), &status_hash[..16]))
}
async fn run_watch_iteration(
&self,
last_hash: &mut String,
emit_human_stdout: bool,
) -> Result<()> {
use colored::Colorize;
let current_hash = self.get_repo_state_hash()?;
if current_hash == *last_hash {
return Ok(());
}
if emit_human_stdout {
println!(
"\n{} Change detected at {}",
"→".yellow(),
chrono::Local::now().format("%H:%M:%S")
);
}
match self.run_quick().await {
Ok(_) => {
if emit_human_stdout {
println!("{} Regenerated artifacts", "✓".green());
}
}
Err(e) => {
if emit_human_stdout {
println!("{} Error: {}", "✗".red(), e);
}
}
}
*last_hash = self.get_repo_state_hash().unwrap_or(current_hash);
if emit_human_stdout {
println!("\n{} Waiting for changes...", "ℹ".blue());
}
Ok(())
}
async fn run_watch_polling(
&self,
last_hash: &mut String,
interval: std::time::Duration,
emit_human_stdout: bool,
) -> Result<()> {
loop {
tokio::time::sleep(interval).await;
self.run_watch_iteration(last_hash, emit_human_stdout)
.await?;
}
}
fn init_repo_watcher(
&self,
) -> Result<(
notify::RecommendedWatcher,
tokio::sync::mpsc::UnboundedReceiver<WatchSignal>,
)> {
use notify::{RecursiveMode, Watcher};
let repo_root = self.config.repo_root.clone();
let ignored_output_dir = self
.config
.output_dir
.as_ref()
.filter(|dir| dir.starts_with(&repo_root))
.cloned();
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
let event_sender = sender.clone();
let mut watcher = notify::recommended_watcher(
move |result: notify::Result<notify::Event>| match result {
Ok(event) => {
if should_ignore_watch_event(&repo_root, ignored_output_dir.as_deref(), &event)
{
return;
}
let _ = event_sender.send(WatchSignal::FilesChanged);
}
Err(err) => {
let _ = sender.send(WatchSignal::WatchError(err.to_string()));
}
},
)?;
watcher.watch(&self.config.repo_root, RecursiveMode::Recursive)?;
Ok((watcher, receiver))
}
fn drain_watch_queue(
&self,
receiver: &mut tokio::sync::mpsc::UnboundedReceiver<WatchSignal>,
emit_human_stdout: bool,
) {
use colored::Colorize;
while let Ok(signal) = receiver.try_recv() {
if let WatchSignal::WatchError(err) = signal
&& emit_human_stdout
{
eprintln!("{} Watcher error: {}", "âš ".yellow(), err);
}
}
}
fn find_previous_run(&self) -> Result<Option<std::path::PathBuf>> {
let artifacts_base = self.config.artifacts_base();
if !artifacts_base.exists() {
return Ok(None);
}
let mut entries: Vec<_> = std::fs::read_dir(&artifacts_base)?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir() && !e.path().is_symlink())
.collect();
entries.sort_by_key(|e| std::cmp::Reverse(e.path()));
Ok(entries.first().map(|e| e.path()))
}
fn read_previous_head(&self, prev_run: &std::path::Path) -> Result<String> {
let metadata_file = {
let new_path = prev_run.join("00_summary/pr-metadata.txt");
if new_path.exists() {
std::path::PathBuf::from("00_summary/pr-metadata.txt")
} else {
std::path::PathBuf::from("ai-context/pr-metadata.txt")
}
};
let content = crate::paths::read_to_string_within(prev_run, &metadata_file)?;
for line in content.lines() {
if line.starts_with("HEAD:")
&& let Some(start) = line.rfind('(')
&& let Some(end) = line.rfind(')')
{
return Ok(line[start + 1..end].to_string());
}
}
anyhow::bail!("Could not parse HEAD from previous run metadata")
}
}
fn should_compute_snapshot_regression(config: &Config) -> bool {
matches!(
config.execution_mode,
crate::cli::ExecutionMode::Deep | crate::cli::ExecutionMode::Ci
)
}
fn commit_ids_match(previous: &str, current: &str) -> bool {
let previous = previous.trim();
let current = current.trim();
previous == current || current.starts_with(previous) || previous.starts_with(current)
}
enum WatchSignal {
FilesChanged,
WatchError(String),
}
fn should_ignore_watch_event(
repo_root: &std::path::Path,
ignored_output_dir: Option<&std::path::Path>,
event: ¬ify::Event,
) -> bool {
if matches!(event.kind, notify::EventKind::Access(_)) {
return true;
}
ignored_output_dir.is_some_and(|output_dir| {
output_dir.starts_with(repo_root)
&& !event.paths.is_empty()
&& event.paths.iter().all(|path| path.starts_with(output_dir))
})
}
#[cfg(test)]
mod tests {
use super::{commit_ids_match, should_compute_snapshot_regression, should_ignore_watch_event};
use crate::cli::ExecutionMode;
use crate::config::test_config;
use notify::EventKind;
use notify::event::{AccessKind, CreateKind, EventAttributes};
use std::path::PathBuf;
#[test]
fn snapshot_regression_is_disabled_for_standard_mode() {
let mut config = test_config();
config.execution_mode = ExecutionMode::Standard;
assert!(!should_compute_snapshot_regression(&config));
}
#[test]
fn snapshot_regression_stays_enabled_for_deep_and_ci_modes() {
let mut config = test_config();
config.execution_mode = ExecutionMode::Deep;
assert!(should_compute_snapshot_regression(&config));
config.execution_mode = ExecutionMode::Ci;
assert!(should_compute_snapshot_regression(&config));
}
#[test]
fn watch_ignores_access_only_events() {
let repo_root = PathBuf::from("/tmp/repo");
let event = notify::Event {
kind: EventKind::Access(AccessKind::Any),
paths: vec![repo_root.join("src/lib.rs")],
attrs: EventAttributes::default(),
};
assert!(should_ignore_watch_event(&repo_root, None, &event));
}
#[test]
fn watch_ignores_output_dir_changes_inside_repo() {
let repo_root = PathBuf::from("/tmp/repo");
let output_dir = repo_root.join("tmp-artifacts");
let event = notify::Event {
kind: EventKind::Create(CreateKind::File),
paths: vec![output_dir.join("report.json")],
attrs: EventAttributes::default(),
};
assert!(should_ignore_watch_event(
&repo_root,
Some(output_dir.as_path()),
&event
));
}
#[test]
fn watch_keeps_source_file_changes() {
let repo_root = PathBuf::from("/tmp/repo");
let output_dir = repo_root.join("tmp-artifacts");
let event = notify::Event {
kind: EventKind::Create(CreateKind::File),
paths: vec![repo_root.join("src/lib.rs")],
attrs: EventAttributes::default(),
};
assert!(!should_ignore_watch_event(
&repo_root,
Some(output_dir.as_path()),
&event
));
}
#[test]
fn commit_ids_match_accepts_legacy_short_sha_metadata() {
assert!(commit_ids_match("abc1234", "abc1234def56789"));
assert!(commit_ids_match("abc1234def56789", "abc1234"));
assert!(commit_ids_match("abc1234def56789", "abc1234def56789"));
assert!(!commit_ids_match("abc1234", "def5678"));
}
}