1use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24
25use crate::graph::GraphDb;
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32pub struct BisectPredicates {
33 #[serde(default)]
35 pub node_count_min: Option<u64>,
36 #[serde(default)]
38 pub node_count_max: Option<u64>,
39 #[serde(default)]
41 pub nodes_exist: Vec<String>,
42 #[serde(default)]
44 pub nodes_missing: Vec<String>,
45 #[serde(default)]
47 pub nodes_alive: Vec<String>,
48 #[serde(default)]
50 pub rule_violations_max: Option<u64>,
51}
52
53#[derive(Debug, Clone)]
55pub struct PredicateOutcome {
56 pub predicate: String,
57 pub passed: bool,
58 pub detail: String,
59}
60
61#[derive(Debug, Clone)]
62pub struct BisectReport {
63 pub outcomes: Vec<PredicateOutcome>,
64}
65
66impl BisectReport {
67 pub fn passed(&self) -> bool {
68 self.outcomes.iter().all(|o| o.passed)
69 }
70}
71
72impl BisectPredicates {
73 pub fn load(repo_path: &Path, custom: Option<&Path>) -> anyhow::Result<Self> {
75 let target = match custom {
76 Some(p) => p.to_path_buf(),
77 None => repo_path.join(".cgx").join("bisect.toml"),
78 };
79 if !target.exists() {
80 anyhow::bail!(
81 "no predicate file at {}. Create `.cgx/bisect.toml` first — see `cgx bisect-script --example` for the schema.",
82 target.display()
83 );
84 }
85 let content = std::fs::read_to_string(&target)?;
86 let parsed: BisectPredicates = toml::from_str(&content)?;
87 Ok(parsed)
88 }
89
90 pub fn default_path(repo_path: &Path) -> PathBuf {
92 repo_path.join(".cgx").join("bisect.toml")
93 }
94
95 pub fn evaluate(&self, db: &GraphDb) -> anyhow::Result<BisectReport> {
97 let mut outcomes: Vec<PredicateOutcome> = Vec::new();
98
99 if let Some(min) = self.node_count_min {
100 let actual = db.node_count().unwrap_or(0);
101 outcomes.push(PredicateOutcome {
102 predicate: "node_count_min".to_string(),
103 passed: actual >= min,
104 detail: format!("required ≥ {}, got {}", min, actual),
105 });
106 }
107 if let Some(max) = self.node_count_max {
108 let actual = db.node_count().unwrap_or(0);
109 outcomes.push(PredicateOutcome {
110 predicate: "node_count_max".to_string(),
111 passed: actual <= max,
112 detail: format!("required ≤ {}, got {}", max, actual),
113 });
114 }
115
116 for id in &self.nodes_exist {
117 let exists = db.get_node(id).map(|n| n.is_some()).unwrap_or(false);
118 outcomes.push(PredicateOutcome {
119 predicate: format!("nodes_exist:{}", id),
120 passed: exists,
121 detail: if exists {
122 "found".to_string()
123 } else {
124 "missing".to_string()
125 },
126 });
127 }
128
129 for id in &self.nodes_missing {
130 let exists = db.get_node(id).map(|n| n.is_some()).unwrap_or(false);
131 outcomes.push(PredicateOutcome {
132 predicate: format!("nodes_missing:{}", id),
133 passed: !exists,
134 detail: if exists {
135 "still present".to_string()
136 } else {
137 "absent".to_string()
138 },
139 });
140 }
141
142 for id in &self.nodes_alive {
143 let node = db.get_node(id).ok().flatten();
144 let (alive, detail) = match &node {
145 Some(n) if n.is_dead_candidate => (false, "flagged dead".to_string()),
146 Some(_) => (true, "alive".to_string()),
147 None => (false, "missing".to_string()),
148 };
149 outcomes.push(PredicateOutcome {
150 predicate: format!("nodes_alive:{}", id),
151 passed: alive,
152 detail,
153 });
154 }
155
156 if let Some(_max) = self.rule_violations_max {
157 outcomes.push(PredicateOutcome {
162 predicate: "rule_violations_max".to_string(),
163 passed: true,
164 detail: "skipped (run `cgx rules check` separately and use --rule-violations)"
165 .to_string(),
166 });
167 }
168
169 Ok(BisectReport { outcomes })
170 }
171}
172
173pub const EXAMPLE_TOML: &str = r#"# .cgx/bisect.toml — declarative predicates for `git bisect run cgx bisect-script`.
176# All populated predicates must pass for the commit to be considered "good".
177
178# Graph size bounds — useful for catching mass deletions or runaway code growth.
179node_count_min = 100
180# node_count_max = 5000
181
182# Node IDs that must exist (e.g. a public function you accidentally deleted).
183nodes_exist = [
184 # "fn:src/auth.rs:authenticate",
185]
186
187# Node IDs that must NOT exist (e.g. a symbol you renamed away that shouldn't come back).
188nodes_missing = [
189 # "fn:src/auth.rs:old_authenticate",
190]
191
192# Node IDs that must exist AND not be flagged as dead-code candidates.
193nodes_alive = [
194 # "fn:src/api.rs:start_server",
195]
196
197# Maximum number of architecture-rule violations allowed (run `cgx rules check`
198# separately and feed the count via `--rule-violations N`).
199# rule_violations_max = 0
200"#;