1use std::collections::HashSet;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14use anyhow::{anyhow, bail, Result};
15use serde::{Deserialize, Serialize};
16
17use crate::model::{Kind, SchemaRecord};
18
19#[derive(Debug, Deserialize, Serialize)]
21pub struct RqHit {
22 pub name: String,
23 pub file: String,
24 pub line: u64,
25 #[serde(default)]
26 pub kind: String,
27 #[serde(default)]
28 pub confidence: f64,
29 #[serde(default, skip_deserializing)]
32 pub via: String,
33 #[serde(default, skip_deserializing)]
38 pub proximity: usize,
39 #[serde(skip)]
41 candidate_rank: usize,
42}
43
44pub fn resolve(
48 rec: &SchemaRecord,
49 code_dir: Option<&str>,
50 schema_path: Option<&Path>,
51 limit: usize,
52) -> Result<Vec<RqHit>> {
53 let mut seen = HashSet::new();
56 let mut hits: Vec<RqHit> = Vec::new();
57 for (idx, cand) in candidates(rec).into_iter().enumerate() {
58 for mut hit in run_rq(&cand, code_dir)? {
59 if seen.insert(format!("{}:{}", hit.file, hit.line)) {
60 hit.via = cand.clone();
61 hit.candidate_rank = idx;
62 hits.push(hit);
63 }
64 }
65 }
66
67 if let Some(schema_dir) = schema_path
71 .and_then(|p| std::fs::canonicalize(p).ok())
72 .and_then(|p| p.parent().map(Path::to_path_buf))
73 {
74 if let Some(root) = git_toplevel(code_dir).and_then(|r| std::fs::canonicalize(r).ok()) {
75 for h in &mut hits {
76 let file_abs = root.join(&h.file);
77 let hit_dir = file_abs.parent().unwrap_or(&root);
78 h.proximity = shared_prefix(&schema_dir, hit_dir);
79 }
80 }
81 }
82
83 hits.sort_by(|a, b| {
84 b.proximity
85 .cmp(&a.proximity)
86 .then(a.candidate_rank.cmp(&b.candidate_rank))
87 .then(b.confidence.total_cmp(&a.confidence))
88 });
89 hits.truncate(limit);
90 Ok(hits)
91}
92
93fn git_toplevel(dir: Option<&str>) -> Option<PathBuf> {
96 let mut cmd = Command::new("git");
97 cmd.args(["rev-parse", "--show-toplevel"]);
98 if let Some(d) = dir {
99 cmd.current_dir(d);
100 }
101 let out = cmd.output().ok()?;
102 out.status
103 .success()
104 .then(|| PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()))
105}
106
107fn shared_prefix(a: &Path, b: &Path) -> usize {
109 a.components()
110 .zip(b.components())
111 .take_while(|(x, y)| x == y)
112 .count()
113}
114
115pub fn candidates(rec: &SchemaRecord) -> Vec<String> {
118 let field = &rec.name;
119 let snake = to_snake(field);
120 let pascal = to_pascal(field);
121 let mut c = Vec::new();
122
123 match rec.kind {
124 Kind::Mutation => {
125 c.push(format!("Mutations::{pascal}"));
126 c.push(pascal.clone());
127 c.push(format!("{pascal}Mutation"));
128 if let Some(t) = &rec.parent {
129 c.push(format!("{t}Type#{snake}"));
130 }
131 c.push(snake.clone());
132 }
133 Kind::Query | Kind::Subscription => {
134 c.push(format!("Resolvers::{pascal}"));
135 c.push(format!("{pascal}Resolver"));
136 c.push(format!("Resolvers::{pascal}Resolver"));
137 if let Some(t) = &rec.parent {
138 c.push(format!("{t}Type#{snake}"));
139 }
140 c.push(snake.clone());
141 }
142 Kind::Field | Kind::InputField => {
143 if let Some(t) = &rec.parent {
144 c.push(format!("{t}Type#{snake}"));
145 c.push(format!("{t}#{snake}"));
146 c.push(format!("Types::{t}Type#{snake}"));
147 }
148 c.push(snake.clone());
149 }
150 Kind::Object | Kind::Interface | Kind::InputObject | Kind::Union | Kind::Enum
152 | Kind::Scalar => {
153 let t = &rec.name;
154 c.push(format!("{t}Type"));
155 c.push(format!("Types::{t}"));
156 c.push(format!("Types::{t}Type"));
157 c.push(t.clone());
158 }
159 Kind::EnumValue | Kind::Directive => {
160 c.push(pascal.clone());
161 c.push(snake.clone());
162 }
163 }
164 c
165}
166
167fn run_rq(query: &str, dir: Option<&str>) -> Result<Vec<RqHit>> {
168 let mut cmd = Command::new("rq");
169 cmd.arg("--json").arg("--limit").arg("5").arg(query);
170 if let Some(d) = dir {
173 cmd.current_dir(d);
174 }
175 let output = cmd
176 .output()
177 .map_err(|e| anyhow!("running rq: {e} — is `rq` installed and on PATH?"))?;
178 match output.status.code() {
182 Some(0) | Some(1) => Ok(serde_json::from_slice(&output.stdout).unwrap_or_default()),
183 other => {
184 let msg = String::from_utf8_lossy(&output.stderr);
185 bail!(
186 "rq failed (exit {other:?}) for {query:?}: {}",
187 msg.lines().next().unwrap_or("").trim()
188 )
189 }
190 }
191}
192
193fn to_snake(s: &str) -> String {
195 let mut out = String::new();
196 for (i, c) in s.chars().enumerate() {
197 if c.is_uppercase() {
198 if i > 0 {
199 out.push('_');
200 }
201 out.extend(c.to_lowercase());
202 } else {
203 out.push(c);
204 }
205 }
206 out
207}
208
209fn to_pascal(s: &str) -> String {
211 let mut chars = s.chars();
212 match chars.next() {
213 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
214 None => String::new(),
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
223 SchemaRecord {
224 path: parent.map_or_else(|| name.into(), |p| format!("{p}.{name}")),
225 name: name.into(),
226 kind,
227 parent: parent.map(String::from),
228 type_ref: None,
229 args: vec![],
230 description: None,
231 deprecated: None,
232 directives: vec![],
233 }
234 }
235
236 #[test]
237 fn casing() {
238 assert_eq!(to_snake("nameWithOwner"), "name_with_owner");
239 assert_eq!(to_pascal("createUser"), "CreateUser");
240 }
241
242 #[test]
243 fn root_query_prefers_resolver_then_type_method() {
244 let c = candidates(&rec("user", Some("Query"), Kind::Query));
245 assert_eq!(c[0], "Resolvers::User");
246 assert!(c.contains(&"QueryType#user".to_string()));
247 }
248
249 #[test]
250 fn mutation_prefers_mutation_class() {
251 let c = candidates(&rec("createUser", Some("Mutation"), Kind::Mutation));
252 assert_eq!(c[0], "Mutations::CreateUser");
253 }
254
255 #[test]
256 fn object_field_targets_the_type_method() {
257 let c = candidates(&rec("nameWithOwner", Some("Repository"), Kind::Field));
258 assert_eq!(c[0], "RepositoryType#name_with_owner");
259 }
260}