use std::path::PathBuf;
use serde_json::{Value, json};
use crate::extract::{KIND_NAMES, resolve_format};
use crate::scan::{self, ScanOptions};
use crate::walk::{self, WalkOptions};
pub(crate) fn definition() -> Value {
json!({
"name": "ids_le_scan",
"description": "Extract every identifier from files or directories, with the file \
it came from, its line and column, the document's key path for it, \
and the decoded time where the identifier carries one. Reads the \
filesystem; never writes to it. A run that cannot be named is \
returned as a row with `valid: false` and a named reason, never \
dropped.",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "a file or directory to read" },
"paths": {
"type": "array",
"items": { "type": "string" },
"description": "several files or directories, instead of `path`",
},
"format": {
"type": "string",
"description": "Force a format for every file instead of inferring one \
per file name. An unrecognised name reads the text \
directly, without key paths.",
},
"kind": {
"type": "string",
"enum": KIND_NAMES,
"description": "Return only one kind. This narrows the report after \
the analysis; omit it for the complete answer.",
},
"hidden": {
"type": "boolean",
"default": false,
"description": "Walk hidden files and directories too, which is where \
a dotenv file lives.",
},
"ignored": {
"type": "boolean",
"default": false,
"description": "Walk files excluded by .gitignore too.",
},
},
"anyOf": [{ "required": ["path"] }, { "required": ["paths"] }],
"additionalProperties": false,
},
})
}
pub(crate) fn run(arguments: &Value) -> Result<Value, String> {
let inputs = requested_paths(arguments)?;
let flag = |name: &str| {
arguments
.get(name)
.and_then(Value::as_bool)
.unwrap_or(false)
};
let walk_options = WalkOptions {
hidden: flag("hidden"),
respect_ignore: !flag("ignored"),
};
let options = ScanOptions {
format: arguments
.get("format")
.and_then(Value::as_str)
.map(|name| resolve_format(Some(name), None)),
..ScanOptions::default()
}
.with_kind(super::requested_kind(arguments)?);
let targets = walk::collect(&inputs, &walk_options)?;
let scanned = targets
.iter()
.map(|target| scan::scan_file(target, options))
.collect();
let (read, binary) = scan::partition(scanned);
let reports: Vec<Value> = read
.iter()
.map(|report| serde_json::to_value(report).expect("a report serializes"))
.collect();
let ids: usize = read.iter().map(|report| report.summary.ids).sum();
let refused: usize = read.iter().map(|report| report.summary.refused).sum();
let mut diagnostics: Vec<Value> = read
.iter()
.filter(|report| report.was_skipped())
.map(|report| {
super::warning(
"unreadable",
&format!(
"{} could not be read, so this scan does not cover it",
report.file
),
)
})
.collect();
if refused > 0 {
diagnostics.push(super::warning(
"refused",
&format!(
"{refused} run(s) could not be named; each is in its file's report with \
`valid: false` and a reason"
),
));
}
let count = reports.len();
Ok(super::envelope(
"ids_le_scan",
&json!({ "reports": reports, "ids": ids, "refused": refused, "binaryFiles": binary }),
count,
&diagnostics,
false,
))
}
fn requested_paths(arguments: &Value) -> Result<Vec<PathBuf>, String> {
if let Some(path) = arguments.get("path").and_then(Value::as_str) {
return Ok(vec![PathBuf::from(path)]);
}
if let Some(items) = arguments.get("paths").and_then(Value::as_array) {
let paths: Vec<PathBuf> = items
.iter()
.filter_map(|item| item.as_str().map(PathBuf::from))
.collect();
if paths.is_empty() {
return Err("the list of paths was empty".to_string());
}
return Ok(paths);
}
Err("no file or directory was supplied to read".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempTree;
#[test]
fn the_tool_name_is_pinned() {
assert_eq!(definition()["name"], "ids_le_scan");
}
#[test]
fn the_schema_names_what_the_handler_requires() {
let definition = definition();
let schema = &definition["inputSchema"];
assert_eq!(schema["additionalProperties"], false);
let either: Vec<&str> = schema["anyOf"]
.as_array()
.expect("an anyOf")
.iter()
.filter_map(|branch| branch["required"][0].as_str())
.collect();
assert_eq!(either, ["path", "paths"]);
for name in either {
assert!(
schema["properties"].get(name).is_some(),
"{name} is required and not defined"
);
}
assert!(run(&json!({})).is_err(), "the handler accepts neither");
}
#[test]
fn every_advertised_knob_changes_the_answer() {
const UUID: &str = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
let tree = TempTree::new("mcp-scan-knobs");
tree.mkdir(".git");
tree.write(".gitignore", "ignored.env\n");
tree.write("ignored.env", &format!("SERVICE_ID={UUID}\n"));
tree.write(".env", &format!("SERVICE_ID={UUID}\n"));
tree.write("plain.txt", &format!("id = \"{UUID}\"\n"));
let path = tree.path().to_string_lossy().to_string();
let ids = |arguments: &Value| -> u64 {
run(arguments).expect("a result")["data"]["ids"]
.as_u64()
.expect("a count")
};
assert_eq!(ids(&json!({ "path": path })), 1, "path");
assert_eq!(ids(&json!({ "paths": [&path] })), 1, "paths");
assert_eq!(ids(&json!({ "path": path, "hidden": true })), 2, "hidden");
assert_eq!(ids(&json!({ "path": path, "ignored": true })), 2, "ignored");
assert_eq!(ids(&json!({ "path": path, "kind": "ulid" })), 0, "kind");
let forced = run(&json!({ "path": path, "format": "toml" })).expect("a result");
assert_eq!(
forced["data"]["reports"][0]["format"], "toml",
"format did not force the reader"
);
assert_eq!(
forced["data"]["reports"][0]["ids"][0]["key"], "id",
"the forced reader supplied no key path"
);
}
}