#![allow(dead_code)]
use std::borrow::Cow;
use serde::Serialize;
use crate::error_codes;
use crate::meta::REPO_SLUG;
use crate::net::url_encode::encode_unreserved_into;
use crate::tools::spec;
const TEMPLATE_FILE: &str = "tool_request.yml";
pub use jarvy_templates::MAX_TOOL_NAME_LEN;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RequestChannel {
Sent,
WillSend,
Manual,
}
pub fn pick_channel(telemetry_enabled: bool) -> RequestChannel {
if telemetry_enabled {
RequestChannel::WillSend
} else {
RequestChannel::Manual
}
}
#[derive(Debug, Clone, Serialize)]
pub struct UnsupportedToolReport {
pub kind: &'static str,
pub tool: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub suggestions: Vec<String>,
pub channel: &'static str,
pub fallback_issue_url: String,
pub scaffold_cmd: String,
pub exit_code: i32,
}
pub fn build_report(
tool: &str,
version: Option<&str>,
channel: RequestChannel,
) -> UnsupportedToolReport {
let safe_tool = cow_into_string(sanitize_for_display(tool));
let safe_version = version.map(|v| cow_into_string(sanitize_for_display(v)));
UnsupportedToolReport {
kind: "unsupported_tool",
suggestions: fuzzy_suggest(&safe_tool, 3),
channel: match channel {
RequestChannel::Sent | RequestChannel::WillSend => "telemetry",
RequestChannel::Manual => "manual",
},
fallback_issue_url: issue_url(&safe_tool, safe_version.as_deref()),
scaffold_cmd: format!("cargo run -p cargo-jarvy -- new-tool {}", safe_tool),
exit_code: error_codes::TOOL_UNSUPPORTED,
tool: safe_tool,
version: safe_version,
}
}
pub use jarvy_templates::validate_tool_name;
pub fn sanitize_for_display(input: &str) -> Cow<'_, str> {
let needs_strip = input.len() > MAX_TOOL_NAME_LEN || input.chars().any(is_unsafe_for_display);
if !needs_strip {
return Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len().min(MAX_TOOL_NAME_LEN));
for c in input.chars() {
if out.len() >= MAX_TOOL_NAME_LEN {
out.push('…');
break;
}
if is_unsafe_for_display(c) {
out.push('?');
} else {
out.push(c);
}
}
Cow::Owned(out)
}
fn is_unsafe_for_display(c: char) -> bool {
let u = c as u32;
c.is_control()
|| matches!(u, 0x2028 | 0x2029)
|| matches!(u, 0x200B..=0x200F)
|| matches!(u, 0x202A..=0x202E)
|| matches!(u, 0x2066..=0x2069)
|| matches!(u, 0xFFF9..=0xFFFB)
}
pub fn fuzzy_suggest(query: &str, limit: usize) -> Vec<String> {
if query.is_empty() || limit == 0 {
return Vec::new();
}
let q_cow: Cow<'_, str> = if query.bytes().any(|b| b.is_ascii_uppercase()) {
Cow::Owned(query.to_ascii_lowercase())
} else {
Cow::Borrowed(query)
};
let q: &str = q_cow.as_ref();
let cutoff = std::cmp::max(2, q.len() / 2);
let mut prev: Vec<usize> = Vec::with_capacity(32);
let mut curr: Vec<usize> = Vec::with_capacity(32);
let mut scored: Vec<(usize, &'static str)> = Vec::with_capacity(8);
for name in spec::iter_tool_names() {
let len_gap = (q.len() as isize - name.len() as isize).unsigned_abs();
if len_gap > cutoff {
continue;
}
let mut d = levenshtein(q, name, &mut prev, &mut curr);
if name.starts_with(q) || q.starts_with(name) {
d /= 2;
}
if d <= cutoff {
scored.push((d, name));
}
}
scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
scored
.into_iter()
.take(limit)
.map(|(_, n)| n.to_string())
.collect()
}
pub fn issue_url(tool: &str, version: Option<&str>) -> String {
let mut url = String::with_capacity(256);
url.push_str("https://github.com/");
url.push_str(REPO_SLUG);
url.push_str("/issues/new?template=");
url.push_str(TEMPLATE_FILE);
url.push_str("&labels=tool-request,needs-triage&title=");
encode_unreserved_into(&mut url, "[Tool]: ");
encode_unreserved_into(&mut url, tool);
url.push_str("&tool_name=");
encode_unreserved_into(&mut url, tool);
if let Some(v) = version {
url.push_str("&use_case=");
encode_unreserved_into(&mut url, "Requested version: ");
encode_unreserved_into(&mut url, v);
encode_unreserved_into(&mut url, " (auto-filed by `jarvy setup`).");
}
url
}
pub fn to_human(report: &UnsupportedToolReport, channel: RequestChannel, seamless: bool) -> String {
use std::fmt::Write as _;
let cap = match channel {
RequestChannel::Manual => 768,
_ => 384,
};
let mut out = String::with_capacity(cap);
let _ = writeln!(
out,
"[jarvy] tool `{}` is not in the Jarvy registry.",
report.tool
);
if !report.suggestions.is_empty() {
out.push_str(" Did you mean: ");
out.push_str(&report.suggestions.join(", "));
out.push_str("?\n");
}
match channel {
RequestChannel::Sent => {
out.push_str(" Reported via telemetry — no further action needed.\n");
}
RequestChannel::WillSend => {
out.push_str(" Reporting via telemetry.\n");
}
RequestChannel::Manual => {
out.push_str(" Telemetry off — please file a tool request (pre-filled):\n");
out.push_str(" ");
out.push_str(&report.fallback_issue_url);
out.push('\n');
if !seamless {
out.push_str(
" Or enable telemetry once with `jarvy telemetry enable` to skip the form.\n",
);
}
}
}
if validate_tool_name(&report.tool).is_ok() {
out.push_str(" Scaffold locally: ");
out.push_str(&report.scaffold_cmd);
out.push('\n');
} else {
out.push_str(
" (Scaffold command suppressed — tool name contains unsafe characters.)\n",
);
}
out
}
pub fn to_json(report: &UnsupportedToolReport) -> String {
serde_json::to_string(report)
.unwrap_or_else(|_| r#"{"kind":"unsupported_tool","error":"serialize_failed"}"#.to_string())
}
pub fn scaffold_snippet(tool: &str) -> String {
spec::render_tool_template(tool, None)
}
fn cow_into_string(cow: Cow<'_, str>) -> String {
match cow {
Cow::Owned(s) => s,
Cow::Borrowed(s) => s.to_string(),
}
}
fn levenshtein(a: &str, b: &str, prev: &mut Vec<usize>, curr: &mut Vec<usize>) -> usize {
let (a, b) = if a.len() < b.len() { (b, a) } else { (a, b) };
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
let n = b_bytes.len();
if n == 0 {
return a_bytes.len();
}
prev.clear();
prev.extend(0..=n);
curr.clear();
curr.resize(n + 1, 0);
for (i, &ac) in a_bytes.iter().enumerate() {
curr[0] = i + 1;
for (j, &bc) in b_bytes.iter().enumerate() {
let cost = usize::from(ac != bc);
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
}
std::mem::swap(prev, curr);
}
prev[n]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pick_channel_table() {
assert_eq!(pick_channel(true), RequestChannel::WillSend);
assert_eq!(pick_channel(false), RequestChannel::Manual);
}
#[test]
fn validate_tool_name_accepts_canonical_shapes() {
assert!(validate_tool_name("git").is_ok());
assert!(validate_tool_name("docker-compose").is_ok());
assert!(validate_tool_name("k3s.io").is_ok());
assert!(validate_tool_name("my_tool_2").is_ok());
}
#[test]
fn validate_tool_name_rejects_injection_attempts() {
assert!(validate_tool_name("").is_err());
assert!(validate_tool_name("foo\"); panic!(\"x").is_err());
assert!(validate_tool_name("foo bar").is_err()); assert!(validate_tool_name("foo\nbar").is_err()); assert!(validate_tool_name("foo;rm").is_err()); assert!(validate_tool_name(&"a".repeat(65)).is_err()); }
#[test]
fn sanitize_for_display_passes_through_clean_input() {
let s = sanitize_for_display("git");
assert!(matches!(s, Cow::Borrowed(_)));
assert_eq!(s.as_ref(), "git");
}
#[test]
fn sanitize_for_display_strips_ansi_and_control_bytes() {
let s = sanitize_for_display("\x1b[2J\x1b[31mevil\x1b[0m");
assert!(matches!(s, Cow::Owned(_)));
assert!(!s.contains('\x1b'));
assert!(!s.contains('\r'));
}
#[test]
fn sanitize_for_display_strips_zero_width_homoglyph() {
let s = sanitize_for_display("g\u{200B}it");
assert!(matches!(s, Cow::Owned(_)));
assert!(!s.contains('\u{200B}'), "zero-width survived: {:?}", s);
}
#[test]
fn sanitize_for_display_strips_rtl_override() {
let s = sanitize_for_display("a\u{202E}b");
assert!(matches!(s, Cow::Owned(_)));
assert!(!s.contains('\u{202E}'));
}
#[test]
fn sanitize_for_display_strips_line_separator() {
let s = sanitize_for_display("a\u{2028}b");
assert!(matches!(s, Cow::Owned(_)));
assert!(!s.contains('\u{2028}'));
}
#[test]
fn sanitize_for_display_caps_length_exact() {
let long = "a".repeat(200);
let s = sanitize_for_display(&long);
assert_eq!(
s.len(),
MAX_TOOL_NAME_LEN + '…'.len_utf8(),
"length-cap math: {}",
s.len()
);
assert!(s.ends_with('…'), "tail: {:?}", s);
}
#[test]
fn sanitize_for_display_exact_max_len_is_borrowed() {
let exact = "a".repeat(MAX_TOOL_NAME_LEN);
let s = sanitize_for_display(&exact);
assert!(matches!(s, Cow::Borrowed(_)));
assert_eq!(s.len(), MAX_TOOL_NAME_LEN);
}
#[test]
fn levenshtein_basic() {
let mut prev = Vec::new();
let mut curr = Vec::new();
assert_eq!(levenshtein("git", "git", &mut prev, &mut curr), 0);
assert_eq!(levenshtein("gti", "git", &mut prev, &mut curr), 2);
assert_eq!(levenshtein("docker", "docke", &mut prev, &mut curr), 1);
assert_eq!(levenshtein("", "abc", &mut prev, &mut curr), 3);
}
#[test]
fn issue_url_contains_template_and_tool() {
let url = issue_url("kubectl", Some("1.30"));
assert!(url.contains("template=tool_request.yml"));
assert!(url.contains("tool_name=kubectl"));
assert!(url.contains("title=%5BTool%5D%3A%20kubectl"));
assert!(url.contains("use_case="));
assert!(url.contains("bearbinary/jarvy"));
}
#[test]
fn build_report_carries_exit_code_and_channel_tag() {
let r = build_report("definitely-not-a-real-tool", None, RequestChannel::Sent);
assert_eq!(r.kind, "unsupported_tool");
assert_eq!(r.exit_code, error_codes::TOOL_UNSUPPORTED);
assert!(r.scaffold_cmd.contains("definitely-not-a-real-tool"));
assert_eq!(r.channel, "telemetry");
}
#[test]
fn build_report_manual_channel_when_telemetry_off() {
let r = build_report("foo", None, RequestChannel::Manual);
assert_eq!(r.channel, "manual");
}
#[test]
fn build_report_sanitizes_tool_name_into_output() {
let r = build_report("\x1b[31mevil", None, RequestChannel::Sent);
assert!(!r.tool.contains('\x1b'));
assert!(!r.scaffold_cmd.contains('\x1b'));
}
#[test]
fn fuzzy_suggest_finds_close_match() {
let s = fuzzy_suggest("gti", 3);
assert!(s.contains(&"git".to_string()), "got: {:?}", s);
}
#[test]
fn fuzzy_suggest_prefix_boost_ranks_first() {
let s = fuzzy_suggest("dock", 5);
assert_eq!(
s.first().map(String::as_str),
Some("docker"),
"got: {:?}",
s
);
}
#[test]
fn fuzzy_suggest_empty_query_returns_empty() {
assert!(fuzzy_suggest("", 3).is_empty());
}
#[test]
fn fuzzy_suggest_limit_zero_returns_empty() {
assert!(fuzzy_suggest("git", 0).is_empty());
}
#[test]
fn scaffold_snippet_matches_canonical_template() {
let s = scaffold_snippet("foo");
assert!(s.contains("define_tool!(FOO,"));
assert!(s.contains("command: \"foo\""));
assert!(
!s.contains("__PKG_BSD__"),
"all placeholders must be substituted; got: {}",
s
);
}
#[test]
fn to_json_carries_canonical_fields() {
let r = build_report("xyz", Some("1.0"), RequestChannel::Sent);
let v: serde_json::Value = serde_json::from_str(&to_json(&r)).unwrap();
assert_eq!(v["kind"], "unsupported_tool");
assert_eq!(v["tool"], "xyz");
assert_eq!(v["exit_code"], 8);
assert_eq!(v["channel"], "telemetry");
assert!(v.get("docs_url").is_none());
}
#[test]
fn to_human_telemetry_send_omits_url() {
let r = build_report("foo", None, RequestChannel::Sent);
let s = to_human(&r, RequestChannel::Sent, false);
assert!(s.contains("Reported via telemetry"));
assert!(
!s.contains("github.com"),
"URL should not appear when telemetry handles the request: {}",
s
);
}
#[test]
fn to_human_manual_shows_url_and_enable_hint() {
let r = build_report("foo", None, RequestChannel::Manual);
let s = to_human(&r, RequestChannel::Manual, false);
assert!(s.contains("Telemetry off"));
assert!(s.contains("github.com"));
assert!(s.contains("please file a tool request"));
assert!(s.contains("pre-filled"));
assert!(s.contains("jarvy telemetry enable"));
}
#[test]
fn to_human_manual_in_seamless_suppresses_enable_hint() {
let r = build_report("foo", None, RequestChannel::Manual);
let s = to_human(&r, RequestChannel::Manual, true);
assert!(s.contains("Telemetry off"));
assert!(s.contains("github.com"));
assert!(
!s.contains("jarvy telemetry enable"),
"seamless mode must hide the enable-telemetry hint: {}",
s
);
}
}