Skip to main content

bn/commands/
fact.rs

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
11/// Default TTL for facts: 30 days.
12const DEFAULT_TTL_DAYS: i64 = 30;
13
14/// Create a verified fact (convenience wrapper around create with bean_type=fact).
15///
16/// Facts require a verify command — that's the point. If you can't write a
17/// verify command, the knowledge belongs in agents.md, not in `bn fact`.
18pub 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    // Create the bean via normal create flow
35    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), // facts are lower priority than tasks
45            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    // Now patch the bean to set fact-specific fields
61    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    // Set TTL
67    let ttl = ttl_days.unwrap_or(DEFAULT_TTL_DAYS);
68    bean.stale_after = Some(Utc::now() + Duration::days(ttl));
69
70    // Set paths for relevance matching
71    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    // Rebuild index
82    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
89/// Verify all facts and report staleness.
90///
91/// Re-runs verify commands for all beans with bean_type=fact.
92/// Reports which facts are stale (past their stale_after date)
93/// and which have failing verify commands.
94///
95/// Suspect propagation: facts that require artifacts from failing/stale facts
96/// are marked as suspect (up to depth 3).
97pub 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    // Find all fact beans (both active and archived)
106    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    // Collect all facts and their states for suspect propagation
117    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    // Check active beans
122    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        // Check staleness
150        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            // Stale facts invalidate their produced artifacts
156            for prod in &bean.produces {
157                invalid_artifacts.insert(prod.clone());
158            }
159        }
160
161        // Re-run verify command
162        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                    // Reset stale_after from now
173                    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                    // Failing facts invalidate their produced artifacts
182                    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    // Suspect propagation: facts requiring invalid artifacts are suspect (depth limit 3)
202    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                        // This suspect fact's produced artifacts also become invalid
217                        // (for the next depth iteration)
218                        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, // pass_ok since file doesn't exist
322        )
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), // 7 days
366            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        // stale_after should be ~7 days from now
374        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(), // empty verify
387            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}