Skip to main content

context_bar_core/
context_engine.rs

1use std::{
2    collections::BTreeMap,
3    fs,
4    path::{Path, PathBuf},
5    time::SystemTime,
6};
7
8use serde::Serialize;
9use time::{OffsetDateTime, format_description::well_known::Rfc3339};
10#[cfg(target_arch = "wasm32")]
11use zed_extension_api as zed;
12
13use crate::{
14    git_signal::{ChangeSummary, CommitSummary, GitSignals},
15    time_windows::{NOW_WINDOW, SESSION_WINDOW, WEEK_WINDOW},
16    usage_signal::UsageSnapshot,
17};
18#[cfg(target_arch = "wasm32")]
19use crate::{git_signal, usage_signal};
20
21#[derive(Clone, Debug, Serialize)]
22pub struct TouchedFile {
23    pub path: String,
24    pub modified_at: String,
25}
26
27#[derive(Clone, Debug, Serialize)]
28pub struct WindowSummary {
29    pub label: String,
30    pub top_files: Vec<String>,
31    pub focus_areas: Vec<String>,
32    pub themes: Vec<String>,
33    pub resume_hint: String,
34    pub commit_refs: Vec<CommitSummary>,
35    pub change_summary: Vec<ChangeSummary>,
36}
37
38#[derive(Clone, Debug, Serialize)]
39pub struct AssistantMemory {
40    pub latest_summary: String,
41    pub thread_refs: Vec<String>,
42}
43
44#[derive(Clone, Debug, Serialize)]
45pub struct ContextSnapshot {
46    pub worktree_root: String,
47    pub branch: String,
48    pub updated_at: String,
49    pub touched_files: Vec<TouchedFile>,
50    pub now: WindowSummary,
51    pub session: WindowSummary,
52    pub week: WindowSummary,
53    pub assistant_memory: AssistantMemory,
54    #[serde(default)]
55    pub usage: UsageSnapshot,
56}
57
58#[derive(Clone, Debug)]
59pub struct FileObservation {
60    pub relative_path: String,
61    pub modified_at: SystemTime,
62}
63
64pub struct ContextEngine;
65
66impl ContextEngine {
67    /// Convenience entry point bound to Zed's `Worktree`. The actual snapshot
68    /// assembly is delegated to [`assemble`] so non-Zed integration layers
69    /// (e.g. a future MCP server or ACP bridge) can drive the engine with
70    /// their own signal sources without redesign.
71    #[cfg(target_arch = "wasm32")]
72    pub fn generate(worktree: &zed::Worktree) -> Result<ContextSnapshot, String> {
73        let root = PathBuf::from(worktree.root_path());
74        let git = git_signal::collect(worktree)?;
75        let touched_files = collect_file_observations(&root)?;
76        let usage = usage_signal::collect(worktree);
77        let mut snapshot = assemble(root, git, touched_files)?;
78        snapshot.usage = usage;
79        Ok(snapshot)
80    }
81}
82
83/// Assemble a [`ContextSnapshot`] from already-collected signals. This is the
84/// stable seam any future integration layer (HUD, ACP, MCP) plugs into.
85pub fn assemble(
86    root: PathBuf,
87    git: GitSignals,
88    touched_files: Vec<FileObservation>,
89) -> Result<ContextSnapshot, String> {
90    {
91        let updated_at = timestamp(SystemTime::now())?;
92
93        let now_files = filter_files_for_window(&touched_files, NOW_WINDOW.duration);
94        let session_files = filter_files_for_window(&touched_files, SESSION_WINDOW.duration);
95        let week_files = filter_files_for_window(&touched_files, WEEK_WINDOW.duration);
96
97        let snapshot = ContextSnapshot {
98            worktree_root: root.display().to_string(),
99            branch: git.branch.clone(),
100            updated_at,
101            touched_files: touched_files
102                .iter()
103                .take(25)
104                .map(|file| {
105                    Ok(TouchedFile {
106                        path: file.relative_path.clone(),
107                        modified_at: timestamp(file.modified_at)?,
108                    })
109                })
110                .collect::<Result<Vec<_>, String>>()?,
111            now: summarize_window(NOW_WINDOW.label, &git, &now_files, NOW_WINDOW.duration, true),
112            session: summarize_window(SESSION_WINDOW.label, &git, &session_files, SESSION_WINDOW.duration, false),
113            week: summarize_window(WEEK_WINDOW.label, &git, &week_files, WEEK_WINDOW.duration, false),
114            assistant_memory: AssistantMemory {
115                latest_summary: build_assistant_memory(&git, &session_files),
116                thread_refs: Vec::new(),
117            },
118            usage: UsageSnapshot::default(),
119        };
120
121        Ok(snapshot)
122    }
123}
124
125pub fn collect_files(root: &Path) -> Result<Vec<FileObservation>, String> {
126    collect_file_observations(root)
127}
128
129pub fn render_window_markdown(snapshot: &ContextSnapshot, window: &str) -> String {
130    let summary = match window {
131        "now" => &snapshot.now,
132        "session" => &snapshot.session,
133        "week" => &snapshot.week,
134        _ => &snapshot.now,
135    };
136
137    let mut lines = vec![
138        format!("# {} brief", summary.label),
139        String::new(),
140        format!("- worktree: {}", snapshot.worktree_root),
141        format!("- branch: {}", snapshot.branch),
142        format!("- updated_at: {}", snapshot.updated_at),
143        String::new(),
144        "## Resume hint".to_string(),
145        summary.resume_hint.clone(),
146        String::new(),
147    ];
148
149    if !summary.top_files.is_empty() {
150        lines.push("## Top files".to_string());
151        lines.extend(summary.top_files.iter().map(|path| format!("- {path}")));
152        lines.push(String::new());
153    }
154
155    if !summary.focus_areas.is_empty() {
156        lines.push("## Focus areas".to_string());
157        lines.extend(summary.focus_areas.iter().map(|path| format!("- {path}")));
158        lines.push(String::new());
159    }
160
161    if !summary.themes.is_empty() {
162        lines.push("## Themes".to_string());
163        lines.extend(summary.themes.iter().map(|theme| format!("- {theme}")));
164        lines.push(String::new());
165    }
166
167    if !summary.commit_refs.is_empty() {
168        lines.push("## Recent commits".to_string());
169        lines.extend(
170            summary
171                .commit_refs
172                .iter()
173                .map(|commit| format!("- {} {}", commit.sha, commit.subject)),
174        );
175        lines.push(String::new());
176    }
177
178    if !summary.change_summary.is_empty() {
179        lines.push("## Local changes".to_string());
180        lines.extend(
181            summary
182                .change_summary
183                .iter()
184                .map(|change| format!("- {} {}", change.code, change.path)),
185        );
186    }
187
188    lines.join("\n").trim().to_string() + "\n"
189}
190
191fn summarize_window(
192    label: &str,
193    git: &GitSignals,
194    files: &[FileObservation],
195    window: std::time::Duration,
196    include_local_changes: bool,
197) -> WindowSummary {
198    let top_files = files
199        .iter()
200        .take(6)
201        .map(|file| file.relative_path.clone())
202        .collect::<Vec<_>>();
203
204    let windowed_commits = filter_commits_for_window(&git.recent_commits, window);
205    let focus_areas = top_directories(files, 4);
206    let themes = derive_themes(files, &windowed_commits, 4);
207    let change_summary = if include_local_changes {
208        combine_change_summary(git, 8)
209    } else {
210        Vec::new()
211    };
212
213    let resume_hint = match label {
214        "now" => {
215            if top_files.is_empty() {
216                format!("No files changed in the last {} minutes.", NOW_WINDOW.duration.as_secs() / 60)
217            } else {
218                format!(
219                    "Continue in {} on branch {}. Local changes are concentrated in {}.",
220                    top_files[0],
221                    git.branch,
222                    focus_areas
223                        .first()
224                        .cloned()
225                        .unwrap_or_else(|| "the worktree root".to_string())
226                )
227            }
228        }
229        "session" => format!(
230            "Session focus is {} with {} recent file touches.",
231            focus_areas
232                .first()
233                .cloned()
234                .unwrap_or_else(|| "mixed project areas".to_string()),
235            files.len()
236        ),
237        _ => format!(
238            "Weekly pattern points to {} and {} recent commits on {}.",
239            focus_areas
240                .first()
241                .cloned()
242                .unwrap_or_else(|| "mixed project areas".to_string()),
243            windowed_commits.len(),
244            git.branch
245        ),
246    };
247
248    WindowSummary {
249        label: label.to_string(),
250        top_files,
251        focus_areas,
252        themes,
253        resume_hint,
254        commit_refs: windowed_commits.into_iter().take(6).collect(),
255        change_summary,
256    }
257}
258
259fn filter_commits_for_window(
260    commits: &[CommitSummary],
261    window: std::time::Duration,
262) -> Vec<CommitSummary> {
263    let now = SystemTime::now();
264    commits
265        .iter()
266        .filter(|commit| match commit.committed_at_system {
267            Some(time) => now
268                .duration_since(time)
269                .map(|age| age <= window)
270                .unwrap_or(true),
271            // If timestamp is missing, fall back to inclusion only for the
272            // widest window so older parsing paths still produce output.
273            None => window >= WEEK_WINDOW.duration,
274        })
275        .cloned()
276        .collect()
277}
278
279fn build_assistant_memory(git: &GitSignals, session_files: &[FileObservation]) -> String {
280    let top_file = session_files
281        .first()
282        .map(|file| file.relative_path.as_str())
283        .unwrap_or("no recent files");
284    let commit = git
285        .recent_commits
286        .first()
287        .map(|commit| commit.subject.as_str())
288        .unwrap_or("no recent commits");
289
290    format!(
291        "Current branch is {}. Session focus is {}. Latest visible commit theme: {}.",
292        git.branch, top_file, commit
293    )
294}
295
296fn combine_change_summary(git: &GitSignals, limit: usize) -> Vec<ChangeSummary> {
297    git.staged_changes
298        .iter()
299        .chain(git.unstaged_changes.iter())
300        .take(limit)
301        .cloned()
302        .collect()
303}
304
305fn derive_themes(
306    files: &[FileObservation],
307    commits: &[CommitSummary],
308    limit: usize,
309) -> Vec<String> {
310    let mut themes = Vec::new();
311
312    for area in top_directories(files, limit) {
313        if area != "." {
314            themes.push(format!("focus on {area}"));
315        }
316    }
317
318    for commit in commits.iter().take(limit) {
319        if themes.len() >= limit {
320            break;
321        }
322        themes.push(commit.subject.clone());
323    }
324
325    themes
326}
327
328fn top_directories(files: &[FileObservation], limit: usize) -> Vec<String> {
329    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
330
331    for file in files {
332        let area = Path::new(&file.relative_path)
333            .parent()
334            .map(|path| {
335                let value = path.to_string_lossy().to_string();
336                if value.is_empty() { ".".to_string() } else { value }
337            })
338            .unwrap_or_else(|| ".".to_string());
339        if is_noise_area(&area) {
340            continue;
341        }
342        *counts.entry(area).or_default() += 1;
343    }
344
345    let mut entries = counts.into_iter().collect::<Vec<_>>();
346    entries.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
347
348    entries.into_iter().take(limit).map(|(path, _)| path).collect()
349}
350
351fn is_noise_area(area: &str) -> bool {
352    matches!(
353        area,
354        "." | ".git" | ".tmp" | ".context-bar" | "target" | "node_modules"
355    )
356}
357
358fn filter_files_for_window(
359    files: &[FileObservation],
360    duration: std::time::Duration,
361) -> Vec<FileObservation> {
362    let now = SystemTime::now();
363    files.iter()
364        .filter(|file| {
365            now.duration_since(file.modified_at)
366                .map(|age| age <= duration)
367                .unwrap_or(false)
368        })
369        .cloned()
370        .collect()
371}
372
373/// Maximum directory recursion depth for `collect_dir`. Prevents stack
374/// overflow / runaway walks on pathological worktrees (deep symlink chains,
375/// generated monorepos, etc.). 12 is generous for real projects.
376const MAX_COLLECT_DEPTH: usize = 12;
377
378fn collect_file_observations(root: &Path) -> Result<Vec<FileObservation>, String> {
379    let mut observations = Vec::new();
380    collect_dir(root, root, &mut observations, 0)?;
381    observations.sort_by(|left, right| right.modified_at.cmp(&left.modified_at));
382    Ok(observations)
383}
384
385fn collect_dir(
386    root: &Path,
387    current: &Path,
388    observations: &mut Vec<FileObservation>,
389    depth: usize,
390) -> Result<(), String> {
391    if depth >= MAX_COLLECT_DEPTH {
392        return Ok(());
393    }
394    for entry in fs::read_dir(current)
395        .map_err(|error| format!("failed to read directory {}: {error}", current.display()))?
396    {
397        let entry = entry.map_err(|error| format!("failed to inspect directory entry: {error}"))?;
398        let path = entry.path();
399        let file_type = entry
400            .file_type()
401            .map_err(|error| format!("failed to read file type for {}: {error}", path.display()))?;
402
403        if file_type.is_symlink() {
404            continue;
405        }
406
407        if file_type.is_dir() {
408            if should_skip_dir(&path) {
409                continue;
410            }
411            collect_dir(root, &path, observations, depth + 1)?;
412            continue;
413        }
414
415        if !file_type.is_file() {
416            continue;
417        }
418
419        if should_skip_file(&path) {
420            continue;
421        }
422
423        let metadata = entry
424            .metadata()
425            .map_err(|error| format!("failed to read metadata for {}: {error}", path.display()))?;
426        let modified_at = match metadata.modified() {
427            Ok(modified_at) => modified_at,
428            Err(_) => continue,
429        };
430
431        let relative_path = path
432            .strip_prefix(root)
433            .map_err(|error| format!("failed to compute relative path for {}: {error}", path.display()))?
434            .to_string_lossy()
435            .to_string();
436
437        observations.push(FileObservation {
438            relative_path,
439            modified_at,
440        });
441    }
442
443    Ok(())
444}
445
446fn should_skip_dir(path: &Path) -> bool {
447    matches!(
448        path.file_name().and_then(|value| value.to_str()),
449        Some(".git" | "target" | ".context-bar" | "node_modules" | ".tmp")
450    )
451}
452
453fn should_skip_file(path: &Path) -> bool {
454    matches!(
455        path.file_name().and_then(|value| value.to_str()),
456        Some("extension.wasm" | "Cargo.lock")
457    )
458}
459
460fn timestamp(time: SystemTime) -> Result<String, String> {
461    let datetime = OffsetDateTime::from(time);
462    datetime
463        .format(&Rfc3339)
464        .map_err(|error| format!("failed to format timestamp: {error}"))
465}
466
467#[cfg(test)]
468mod tests {
469    use super::{CommitSummary, FileObservation, derive_themes, top_directories};
470    use std::time::SystemTime;
471
472    #[test]
473    fn picks_top_directories_from_recent_files() {
474        let files = vec![
475            FileObservation {
476                relative_path: "src/lib.rs".to_string(),
477                modified_at: SystemTime::now(),
478            },
479            FileObservation {
480                relative_path: "src/context_engine.rs".to_string(),
481                modified_at: SystemTime::now(),
482            },
483            FileObservation {
484                relative_path: "docs/02-architecture.md".to_string(),
485                modified_at: SystemTime::now(),
486            },
487        ];
488
489        let areas = top_directories(&files, 2);
490        assert_eq!(areas, vec!["src".to_string(), "docs".to_string()]);
491    }
492
493    #[test]
494    fn derives_themes_from_files_and_commits() {
495        let files = vec![FileObservation {
496            relative_path: "src/lib.rs".to_string(),
497            modified_at: SystemTime::now(),
498        }];
499        let commits = vec![CommitSummary {
500            sha: "abc1234".to_string(),
501            subject: "Add context engine".to_string(),
502            committed_at: None,
503            committed_at_system: None,
504        }];
505
506        let themes = derive_themes(&files, &commits, 4);
507        assert!(themes.iter().any(|theme| theme.contains("src")));
508        assert!(themes.iter().any(|theme| theme.contains("Add context engine")));
509    }
510}