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,
}
#[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)]
#[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 primary_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)]
pub loc: LocConfig,
#[serde(default = "default_enabled")]
pub churn: ChurnConfig,
#[serde(default = "default_enabled")]
pub hotspot: HotspotConfig,
#[serde(default = "default_enabled")]
pub change_coupling: ChangeCouplingConfig,
#[serde(default = "default_enabled")]
pub duplication: DuplicationConfig,
#[serde(default = "default_enabled")]
pub ccn: CcnConfig,
#[serde(default = "default_enabled")]
pub cognitive: CognitiveConfig,
#[serde(default = "default_enabled")]
pub lcom: LcomConfig,
}
impl Eq for MetricsConfig {}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
top_n: default_top_n(),
loc: LocConfig::default(),
churn: ChurnConfig::enabled(),
hotspot: HotspotConfig::enabled(),
change_coupling: ChangeCouplingConfig::enabled(),
duplication: DuplicationConfig::enabled(),
ccn: CcnConfig::enabled(),
cognitive: CognitiveConfig::enabled(),
lcom: LcomConfig::enabled(),
}
}
}
impl MetricsConfig {
#[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
}
trait Toggle {
fn enabled() -> Self;
}
fn default_enabled<T: Toggle>() -> T {
T::enabled()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CognitiveConfig {
pub enabled: bool,
#[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 {}
impl Toggle for CognitiveConfig {
fn enabled() -> Self {
Self {
enabled: true,
floor_critical: None,
floor_ok: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ChurnConfig {
pub enabled: bool,
#[serde(default)]
pub top_n: Option<usize>,
}
impl Toggle for ChurnConfig {
fn enabled() -> Self {
Self {
enabled: true,
top_n: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct HotspotConfig {
pub enabled: bool,
#[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 {
enabled: false,
weight_churn: default_weight(),
weight_complexity: default_weight(),
top_n: None,
floor_ok: None,
}
}
}
impl Toggle for HotspotConfig {
fn enabled() -> Self {
Self {
enabled: true,
..Self::default()
}
}
}
fn default_weight() -> f64 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ChangeCouplingConfig {
pub enabled: bool,
#[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 {
enabled: false,
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(),
}
}
}
impl Toggle for ChangeCouplingConfig {
fn enabled() -> Self {
Self {
enabled: true,
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 {
pub enabled: bool,
#[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 {
enabled: false,
backend: LcomBackend::default(),
min_cluster_count: default_min_cluster_count(),
top_n: None,
floor_critical: None,
}
}
}
impl Toggle for LcomConfig {
fn enabled() -> Self {
Self {
enabled: true,
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 {
pub enabled: bool,
#[serde(default = "default_min_tokens")]
pub 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 {
enabled: false,
min_tokens: default_min_tokens(),
top_n: None,
floor_critical: None,
}
}
}
impl Toggle for DuplicationConfig {
fn enabled() -> Self {
Self {
enabled: true,
min_tokens: default_min_tokens(),
top_n: None,
floor_critical: None,
}
}
}
fn default_min_tokens() -> u32 {
50
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CcnConfig {
pub enabled: bool,
#[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 {}
impl Toggle for CcnConfig {
fn enabled() -> Self {
Self {
enabled: true,
top_n: None,
floor_critical: None,
floor_ok: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub enum PolicyAction {
ReportOnly,
Notify,
Propose,
Execute,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PolicyConfig {
#[serde(default)]
pub drain: PolicyDrainConfig,
#[serde(default)]
pub rules: BTreeMap<String, PolicyRuleConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PolicyRuleConfig {
pub action: PolicyAction,
#[serde(default)]
pub threshold: BTreeMap<String, toml::Value>,
#[serde(default)]
pub trigger: Option<String>,
}
#[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,
}
})?;
Ok(())
}
#[must_use = "the serialised string is the only return value"]
pub fn to_toml_string(&self) -> std::result::Result<String, toml::ser::Error> {
toml::to_string_pretty(self)
}
pub fn save(&self, path: &Path) -> Result<()> {
let body = self
.to_toml_string()
.expect("serialization is infallible for owned data");
atomic_write(path, 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_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(())
}
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
}