use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use bock_ai::{
compute_key, node_kind_name, AiProvider, CandidateRule, Decision, DecisionType,
ManifestError, ManifestWriter, RepairRequest, Rule, RuleCache, TargetProfile,
};
use bock_air::AIRNode;
use bock_types::Strictness;
use chrono::Utc;
use crate::toolchain::{CompilationResult, ToolchainError, ToolchainRegistry};
#[derive(Debug, Clone)]
pub struct RepairConfig {
pub max_attempts: usize,
pub confidence_threshold: f64,
pub strictness: Strictness,
pub module_path: PathBuf,
}
impl Default for RepairConfig {
fn default() -> Self {
Self {
max_attempts: 2,
confidence_threshold: 0.75,
strictness: Strictness::Development,
module_path: PathBuf::new(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RepairOutcome {
FirstTrySuccess {
code: String,
},
Repaired {
code: String,
attempts: usize,
rule_added: bool,
},
RejectedLowConfidence {
confidence: f64,
compiler_error: String,
},
Exhausted {
attempts: usize,
compiler_error: String,
},
NoProvider {
compiler_error: String,
},
ProductionBlocked {
compiler_error: String,
},
ProviderError {
message: String,
},
}
impl RepairOutcome {
#[must_use]
pub fn accepted_code(&self) -> Option<&str> {
match self {
Self::FirstTrySuccess { code } | Self::Repaired { code, .. } => Some(code),
_ => None,
}
}
#[must_use]
pub fn is_success(&self) -> bool {
matches!(self, Self::FirstTrySuccess { .. } | Self::Repaired { .. })
}
}
#[derive(Debug, thiserror::Error)]
pub enum RepairError {
#[error("manifest error: {0}")]
Manifest(#[from] ManifestError),
#[error("rule cache error: {0}")]
Rules(#[from] bock_ai::RuleCacheError),
#[error("I/O error during repair: {0}")]
Io(#[from] std::io::Error),
#[error("toolchain error: {0}")]
Toolchain(#[from] ToolchainError),
}
pub struct RepairPipeline {
provider: Option<Arc<dyn AiProvider>>,
rules: Option<RuleCache>,
manifest: Option<Arc<Mutex<ManifestWriter>>>,
toolchain: Arc<ToolchainRegistry>,
config: RepairConfig,
}
impl RepairPipeline {
#[must_use]
pub fn without_provider(toolchain: Arc<ToolchainRegistry>, config: RepairConfig) -> Self {
Self {
provider: None,
rules: None,
manifest: None,
toolchain,
config,
}
}
#[must_use]
pub fn new(
provider: Arc<dyn AiProvider>,
rules: Option<RuleCache>,
manifest: Option<Arc<Mutex<ManifestWriter>>>,
toolchain: Arc<ToolchainRegistry>,
config: RepairConfig,
) -> Self {
Self {
provider: Some(provider),
rules,
manifest,
toolchain,
config,
}
}
#[must_use]
pub fn config(&self) -> &RepairConfig {
&self.config
}
pub async fn run(
&self,
target: &TargetProfile,
node: &AIRNode,
initial_code: String,
source_path: &Path,
) -> Result<RepairOutcome, RepairError> {
let target_id = target.id.clone();
write_candidate(source_path, &initial_code)?;
match self.toolchain.invoke(&target_id, source_path, false) {
Ok(_) => return Ok(RepairOutcome::FirstTrySuccess { code: initial_code }),
Err(ToolchainError::InvocationFailed { .. }) => { }
Err(other) => return Err(other.into()),
}
let mut compiler_error =
invocation_error(&self.toolchain.invoke(&target_id, source_path, false));
let Some(provider) = self.provider.clone() else {
return Ok(RepairOutcome::NoProvider { compiler_error });
};
if matches!(self.config.strictness, Strictness::Production) {
return Ok(RepairOutcome::ProductionBlocked { compiler_error });
}
let mut current_code = initial_code;
let mut attempts: usize = 0;
let mut rule_added = false;
while attempts < self.config.max_attempts {
attempts += 1;
let request = RepairRequest {
original_code: current_code.clone(),
compiler_error: compiler_error.clone(),
node: node.clone(),
target: target.clone(),
};
let response = match provider.repair(&request).await {
Ok(r) => r,
Err(e) => {
return Ok(RepairOutcome::ProviderError {
message: format!("{e}"),
});
}
};
if response.confidence < self.config.confidence_threshold {
return Ok(RepairOutcome::RejectedLowConfidence {
confidence: response.confidence,
compiler_error,
});
}
write_candidate(source_path, &response.fixed_code)?;
match self.toolchain.invoke(&target_id, source_path, false) {
Ok(_) => {
self.record_repair(node, target, &response, &compiler_error)?;
if let Some(candidate) = response.candidate_rule.as_ref() {
if let Some(rules) = &self.rules {
let rule = persist_rule(
rules,
candidate,
node,
response.confidence,
)?;
rule_added = true;
self.record_rule_applied(node, target, &rule)?;
}
}
return Ok(RepairOutcome::Repaired {
code: response.fixed_code,
attempts,
rule_added,
});
}
Err(ToolchainError::InvocationFailed { .. }) => {
current_code = response.fixed_code;
compiler_error = invocation_error(
&self.toolchain.invoke(&target_id, source_path, false),
);
}
Err(other) => return Err(other.into()),
}
}
Ok(RepairOutcome::Exhausted {
attempts,
compiler_error,
})
}
fn record_repair(
&self,
node: &AIRNode,
target: &TargetProfile,
response: &bock_ai::RepairResponse,
original_error: &str,
) -> Result<(), ManifestError> {
let Some(manifest) = &self.manifest else {
return Ok(());
};
let mut mw = manifest.lock().expect("manifest writer mutex poisoned");
let provider_id = self
.provider
.as_ref()
.map_or_else(|| "deterministic".into(), |p| p.model_id());
let id = decision_id("repair", node, target);
mw.record(Decision {
id,
module: self.config.module_path.clone(),
target: Some(target.id.clone()),
decision_type: DecisionType::Repair,
choice: response.fixed_code.clone(),
alternatives: Vec::new(),
reasoning: Some(format!(
"compiler error: {}; fixed by AI repair ({})",
summarize(original_error),
response
.reasoning
.as_deref()
.unwrap_or("no reasoning supplied")
)),
model_id: provider_id,
confidence: response.confidence,
pinned: false,
pin_reason: None,
pinned_at: None,
pinned_by: None,
superseded_by: None,
timestamp: Utc::now(),
});
Ok(())
}
fn record_rule_applied(
&self,
node: &AIRNode,
target: &TargetProfile,
rule: &Rule,
) -> Result<(), ManifestError> {
let Some(manifest) = &self.manifest else {
return Ok(());
};
let mut mw = manifest.lock().expect("manifest writer mutex poisoned");
let provider_id = self
.provider
.as_ref()
.map_or_else(|| "deterministic".into(), |p| p.model_id());
let id = decision_id(&format!("rule:{}", rule.id), node, target);
mw.record(Decision {
id,
module: self.config.module_path.clone(),
target: Some(target.id.clone()),
decision_type: DecisionType::RuleApplied,
choice: format!("rule {} matched pattern {}", rule.id, rule.node_kind),
alternatives: Vec::new(),
reasoning: Some(format!(
"candidate rule extracted from repair; future {} nodes may skip AI",
rule.node_kind
)),
model_id: provider_id,
confidence: rule.confidence,
pinned: rule.pinned,
pin_reason: rule.pinned.then(|| "manual".into()),
pinned_at: rule.pinned.then(Utc::now),
pinned_by: rule.pinned.then(|| "rule-author".into()),
superseded_by: None,
timestamp: Utc::now(),
});
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RuleLookupOutcome {
Applied {
rule: Rule,
code: String,
},
Miss,
MissNeedsPin,
}
pub fn try_apply_rule(
rules: &RuleCache,
target_id: &str,
node: &AIRNode,
strictness: Strictness,
) -> Result<RuleLookupOutcome, bock_ai::RuleCacheError> {
let production_only = matches!(strictness, Strictness::Production);
let Some(rule) = rules.lookup(target_id, node, production_only)? else {
return Ok(if production_only {
RuleLookupOutcome::MissNeedsPin
} else {
RuleLookupOutcome::Miss
});
};
let code = apply_template(&rule.template, node);
Ok(RuleLookupOutcome::Applied { rule, code })
}
#[must_use]
pub fn apply_template(template: &str, _node: &AIRNode) -> String {
template.to_string()
}
fn write_candidate(source_path: &Path, code: &str) -> std::io::Result<()> {
if let Some(parent) = source_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(source_path, code)
}
fn invocation_error(result: &Result<CompilationResult, ToolchainError>) -> String {
match result {
Ok(_) => "compilation unexpectedly succeeded".into(),
Err(ToolchainError::InvocationFailed {
stdout,
stderr,
exit_code,
..
}) => {
let diag = if stderr.is_empty() { stdout } else { stderr };
format!(
"exit {}: {}",
exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into()),
summarize(diag)
)
}
Err(e) => format!("{e}"),
}
}
fn summarize(error: &str) -> String {
let trimmed = error.trim();
if trimmed.len() <= 512 {
return trimmed.into();
}
let mut s = String::with_capacity(515);
s.push_str(&trimmed[..512]);
s.push_str("...");
s
}
fn persist_rule(
rules: &RuleCache,
candidate: &CandidateRule,
node: &AIRNode,
confidence: f64,
) -> Result<Rule, bock_ai::RuleCacheError> {
let kind = node_kind_name(&node.kind);
let rule = Rule::from_candidate(candidate, kind, confidence);
rules.insert(&rule)?;
Ok(rule)
}
fn decision_id(prefix: &str, node: &AIRNode, target: &TargetProfile) -> String {
#[derive(serde::Serialize)]
struct Keyed<'a> {
prefix: &'a str,
target: &'a str,
node_debug: String,
}
let keyed = Keyed {
prefix,
target: &target.id,
node_debug: format!("{node:?}"),
};
compute_key(&keyed).unwrap_or_else(|_| format!("{prefix}-{}", node.id))
}
#[cfg(test)]
mod tests {
use super::*;
use bock_air::{NodeIdGen, NodeKind};
use bock_errors::Span;
fn dummy_node() -> AIRNode {
let gen = NodeIdGen::new();
AIRNode::new(
gen.next(),
Span::dummy(),
NodeKind::Block {
stmts: Vec::new(),
tail: None,
},
)
}
fn js_target() -> TargetProfile {
TargetProfile {
id: "js".into(),
display_name: "JavaScript".into(),
capabilities: Default::default(),
conventions: Default::default(),
}
}
#[test]
fn accepted_code_reports_working_outcome() {
let ok = RepairOutcome::FirstTrySuccess { code: "x".into() };
assert_eq!(ok.accepted_code(), Some("x"));
assert!(ok.is_success());
let rep = RepairOutcome::Repaired {
code: "y".into(),
attempts: 1,
rule_added: false,
};
assert_eq!(rep.accepted_code(), Some("y"));
assert!(rep.is_success());
let bad = RepairOutcome::NoProvider {
compiler_error: "boom".into(),
};
assert_eq!(bad.accepted_code(), None);
assert!(!bad.is_success());
}
#[test]
fn summarize_truncates_long_errors() {
let long = "x".repeat(1000);
let out = summarize(&long);
assert!(out.len() <= 515);
assert!(out.ends_with("..."));
}
#[test]
fn summarize_short_errors_unchanged() {
let out = summarize(" short error ");
assert_eq!(out, "short error");
}
#[test]
fn apply_template_returns_template_verbatim() {
let code = apply_template("switch(x){}", &dummy_node());
assert_eq!(code, "switch(x){}");
}
#[test]
fn try_apply_rule_misses_with_empty_cache() {
let dir = tempfile::tempdir().unwrap();
let rules = RuleCache::new(dir.path());
let outcome = try_apply_rule(&rules, "js", &dummy_node(), Strictness::Development).unwrap();
assert_eq!(outcome, RuleLookupOutcome::Miss);
}
#[test]
fn try_apply_rule_hits_matching_kind() {
let dir = tempfile::tempdir().unwrap();
let rules = RuleCache::new(dir.path());
let candidate = CandidateRule {
target_id: "js".into(),
pattern: "empty block".into(),
template: "() => {}".into(),
priority: 1,
};
let rule = Rule::from_candidate(&candidate, "Block", 0.9);
rules.insert(&rule).unwrap();
let outcome = try_apply_rule(&rules, "js", &dummy_node(), Strictness::Sketch).unwrap();
match outcome {
RuleLookupOutcome::Applied { rule: r, code } => {
assert_eq!(r.node_kind, "Block");
assert_eq!(code, "() => {}");
}
other => panic!("expected Applied, got {other:?}"),
}
}
#[test]
fn try_apply_rule_reports_miss_needs_pin_in_production() {
let dir = tempfile::tempdir().unwrap();
let rules = RuleCache::new(dir.path());
let candidate = CandidateRule {
target_id: "js".into(),
pattern: "empty block".into(),
template: "() => {}".into(),
priority: 1,
};
let rule = Rule::from_candidate(&candidate, "Block", 0.9);
rules.insert(&rule).unwrap();
let outcome = try_apply_rule(&rules, "js", &dummy_node(), Strictness::Production).unwrap();
assert_eq!(outcome, RuleLookupOutcome::MissNeedsPin);
}
#[test]
fn pipeline_without_provider_returns_no_provider() {
use std::path::PathBuf;
let mut registry = ToolchainRegistry::new();
registry.register(crate::toolchain::ToolchainSpec {
target_id: "fake".into(),
display_name: "Fake".into(),
binary_name: "not_a_real_binary_repair_xyz".into(),
version_args: vec!["--version".into()],
compile_command: "not_a_real_binary_repair_xyz".into(),
compile_args: vec![],
install_hint: "n/a".into(),
});
let toolchain = Arc::new(registry);
let pipeline = RepairPipeline::without_provider(toolchain, RepairConfig::default());
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("out.js");
let target = TargetProfile {
id: "fake".into(),
display_name: "Fake".into(),
capabilities: Default::default(),
conventions: Default::default(),
};
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let result = rt.block_on(pipeline.run(&target, &dummy_node(), "x".into(), &src));
assert!(result.is_err(), "expected NotFound escalation");
let _ = PathBuf::new();
let _ = js_target();
}
}