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)
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) {
37 return Ok(records);
38 }
39 }
40 let records = sdl::from_sdl(&text)?;
41 record_cache::store(&text, &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::status!("using schema {}", rel(&root, &chosen.path));
90 if elsewhere > 0 {
91 crate::status!(
92 "{elsewhere} other schema file(s) found elsewhere — pass a path to pick one"
93 );
94 }
95
96 Ok(chosen.path.to_string_lossy().into_owned())
97}
98
99struct Candidate {
100 path: PathBuf,
101 depth: usize,
102 tier: u8,
104 supergraph: bool,
107}
108
109fn walk(dir: &Path, depth: usize, out: &mut Vec<Candidate>) {
110 if depth > MAX_DEPTH {
111 return;
112 }
113 let Ok(entries) = std::fs::read_dir(dir) else {
114 return;
115 };
116 for entry in entries.flatten() {
117 let Ok(ft) = entry.file_type() else { continue };
118 let path = entry.path();
119 if ft.is_dir() {
120 let name = entry.file_name();
121 let name = name.to_string_lossy();
122 if name.starts_with('.') || NOISE_DIRS.contains(&name.as_ref()) {
123 continue;
124 }
125 walk(&path, depth + 1, out);
126 } else if ft.is_file() {
127 if let Some(tier) = classify(&path) {
128 let supergraph = path
129 .file_name()
130 .is_some_and(|n| n.to_string_lossy().to_lowercase().starts_with("supergraph"));
131 out.push(Candidate {
132 path,
133 depth,
134 tier,
135 supergraph,
136 });
137 }
138 }
139 }
140}
141
142fn classify(path: &Path) -> Option<u8> {
145 let name = path.file_name()?.to_string_lossy().to_lowercase();
146 if name.ends_with(".graphqls") {
147 return Some(0);
148 }
149 let sdl_ext = name.ends_with(".graphql") || name.ends_with(".gql");
150 if sdl_ext && name.starts_with("schema.") {
151 return Some(1);
152 }
153 if name.ends_with(".json") && sniff_is_introspection(path) {
154 return Some(2);
155 }
156 if sdl_ext && sniff_is_schema(path) {
157 return Some(3);
158 }
159 None
160}
161
162fn sniff_is_introspection(path: &Path) -> bool {
165 use std::io::Read;
166 let Ok(f) = std::fs::File::open(path) else {
167 return false;
168 };
169 let mut head = [0u8; 4096];
170 let n = std::io::BufReader::new(f).read(&mut head).unwrap_or(0);
171 let s = String::from_utf8_lossy(&head[..n]);
172 s.contains("__schema") || s.contains("\"queryType\"")
173}
174
175fn sniff_is_schema(path: &Path) -> bool {
179 let Ok(content) = std::fs::read_to_string(path) else {
180 return false;
181 };
182 content.lines().any(|l| {
183 let t = l.trim_start();
184 t.starts_with("type ")
185 || t.starts_with("interface ")
186 || t.starts_with("input ")
187 || t.starts_with("enum ")
188 || t.starts_with("scalar ")
189 || t.starts_with("union ")
190 || t.starts_with("directive ")
191 || t.starts_with("schema ")
192 || t.starts_with("schema{")
193 })
194}
195
196fn rel(root: &Path, p: &Path) -> String {
197 p.strip_prefix(root)
198 .unwrap_or(p)
199 .to_string_lossy()
200 .into_owned()
201}