1use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32
33use anyhow::{Context, Result};
34use brain::BrainConfig;
35use serde::{Deserialize, Serialize};
36
37pub fn capabilities(_config: &BrainConfig) -> Vec<intent::ToolDescriptor> {
41 use crate::capabilities::{backend, native, read_only, usage};
42 use intent::ToolAnnotations;
43 let base = || backend("baseline");
44 vec![
45 native(
46 "baseline",
47 "capture",
48 base(),
49 ToolAnnotations::default(),
50 usage(
51 "The user wants to record the current system state so future drift can be detected.",
52 "For comparing against an existing snapshot — use baseline.diff.",
53 &["None — offline config + capability snapshot."],
54 "free / local snapshot file",
55 "\"Capture a baseline of the current setup\"",
56 ),
57 ),
58 native(
59 "baseline",
60 "diff",
61 base(),
62 read_only(),
63 usage(
64 "The user asks what has changed since a baseline, or to detect configuration drift.",
65 "When no baseline has been captured yet — capture one first.",
66 &["At least one baseline has been captured."],
67 "free / local snapshot comparison",
68 "\"What's drifted since my last baseline?\"",
69 ),
70 ),
71 native(
72 "baseline",
73 "list",
74 base(),
75 read_only(),
76 usage(
77 "The user wants to see which baseline snapshots have been stored.",
78 "For the contents of a specific snapshot — use baseline.diff.",
79 &["None."],
80 "free / local directory listing",
81 "\"List the baselines I've captured\"",
82 ),
83 ),
84 ]
85}
86
87#[derive(Debug, Clone)]
91pub struct CapabilitySummary {
92 pub namespace: String,
93 pub action: String,
94 pub tier: String,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100struct Baseline {
101 version: u32,
103 captured_at: String,
105 #[serde(default)]
107 label: Option<String>,
108 facts: BTreeMap<String, String>,
110}
111
112pub fn capture(
117 config: &BrainConfig,
118 inventory: &[CapabilitySummary],
119 label: Option<&str>,
120) -> Result<String> {
121 let dir = baseline_dir(config);
122 std::fs::create_dir_all(&dir)
123 .with_context(|| format!("creating baseline directory {}", dir.display()))?;
124
125 let version = next_version(&dir);
126 let baseline = Baseline {
127 version,
128 captured_at: chrono::Utc::now().to_rfc3339(),
129 label: label.map(str::to_string),
130 facts: capture_facts(config, inventory),
131 };
132
133 let path = baseline_path(&dir, version);
134 let json =
135 serde_json::to_string_pretty(&baseline).context("serialising the baseline snapshot")?;
136 std::fs::write(&path, json)
137 .with_context(|| format!("writing baseline to {}", path.display()))?;
138
139 let mut out = format!(
140 "Captured baseline v{} ({} facts){}\n",
141 version,
142 baseline.facts.len(),
143 label.map(|l| format!(" — {l}")).unwrap_or_default(),
144 );
145 out.push_str(&format!(" {}", path.display()));
146 Ok(out)
147}
148
149fn capture_facts(
153 config: &BrainConfig,
154 inventory: &[CapabilitySummary],
155) -> BTreeMap<String, String> {
156 let mut f = BTreeMap::new();
157 let mut put = |k: &str, v: String| {
158 f.insert(k.to_string(), v);
159 };
160
161 put("brain.version", env!("CARGO_PKG_VERSION").to_string());
163 put("os.platform", std::env::consts::OS.to_string());
164 put("os.arch", std::env::consts::ARCH.to_string());
165
166 put("llm.context_window", config.llm.context_window.to_string());
170 if config.llm.providers.is_empty() {
171 #[allow(deprecated)]
172 {
173 put("llm.provider", config.llm.provider.clone());
174 put("llm.model", config.llm.model.clone());
175 }
176 } else {
177 for p in &config.llm.providers {
178 put(
179 &format!("llm.provider.{}", p.name),
180 format!("{} ({})", p.kind, p.model),
181 );
182 }
183 }
184
185 put("embedding.model", config.embedding.model.clone());
187 put(
188 "embedding.dimensions",
189 config.embedding.dimensions.to_string(),
190 );
191 put("encryption.enabled", config.encryption.enabled.to_string());
192
193 let a = &config.adapters;
195 put("adapter.http", adapter_fact(a.http.enabled, a.http.port));
196 put("adapter.ws", adapter_fact(a.ws.enabled, a.ws.port));
197 put("adapter.mcp", adapter_fact(a.mcp.enabled, a.mcp.port));
198 put("adapter.grpc", adapter_fact(a.grpc.enabled, a.grpc.port));
199 put(
200 "adapter.terminal",
201 adapter_fact(a.terminal.enabled, a.terminal.port),
202 );
203
204 put(
206 "actions.web_search",
207 config.actions.web_search.enabled.to_string(),
208 );
209 put(
210 "actions.scheduling",
211 config.actions.scheduling.enabled.to_string(),
212 );
213 put(
214 "actions.messaging",
215 config.actions.messaging.enabled.to_string(),
216 );
217
218 put(
220 "agents.auto_discovery",
221 config.agents.auto_discovery.to_string(),
222 );
223 let mut delegates: Vec<&str> = config
224 .agents
225 .delegates
226 .iter()
227 .map(|d| d.name.as_str())
228 .collect();
229 delegates.sort_unstable();
230 put("agents.delegates", join_or_none(&delegates));
231
232 put("reflex.fs.watchers", config.reflex.fs.len().to_string());
235 put(
236 "reflex.cron.enabled",
237 config.reflex.cron.enabled.to_string(),
238 );
239 put("reflex.sys.enabled", config.reflex.sys.enabled.to_string());
240
241 let mut services: Vec<&str> = config
243 .monitoring
244 .services
245 .iter()
246 .map(|s| s.name.as_str())
247 .collect();
248 services.sort_unstable();
249 put("monitoring.services", join_or_none(&services));
250
251 let mut allow: Vec<&str> = config
253 .security
254 .exec_allowlist
255 .iter()
256 .map(String::as_str)
257 .collect();
258 allow.sort_unstable();
259 put("security.exec_allowlist", join_or_none(&allow));
260 put(
261 "security.allowed_paths",
262 config.security.allowed_paths.len().to_string(),
263 );
264
265 for c in inventory {
269 put(
270 &format!("capability.{}.{}", c.namespace, c.action),
271 c.tier.clone(),
272 );
273 }
274
275 f
276}
277
278fn adapter_fact(enabled: bool, port: u16) -> String {
279 if enabled {
280 format!("enabled port={port}")
281 } else {
282 "disabled".to_string()
283 }
284}
285
286fn join_or_none(items: &[&str]) -> String {
289 if items.is_empty() {
290 "(none)".to_string()
291 } else {
292 items.join(", ")
293 }
294}
295
296pub fn diff(
303 config: &BrainConfig,
304 inventory: &[CapabilitySummary],
305 from: Option<u32>,
306 to: Option<u32>,
307) -> Result<String> {
308 let dir = baseline_dir(config);
309 let stored = list_baselines(&dir);
310 if stored.is_empty() {
311 anyhow::bail!(
312 "no baselines stored in {} — capture one first with `brain baseline capture`",
313 dir.display()
314 );
315 }
316
317 let from_version = from.unwrap_or_else(|| stored.last().map(|b| b.version).unwrap());
319 let base = load_baseline(&dir, from_version)?;
320
321 let (target_facts, target_label) = match to {
323 Some(v) => {
324 let b = load_baseline(&dir, v)?;
325 (
326 b.facts,
327 format!("baseline v{v} ({})", short_time(&b.captured_at)),
328 )
329 }
330 None => (
331 capture_facts(config, inventory),
332 "current live state".to_string(),
333 ),
334 };
335
336 let from_label = format!(
337 "baseline v{} ({})",
338 base.version,
339 short_time(&base.captured_at)
340 );
341 let mut out = format!("Drift: {from_label} → {target_label}\n");
342 if let Some(l) = &base.label {
343 out.push_str(&format!(" from label: {l}\n"));
344 }
345
346 let drift = Drift::between(&base.facts, &target_facts);
347 out.push_str(&drift.render());
348 Ok(out)
349}
350
351struct Drift {
353 added: Vec<(String, String)>,
354 removed: Vec<(String, String)>,
355 changed: Vec<(String, String, String)>,
357}
358
359impl Drift {
360 fn between(from: &BTreeMap<String, String>, to: &BTreeMap<String, String>) -> Drift {
361 let mut added = Vec::new();
362 let mut removed = Vec::new();
363 let mut changed = Vec::new();
364
365 for (k, new) in to {
366 match from.get(k) {
367 None => added.push((k.clone(), new.clone())),
368 Some(old) if old != new => changed.push((k.clone(), old.clone(), new.clone())),
369 Some(_) => {}
370 }
371 }
372 for (k, old) in from {
373 if !to.contains_key(k) {
374 removed.push((k.clone(), old.clone()));
375 }
376 }
377 Drift {
378 added,
379 removed,
380 changed,
381 }
382 }
383
384 fn is_empty(&self) -> bool {
385 self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
386 }
387
388 fn render(&self) -> String {
389 if self.is_empty() {
390 return " No drift — every fact matches.\n".to_string();
391 }
392 let mut s = String::new();
393 for (k, old, new) in &self.changed {
394 s.push_str(&format!(" ~ {k}: {old} → {new}\n"));
395 }
396 for (k, v) in &self.added {
397 s.push_str(&format!(" + {k}: {v}\n"));
398 }
399 for (k, v) in &self.removed {
400 s.push_str(&format!(" - {k}: {v}\n"));
401 }
402 s.push_str(&format!(
403 "\n {} changed, {} added, {} removed\n",
404 self.changed.len(),
405 self.added.len(),
406 self.removed.len(),
407 ));
408 s
409 }
410}
411
412pub fn list(config: &BrainConfig) -> Result<String> {
416 let dir = baseline_dir(config);
417 let mut baselines = list_baselines(&dir);
418 if baselines.is_empty() {
419 return Ok(format!(
420 "No baselines stored in {}. Capture one with `brain baseline capture`.",
421 dir.display()
422 ));
423 }
424 baselines.reverse(); let mut out = format!("Baselines in {}:\n", dir.display());
426 for b in &baselines {
427 let label = b
428 .label
429 .as_deref()
430 .map(|l| format!(" — {l}"))
431 .unwrap_or_default();
432 out.push_str(&format!(
433 " v{:<4} {} ({} facts){}\n",
434 b.version,
435 short_time(&b.captured_at),
436 b.facts.len(),
437 label,
438 ));
439 }
440 Ok(out)
441}
442
443fn baseline_dir(config: &BrainConfig) -> PathBuf {
446 config.data_dir().join("baselines")
447}
448
449fn baseline_path(dir: &Path, version: u32) -> PathBuf {
450 dir.join(format!("baseline-{version:04}.json"))
451}
452
453fn list_baselines(dir: &Path) -> Vec<Baseline> {
456 let Ok(entries) = std::fs::read_dir(dir) else {
457 return Vec::new();
458 };
459 let mut out: Vec<Baseline> = entries
460 .flatten()
461 .map(|e| e.path())
462 .filter(|p| is_baseline_file(p))
463 .filter_map(|p| std::fs::read_to_string(&p).ok())
464 .filter_map(|s| serde_json::from_str::<Baseline>(&s).ok())
465 .collect();
466 out.sort_by_key(|b| b.version);
467 out
468}
469
470fn is_baseline_file(p: &Path) -> bool {
471 p.is_file()
472 && p.file_name()
473 .and_then(|n| n.to_str())
474 .is_some_and(|n| n.starts_with("baseline-") && n.ends_with(".json"))
475}
476
477fn load_baseline(dir: &Path, version: u32) -> Result<Baseline> {
478 let path = baseline_path(dir, version);
479 let content = std::fs::read_to_string(&path).with_context(|| {
480 format!(
481 "no baseline v{version} at {} — run `brain baseline list` to see stored versions",
482 path.display()
483 )
484 })?;
485 serde_json::from_str(&content)
486 .with_context(|| format!("parsing baseline v{version} at {}", path.display()))
487}
488
489fn next_version(dir: &Path) -> u32 {
491 list_baselines(dir)
492 .last()
493 .map(|b| b.version + 1)
494 .unwrap_or(1)
495}
496
497fn short_time(rfc3339: &str) -> String {
500 chrono::DateTime::parse_from_rfc3339(rfc3339)
501 .map(|t| t.format("%Y-%m-%d %H:%M").to_string())
502 .unwrap_or_else(|_| rfc3339.to_string())
503}
504
505pub struct BaselineProvider {
513 config: BrainConfig,
514 inventory: Vec<CapabilitySummary>,
515}
516
517impl BaselineProvider {
518 pub fn new(config: BrainConfig, inventory: Vec<CapabilitySummary>) -> Self {
519 Self { config, inventory }
520 }
521}
522
523#[async_trait::async_trait]
524impl cortex::actions::BaselineBackend for BaselineProvider {
525 async fn capture(&self, label: Option<&str>) -> Result<String, cortex::actions::ActionError> {
526 capture(&self.config, &self.inventory, label).map_err(to_action_err)
527 }
528
529 async fn diff(
530 &self,
531 from: Option<u32>,
532 to: Option<u32>,
533 ) -> Result<String, cortex::actions::ActionError> {
534 diff(&self.config, &self.inventory, from, to).map_err(to_action_err)
535 }
536
537 async fn list(&self) -> Result<String, cortex::actions::ActionError> {
538 list(&self.config).map_err(to_action_err)
539 }
540}
541
542fn to_action_err(e: anyhow::Error) -> cortex::actions::ActionError {
543 cortex::actions::ActionError::ExecutionFailed(e.to_string())
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 fn test_config() -> BrainConfig {
551 BrainConfig::default()
552 }
553
554 fn test_inventory() -> Vec<CapabilitySummary> {
557 vec![
558 CapabilitySummary {
559 namespace: "memory".into(),
560 action: "store".into(),
561 tier: "write".into(),
562 },
563 CapabilitySummary {
564 namespace: "net".into(),
565 action: "http".into(),
566 tier: "external".into(),
567 },
568 ]
569 }
570
571 #[test]
572 fn capture_is_deterministic_for_one_config() {
573 let cfg = test_config();
574 let inv = test_inventory();
575 assert_eq!(capture_facts(&cfg, &inv), capture_facts(&cfg, &inv));
576 }
577
578 #[test]
579 fn capture_records_core_identity_facts() {
580 let facts = capture_facts(&test_config(), &test_inventory());
581 assert_eq!(
582 facts.get("brain.version").map(String::as_str),
583 Some(env!("CARGO_PKG_VERSION"))
584 );
585 assert_eq!(
586 facts.get("os.arch").map(String::as_str),
587 Some(std::env::consts::ARCH)
588 );
589 assert!(facts.contains_key("capability.memory.store"));
591 assert_eq!(
592 facts.get("capability.memory.store").map(String::as_str),
593 Some("write")
594 );
595 }
596
597 #[test]
598 fn dropping_an_inventory_entry_drops_its_capability_fact() {
599 let cfg = test_config();
600 let with_web = capture_facts(&cfg, &test_inventory());
601 let without_web = capture_facts(
603 &cfg,
604 &[CapabilitySummary {
605 namespace: "memory".into(),
606 action: "store".into(),
607 tier: "write".into(),
608 }],
609 );
610
611 assert!(with_web.contains_key("capability.net.http"));
612 assert!(!without_web.contains_key("capability.net.http"));
613
614 let drift = Drift::between(&with_web, &without_web);
615 assert!(drift
616 .removed
617 .iter()
618 .any(|(k, _)| k == "capability.net.http"));
619 }
620
621 #[test]
622 fn drift_between_identical_maps_is_empty() {
623 let facts = capture_facts(&test_config(), &test_inventory());
624 let drift = Drift::between(&facts, &facts);
625 assert!(drift.is_empty());
626 assert!(drift.render().contains("No drift"));
627 }
628
629 #[test]
630 fn drift_classifies_added_removed_changed() {
631 let mut from = BTreeMap::new();
632 from.insert("keep".to_string(), "same".to_string());
633 from.insert("change".to_string(), "old".to_string());
634 from.insert("gone".to_string(), "x".to_string());
635 let mut to = BTreeMap::new();
636 to.insert("keep".to_string(), "same".to_string());
637 to.insert("change".to_string(), "new".to_string());
638 to.insert("fresh".to_string(), "y".to_string());
639
640 let drift = Drift::between(&from, &to);
641 assert_eq!(drift.added, vec![("fresh".to_string(), "y".to_string())]);
642 assert_eq!(drift.removed, vec![("gone".to_string(), "x".to_string())]);
643 assert_eq!(
644 drift.changed,
645 vec![("change".to_string(), "old".to_string(), "new".to_string())]
646 );
647 assert!(!drift.is_empty());
648 }
649
650 #[test]
651 fn next_version_increments_and_round_trips() {
652 let dir = tempfile::tempdir().unwrap();
653 let p = dir.path();
654 assert_eq!(next_version(p), 1);
655
656 let b = Baseline {
657 version: 1,
658 captured_at: chrono::Utc::now().to_rfc3339(),
659 label: Some("first".into()),
660 facts: capture_facts(&test_config(), &test_inventory()),
661 };
662 std::fs::write(
663 baseline_path(p, 1),
664 serde_json::to_string_pretty(&b).unwrap(),
665 )
666 .unwrap();
667
668 assert_eq!(next_version(p), 2);
669 let loaded = load_baseline(p, 1).unwrap();
670 assert_eq!(loaded.version, 1);
671 assert_eq!(loaded.label.as_deref(), Some("first"));
672 assert_eq!(loaded.facts, b.facts);
673 }
674
675 #[test]
676 fn list_baselines_ignores_non_baseline_files_and_sorts() {
677 let dir = tempfile::tempdir().unwrap();
678 let p = dir.path();
679 for v in [3u32, 1, 2] {
680 let b = Baseline {
681 version: v,
682 captured_at: chrono::Utc::now().to_rfc3339(),
683 label: None,
684 facts: BTreeMap::new(),
685 };
686 std::fs::write(baseline_path(p, v), serde_json::to_string(&b).unwrap()).unwrap();
687 }
688 std::fs::write(p.join("notes.txt"), "ignore me").unwrap();
689 std::fs::write(p.join("baseline-bad.json"), "{not json").unwrap();
690
691 let versions: Vec<u32> = list_baselines(p).iter().map(|b| b.version).collect();
692 assert_eq!(versions, vec![1, 2, 3]);
693 }
694
695 #[test]
696 fn empty_lists_render_as_stable_none_value() {
697 let facts = capture_facts(&test_config(), &test_inventory());
700 assert_eq!(
701 facts.get("agents.delegates").map(String::as_str),
702 Some("(none)")
703 );
704 }
705
706 #[test]
707 fn short_time_compacts_rfc3339() {
708 assert_eq!(short_time("2026-06-09T10:11:12.345Z"), "2026-06-09 10:11");
709 assert_eq!(short_time("not-a-time"), "not-a-time");
711 }
712}