mod filter;
mod plan;
use anyhow::{Context, Result};
use jaq_json::Val;
use std::collections::HashSet;
use std::io::{Read, Write};
use std::path::{Path as FsPath, PathBuf};
use toolpath::v1::{Graph, Path, PathOrRef, query};
use crate::kinds::{self, KindSelector};
pub struct Scope {
pub source: Option<String>,
pub ids: Vec<String>,
pub inputs: Vec<String>,
pub project: Option<PathBuf>,
pub kind: Option<String>,
}
pub fn run(scope: &Scope, code: &str, compact: bool, raw: bool) -> Result<()> {
let plan = plan::analyze(code);
let explain = std::env::var("TOOLPATH_QUERY_EXPLAIN");
if matches!(explain.as_deref(), Ok(v) if !v.is_empty() && v != "0") {
eprintln!("query plan: {}", plan.describe());
}
let stdout = std::io::stdout();
let mut out = std::io::BufWriter::new(stdout.lock());
filter::execute(&plan, code, compact, raw, &mut out, |emit| {
stream_files(scope, emit)
})?;
out.flush().context("flush stdout")
}
struct DocSource {
cache_id: String,
location: SourceLoc,
explicit: bool,
}
enum SourceLoc {
File(PathBuf),
Stdin,
}
impl DocSource {
fn label(&self) -> String {
match &self.location {
SourceLoc::File(p) => p.display().to_string(),
SourceLoc::Stdin => "<stdin>".to_string(),
}
}
}
fn stream_files(scope: &Scope, emit: &mut dyn FnMut(Val) -> Result<()>) -> Result<()> {
let kind_sel = scope.kind.as_deref().map(kinds::parse_kind_selector);
let project = scope.project.as_deref().map(canonicalize_or_self);
for src in select_files(scope)? {
let graph = match read_source(&src) {
Ok(g) => g,
Err(e) if src.explicit => {
return Err(e.context(format!("read {}", src.label())));
}
Err(e) => {
eprintln!("warning: skipping {}: {e:#}", src.label());
continue;
}
};
let mut steps = Vec::new();
wrap_graph(
&src,
&graph,
kind_sel.as_ref(),
project.as_deref(),
&mut steps,
);
drop(graph);
emit(filter::steps_to_val(steps)?)?;
}
Ok(())
}
fn select_files(scope: &Scope) -> Result<Vec<DocSource>> {
let mut sources = Vec::new();
let restrict = scope.source.is_some() || !scope.ids.is_empty();
let load_cache = restrict || scope.inputs.is_empty();
if load_cache {
let id_set: Option<HashSet<&str>> = if scope.ids.is_empty() {
None
} else {
Some(scope.ids.iter().map(String::as_str).collect())
};
let prefix = scope.source.as_ref().map(|s| format!("{s}-"));
let by_id = id_set.is_some();
let mut seen_ids: HashSet<String> = HashSet::new();
for entry in crate::cache::list_cached()? {
if let Some(ids) = &id_set
&& !ids.contains(entry.id.as_str())
{
continue;
}
seen_ids.insert(entry.id.clone());
if let Some(p) = &prefix
&& !entry.id.starts_with(p.as_str())
{
continue;
}
sources.push(DocSource {
cache_id: entry.id,
location: SourceLoc::File(entry.path),
explicit: by_id,
});
}
for id in &scope.ids {
if !seen_ids.contains(id) {
anyhow::bail!(
"no cached document with id `{id}`; run `path p cache ls` to see what's cached"
);
}
}
}
for inp in &scope.inputs {
if inp == "-" {
sources.push(DocSource {
cache_id: "stdin".to_string(),
location: SourceLoc::Stdin,
explicit: true,
});
} else {
sources.push(DocSource {
cache_id: inp.clone(),
location: SourceLoc::File(PathBuf::from(inp)),
explicit: true,
});
}
}
Ok(sources)
}
fn read_source(src: &DocSource) -> Result<Graph> {
match &src.location {
SourceLoc::File(p) => crate::io::read_document_auto(p),
SourceLoc::Stdin => {
let mut s = String::new();
std::io::stdin()
.read_to_string(&mut s)
.context("read stdin")?;
match Graph::from_json(&s) {
Ok(g) => Ok(g),
Err(json_err) => Graph::from_jsonl_str(&s).map_err(|jsonl_err| {
anyhow::anyhow!(
"stdin is neither toolpath JSON ({json_err}) nor JSONL ({jsonl_err})"
)
}),
}
}
}
}
fn wrap_graph(
src: &DocSource,
graph: &Graph,
kind_sel: Option<&KindSelector>,
project: Option<&FsPath>,
out: &mut Vec<serde_json::Value>,
) {
for entry in &graph.paths {
let PathOrRef::Path(path) = entry else {
continue;
};
if let Some(sel) = kind_sel {
let kind = path.meta.as_ref().and_then(|m| m.kind.as_deref());
if !kind.is_some_and(|k| sel.matches_uri(k)) {
continue;
}
}
if let Some(proj) = project
&& !path_matches_project(path, proj)
{
continue;
}
wrap_path(src, path, out);
}
}
fn wrap_path(src: &DocSource, path: &Path, out: &mut Vec<serde_json::Value>) {
let dead: HashSet<&str> = query::dead_ends(&path.steps, &path.path.head)
.into_iter()
.map(|s| s.step.id.as_str())
.collect();
let path_ctx = path_context(path);
for step in &path.steps {
let serde_json::Value::Object(mut obj) = serde_json::to_value(step).unwrap_or_default()
else {
continue;
};
obj.insert(
"cache_id".to_string(),
serde_json::Value::String(src.cache_id.clone()),
);
obj.insert("path".to_string(), path_ctx.clone());
obj.insert(
"dead_end".to_string(),
serde_json::Value::Bool(dead.contains(step.step.id.as_str())),
);
out.push(serde_json::Value::Object(obj));
}
}
fn path_context(path: &Path) -> serde_json::Value {
let mut m = serde_json::Map::new();
m.insert(
"id".to_string(),
serde_json::Value::String(path.path.id.clone()),
);
if let Some(base) = &path.path.base
&& let Ok(v) = serde_json::to_value(base)
{
m.insert("base".to_string(), v);
}
if let Some(meta) = &path.meta
&& let Ok(v) = serde_json::to_value(meta)
{
m.insert("meta".to_string(), v);
}
serde_json::Value::Object(m)
}
fn path_matches_project(path: &Path, project: &FsPath) -> bool {
let Some(base) = &path.path.base else {
return false;
};
let Some(fs) = base.uri.strip_prefix("file://") else {
return false;
};
canonicalize_or_self(FsPath::new(fs)) == project
}
fn canonicalize_or_self(p: &FsPath) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use toolpath::v1::{Base, Graph, Path, PathIdentity, PathMeta, Step};
fn doc_src(id: &str) -> DocSource {
DocSource {
cache_id: id.to_string(),
location: SourceLoc::Stdin,
explicit: false,
}
}
fn forked_path() -> Path {
let s1 = Step::new("s1", "human:alex", "2026-01-01T10:00:00Z")
.with_raw_change("src/main.rs", "@@");
let s2 = Step::new("s2", "agent:claude", "2026-01-01T11:00:00Z")
.with_parent("s1")
.with_raw_change("src/lib.rs", "@@");
let s2a = Step::new("s2a", "agent:claude", "2026-01-01T11:30:00Z")
.with_parent("s1")
.with_raw_change("src/dead.rs", "@@");
let s3 = Step::new("s3", "human:alex", "2026-01-01T12:00:00Z")
.with_parent("s2")
.with_raw_change("src/main.rs", "@@");
Path {
path: PathIdentity {
id: "p1".into(),
base: Some(Base::vcs("file:///work/repo", "abc")),
head: "s3".into(),
graph_ref: None,
},
steps: vec![s1, s2, s2a, s3],
meta: Some(PathMeta {
kind: Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION.to_string()),
source: Some("claude".to_string()),
..Default::default()
}),
}
}
#[test]
fn wraps_step_verbatim_with_context() {
let path = forked_path();
let mut out = Vec::new();
wrap_path(&doc_src("claude-abc"), &path, &mut out);
assert_eq!(out.len(), 4);
let first = &out[0];
assert_eq!(first["cache_id"], "claude-abc");
assert_eq!(first["path"]["id"], "p1");
assert_eq!(first["path"]["meta"]["source"], "claude");
assert_eq!(first["step"]["id"], "s1");
assert_eq!(first["step"]["actor"], "human:alex");
assert!(first["change"]["src/main.rs"]["raw"].is_string());
}
#[test]
fn dead_end_flag_tracks_ancestry_of_head() {
let path = forked_path();
let mut out = Vec::new();
wrap_path(&doc_src("g"), &path, &mut out);
let dead: std::collections::HashMap<&str, bool> = out
.iter()
.map(|e| {
(
e["step"]["id"].as_str().unwrap(),
e["dead_end"].as_bool().unwrap(),
)
})
.collect();
assert!(!dead["s1"]);
assert!(!dead["s2"]);
assert!(!dead["s3"]);
assert!(dead["s2a"], "s2a is off the head's ancestry");
}
#[test]
fn wrap_graph_filters_by_kind() {
let graph = Graph::from_path(forked_path());
let mut out = Vec::new();
let sel = kinds::parse_kind_selector("agent-coding-session/v1.1.0");
wrap_graph(&doc_src("g"), &graph, Some(&sel), None, &mut out);
assert_eq!(out.len(), 4, "matching kind keeps all steps");
out.clear();
let miss = kinds::parse_kind_selector("agent-coding-session/v2");
wrap_graph(&doc_src("g"), &graph, Some(&miss), None, &mut out);
assert!(out.is_empty(), "non-matching kind drops the whole path");
}
#[test]
fn project_matches_file_base_only() {
let path = forked_path(); assert!(path_matches_project(
&path,
&canonicalize_or_self(FsPath::new("/work/repo"))
));
assert!(!path_matches_project(
&path,
&canonicalize_or_self(FsPath::new("/other"))
));
let mut vcs = forked_path();
vcs.path.base = Some(Base::vcs("github:org/repo", "abc"));
assert!(!path_matches_project(
&vcs,
&canonicalize_or_self(FsPath::new("/work/repo"))
));
}
#[test]
fn select_files_input_only_skips_cache() {
let scope = Scope {
source: None,
ids: vec![],
inputs: vec!["/tmp/some.json".to_string(), "-".to_string()],
project: None,
kind: None,
};
let files = select_files(&scope).unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0].cache_id, "/tmp/some.json");
assert!(matches!(files[1].location, SourceLoc::Stdin));
assert_eq!(files[1].cache_id, "stdin");
}
}