Skip to main content

rust_doctor/
discovery.rs

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/// Detected framework or runtime in the project's dependencies.
8#[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
49/// Maps crate dependency names to Framework variants.
50/// For prefix-based matching (embassy-*), see `detect_frameworks`.
51const 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/// Discovered project information from cargo metadata.
69#[derive(Debug)]
70pub struct ProjectInfo {
71    /// Absolute path to the workspace or project root.
72    pub root_dir: PathBuf,
73    /// Primary package name (first workspace member, or the single package).
74    pub name: String,
75    /// Primary package version.
76    pub version: String,
77    /// Rust edition of the primary package.
78    pub edition: String,
79    /// Detected frameworks/runtimes from dependencies.
80    pub frameworks: Vec<Framework>,
81    /// Whether this is a Cargo workspace (>1 member).
82    pub is_workspace: bool,
83    /// Number of workspace members.
84    pub member_count: usize,
85    /// Whether the primary package has a build script (build.rs).
86    pub has_build_script: bool,
87    /// The `rust-version` (MSRV) field, if specified.
88    pub rust_version: Option<String>,
89    /// Whether the project declares `#![no_std]`.
90    pub is_no_std: bool,
91    /// The `[package.metadata]` table from Cargo.toml (for config fallback).
92    pub package_metadata: serde_json::Value,
93    /// Workspace member names and their root directories.
94    pub workspace_members: Vec<WorkspaceMember>,
95}
96
97/// A workspace member package.
98#[derive(Debug, Clone)]
99pub struct WorkspaceMember {
100    /// Package name.
101    pub name: String,
102    /// Absolute path to the member's root directory (parent of Cargo.toml).
103    pub root_dir: PathBuf,
104}
105
106/// Run cargo metadata and discover project characteristics.
107///
108/// `manifest_path` should point to the Cargo.toml file.
109/// If `offline` is true, passes `--offline` to cargo to prevent network access.
110/// Returns `Ok(ProjectInfo)` on success, or an error if cargo metadata fails.
111pub 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    // Use first workspace member as "primary" package
132    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    // Detect build script
143    let has_build_script = primary
144        .targets
145        .iter()
146        .any(|t| t.kind.contains(&TargetKind::CustomBuild));
147
148    // Collect all dependency names across all workspace members
149    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    // Detect #![no_std] from primary package's lib.rs or main.rs
162    let is_no_std = detect_no_std(primary);
163
164    let package_metadata = primary.metadata.clone();
165
166    // Collect workspace member info
167    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
194/// Detect frameworks from dependency names.
195fn 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    // Prefix-based detection for embassy-* crates
203    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
212/// Detect `#![no_std]` by scanning the primary source file's first 10 lines.
213fn detect_no_std(pkg: &cargo_metadata::Package) -> bool {
214    // Find lib or bin target's source path
215    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
228/// Returns `true` if the file declares `#![no_std]` in its first 10 lines.
229fn 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        // Check for #![no_std], tolerating internal whitespace like #![ no_std ]
241        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
252/// Validate a directory, discover the project, and load file config.
253///
254/// Shared bootstrap logic used by both the CLI entry point and the MCP server.
255/// Returns the canonicalized directory, project info, and file config.
256pub 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        // no_std is on line 12, beyond the 10-line scan window
389        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        // Run discovery on rust-doctor itself
419        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        // rust-doctor depends on tokio (for MCP server)
430        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}