use serde::Deserialize;
use super::heal_intake::{is_valid_repo_spec, Checkout, HealTarget};
pub const HEAL_CONFIG_FILE: &str = "heal.toml";
pub const DEFAULT_LABEL: &str = "self-heal";
pub const DEFAULT_BASE: &str = "main";
#[derive(Debug, Deserialize, Default)]
struct RawConfig {
#[serde(default)]
target: Vec<RawTarget>,
#[serde(default)]
review_models: Vec<String>,
#[serde(default)]
engine: Option<String>,
#[serde(default)]
coder_model: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawTarget {
repo: String,
#[serde(default)]
fix_repo: Option<String>,
#[serde(default)]
project: Option<String>,
#[serde(default)]
path: Option<std::path::PathBuf>,
#[serde(default)]
label: Option<String>,
#[serde(default)]
base: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RejectedTarget {
pub repo: String,
pub reason: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HealConfig {
pub targets: Vec<HealTarget>,
pub rejected: Vec<RejectedTarget>,
pub review_models: Vec<String>,
pub engine: Option<String>,
pub coder_model: Option<String>,
}
impl HealConfig {
pub fn is_enabled(&self) -> bool {
!self.targets.is_empty() && !self.review_models.is_empty()
}
pub fn disabled_reason(&self) -> Option<&'static str> {
if self.targets.is_empty() {
Some("no usable targets are configured")
} else if self.review_models.is_empty() {
Some(
"no `review_models` are configured; the loop will not run a coder session it has no panel to check",
)
} else {
None
}
}
pub fn load(dir: &std::path::Path) -> Self {
let path = dir.join(HEAL_CONFIG_FILE);
let Ok(raw) = std::fs::read_to_string(&path) else {
return Self::default();
};
Self::parse(&raw)
}
pub fn parse(raw: &str) -> Self {
let parsed: RawConfig = match toml::from_str(raw) {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "unreadable heal.toml; the self-healing loop is disabled");
return Self::default();
}
};
let mut targets = Vec::new();
let mut rejected = Vec::new();
for t in parsed.target {
if !is_valid_repo_spec(&t.repo) {
rejected.push(RejectedTarget {
repo: t.repo,
reason: "not a valid owner/name repository spec".into(),
});
continue;
}
if t.project.is_some() && t.path.is_some() {
rejected.push(RejectedTarget {
repo: t.repo,
reason: "set `project` or `path`, not both — they name different \
owners of the working tree"
.into(),
});
continue;
}
let checkout = match (t.project, t.path) {
(Some(p), None) => Some(Checkout::Project(p)),
(None, Some(p)) => Some(Checkout::Local(p)),
(None, None) => None,
(Some(_), Some(_)) => unreachable!("rejected above"),
};
if let Some(fr) = &t.fix_repo {
if !is_valid_repo_spec(fr) {
rejected.push(RejectedTarget {
repo: t.repo,
reason: format!("`fix_repo` {fr:?} is not a valid owner/name spec"),
});
continue;
}
}
targets.push(HealTarget {
repo: t.repo,
fix_repo: t.fix_repo,
checkout,
label: t.label.unwrap_or_else(|| DEFAULT_LABEL.to_string()),
base: t
.base
.filter(|b| !b.trim().is_empty())
.unwrap_or_else(|| DEFAULT_BASE.to_string()),
});
}
Self {
targets,
rejected,
review_models: parsed
.review_models
.into_iter()
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty())
.collect(),
engine: parsed
.engine
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty()),
coder_model: parsed
.coder_model
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coder_model_and_engine_are_trimmed_not_merely_tested_for_blankness() {
let c = HealConfig::parse(
r#"
review_models = ["reviewer-a"]
coder_model = " gpt-5.5 "
engine = " native "
[[targets]]
repo = "acme/one"
"#,
);
assert_eq!(c.coder_model.as_deref(), Some("gpt-5.5"));
assert_eq!(c.engine.as_deref(), Some("native"));
}
#[test]
fn an_absent_file_disables_the_loop() {
let dir = tempfile::tempdir().unwrap();
let c = HealConfig::load(dir.path());
assert!(!c.is_enabled(), "no compiled-in targets");
assert!(c.targets.is_empty());
}
#[test]
fn there_is_no_built_in_target_list() {
let c = HealConfig::parse("");
assert!(c.targets.is_empty());
}
#[test]
fn a_project_target_parses() {
let c = HealConfig::parse(
r#"
[[target]]
repo = "Parslee-ai/car"
project = "car"
label = "self-heal"
"#,
);
assert_eq!(c.targets.len(), 1);
assert_eq!(c.targets[0].repo, "Parslee-ai/car");
assert_eq!(c.targets[0].checkout, Some(Checkout::Project("car".into())));
assert!(c.targets[0].can_write());
}
#[test]
fn a_target_with_no_checkout_is_watch_only() {
let c = HealConfig::parse(
r#"
[[target]]
repo = "Parslee-ai/car-releases"
"#,
);
assert_eq!(c.targets.len(), 1);
assert!(!c.targets[0].can_write());
assert_eq!(c.targets[0].label, DEFAULT_LABEL);
}
#[test]
fn project_and_path_together_are_refused() {
let c = HealConfig::parse(
r#"
[[target]]
repo = "acme/widgets"
project = "widgets"
path = "/tmp/widgets"
"#,
);
assert!(c.targets.is_empty());
assert_eq!(c.rejected.len(), 1);
assert!(c.rejected[0].reason.contains("not both"));
}
#[test]
fn a_bad_repo_spec_is_reported_not_silently_dropped() {
let c = HealConfig::parse(
r#"
[[target]]
repo = "not-a-spec"
project = "x"
"#,
);
assert!(c.targets.is_empty());
assert_eq!(c.rejected[0].repo, "not-a-spec");
}
#[test]
fn one_bad_target_does_not_disable_the_good_ones() {
let c = HealConfig::parse(
r#"
review_models = ["a", "b"]
[[target]]
repo = "bad spec"
[[target]]
repo = "acme/widgets"
project = "widgets"
"#,
);
assert_eq!(c.targets.len(), 1);
assert_eq!(c.rejected.len(), 1);
assert!(c.is_enabled());
assert_eq!(c.disabled_reason(), None);
}
#[test]
fn targets_without_a_review_panel_do_not_enable_the_loop() {
let c = HealConfig::parse(
r#"
[[target]]
repo = "acme/widgets"
project = "widgets"
"#,
);
assert_eq!(c.targets.len(), 1);
assert!(!c.is_enabled());
assert!(c.disabled_reason().unwrap().contains("review_models"));
}
#[test]
fn the_panel_and_the_engine_are_read_from_the_file() {
let c = HealConfig::parse(
r#"
review_models = ["claude-opus-5", " ", "gpt-5.5"]
engine = "native"
[[target]]
repo = "acme/widgets"
project = "widgets"
base = "master"
"#,
);
assert_eq!(c.review_models, vec!["claude-opus-5", "gpt-5.5"]);
assert_eq!(c.engine.as_deref(), Some("native"));
assert_eq!(c.targets[0].base, "master");
}
#[test]
fn an_unnamed_base_defaults_rather_than_being_empty() {
let c = HealConfig::parse(
r#"
review_models = ["a"]
[[target]]
repo = "acme/widgets"
project = "widgets"
base = " "
"#,
);
assert_eq!(c.targets[0].base, DEFAULT_BASE);
}
#[test]
fn a_malformed_file_disables_the_loop_entirely() {
let c = HealConfig::parse("[[target]\nrepo = ");
assert!(!c.is_enabled());
}
#[test]
fn per_target_labels_are_independent() {
let c = HealConfig::parse(
r#"
[[target]]
repo = "acme/widgets"
project = "widgets"
label = "auto-fix"
[[target]]
repo = "acme/gadgets"
project = "gadgets"
"#,
);
assert_eq!(c.targets[0].label, "auto-fix");
assert_eq!(c.targets[1].label, DEFAULT_LABEL);
}
#[test]
fn a_cross_repo_target_carries_where_fixes_land() {
let c = HealConfig::parse(
r#"
[[target]]
repo = "Parslee-ai/car-releases"
fix_repo = "Parslee-ai/car"
project = "car"
"#,
);
assert_eq!(c.targets.len(), 1);
assert_eq!(c.targets[0].coverage_repo(), "Parslee-ai/car");
assert!(c.targets[0].is_cross_repo());
}
#[test]
fn a_bad_fix_repo_is_reported_not_ignored() {
let c = HealConfig::parse(
r#"
[[target]]
repo = "acme/tracker"
fix_repo = "not a spec"
project = "x"
"#,
);
assert!(c.targets.is_empty());
assert!(c.rejected[0].reason.contains("fix_repo"));
}
}