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