Skip to content

Commit 13c42a9

Browse files
committed
Add missing handler files
1 parent f64fa8f commit 13c42a9

File tree

3 files changed

+757
-0
lines changed

3 files changed

+757
-0
lines changed
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
//! Project discovery utilities for finding LeanSpec projects
2+
3+
use serde::{Deserialize, Serialize};
4+
use std::fs;
5+
use std::path::{Path, PathBuf};
6+
use thiserror::Error;
7+
8+
/// Errors that can occur during project discovery
9+
#[derive(Debug, Error)]
10+
pub enum DiscoveryError {
11+
#[error("IO error: {0}")]
12+
Io(#[from] std::io::Error),
13+
14+
#[error("Path does not exist: {0}")]
15+
PathNotFound(PathBuf),
16+
17+
#[error("Access denied: {0}")]
18+
PermissionDenied(String),
19+
}
20+
21+
/// A discovered LeanSpec project
22+
#[derive(Debug, Clone, Serialize, Deserialize)]
23+
#[serde(rename_all = "camelCase")]
24+
pub struct DiscoveredProject {
25+
/// Absolute path to the project directory
26+
pub path: PathBuf,
27+
/// Project name (from package.json or directory name)
28+
pub name: String,
29+
/// Whether this project has a .lean-spec directory
30+
pub has_lean_spec: bool,
31+
/// Path to specs directory
32+
pub specs_dir: Option<PathBuf>,
33+
}
34+
35+
/// Project discovery configuration
36+
pub struct ProjectDiscovery {
37+
/// Maximum depth to scan (default: 5)
38+
max_depth: usize,
39+
/// Directories to skip
40+
ignore_dirs: Vec<String>,
41+
}
42+
43+
impl Default for ProjectDiscovery {
44+
fn default() -> Self {
45+
Self {
46+
max_depth: 5,
47+
ignore_dirs: vec![
48+
".git".to_string(),
49+
"node_modules".to_string(),
50+
"target".to_string(),
51+
".next".to_string(),
52+
"dist".to_string(),
53+
"build".to_string(),
54+
".svn".to_string(),
55+
".hg".to_string(),
56+
"vendor".to_string(),
57+
"__pycache__".to_string(),
58+
],
59+
}
60+
}
61+
}
62+
63+
impl ProjectDiscovery {
64+
/// Create a new project discovery with default settings
65+
pub fn new() -> Self {
66+
Self::default()
67+
}
68+
69+
/// Set maximum scan depth
70+
pub fn with_max_depth(mut self, max_depth: usize) -> Self {
71+
self.max_depth = max_depth;
72+
self
73+
}
74+
75+
/// Add a directory to ignore
76+
pub fn with_ignore_dir(mut self, dir: String) -> Self {
77+
self.ignore_dirs.push(dir);
78+
self
79+
}
80+
81+
/// Discover LeanSpec projects starting from a path
82+
pub fn discover<P: AsRef<Path>>(
83+
&self,
84+
start_path: P,
85+
) -> Result<Vec<DiscoveredProject>, DiscoveryError> {
86+
let start_path = start_path.as_ref();
87+
88+
if !start_path.exists() {
89+
return Err(DiscoveryError::PathNotFound(start_path.to_path_buf()));
90+
}
91+
92+
let mut projects = Vec::new();
93+
self.scan_directory(start_path, 0, &mut projects)?;
94+
95+
Ok(projects)
96+
}
97+
98+
/// Recursively scan a directory for LeanSpec projects
99+
fn scan_directory(
100+
&self,
101+
path: &Path,
102+
depth: usize,
103+
projects: &mut Vec<DiscoveredProject>,
104+
) -> Result<(), DiscoveryError> {
105+
// Stop if max depth reached
106+
if depth > self.max_depth {
107+
return Ok(());
108+
}
109+
110+
// Check if this directory is a LeanSpec project
111+
let lean_spec_dir = path.join(".lean-spec");
112+
if lean_spec_dir.exists() && lean_spec_dir.is_dir() {
113+
let name = self.extract_project_name(path)?;
114+
let specs_dir = self.find_specs_dir(path);
115+
116+
projects.push(DiscoveredProject {
117+
path: path.to_path_buf(),
118+
name,
119+
has_lean_spec: true,
120+
specs_dir,
121+
});
122+
123+
// Don't scan nested projects
124+
return Ok(());
125+
}
126+
127+
// Scan subdirectories
128+
match fs::read_dir(path) {
129+
Ok(entries) => {
130+
for entry in entries {
131+
let entry = match entry {
132+
Ok(e) => e,
133+
Err(_) => continue, // Skip entries we can't read
134+
};
135+
136+
let entry_path = entry.path();
137+
138+
// Skip if not a directory
139+
if !entry_path.is_dir() {
140+
continue;
141+
}
142+
143+
// Skip ignored directories
144+
if self.is_ignored(&entry_path) {
145+
continue;
146+
}
147+
148+
// Recursively scan subdirectory
149+
if let Err(e) = self.scan_directory(&entry_path, depth + 1, projects) {
150+
// Log error but continue scanning
151+
eprintln!("Error scanning {}: {}", entry_path.display(), e);
152+
}
153+
}
154+
}
155+
Err(e) => {
156+
// Return permission denied error only if it's the start directory
157+
if depth == 0 {
158+
return Err(DiscoveryError::PermissionDenied(e.to_string()));
159+
}
160+
// Otherwise, just skip this directory
161+
}
162+
}
163+
164+
Ok(())
165+
}
166+
167+
/// Check if a directory should be ignored
168+
fn is_ignored(&self, path: &Path) -> bool {
169+
if let Some(name) = path.file_name() {
170+
if let Some(name_str) = name.to_str() {
171+
// Skip hidden directories (starting with .)
172+
if name_str.starts_with('.') {
173+
return true;
174+
}
175+
176+
// Skip ignored directories
177+
if self.ignore_dirs.contains(&name_str.to_string()) {
178+
return true;
179+
}
180+
}
181+
}
182+
183+
false
184+
}
185+
186+
/// Extract project name from directory
187+
fn extract_project_name(&self, path: &Path) -> Result<String, DiscoveryError> {
188+
// Try to read package.json
189+
let package_json_path = path.join("package.json");
190+
if package_json_path.exists() {
191+
if let Ok(content) = fs::read_to_string(&package_json_path) {
192+
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
193+
if let Some(name) = json.get("name").and_then(|n| n.as_str()) {
194+
return Ok(name.to_string());
195+
}
196+
}
197+
}
198+
}
199+
200+
// Try to read Cargo.toml
201+
let cargo_toml_path = path.join("Cargo.toml");
202+
if cargo_toml_path.exists() {
203+
if let Ok(content) = fs::read_to_string(&cargo_toml_path) {
204+
// Simple TOML parsing for name field
205+
for line in content.lines() {
206+
if line.trim().starts_with("name") {
207+
if let Some(name) = line.split('=').nth(1) {
208+
let name = name.trim().trim_matches('"').trim_matches('\'');
209+
return Ok(name.to_string());
210+
}
211+
}
212+
}
213+
}
214+
}
215+
216+
// Fall back to directory name
217+
Ok(path
218+
.file_name()
219+
.and_then(|n| n.to_str())
220+
.unwrap_or("unknown")
221+
.to_string())
222+
}
223+
224+
/// Find the specs directory in a project
225+
fn find_specs_dir(&self, project_path: &Path) -> Option<PathBuf> {
226+
// Try common locations
227+
let specs_dir = project_path.join("specs");
228+
if specs_dir.exists() && specs_dir.is_dir() {
229+
return Some(specs_dir);
230+
}
231+
232+
let docs_specs = project_path.join("docs").join("specs");
233+
if docs_specs.exists() && docs_specs.is_dir() {
234+
return Some(docs_specs);
235+
}
236+
237+
None
238+
}
239+
}
240+
241+
#[cfg(test)]
242+
mod tests {
243+
use super::*;
244+
use std::fs;
245+
use tempfile::TempDir;
246+
247+
fn create_leanspec_project(dir: &Path, name: &str, with_package_json: bool) -> PathBuf {
248+
let project_dir = dir.join(name);
249+
fs::create_dir_all(&project_dir).unwrap();
250+
251+
// Create .lean-spec directory
252+
fs::create_dir_all(project_dir.join(".lean-spec")).unwrap();
253+
254+
// Create specs directory
255+
fs::create_dir_all(project_dir.join("specs")).unwrap();
256+
257+
if with_package_json {
258+
let package_json = serde_json::json!({
259+
"name": name,
260+
"version": "1.0.0"
261+
});
262+
fs::write(
263+
project_dir.join("package.json"),
264+
serde_json::to_string_pretty(&package_json).unwrap(),
265+
)
266+
.unwrap();
267+
}
268+
269+
project_dir
270+
}
271+
272+
#[test]
273+
fn test_discover_single_project() {
274+
let temp_dir = TempDir::new().unwrap();
275+
create_leanspec_project(temp_dir.path(), "test-project", true);
276+
277+
let discovery = ProjectDiscovery::new();
278+
let projects = discovery.discover(temp_dir.path()).unwrap();
279+
280+
assert_eq!(projects.len(), 1);
281+
assert_eq!(projects[0].name, "test-project");
282+
assert!(projects[0].has_lean_spec);
283+
assert!(projects[0].specs_dir.is_some());
284+
}
285+
286+
#[test]
287+
fn test_discover_multiple_projects() {
288+
let temp_dir = TempDir::new().unwrap();
289+
create_leanspec_project(temp_dir.path(), "project-1", true);
290+
create_leanspec_project(temp_dir.path(), "project-2", true);
291+
292+
let discovery = ProjectDiscovery::new();
293+
let projects = discovery.discover(temp_dir.path()).unwrap();
294+
295+
assert_eq!(projects.len(), 2);
296+
assert!(projects.iter().any(|p| p.name == "project-1"));
297+
assert!(projects.iter().any(|p| p.name == "project-2"));
298+
}
299+
300+
#[test]
301+
fn test_discover_nested_projects() {
302+
let temp_dir = TempDir::new().unwrap();
303+
let parent = create_leanspec_project(temp_dir.path(), "parent-project", true);
304+
305+
// Create nested project (should be ignored)
306+
create_leanspec_project(&parent, "nested-project", true);
307+
308+
let discovery = ProjectDiscovery::new();
309+
let projects = discovery.discover(temp_dir.path()).unwrap();
310+
311+
// Should only find the parent project, not the nested one
312+
assert_eq!(projects.len(), 1);
313+
assert_eq!(projects[0].name, "parent-project");
314+
}
315+
316+
#[test]
317+
fn test_ignore_patterns() {
318+
let temp_dir = TempDir::new().unwrap();
319+
320+
// Create project in regular directory
321+
create_leanspec_project(temp_dir.path(), "regular-project", true);
322+
323+
// Create project in node_modules (should be ignored)
324+
let node_modules = temp_dir.path().join("node_modules");
325+
fs::create_dir_all(&node_modules).unwrap();
326+
create_leanspec_project(&node_modules, "ignored-project", true);
327+
328+
let discovery = ProjectDiscovery::new();
329+
let projects = discovery.discover(temp_dir.path()).unwrap();
330+
331+
assert_eq!(projects.len(), 1);
332+
assert_eq!(projects[0].name, "regular-project");
333+
}
334+
335+
#[test]
336+
fn test_max_depth() {
337+
let temp_dir = TempDir::new().unwrap();
338+
339+
// Create deeply nested structure
340+
let level1 = temp_dir.path().join("level1");
341+
let level2 = level1.join("level2");
342+
let level3 = level2.join("level3");
343+
fs::create_dir_all(&level3).unwrap();
344+
345+
create_leanspec_project(&level3, "deep-project", true);
346+
347+
// With max_depth=2, should not find the project
348+
let discovery = ProjectDiscovery::new().with_max_depth(2);
349+
let projects = discovery.discover(temp_dir.path()).unwrap();
350+
assert_eq!(projects.len(), 0);
351+
352+
// With max_depth=5, should find it
353+
let discovery = ProjectDiscovery::new().with_max_depth(5);
354+
let projects = discovery.discover(temp_dir.path()).unwrap();
355+
assert_eq!(projects.len(), 1);
356+
}
357+
358+
#[test]
359+
fn test_extract_name_without_package_json() {
360+
let temp_dir = TempDir::new().unwrap();
361+
let project_path = create_leanspec_project(temp_dir.path(), "no-package-json", false);
362+
363+
let discovery = ProjectDiscovery::new();
364+
let name = discovery.extract_project_name(&project_path).unwrap();
365+
366+
assert_eq!(name, "no-package-json");
367+
}
368+
}

0 commit comments

Comments
 (0)