1use std::path::Path;
2
3use anyhow::{anyhow, Result};
4use chrono::{Duration, Utc};
5
6use crate::bean::Bean;
7use crate::commands::create::{cmd_create, CreateArgs};
8use crate::discovery::find_bean_file;
9use crate::index::Index;
10
11const DEFAULT_TTL_DAYS: i64 = 30;
13
14pub fn cmd_fact(
19 beans_dir: &Path,
20 title: String,
21 verify: String,
22 description: Option<String>,
23 paths: Option<String>,
24 ttl_days: Option<i64>,
25 pass_ok: bool,
26) -> Result<String> {
27 if verify.trim().is_empty() {
28 return Err(anyhow!(
29 "Facts require a verify command. If you can't write one, \
30 this belongs in agents.md, not bn fact."
31 ));
32 }
33
34 let bean_id = cmd_create(
36 beans_dir,
37 CreateArgs {
38 title,
39 description,
40 acceptance: None,
41 notes: None,
42 design: None,
43 verify: Some(verify),
44 priority: Some(3), labels: Some("fact".to_string()),
46 assignee: None,
47 deps: None,
48 parent: None,
49 produces: None,
50 requires: None,
51 paths: None,
52 on_fail: None,
53 pass_ok,
54 claim: false,
55 by: None,
56 verify_timeout: None,
57 },
58 )?;
59
60 let bean_path = find_bean_file(beans_dir, &bean_id)?;
62 let mut bean = Bean::from_file(&bean_path)?;
63
64 bean.bean_type = "fact".to_string();
65
66 let ttl = ttl_days.unwrap_or(DEFAULT_TTL_DAYS);
68 bean.stale_after = Some(Utc::now() + Duration::days(ttl));
69
70 if let Some(paths_str) = paths {
72 bean.paths = paths_str
73 .split(',')
74 .map(|s| s.trim().to_string())
75 .filter(|s| !s.is_empty())
76 .collect();
77 }
78
79 bean.to_file(&bean_path)?;
80
81 let index = Index::build(beans_dir)?;
83 index.save(beans_dir)?;
84
85 eprintln!("Created fact {}: {}", bean_id, bean.title);
86 Ok(bean_id)
87}
88
89pub fn cmd_verify_facts(beans_dir: &Path) -> Result<()> {
98 use std::collections::{HashMap, HashSet};
99 use std::process::Command as ShellCommand;
100
101 let project_root = beans_dir
102 .parent()
103 .ok_or_else(|| anyhow!("Cannot determine project root from beans dir"))?;
104
105 let index = Index::load_or_rebuild(beans_dir)?;
107 let archived = Index::collect_archived(beans_dir).unwrap_or_default();
108
109 let now = Utc::now();
110 let mut stale_count = 0;
111 let mut failing_count = 0;
112 let mut verified_count = 0;
113 let mut total_facts = 0;
114 let mut suspect_count = 0;
115
116 let mut invalid_artifacts: HashSet<String> = HashSet::new();
118 let mut fact_requires: HashMap<String, Vec<String>> = HashMap::new();
119 let mut fact_titles: HashMap<String, String> = HashMap::new();
120
121 for entry in index.beans.iter().chain(archived.iter()) {
123 let bean_path = if entry.status == crate::bean::Status::Closed {
124 crate::discovery::find_archived_bean(beans_dir, &entry.id).ok()
125 } else {
126 find_bean_file(beans_dir, &entry.id).ok()
127 };
128
129 let bean_path = match bean_path {
130 Some(p) => p,
131 None => continue,
132 };
133
134 let mut bean = match Bean::from_file(&bean_path) {
135 Ok(b) => b,
136 Err(_) => continue,
137 };
138
139 if bean.bean_type != "fact" {
140 continue;
141 }
142
143 total_facts += 1;
144 fact_titles.insert(bean.id.clone(), bean.title.clone());
145 if !bean.requires.is_empty() {
146 fact_requires.insert(bean.id.clone(), bean.requires.clone());
147 }
148
149 let is_stale = bean.stale_after.map(|sa| now > sa).unwrap_or(false);
151
152 if is_stale {
153 stale_count += 1;
154 eprintln!("⚠ STALE: [{}] \"{}\"", bean.id, bean.title);
155 for prod in &bean.produces {
157 invalid_artifacts.insert(prod.clone());
158 }
159 }
160
161 if let Some(ref verify_cmd) = bean.verify {
163 let output = ShellCommand::new("sh")
164 .args(["-c", verify_cmd])
165 .current_dir(project_root)
166 .output();
167
168 match output {
169 Ok(o) if o.status.success() => {
170 verified_count += 1;
171 bean.last_verified = Some(now);
172 if bean.stale_after.is_some() {
174 bean.stale_after = Some(now + Duration::days(DEFAULT_TTL_DAYS));
175 }
176 bean.to_file(&bean_path)?;
177 println!(" ✓ [{}] \"{}\"", bean.id, bean.title);
178 }
179 Ok(_) => {
180 failing_count += 1;
181 for prod in &bean.produces {
183 invalid_artifacts.insert(prod.clone());
184 }
185 eprintln!(
186 " ✗ FAILING: [{}] \"{}\" — verify command returned non-zero",
187 bean.id, bean.title
188 );
189 }
190 Err(e) => {
191 failing_count += 1;
192 for prod in &bean.produces {
193 invalid_artifacts.insert(prod.clone());
194 }
195 eprintln!(" ✗ ERROR: [{}] \"{}\" — {}", bean.id, bean.title, e);
196 }
197 }
198 }
199 }
200
201 if !invalid_artifacts.is_empty() {
203 let mut suspect_ids: HashSet<String> = HashSet::new();
204 let mut current_invalid = invalid_artifacts.clone();
205
206 for _depth in 0..3 {
207 let mut newly_invalid: HashSet<String> = HashSet::new();
208
209 for (fact_id, requires) in &fact_requires {
210 if suspect_ids.contains(fact_id) {
211 continue;
212 }
213 for req in requires {
214 if current_invalid.contains(req) {
215 suspect_ids.insert(fact_id.clone());
216 if let Some(entry) = index
219 .beans
220 .iter()
221 .chain(archived.iter())
222 .find(|e| e.id == *fact_id)
223 {
224 let bean_path = if entry.status == crate::bean::Status::Closed {
225 crate::discovery::find_archived_bean(beans_dir, &entry.id).ok()
226 } else {
227 find_bean_file(beans_dir, &entry.id).ok()
228 };
229 if let Some(bp) = bean_path {
230 if let Ok(b) = Bean::from_file(&bp) {
231 for prod in &b.produces {
232 newly_invalid.insert(prod.clone());
233 }
234 }
235 }
236 }
237 break;
238 }
239 }
240 }
241
242 if newly_invalid.is_empty() {
243 break;
244 }
245 current_invalid = newly_invalid;
246 }
247
248 for suspect_id in &suspect_ids {
249 suspect_count += 1;
250 let title = fact_titles
251 .get(suspect_id)
252 .map(|s| s.as_str())
253 .unwrap_or("?");
254 eprintln!(
255 " ⚠ SUSPECT: [{}] \"{}\" — requires artifact from invalid fact",
256 suspect_id, title
257 );
258 }
259 }
260
261 println!();
262 println!(
263 "Facts: {} total, {} verified, {} stale, {} failing, {} suspect",
264 total_facts, verified_count, stale_count, failing_count, suspect_count
265 );
266
267 if failing_count > 0 {
268 std::process::exit(1);
269 }
270
271 Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::config::Config;
278 use std::fs;
279 use tempfile::TempDir;
280
281 fn setup_beans_dir_with_config() -> (TempDir, std::path::PathBuf) {
282 let dir = TempDir::new().unwrap();
283 let beans_dir = dir.path().join(".beans");
284 fs::create_dir(&beans_dir).unwrap();
285
286 let config = Config {
287 project: "test".to_string(),
288 next_id: 1,
289 auto_close_parent: true,
290 max_tokens: 30000,
291 run: None,
292 plan: None,
293 max_loops: 10,
294 max_concurrent: 4,
295 poll_interval: 30,
296 extends: vec![],
297 rules_file: None,
298 file_locking: false,
299 on_close: None,
300 on_fail: None,
301 post_plan: None,
302 verify_timeout: None,
303 review: None,
304 };
305 config.save(&beans_dir).unwrap();
306
307 (dir, beans_dir)
308 }
309
310 #[test]
311 fn create_fact_sets_bean_type() {
312 let (_dir, beans_dir) = setup_beans_dir_with_config();
313
314 let id = cmd_fact(
315 &beans_dir,
316 "Auth uses RS256".to_string(),
317 "grep -q RS256 src/auth.rs".to_string(),
318 None,
319 None,
320 None,
321 true, )
323 .unwrap();
324
325 let bean_path = find_bean_file(&beans_dir, &id).unwrap();
326 let bean = Bean::from_file(&bean_path).unwrap();
327
328 assert_eq!(bean.bean_type, "fact");
329 assert!(bean.labels.contains(&"fact".to_string()));
330 assert!(bean.stale_after.is_some());
331 assert!(bean.verify.is_some());
332 }
333
334 #[test]
335 fn create_fact_with_paths() {
336 let (_dir, beans_dir) = setup_beans_dir_with_config();
337
338 let id = cmd_fact(
339 &beans_dir,
340 "Config file format".to_string(),
341 "true".to_string(),
342 None,
343 Some("src/config.rs, src/main.rs".to_string()),
344 None,
345 true,
346 )
347 .unwrap();
348
349 let bean_path = find_bean_file(&beans_dir, &id).unwrap();
350 let bean = Bean::from_file(&bean_path).unwrap();
351
352 assert_eq!(bean.paths, vec!["src/config.rs", "src/main.rs"]);
353 }
354
355 #[test]
356 fn create_fact_with_custom_ttl() {
357 let (_dir, beans_dir) = setup_beans_dir_with_config();
358
359 let id = cmd_fact(
360 &beans_dir,
361 "Short-lived fact".to_string(),
362 "true".to_string(),
363 None,
364 None,
365 Some(7), true,
367 )
368 .unwrap();
369
370 let bean_path = find_bean_file(&beans_dir, &id).unwrap();
371 let bean = Bean::from_file(&bean_path).unwrap();
372
373 let stale = bean.stale_after.unwrap();
375 let diff = stale - Utc::now();
376 assert!(diff.num_days() >= 6 && diff.num_days() <= 7);
377 }
378
379 #[test]
380 fn create_fact_requires_verify() {
381 let (_dir, beans_dir) = setup_beans_dir_with_config();
382
383 let result = cmd_fact(
384 &beans_dir,
385 "No verify fact".to_string(),
386 " ".to_string(), None,
388 None,
389 None,
390 true,
391 );
392
393 assert!(result.is_err());
394 assert!(result.unwrap_err().to_string().contains("verify command"));
395 }
396}