use std::{ collections::HashMap, env::current_dir, fs::File, io::{self, Read, Write}, path::{Path, PathBuf}, time::SystemTime, }; use anyhow::{Context, anyhow}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{InteractionPlan, Paths}; use super::cli::SimulatorCLI; /// A bug is a run that has been identified as buggy. #[derive(Clone)] pub(crate) enum Bug { Unloaded { seed: u64 }, Loaded(LoadedBug), } #[derive(Clone)] pub struct LoadedBug { /// The seed of the bug. pub seed: u64, /// The plan of the bug. pub plan: InteractionPlan, /// The shrunk plan of the bug, if any. pub shrunk_plan: Option, /// The runs of the bug. pub runs: Vec, } #[derive(Clone, Serialize, Deserialize)] pub(crate) struct BugRun { /// Commit hash of the current version of Limbo. pub(crate) hash: String, /// Timestamp of the run. #[serde(with = "chrono::serde::ts_seconds")] pub(crate) timestamp: DateTime, /// Error message of the run. pub(crate) error: Option, /// Options pub(crate) cli_options: SimulatorCLI, /// Whether the run was a shrunk run. pub(crate) shrunk: bool, } impl Bug { #[expect(dead_code)] /// Check if the bug is loaded. pub(crate) fn is_loaded(&self) -> bool { match self { Bug::Unloaded { .. } => false, Bug::Loaded { .. } => true, } } /// Get the seed of the bug. pub(crate) fn seed(&self) -> u64 { match self { Bug::Unloaded { seed } => *seed, Bug::Loaded(LoadedBug { seed, .. }) => *seed, } } } /// Bug Base is a local database of buggy runs. pub(crate) struct BugBase { /// Path to the bug base directory. path: PathBuf, /// The list of buggy runs, uniquely identified by their seed bugs: HashMap, } impl BugBase { /// Create a new bug base. fn new(path: PathBuf) -> anyhow::Result { let mut bugs = HashMap::new(); // list all the bugs in the path as directories if let Ok(entries) = std::fs::read_dir(&path) { for entry in entries.flatten() { if entry.file_type().is_ok_and(|ft| ft.is_dir()) { let seed = entry .file_name() .to_string_lossy() .to_string() .parse::() .with_context(|| { format!( "failed to parse seed from directory name {}", entry.file_name().to_string_lossy() ) })?; bugs.insert(seed, Bug::Unloaded { seed }); } } } Ok(Self { path, bugs }) } /// Load the bug base from one of the potential paths. pub(crate) fn load() -> anyhow::Result { let potential_paths = vec![ // limbo project directory BugBase::get_limbo_project_dir()?, // home directory dirs::home_dir().with_context(|| "should be able to get home directory")?, // current directory std::env::current_dir().with_context(|| "should be able to get current directory")?, ]; for path in &potential_paths { let path = path.join(".bugbase"); if path.exists() { return BugBase::new(path); } } for path in potential_paths { let path = path.join(".bugbase"); if std::fs::create_dir_all(&path).is_ok() { tracing::info!("bug base created at {}", path.display()); return BugBase::new(path); } } Err(anyhow!("failed to create bug base")) } #[expect(dead_code)] /// Load the bug base from one of the potential paths. pub(crate) fn interactive_load() -> anyhow::Result { let potential_paths = vec![ // limbo project directory BugBase::get_limbo_project_dir()?, // home directory dirs::home_dir().with_context(|| "should be able to get home directory")?, // current directory std::env::current_dir().with_context(|| "should be able to get current directory")?, ]; for path in potential_paths { let path = path.join(".bugbase"); if path.exists() { return BugBase::new(path); } } println!("select bug base location:"); println!("1. limbo project directory"); println!("2. home directory"); println!("3. current directory"); print!("> "); io::stdout().flush().unwrap(); let mut choice = String::new(); io::stdin() .read_line(&mut choice) .expect("failed to read line"); let choice = choice .trim() .parse::() .with_context(|| format!("invalid choice {choice}"))?; let path = match choice { 1 => BugBase::get_limbo_project_dir()?.join(".bugbase"), 2 => { let home = std::env::var("HOME").with_context(|| "failed to get home directory")?; PathBuf::from(home).join(".bugbase") } 3 => PathBuf::from(".bugbase"), _ => anyhow::bail!(format!("invalid choice {choice}")), }; if path.exists() { unreachable!("bug base already exists at {}", path.display()); } else { std::fs::create_dir_all(&path).with_context(|| "failed to create bug base")?; tracing::info!("bug base created at {}", path.display()); BugBase::new(path) } } /// Add a new bug to the bug base. pub(crate) fn add_bug( &mut self, seed: u64, plan: InteractionPlan, error: Option, cli_options: &SimulatorCLI, ) -> anyhow::Result<()> { tracing::debug!("adding bug with seed {}", seed); let bug = self.get_bug(seed); if bug.is_some() { let mut bug = self.load_bug(seed)?; bug.plan = plan.clone(); bug.runs.push(BugRun { hash: Self::get_current_commit_hash()?, timestamp: SystemTime::now().into(), error, cli_options: cli_options.clone(), shrunk: false, }); self.bugs.insert(seed, Bug::Loaded(bug.clone())); } else { let bug = LoadedBug { seed, plan: plan.clone(), runs: vec![BugRun { hash: Self::get_current_commit_hash()?, timestamp: SystemTime::now().into(), error, cli_options: cli_options.clone(), shrunk: false, }], shrunk_plan: None, }; self.bugs.insert(seed, Bug::Loaded(bug.clone())); } // Save the bug to the bug base. self.save_bug(seed) } /// Get a bug from the bug base. pub(crate) fn get_bug(&self, seed: u64) -> Option<&Bug> { self.bugs.get(&seed) } /// Save a bug to the bug base. fn save_bug(&self, seed: u64) -> anyhow::Result<()> { let bug = self.get_bug(seed); match bug { None | Some(Bug::Unloaded { .. }) => { unreachable!("save should only be called within add_bug"); } Some(Bug::Loaded(bug)) => { let bug_path = self.path.join(seed.to_string()); std::fs::create_dir_all(&bug_path) .with_context(|| "should be able to create bug directory")?; let seed_path = bug_path.join("seed.txt"); std::fs::write(&seed_path, seed.to_string()) .with_context(|| "should be able to write seed file")?; let plan_path = bug_path.join("plan.json"); std::fs::write( &plan_path, serde_json::to_string_pretty(&bug.plan) .with_context(|| "should be able to serialize plan")?, ) .with_context(|| "should be able to write plan file")?; if let Some(shrunk_plan) = &bug.shrunk_plan { let shrunk_plan_path = bug_path.join("shrunk.json"); std::fs::write( &shrunk_plan_path, serde_json::to_string_pretty(shrunk_plan) .with_context(|| "should be able to serialize shrunk plan")?, ) .with_context(|| "should be able to write shrunk plan file")?; let readable_shrunk_plan_path = bug_path.join("shrunk.sql"); std::fs::write(&readable_shrunk_plan_path, shrunk_plan.to_string()) .with_context(|| "should be able to write readable shrunk plan file")?; } let readable_plan_path = bug_path.join("plan.sql"); std::fs::write(&readable_plan_path, bug.plan.to_string()) .with_context(|| "should be able to write readable plan file")?; let runs_path = bug_path.join("runs.json"); std::fs::write( &runs_path, serde_json::to_string_pretty(&bug.runs) .with_context(|| "should be able to serialize runs")?, ) .with_context(|| "should be able to write runs file")?; } } Ok(()) } pub(crate) fn load_bug(&mut self, seed: u64) -> anyhow::Result { let seed_match = self.bugs.get(&seed); match seed_match { None => anyhow::bail!("No bugs found for seed {}", seed), Some(Bug::Unloaded { .. }) => { let plan = std::fs::read_to_string(self.path.join(seed.to_string()).join("plan.json")) .with_context(|| { format!( "should be able to read plan file at {}", self.path.join(seed.to_string()).join("plan.json").display() ) })?; let plan: InteractionPlan = serde_json::from_str(&plan) .with_context(|| "should be able to deserialize plan")?; let shrunk_plan: Option = std::fs::read_to_string(self.path.join(seed.to_string()).join("shrunk.json")) .with_context(|| "should be able to read shrunk plan file") .and_then(|shrunk| { serde_json::from_str(&shrunk).map_err(|e| anyhow!("{}", e)) }) .ok(); let shrunk_plan: Option = shrunk_plan.and_then(|shrunk_plan| serde_json::from_str(&shrunk_plan).ok()); let runs = std::fs::read_to_string(self.path.join(seed.to_string()).join("runs.json")) .with_context(|| "should be able to read runs file") .and_then(|runs| serde_json::from_str(&runs).map_err(|e| anyhow!("{}", e))) .unwrap_or_default(); let bug = LoadedBug { seed, plan: plan.clone(), runs, shrunk_plan, }; self.bugs.insert(seed, Bug::Loaded(bug.clone())); tracing::debug!("Loaded bug with seed {}", seed); Ok(bug) } Some(Bug::Loaded(bug)) => { tracing::warn!( "Bug with seed {} is already loaded, returning the existing plan", seed ); Ok(bug.clone()) } } } #[expect(dead_code)] pub(crate) fn mark_successful_run( &mut self, seed: u64, cli_options: &SimulatorCLI, ) -> anyhow::Result<()> { let bug = self.get_bug(seed); match bug { None => { tracing::debug!("removing bug base entry for {}", seed); std::fs::remove_dir_all(self.path.join(seed.to_string())) .with_context(|| "should be able to remove bug directory")?; } Some(_) => { let mut bug = self.load_bug(seed)?; bug.runs.push(BugRun { hash: Self::get_current_commit_hash()?, timestamp: SystemTime::now().into(), error: None, cli_options: cli_options.clone(), shrunk: false, }); self.bugs.insert(seed, Bug::Loaded(bug.clone())); // Save the bug to the bug base. self.save_bug(seed) .with_context(|| "should be able to save bug")?; tracing::debug!("Updated bug with seed {}", seed); } } Ok(()) } pub(crate) fn make_shrunk( &mut self, seed: u64, cli_options: &SimulatorCLI, shrunk_plan: InteractionPlan, error: Option, ) -> anyhow::Result<()> { let mut bug = self.load_bug(seed)?; bug.shrunk_plan = Some(shrunk_plan); bug.runs.push(BugRun { hash: Self::get_current_commit_hash()?, timestamp: SystemTime::now().into(), error, cli_options: cli_options.clone(), shrunk: true, }); self.bugs.insert(seed, Bug::Loaded(bug.clone())); // Save the bug to the bug base. self.save_bug(seed) .with_context(|| "should be able to save shrunk bug")?; Ok(()) } pub(crate) fn load_bugs(&mut self) -> anyhow::Result> { let seeds = self.bugs.keys().copied().collect::>(); seeds .iter() .map(|seed| self.load_bug(*seed)) .collect::, _>>() } pub(crate) fn list_bugs(&mut self) -> anyhow::Result<()> { let bugs = self.load_bugs()?; for bug in bugs { println!("seed: {}", bug.seed); println!("plan: {}", bug.plan.stats()); println!("runs:"); println!(" ------------------"); for run in &bug.runs { println!(" - hash: {}", run.hash); println!(" timestamp: {}", run.timestamp); println!( " type: {}", if run.cli_options.differential { "differential" } else if run.cli_options.doublecheck { "doublecheck" } else { "default" } ); if let Some(error) = &run.error { println!(" error: {error}"); } } println!(" ------------------"); } Ok(()) } } impl BugBase { #[expect(dead_code)] /// Get the path to the bug base directory. pub(crate) fn path(&self) -> &PathBuf { &self.path } /// Get paths to all the files for a given seed. pub(crate) fn paths(&self, seed: u64) -> Paths { let base = self.path.join(format!("{seed}/")); Paths::new(&base) } } impl BugBase { pub(crate) fn get_current_commit_hash() -> anyhow::Result { let git_dir = find_git_dir(current_dir()?).with_context(|| "should be a git repo")?; let hash = resolve_head(&git_dir).with_context(|| "should be able to get the commit hash")?; Ok(hash) } pub(crate) fn get_limbo_project_dir() -> anyhow::Result { let git_dir = find_git_dir(current_dir()?).with_context(|| "should be a git repo")?; let workdir = git_dir .parent() .with_context(|| "work tree should be parent of .git")?; Ok(workdir.to_path_buf()) } } fn find_git_dir(start_path: impl AsRef) -> Option { // HACK ignores stuff like bare repo, worktree, etc. let mut current = start_path.as_ref().to_path_buf(); loop { let git_path = current.join(".git"); if git_path.is_dir() { return Some(git_path); } if !current.pop() { return None; } } } fn resolve_head(git_dir: impl AsRef) -> anyhow::Result { // HACK ignores stuff like packed-refs let head_path = git_dir.as_ref().join("HEAD"); let head_contents = read_to_string(&head_path)?; if let Some(ref_path) = head_contents.strip_prefix("ref: ") { let ref_file = git_dir.as_ref().join(ref_path); read_to_string(&ref_file) } else { Ok(head_contents) } } fn read_to_string(path: impl AsRef) -> anyhow::Result { let mut file = File::open(path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; Ok(contents.trim().to_string()) }