use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::error::{Error, Result};
pub const URL_ROUTER_SCHEMA: &str = "pi.url_router.v1";
const SSH_MAX_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedDoc {
pub schema: String,
pub scheme: String,
pub reference: String,
pub content: String,
pub content_type: String,
pub metadata: serde_json::Value,
pub line_addressable: bool,
}
const SCHEMES: &[&str] = &["skill", "prompt", "local", "conflict", "pr", "issue", "ssh"];
fn split_scheme(path: &str) -> Option<(&str, &str)> {
let (scheme, rest) = path.split_once("://")?;
if scheme.is_empty()
|| !scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return None;
}
Some((scheme, rest))
}
#[must_use]
pub fn has_scheme(path: &str) -> bool {
split_scheme(path).is_some_and(|(scheme, _)| scheme != "file")
}
#[derive(Debug, Clone, Default)]
pub struct ResolveOptions {
pub gh_binary: Option<String>,
}
pub fn resolve(path: &str, cwd: &Path) -> Result<ResolvedDoc> {
resolve_with(path, cwd, &ResolveOptions::default())
}
pub fn resolve_with(path: &str, cwd: &Path, options: &ResolveOptions) -> Result<ResolvedDoc> {
let Some((scheme, rest)) = split_scheme(path) else {
return Err(Error::tool(
"read",
format!("PI_URL_NO_SCHEME: '{path}' is not a scheme URL"),
));
};
match scheme {
"skill" => resolve_skill(rest),
"prompt" => resolve_prompt(rest),
"local" => resolve_local(rest),
"conflict" => resolve_conflict(rest, cwd),
"pr" | "issue" => resolve_github(scheme, rest, cwd, options),
"ssh" => resolve_ssh(rest),
other => Err(Error::tool(
"read",
format!(
"PI_URL_UNKNOWN_SCHEME: unknown scheme '{other}://'. Registered schemes: {}",
SCHEMES
.iter()
.map(|s| format!("{s}://"))
.collect::<Vec<_>>()
.join(", ")
),
)),
}
}
fn doc(
scheme: &str,
reference: &str,
content: String,
content_type: &str,
metadata: serde_json::Value,
) -> ResolvedDoc {
ResolvedDoc {
schema: URL_ROUTER_SCHEMA.to_string(),
scheme: scheme.to_string(),
reference: reference.to_string(),
content,
content_type: content_type.to_string(),
metadata,
line_addressable: true,
}
}
fn resolve_skill(name: &str) -> Result<ResolvedDoc> {
let skills = crate::resources::load_skills(crate::resources::LoadSkillsOptions {
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
agent_dir: crate::config::Config::global_dir(),
skill_paths: Vec::new(),
include_defaults: true,
});
let Some(skill) = skills.skills.iter().find(|skill| skill.name == name) else {
let known: Vec<&str> = skills.skills.iter().map(|s| s.name.as_str()).collect();
return Err(Error::tool(
"read",
format!(
"PI_URL_UNRESOLVABLE: no skill named '{name}'. Available: {}",
if known.is_empty() {
"(none)".to_string()
} else {
known.join(", ")
}
),
));
};
let content = std::fs::read_to_string(&skill.file_path)
.map_err(|e| Error::tool("read", format!("Failed to read skill '{name}': {e}")))?;
Ok(doc(
"skill",
name,
content,
"text/markdown",
serde_json::json!({
"path": skill.file_path.display().to_string(),
"source": skill.source,
"description": skill.description,
}),
))
}
fn resolve_prompt(name: &str) -> Result<ResolvedDoc> {
let templates =
crate::resources::load_prompt_templates(crate::resources::LoadPromptTemplatesOptions {
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
agent_dir: crate::config::Config::global_dir(),
prompt_paths: Vec::new(),
include_defaults: true,
});
let Some(template) = templates.iter().find(|t| t.name == name) else {
let known: Vec<&str> = templates.iter().map(|t| t.name.as_str()).collect();
return Err(Error::tool(
"read",
format!(
"PI_URL_UNRESOLVABLE: no prompt template named '{name}'. Available: {}",
if known.is_empty() {
"(none)".to_string()
} else {
known.join(", ")
}
),
));
};
Ok(doc(
"prompt",
name,
template.content.clone(),
"text/markdown",
serde_json::json!({
"path": template.file_path.display().to_string(),
"description": template.description,
}),
))
}
fn scratch_store() -> &'static std::sync::Mutex<std::collections::HashMap<String, String>> {
static STORE: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<String, String>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
&STORE
}
#[allow(clippy::significant_drop_tightening)]
fn resolve_local(name: &str) -> Result<ResolvedDoc> {
let store = scratch_store()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(content) = store.get(name) else {
let known: Vec<&String> = store.keys().collect();
return Err(Error::tool(
"read",
format!(
"PI_URL_UNRESOLVABLE: no local scratch document '{name}'. Present: {}",
if known.is_empty() {
"(none)".to_string()
} else {
known
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
}
),
));
};
Ok(doc(
"local",
name,
content.clone(),
"text/plain",
serde_json::json!({ "scratch": true }),
))
}
pub fn write_local(name: &str, content: &str) -> Result<()> {
if name.trim().is_empty() {
return Err(Error::validation(
"local:// scratch name must be non-empty".to_string(),
));
}
scratch_store()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(name.to_string(), content.to_string());
Ok(())
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConflictRegion {
pub index: usize,
pub file: String,
pub ours_label: String,
pub theirs_label: String,
pub ours: String,
pub theirs: String,
pub base: String,
}
fn git_output(repo: &Path, args: &[&str]) -> Result<String> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.map_err(|e| Error::tool("read", format!("Failed to run git: {e}")))?;
if !output.status.success() {
return Err(Error::tool(
"read",
format!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
),
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn parse_conflicts(file: &Path, relative: &str, start_index: usize) -> Vec<ConflictRegion> {
let Ok(content) = std::fs::read_to_string(file) else {
return Vec::new();
};
let mut regions = Vec::new();
let mut ours_label = String::new();
let mut ours = String::new();
let mut theirs = String::new();
let mut base = String::new();
let mut section: Option<&str> = None;
for line in content.lines() {
if let Some(rest) = line.strip_prefix("<<<<<<<") {
ours_label = rest.trim().to_string();
section = Some("ours");
ours.clear();
theirs.clear();
base.clear();
continue;
}
if line.starts_with("|||||||") && section == Some("ours") {
section = Some("base");
continue;
}
if line.starts_with("=======") && matches!(section, Some("ours" | "base")) {
section = Some("theirs");
continue;
}
if let Some(rest) = line.strip_prefix(">>>>>>>")
&& section == Some("theirs")
{
regions.push(ConflictRegion {
index: start_index + regions.len(),
file: relative.to_string(),
ours_label: std::mem::take(&mut ours_label),
theirs_label: rest.trim().to_string(),
ours: std::mem::take(&mut ours),
theirs: std::mem::take(&mut theirs),
base: std::mem::take(&mut base),
});
section = None;
continue;
}
match section {
Some("ours") => {
ours.push_str(line);
ours.push('\n');
}
Some("base") => {
base.push_str(line);
base.push('\n');
}
Some("theirs") => {
theirs.push_str(line);
theirs.push('\n');
}
_ => {}
}
}
regions
}
pub fn conflict_regions(cwd: &Path) -> Result<Vec<ConflictRegion>> {
let listing = git_output(cwd, &["diff", "--name-only", "--diff-filter=U"])?;
let mut regions = Vec::new();
for relative in listing.lines().filter(|line| !line.is_empty()) {
let file = cwd.join(relative);
let parsed = parse_conflicts(&file, relative, regions.len());
regions.extend(parsed);
}
Ok(regions)
}
fn resolve_conflict(rest: &str, cwd: &Path) -> Result<ResolvedDoc> {
let regions = conflict_regions(cwd)?;
if regions.is_empty() {
return Err(Error::tool(
"read",
"PI_URL_UNRESOLVABLE: no merge conflicts in this repo".to_string(),
));
}
let (index_part, selector) = rest.split_once(' ').map_or((rest, "full"), |(idx, sel)| {
(idx, sel.trim_start_matches('@'))
});
if index_part == "*" {
let rendered = regions
.iter()
.map(|region| {
format!(
"### conflict {} in {} (ours: {}, theirs: {})\n--- ours ---\n{}--- theirs ---\n{}",
region.index, region.file, region.ours_label, region.theirs_label, region.ours, region.theirs
)
})
.collect::<Vec<_>>()
.join("\n");
return Ok(doc(
"conflict",
"*",
rendered,
"text/plain",
serde_json::json!({ "count": regions.len() }),
));
}
let index: usize = index_part.parse().map_err(|_| {
Error::validation(format!(
"conflict:// index must be a number or '*', got '{index_part}'"
))
})?;
let Some(region) = regions.iter().find(|region| region.index == index) else {
return Err(Error::tool(
"read",
format!(
"PI_URL_UNRESOLVABLE: no conflict region {index} (have {})",
regions.len()
),
));
};
let (content, which) = match selector {
"ours" => (region.ours.clone(), "ours"),
"theirs" => (region.theirs.clone(), "theirs"),
"base" => (region.base.clone(), "base"),
_ => (
format!(
"### conflict {} in {} (ours: {}, theirs: {})\n--- ours ---\n{}--- base ---\n{}--- theirs ---\n{}",
region.index,
region.file,
region.ours_label,
region.theirs_label,
region.ours,
region.base,
region.theirs
),
"full",
),
};
Ok(doc(
"conflict",
rest,
content,
"text/plain",
serde_json::to_value(region)?,
))
.map(|mut doc| {
doc.metadata["selector"] = serde_json::Value::String(which.to_string()); doc
})
}
pub fn write_conflict_resolution(cwd: &Path, index: usize, side: &str) -> Result<ConflictRegion> {
let regions = conflict_regions(cwd)?;
let Some(region) = regions.iter().find(|region| region.index == index) else {
return Err(Error::tool(
"write",
format!(
"PI_URL_UNRESOLVABLE: no conflict region {index} (have {})",
regions.len()
),
));
};
let chosen = match side {
"ours" => ®ion.ours,
"theirs" => ®ion.theirs,
"base" => ®ion.base,
other => {
return Err(Error::validation(format!(
"conflict resolution side must be ours, theirs, or base; got '{other}'"
)));
}
};
let file = cwd.join(®ion.file);
let content = std::fs::read_to_string(&file)
.map_err(|e| Error::tool("write", format!("Failed to read {}: {e}", region.file)))?;
let mut out = String::with_capacity(content.len());
let mut skipping = false;
let mut in_target = false;
let mut occurrence = regions
.iter()
.filter(|r| r.file == region.file && r.index <= index)
.count()
.saturating_sub(1);
for line in content.lines() {
if line.starts_with("<<<<<<<") {
skipping = true;
in_target = occurrence == 0;
if in_target {
out.push_str(chosen);
}
occurrence = occurrence.saturating_sub(1);
continue;
}
if line.starts_with(">>>>>>>") && skipping {
skipping = false;
in_target = false;
continue;
}
if !skipping {
out.push_str(line);
out.push('\n');
} else if in_target {
continue;
}
let _ = in_target;
}
std::fs::write(&file, out)
.map_err(|e| Error::tool("write", format!("Failed to write {}: {e}", region.file)))?;
Ok(region.clone())
}
fn parse_repo_number(rest: &str) -> Result<(Option<String>, String, Option<String>)> {
let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect();
if parts.is_empty() {
return Err(Error::validation(format!(
"pr/issue reference must be <n> or <owner/repo/n>, got '{rest}'"
)));
}
let (number_index, repo) = if parts.len() >= 3 {
(2, Some(format!("{}/{}", parts[0], parts[1])))
} else {
(0, None)
};
let number = parts
.get(number_index)
.or_else(|| parts.first())
.ok_or_else(|| Error::validation(format!("missing issue/PR number in '{rest}'")))?
.to_string();
if number.parse::<u64>().is_err() {
return Err(Error::validation(format!(
"issue/PR number must be numeric, got '{number}'"
)));
}
let sub = parts.get(number_index + 1).map(|s| (*s).to_string());
Ok((repo, number, sub))
}
fn resolve_github(
scheme: &str,
rest: &str,
cwd: &Path,
options: &ResolveOptions,
) -> Result<ResolvedDoc> {
let (repo, number, sub) = parse_repo_number(rest)?;
let mut args: Vec<String> = if sub.as_deref() == Some("diff") && scheme == "pr" {
vec!["pr".to_string(), "diff".to_string(), number.clone()]
} else {
vec![scheme.to_string(), "view".to_string(), number.clone()]
};
if let Some(repo) = &repo {
args.push("--repo".to_string());
args.push(repo.clone());
}
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let gh = options.gh_binary.as_deref().unwrap_or("gh");
let output = std::process::Command::new(gh)
.args(&arg_refs)
.current_dir(cwd)
.output()
.map_err(|e| {
Error::tool(
"read",
format!("PI_URL_BACKEND: failed to run gh (install gh CLI): {e}"),
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::tool(
"read",
format!(
"PI_URL_UNRESOLVABLE: gh {} failed: {}",
arg_refs.join(" "),
stderr.trim()
),
));
}
let content = String::from_utf8_lossy(&output.stdout).into_owned();
Ok(doc(
scheme,
rest,
content,
"text/plain",
serde_json::json!({
"repo": repo,
"number": number,
"sub": sub,
"backend": "gh",
}),
))
}
fn resolve_ssh(rest: &str) -> Result<ResolvedDoc> {
let (host, remote_path) = rest.split_once('/').ok_or_else(|| {
Error::validation(format!("ssh:// reference must be host/path, got '{rest}'"))
})?;
if host.is_empty() || remote_path.is_empty() {
return Err(Error::validation(format!(
"ssh:// reference must be host/path, got '{rest}'"
)));
}
let output = std::process::Command::new("ssh")
.args([
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
host,
&format!("head -c {SSH_MAX_BYTES} -- '{remote_path}'"),
])
.output()
.map_err(|e| Error::tool("read", format!("PI_URL_BACKEND: failed to run ssh: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::tool(
"read",
format!(
"PI_URL_UNRESOLVABLE: ssh {host} cat '{remote_path}' failed: {}",
stderr.trim()
),
));
}
let content = String::from_utf8_lossy(&output.stdout).into_owned();
Ok(doc(
"ssh",
rest,
content,
"text/plain",
serde_json::json!({
"host": host,
"path": remote_path,
"cappedAt": SSH_MAX_BYTES,
}),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn init_repo_with_conflict(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pi-url-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&dir)
.args(args)
.output()
.expect("git");
assert!(out.status.success(), "git {} failed", args.join(" "));
};
git(&["init", "-b", "main"]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "T"]);
std::fs::write(dir.join("f.txt"), "line\n").expect("write"); git(&["add", "."]);
git(&["commit", "-m", "init"]);
git(&["checkout", "-b", "side"]);
std::fs::write(dir.join("f.txt"), "side\n").expect("side"); git(&["commit", "-am", "side"]);
git(&["checkout", "main"]);
std::fs::write(dir.join("f.txt"), "main\n").expect("main"); git(&["commit", "-am", "main"]);
let out = std::process::Command::new("git")
.arg("-C")
.arg(&dir)
.args(["merge", "side"])
.output()
.expect("merge");
assert!(!out.status.success(), "merge must conflict");
dir
}
#[test]
fn unknown_scheme_lists_registered() {
let err = resolve("foo://bar", Path::new(".")).unwrap_err();
let text = err.to_string();
assert!(text.contains("PI_URL_UNKNOWN_SCHEME"), "{text}");
assert!(text.contains("skill://"), "{text}");
}
#[test]
fn conflict_regions_parse_and_select() {
let repo = init_repo_with_conflict("parse");
let regions = conflict_regions(&repo).expect("regions");
assert_eq!(regions.len(), 1);
let region = ®ions[0]; assert_eq!(region.file, "f.txt");
assert!(region.ours.contains("main"));
assert!(region.theirs.contains("side"));
let doc = resolve("conflict://0", &repo).expect("full doc");
assert!(doc.content.contains("--- ours ---"));
let doc = resolve("conflict://0 @theirs", &repo).expect("theirs doc");
assert_eq!(doc.content.trim(), "side");
let doc = resolve("conflict://*", &repo).expect("bulk");
assert!(doc.content.contains("conflict 0"));
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn write_conflict_resolution_picks_a_side() {
let repo = init_repo_with_conflict("resolve");
let region = write_conflict_resolution(&repo, 0, "theirs").expect("resolve");
assert_eq!(region.file, "f.txt");
let content = std::fs::read_to_string(repo.join("f.txt")).expect("read");
assert_eq!(content.trim(), "side");
assert!(!content.contains("<<<<<<<"));
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn local_scratch_roundtrip() {
write_local("note", "scratch payload").expect("write");
let doc = resolve("local://note", Path::new(".")).expect("read");
assert_eq!(doc.content, "scratch payload");
let err = resolve("local://missing", Path::new(".")).unwrap_err();
assert!(err.to_string().contains("PI_URL_UNRESOLVABLE"));
}
#[test]
fn github_reference_parsing() {
let (repo, number, sub) = parse_repo_number("1428").expect("bare");
assert_eq!(repo, None);
assert_eq!(number, "1428");
assert_eq!(sub, None);
let (repo, number, sub) = parse_repo_number("owner/repo/1428/diff").expect("full");
assert_eq!(repo.as_deref(), Some("owner/repo"));
assert_eq!(number, "1428");
assert_eq!(sub.as_deref(), Some("diff"));
assert!(parse_repo_number("notanumber").is_err());
}
}