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