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 project_under: Option<PathBuf>,
pub kind: Option<String>,
}
pub fn run(
scope: &Scope,
code: &str,
compact: bool,
raw: bool,
explain: Option<&str>,
) -> Result<()> {
let plan = plan::analyze(code);
if matches!(explain, Some(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());
execute_plan(scope, &plan, code, compact, raw, &mut out)?;
out.flush().context("flush stdout")
}
#[cfg(not(target_os = "emscripten"))]
fn execute_plan(
scope: &Scope,
plan: &plan::Plan,
code: &str,
compact: bool,
raw: bool,
out: &mut dyn Write,
) -> Result<()> {
match plan {
plan::Plan::Slurp => filter::execute(plan, code, compact, raw, out, |emit| {
stream_files(scope, emit)
}),
plan::Plan::PerFileStream => {
filter::compile_check(code)?;
for_each_file(
scope,
|steps| filter::render_file(code, steps, compact, raw),
|bytes| {
out.write_all(&bytes)?;
Ok(())
},
)
}
plan::Plan::Decompose { reduce } => {
filter::compile_check(code)?;
let mut partials: Vec<Val> = Vec::new();
let mut saw_file = false;
for_each_file(
scope,
|steps| filter::partials_file(code, steps),
|bytes| {
saw_file = true;
partials.extend(filter::unpack_partials(&bytes)?);
Ok(())
},
)?;
filter::reduce_partials(code, reduce, partials, saw_file, compact, raw, out)
}
}
}
#[cfg(target_os = "emscripten")]
fn execute_plan(
scope: &Scope,
plan: &plan::Plan,
code: &str,
compact: bool,
raw: bool,
out: &mut dyn Write,
) -> Result<()> {
filter::execute(plan, code, compact, raw, out, |emit| {
stream_files(scope, emit)
})
}
#[cfg(not(target_os = "emscripten"))]
fn for_each_file<T: Send>(
scope: &Scope,
per_file: impl Fn(Vec<serde_json::Value>) -> Result<T> + Sync,
mut consume: impl FnMut(T) -> Result<()>,
) -> Result<()> {
use rayon::prelude::*;
let kind_sel = scope.kind.as_deref().map(kinds::parse_kind_selector);
let project = scope.project.as_deref().map(canonicalize_or_self);
let project_under = scope.project_under.as_deref().map(canonicalize_or_self);
let sources = select_files(scope)?;
let chunk = rayon::current_num_threads().max(1) * 2;
for batch in sources.chunks(chunk) {
let products: Vec<Result<T>> = batch
.par_iter()
.map(|src| {
load_and_wrap(
src,
kind_sel.as_ref(),
project.as_deref(),
project_under.as_deref(),
)
.and_then(&per_file)
})
.collect();
for (src, res) in batch.iter().zip(products) {
match res {
Ok(product) => consume(product)?,
Err(e) if src.explicit => {
return Err(e.context(format!("read {}", src.label())));
}
Err(e) => eprintln!("warning: skipping {}: {e:#}", src.label()),
}
}
}
Ok(())
}
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);
let project_under = scope.project_under.as_deref().map(canonicalize_or_self);
let sources = select_files(scope)?;
#[cfg(not(target_os = "emscripten"))]
{
use rayon::prelude::*;
let chunk = rayon::current_num_threads().max(1) * 2;
for batch in sources.chunks(chunk) {
let parsed: Vec<Result<Vec<serde_json::Value>>> = batch
.par_iter()
.map(|src| {
load_and_wrap(
src,
kind_sel.as_ref(),
project.as_deref(),
project_under.as_deref(),
)
})
.collect();
for (src, res) in batch.iter().zip(parsed) {
emit_wrapped(src, res, emit)?;
}
}
}
#[cfg(target_os = "emscripten")]
for src in &sources {
let res = load_and_wrap(
src,
kind_sel.as_ref(),
project.as_deref(),
project_under.as_deref(),
);
emit_wrapped(src, res, emit)?;
}
Ok(())
}
fn load_and_wrap(
src: &DocSource,
kind_sel: Option<&KindSelector>,
project: Option<&FsPath>,
project_under: Option<&FsPath>,
) -> Result<Vec<serde_json::Value>> {
let graph = read_source(src)?;
let mut steps = Vec::new();
wrap_graph(src, &graph, kind_sel, project, project_under, &mut steps);
Ok(steps)
}
fn emit_wrapped(
src: &DocSource,
res: Result<Vec<serde_json::Value>>,
emit: &mut dyn FnMut(Val) -> Result<()>,
) -> Result<()> {
match res {
Ok(steps) => emit(filter::steps_to_val(steps)?),
Err(e) if src.explicit => Err(e.context(format!("read {}", src.label()))),
Err(e) => {
eprintln!("warning: skipping {}: {e:#}", src.label());
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"
);
}
}
if let Some(name) = &scope.source
&& scope.ids.is_empty()
&& sources.is_empty()
{
eprintln!(
"warning: no cached documents with source `{name}`; 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>,
project_under: 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;
}
if let Some(dir) = project_under
&& !path_matches_project_under(path, dir)
{
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 {
base_fs_path(path).is_some_and(|p| p == project)
}
fn path_matches_project_under(path: &Path, project_under: &FsPath) -> bool {
base_fs_path(path).is_some_and(|p| p.starts_with(project_under))
}
fn base_fs_path(path: &Path) -> Option<PathBuf> {
let base = path.path.base.as_ref()?;
let fs = base.uri.strip_prefix("file://")?;
Some(canonicalize_or_self(FsPath::new(fs)))
}
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(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION);
wrap_graph(&doc_src("g"), &graph, Some(&sel), None, 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, 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,
project_under: 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");
}
}