use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::core::error::{Error, Result};
use crate::core::fs::atomic_write;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub project: ProjectConfig,
#[serde(default)]
pub git: GitConfig,
#[serde(default)]
pub metrics: MetricsConfig,
#[serde(default)]
pub policy: PolicyConfig,
#[serde(default)]
pub diff: DiffConfig,
#[serde(default)]
pub features: FeaturesConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct DiffConfig {
#[serde(default = "DiffConfig::default_max_loc_threshold")]
pub max_loc_threshold: u64,
}
impl DiffConfig {
pub(crate) const DEFAULT_MAX_LOC_THRESHOLD: u64 = 200_000;
fn default_max_loc_threshold() -> u64 {
Self::DEFAULT_MAX_LOC_THRESHOLD
}
}
impl Default for DiffConfig {
fn default() -> Self {
Self {
max_loc_threshold: Self::DEFAULT_MAX_LOC_THRESHOLD,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct FeaturesConfig {
#[serde(default)]
pub docs: DocsConfig,
#[serde(default)]
pub test: TestConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct DocsConfig {
pub enabled: bool,
#[serde(default = "DocsConfig::default_pairs_path")]
pub pairs_path: String,
#[serde(default = "DocsConfig::default_scaffold_root")]
pub scaffold_root: String,
#[serde(default)]
pub standalone: StandaloneDocsConfig,
#[serde(default)]
pub doc_freshness: DocFreshnessConfig,
#[serde(default)]
pub hotspot: DocHotspotConfig,
#[serde(default)]
pub todo_density: TodoDensityConfig,
#[serde(default)]
pub doc_link_health: DocLinkHealthConfig,
}
impl Eq for DocsConfig {}
impl DocsConfig {
pub(crate) const DEFAULT_PAIRS_PATH: &'static str = ".heal/doc_pairs.json";
pub(crate) const DEFAULT_SCAFFOLD_ROOT: &'static str = ".heal/docs";
fn default_pairs_path() -> String {
Self::DEFAULT_PAIRS_PATH.to_owned()
}
fn default_scaffold_root() -> String {
Self::DEFAULT_SCAFFOLD_ROOT.to_owned()
}
}
impl Default for DocsConfig {
fn default() -> Self {
Self {
enabled: false,
pairs_path: Self::default_pairs_path(),
scaffold_root: Self::default_scaffold_root(),
standalone: StandaloneDocsConfig::default(),
doc_freshness: DocFreshnessConfig::default(),
hotspot: DocHotspotConfig::default(),
todo_density: TodoDensityConfig::default(),
doc_link_health: DocLinkHealthConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DocLinkHealthConfig {
#[serde(default)]
pub exclude_link_prefixes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct DocHotspotConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_ok: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_n: Option<usize>,
#[serde(default = "default_weight")]
pub weight_drift: f64,
}
impl Eq for DocHotspotConfig {}
impl Default for DocHotspotConfig {
fn default() -> Self {
Self {
floor_ok: None,
top_n: None,
weight_drift: default_weight(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct StandaloneDocsConfig {
#[serde(default = "StandaloneDocsConfig::default_include")]
pub include: Vec<String>,
#[serde(default = "StandaloneDocsConfig::default_exclude")]
pub exclude: Vec<String>,
#[serde(default)]
pub entrypoints: Vec<String>,
}
impl StandaloneDocsConfig {
fn default_include() -> Vec<String> {
vec!["**/*.md".to_owned(), "**/*.rst".to_owned()]
}
fn default_exclude() -> Vec<String> {
vec![
"CHANGELOG*".to_owned(),
"CHANGELOG/**".to_owned(),
"CONTRIBUTING*".to_owned(),
"CODE_OF_CONDUCT*".to_owned(),
"SECURITY*".to_owned(),
"**/adr/**".to_owned(),
"target/**".to_owned(),
"dist/**".to_owned(),
"node_modules/**".to_owned(),
]
}
}
impl Default for StandaloneDocsConfig {
fn default() -> Self {
Self {
include: Self::default_include(),
exclude: Self::default_exclude(),
entrypoints: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DocFreshnessConfig {
#[serde(default = "DocFreshnessConfig::default_high_commits")]
pub high_commits: u32,
#[serde(default = "DocFreshnessConfig::default_critical_commits")]
pub critical_commits: u32,
}
impl DocFreshnessConfig {
pub(crate) const DEFAULT_HIGH_COMMITS: u32 = 5;
pub(crate) const DEFAULT_CRITICAL_COMMITS: u32 = 20;
fn default_high_commits() -> u32 {
Self::DEFAULT_HIGH_COMMITS
}
fn default_critical_commits() -> u32 {
Self::DEFAULT_CRITICAL_COMMITS
}
}
impl Default for DocFreshnessConfig {
fn default() -> Self {
Self {
high_commits: Self::default_high_commits(),
critical_commits: Self::default_critical_commits(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TodoDensityConfig {
#[serde(default = "TodoDensityConfig::default_ignore_in_inline_code")]
pub ignore_in_inline_code: bool,
#[serde(default)]
pub allowlist_paths: Vec<String>,
}
impl TodoDensityConfig {
pub(crate) const DEFAULT_IGNORE_IN_INLINE_CODE: bool = true;
fn default_ignore_in_inline_code() -> bool {
Self::DEFAULT_IGNORE_IN_INLINE_CODE
}
}
impl Default for TodoDensityConfig {
fn default() -> Self {
Self {
ignore_in_inline_code: Self::default_ignore_in_inline_code(),
allowlist_paths: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TestConfig {
pub enabled: bool,
#[serde(default = "TestConfig::default_test_paths")]
pub test_paths: Vec<String>,
#[serde(default)]
pub coverage: TestCoverageConfig,
#[serde(default)]
pub hotspot: TestHotspotConfig,
}
impl TestConfig {
fn default_test_paths() -> Vec<String> {
vec![
"tests/**".to_owned(),
"**/*_test.rs".to_owned(),
"**/*.test.ts".to_owned(),
"**/*.test.tsx".to_owned(),
"**/*.test.js".to_owned(),
"**/*.test.jsx".to_owned(),
"**/*.spec.ts".to_owned(),
"**/*.spec.tsx".to_owned(),
"**/*.spec.js".to_owned(),
"**/*.spec.jsx".to_owned(),
"**/__tests__/**".to_owned(),
"**/*_test.go".to_owned(),
"**/test_*.py".to_owned(),
"**/*_test.py".to_owned(),
"**/*Test.scala".to_owned(),
"**/*Spec.scala".to_owned(),
]
}
}
impl Default for TestConfig {
fn default() -> Self {
Self {
enabled: false,
test_paths: Self::default_test_paths(),
coverage: TestCoverageConfig::default(),
hotspot: TestHotspotConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct TestHotspotConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_ok: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_n: Option<usize>,
}
impl Eq for TestHotspotConfig {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TestCoverageConfig {
pub enabled: bool,
#[serde(default = "TestCoverageConfig::default_lcov_paths")]
pub lcov_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_commit_refresh: Option<String>,
}
impl TestCoverageConfig {
fn default_lcov_paths() -> Vec<String> {
vec![
"lcov.info".to_owned(),
"coverage/lcov.info".to_owned(),
"target/llvm-cov/lcov.info".to_owned(),
"coverage/lcov-report/lcov.info".to_owned(),
]
}
}
impl Default for TestCoverageConfig {
fn default() -> Self {
Self {
enabled: false,
lcov_paths: Self::default_lcov_paths(),
post_commit_refresh: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ProjectConfig {
#[serde(default)]
pub response_language: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspaces: Vec<WorkspaceOverlay>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceOverlay {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude_paths: Vec<String>,
#[serde(default, skip_serializing_if = "WorkspaceMetricsOverlay::is_empty")]
pub metrics: WorkspaceMetricsOverlay,
}
impl Eq for WorkspaceOverlay {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceMetricsOverlay {
#[serde(default, skip_serializing_if = "WorkspaceMetricOverlay::is_empty")]
pub ccn: WorkspaceMetricOverlay,
#[serde(default, skip_serializing_if = "WorkspaceMetricOverlay::is_empty")]
pub cognitive: WorkspaceMetricOverlay,
#[serde(default, skip_serializing_if = "WorkspaceMetricOverlay::is_empty")]
pub duplication: WorkspaceMetricOverlay,
#[serde(default, skip_serializing_if = "WorkspaceMetricOverlay::is_empty")]
pub change_coupling: WorkspaceMetricOverlay,
#[serde(default, skip_serializing_if = "WorkspaceMetricOverlay::is_empty")]
pub lcom: WorkspaceMetricOverlay,
}
impl Eq for WorkspaceMetricsOverlay {}
impl WorkspaceMetricsOverlay {
#[must_use]
pub fn is_empty(&self) -> bool {
self.ccn.is_empty()
&& self.cognitive.is_empty()
&& self.duplication.is_empty()
&& self.change_coupling.is_empty()
&& self.lcom.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceMetricOverlay {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_critical: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_ok: Option<f64>,
}
impl Eq for WorkspaceMetricOverlay {}
impl WorkspaceMetricOverlay {
#[must_use]
pub fn is_empty(&self) -> bool {
self.floor_critical.is_none() && self.floor_ok.is_none()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct GitConfig {
#[serde(default = "default_since_days")]
pub since_days: u32,
#[serde(default)]
pub exclude_paths: Vec<String>,
}
impl Default for GitConfig {
fn default() -> Self {
Self {
since_days: default_since_days(),
exclude_paths: Vec::new(),
}
}
}
fn default_since_days() -> u32 {
90
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct MetricsConfig {
#[serde(default = "default_top_n")]
pub top_n: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
#[serde(default)]
pub loc: LocConfig,
#[serde(default)]
pub churn: ChurnConfig,
#[serde(default)]
pub hotspot: HotspotConfig,
#[serde(default)]
pub change_coupling: ChangeCouplingConfig,
#[serde(default)]
pub duplication: DuplicationConfig,
#[serde(default)]
pub ccn: CcnConfig,
#[serde(default)]
pub cognitive: CognitiveConfig,
#[serde(default)]
pub lcom: LcomConfig,
}
impl Eq for MetricsConfig {}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
top_n: default_top_n(),
disabled: Vec::new(),
loc: LocConfig::default(),
churn: ChurnConfig::default(),
hotspot: HotspotConfig::default(),
change_coupling: ChangeCouplingConfig::default(),
duplication: DuplicationConfig::default(),
ccn: CcnConfig::default(),
cognitive: CognitiveConfig::default(),
lcom: LcomConfig::default(),
}
}
}
pub const DISABLEABLE_METRICS: &[&str] = &[
"churn",
"hotspot",
"change_coupling",
"duplication",
"ccn",
"cognitive",
"lcom",
];
impl MetricsConfig {
#[must_use]
pub fn is_enabled(&self, metric: &str) -> bool {
!self.disabled.iter().any(|m| m == metric)
}
#[must_use]
pub fn top_n_loc(&self) -> usize {
self.loc.top_n.unwrap_or(self.top_n)
}
#[must_use]
pub fn top_n_complexity(&self) -> usize {
self.ccn.top_n.unwrap_or(self.top_n)
}
#[must_use]
pub fn top_n_churn(&self) -> usize {
self.churn.top_n.unwrap_or(self.top_n)
}
#[must_use]
pub fn top_n_change_coupling(&self) -> usize {
self.change_coupling.top_n.unwrap_or(self.top_n)
}
#[must_use]
pub fn top_n_duplication(&self) -> usize {
self.duplication.top_n.unwrap_or(self.top_n)
}
#[must_use]
pub fn top_n_hotspot(&self) -> usize {
self.hotspot.top_n.unwrap_or(self.top_n)
}
#[must_use]
pub fn top_n_lcom(&self) -> usize {
self.lcom.top_n.unwrap_or(self.top_n)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct LocConfig {
#[serde(default = "default_true")]
pub inherit_git_excludes: bool,
#[serde(default)]
pub exclude_paths: Vec<String>,
#[serde(default)]
pub top_n: Option<usize>,
}
impl Default for LocConfig {
fn default() -> Self {
Self {
inherit_git_excludes: true,
exclude_paths: Vec::new(),
top_n: None,
}
}
}
fn default_true() -> bool {
true
}
fn default_top_n() -> usize {
5
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CognitiveConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_critical: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_ok: Option<f64>,
}
impl Eq for CognitiveConfig {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ChurnConfig {
#[serde(default)]
pub top_n: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct HotspotConfig {
#[serde(default = "default_weight")]
pub weight_churn: f64,
#[serde(default = "default_weight")]
pub weight_complexity: f64,
#[serde(default)]
pub top_n: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_ok: Option<f64>,
}
impl Eq for HotspotConfig {}
impl Default for HotspotConfig {
fn default() -> Self {
Self {
weight_churn: default_weight(),
weight_complexity: default_weight(),
top_n: None,
floor_ok: None,
}
}
}
fn default_weight() -> f64 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ChangeCouplingConfig {
#[serde(default = "default_min_coupling")]
pub min_coupling: u32,
#[serde(default = "default_min_lift")]
pub min_lift: f64,
#[serde(default = "default_symmetric_threshold")]
pub symmetric_threshold: f64,
#[serde(default)]
pub top_n: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_critical: Option<f64>,
#[serde(default)]
pub cross_workspace: CrossWorkspacePolicy,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CrossWorkspacePolicy {
#[default]
Surface,
Hide,
}
impl Eq for ChangeCouplingConfig {}
impl Default for ChangeCouplingConfig {
fn default() -> Self {
Self {
min_coupling: default_min_coupling(),
min_lift: default_min_lift(),
symmetric_threshold: default_symmetric_threshold(),
top_n: None,
floor_critical: None,
cross_workspace: CrossWorkspacePolicy::default(),
}
}
}
fn default_min_coupling() -> u32 {
3
}
fn default_min_lift() -> f64 {
2.0
}
fn default_symmetric_threshold() -> f64 {
0.5
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct LcomConfig {
#[serde(default)]
pub backend: LcomBackend,
#[serde(default = "default_min_cluster_count")]
pub min_cluster_count: u32,
#[serde(default)]
pub top_n: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_critical: Option<f64>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum LcomBackend {
#[default]
TreeSitterApprox,
Lsp,
}
impl Eq for LcomConfig {}
impl Default for LcomConfig {
fn default() -> Self {
Self {
backend: LcomBackend::default(),
min_cluster_count: default_min_cluster_count(),
top_n: None,
floor_critical: None,
}
}
}
fn default_min_cluster_count() -> u32 {
2
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct DuplicationConfig {
#[serde(default = "default_min_tokens")]
pub min_tokens: u32,
#[serde(default = "default_docs_min_tokens")]
pub docs_min_tokens: u32,
#[serde(default)]
pub top_n: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_critical: Option<f64>,
}
impl Eq for DuplicationConfig {}
impl Default for DuplicationConfig {
fn default() -> Self {
Self {
min_tokens: default_min_tokens(),
docs_min_tokens: default_docs_min_tokens(),
top_n: None,
floor_critical: None,
}
}
}
fn default_min_tokens() -> u32 {
50
}
fn default_docs_min_tokens() -> u32 {
100
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CcnConfig {
#[serde(default)]
pub top_n: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_critical: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_ok: Option<f64>,
}
impl Eq for CcnConfig {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PolicyConfig {
#[serde(default)]
pub drain: PolicyDrainConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PolicyDrainConfig {
#[serde(default = "default_drain_must")]
pub must: Vec<DrainSpec>,
#[serde(default = "default_drain_should")]
pub should: Vec<DrainSpec>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metrics: BTreeMap<String, PolicyDrainMetricOverride>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PolicyDrainMetricOverride {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub must: Option<Vec<DrainSpec>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub should: Option<Vec<DrainSpec>>,
}
impl Default for PolicyDrainConfig {
fn default() -> Self {
Self {
must: default_drain_must(),
should: default_drain_should(),
metrics: BTreeMap::new(),
}
}
}
fn default_drain_must() -> Vec<DrainSpec> {
vec![DrainSpec {
severity: crate::core::severity::Severity::Critical,
hotspot: HotspotMatch::Required,
}]
}
fn default_drain_should() -> Vec<DrainSpec> {
vec![
DrainSpec {
severity: crate::core::severity::Severity::Critical,
hotspot: HotspotMatch::Any,
},
DrainSpec {
severity: crate::core::severity::Severity::High,
hotspot: HotspotMatch::Required,
},
]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DrainSpec {
pub severity: crate::core::severity::Severity,
pub hotspot: HotspotMatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HotspotMatch {
Any,
Required,
}
impl DrainSpec {
#[must_use]
pub fn matches(&self, finding: &crate::core::finding::Finding) -> bool {
self.matches_attrs(finding.severity, finding.hotspot)
}
#[must_use]
pub fn matches_attrs(&self, severity: crate::core::severity::Severity, hotspot: bool) -> bool {
if severity != self.severity {
return false;
}
match self.hotspot {
HotspotMatch::Any => true,
HotspotMatch::Required => hotspot,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum DrainTier {
Must,
Should,
Advisory,
}
impl PolicyDrainConfig {
#[must_use]
pub fn tier_for(&self, finding: &crate::core::finding::Finding) -> Option<DrainTier> {
self.tier_for_attrs(&finding.metric, finding.severity, finding.hotspot)
}
#[must_use]
pub fn tier_for_attrs(
&self,
metric: &str,
severity: crate::core::severity::Severity,
hotspot: bool,
) -> Option<DrainTier> {
if severity == crate::core::severity::Severity::Ok {
return None;
}
if metric == crate::core::finding::Finding::METRIC_CHANGE_COUPLING_CROSS_WORKSPACE
&& !self.metrics.contains_key(metric)
{
return Some(DrainTier::Advisory);
}
let (must, should) = self.specs_for(metric);
if must.iter().any(|s| s.matches_attrs(severity, hotspot)) {
return Some(DrainTier::Must);
}
if should.iter().any(|s| s.matches_attrs(severity, hotspot)) {
return Some(DrainTier::Should);
}
Some(DrainTier::Advisory)
}
fn specs_for(&self, metric: &str) -> (&[DrainSpec], &[DrainSpec]) {
let mut must: &[DrainSpec] = &self.must;
let mut should: &[DrainSpec] = &self.should;
let override_chain =
std::iter::once(metric).chain(metric.split_once('.').map(|(parent, _)| parent));
for key in override_chain {
if let Some(ov) = self.metrics.get(key) {
if let Some(m) = ov.must.as_ref() {
must = m;
}
if let Some(s) = ov.should.as_ref() {
should = s;
}
if ov.must.is_some() && ov.should.is_some() {
break;
}
}
}
(must, should)
}
}
impl std::str::FromStr for DrainSpec {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let mut parts = s.split(':');
let severity_token = parts
.next()
.ok_or_else(|| format!("drain spec '{s}' is empty"))?;
let severity = severity_token
.parse::<crate::core::severity::Severity>()
.map_err(|_| {
format!(
"drain spec '{s}' has unknown severity '{severity_token}' (expected one of \
critical / high / medium / ok)"
)
})?;
let hotspot = match parts.next() {
None => HotspotMatch::Any,
Some("hotspot") => HotspotMatch::Required,
Some(other) => {
return Err(format!(
"drain spec '{s}' has unknown flag '{other}' (only 'hotspot' is supported)"
));
}
};
if parts.next().is_some() {
return Err(format!(
"drain spec '{s}' has too many ':' segments (expected at most one)"
));
}
Ok(Self { severity, hotspot })
}
}
impl serde::Serialize for DrainSpec {
fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
let severity = self.severity.as_str();
let body = match self.hotspot {
HotspotMatch::Any => severity.to_owned(),
HotspotMatch::Required => format!("{severity}:hotspot"),
};
ser.serialize_str(&body)
}
}
impl<'de> serde::Deserialize<'de> for DrainSpec {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
let s = String::deserialize(de)?;
s.parse::<Self>().map_err(serde::de::Error::custom)
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let raw = std::fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::ConfigMissing(path.to_path_buf())
} else {
Error::Io {
path: path.to_path_buf(),
source: e,
}
}
})?;
let cfg = Self::from_toml_str(&raw).map_err(|source| Error::ConfigParse {
path: path.to_path_buf(),
source,
})?;
cfg.validate(path)?;
Ok(cfg)
}
#[must_use = "ignoring the parse result will silently swallow schema errors"]
pub fn from_toml_str(s: &str) -> std::result::Result<Self, toml::de::Error> {
toml::from_str(s)
}
pub fn validate(&self, path: &Path) -> Result<()> {
validate_workspaces(&self.project.workspaces).map_err(|message| Error::ConfigInvalid {
path: path.to_path_buf(),
message: message.clone(),
})?;
validate_gitignore_lines(&self.exclude_lines()).map_err(|message| {
Error::ConfigInvalid {
path: path.to_path_buf(),
message,
}
})?;
let standalone = &self.features.docs.standalone;
validate_gitignore_lines(&standalone.include)
.and_then(|()| validate_gitignore_lines(&standalone.exclude))
.and_then(|()| validate_gitignore_lines(&standalone.entrypoints))
.and_then(|()| {
validate_gitignore_lines(&self.features.docs.todo_density.allowlist_paths)
})
.map_err(|message| Error::ConfigInvalid {
path: path.to_path_buf(),
message,
})?;
validate_disabled_metrics(&self.metrics.disabled).map_err(|message| {
Error::ConfigInvalid {
path: path.to_path_buf(),
message,
}
})?;
Ok(())
}
#[must_use = "the serialized string is the only return value"]
pub fn to_explicit_toml(&self) -> std::result::Result<String, toml::ser::Error> {
toml::to_string_pretty(self)
}
#[must_use = "the serialized string is the only return value"]
pub fn to_minimal_toml(&self) -> std::result::Result<String, toml::ser::Error> {
let mut actual = toml::Value::try_from(self)?;
let default = toml::Value::try_from(Self::default())?;
prune_against_default(&mut actual, &default);
toml::to_string_pretty(&actual)
}
pub fn save(&self, path: &Path) -> Result<()> {
let body = self
.to_minimal_toml()
.expect("serialization is infallible for owned data");
atomic_write(path, with_config_header(&body).as_bytes())
}
pub fn save_explicit(&self, path: &Path) -> Result<()> {
let body = self
.to_explicit_toml()
.expect("serialization is infallible for owned data");
atomic_write(path, with_config_header(&body).as_bytes())
}
#[must_use]
pub fn exclude_lines(&self) -> Vec<String> {
let mut lines: Vec<String> = if self.metrics.loc.inherit_git_excludes {
self.git.exclude_paths.clone()
} else {
Vec::new()
};
lines.extend(self.metrics.loc.exclude_paths.iter().cloned());
for ws in &self.project.workspaces {
let prefix = ws.path.trim_end_matches('/');
for ex in &ws.exclude_paths {
lines.push(translate_workspace_pattern(prefix, ex));
}
}
lines
}
}
fn validate_disabled_metrics(disabled: &[String]) -> std::result::Result<(), String> {
for name in disabled {
if name == "loc" {
return Err(
"metrics.disabled cannot contain `loc` — LOC is a foundational \
metric required by hotspot, churn weighting, and primary-language \
detection"
.to_owned(),
);
}
if !DISABLEABLE_METRICS.contains(&name.as_str()) {
return Err(format!(
"metrics.disabled contains unknown metric `{name}` — \
expected one of: {}",
DISABLEABLE_METRICS.join(", "),
));
}
}
Ok(())
}
fn validate_gitignore_lines(lines: &[String]) -> std::result::Result<(), String> {
if lines.is_empty() {
return Ok(());
}
let mut builder = ignore::gitignore::GitignoreBuilder::new(Path::new("/"));
for line in lines {
if let Err(e) = builder.add_line(None, line) {
return Err(format!("invalid gitignore pattern `{line}`: {e}"));
}
}
builder
.build()
.map(|_| ())
.map_err(|e| format!("gitignore matcher build failed: {e}"))
}
#[must_use]
pub(crate) fn translate_workspace_pattern(workspace_path: &str, line: &str) -> String {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
return line.to_owned();
}
let (negated, body) = trimmed
.strip_prefix('!')
.map_or((false, trimmed), |rest| (true, rest));
let translated = if let Some(anchored) = body.strip_prefix('/') {
format!("/{workspace_path}/{anchored}")
} else {
format!("{workspace_path}/**/{body}")
};
if negated {
format!("!{translated}")
} else {
translated
}
}
pub fn load_from_project(project_root: &Path) -> Result<Config> {
Config::load(&crate::core::paths::HealPaths::new(project_root).config())
}
fn validate_workspaces(workspaces: &[WorkspaceOverlay]) -> std::result::Result<(), String> {
let mut normalized: Vec<String> = Vec::with_capacity(workspaces.len());
for w in workspaces {
let p = w.path.trim();
if p.is_empty() {
return Err("[[project.workspaces]] entry has empty `path`".into());
}
if p.starts_with('/') {
return Err(format!(
"[[project.workspaces]] path `{p}` must be repo-root relative (no leading `/`)"
));
}
if p.split('/').any(|seg| seg == "..") {
return Err(format!(
"[[project.workspaces]] path `{p}` must not contain `..`"
));
}
for ex in &w.exclude_paths {
let e = ex.trim();
let body = e.strip_prefix('!').unwrap_or(e);
if body.is_empty() || body.starts_with('#') {
continue;
}
if body.split('/').any(|seg| seg == "..") {
return Err(format!(
"[[project.workspaces]] `{p}` exclude `{e}` must not contain `..`"
));
}
}
let canonical = p.trim_end_matches('/').to_string();
normalized.push(canonical);
}
for (i, a) in normalized.iter().enumerate() {
for b in normalized.iter().skip(i + 1) {
if a == b {
return Err(format!(
"[[project.workspaces]] declares `{a}` more than once"
));
}
let pa = Path::new(a);
let pb = Path::new(b);
if path_has_prefix(pb, pa, true) || path_has_prefix(pa, pb, true) {
return Err(format!(
"[[project.workspaces]] `{a}` and `{b}` nest; one workspace cannot live inside another"
));
}
}
}
Ok(())
}
const CONFIG_HEADER: &str = "\
# .heal/config.toml — heal's per-project config. Omitted keys fall back to defaults; run `heal init --explicit` to write the full default body.
# Run `claude /heal-setup` to calibrate the codebase and tune thresholds.
";
fn with_config_header(body: &str) -> String {
let trimmed = body.trim_start_matches('\n');
if trimmed.is_empty() {
CONFIG_HEADER.to_owned()
} else {
format!("{CONFIG_HEADER}\n{trimmed}")
}
}
fn prune_against_default(actual: &mut toml::Value, default: &toml::Value) {
let (Some(actual_table), Some(default_table)) = (actual.as_table_mut(), default.as_table())
else {
return;
};
let keys: Vec<String> = actual_table.keys().cloned().collect();
for key in keys {
let Some(default_val) = default_table.get(&key) else {
continue;
};
let actual_is_table = actual_table.get(&key).is_some_and(toml::Value::is_table);
let default_is_table = default_val.is_table();
if actual_is_table && default_is_table {
if let Some(actual_val) = actual_table.get_mut(&key) {
prune_against_default(actual_val, default_val);
if actual_val.as_table().is_some_and(toml::Table::is_empty) {
actual_table.remove(&key);
}
}
} else if actual_table.get(&key) == Some(default_val) {
actual_table.remove(&key);
}
}
}
fn path_has_prefix(path: &Path, prefix: &Path, strict: bool) -> bool {
if path.strip_prefix(prefix).is_err() {
return false;
}
!(strict && path == prefix)
}
#[must_use]
pub fn assign_workspace<'a>(file: &Path, workspaces: &'a [WorkspaceOverlay]) -> Option<&'a str> {
let mut best: Option<&str> = None;
for w in workspaces {
let candidate = w.path.trim_end_matches('/');
if path_has_prefix(file, Path::new(candidate), false)
&& best.is_none_or(|b: &str| candidate.len() > b.len())
{
best = Some(candidate);
}
}
best
}