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 b.supergraph
60 .cmp(&a.supergraph)
61 .then(a.tier.cmp(&b.tier))
62 .then(a.depth.cmp(&b.depth))
63 .then(a.path.cmp(&b.path))
64 });
65 let chosen = found.remove(0);
66
67 let elsewhere = found
68 .iter()
69 .filter(|c| c.path.parent() != chosen.path.parent())
70 .count();
71 eprintln!("gqls: using schema {}", rel(&root, &chosen.path));
72 if elsewhere > 0 {
73 eprintln!("gqls: {elsewhere} other schema file(s) found elsewhere — pass a path to pick one");
74 }
75
76 Ok(chosen.path.to_string_lossy().into_owned())
77}
78
79struct Candidate {
80 path: PathBuf,
81 depth: usize,
82 tier: u8,
84 supergraph: bool,
87}
88
89fn walk(dir: &Path, depth: usize, out: &mut Vec<Candidate>) {
90 if depth > MAX_DEPTH {
91 return;
92 }
93 let Ok(entries) = std::fs::read_dir(dir) else {
94 return;
95 };
96 for entry in entries.flatten() {
97 let Ok(ft) = entry.file_type() else { continue };
98 let path = entry.path();
99 if ft.is_dir() {
100 let name = entry.file_name();
101 let name = name.to_string_lossy();
102 if name.starts_with('.') || NOISE_DIRS.contains(&name.as_ref()) {
103 continue;
104 }
105 walk(&path, depth + 1, out);
106 } else if ft.is_file() {
107 if let Some(tier) = classify(&path) {
108 let supergraph = path
109 .file_name()
110 .is_some_and(|n| n.to_string_lossy().to_lowercase().starts_with("supergraph"));
111 out.push(Candidate {
112 path,
113 depth,
114 tier,
115 supergraph,
116 });
117 }
118 }
119 }
120}
121
122fn classify(path: &Path) -> Option<u8> {
125 let name = path.file_name()?.to_string_lossy().to_lowercase();
126 if name.ends_with(".graphqls") {
127 return Some(0);
128 }
129 let sdl_ext = name.ends_with(".graphql") || name.ends_with(".gql");
130 if sdl_ext && name.starts_with("schema.") {
131 return Some(1);
132 }
133 if name.ends_with(".json") && sniff_is_introspection(path) {
134 return Some(2);
135 }
136 if sdl_ext && sniff_is_schema(path) {
137 return Some(3);
138 }
139 None
140}
141
142fn sniff_is_introspection(path: &Path) -> bool {
145 use std::io::Read;
146 let Ok(f) = std::fs::File::open(path) else {
147 return false;
148 };
149 let mut head = [0u8; 4096];
150 let n = std::io::BufReader::new(f).read(&mut head).unwrap_or(0);
151 let s = String::from_utf8_lossy(&head[..n]);
152 s.contains("__schema") || s.contains("\"queryType\"")
153}
154
155fn sniff_is_schema(path: &Path) -> bool {
159 let Ok(content) = std::fs::read_to_string(path) else {
160 return false;
161 };
162 content.lines().any(|l| {
163 let t = l.trim_start();
164 t.starts_with("type ")
165 || t.starts_with("interface ")
166 || t.starts_with("input ")
167 || t.starts_with("enum ")
168 || t.starts_with("scalar ")
169 || t.starts_with("union ")
170 || t.starts_with("directive ")
171 || t.starts_with("schema ")
172 || t.starts_with("schema{")
173 })
174}
175
176fn rel(root: &Path, p: &Path) -> String {
177 p.strip_prefix(root)
178 .unwrap_or(p)
179 .to_string_lossy()
180 .into_owned()
181}