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!(
352 suffix.contains("src/embeddings.rs"),
353 "got {suffix}"
354 );
355 assert!(
356 suffix.contains("crates/kimetsu-brain/src/ambient.rs"),
357 "got {suffix}"
358 );
359 assert!(!suffix.contains('\\'), "backslashes must be normalized");
360 }
361
362 #[test]
363 fn render_collapses_multiple_fields_with_separator() {
364 let ctx = AmbientContext {
365 branch: Some("main".into()),
366 git_status: vec![StatusEntry {
367 flag: "??".into(),
368 path: PathBuf::from("new.rs"),
369 }],
370 recent_files: vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")],
371 collected_at_unix: 0,
372 };
373 let suffix = render_as_query_suffix(&ctx);
374 assert!(suffix.starts_with("\n[workspace:"));
375 assert!(suffix.ends_with(']'));
376 assert!(suffix.contains("branch=main"));
378 assert!(suffix.contains("recent: a.rs, b.rs"));
379 assert!(suffix.contains("dirty: ?? new.rs"));
380 let pipe_count = suffix.matches(" | ").count();
381 assert_eq!(pipe_count, 2, "three blocks -> two separators");
382 }
383
384 #[test]
385 fn augment_query_appends_suffix_when_nonempty() {
386 let ctx = AmbientContext {
387 branch: Some("dev".into()),
388 ..Default::default()
389 };
390 let out = augment_query("fix it", &ctx);
391 assert!(out.starts_with("fix it"));
392 assert!(out.contains("branch=dev"));
393 }
394
395 #[test]
396 fn parse_git_status_handles_typical_lines() {
397 let sample = " M src/a.rs\n?? src/b.rs\nMM src/c.rs\nA src/d.rs\nbadly-formatted\n";
398 let parsed = parse_git_status(sample, 10);
399 assert_eq!(parsed.len(), 4);
400 assert_eq!(parsed[0].flag, " M");
401 assert_eq!(parsed[0].path, PathBuf::from("src/a.rs"));
402 assert_eq!(parsed[1].flag, "??");
403 assert_eq!(parsed[2].flag, "MM");
404 assert_eq!(parsed[3].flag, "A ");
405 }
406
407 #[test]
408 fn parse_git_status_respects_limit() {
409 let sample =
410 " 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";
411 let parsed = parse_git_status(sample, 3);
412 assert_eq!(parsed.len(), 3);
413 assert_eq!(parsed[0].path, PathBuf::from("one"));
414 assert_eq!(parsed[2].path, PathBuf::from("three"));
415 }
416
417 #[test]
418 fn ambient_enabled_respects_env() {
419 let _lock = crate::user_brain::test_env_lock()
422 .lock()
423 .unwrap_or_else(|p| p.into_inner());
424 let prev = std::env::var("KIMETSU_BRAIN_AMBIENT").ok();
425 unsafe {
428 std::env::remove_var("KIMETSU_BRAIN_AMBIENT");
429 }
430 assert!(ambient_enabled(), "default ON");
431 for off in ["off", "0", "false", "no", "NONE"] {
432 unsafe {
433 std::env::set_var("KIMETSU_BRAIN_AMBIENT", off);
434 }
435 assert!(!ambient_enabled(), "value {off:?} should disable");
436 }
437 for on in ["on", "1", "true", "yes", "anything-else"] {
438 unsafe {
439 std::env::set_var("KIMETSU_BRAIN_AMBIENT", on);
440 }
441 assert!(ambient_enabled(), "value {on:?} should enable");
442 }
443 unsafe {
444 match prev {
445 Some(v) => std::env::set_var("KIMETSU_BRAIN_AMBIENT", v),
446 None => std::env::remove_var("KIMETSU_BRAIN_AMBIENT"),
447 }
448 }
449 }
450
451 #[test]
452 fn collect_recent_files_skips_dotkimetsu() {
453 let root = std::env::temp_dir().join(format!(
457 "kimetsu-ambient-test-{}",
458 ulid::Ulid::new()
459 ));
460 std::fs::create_dir_all(root.join("src")).unwrap();
461 std::fs::create_dir_all(root.join(".kimetsu/runs")).unwrap();
462 std::fs::write(root.join("src/a.rs"), "// a").unwrap();
463 std::fs::write(root.join("src/b.rs"), "// b").unwrap();
464 std::fs::write(root.join(".kimetsu/runs/01.trace"), "noise").unwrap();
465
466 let files = collect_recent_files(&root, 5, Duration::from_secs(2));
467 assert!(
468 files.iter().all(|p| !p.starts_with(".kimetsu")),
469 ".kimetsu/ entries should be filtered out: {:?}",
470 files
471 );
472 assert!(files.iter().any(|p| p.ends_with("a.rs") || p.ends_with("b.rs")));
473 let _ = std::fs::remove_dir_all(&root);
474 }
475}