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