use cargo_metadata::{DependencyKind, MetadataCommand, TargetKind};
use std::collections::HashSet;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Framework {
Tokio,
AsyncStd,
Smol,
Axum,
ActixWeb,
Rocket,
Warp,
Diesel,
Sqlx,
SeaOrm,
Tonic,
WasmBindgen,
WebSys,
Embassy,
CortexM,
}
impl std::fmt::Display for Framework {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Tokio => write!(f, "tokio"),
Self::AsyncStd => write!(f, "async-std"),
Self::Smol => write!(f, "smol"),
Self::Axum => write!(f, "axum"),
Self::ActixWeb => write!(f, "actix-web"),
Self::Rocket => write!(f, "rocket"),
Self::Warp => write!(f, "warp"),
Self::Diesel => write!(f, "diesel"),
Self::Sqlx => write!(f, "sqlx"),
Self::SeaOrm => write!(f, "sea-orm"),
Self::Tonic => write!(f, "tonic"),
Self::WasmBindgen => write!(f, "wasm-bindgen"),
Self::WebSys => write!(f, "web-sys"),
Self::Embassy => write!(f, "embassy"),
Self::CortexM => write!(f, "cortex-m"),
}
}
}
const FRAMEWORK_MAP: &[(&str, Framework)] = &[
("tokio", Framework::Tokio),
("async-std", Framework::AsyncStd),
("smol", Framework::Smol),
("axum", Framework::Axum),
("actix-web", Framework::ActixWeb),
("rocket", Framework::Rocket),
("warp", Framework::Warp),
("diesel", Framework::Diesel),
("sqlx", Framework::Sqlx),
("sea-orm", Framework::SeaOrm),
("tonic", Framework::Tonic),
("wasm-bindgen", Framework::WasmBindgen),
("web-sys", Framework::WebSys),
("cortex-m", Framework::CortexM),
];
#[derive(Debug)]
pub struct ProjectInfo {
pub root_dir: PathBuf,
pub name: String,
pub version: String,
pub edition: String,
pub frameworks: Vec<Framework>,
pub is_workspace: bool,
pub member_count: usize,
pub has_build_script: bool,
pub rust_version: Option<String>,
pub is_no_std: bool,
pub package_metadata: serde_json::Value,
pub workspace_members: Vec<WorkspaceMember>,
}
#[derive(Debug, Clone)]
pub struct WorkspaceMember {
pub name: String,
pub root_dir: PathBuf,
}
pub fn discover_project(
manifest_path: &Path,
offline: bool,
) -> Result<ProjectInfo, crate::error::DiscoveryError> {
use crate::error::DiscoveryError;
let mut cmd = MetadataCommand::new();
cmd.manifest_path(manifest_path).no_deps();
if offline {
cmd.other_options(["--offline".to_string()]);
}
let metadata = cmd
.exec()
.map_err(|source| DiscoveryError::CargoMetadata { source })?;
let workspace_root = PathBuf::from(metadata.workspace_root.as_std_path());
let members = metadata.workspace_packages();
let member_count = members.len();
let is_workspace = member_count > 1;
let primary = members.first().ok_or(DiscoveryError::NoPackages)?;
let name = primary.name.clone();
let version = primary.version.to_string();
let edition = primary.edition.as_str().to_string();
let rust_version = primary
.rust_version
.as_ref()
.map(std::string::ToString::to_string);
let has_build_script = primary
.targets
.iter()
.any(|t| t.kind.contains(&TargetKind::CustomBuild));
let all_dep_names: HashSet<&str> = members
.iter()
.flat_map(|pkg| {
pkg.dependencies
.iter()
.filter(|d| d.kind == DependencyKind::Normal)
.map(|d| d.name.as_str())
})
.collect();
let frameworks = detect_frameworks(&all_dep_names);
let is_no_std = detect_no_std(primary);
let package_metadata = primary.metadata.clone();
let workspace_members_info: Vec<WorkspaceMember> = members
.iter()
.map(|pkg| WorkspaceMember {
name: pkg.name.clone(),
root_dir: PathBuf::from(pkg.manifest_path.parent().map_or(
workspace_root.as_path(),
cargo_metadata::camino::Utf8Path::as_std_path,
)),
})
.collect();
Ok(ProjectInfo {
root_dir: workspace_root,
name,
version,
edition,
frameworks,
is_workspace,
member_count,
has_build_script,
rust_version,
is_no_std,
package_metadata,
workspace_members: workspace_members_info,
})
}
fn detect_frameworks(dep_names: &HashSet<&str>) -> Vec<Framework> {
let mut frameworks: Vec<Framework> = FRAMEWORK_MAP
.iter()
.filter(|(crate_name, _)| dep_names.contains(crate_name))
.map(|(_, framework)| *framework)
.collect();
if dep_names.iter().any(|name| name.starts_with("embassy-"))
&& !frameworks.contains(&Framework::Embassy)
{
frameworks.push(Framework::Embassy);
}
frameworks
}
fn detect_no_std(pkg: &cargo_metadata::Package) -> bool {
let src_path = pkg
.targets
.iter()
.find(|t| {
t.kind.contains(&TargetKind::Lib)
|| t.kind.contains(&TargetKind::RLib)
|| t.kind.contains(&TargetKind::Bin)
})
.map(|t| t.src_path.as_std_path());
src_path.is_some_and(file_declares_no_std)
}
fn file_declares_no_std(path: &Path) -> bool {
let Ok(file) = File::open(path) else {
return false;
};
let reader = BufReader::new(file);
for line in reader.lines().take(10) {
let Ok(line) = line else {
break;
};
let trimmed = line.trim();
if trimmed
.strip_prefix("#![")
.and_then(|s| s.strip_suffix(']'))
.is_some_and(|inner| inner.trim() == "no_std")
{
return true;
}
}
false
}
pub fn bootstrap_project(
directory: &Path,
offline: bool,
) -> Result<(PathBuf, ProjectInfo, Option<crate::config::FileConfig>), crate::error::BootstrapError>
{
let target_dir = directory.canonicalize().map_err(|source| {
crate::error::BootstrapError::InvalidDirectory {
path: directory.display().to_string(),
source,
}
})?;
let cargo_toml = target_dir.join("Cargo.toml");
if !cargo_toml.try_exists().unwrap_or(false) {
return Err(crate::error::BootstrapError::NoCargo { path: target_dir });
}
let project_info = discover_project(&cargo_toml, offline)?;
let file_config = match crate::config::load_file_config(
&project_info.root_dir,
Some(&project_info.package_metadata),
) {
Ok(config) => config,
Err(e) => {
eprintln!("Warning: {e}\nUsing default configuration.");
None
}
};
Ok((target_dir, project_info, file_config))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_detect_frameworks_tokio() {
let deps: HashSet<&str> = ["tokio", "serde"].into_iter().collect();
let frameworks = detect_frameworks(&deps);
assert!(frameworks.contains(&Framework::Tokio));
assert!(!frameworks.contains(&Framework::Axum));
}
#[test]
fn test_detect_frameworks_web_stack() {
let deps: HashSet<&str> = ["tokio", "axum", "sqlx", "serde"].into_iter().collect();
let frameworks = detect_frameworks(&deps);
assert!(frameworks.contains(&Framework::Tokio));
assert!(frameworks.contains(&Framework::Axum));
assert!(frameworks.contains(&Framework::Sqlx));
}
#[test]
fn test_detect_frameworks_embassy_prefix() {
let deps: HashSet<&str> = ["embassy-executor", "embassy-time"].into_iter().collect();
let frameworks = detect_frameworks(&deps);
assert!(frameworks.contains(&Framework::Embassy));
}
#[test]
fn test_detect_frameworks_cortex_m() {
let deps: HashSet<&str> = ["cortex-m", "cortex-m-rt"].into_iter().collect();
let frameworks = detect_frameworks(&deps);
assert!(frameworks.contains(&Framework::CortexM));
}
#[test]
fn test_detect_frameworks_empty() {
let deps: HashSet<&str> = HashSet::new();
let frameworks = detect_frameworks(&deps);
assert!(frameworks.is_empty());
}
#[test]
fn test_detect_frameworks_no_match() {
let deps: HashSet<&str> = ["serde", "rand", "log"].into_iter().collect();
let frameworks = detect_frameworks(&deps);
assert!(frameworks.is_empty());
}
#[test]
fn test_file_declares_no_std_true() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("lib.rs");
let mut f = File::create(&file_path).unwrap();
writeln!(f, "#![no_std]").unwrap();
writeln!(f, "pub fn hello() {{}}").unwrap();
drop(f);
assert!(file_declares_no_std(&file_path));
}
#[test]
fn test_file_declares_no_std_false() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("lib.rs");
let mut f = File::create(&file_path).unwrap();
writeln!(f, "use std::io;").unwrap();
writeln!(f, "pub fn hello() {{}}").unwrap();
drop(f);
assert!(!file_declares_no_std(&file_path));
}
#[test]
fn test_file_declares_no_std_with_comments() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("lib.rs");
let mut f = File::create(&file_path).unwrap();
writeln!(f, "// Copyright 2026").unwrap();
writeln!(f, "//! Crate documentation").unwrap();
writeln!(f, "#![no_std]").unwrap();
writeln!(f, "pub fn hello() {{}}").unwrap();
drop(f);
assert!(file_declares_no_std(&file_path));
}
#[test]
fn test_file_declares_no_std_beyond_line_10() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("lib.rs");
let mut f = File::create(&file_path).unwrap();
for i in 1..=11 {
writeln!(f, "// Line {i}").unwrap();
}
writeln!(f, "#![no_std]").unwrap();
drop(f);
assert!(!file_declares_no_std(&file_path));
}
#[test]
fn test_file_declares_no_std_nonexistent() {
assert!(!file_declares_no_std(Path::new("/nonexistent/lib.rs")));
}
#[test]
fn test_framework_display() {
assert_eq!(Framework::Tokio.to_string(), "tokio");
assert_eq!(Framework::ActixWeb.to_string(), "actix-web");
assert_eq!(Framework::SeaOrm.to_string(), "sea-orm");
assert_eq!(Framework::WasmBindgen.to_string(), "wasm-bindgen");
}
#[test]
fn test_file_declares_no_std_with_internal_spaces() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("lib.rs");
let mut f = File::create(&file_path).unwrap();
writeln!(f, "#![ no_std ]").unwrap();
drop(f);
assert!(file_declares_no_std(&file_path));
}
#[test]
fn test_discover_project_on_self() {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
let info = discover_project(&manifest, false).unwrap();
assert_eq!(info.name, "rust-doctor");
assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
assert_eq!(info.edition, "2024");
assert!(!info.is_workspace);
assert_eq!(info.member_count, 1);
assert!(!info.has_build_script);
assert!(!info.is_no_std);
assert!(info.frameworks.contains(&Framework::Tokio));
}
#[test]
fn test_discover_project_bad_path() {
let result = discover_project(Path::new("/nonexistent/Cargo.toml"), false);
assert!(result.is_err());
}
}