Skip to content

Commit c3736f3

Browse files
committed
feat: port rust workspace discovery primitives
1 parent b7d4795 commit c3736f3

9 files changed

Lines changed: 393 additions & 15 deletions

File tree

Lines changed: 119 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,126 @@
1-
use std::path::Path;
1+
use std::{collections::HashSet, path::Path};
22

33
use serde_json::Value;
44

5-
use crate::{error::Result, LegolasError};
5+
use crate::{error::Result, workspace::exists};
66

7-
pub fn detect_frameworks(_project_root: &Path, _manifest: &Value) -> Result<Vec<String>> {
8-
Err(LegolasError::NotImplemented("detect_frameworks"))
7+
struct FrameworkMarker {
8+
name: &'static str,
9+
packages: &'static [&'static str],
10+
files: &'static [&'static str],
911
}
1012

11-
pub fn detect_package_manager(_project_root: &Path, _manifest: &Value) -> Result<String> {
12-
Err(LegolasError::NotImplemented("detect_package_manager"))
13+
const FRAMEWORK_MARKERS: [FrameworkMarker; 9] = [
14+
FrameworkMarker {
15+
name: "Next.js",
16+
packages: &["next"],
17+
files: &["next.config.js", "next.config.mjs", "next.config.ts"],
18+
},
19+
FrameworkMarker {
20+
name: "Vite",
21+
packages: &["vite"],
22+
files: &["vite.config.js", "vite.config.ts", "vite.config.mjs"],
23+
},
24+
FrameworkMarker {
25+
name: "Webpack",
26+
packages: &["webpack"],
27+
files: &["webpack.config.js", "webpack.config.ts"],
28+
},
29+
FrameworkMarker {
30+
name: "Rollup",
31+
packages: &["rollup"],
32+
files: &["rollup.config.js", "rollup.config.mjs", "rollup.config.ts"],
33+
},
34+
FrameworkMarker {
35+
name: "Astro",
36+
packages: &["astro"],
37+
files: &["astro.config.mjs", "astro.config.ts"],
38+
},
39+
FrameworkMarker {
40+
name: "Nuxt",
41+
packages: &["nuxt"],
42+
files: &["nuxt.config.ts", "nuxt.config.js"],
43+
},
44+
FrameworkMarker {
45+
name: "React",
46+
packages: &["react"],
47+
files: &[],
48+
},
49+
FrameworkMarker {
50+
name: "Vue",
51+
packages: &["vue"],
52+
files: &[],
53+
},
54+
FrameworkMarker {
55+
name: "Svelte",
56+
packages: &["svelte", "@sveltejs/kit"],
57+
files: &[],
58+
},
59+
];
60+
61+
const PACKAGE_MANAGER_CHECKS: [(&str, &str); 5] = [
62+
("pnpm-lock.yaml", "pnpm"),
63+
("yarn.lock", "yarn"),
64+
("package-lock.json", "npm"),
65+
("bun.lockb", "bun"),
66+
("bun.lock", "bun"),
67+
];
68+
69+
pub fn detect_frameworks(project_root: &Path, manifest: &Value) -> Result<Vec<String>> {
70+
let all_dependencies = dependency_names(manifest);
71+
let mut detected = Vec::new();
72+
73+
for marker in FRAMEWORK_MARKERS {
74+
let package_hit = marker
75+
.packages
76+
.iter()
77+
.any(|package| all_dependencies.contains(*package));
78+
let file_hit = any_exists(project_root, marker.files)?;
79+
80+
if package_hit || file_hit {
81+
detected.push(marker.name.to_string());
82+
}
83+
}
84+
85+
Ok(detected)
86+
}
87+
88+
pub fn detect_package_manager(project_root: &Path, manifest: &Value) -> Result<String> {
89+
if let Some(explicit) = manifest
90+
.get("packageManager")
91+
.and_then(Value::as_str)
92+
.filter(|value| !value.is_empty())
93+
{
94+
return Ok(explicit.to_string());
95+
}
96+
97+
for (file, name) in PACKAGE_MANAGER_CHECKS {
98+
if exists(project_root.join(file))? {
99+
return Ok(name.to_string());
100+
}
101+
}
102+
103+
Ok("unknown".to_string())
104+
}
105+
106+
fn dependency_names(manifest: &Value) -> HashSet<String> {
107+
let mut dependencies = HashSet::new();
108+
109+
for field in ["dependencies", "devDependencies"] {
110+
if let Some(entries) = manifest.get(field).and_then(Value::as_object) {
111+
dependencies.extend(entries.keys().cloned());
112+
}
113+
}
114+
115+
dependencies
116+
}
117+
118+
fn any_exists(project_root: &Path, files: &[&str]) -> Result<bool> {
119+
for file in files {
120+
if exists(project_root.join(file))? {
121+
return Ok(true);
122+
}
123+
}
124+
125+
Ok(false)
13126
}
Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,112 @@
1-
use std::path::{Path, PathBuf};
1+
use std::{
2+
fs,
3+
io::ErrorKind,
4+
path::{Component, Path, PathBuf},
5+
};
26

37
use serde::de::DeserializeOwned;
48

59
use crate::{error::Result, LegolasError};
610

7-
pub fn find_project_root<P: AsRef<Path>>(_input_path: P) -> Result<PathBuf> {
8-
Err(LegolasError::NotImplemented("find_project_root"))
11+
const ROOT_MARKERS: [&str; 6] = [
12+
"package.json",
13+
"pnpm-lock.yaml",
14+
"package-lock.json",
15+
"yarn.lock",
16+
"bun.lock",
17+
"bun.lockb",
18+
];
19+
20+
pub fn find_project_root<P: AsRef<Path>>(input_path: P) -> Result<PathBuf> {
21+
let resolved = resolve_absolute(input_path.as_ref())?;
22+
let mut current = normalize_to_directory(&resolved)?;
23+
let initial_directory = current.clone();
24+
25+
loop {
26+
for marker in ROOT_MARKERS {
27+
if exists(current.join(marker))? {
28+
return Ok(current);
29+
}
30+
}
31+
32+
let Some(parent) = current.parent() else {
33+
return Ok(initial_directory);
34+
};
35+
let parent = parent.to_path_buf();
36+
37+
if parent == current {
38+
return Ok(initial_directory);
39+
}
40+
41+
current = parent;
42+
}
943
}
1044

11-
pub fn read_text_if_exists<P: AsRef<Path>>(_file_path: P) -> Result<Option<String>> {
12-
Err(LegolasError::NotImplemented("read_text_if_exists"))
45+
pub fn read_text_if_exists<P: AsRef<Path>>(file_path: P) -> Result<Option<String>> {
46+
match fs::read_to_string(file_path.as_ref()) {
47+
Ok(contents) => Ok(Some(contents)),
48+
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
49+
Err(error) => Err(error.into()),
50+
}
1351
}
1452

15-
pub fn read_json_if_exists<T, P>(_file_path: P) -> Result<Option<T>>
53+
pub fn read_json_if_exists<T, P>(file_path: P) -> Result<Option<T>>
1654
where
1755
T: DeserializeOwned,
1856
P: AsRef<Path>,
1957
{
20-
Err(LegolasError::NotImplemented("read_json_if_exists"))
58+
match read_text_if_exists(file_path)? {
59+
Some(contents) if contents.is_empty() => Ok(None),
60+
Some(contents) => Ok(Some(serde_json::from_str(&contents)?)),
61+
None => Ok(None),
62+
}
63+
}
64+
65+
pub fn exists<P: AsRef<Path>>(file_path: P) -> Result<bool> {
66+
match fs::metadata(file_path.as_ref()) {
67+
Ok(_) => Ok(true),
68+
Err(_) => Ok(false),
69+
}
2170
}
2271

23-
pub fn exists<P: AsRef<Path>>(_file_path: P) -> Result<bool> {
24-
Err(LegolasError::NotImplemented("exists"))
72+
fn normalize_to_directory(target_path: &Path) -> Result<PathBuf> {
73+
match fs::metadata(target_path) {
74+
Ok(stats) => {
75+
if stats.is_dir() {
76+
Ok(target_path.to_path_buf())
77+
} else {
78+
Ok(target_path.parent().unwrap_or(target_path).to_path_buf())
79+
}
80+
}
81+
Err(error) if error.kind() == ErrorKind::NotFound => Err(LegolasError::PathNotFound(
82+
target_path.display().to_string(),
83+
)),
84+
Err(error) => Err(error.into()),
85+
}
86+
}
87+
88+
fn resolve_absolute(input_path: &Path) -> Result<PathBuf> {
89+
let absolute = if input_path.is_absolute() {
90+
input_path.to_path_buf()
91+
} else {
92+
std::env::current_dir()?.join(input_path)
93+
};
94+
95+
Ok(normalize_path(&absolute))
96+
}
97+
98+
fn normalize_path(path: &Path) -> PathBuf {
99+
let mut normalized = PathBuf::new();
100+
101+
for component in path.components() {
102+
match component {
103+
Component::CurDir => {}
104+
Component::ParentDir => {
105+
normalized.pop();
106+
}
107+
other => normalized.push(other.as_os_str()),
108+
}
109+
}
110+
111+
normalized
25112
}

0 commit comments

Comments
 (0)