use std::path::{Path, PathBuf};
use cargo_toml::{Dependency, Manifest};
use crate::shared::cli_error::{CliError, CliResult};
#[derive(Debug, Clone, PartialEq)]
pub struct WorkspaceInfo {
pub is_workspace: bool,
pub workspace_root: Option<PathBuf>,
pub target_crate: Option<String>,
pub target_crate_path: Option<PathBuf>,
pub components_base_path: String,
}
impl Default for WorkspaceInfo {
fn default() -> Self {
Self {
is_workspace: false,
workspace_root: None,
target_crate: None,
target_crate_path: None,
components_base_path: "src/components".to_string(),
}
}
}
pub fn analyze_workspace() -> CliResult<WorkspaceInfo> {
let current_dir = std::env::current_dir()?;
analyze_workspace_from_path(¤t_dir)
}
pub fn analyze_workspace_from_path(start_path: &Path) -> CliResult<WorkspaceInfo> {
let local_cargo_toml = start_path.join("Cargo.toml");
if !local_cargo_toml.exists() {
return Err(CliError::file_operation("Cargo.toml not found in current directory"));
}
let local_manifest = load_cargo_manifest(&local_cargo_toml)?
.ok_or_else(|| CliError::file_operation("Failed to parse Cargo.toml"))?;
if local_manifest.workspace.is_some() {
return analyze_from_workspace_root(start_path, &local_manifest);
}
if let Some(workspace_root) = find_workspace_root(start_path)? {
return analyze_from_workspace_member(start_path, &workspace_root);
}
let has_leptos = check_leptos_in_manifest(&local_manifest);
if !has_leptos {
return Err(CliError::config("Leptos dependency not found in Cargo.toml"));
}
Ok(WorkspaceInfo {
is_workspace: false,
workspace_root: None,
target_crate: local_manifest.package.as_ref().map(|p| p.name.clone()),
target_crate_path: Some(start_path.to_path_buf()),
components_base_path: "src/components".to_string(),
})
}
fn analyze_from_workspace_root(workspace_root: &Path, manifest: &Manifest) -> CliResult<WorkspaceInfo> {
let workspace =
manifest.workspace.as_ref().ok_or_else(|| CliError::config("Expected workspace manifest"))?;
let members = expand_workspace_members(workspace_root, &workspace.members)?;
for member_path in &members {
let member_cargo_toml = member_path.join("Cargo.toml");
if let Some(member_manifest) = load_cargo_manifest(&member_cargo_toml)?
&& member_manifest.dependencies.contains_key("leptos")
{
let crate_name = member_manifest
.package
.as_ref()
.map(|p| p.name.clone())
.or_else(|| member_path.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_default();
let relative_path = member_path.strip_prefix(workspace_root).unwrap_or(member_path);
return Ok(WorkspaceInfo {
is_workspace: true,
workspace_root: Some(workspace_root.to_path_buf()),
target_crate: Some(crate_name),
target_crate_path: Some(member_path.clone()),
components_base_path: format!("{}/src/components", relative_path.display()),
});
}
}
if workspace.dependencies.contains_key("leptos") {
for member_path in &members {
let member_cargo_toml = member_path.join("Cargo.toml");
if let Some(member_manifest) = load_cargo_manifest(&member_cargo_toml)?
&& let Some(dep) = member_manifest.dependencies.get("leptos")
&& matches!(dep, Dependency::Inherited(_))
{
let crate_name = member_manifest
.package
.as_ref()
.map(|p| p.name.clone())
.or_else(|| member_path.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_default();
let relative_path = member_path.strip_prefix(workspace_root).unwrap_or(member_path);
return Ok(WorkspaceInfo {
is_workspace: true,
workspace_root: Some(workspace_root.to_path_buf()),
target_crate: Some(crate_name),
target_crate_path: Some(member_path.clone()),
components_base_path: format!("{}/src/components", relative_path.display()),
});
}
}
}
Err(CliError::config(
"No workspace member with Leptos dependency found. Please run from a crate directory with Leptos installed.",
))
}
fn analyze_from_workspace_member(member_path: &Path, workspace_root: &Path) -> CliResult<WorkspaceInfo> {
let member_cargo_toml = member_path.join("Cargo.toml");
let member_manifest = load_cargo_manifest(&member_cargo_toml)?
.ok_or_else(|| CliError::file_operation("Failed to parse member Cargo.toml"))?;
let has_leptos = check_leptos_in_manifest(&member_manifest);
let workspace_cargo_toml = workspace_root.join("Cargo.toml");
let workspace_has_leptos = if let Some(ws_manifest) = load_cargo_manifest(&workspace_cargo_toml)? {
ws_manifest.workspace.as_ref().is_some_and(|ws| ws.dependencies.contains_key("leptos"))
} else {
false
};
if !has_leptos && !workspace_has_leptos {
return Err(CliError::config("Leptos dependency not found in this crate or workspace"));
}
let crate_name = member_manifest
.package
.as_ref()
.map(|p| p.name.clone())
.or_else(|| member_path.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_default();
Ok(WorkspaceInfo {
is_workspace: true,
workspace_root: Some(workspace_root.to_path_buf()),
target_crate: Some(crate_name),
target_crate_path: Some(member_path.to_path_buf()),
components_base_path: "src/components".to_string(),
})
}
fn find_workspace_root(start_path: &Path) -> CliResult<Option<PathBuf>> {
let mut current = start_path.parent();
while let Some(dir) = current {
let cargo_toml = dir.join("Cargo.toml");
if cargo_toml.exists()
&& let Some(manifest) = load_cargo_manifest(&cargo_toml)?
&& manifest.workspace.is_some()
{
return Ok(Some(dir.to_path_buf()));
}
current = dir.parent();
}
Ok(None)
}
fn expand_workspace_members(workspace_root: &Path, members: &[String]) -> CliResult<Vec<PathBuf>> {
let mut result = Vec::new();
for member in members {
if member.contains('*') {
let pattern = workspace_root.join(member);
let pattern_str = pattern.to_string_lossy();
if let Ok(paths) = glob::glob(&pattern_str) {
for path in paths.flatten() {
if path.is_dir() && path.join("Cargo.toml").exists() {
result.push(path);
}
}
}
} else {
let member_path = workspace_root.join(member);
if member_path.is_dir() && member_path.join("Cargo.toml").exists() {
result.push(member_path);
}
}
}
Ok(result)
}
fn check_leptos_in_manifest(manifest: &Manifest) -> bool {
manifest.dependencies.contains_key("leptos")
}
pub fn check_leptos_dependency() -> CliResult<bool> {
match analyze_workspace() {
Ok(_) => Ok(true), Err(e) => {
let err_msg = format!("{e}");
if err_msg.contains("Leptos") { Ok(false) } else { Err(e) }
}
}
}
pub fn get_tailwind_input_file() -> CliResult<String> {
let current_dir = std::env::current_dir()?;
get_tailwind_input_file_from_path(¤t_dir)
}
pub fn get_tailwind_input_file_from_path(start_path: &Path) -> CliResult<String> {
let local_cargo_toml = start_path.join("Cargo.toml");
if let Some(manifest) = load_cargo_manifest(&local_cargo_toml)?
&& let Some(tailwind_file) = extract_tailwind_from_manifest(&manifest)
{
return Ok(tailwind_file);
}
if let Some(workspace_root) = find_workspace_root(start_path)?
&& let Some(manifest) = load_cargo_manifest(&workspace_root.join("Cargo.toml"))?
&& let Some(tailwind_file) = extract_tailwind_from_manifest(&manifest)
{
return Ok(tailwind_file);
}
Err(CliError::config(
"Missing `tailwind-input-file` in Cargo.toml. \
Please add Leptos metadata to your Cargo.toml:\n\n\
[package.metadata.leptos]\n\
tailwind-input-file = \"style/tailwind.css\"\n\n\
Or for workspaces:\n\n\
[[workspace.metadata.leptos]]\n\
tailwind-input-file = \"style/tailwind.css\"",
))
}
fn extract_tailwind_from_manifest(manifest: &Manifest) -> Option<String> {
if let Some(workspace) = &manifest.workspace
&& let Some(metadata) = &workspace.metadata
&& let Some(leptos_value) = metadata.get("leptos")
{
if let Some(array) = leptos_value.as_array() {
if let Some(first) = array.first() {
if let Some(tailwind) = first.get("tailwind-input-file") {
if let Some(value) = tailwind.as_str() {
return Some(value.to_string());
}
}
}
}
if let Some(tailwind) = leptos_value.get("tailwind-input-file") {
if let Some(value) = tailwind.as_str() {
return Some(value.to_string());
}
}
}
if let Some(package) = &manifest.package
&& let Some(metadata) = &package.metadata
&& let Some(leptos) = metadata.get("leptos")
&& let Some(tailwind) = leptos.get("tailwind-input-file")
&& let Some(value) = tailwind.as_str()
{
return Some(value.to_string());
}
None
}
pub fn load_cargo_manifest(cargo_toml_path: &Path) -> CliResult<Option<Manifest>> {
if !cargo_toml_path.exists() {
return Ok(None);
}
match Manifest::from_path(cargo_toml_path) {
Ok(manifest) => Ok(Some(manifest)),
Err(_) => {
let contents = std::fs::read_to_string(cargo_toml_path)?;
let manifest = Manifest::from_slice(contents.as_bytes())?;
Ok(Some(manifest))
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn write_cargo_toml(dir: &Path, content: &str) {
fs::write(dir.join("Cargo.toml"), content).unwrap();
}
fn create_src_dir(dir: &Path) {
fs::create_dir_all(dir.join("src")).unwrap();
fs::write(dir.join("src/lib.rs"), "").unwrap();
}
#[test]
fn test_single_crate_with_leptos() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
[dependencies]
leptos = "0.7"
"#,
);
create_src_dir(root);
let info = analyze_workspace_from_path(root).unwrap();
assert!(!info.is_workspace);
assert_eq!(info.target_crate, Some("my-app".to_string()));
assert_eq!(info.components_base_path, "src/components");
}
#[test]
fn test_single_crate_without_leptos() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = "1"
"#,
);
create_src_dir(root);
let result = analyze_workspace_from_path(root);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Leptos"));
}
#[test]
fn test_workspace_with_leptos_member() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[workspace]
members = ["app", "server"]
"#,
);
let app_dir = root.join("app");
fs::create_dir_all(&app_dir).unwrap();
write_cargo_toml(
&app_dir,
r#"
[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
[dependencies]
leptos = "0.7"
"#,
);
create_src_dir(&app_dir);
let server_dir = root.join("server");
fs::create_dir_all(&server_dir).unwrap();
write_cargo_toml(
&server_dir,
r#"
[package]
name = "my-server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
"#,
);
create_src_dir(&server_dir);
let info = analyze_workspace_from_path(root).unwrap();
assert!(info.is_workspace);
assert_eq!(info.target_crate, Some("my-app".to_string()));
assert_eq!(info.components_base_path, "app/src/components");
}
#[test]
fn test_workspace_from_member_directory() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[workspace]
members = ["frontend"]
"#,
);
let frontend_dir = root.join("frontend");
fs::create_dir_all(&frontend_dir).unwrap();
write_cargo_toml(
&frontend_dir,
r#"
[package]
name = "frontend"
version = "0.1.0"
edition = "2021"
[dependencies]
leptos = "0.7"
"#,
);
create_src_dir(&frontend_dir);
let info = analyze_workspace_from_path(&frontend_dir).unwrap();
assert!(info.is_workspace);
assert_eq!(info.target_crate, Some("frontend".to_string()));
assert_eq!(info.components_base_path, "src/components");
}
#[test]
fn test_workspace_with_workspace_dependencies() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[workspace]
members = ["app"]
[workspace.dependencies]
leptos = "0.7"
"#,
);
let app_dir = root.join("app");
fs::create_dir_all(&app_dir).unwrap();
write_cargo_toml(
&app_dir,
r#"
[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
[dependencies]
leptos.workspace = true
"#,
);
create_src_dir(&app_dir);
let info = analyze_workspace_from_path(root).unwrap();
assert!(info.is_workspace);
assert_eq!(info.target_crate, Some("my-app".to_string()));
}
#[test]
fn test_workspace_no_leptos_member() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[workspace]
members = ["server"]
"#,
);
let server_dir = root.join("server");
fs::create_dir_all(&server_dir).unwrap();
write_cargo_toml(
&server_dir,
r#"
[package]
name = "server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
"#,
);
create_src_dir(&server_dir);
let result = analyze_workspace_from_path(root);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Leptos"));
}
#[test]
fn test_no_cargo_toml() {
let temp = TempDir::new().unwrap();
let result = analyze_workspace_from_path(temp.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Cargo.toml"));
}
#[test]
fn test_workspace_info_default() {
let info = WorkspaceInfo::default();
assert!(!info.is_workspace);
assert!(info.workspace_root.is_none());
assert!(info.target_crate.is_none());
assert_eq!(info.components_base_path, "src/components");
}
#[test]
fn test_get_tailwind_from_workspace_metadata() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[workspace]
members = ["app"]
[[workspace.metadata.leptos]]
name = "my-app"
tailwind-input-file = "style/main.css"
"#,
);
let result = get_tailwind_input_file_from_path(root).unwrap();
assert_eq!(result, "style/main.css");
}
#[test]
fn test_get_tailwind_from_package_metadata() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[package]
name = "my-app"
version = "0.1.0"
[package.metadata.leptos]
tailwind-input-file = "assets/tailwind.css"
"#,
);
let result = get_tailwind_input_file_from_path(root).unwrap();
assert_eq!(result, "assets/tailwind.css");
}
#[test]
fn test_get_tailwind_from_workspace_root_when_in_member() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[workspace]
members = ["app"]
[[workspace.metadata.leptos]]
name = "my-app"
tailwind-input-file = "style/global.css"
"#,
);
let app_dir = root.join("app");
fs::create_dir_all(&app_dir).unwrap();
write_cargo_toml(
&app_dir,
r#"
[package]
name = "app"
version = "0.1.0"
[dependencies]
leptos = "0.7"
"#,
);
let result = get_tailwind_input_file_from_path(&app_dir).unwrap();
assert_eq!(result, "style/global.css");
}
#[test]
fn test_get_tailwind_missing_returns_error() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[package]
name = "my-app"
version = "0.1.0"
[dependencies]
leptos = "0.7"
"#,
);
let result = get_tailwind_input_file_from_path(root);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("tailwind-input-file"));
assert!(err_msg.contains("Cargo.toml"));
}
#[test]
fn test_get_tailwind_no_cargo_toml_returns_error() {
let temp = TempDir::new().unwrap();
let result = get_tailwind_input_file_from_path(temp.path());
assert!(result.is_err());
}
#[test]
fn test_get_tailwind_prefers_local_over_workspace() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[workspace]
members = ["app"]
[[workspace.metadata.leptos]]
name = "workspace-app"
tailwind-input-file = "style/workspace.css"
"#,
);
let app_dir = root.join("app");
fs::create_dir_all(&app_dir).unwrap();
write_cargo_toml(
&app_dir,
r#"
[package]
name = "app"
version = "0.1.0"
[package.metadata.leptos]
tailwind-input-file = "style/local.css"
"#,
);
let result = get_tailwind_input_file_from_path(&app_dir).unwrap();
assert_eq!(result, "style/local.css");
}
#[test]
fn test_get_tailwind_multiple_leptos_entries_uses_first() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[workspace]
members = ["app"]
[[workspace.metadata.leptos]]
name = "first-app"
tailwind-input-file = "style/first.css"
[[workspace.metadata.leptos]]
name = "second-app"
tailwind-input-file = "style/second.css"
"#,
);
let result = get_tailwind_input_file_from_path(root).unwrap();
assert_eq!(result, "style/first.css");
}
#[test]
fn test_get_tailwind_workspace_single_table_format() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[workspace]
members = ["app"]
[workspace.metadata.leptos]
tailwind-input-file = "style/single.css"
"#,
);
let result = get_tailwind_input_file_from_path(root).unwrap();
assert_eq!(result, "style/single.css");
}
#[test]
fn test_get_tailwind_metadata_exists_but_no_leptos_key() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[package]
name = "my-app"
version = "0.1.0"
[package.metadata]
some-other-tool = { key = "value" }
"#,
);
let result = get_tailwind_input_file_from_path(root);
assert!(result.is_err());
}
#[test]
fn test_get_tailwind_leptos_exists_but_no_tailwind_key() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[package]
name = "my-app"
version = "0.1.0"
[package.metadata.leptos]
name = "my-app"
site-root = "target/site"
"#,
);
let result = get_tailwind_input_file_from_path(root);
assert!(result.is_err());
}
#[test]
fn test_get_tailwind_empty_value() {
let temp = TempDir::new().unwrap();
let root = temp.path();
write_cargo_toml(
root,
r#"
[package]
name = "my-app"
version = "0.1.0"
[package.metadata.leptos]
tailwind-input-file = ""
"#,
);
let result = get_tailwind_input_file_from_path(root).unwrap();
assert_eq!(result, "");
}
}