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 ambient_enabled_with(true)
108}
109
110pub fn ambient_enabled_with(config_ambient: bool) -> bool {
117 match std::env::var("KIMETSU_BRAIN_AMBIENT") {
119 Ok(value) => {
120 let v = value.trim().to_ascii_lowercase();
121 !matches!(v.as_str(), "off" | "0" | "false" | "no" | "none")
123 }
124 Err(_) => config_ambient,
126 }
127}
128
129pub fn collect(workspace: &Path) -> AmbientContext {
132 collect_with_opts(workspace, &CollectOptions::default())
133}
134
135pub fn collect_with_opts(workspace: &Path, opts: &CollectOptions) -> AmbientContext {
136 let collected_at_unix = SystemTime::now()
137 .duration_since(UNIX_EPOCH)
138 .map(|d| d.as_secs() as i64)
139 .unwrap_or(0);
140 AmbientContext {
141 branch: collect_branch(workspace, opts.git_timeout),
142 git_status: collect_git_status(workspace, opts.git_status_limit, opts.git_timeout),
143 recent_files: collect_recent_files(workspace, opts.recent_files_limit, opts.walk_budget),
144 collected_at_unix,
145 }
146}
147
148pub fn render_as_query_suffix(ctx: &AmbientContext) -> String {
154 let mut parts: Vec<String> = Vec::new();
155 if let Some(branch) = ctx.branch.as_deref().filter(|s| !s.is_empty()) {
156 parts.push(format!("branch={branch}"));
157 }
158 if !ctx.recent_files.is_empty() {
159 let listed = ctx
160 .recent_files
161 .iter()
162 .map(|p| p.to_string_lossy().replace('\\', "/"))
163 .collect::<Vec<_>>()
164 .join(", ");
165 parts.push(format!("recent: {listed}"));
166 }
167 if !ctx.git_status.is_empty() {
168 let dirty = ctx
169 .git_status
170 .iter()
171 .map(|entry| {
172 format!(
173 "{} {}",
174 entry.flag.trim(),
175 entry.path.to_string_lossy().replace('\\', "/")
176 )
177 })
178 .collect::<Vec<_>>()
179 .join(", ");
180 parts.push(format!("dirty: {dirty}"));
181 }
182 if parts.is_empty() {
183 String::new()
184 } else {
185 format!("\n[workspace: {}]", parts.join(" | "))
186 }
187}
188
189pub fn augment_query(query: &str, ctx: &AmbientContext) -> String {
192 let suffix = render_as_query_suffix(ctx);
193 if suffix.is_empty() {
194 query.to_string()
195 } else {
196 format!("{query}{suffix}")
197 }
198}
199
200fn collect_branch(workspace: &Path, timeout: Duration) -> Option<String> {
203 let out = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"], timeout)?;
204 let trimmed = out.trim();
205 if trimmed.is_empty() || trimmed == "HEAD" {
206 return None;
210 }
211 Some(trimmed.to_string())
212}
213
214fn collect_git_status(workspace: &Path, limit: usize, timeout: Duration) -> Vec<StatusEntry> {
215 let Some(out) = run_git(workspace, &["status", "--short", "--no-renames"], timeout) else {
216 return Vec::new();
217 };
218 parse_git_status(&out, limit)
219}
220
221fn parse_git_status(stdout: &str, limit: usize) -> Vec<StatusEntry> {
222 let mut entries = Vec::new();
223 for line in stdout.lines() {
224 if entries.len() >= limit {
225 break;
226 }
227 let bytes = line.as_bytes();
233 if bytes.len() < 4 || bytes[2] != b' ' {
234 continue;
235 }
236 let flag = line.get(..2).unwrap_or("").to_string();
237 let rest = line.get(3..).unwrap_or("").trim();
238 if rest.is_empty() {
239 continue;
240 }
241 entries.push(StatusEntry {
242 flag,
243 path: PathBuf::from(rest),
244 });
245 }
246 entries
247}
248
249fn collect_recent_files(workspace: &Path, limit: usize, budget: Duration) -> Vec<PathBuf> {
250 if limit == 0 {
251 return Vec::new();
252 }
253 let started = Instant::now();
254 let mut candidates: Vec<(SystemTime, PathBuf)> = Vec::new();
255 let walker = ignore::WalkBuilder::new(workspace)
256 .standard_filters(true)
257 .hidden(true)
258 .git_ignore(true)
259 .git_exclude(true)
260 .max_depth(Some(6))
261 .build();
262 for result in walker {
263 if started.elapsed() > budget {
264 break;
265 }
266 let Ok(entry) = result else { continue };
267 let Some(file_type) = entry.file_type() else {
268 continue;
269 };
270 if !file_type.is_file() {
271 continue;
272 }
273 let Ok(meta) = entry.metadata() else { continue };
274 let Ok(mtime) = meta.modified() else { continue };
275 let abs = entry.path().to_path_buf();
276 let rel = abs.strip_prefix(workspace).unwrap_or(&abs).to_path_buf();
277 if rel
281 .components()
282 .next()
283 .map(|c| c.as_os_str() == ".kimetsu")
284 .unwrap_or(false)
285 {
286 continue;
287 }
288 candidates.push((mtime, rel));
289 }
290 candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
291 candidates
292 .into_iter()
293 .take(limit)
294 .map(|(_, path)| path)
295 .collect()
296}
297
298fn run_git(workspace: &Path, args: &[&str], timeout: Duration) -> Option<String> {
299 let mut child = Command::new("git")
300 .args(args)
301 .current_dir(workspace)
302 .stdout(Stdio::piped())
303 .stderr(Stdio::null())
304 .stdin(Stdio::null())
305 .spawn()
306 .ok()?;
307 let started = Instant::now();
308 loop {
309 match child.try_wait() {
310 Ok(Some(status)) if status.success() => {
311 use std::io::Read;
312 let mut buf = String::new();
313 if let Some(mut stdout) = child.stdout.take() {
314 stdout.read_to_string(&mut buf).ok()?;
315 }
316 return Some(buf);
317 }
318 Ok(Some(_)) => return None,
319 Ok(None) => {
320 if started.elapsed() >= timeout {
321 let _ = child.kill();
322 return None;
323 }
324 std::thread::sleep(Duration::from_millis(20));
325 }
326 Err(_) => return None,
327 }
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn render_omits_empty_fields() {
337 let ctx = AmbientContext::default();
338 assert_eq!(render_as_query_suffix(&ctx), "");
339 assert_eq!(augment_query("plan the patch", &ctx), "plan the patch");
340 }
341
342 #[test]
343 fn render_includes_branch_only_when_present() {
344 let ctx = AmbientContext {
345 branch: Some("feature/embedder".to_string()),
346 ..Default::default()
347 };
348 let suffix = render_as_query_suffix(&ctx);
349 assert!(suffix.contains("branch=feature/embedder"), "got {suffix}");
350 assert!(!suffix.contains("recent:"));
351 assert!(!suffix.contains("dirty:"));
352 }
353
354 #[test]
355 fn render_normalizes_windows_path_separators() {
356 let ctx = AmbientContext {
357 recent_files: vec![PathBuf::from("src\\embeddings.rs")],
358 git_status: vec![StatusEntry {
359 flag: " M".into(),
360 path: PathBuf::from("crates\\kimetsu-brain\\src\\ambient.rs"),
361 }],
362 ..Default::default()
363 };
364 let suffix = render_as_query_suffix(&ctx);
365 assert!(suffix.contains("src/embeddings.rs"), "got {suffix}");
366 assert!(
367 suffix.contains("crates/kimetsu-brain/src/ambient.rs"),
368 "got {suffix}"
369 );
370 assert!(!suffix.contains('\\'), "backslashes must be normalized");
371 }
372
373 #[test]
374 fn render_collapses_multiple_fields_with_separator() {
375 let ctx = AmbientContext {
376 branch: Some("main".into()),
377 git_status: vec![StatusEntry {
378 flag: "??".into(),
379 path: PathBuf::from("new.rs"),
380 }],
381 recent_files: vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")],
382 collected_at_unix: 0,
383 };
384 let suffix = render_as_query_suffix(&ctx);
385 assert!(suffix.starts_with("\n[workspace:"));
386 assert!(suffix.ends_with(']'));
387 assert!(suffix.contains("branch=main"));
389 assert!(suffix.contains("recent: a.rs, b.rs"));
390 assert!(suffix.contains("dirty: ?? new.rs"));
391 let pipe_count = suffix.matches(" | ").count();
392 assert_eq!(pipe_count, 2, "three blocks -> two separators");
393 }
394
395 #[test]
396 fn augment_query_appends_suffix_when_nonempty() {
397 let ctx = AmbientContext {
398 branch: Some("dev".into()),
399 ..Default::default()
400 };
401 let out = augment_query("fix it", &ctx);
402 assert!(out.starts_with("fix it"));
403 assert!(out.contains("branch=dev"));
404 }
405
406 #[test]
407 fn parse_git_status_handles_typical_lines() {
408 let sample = " M src/a.rs\n?? src/b.rs\nMM src/c.rs\nA src/d.rs\nbadly-formatted\n";
409 let parsed = parse_git_status(sample, 10);
410 assert_eq!(parsed.len(), 4);
411 assert_eq!(parsed[0].flag, " M");
412 assert_eq!(parsed[0].path, PathBuf::from("src/a.rs"));
413 assert_eq!(parsed[1].flag, "??");
414 assert_eq!(parsed[2].flag, "MM");
415 assert_eq!(parsed[3].flag, "A ");
416 }
417
418 #[test]
419 fn parse_git_status_respects_limit() {
420 let sample =
421 " 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";
422 let parsed = parse_git_status(sample, 3);
423 assert_eq!(parsed.len(), 3);
424 assert_eq!(parsed[0].path, PathBuf::from("one"));
425 assert_eq!(parsed[2].path, PathBuf::from("three"));
426 }
427
428 #[test]
429 fn ambient_enabled_respects_env() {
430 let _lock = crate::user_brain::test_env_lock()
433 .lock()
434 .unwrap_or_else(|p| p.into_inner());
435 let prev = std::env::var("KIMETSU_BRAIN_AMBIENT").ok();
436 unsafe {
439 std::env::remove_var("KIMETSU_BRAIN_AMBIENT");
440 }
441 assert!(ambient_enabled(), "default ON");
442 for off in ["off", "0", "false", "no", "NONE"] {
443 unsafe {
444 std::env::set_var("KIMETSU_BRAIN_AMBIENT", off);
445 }
446 assert!(!ambient_enabled(), "value {off:?} should disable");
447 }
448 for on in ["on", "1", "true", "yes", "anything-else"] {
449 unsafe {
450 std::env::set_var("KIMETSU_BRAIN_AMBIENT", on);
451 }
452 assert!(ambient_enabled(), "value {on:?} should enable");
453 }
454 unsafe {
455 match prev {
456 Some(v) => std::env::set_var("KIMETSU_BRAIN_AMBIENT", v),
457 None => std::env::remove_var("KIMETSU_BRAIN_AMBIENT"),
458 }
459 }
460 }
461
462 #[test]
463 fn collect_recent_files_skips_dotkimetsu() {
464 let root = std::env::temp_dir().join(format!("kimetsu-ambient-test-{}", ulid::Ulid::new()));
468 std::fs::create_dir_all(root.join("src")).unwrap();
469 std::fs::create_dir_all(root.join(".kimetsu/runs")).unwrap();
470 std::fs::write(root.join("src/a.rs"), "// a").unwrap();
471 std::fs::write(root.join("src/b.rs"), "// b").unwrap();
472 std::fs::write(root.join(".kimetsu/runs/01.trace"), "noise").unwrap();
473
474 let files = collect_recent_files(&root, 5, Duration::from_secs(2));
475 assert!(
476 files.iter().all(|p| !p.starts_with(".kimetsu")),
477 ".kimetsu/ entries should be filtered out: {:?}",
478 files
479 );
480 assert!(
481 files
482 .iter()
483 .any(|p| p.ends_with("a.rs") || p.ends_with("b.rs"))
484 );
485 let _ = std::fs::remove_dir_all(&root);
486 }
487
488 #[test]
492 fn w3_ambient_enabled_with_config_false_when_env_unset() {
493 let _lock = crate::user_brain::test_env_lock()
494 .lock()
495 .unwrap_or_else(|p| p.into_inner());
496 let prev = std::env::var("KIMETSU_BRAIN_AMBIENT").ok();
497 unsafe {
498 std::env::remove_var("KIMETSU_BRAIN_AMBIENT");
499 }
500 assert!(
502 !ambient_enabled_with(false),
503 "config=false + env unset must be disabled"
504 );
505 assert!(
507 ambient_enabled_with(true),
508 "config=true + env unset must be enabled"
509 );
510 unsafe {
511 match prev {
512 Some(v) => std::env::set_var("KIMETSU_BRAIN_AMBIENT", v),
513 None => std::env::remove_var("KIMETSU_BRAIN_AMBIENT"),
514 }
515 }
516 }
517
518 #[test]
520 fn w3_ambient_env_disable_overrides_config_true() {
521 let _lock = crate::user_brain::test_env_lock()
522 .lock()
523 .unwrap_or_else(|p| p.into_inner());
524 let prev = std::env::var("KIMETSU_BRAIN_AMBIENT").ok();
525 unsafe {
526 std::env::set_var("KIMETSU_BRAIN_AMBIENT", "0");
527 }
528 assert!(
530 !ambient_enabled_with(true),
531 "KIMETSU_BRAIN_AMBIENT=0 must override config=true"
532 );
533 unsafe {
534 match prev {
535 Some(v) => std::env::set_var("KIMETSU_BRAIN_AMBIENT", v),
536 None => std::env::remove_var("KIMETSU_BRAIN_AMBIENT"),
537 }
538 }
539 }
540
541 #[test]
543 fn w3_ambient_env_enable_overrides_config_false() {
544 let _lock = crate::user_brain::test_env_lock()
545 .lock()
546 .unwrap_or_else(|p| p.into_inner());
547 let prev = std::env::var("KIMETSU_BRAIN_AMBIENT").ok();
548 unsafe {
549 std::env::set_var("KIMETSU_BRAIN_AMBIENT", "1");
550 }
551 assert!(
553 ambient_enabled_with(false),
554 "KIMETSU_BRAIN_AMBIENT=1 must override config=false"
555 );
556 unsafe {
557 match prev {
558 Some(v) => std::env::set_var("KIMETSU_BRAIN_AMBIENT", v),
559 None => std::env::remove_var("KIMETSU_BRAIN_AMBIENT"),
560 }
561 }
562 }
563}