Skip to content

Commit 02904d0

Browse files
Merge pull request #1 from Swofty-Developments/feat/improvements
feat: cost display, CodeForge branding, tests_run events, image paste, palette content search, incremental re-index
2 parents 6b13a9b + 4d616c9 commit 02904d0

14 files changed

Lines changed: 463 additions & 19 deletions

File tree

crates/forge-daemon/src/hooks.rs

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,51 @@ fn parse_post_tool_use(raw: &Value) -> Option<(EventKind, Value, Vec<PathBuf>)>
6666
Some((EventKind::FileEdited, payload, edited))
6767
} else if tool_name == "Bash" {
6868
let command = input_str("command")?;
69+
let kind = if is_test_command(&command) {
70+
EventKind::TestsRun
71+
} else {
72+
EventKind::CommandRun
73+
};
6974
let payload = serde_json::json!({
7075
"command": command,
7176
"description": input_str("description"),
7277
});
73-
Some((EventKind::CommandRun, payload, vec![]))
78+
Some((kind, payload, vec![]))
7479
} else {
7580
None
7681
}
7782
}
7883

84+
/// Test-runner detection: standalone runners or `<tool> test` invocations,
85+
/// anywhere in a compound command line.
86+
fn is_test_command(command: &str) -> bool {
87+
const STANDALONE: [&str; 8] = [
88+
"pytest", "jest", "vitest", "mocha", "rspec", "phpunit", "tox", "ctest",
89+
];
90+
const TEST_SUBCOMMAND: [&str; 9] = [
91+
"cargo", "go", "npm", "pnpm", "yarn", "bun", "dotnet", "mix", "mvn",
92+
];
93+
let tokens: Vec<&str> = command
94+
.split(|c: char| c.is_whitespace() || matches!(c, ';' | '(' | ')'))
95+
.filter(|t| !t.is_empty() && *t != "&&" && *t != "||")
96+
.collect();
97+
tokens.iter().enumerate().any(|(i, tok)| {
98+
let base = tok.rsplit('/').next().unwrap_or(tok);
99+
if STANDALONE.contains(&base) {
100+
return true;
101+
}
102+
if base == "gradle" || base == "gradlew" {
103+
return tokens.get(i + 1).is_some_and(|next| *next == "test");
104+
}
105+
TEST_SUBCOMMAND.contains(&base)
106+
&& tokens.get(i + 1).is_some_and(|next| {
107+
*next == "test"
108+
|| *next == "nextest"
109+
|| (*next == "run" && tokens.get(i + 2).is_some_and(|n| n.starts_with("test")))
110+
})
111+
})
112+
}
113+
79114
#[cfg(test)]
80115
mod tests {
81116
use super::*;
@@ -113,15 +148,59 @@ mod tests {
113148
let raw = serde_json::json!({
114149
"hook_event_name": "PostToolUse",
115150
"tool_name": "Bash",
116-
"tool_input": { "command": "cargo test", "description": "Run tests" }
151+
"tool_input": { "command": "cargo build", "description": "Build" }
117152
});
118153
let parsed = parse_hook_event(&raw).unwrap();
119154
assert_eq!(parsed.kind, EventKind::CommandRun);
120-
assert_eq!(parsed.payload["command"], "cargo test");
121-
assert_eq!(parsed.payload["description"], "Run tests");
155+
assert_eq!(parsed.payload["command"], "cargo build");
156+
assert_eq!(parsed.payload["description"], "Build");
122157
assert!(parsed.edited_paths.is_empty());
123158
}
124159

160+
#[test]
161+
fn bash_test_commands_map_to_tests_run() {
162+
for cmd in [
163+
"cargo test",
164+
"cargo nextest run",
165+
"cd api && cargo test -p forge-core",
166+
"npm test",
167+
"npm run test:unit",
168+
"pnpm test --filter web",
169+
"go test ./...",
170+
"python -m pytest tests/",
171+
"./node_modules/.bin/vitest run",
172+
"./gradlew test",
173+
] {
174+
let raw = serde_json::json!({
175+
"hook_event_name": "PostToolUse",
176+
"tool_name": "Bash",
177+
"tool_input": { "command": cmd }
178+
});
179+
let parsed = parse_hook_event(&raw).unwrap();
180+
assert_eq!(parsed.kind, EventKind::TestsRun, "command: {cmd}");
181+
assert_eq!(parsed.payload["command"], cmd);
182+
}
183+
}
184+
185+
#[test]
186+
fn bash_non_test_commands_stay_command_run() {
187+
for cmd in [
188+
"cargo build",
189+
"npm run dev",
190+
"go build ./...",
191+
"ls tests/",
192+
"git commit -m 'test: add coverage'",
193+
] {
194+
let raw = serde_json::json!({
195+
"hook_event_name": "PostToolUse",
196+
"tool_name": "Bash",
197+
"tool_input": { "command": cmd }
198+
});
199+
let parsed = parse_hook_event(&raw).unwrap();
200+
assert_eq!(parsed.kind, EventKind::CommandRun, "command: {cmd}");
201+
}
202+
}
203+
125204
#[test]
126205
fn stop_and_session_start_map_to_session_events() {
127206
let stop = serde_json::json!({ "hook_event_name": "Stop", "session_id": "s" });

crates/forge-index/src/lib.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,52 @@ impl FeatureIndex {
171171
classify::classify(&self.repo_root, &self.features, paths)
172172
}
173173

174+
/// Slugs of features that reference any of `rel_paths` (repo-relative,
175+
/// `/`-separated — the index-meta manifest keys) as an entry point or file.
176+
/// Exact membership only; no directory-prefix widening.
177+
pub fn owners_of(&self, rel_paths: &[String]) -> Vec<String> {
178+
let set: std::collections::BTreeSet<&str> =
179+
rel_paths.iter().map(String::as_str).collect();
180+
self.features
181+
.iter()
182+
.filter(|f| {
183+
f.entry_points
184+
.iter()
185+
.chain(f.files.iter().map(|file| &file.path))
186+
.any(|p| set.contains(p.to_string_lossy().replace('\\', "/").as_str()))
187+
})
188+
.map(|f| f.slug.clone())
189+
.collect()
190+
}
191+
192+
/// Drop references to files that no longer exist on disk from the named
193+
/// features. An unpinned feature left with no paths at all is removed (its
194+
/// subject matter is gone); pinned features survive minus the dead paths.
195+
/// Returns the slugs of removed features.
196+
pub fn prune_missing_files(&mut self, slugs: &[String]) -> Vec<String> {
197+
let root = self.repo_root.clone();
198+
for feature in self.features.iter_mut().filter(|f| slugs.contains(&f.slug)) {
199+
let before = feature.entry_points.len() + feature.files.len();
200+
feature.entry_points.retain(|p| root.join(p).exists());
201+
feature.files.retain(|f| root.join(&f.path).exists());
202+
if feature.entry_points.len() + feature.files.len() != before {
203+
feature.updated_at = Utc::now();
204+
}
205+
}
206+
let mut removed = Vec::new();
207+
self.features.retain(|f| {
208+
let dead = !f.pinned
209+
&& slugs.contains(&f.slug)
210+
&& f.entry_points.is_empty()
211+
&& f.files.is_empty();
212+
if dead {
213+
removed.push(f.slug.clone());
214+
}
215+
!dead
216+
});
217+
removed
218+
}
219+
174220
/// The repo this index belongs to.
175221
pub fn repo_root(&self) -> &Path {
176222
&self.repo_root
@@ -335,6 +381,45 @@ mod tests {
335381
assert_eq!(index.get("unpinned").unwrap().group.as_deref(), Some("crates/forge-index"));
336382
}
337383

384+
#[test]
385+
fn owners_of_matches_exact_membership_only() {
386+
let tmp = TempDir::new("owners");
387+
let mut index = FeatureIndex::load(tmp.path()).expect("load");
388+
index.upsert(feature("auth", false)); // references src/auth.rs
389+
index.upsert(feature("billing", false)); // references src/billing.rs
390+
391+
assert_eq!(index.owners_of(&["src/auth.rs".to_string()]), vec!["auth"]);
392+
// A sibling under src/ is NOT an owner — no directory-prefix widening.
393+
assert!(index.owners_of(&["src/other.rs".to_string()]).is_empty());
394+
}
395+
396+
#[test]
397+
fn prune_missing_files_drops_dead_paths_and_empty_unpinned_features() {
398+
let tmp = TempDir::new("prune");
399+
tmp.write("src/kept.rs", "fn kept() {}");
400+
let mut index = FeatureIndex::load(tmp.path()).expect("load");
401+
402+
// "kept" has one real file; "gone" (unpinned) and "gone-pinned" (pinned)
403+
// reference only files that don't exist on disk.
404+
index.upsert(feature("kept", false));
405+
index.get("kept").unwrap(); // sanity
406+
index.upsert(feature("gone", false));
407+
index.upsert(feature("gone-pinned", true));
408+
// Point "kept" at the real file.
409+
let mut kept = index.get("kept").unwrap().clone();
410+
kept.files[0].path = PathBuf::from("src/kept.rs");
411+
index.upsert(kept);
412+
413+
let slugs: Vec<String> =
414+
["kept", "gone", "gone-pinned"].iter().map(|s| s.to_string()).collect();
415+
let removed = index.prune_missing_files(&slugs);
416+
417+
assert_eq!(removed, vec!["gone".to_string()]);
418+
assert!(index.get("kept").is_some(), "feature with a live file survives");
419+
let pinned = index.get("gone-pinned").expect("pinned survives even when empty");
420+
assert!(pinned.files.is_empty(), "dead paths pruned from pinned feature");
421+
}
422+
338423
#[test]
339424
fn merge_reindex_preserves_pinned_feature_through_public_api() {
340425
let tmp = TempDir::new("reindex");

0 commit comments

Comments
 (0)