1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use ought_spec::{Keyword, Section, SpecGraph};
5
6use crate::types::{SurveyResult, UncoveredBehavior};
7
8pub fn survey(
13 specs: &SpecGraph,
14 paths: &[PathBuf],
15) -> anyhow::Result<SurveyResult> {
16 let mut covered_texts: HashSet<String> = HashSet::new();
18 let mut spec_source_roots: Vec<PathBuf> = Vec::new();
19
20 for spec in specs.specs() {
21 collect_clause_texts(&spec.sections, &mut covered_texts);
22 for src in &spec.metadata.sources {
24 let base = spec
25 .source_path
26 .parent()
27 .unwrap_or(std::path::Path::new("."));
28 spec_source_roots.push(base.join(src));
29 }
30 }
31
32 let scan_paths: Vec<PathBuf> = if paths.is_empty() {
34 spec_source_roots
35 } else {
36 paths.to_vec()
37 };
38
39 let mut uncovered: Vec<UncoveredBehavior> = Vec::new();
41
42 for path in &scan_paths {
43 if path.is_file() {
44 if let Ok(content) = std::fs::read_to_string(path) {
45 extract_uncovered_from_file(path, &content, &covered_texts, specs, &mut uncovered);
46 }
47 } else if path.is_dir() {
48 walk_source_dir(path, &covered_texts, specs, &mut uncovered);
49 }
50 }
51
52 uncovered.sort_by(|a, b| {
54 let a_pub = a.description.contains("public") || a.suggested_keyword == Keyword::Must;
55 let b_pub = b.description.contains("public") || b.suggested_keyword == Keyword::Must;
56 b_pub.cmp(&a_pub)
57 });
58
59 uncovered.sort_by(|a, b| a.suggested_spec.cmp(&b.suggested_spec));
61
62 let mut grouped: Vec<UncoveredBehavior> = Vec::new();
64 let mut current_spec: Option<PathBuf> = None;
65 let mut current_group: Vec<UncoveredBehavior> = Vec::new();
66
67 for item in uncovered {
68 if current_spec.as_ref() != Some(&item.suggested_spec) {
69 current_group.sort_by(|a, b| {
71 let a_pub =
72 a.description.contains("public") || a.suggested_keyword == Keyword::Must;
73 let b_pub =
74 b.description.contains("public") || b.suggested_keyword == Keyword::Must;
75 b_pub.cmp(&a_pub)
76 });
77 grouped.append(&mut current_group);
78 current_spec = Some(item.suggested_spec.clone());
79 }
80 current_group.push(item);
81 }
82 current_group.sort_by(|a, b| {
84 let a_pub = a.description.contains("public") || a.suggested_keyword == Keyword::Must;
85 let b_pub = b.description.contains("public") || b.suggested_keyword == Keyword::Must;
86 b_pub.cmp(&a_pub)
87 });
88 grouped.append(&mut current_group);
89
90 Ok(SurveyResult {
91 uncovered: grouped,
92 })
93}
94
95fn collect_clause_texts(sections: &[Section], texts: &mut HashSet<String>) {
97 for section in sections {
98 for clause in §ion.clauses {
99 texts.insert(clause.text.to_lowercase());
100 for ow in &clause.otherwise {
102 texts.insert(ow.text.to_lowercase());
103 }
104 }
105 collect_clause_texts(§ion.subsections, texts);
106 }
107}
108
109fn walk_source_dir(
111 dir: &std::path::Path,
112 covered_texts: &HashSet<String>,
113 specs: &SpecGraph,
114 uncovered: &mut Vec<UncoveredBehavior>,
115) {
116 let entries = match std::fs::read_dir(dir) {
117 Ok(e) => e,
118 Err(_) => return,
119 };
120 for entry in entries.flatten() {
121 let path = entry.path();
122 if path.is_dir() {
123 if let Some(name) = path.file_name().and_then(|n| n.to_str())
125 && matches!(
126 name,
127 "target"
128 | "node_modules"
129 | ".git"
130 | "__pycache__"
131 | "vendor"
132 | ".venv"
133 | "venv"
134 ) {
135 continue;
136 }
137 walk_source_dir(&path, covered_texts, specs, uncovered);
138 } else if is_source_file(&path)
139 && let Ok(content) = std::fs::read_to_string(&path) {
140 extract_uncovered_from_file(&path, &content, covered_texts, specs, uncovered);
141 }
142 }
143}
144
145fn is_source_file(path: &std::path::Path) -> bool {
147 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
148 matches!(
149 ext,
150 "rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "rb" | "kt" | "swift" | "c"
151 | "cpp" | "h" | "hpp"
152 )
153}
154
155fn extract_uncovered_from_file(
157 file: &std::path::Path,
158 content: &str,
159 covered_texts: &HashSet<String>,
160 specs: &SpecGraph,
161 uncovered: &mut Vec<UncoveredBehavior>,
162) {
163 let suggested_spec = infer_spec_path(file, specs);
164
165 for (line_num_0, line) in content.lines().enumerate() {
166 let trimmed = line.trim();
167 if let Some(fn_name) = extract_public_fn_name(trimmed) {
168 let fn_lower = fn_name.to_lowercase();
170 let fn_words = fn_lower.replace('_', " ");
171 let is_covered = covered_texts
172 .iter()
173 .any(|text| text.contains(&fn_lower) || text.contains(&fn_words));
174
175 if !is_covered {
176 let is_public = trimmed.starts_with("pub ")
177 || trimmed.starts_with("export ")
178 || trimmed.starts_with("def ")
179 || trimmed.starts_with("func ");
180 let (keyword, desc_prefix) = if is_public {
181 (Keyword::Must, "public")
182 } else {
183 (Keyword::Should, "private")
184 };
185
186 uncovered.push(UncoveredBehavior {
187 file: file.to_path_buf(),
188 line: line_num_0 + 1,
189 description: format!(
190 "{} function `{}` has no corresponding spec clause",
191 desc_prefix, fn_name
192 ),
193 suggested_clause: format!(
194 "{} handle {} correctly",
195 if keyword == Keyword::Must {
196 "MUST"
197 } else {
198 "SHOULD"
199 },
200 fn_name.replace('_', " ")
201 ),
202 suggested_keyword: keyword,
203 suggested_spec: suggested_spec.clone(),
204 });
205 }
206 }
207 }
208}
209
210fn extract_public_fn_name(line: &str) -> Option<String> {
212 if let Some(rest) = line
214 .strip_prefix("pub fn ")
215 .or_else(|| line.strip_prefix("pub async fn "))
216 .or_else(|| line.strip_prefix("pub(crate) fn "))
217 .or_else(|| line.strip_prefix("pub(super) fn "))
218 {
219 return extract_ident(rest);
220 }
221 if line.starts_with("fn ")
223 && let Some(rest) = line.strip_prefix("fn ") {
224 return extract_ident(rest);
225 }
226 if let Some(rest) = line.strip_prefix("def ") {
228 let name = extract_ident(rest);
229 if let Some(ref n) = name
231 && n.starts_with("__") && n.ends_with("__") {
232 return None;
233 }
234 return name;
235 }
236 if let Some(rest) = line
238 .strip_prefix("export function ")
239 .or_else(|| line.strip_prefix("export async function "))
240 .or_else(|| line.strip_prefix("function "))
241 .or_else(|| line.strip_prefix("async function "))
242 {
243 return extract_ident(rest);
244 }
245 if let Some(rest) = line.strip_prefix("func ") {
247 if rest.starts_with('(') {
249 if let Some(idx) = rest.find(')') {
251 let after = rest[idx + 1..].trim();
252 return extract_ident(after);
253 }
254 return None;
255 }
256 return extract_ident(rest);
257 }
258 None
259}
260
261fn extract_ident(s: &str) -> Option<String> {
263 let s = s.trim();
264 let end = s
265 .find(|c: char| !c.is_alphanumeric() && c != '_')
266 .unwrap_or(s.len());
267 if end == 0 {
268 return None;
269 }
270 Some(s[..end].to_string())
271}
272
273fn infer_spec_path(source_file: &std::path::Path, specs: &SpecGraph) -> PathBuf {
275 let source_str = source_file.to_string_lossy();
277 for spec in specs.specs() {
278 for src in &spec.metadata.sources {
279 if source_str.contains(src) || source_str.contains(&src.replace("./", "")) {
280 return spec.source_path.clone();
281 }
282 }
283 }
284 let stem = source_file
286 .file_stem()
287 .and_then(|s| s.to_str())
288 .unwrap_or("unknown");
289 PathBuf::from(format!("ought/{}.ought.md", stem))
290}