1use std::path::{Path, PathBuf};
43use std::process::{Command, Stdio};
44use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
45
46use serde::{Deserialize, Serialize};
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
51pub struct AmbientContext {
52 pub branch: Option<String>,
55 pub git_status: Vec<StatusEntry>,
58 pub recent_files: Vec<PathBuf>,
62 pub collected_at_unix: i64,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct StatusEntry {
69 pub flag: String,
73 pub path: PathBuf,
74}
75
76#[derive(Debug, Clone, Copy)]
78pub struct CollectOptions {
79 pub recent_files_limit: usize,
81 pub git_status_limit: usize,
83 pub git_timeout: Duration,
85 pub walk_budget: Duration,
87}
88
89impl Default for CollectOptions {
90 fn default() -> Self {
91 Self {
92 recent_files_limit: 5,
93 git_status_limit: 8,
94 git_timeout: Duration::from_secs(2),
95 walk_budget: Duration::from_millis(150),
96 }
97 }
98}
99
100pub fn ambient_enabled() -> bool {
106 match std::env::var("KIMETSU_BRAIN_AMBIENT") {
107 Ok(value) => {
108 let v = value.trim().to_ascii_lowercase();
109 !matches!(v.as_str(), "off" | "0" | "false" | "no" | "none")
110 }
111 Err(_) => true,
112 }
113}
114
115pub fn collect(workspace: &Path) -> AmbientContext {
118 collect_with_opts(workspace, &CollectOptions::default())
119}
120
121pub fn collect_with_opts(workspace: &Path, opts: &CollectOptions) -> AmbientContext {
122 let collected_at_unix = SystemTime::now()
123 .duration_since(UNIX_EPOCH)
124 .map(|d| d.as_secs() as i64)
125 .unwrap_or(0);
126 AmbientContext {
127 branch: collect_branch(workspace, opts.git_timeout),
128 git_status: collect_git_status(workspace, opts.git_status_limit, opts.git_timeout),
129 recent_files: collect_recent_files(workspace, opts.recent_files_limit, opts.walk_budget),
130 collected_at_unix,
131 }
132}
133
134pub fn render_as_query_suffix(ctx: &AmbientContext) -> String {
140 let mut parts: Vec<String> = Vec::new();
141 if let Some(branch) = ctx.branch.as_deref().filter(|s| !s.is_empty()) {
142 parts.push(format!("branch={branch}"));
143 }
144 if !ctx.recent_files.is_empty() {
145 let listed = ctx
146 .recent_files
147 .iter()
148 .map(|p| p.to_string_lossy().replace('\\', "/"))
149 .collect::<Vec<_>>()
150 .join(", ");
151 parts.push(format!("recent: {listed}"));
152 }
153 if !ctx.git_status.is_empty() {
154 let dirty = ctx
155 .git_status
156 .iter()
157 .map(|entry| {
158 format!(
159 "{} {}",
160 entry.flag.trim(),
161 entry.path.to_string_lossy().replace('\\', "/")
162 )
163 })
164 .collect::<Vec<_>>()
165 .join(", ");
166 parts.push(format!("dirty: {dirty}"));
167 }
168 if parts.is_empty() {
169 String::new()
170 } else {
171 format!("\n[workspace: {}]", parts.join(" | "))
172 }
173}
174
175pub fn augment_query(query: &str, ctx: &AmbientContext) -> String {
178 let suffix = render_as_query_suffix(ctx);
179 if suffix.is_empty() {
180 query.to_string()
181 } else {
182 format!("{query}{suffix}")
183 }
184}
185
186fn collect_branch(workspace: &Path, timeout: Duration) -> Option<String> {
189 let out = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"], timeout)?;
190 let trimmed = out.trim();
191 if trimmed.is_empty() || trimmed == "HEAD" {
192 return None;
196 }
197 Some(trimmed.to_string())
198}
199
200fn collect_git_status(workspace: &Path, limit: usize, timeout: Duration) -> Vec<StatusEntry> {
201 let Some(out) = run_git(workspace, &["status", "--short", "--no-renames"], timeout) else {
202 return Vec::new();
203 };
204 parse_git_status(&out, limit)
205}
206
207fn parse_git_status(stdout: &str, limit: usize) -> Vec<StatusEntry> {
208 let mut entries = Vec::new();
209 for line in stdout.lines() {
210 if entries.len() >= limit {
211 break;
212 }
213 let bytes = line.as_bytes();
219 if bytes.len() < 4 || bytes[2] != b' ' {
220 continue;
221 }
222 let flag = line.get(..2).unwrap_or("").to_string();
223 let rest = line.get(3..).unwrap_or("").trim();
224 if rest.is_empty() {
225 continue;
226 }
227 entries.push(StatusEntry {
228 flag,
229 path: PathBuf::from(rest),
230 });
231 }
232 entries
233}
234
235fn collect_recent_files(workspace: &Path, limit: usize, budget: Duration) -> Vec<PathBuf> {
236 if limit == 0 {
237 return Vec::new();
238 }
239 let started = Instant::now();
240 let mut candidates: Vec<(SystemTime, PathBuf)> = Vec::new();
241 let walker = ignore::WalkBuilder::new(workspace)
242 .standard_filters(true)
243 .hidden(true)
244 .git_ignore(true)
245 .git_exclude(true)
246 .max_depth(Some(6))
247 .build();
248 for result in walker {
249 if started.elapsed() > budget {
250 break;
251 }
252 let Ok(entry) = result else { continue };
253 let Some(file_type) = entry.file_type() else {
254 continue;
255 };
256 if !file_type.is_file() {
257 continue;
258 }
259 let Ok(meta) = entry.metadata() else { continue };
260 let Ok(mtime) = meta.modified() else { continue };
261 let abs = entry.path().to_path_buf();
262 let rel = abs.strip_prefix(workspace).unwrap_or(&abs).to_path_buf();
263 if rel
267 .components()
268 .next()
269 .map(|c| c.as_os_str() == ".kimetsu")
270 .unwrap_or(false)
271 {
272 continue;
273 }
274 candidates.push((mtime, rel));
275 }
276 candidates.sort_by(|a, b| b.0.cmp(&a.0));
277 candidates
278 .into_iter()
279 .take(limit)
280 .map(|(_, path)| path)
281 .collect()
282}
283
284fn run_git(workspace: &Path, args: &[&str], timeout: Duration) -> Option<String> {
285 let mut child = Command::new("git")
286 .args(args)
287 .current_dir(workspace)
288 .stdout(Stdio::piped())
289 .stderr(Stdio::null())
290 .stdin(Stdio::null())
291 .spawn()
292 .ok()?;
293 let started = Instant::now();
294 loop {
295 match child.try_wait() {
296 Ok(Some(status)) if status.success() => {
297 use std::io::Read;
298 let mut buf = String::new();
299 if let Some(mut stdout) = child.stdout.take() {
300 stdout.read_to_string(&mut buf).ok()?;
301 }
302 return Some(buf);
303 }
304 Ok(Some(_)) => return None,
305 Ok(None) => {
306 if started.elapsed() >= timeout {
307 let _ = child.kill();
308 return None;
309 }
310 std::thread::sleep(Duration::from_millis(20));
311 }
312 Err(_) => return None,
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn render_omits_empty_fields() {
323 let ctx = AmbientContext::default();
324 assert_eq!(render_as_query_suffix(&ctx), "");
325 assert_eq!(augment_query("plan the patch", &ctx), "plan the patch");
326 }
327
328 #[test]
329 fn render_includes_branch_only_when_present() {
330 let ctx = AmbientContext {
331 branch: Some("feature/embedder".to_string()),
332 ..Default::default()
333 };
334 let suffix = render_as_query_suffix(&ctx);
335 assert!(suffix.contains("branch=feature/embedder"), "got {suffix}");
336 assert!(!suffix.contains("recent:"));
337 assert!(!suffix.contains("dirty:"));
338 }
339
340 #[test]
341 fn render_normalizes_windows_path_separators() {
342 let ctx = AmbientContext {
343 recent_files: vec![PathBuf::from("src\\embeddings.rs")],
344 git_status: vec![StatusEntry {
345 flag: " M".into(),
346 path: PathBuf::from("crates\\kimetsu-brain\\src\\ambient.rs"),
347 }],
348 ..Default::default()
349 };
350 let suffix = render_as_query_suffix(&ctx);
351 assert!(suffix.contains("src/embeddings.rs"), "got {suffix}");
352 assert!(
353 suffix.contains("crates/kimetsu-brain/src/ambient.rs"),
354 "got {suffix}"
355 );
356 assert!(!suffix.contains('\\'), "backslashes must be normalized");
357 }
358
359 #[test]
360 fn render_collapses_multiple_fields_with_separator() {
361 let ctx = AmbientContext {
362 branch: Some("main".into()),
363 git_status: vec![StatusEntry {
364 flag: "??".into(),
365 path: PathBuf::from("new.rs"),
366 }],
367 recent_files: vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")],
368 collected_at_unix: 0,
369 };
370 let suffix = render_as_query_suffix(&ctx);
371 assert!(suffix.starts_with("\n[workspace:"));
372 assert!(suffix.ends_with(']'));
373 assert!(suffix.contains("branch=main"));
375 assert!(suffix.contains("recent: a.rs, b.rs"));
376 assert!(suffix.contains("dirty: ?? new.rs"));
377 let pipe_count = suffix.matches(" | ").count();
378 assert_eq!(pipe_count, 2, "three blocks -> two separators");
379 }
380
381 #[test]
382 fn augment_query_appends_suffix_when_nonempty() {
383 let ctx = AmbientContext {
384 branch: Some("dev".into()),
385 ..Default::default()
386 };
387 let out = augment_query("fix it", &ctx);
388 assert!(out.starts_with("fix it"));
389 assert!(out.contains("branch=dev"));
390 }
391
392 #[test]
393 fn parse_git_status_handles_typical_lines() {
394 let sample = " M src/a.rs\n?? src/b.rs\nMM src/c.rs\nA src/d.rs\nbadly-formatted\n";
395 let parsed = parse_git_status(sample, 10);
396 assert_eq!(parsed.len(), 4);
397 assert_eq!(parsed[0].flag, " M");
398 assert_eq!(parsed[0].path, PathBuf::from("src/a.rs"));
399 assert_eq!(parsed[1].flag, "??");
400 assert_eq!(parsed[2].flag, "MM");
401 assert_eq!(parsed[3].flag, "A ");
402 }
403
404 #[test]
405 fn parse_git_status_respects_limit() {
406 let sample =
407 " 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";
408 let parsed = parse_git_status(sample, 3);
409 assert_eq!(parsed.len(), 3);
410 assert_eq!(parsed[0].path, PathBuf::from("one"));
411 assert_eq!(parsed[2].path, PathBuf::from("three"));
412 }
413
414 #[test]
415 fn ambient_enabled_respects_env() {
416 let _lock = crate::user_brain::test_env_lock()
419 .lock()
420 .unwrap_or_else(|p| p.into_inner());
421 let prev = std::env::var("KIMETSU_BRAIN_AMBIENT").ok();
422 unsafe {
425 std::env::remove_var("KIMETSU_BRAIN_AMBIENT");
426 }
427 assert!(ambient_enabled(), "default ON");
428 for off in ["off", "0", "false", "no", "NONE"] {
429 unsafe {
430 std::env::set_var("KIMETSU_BRAIN_AMBIENT", off);
431 }
432 assert!(!ambient_enabled(), "value {off:?} should disable");
433 }
434 for on in ["on", "1", "true", "yes", "anything-else"] {
435 unsafe {
436 std::env::set_var("KIMETSU_BRAIN_AMBIENT", on);
437 }
438 assert!(ambient_enabled(), "value {on:?} should enable");
439 }
440 unsafe {
441 match prev {
442 Some(v) => std::env::set_var("KIMETSU_BRAIN_AMBIENT", v),
443 None => std::env::remove_var("KIMETSU_BRAIN_AMBIENT"),
444 }
445 }
446 }
447
448 #[test]
449 fn collect_recent_files_skips_dotkimetsu() {
450 let root = std::env::temp_dir().join(format!("kimetsu-ambient-test-{}", ulid::Ulid::new()));
454 std::fs::create_dir_all(root.join("src")).unwrap();
455 std::fs::create_dir_all(root.join(".kimetsu/runs")).unwrap();
456 std::fs::write(root.join("src/a.rs"), "// a").unwrap();
457 std::fs::write(root.join("src/b.rs"), "// b").unwrap();
458 std::fs::write(root.join(".kimetsu/runs/01.trace"), "noise").unwrap();
459
460 let files = collect_recent_files(&root, 5, Duration::from_secs(2));
461 assert!(
462 files.iter().all(|p| !p.starts_with(".kimetsu")),
463 ".kimetsu/ entries should be filtered out: {:?}",
464 files
465 );
466 assert!(
467 files
468 .iter()
469 .any(|p| p.ends_with("a.rs") || p.ends_with("b.rs"))
470 );
471 let _ = std::fs::remove_dir_all(&root);
472 }
473}