1use cargo_metadata::{DependencyKind, MetadataCommand, TargetKind};
2use std::collections::HashSet;
3use std::fs::File;
4use std::io::{BufRead, BufReader};
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub enum Framework {
10 Tokio,
11 AsyncStd,
12 Smol,
13 Axum,
14 ActixWeb,
15 Rocket,
16 Warp,
17 Diesel,
18 Sqlx,
19 SeaOrm,
20 Tonic,
21 WasmBindgen,
22 WebSys,
23 Embassy,
24 CortexM,
25}
26
27impl std::fmt::Display for Framework {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 Self::Tokio => write!(f, "tokio"),
31 Self::AsyncStd => write!(f, "async-std"),
32 Self::Smol => write!(f, "smol"),
33 Self::Axum => write!(f, "axum"),
34 Self::ActixWeb => write!(f, "actix-web"),
35 Self::Rocket => write!(f, "rocket"),
36 Self::Warp => write!(f, "warp"),
37 Self::Diesel => write!(f, "diesel"),
38 Self::Sqlx => write!(f, "sqlx"),
39 Self::SeaOrm => write!(f, "sea-orm"),
40 Self::Tonic => write!(f, "tonic"),
41 Self::WasmBindgen => write!(f, "wasm-bindgen"),
42 Self::WebSys => write!(f, "web-sys"),
43 Self::Embassy => write!(f, "embassy"),
44 Self::CortexM => write!(f, "cortex-m"),
45 }
46 }
47}
48
49const FRAMEWORK_MAP: &[(&str, Framework)] = &[
52 ("tokio", Framework::Tokio),
53 ("async-std", Framework::AsyncStd),
54 ("smol", Framework::Smol),
55 ("axum", Framework::Axum),
56 ("actix-web", Framework::ActixWeb),
57 ("rocket", Framework::Rocket),
58 ("warp", Framework::Warp),
59 ("diesel", Framework::Diesel),
60 ("sqlx", Framework::Sqlx),
61 ("sea-orm", Framework::SeaOrm),
62 ("tonic", Framework::Tonic),
63 ("wasm-bindgen", Framework::WasmBindgen),
64 ("web-sys", Framework::WebSys),
65 ("cortex-m", Framework::CortexM),
66];
67
68#[derive(Debug)]
70pub struct ProjectInfo {
71 pub root_dir: PathBuf,
73 pub name: String,
75 pub version: String,
77 pub edition: String,
79 pub frameworks: Vec<Framework>,
81 pub is_workspace: bool,
83 pub member_count: usize,
85 pub has_build_script: bool,
87 pub rust_version: Option<String>,
89 pub is_no_std: bool,
91 pub package_metadata: serde_json::Value,
93 pub workspace_members: Vec<WorkspaceMember>,
95}
96
97#[derive(Debug, Clone)]
99pub struct WorkspaceMember {
100 pub name: String,
102 pub root_dir: PathBuf,
104}
105
106pub fn discover_project(
112 manifest_path: &Path,
113 offline: bool,
114) -> Result<ProjectInfo, crate::error::DiscoveryError> {
115 use crate::error::DiscoveryError;
116
117 let mut cmd = MetadataCommand::new();
118 cmd.manifest_path(manifest_path).no_deps();
119 if offline {
120 cmd.other_options(["--offline".to_string()]);
121 }
122 let metadata = cmd
123 .exec()
124 .map_err(|source| DiscoveryError::CargoMetadata { source })?;
125
126 let workspace_root = PathBuf::from(metadata.workspace_root.as_std_path());
127 let members = metadata.workspace_packages();
128 let member_count = members.len();
129 let is_workspace = member_count > 1;
130
131 let primary = members.first().ok_or(DiscoveryError::NoPackages)?;
133
134 let name = primary.name.clone();
135 let version = primary.version.to_string();
136 let edition = primary.edition.as_str().to_string();
137 let rust_version = primary
138 .rust_version
139 .as_ref()
140 .map(std::string::ToString::to_string);
141
142 let has_build_script = primary
144 .targets
145 .iter()
146 .any(|t| t.kind.contains(&TargetKind::CustomBuild));
147
148 let all_dep_names: HashSet<&str> = members
150 .iter()
151 .flat_map(|pkg| {
152 pkg.dependencies
153 .iter()
154 .filter(|d| d.kind == DependencyKind::Normal)
155 .map(|d| d.name.as_str())
156 })
157 .collect();
158
159 let frameworks = detect_frameworks(&all_dep_names);
160
161 let is_no_std = detect_no_std(primary);
163
164 let package_metadata = primary.metadata.clone();
165
166 let workspace_members_info: Vec<WorkspaceMember> = members
168 .iter()
169 .map(|pkg| WorkspaceMember {
170 name: pkg.name.clone(),
171 root_dir: PathBuf::from(pkg.manifest_path.parent().map_or(
172 workspace_root.as_path(),
173 cargo_metadata::camino::Utf8Path::as_std_path,
174 )),
175 })
176 .collect();
177
178 Ok(ProjectInfo {
179 root_dir: workspace_root,
180 name,
181 version,
182 edition,
183 frameworks,
184 is_workspace,
185 member_count,
186 has_build_script,
187 rust_version,
188 is_no_std,
189 package_metadata,
190 workspace_members: workspace_members_info,
191 })
192}
193
194fn detect_frameworks(dep_names: &HashSet<&str>) -> Vec<Framework> {
196 let mut frameworks: Vec<Framework> = FRAMEWORK_MAP
197 .iter()
198 .filter(|(crate_name, _)| dep_names.contains(crate_name))
199 .map(|(_, framework)| *framework)
200 .collect();
201
202 if dep_names.iter().any(|name| name.starts_with("embassy-"))
204 && !frameworks.contains(&Framework::Embassy)
205 {
206 frameworks.push(Framework::Embassy);
207 }
208
209 frameworks
210}
211
212fn detect_no_std(pkg: &cargo_metadata::Package) -> bool {
214 let src_path = pkg
216 .targets
217 .iter()
218 .find(|t| {
219 t.kind.contains(&TargetKind::Lib)
220 || t.kind.contains(&TargetKind::RLib)
221 || t.kind.contains(&TargetKind::Bin)
222 })
223 .map(|t| t.src_path.as_std_path());
224
225 src_path.is_some_and(file_declares_no_std)
226}
227
228fn file_declares_no_std(path: &Path) -> bool {
230 let Ok(file) = File::open(path) else {
231 return false;
232 };
233 let reader = BufReader::new(file);
234
235 for line in reader.lines().take(10) {
236 let Ok(line) = line else {
237 break;
238 };
239 let trimmed = line.trim();
240 if trimmed
242 .strip_prefix("#![")
243 .and_then(|s| s.strip_suffix(']'))
244 .is_some_and(|inner| inner.trim() == "no_std")
245 {
246 return true;
247 }
248 }
249 false
250}
251
252pub fn bootstrap_project(
257 directory: &Path,
258 offline: bool,
259) -> Result<(PathBuf, ProjectInfo, Option<crate::config::FileConfig>), crate::error::BootstrapError>
260{
261 let target_dir = directory.canonicalize().map_err(|source| {
262 crate::error::BootstrapError::InvalidDirectory {
263 path: directory.display().to_string(),
264 source,
265 }
266 })?;
267
268 let cargo_toml = target_dir.join("Cargo.toml");
269 if !cargo_toml.try_exists().unwrap_or(false) {
270 return Err(crate::error::BootstrapError::NoCargo { path: target_dir });
271 }
272
273 let project_info = discover_project(&cargo_toml, offline)?;
274
275 let file_config = match crate::config::load_file_config(
276 &project_info.root_dir,
277 Some(&project_info.package_metadata),
278 ) {
279 Ok(config) => config,
280 Err(e) => {
281 eprintln!("Warning: {e}\nUsing default configuration.");
282 None
283 }
284 };
285
286 Ok((target_dir, project_info, file_config))
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use std::io::Write;
293
294 #[test]
295 fn test_detect_frameworks_tokio() {
296 let deps: HashSet<&str> = ["tokio", "serde"].into_iter().collect();
297 let frameworks = detect_frameworks(&deps);
298 assert!(frameworks.contains(&Framework::Tokio));
299 assert!(!frameworks.contains(&Framework::Axum));
300 }
301
302 #[test]
303 fn test_detect_frameworks_web_stack() {
304 let deps: HashSet<&str> = ["tokio", "axum", "sqlx", "serde"].into_iter().collect();
305 let frameworks = detect_frameworks(&deps);
306 assert!(frameworks.contains(&Framework::Tokio));
307 assert!(frameworks.contains(&Framework::Axum));
308 assert!(frameworks.contains(&Framework::Sqlx));
309 }
310
311 #[test]
312 fn test_detect_frameworks_embassy_prefix() {
313 let deps: HashSet<&str> = ["embassy-executor", "embassy-time"].into_iter().collect();
314 let frameworks = detect_frameworks(&deps);
315 assert!(frameworks.contains(&Framework::Embassy));
316 }
317
318 #[test]
319 fn test_detect_frameworks_cortex_m() {
320 let deps: HashSet<&str> = ["cortex-m", "cortex-m-rt"].into_iter().collect();
321 let frameworks = detect_frameworks(&deps);
322 assert!(frameworks.contains(&Framework::CortexM));
323 }
324
325 #[test]
326 fn test_detect_frameworks_empty() {
327 let deps: HashSet<&str> = HashSet::new();
328 let frameworks = detect_frameworks(&deps);
329 assert!(frameworks.is_empty());
330 }
331
332 #[test]
333 fn test_detect_frameworks_no_match() {
334 let deps: HashSet<&str> = ["serde", "rand", "log"].into_iter().collect();
335 let frameworks = detect_frameworks(&deps);
336 assert!(frameworks.is_empty());
337 }
338
339 #[test]
340 fn test_file_declares_no_std_true() {
341 let dir = tempfile::tempdir().unwrap();
342 let file_path = dir.path().join("lib.rs");
343 let mut f = File::create(&file_path).unwrap();
344 writeln!(f, "#![no_std]").unwrap();
345 writeln!(f, "pub fn hello() {{}}").unwrap();
346 drop(f);
347
348 assert!(file_declares_no_std(&file_path));
349 }
350
351 #[test]
352 fn test_file_declares_no_std_false() {
353 let dir = tempfile::tempdir().unwrap();
354 let file_path = dir.path().join("lib.rs");
355 let mut f = File::create(&file_path).unwrap();
356 writeln!(f, "use std::io;").unwrap();
357 writeln!(f, "pub fn hello() {{}}").unwrap();
358 drop(f);
359
360 assert!(!file_declares_no_std(&file_path));
361 }
362
363 #[test]
364 fn test_file_declares_no_std_with_comments() {
365 let dir = tempfile::tempdir().unwrap();
366 let file_path = dir.path().join("lib.rs");
367 let mut f = File::create(&file_path).unwrap();
368 writeln!(f, "// Copyright 2026").unwrap();
369 writeln!(f, "//! Crate documentation").unwrap();
370 writeln!(f, "#![no_std]").unwrap();
371 writeln!(f, "pub fn hello() {{}}").unwrap();
372 drop(f);
373
374 assert!(file_declares_no_std(&file_path));
375 }
376
377 #[test]
378 fn test_file_declares_no_std_beyond_line_10() {
379 let dir = tempfile::tempdir().unwrap();
380 let file_path = dir.path().join("lib.rs");
381 let mut f = File::create(&file_path).unwrap();
382 for i in 1..=11 {
383 writeln!(f, "// Line {i}").unwrap();
384 }
385 writeln!(f, "#![no_std]").unwrap();
386 drop(f);
387
388 assert!(!file_declares_no_std(&file_path));
390 }
391
392 #[test]
393 fn test_file_declares_no_std_nonexistent() {
394 assert!(!file_declares_no_std(Path::new("/nonexistent/lib.rs")));
395 }
396
397 #[test]
398 fn test_framework_display() {
399 assert_eq!(Framework::Tokio.to_string(), "tokio");
400 assert_eq!(Framework::ActixWeb.to_string(), "actix-web");
401 assert_eq!(Framework::SeaOrm.to_string(), "sea-orm");
402 assert_eq!(Framework::WasmBindgen.to_string(), "wasm-bindgen");
403 }
404
405 #[test]
406 fn test_file_declares_no_std_with_internal_spaces() {
407 let dir = tempfile::tempdir().unwrap();
408 let file_path = dir.path().join("lib.rs");
409 let mut f = File::create(&file_path).unwrap();
410 writeln!(f, "#![ no_std ]").unwrap();
411 drop(f);
412
413 assert!(file_declares_no_std(&file_path));
414 }
415
416 #[test]
417 fn test_discover_project_on_self() {
418 let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
420 let info = discover_project(&manifest, false).unwrap();
421
422 assert_eq!(info.name, "rust-doctor");
423 assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
424 assert_eq!(info.edition, "2024");
425 assert!(!info.is_workspace);
426 assert_eq!(info.member_count, 1);
427 assert!(!info.has_build_script);
428 assert!(!info.is_no_std);
429 assert!(info.frameworks.contains(&Framework::Tokio));
431 }
432
433 #[test]
434 fn test_discover_project_bad_path() {
435 let result = discover_project(Path::new("/nonexistent/Cargo.toml"), false);
436 assert!(result.is_err());
437 }
438}