use crate::mcp::CallOutcome;
use core_api::repograph::{
self, ContextOptions, ImpactOptions, MapOptions, RememberInput, DEFAULT_EXCLUDES,
MAX_OUTPUT_BYTES, NOTE_KINDS, UNTRUSTED_FRAMING,
};
use core_api::{GraphError, SharedDb};
use serde_json::{json, Value as Js};
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
pub(crate) const SYNC_KEY: &str = "__mushroomdb_git_sync__";
const SYNC_REPO_PROP: &str = "repo";
const PROJECT_DIR_VAR: &str = "CLAUDE_PROJECT_DIR";
pub(crate) const TASK_TOOLS: [&str; 9] = [
"explore", "map", "context", "impact", "owners", "why", "recall", "remember", "sync",
];
pub(crate) fn dispatch(
db: &SharedDb,
db_dir: Option<&Path>,
name: &str,
args: &Js,
) -> Option<CallOutcome> {
if !TASK_TOOLS.contains(&name) {
return None;
}
let json_out = match bool_arg(args, "json") {
Ok(b) => b,
Err(e) => return Some(CallOutcome::ToolErr(e)),
};
Some(match name {
"explore" => tool_explore(db, args, json_out),
"map" => tool_map(db, json_out),
"context" => tool_context(db, args, json_out),
"impact" => tool_impact(
db,
args,
std::env::var_os(PROJECT_DIR_VAR).as_deref(),
json_out,
),
"owners" => tool_owners(db, args, json_out),
"why" => tool_why(db, args, json_out),
"recall" => tool_recall(db, db_dir, args, json_out),
"remember" => tool_remember(db, args, json_out),
"sync" => tool_sync(db_dir, json_out),
_ => unreachable!("TASK_TOOLS and this match list the same nine names"),
})
}
fn ok<T: serde::Serialize>(
json_out: bool,
report: &T,
render: impl FnOnce(&T) -> String,
) -> CallOutcome {
if json_out {
return match serde_json::to_value(report) {
Ok(mut value) => {
sanitize_json(&mut value);
CallOutcome::TaskOk {
text: value.to_string(),
}
}
Err(e) => CallOutcome::ToolErr(format!("serialise report: {e}")),
};
}
let text = render(report);
let text = if text.starts_with(UNTRUSTED_FRAMING) {
text
} else {
format!("{UNTRUSTED_FRAMING}{text}")
};
CallOutcome::TaskOk { text }
}
fn sanitize_json(value: &mut Js) {
match value {
Js::String(s) => {
if s.chars().any(is_forbidden_control) {
*s = s
.chars()
.map(|c| if is_forbidden_control(c) { ' ' } else { c })
.collect();
}
}
Js::Array(items) => items.iter_mut().for_each(sanitize_json),
Js::Object(map) => map.values_mut().for_each(sanitize_json),
_ => {}
}
}
fn is_forbidden_control(c: char) -> bool {
c.is_ascii_control() && c != '\n' && c != '\t'
}
fn bool_arg(args: &Js, name: &str) -> Result<bool, String> {
match args.get(name) {
None | Some(Js::Null) => Ok(false),
Some(Js::Bool(b)) => Ok(*b),
Some(_) => Err(format!("{name} must be a boolean")),
}
}
fn str_arg<'a>(args: &'a Js, name: &str) -> Result<&'a str, String> {
args.get(name)
.and_then(Js::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| format!("missing {name}"))
}
fn str_list_arg(args: &Js, name: &str) -> Result<Vec<String>, String> {
let Some(v) = args.get(name) else {
return Ok(Vec::new());
};
if v.is_null() {
return Ok(Vec::new());
}
let arr = v
.as_array()
.ok_or_else(|| format!("{name} must be an array of strings"))?;
arr.iter()
.map(|x| {
x.as_str()
.map(str::to_string)
.ok_or_else(|| format!("{name} must be an array of strings"))
})
.collect()
}
const BYTES_PER_TOKEN: usize = 4;
const DEFAULT_EXPLORE_TOKENS: u64 = (repograph::DEFAULT_EXPLORE_BYTES / BYTES_PER_TOKEN) as u64;
const MIN_EXPLORE_TOKENS: u64 = 200;
fn tool_explore(db: &SharedDb, args: &Js, json_out: bool) -> CallOutcome {
let target = match str_arg(args, "target") {
Ok(t) => t,
Err(e) => return CallOutcome::ToolErr(e),
};
let depth = match args.get("depth") {
None | Some(Js::Null) => repograph::Depth::Context,
Some(Js::String(s)) => match repograph::Depth::parse(s) {
Some(d) => d,
None => {
return CallOutcome::ToolErr(format!(
"depth must be one of {}, got {s:?}",
repograph::Depth::NAMES.join(", ")
))
}
},
Some(_) => return CallOutcome::ToolErr("depth must be a string".into()),
};
let tokens = match args.get("budget") {
None | Some(Js::Null) => DEFAULT_EXPLORE_TOKENS,
Some(v) => match v.as_u64() {
Some(n) => n.max(MIN_EXPLORE_TOKENS),
None => return CallOutcome::ToolErr("budget must be a positive integer".into()),
},
};
let full = match bool_arg(args, "full") {
Ok(b) => b,
Err(e) => return CallOutcome::ToolErr(e),
};
let budget_bytes = usize::try_from(tokens)
.unwrap_or(usize::MAX)
.saturating_mul(BYTES_PER_TOKEN);
let report = {
let g = db.read();
repograph::explore(&*g, None, target, depth, full)
};
ok(json_out, &report, |r| {
repograph::render_explore(r, budget_bytes)
})
}
fn tool_map(db: &SharedDb, json_out: bool) -> CallOutcome {
let map = {
let g = db.read();
repograph::repo_map(&*g, &MapOptions::default())
};
ok(json_out, &map, repograph::render_map)
}
fn tool_context(db: &SharedDb, args: &Js, json_out: bool) -> CallOutcome {
let target = match str_arg(args, "target") {
Ok(t) => t,
Err(e) => return CallOutcome::ToolErr(e),
};
let full = match bool_arg(args, "full") {
Ok(b) => b,
Err(e) => return CallOutcome::ToolErr(e),
};
let report = {
let g = db.read();
repograph::context_with(&*g, None, target, &ContextOptions { source: full })
};
ok(json_out, &report, repograph::render_context)
}
fn tool_impact(
db: &SharedDb,
args: &Js,
project_dir: Option<&OsStr>,
json_out: bool,
) -> CallOutcome {
let mut files = match str_list_arg(args, "files") {
Ok(f) => f,
Err(e) => return CallOutcome::ToolErr(e),
};
if files.is_empty() {
let repo = match project_repo(db, project_dir) {
Some(r) => r,
None => {
return CallOutcome::ToolErr(
"no repository to read a diff from: pass files explicitly".into(),
)
}
};
match changed_paths(&repo) {
Ok(paths) => files = paths,
Err(e) => {
return CallOutcome::ToolErr(format!(
"could not read the diff in {}: {e}; pass files explicitly",
repo.display()
))
}
}
}
let modified: BTreeSet<String> = files.iter().cloned().collect();
let report = {
let g = db.read();
repograph::impact(&*g, &files, &modified, &ImpactOptions::default())
};
ok(json_out, &report, repograph::render_impact)
}
fn project_repo(db: &SharedDb, project_dir: Option<&OsStr>) -> Option<PathBuf> {
if let Some(root) = project_dir.map(Path::new).and_then(repo_root) {
return Some(root);
}
let repo = {
let g = db.read();
g.node_ref(SYNC_KEY)
.and_then(|n| n.prop(SYNC_REPO_PROP))
.and_then(|v| match v {
core_api::Value::Str(s) => Some(s),
_ => None,
})
}?;
repo_root(Path::new(&repo))
}
fn repo_root(dir: &Path) -> Option<PathBuf> {
if !dir.is_dir() {
return None;
}
let output = Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!root.is_empty()).then(|| PathBuf::from(root))
}
fn changed_paths(root: &Path) -> Result<Vec<String>, String> {
const LISTS: [&[&str]; 2] = [
&["diff", "--name-only", "-z", "HEAD"],
&["ls-files", "--others", "--exclude-standard", "-z"],
];
let excludes: Vec<String> = DEFAULT_EXCLUDES.iter().map(|p| (*p).to_string()).collect();
let mut out: BTreeSet<String> = BTreeSet::new();
let mut ran = false;
for args in LISTS {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
continue;
}
ran = true;
for path in String::from_utf8_lossy(&output.stdout).split('\0') {
if !path.is_empty() && !repograph::path_excluded(path, &excludes) {
out.insert(path.to_string());
}
}
}
if !ran {
return Err("git listed nothing there".into());
}
Ok(out.into_iter().collect())
}
fn tool_owners(db: &SharedDb, args: &Js, json_out: bool) -> CallOutcome {
let path = match str_arg(args, "path") {
Ok(p) => p,
Err(e) => return CallOutcome::ToolErr(e),
};
let report = {
let g = db.read();
repograph::owners(&*g, path, None)
};
let Some(report) = report else {
return CallOutcome::ToolErr(format!("no file in the store at {path}"));
};
ok(json_out, &report, repograph::render_owners)
}
fn tool_why(db: &SharedDb, args: &Js, json_out: bool) -> CallOutcome {
let a = match str_arg(args, "a") {
Ok(v) => v.to_string(),
Err(e) => return CallOutcome::ToolErr(e),
};
let b = match str_arg(args, "b") {
Ok(v) => v.to_string(),
Err(e) => return CallOutcome::ToolErr(e),
};
let report = {
let g = db.read();
repograph::why(&*g, &a, &b)
};
ok(json_out, &report, repograph::render_why)
}
fn tool_recall(db: &SharedDb, db_dir: Option<&Path>, args: &Js, json_out: bool) -> CallOutcome {
let topic = match str_arg(args, "topic") {
Ok(t) => t.to_string(),
Err(e) => return CallOutcome::ToolErr(e),
};
let label = db_dir.map_or_else(|| "store".to_string(), |d| d.display().to_string());
let digest = {
let g = db.read();
repograph::recall_digest(&*g, &topic, &label, MAX_OUTPUT_BYTES)
};
let text = if digest.is_empty() {
format!(
"mushroomdb recall — nothing indexed matches {}\n",
repograph::sanitize(&topic)
)
} else {
digest.clone()
};
ok(
json_out,
&json!({ "topic": topic, "digest": digest }),
|_| text,
)
}
fn tool_remember(db: &SharedDb, args: &Js, json_out: bool) -> CallOutcome {
let text = match str_arg(args, "text") {
Ok(t) => t.to_string(),
Err(e) => return CallOutcome::ToolErr(e),
};
let mut about = match str_list_arg(args, "about") {
Ok(a) => a,
Err(e) => return CallOutcome::ToolErr(e),
};
about.sort();
about.dedup();
let kind = match args.get("kind") {
None | Some(Js::Null) => "note".to_string(),
Some(Js::String(k)) => k.clone(),
Some(_) => return CallOutcome::ToolErr("kind must be a string".into()),
};
if !NOTE_KINDS.contains(&kind.as_str()) {
return CallOutcome::ToolErr(format!(
"kind must be one of {}, got {kind:?}",
NOTE_KINDS.join(", ")
));
}
let missing: Vec<String> = {
let g = db.read();
about
.iter()
.filter(|k| !g.has_node(k))
.map(|k| repograph::sanitize(k))
.collect()
};
if !missing.is_empty() {
return CallOutcome::ToolErr(format!(
"unknown about {}: {}",
if missing.len() == 1 { "key" } else { "keys" },
missing.join(", ")
));
}
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let input = RememberInput {
text: &text,
about: &about,
kind: &kind,
ts,
};
let key = {
let mut g = db.write();
repograph::remember(&mut *g, &input)
};
match key {
Ok(key) => {
let mut rendered = format!("remembered {}\n", repograph::sanitize(&key));
if !about.is_empty() {
rendered.push_str(&format!(
"about {}\n",
about
.iter()
.map(|k| repograph::sanitize(k))
.collect::<Vec<_>>()
.join(", ")
));
}
ok(
json_out,
&json!({ "key": key, "kind": kind, "about": about }),
|_| rendered,
)
}
Err(e) => CallOutcome::ToolErr(match e {
GraphError::QueryError { detail } | GraphError::IngestError { detail } => detail,
other => other.to_string(),
}),
}
}
fn tool_sync(db_dir: Option<&Path>, json_out: bool) -> CallOutcome {
let Some(db_dir) = db_dir else {
return CallOutcome::ToolErr(
"store path unknown: sync needs the directory this server was started on".into(),
);
};
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(e) => return CallOutcome::ToolErr(format!("sync cannot find this binary: {e}")),
};
let output = match Command::new(&exe)
.arg("sync")
.arg(db_dir)
.arg("--json")
.output()
{
Ok(o) => o,
Err(e) => {
return CallOutcome::ToolErr(format!("sync could not run {}: {e}", exe.display()))
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = stderr.trim();
let detail = if detail.is_empty() {
format!("exit {}", output.status)
} else {
repograph::sanitize(detail)
};
return CallOutcome::ToolErr(format!("sync failed: {detail}"));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let Ok(Js::Object(report)) = serde_json::from_str::<Js>(stdout.trim()) else {
return CallOutcome::ToolErr(format!(
"sync produced no report: {}",
repograph::sanitize(stdout.trim())
));
};
let text = report
.get("text")
.and_then(Js::as_str)
.unwrap_or_default()
.to_string();
ok(json_out, &Js::Object(report), |_| text)
}
fn json_arg() -> Js {
json!({
"type": "boolean",
"description": "Answer with the report as JSON, not the rendered digest."
})
}
pub(crate) fn task_tools() -> Vec<Js> {
let mut tools = task_tool_schemas();
for tool in &mut tools {
if let Some(props) = tool["inputSchema"]["properties"].as_object_mut() {
props.insert("json".to_string(), json_arg());
}
}
tools
}
fn task_tool_schemas() -> Vec<Js> {
vec![
json!({
"name": "explore",
"description": "Find your way around this repository from its code graph: a symbol's definition, callers and callees; the blast radius (files that import it or change with it) if it changes; who owns it and why files are related. Cheaper than grep for anything cross-file. depth=context (default) | impact | history | all.",
"inputSchema": {
"type": "object",
"properties": {
"target": {
"type": "string",
"minLength": 1,
"description": "A file path, a symbol key (path#name), or a bare symbol name."
},
"depth": {
"type": "string",
"enum": ["context", "impact", "history", "all"]
},
"budget": {
"type": "integer",
"minimum": 200,
"description": "Max reply tokens (default 1200)."
},
"full": {
"type": "boolean",
"description": "Include the source body."
}
},
"required": ["target"]
}
}),
json!({
"name": "map",
"description": "Summarise the graphed repository in one screen: size, last sync, clusters, key files, owners, hot files, stale concepts, and questions worth asking next. Start here when you do not know the codebase.",
"inputSchema": { "type": "object", "properties": {} }
}),
json!({
"name": "context",
"description": "Everything known about one file or symbol: where it is as path:start-end, its signature and doc, owner, every call site into it grouped by calling file, its callees, importers and imports, co-change partners, recent commits, and any notes or concepts about it. The body is not quoted unless you ask for it with 'full'.",
"inputSchema": {
"type": "object",
"properties": {
"target": {
"type": "string",
"minLength": 1,
"description": "A file path, a symbol key (path#name), or a bare symbol name. An ambiguous bare name returns the candidates instead."
},
"full": {
"type": "boolean",
"description": "Include the source body (default: pointers and signature only)."
}
},
"required": ["target"]
}
}),
json!({
"name": "impact",
"description": "What else the files in a change reach: co-change partners, by similarity score or by how many commits the two share, plus importers, symbols used elsewhere, and each file's owner. Defaults to the current git diff plus untracked files when no list is given.",
"inputSchema": {
"type": "object",
"properties": {
"files": {
"type": "array",
"items": { "type": "string" },
"description": "Repository-relative paths. Omit to use the working tree's diff against HEAD plus its untracked files."
}
}
}
}),
json!({
"name": "owners",
"description": "Who has written a file: top author and share, authors who know it, the last commit to touch it, and the split by quarter.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"minLength": 1,
"description": "Repository-relative file path."
}
},
"required": ["path"]
}
}),
json!({
"name": "why",
"description": "What links two files, symbols, or people, with the evidence for each link: shared commits, the importing line, every calling line, the file two authors both know. With no rule edge it reports the commits the two share, and failing that the shortest path between them.",
"inputSchema": {
"type": "object",
"properties": {
"a": { "type": "string", "minLength": 1, "description": "First node key." },
"b": { "type": "string", "minLength": 1, "description": "Second node key." }
},
"required": ["a", "b"]
}
}),
json!({
"name": "recall",
"description": "Where the graph says a topic lives: one pointer per hit — path:line, the symbol, and the first line of its doc — across notes, concepts, files, symbols and people.",
"inputSchema": {
"type": "object",
"properties": {
"topic": {
"type": "string",
"minLength": 1,
"description": "Free-form text. The identifiers in it — a path, a `mod::name`, a snake_case word, or any word in backticks — are searched as phrases; a topic naming none of those matches nothing."
}
},
"required": ["topic"]
}
}),
json!({
"name": "remember",
"description": "Write a note into the graph and return its key. Keys listed in 'about' are linked to the note, and every one of them must already exist.",
"inputSchema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"minLength": 1,
"description": "The note itself, 1 to 4000 characters."
},
"about": {
"type": "array",
"items": { "type": "string" },
"description": "Existing node keys the note is about: files, symbols, authors, concepts, other notes."
},
"kind": {
"type": "string",
"enum": ["note", "decision", "todo"],
"description": "What kind of note this is (default: note)."
}
},
"required": ["text"]
}
}),
json!({
"name": "sync",
"description": "Bring the store up to date with the repository it was built from: the commits since the last sync, then the files that differ from HEAD. Returns what changed.",
"inputSchema": { "type": "object", "properties": {} }
}),
]
}
#[cfg(test)]
mod tests {
use super::*;
use core_api::Value;
use std::sync::atomic::{AtomicU64, Ordering};
fn tmp(name: &str) -> PathBuf {
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("mcp-tasks-{name}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn git(repo: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.expect("git");
assert!(out.status.success(), "git {args:?}: {out:?}");
}
fn dirty_repo(name: &str) -> PathBuf {
let repo = tmp(name);
std::fs::create_dir_all(repo.join("src")).expect("src");
std::fs::create_dir_all(repo.join("target")).expect("target");
git(&repo, &["init", "-q"]);
git(&repo, &["config", "user.email", "t@example.test"]);
git(&repo, &["config", "user.name", "Test"]);
std::fs::write(repo.join("src/core.rs"), "fn init() {}\n").expect("write");
git(&repo, &["add", "src/core.rs"]);
git(&repo, &["commit", "-qm", "first"]);
std::fs::write(repo.join("src/core.rs"), "fn init() { /* edited */ }\n").expect("edit");
std::fs::write(repo.join("target/debug.log"), "noise\n").expect("artefact");
repo
}
fn store_for(name: &str, repo: Option<&Path>) -> (SharedDb, PathBuf) {
let dir = tmp(name);
let db = SharedDb::open(&dir).expect("open");
{
let mut w = db.write();
w.insert_node(
"File",
"src/core.rs",
vec![
("id".into(), Value::Str("src/core.rs".into())),
("path".into(), Value::Str("src/core.rs".into())),
("lines".into(), Value::Int(1)),
],
)
.expect("file");
let marker = repo.map_or_else(
|| "/nonexistent/mushroomdb-test-repo".to_string(),
|r| r.display().to_string(),
);
w.insert_node(
"GitSync",
SYNC_KEY,
vec![
("id".into(), Value::Str(SYNC_KEY.into())),
(SYNC_REPO_PROP.into(), Value::Str(marker)),
],
)
.expect("marker");
}
(db, dir)
}
fn report(outcome: &CallOutcome) -> Js {
match outcome {
CallOutcome::TaskOk { text } => {
serde_json::from_str(text).expect("a json reply is the serialised report")
}
other => panic!("expected a task result, got {}", describe(other)),
}
}
fn impact_files(outcome: &CallOutcome) -> Vec<String> {
report(outcome)["files"]
.as_array()
.expect("files")
.iter()
.map(|f| f["path"].as_str().expect("path").to_string())
.collect()
}
fn impact_report(db: &SharedDb, project_dir: Option<&OsStr>) -> CallOutcome {
tool_impact(db, &json!({"json": true}), project_dir, true)
}
fn describe(outcome: &CallOutcome) -> String {
match outcome {
CallOutcome::ToolErr(m) => format!("tool error: {m}"),
CallOutcome::TaskOk { text } => format!("ok: {text}"),
CallOutcome::ToolOk(v) => format!("json: {v}"),
CallOutcome::Protocol { message, .. } => format!("protocol: {message}"),
}
}
#[test]
fn default_files_come_from_the_marker_repo_and_skip_excluded_paths() {
let repo = dirty_repo("marker-repo");
let (db, dir) = store_for("marker-store", Some(&repo));
let outcome = impact_report(&db, None);
assert_eq!(
impact_files(&outcome),
vec!["src/core.rs".to_string()],
"the uncommitted edit, and not the build artefact"
);
assert_eq!(
report(&outcome)["unknown"],
json!([]),
"an excluded path must not come back as unknown"
);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn the_project_directory_wins_over_the_marker() {
let project = dirty_repo("project-repo");
let (db, dir) = store_for("project-store", None);
let outcome = impact_report(&db, Some(project.as_os_str()));
assert_eq!(impact_files(&outcome), vec!["src/core.rs".to_string()]);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&project);
}
#[test]
fn a_project_subdirectory_resolves_to_the_repository_root() {
let repo = dirty_repo("subdir-repo");
let (db, dir) = store_for("subdir-store", None);
let outcome = impact_report(&db, Some(repo.join("src").as_os_str()));
assert_eq!(
impact_files(&outcome),
vec!["src/core.rs".to_string()],
"paths stay root-relative, matching File keys"
);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn a_project_directory_outside_a_checkout_falls_back_to_the_marker() {
let repo = dirty_repo("fallback-repo");
let plain = tmp("fallback-plain");
std::fs::create_dir_all(&plain).expect("plain dir");
let (db, dir) = store_for("fallback-store", Some(&repo));
let outcome = impact_report(&db, Some(plain.as_os_str()));
assert_eq!(impact_files(&outcome), vec!["src/core.rs".to_string()]);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&plain);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn no_checkout_anywhere_says_pass_files_explicitly() {
let (db, dir) = store_for("no-repo-store", None);
let outcome = impact_report(
&db,
Some(OsStr::new("/nonexistent/mushroomdb-test-project")),
);
match &outcome {
CallOutcome::ToolErr(m) => assert!(m.contains("pass files explicitly"), "{m}"),
other => panic!("{}", describe(other)),
}
drop(db);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn explicit_files_ignore_the_project_directory() {
let (db, dir) = store_for("explicit-store", None);
let outcome = tool_impact(
&db,
&json!({"files": ["src/core.rs"], "json": true}),
Some(OsStr::new("/nonexistent/mushroomdb-test-project")),
true,
);
assert_eq!(impact_files(&outcome), vec!["src/core.rs".to_string()]);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
}
}