1use std::path::{Path, PathBuf};
7
8use anyhow::{anyhow, bail, Result};
9
10use crate::model::SchemaRecord;
11
12pub mod introspect;
13pub mod sdl;
14
15pub fn load(source: &str) -> Result<Vec<SchemaRecord>> {
17 if source.starts_with("http://") || source.starts_with("https://") {
18 introspect::from_url(source)
19 } else if source.ends_with(".json") {
20 introspect::from_json_file(source)
21 } else {
22 let text =
23 std::fs::read_to_string(source).map_err(|e| anyhow!("reading {source}: {e}"))?;
24 sdl::from_sdl(&text)
25 }
26}
27
28const NOISE_DIRS: &[&str] = &[
29 ".git",
30 "node_modules",
31 "target",
32 "vendor",
33 "tmp",
34 "dist",
35 "build",
36 ".venv",
37];
38const MAX_DEPTH: usize = 12;
39
40pub fn discover() -> Result<String> {
45 let root = std::env::current_dir()?;
46 let mut found: Vec<Candidate> = Vec::new();
47 walk(&root, 0, &mut found);
48
49 if found.is_empty() {
50 bail!(
51 "no GraphQL schema found under {} — pass a .graphql file or an http(s) URL",
52 root.display()
53 );
54 }
55
56 found.sort_by(|a, b| {
57 a.tier
58 .cmp(&b.tier)
59 .then(a.depth.cmp(&b.depth))
60 .then(a.path.cmp(&b.path))
61 });
62 let chosen = found.remove(0);
63
64 let elsewhere = found
65 .iter()
66 .filter(|c| c.path.parent() != chosen.path.parent())
67 .count();
68 eprintln!("gqls: using schema {}", rel(&root, &chosen.path));
69 if elsewhere > 0 {
70 eprintln!("gqls: {elsewhere} other schema file(s) found elsewhere — pass a path to pick one");
71 }
72
73 Ok(chosen.path.to_string_lossy().into_owned())
74}
75
76struct Candidate {
77 path: PathBuf,
78 depth: usize,
79 tier: u8,
81}
82
83fn walk(dir: &Path, depth: usize, out: &mut Vec<Candidate>) {
84 if depth > MAX_DEPTH {
85 return;
86 }
87 let Ok(entries) = std::fs::read_dir(dir) else {
88 return;
89 };
90 for entry in entries.flatten() {
91 let Ok(ft) = entry.file_type() else { continue };
92 let path = entry.path();
93 if ft.is_dir() {
94 let name = entry.file_name();
95 let name = name.to_string_lossy();
96 if name.starts_with('.') || NOISE_DIRS.contains(&name.as_ref()) {
97 continue;
98 }
99 walk(&path, depth + 1, out);
100 } else if ft.is_file() {
101 if let Some(tier) = classify(&path) {
102 out.push(Candidate { path, depth, tier });
103 }
104 }
105 }
106}
107
108fn classify(path: &Path) -> Option<u8> {
111 let name = path.file_name()?.to_string_lossy().to_lowercase();
112 if name.ends_with(".graphqls") {
113 return Some(0);
114 }
115 let sdl_ext = name.ends_with(".graphql") || name.ends_with(".gql");
116 if sdl_ext && name.starts_with("schema.") {
117 return Some(1);
118 }
119 if name.ends_with(".json") && sniff_is_introspection(path) {
120 return Some(2);
121 }
122 if sdl_ext && sniff_is_schema(path) {
123 return Some(3);
124 }
125 None
126}
127
128fn sniff_is_introspection(path: &Path) -> bool {
131 use std::io::Read;
132 let Ok(f) = std::fs::File::open(path) else {
133 return false;
134 };
135 let mut head = [0u8; 4096];
136 let n = std::io::BufReader::new(f).read(&mut head).unwrap_or(0);
137 let s = String::from_utf8_lossy(&head[..n]);
138 s.contains("__schema") || s.contains("\"queryType\"")
139}
140
141fn sniff_is_schema(path: &Path) -> bool {
145 let Ok(content) = std::fs::read_to_string(path) else {
146 return false;
147 };
148 content.lines().any(|l| {
149 let t = l.trim_start();
150 t.starts_with("type ")
151 || t.starts_with("interface ")
152 || t.starts_with("input ")
153 || t.starts_with("enum ")
154 || t.starts_with("scalar ")
155 || t.starts_with("union ")
156 || t.starts_with("directive ")
157 || t.starts_with("schema ")
158 || t.starts_with("schema{")
159 })
160}
161
162fn rel(root: &Path, p: &Path) -> String {
163 p.strip_prefix(root)
164 .unwrap_or(p)
165 .to_string_lossy()
166 .into_owned()
167}