use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AmbientContext {
pub branch: Option<String>,
pub git_status: Vec<StatusEntry>,
pub recent_files: Vec<PathBuf>,
pub collected_at_unix: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusEntry {
pub flag: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, Copy)]
pub struct CollectOptions {
pub recent_files_limit: usize,
pub git_status_limit: usize,
pub git_timeout: Duration,
pub walk_budget: Duration,
}
impl Default for CollectOptions {
fn default() -> Self {
Self {
recent_files_limit: 5,
git_status_limit: 8,
git_timeout: Duration::from_secs(2),
walk_budget: Duration::from_millis(150),
}
}
}
pub fn ambient_enabled() -> bool {
match std::env::var("KIMETSU_BRAIN_AMBIENT") {
Ok(value) => {
let v = value.trim().to_ascii_lowercase();
!matches!(v.as_str(), "off" | "0" | "false" | "no" | "none")
}
Err(_) => true,
}
}
pub fn collect(workspace: &Path) -> AmbientContext {
collect_with_opts(workspace, &CollectOptions::default())
}
pub fn collect_with_opts(workspace: &Path, opts: &CollectOptions) -> AmbientContext {
let collected_at_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
AmbientContext {
branch: collect_branch(workspace, opts.git_timeout),
git_status: collect_git_status(workspace, opts.git_status_limit, opts.git_timeout),
recent_files: collect_recent_files(workspace, opts.recent_files_limit, opts.walk_budget),
collected_at_unix,
}
}
pub fn render_as_query_suffix(ctx: &AmbientContext) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(branch) = ctx.branch.as_deref().filter(|s| !s.is_empty()) {
parts.push(format!("branch={branch}"));
}
if !ctx.recent_files.is_empty() {
let listed = ctx
.recent_files
.iter()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.collect::<Vec<_>>()
.join(", ");
parts.push(format!("recent: {listed}"));
}
if !ctx.git_status.is_empty() {
let dirty = ctx
.git_status
.iter()
.map(|entry| {
format!(
"{} {}",
entry.flag.trim(),
entry.path.to_string_lossy().replace('\\', "/")
)
})
.collect::<Vec<_>>()
.join(", ");
parts.push(format!("dirty: {dirty}"));
}
if parts.is_empty() {
String::new()
} else {
format!("\n[workspace: {}]", parts.join(" | "))
}
}
pub fn augment_query(query: &str, ctx: &AmbientContext) -> String {
let suffix = render_as_query_suffix(ctx);
if suffix.is_empty() {
query.to_string()
} else {
format!("{query}{suffix}")
}
}
fn collect_branch(workspace: &Path, timeout: Duration) -> Option<String> {
let out = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"], timeout)?;
let trimmed = out.trim();
if trimmed.is_empty() || trimmed == "HEAD" {
return None;
}
Some(trimmed.to_string())
}
fn collect_git_status(workspace: &Path, limit: usize, timeout: Duration) -> Vec<StatusEntry> {
let Some(out) = run_git(workspace, &["status", "--short", "--no-renames"], timeout) else {
return Vec::new();
};
parse_git_status(&out, limit)
}
fn parse_git_status(stdout: &str, limit: usize) -> Vec<StatusEntry> {
let mut entries = Vec::new();
for line in stdout.lines() {
if entries.len() >= limit {
break;
}
let bytes = line.as_bytes();
if bytes.len() < 4 || bytes[2] != b' ' {
continue;
}
let flag = line.get(..2).unwrap_or("").to_string();
let rest = line.get(3..).unwrap_or("").trim();
if rest.is_empty() {
continue;
}
entries.push(StatusEntry {
flag,
path: PathBuf::from(rest),
});
}
entries
}
fn collect_recent_files(workspace: &Path, limit: usize, budget: Duration) -> Vec<PathBuf> {
if limit == 0 {
return Vec::new();
}
let started = Instant::now();
let mut candidates: Vec<(SystemTime, PathBuf)> = Vec::new();
let walker = ignore::WalkBuilder::new(workspace)
.standard_filters(true)
.hidden(true)
.git_ignore(true)
.git_exclude(true)
.max_depth(Some(6))
.build();
for result in walker {
if started.elapsed() > budget {
break;
}
let Ok(entry) = result else { continue };
let Some(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_file() {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
let Ok(mtime) = meta.modified() else { continue };
let abs = entry.path().to_path_buf();
let rel = abs.strip_prefix(workspace).unwrap_or(&abs).to_path_buf();
if rel
.components()
.next()
.map(|c| c.as_os_str() == ".kimetsu")
.unwrap_or(false)
{
continue;
}
candidates.push((mtime, rel));
}
candidates.sort_by(|a, b| b.0.cmp(&a.0));
candidates
.into_iter()
.take(limit)
.map(|(_, path)| path)
.collect()
}
fn run_git(workspace: &Path, args: &[&str], timeout: Duration) -> Option<String> {
let mut child = Command::new("git")
.args(args)
.current_dir(workspace)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn()
.ok()?;
let started = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => {
use std::io::Read;
let mut buf = String::new();
if let Some(mut stdout) = child.stdout.take() {
stdout.read_to_string(&mut buf).ok()?;
}
return Some(buf);
}
Ok(Some(_)) => return None,
Ok(None) => {
if started.elapsed() >= timeout {
let _ = child.kill();
return None;
}
std::thread::sleep(Duration::from_millis(20));
}
Err(_) => return None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_omits_empty_fields() {
let ctx = AmbientContext::default();
assert_eq!(render_as_query_suffix(&ctx), "");
assert_eq!(augment_query("plan the patch", &ctx), "plan the patch");
}
#[test]
fn render_includes_branch_only_when_present() {
let ctx = AmbientContext {
branch: Some("feature/embedder".to_string()),
..Default::default()
};
let suffix = render_as_query_suffix(&ctx);
assert!(suffix.contains("branch=feature/embedder"), "got {suffix}");
assert!(!suffix.contains("recent:"));
assert!(!suffix.contains("dirty:"));
}
#[test]
fn render_normalizes_windows_path_separators() {
let ctx = AmbientContext {
recent_files: vec![PathBuf::from("src\\embeddings.rs")],
git_status: vec![StatusEntry {
flag: " M".into(),
path: PathBuf::from("crates\\kimetsu-brain\\src\\ambient.rs"),
}],
..Default::default()
};
let suffix = render_as_query_suffix(&ctx);
assert!(
suffix.contains("src/embeddings.rs"),
"got {suffix}"
);
assert!(
suffix.contains("crates/kimetsu-brain/src/ambient.rs"),
"got {suffix}"
);
assert!(!suffix.contains('\\'), "backslashes must be normalized");
}
#[test]
fn render_collapses_multiple_fields_with_separator() {
let ctx = AmbientContext {
branch: Some("main".into()),
git_status: vec![StatusEntry {
flag: "??".into(),
path: PathBuf::from("new.rs"),
}],
recent_files: vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")],
collected_at_unix: 0,
};
let suffix = render_as_query_suffix(&ctx);
assert!(suffix.starts_with("\n[workspace:"));
assert!(suffix.ends_with(']'));
assert!(suffix.contains("branch=main"));
assert!(suffix.contains("recent: a.rs, b.rs"));
assert!(suffix.contains("dirty: ?? new.rs"));
let pipe_count = suffix.matches(" | ").count();
assert_eq!(pipe_count, 2, "three blocks -> two separators");
}
#[test]
fn augment_query_appends_suffix_when_nonempty() {
let ctx = AmbientContext {
branch: Some("dev".into()),
..Default::default()
};
let out = augment_query("fix it", &ctx);
assert!(out.starts_with("fix it"));
assert!(out.contains("branch=dev"));
}
#[test]
fn parse_git_status_handles_typical_lines() {
let sample = " M src/a.rs\n?? src/b.rs\nMM src/c.rs\nA src/d.rs\nbadly-formatted\n";
let parsed = parse_git_status(sample, 10);
assert_eq!(parsed.len(), 4);
assert_eq!(parsed[0].flag, " M");
assert_eq!(parsed[0].path, PathBuf::from("src/a.rs"));
assert_eq!(parsed[1].flag, "??");
assert_eq!(parsed[2].flag, "MM");
assert_eq!(parsed[3].flag, "A ");
}
#[test]
fn parse_git_status_respects_limit() {
let sample =
" M one\n M two\n M three\n M four\n M five\n M six\n M seven\n M eight\n M nine\n";
let parsed = parse_git_status(sample, 3);
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[0].path, PathBuf::from("one"));
assert_eq!(parsed[2].path, PathBuf::from("three"));
}
#[test]
fn ambient_enabled_respects_env() {
let _lock = crate::user_brain::test_env_lock()
.lock()
.unwrap_or_else(|p| p.into_inner());
let prev = std::env::var("KIMETSU_BRAIN_AMBIENT").ok();
unsafe {
std::env::remove_var("KIMETSU_BRAIN_AMBIENT");
}
assert!(ambient_enabled(), "default ON");
for off in ["off", "0", "false", "no", "NONE"] {
unsafe {
std::env::set_var("KIMETSU_BRAIN_AMBIENT", off);
}
assert!(!ambient_enabled(), "value {off:?} should disable");
}
for on in ["on", "1", "true", "yes", "anything-else"] {
unsafe {
std::env::set_var("KIMETSU_BRAIN_AMBIENT", on);
}
assert!(ambient_enabled(), "value {on:?} should enable");
}
unsafe {
match prev {
Some(v) => std::env::set_var("KIMETSU_BRAIN_AMBIENT", v),
None => std::env::remove_var("KIMETSU_BRAIN_AMBIENT"),
}
}
}
#[test]
fn collect_recent_files_skips_dotkimetsu() {
let root = std::env::temp_dir().join(format!(
"kimetsu-ambient-test-{}",
ulid::Ulid::new()
));
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join(".kimetsu/runs")).unwrap();
std::fs::write(root.join("src/a.rs"), "// a").unwrap();
std::fs::write(root.join("src/b.rs"), "// b").unwrap();
std::fs::write(root.join(".kimetsu/runs/01.trace"), "noise").unwrap();
let files = collect_recent_files(&root, 5, Duration::from_secs(2));
assert!(
files.iter().all(|p| !p.starts_with(".kimetsu")),
".kimetsu/ entries should be filtered out: {:?}",
files
);
assert!(files.iter().any(|p| p.ends_with("a.rs") || p.ends_with("b.rs")));
let _ = std::fs::remove_dir_all(&root);
}
}