use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::config;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceDiscovery {
pub root: PathBuf,
pub workspace_name: String,
pub package_manager: Option<String>,
pub turborepo: bool,
pub packages: Vec<WorkspacePackage>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspacePackage {
pub name: Option<String>,
pub slug: String,
pub project_name: String,
pub relative_path: PathBuf,
pub path: PathBuf,
pub package_kind: WorkspacePackageKind,
pub app_candidate: bool,
pub env_status: WorkspaceEnvStatus,
pub setup_status: WorkspaceSetupStatus,
pub env_example_keys: Vec<String>,
pub scripts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum WorkspacePackageKind {
App,
Package,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum WorkspaceEnvStatus {
Present,
ExampleOnly,
Missing,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum WorkspaceSetupStatus {
Configured,
NeedsEnv,
NotConfigured,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PackageJson {
name: Option<String>,
#[serde(rename = "packageManager")]
package_manager: Option<String>,
workspaces: Option<WorkspacesField>,
#[serde(default)]
scripts: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum WorkspacesField {
List(Vec<String>),
Object { packages: Vec<String> },
}
pub fn discover(root: &Path) -> Result<Option<WorkspaceDiscovery>> {
let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let root_package = read_package_json(&root).unwrap_or(PackageJson {
name: None,
package_manager: None,
workspaces: None,
scripts: BTreeMap::new(),
});
let mut globs = workspace_globs_from_pnpm(&root)?;
globs.extend(workspace_globs_from_package_json(&root_package));
let turborepo = root.join("turbo.json").is_file();
if globs.is_empty() && turborepo {
if root.join("apps").is_dir() {
globs.push("apps/*".to_string());
}
if root.join("packages").is_dir() {
globs.push("packages/*".to_string());
}
}
if globs.is_empty() {
return Ok(None);
}
let workspace_name = workspace_name(&root, root_package.name.as_deref());
let package_manager = root_package
.package_manager
.as_deref()
.and_then(package_manager_name);
let mut packages = workspace_packages(&root, &workspace_name, &globs)?;
packages.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
Ok(Some(WorkspaceDiscovery {
root,
workspace_name,
package_manager,
turborepo,
packages,
}))
}
impl WorkspaceDiscovery {
pub fn app_candidates(&self) -> impl Iterator<Item = &WorkspacePackage> {
self.packages.iter().filter(|package| package.app_candidate)
}
pub fn selected_apps<'a>(
&'a self,
requested: &[String],
all: bool,
) -> Result<Vec<&'a WorkspacePackage>> {
if all {
return Ok(self.app_candidates().collect());
}
let mut selected = Vec::new();
for app in requested {
let Some(package) = self.app_candidates().find(|package| package.matches(app)) else {
anyhow::bail!("workspace app {app} was not found");
};
selected.push(package);
}
Ok(selected)
}
}
impl WorkspacePackage {
pub fn can_setup(&self) -> bool {
self.app_candidate
&& self.setup_status != WorkspaceSetupStatus::Configured
&& self.env_status == WorkspaceEnvStatus::Present
}
fn matches(&self, value: &str) -> bool {
self.slug == value
|| self.project_name == value
|| self.name.as_deref() == Some(value)
|| self.relative_path.to_string_lossy() == value
}
}
fn workspace_packages(
root: &Path,
workspace_name: &str,
globs: &[String],
) -> Result<Vec<WorkspacePackage>> {
let mut package_dirs = BTreeSet::new();
for pattern in globs {
let pattern = pattern.trim();
if pattern.is_empty() || pattern.starts_with('!') {
continue;
}
let absolute_pattern = root.join(pattern).to_string_lossy().to_string();
for entry in glob::glob(&absolute_pattern)
.with_context(|| format!("failed to read workspace glob {pattern}"))?
{
let path = entry.context("failed to read workspace glob entry")?;
if path.is_dir() && path.join("package.json").is_file() {
package_dirs.insert(path.canonicalize().unwrap_or(path));
}
}
}
let mut packages = Vec::new();
for path in package_dirs {
let package = read_package_json(&path)
.with_context(|| format!("failed to read {}", path.join("package.json").display()))?;
let relative_path = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
let package_kind = classify_package_kind(&relative_path);
let env_status = env_status(&path);
let setup_status = setup_status(&path, &env_status);
let env_example_keys = env_example_keys(&path)?;
let slug = app_slug(package.name.as_deref(), &relative_path);
let project_name = format!("{workspace_name}:{slug}");
let scripts = package.scripts.keys().cloned().collect::<Vec<_>>();
let has_runtime_script = package.scripts.contains_key("dev")
|| package.scripts.contains_key("start")
|| package.scripts.contains_key("payload");
let app_candidate = package_kind == WorkspacePackageKind::App
|| env_status != WorkspaceEnvStatus::Missing
|| has_runtime_script;
packages.push(WorkspacePackage {
name: package.name,
slug,
project_name,
relative_path,
path,
package_kind,
app_candidate,
env_status,
setup_status,
env_example_keys,
scripts,
});
}
Ok(packages)
}
fn read_package_json(path: &Path) -> Result<PackageJson> {
let package_path = path.join("package.json");
let contents = fs::read_to_string(&package_path)
.with_context(|| format!("failed to read {}", package_path.display()))?;
serde_json::from_str(&contents)
.with_context(|| format!("failed to parse {}", package_path.display()))
}
fn workspace_globs_from_package_json(package: &PackageJson) -> Vec<String> {
match &package.workspaces {
Some(WorkspacesField::List(packages)) => packages.clone(),
Some(WorkspacesField::Object { packages }) => packages.clone(),
None => Vec::new(),
}
}
fn workspace_globs_from_pnpm(root: &Path) -> Result<Vec<String>> {
let path = root.join("pnpm-workspace.yaml");
if !path.is_file() {
return Ok(Vec::new());
}
let contents =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
Ok(parse_pnpm_workspace_packages(&contents))
}
fn parse_pnpm_workspace_packages(contents: &str) -> Vec<String> {
let mut in_packages = false;
let mut patterns = Vec::new();
for raw_line in contents.lines() {
let line_without_comment = raw_line.split('#').next().unwrap_or("").trim_end();
let trimmed = line_without_comment.trim();
if trimmed.is_empty() {
continue;
}
if !raw_line.starts_with(' ') && !raw_line.starts_with('\t') {
in_packages = trimmed == "packages:";
continue;
}
if !in_packages {
continue;
}
if let Some(pattern) = trimmed.strip_prefix('-') {
let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
if !pattern.is_empty() {
patterns.push(pattern.to_string());
}
}
}
patterns
}
fn classify_package_kind(relative_path: &Path) -> WorkspacePackageKind {
match relative_path
.components()
.next()
.and_then(|part| part.as_os_str().to_str())
{
Some("apps") => WorkspacePackageKind::App,
_ => WorkspacePackageKind::Package,
}
}
fn env_status(path: &Path) -> WorkspaceEnvStatus {
if path.join(".env").is_file() {
WorkspaceEnvStatus::Present
} else if path.join(".env.example").is_file() {
WorkspaceEnvStatus::ExampleOnly
} else {
WorkspaceEnvStatus::Missing
}
}
fn setup_status(path: &Path, env_status: &WorkspaceEnvStatus) -> WorkspaceSetupStatus {
if config::config_path(path).is_file() {
WorkspaceSetupStatus::Configured
} else if *env_status == WorkspaceEnvStatus::ExampleOnly {
WorkspaceSetupStatus::NeedsEnv
} else {
WorkspaceSetupStatus::NotConfigured
}
}
fn env_example_keys(path: &Path) -> Result<Vec<String>> {
let example = path.join(".env.example");
if !example.is_file() {
return Ok(Vec::new());
}
let contents = fs::read_to_string(&example)
.with_context(|| format!("failed to read {}", example.display()))?;
Ok(parse_env_example_keys(&contents))
}
fn parse_env_example_keys(contents: &str) -> Vec<String> {
let mut keys = BTreeSet::new();
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let trimmed = trimmed.strip_prefix("export ").unwrap_or(trimmed).trim();
let Some((key, _)) = trimmed.split_once('=') else {
continue;
};
let key = key.trim();
if is_env_key_name(key) {
keys.insert(key.to_string());
}
}
keys.into_iter().collect()
}
fn is_env_key_name(key: &str) -> bool {
!key.is_empty()
&& key
.chars()
.next()
.is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
&& key
.chars()
.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn workspace_name(root: &Path, package_name: Option<&str>) -> String {
package_name
.map(project_slug)
.filter(|name| !name.is_empty())
.or_else(|| {
root.file_name()
.and_then(|name| name.to_str())
.map(project_slug)
})
.unwrap_or_else(|| "workspace".to_string())
}
fn app_slug(package_name: Option<&str>, relative_path: &Path) -> String {
package_name
.and_then(|name| name.rsplit('/').next())
.map(project_slug)
.filter(|slug| !slug.is_empty())
.or_else(|| {
relative_path
.file_name()
.and_then(|name| name.to_str())
.map(project_slug)
})
.unwrap_or_else(|| "app".to_string())
}
fn project_slug(value: &str) -> String {
let mut out = String::new();
for ch in value.trim_start_matches('@').chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch);
} else if ch == '/' || ch == '.' || ch == ' ' {
out.push('-');
}
}
out.trim_matches('-').to_string()
}
fn package_manager_name(value: &str) -> Option<String> {
["pnpm", "npm", "yarn", "bun"]
.iter()
.find(|candidate| value.starts_with(&format!("{candidate}@")))
.map(|candidate| (*candidate).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discovers_pnpm_turborepo_apps_and_skips_library_by_default() {
let tempdir = tempfile::tempdir().unwrap();
fs::write(
tempdir.path().join("package.json"),
r#"{"name":"cms-core","packageManager":"pnpm@9.15.9"}"#,
)
.unwrap();
fs::write(
tempdir.path().join("pnpm-workspace.yaml"),
"packages:\n - \"apps/*\"\n - \"packages/*\"\n",
)
.unwrap();
fs::write(tempdir.path().join("turbo.json"), "{}").unwrap();
for (dir, name, env_file, env_example) in [
("apps/ambienta", "@cms-app/ambienta", false, true),
("apps/core-workbench", "@cms-app/core-workbench", true, true),
("apps/creativestudio", "@cms-app/creativestudio", true, true),
("packages/cms-core", "@cms-core/platform", false, false),
] {
let path = tempdir.path().join(dir);
fs::create_dir_all(&path).unwrap();
fs::write(
path.join("package.json"),
format!(r#"{{"name":"{name}","scripts":{{"dev":"next dev"}}}}"#),
)
.unwrap();
if env_file {
fs::write(path.join(".env"), "DATABASE_URI=mongodb://secret\n").unwrap();
}
if env_example {
fs::write(
path.join(".env.example"),
"DATABASE_URI=\nPAYLOAD_SECRET=\n",
)
.unwrap();
}
}
fs::write(
tempdir.path().join("packages/cms-core/package.json"),
r#"{"name":"@cms-core/platform","scripts":{"build":"tsc"}}"#,
)
.unwrap();
let discovery = discover(tempdir.path()).unwrap().unwrap();
assert!(discovery.turborepo);
assert_eq!(discovery.package_manager.as_deref(), Some("pnpm"));
let apps = discovery
.app_candidates()
.map(|package| package.slug.as_str())
.collect::<Vec<_>>();
assert_eq!(apps, vec!["ambienta", "core-workbench", "creativestudio"]);
assert_eq!(discovery.packages[0].project_name, "cms-core:ambienta");
assert_eq!(
discovery.packages[0].env_status,
WorkspaceEnvStatus::ExampleOnly
);
assert_eq!(
discovery.packages[0].setup_status,
WorkspaceSetupStatus::NeedsEnv
);
assert!(discovery.packages[0]
.env_example_keys
.contains(&"PAYLOAD_SECRET".to_string()));
}
#[test]
fn package_json_workspaces_are_supported() {
let tempdir = tempfile::tempdir().unwrap();
fs::write(
tempdir.path().join("package.json"),
r#"{"name":"root","workspaces":["apps/*"]}"#,
)
.unwrap();
let app = tempdir.path().join("apps/site");
fs::create_dir_all(&app).unwrap();
fs::write(
app.join("package.json"),
r#"{"name":"@scope/site","scripts":{"dev":"vite"}}"#,
)
.unwrap();
let discovery = discover(tempdir.path()).unwrap().unwrap();
assert_eq!(discovery.packages.len(), 1);
assert_eq!(discovery.packages[0].project_name, "root:site");
}
#[test]
fn env_example_key_parser_ignores_values_and_invalid_lines() {
let keys = parse_env_example_keys(
r#"
# comment
DATABASE_URI=<fill me>
export PAYLOAD_SECRET=
not a dotenv line
1_BAD=value
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
"#,
);
assert_eq!(
keys,
vec![
"DATABASE_URI".to_string(),
"NEXT_PUBLIC_SERVER_URL".to_string(),
"PAYLOAD_SECRET".to_string(),
]
);
}
}