Skip to main content

fallow_config/
fixability.rs

1use std::path::{Path, PathBuf};
2
3use crate::FallowConfig;
4
5/// Classification of whether fallow can apply config edits at `root`.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum ConfigFixPlan {
8    /// A fallow config file exists; append entries in place.
9    Edit {
10        /// Path to the existing config file to edit.
11        config_path: PathBuf,
12    },
13    /// No fallow config exists, but a workspace marker sits above `root`,
14    /// so creating one inside this subpackage would fragment the monorepo.
15    BlockedMonorepo {
16        /// Directory containing the workspace marker that blocked creation.
17        workspace_root: PathBuf,
18    },
19    /// No fallow config exists and config creation was disabled.
20    BlockedNoCreate {
21        /// Path where the config would have been created.
22        target: PathBuf,
23    },
24    /// No fallow config exists; the writer can create one at `target`.
25    Create {
26        /// Path for the new config file (`<root>/.fallowrc.json`).
27        target: PathBuf,
28    },
29}
30
31/// Classify how config-editing fixes should behave for `root`.
32#[must_use]
33pub fn classify_config_fix_plan(
34    root: &Path,
35    explicit: Option<&PathBuf>,
36    no_create_config: bool,
37) -> ConfigFixPlan {
38    if let Some(existing) = resolve_existing_config_path(root, explicit) {
39        return ConfigFixPlan::Edit {
40            config_path: existing,
41        };
42    }
43    let target = root.join(".fallowrc.json");
44    if let Some(workspace_root) = find_workspace_root_above(root) {
45        return ConfigFixPlan::BlockedMonorepo { workspace_root };
46    }
47    if no_create_config {
48        return ConfigFixPlan::BlockedNoCreate { target };
49    }
50    ConfigFixPlan::Create { target }
51}
52
53/// Whether `fallow fix --yes` can apply config edits at `root` with default
54/// config-creation behavior. Drives JSON `auto_fixable` for config actions.
55#[must_use]
56pub fn is_config_fixable(root: &Path, explicit: Option<&PathBuf>) -> bool {
57    matches!(
58        classify_config_fix_plan(root, explicit, false),
59        ConfigFixPlan::Edit { .. } | ConfigFixPlan::Create { .. }
60    )
61}
62
63fn resolve_existing_config_path(root: &Path, explicit: Option<&PathBuf>) -> Option<PathBuf> {
64    if let Some(path) = explicit {
65        let absolute = if path.is_absolute() {
66            path.clone()
67        } else {
68            std::env::current_dir().map_or_else(|_| path.clone(), |cwd| cwd.join(path))
69        };
70        if absolute.exists() {
71            return Some(absolute);
72        }
73        return None;
74    }
75    FallowConfig::find_config_path(root)
76}
77
78fn find_workspace_root_above(start: &Path) -> Option<PathBuf> {
79    let mut current = start.parent()?;
80    loop {
81        if has_workspace_marker(current) {
82            return Some(current.to_path_buf());
83        }
84        current = current.parent()?;
85    }
86}
87
88fn has_workspace_marker(dir: &Path) -> bool {
89    const SENTINELS: &[&str] = &[
90        "pnpm-workspace.yaml",
91        "turbo.json",
92        "lerna.json",
93        "rush.json",
94    ];
95    for name in SENTINELS {
96        if dir.join(name).exists() {
97            return true;
98        }
99    }
100    if crate::workspace::load_root_deno_workspace_patterns(dir)
101        .ok()
102        .flatten()
103        .is_some_and(|(_path, patterns)| !patterns.is_empty())
104    {
105        return true;
106    }
107    let pkg_path = dir.join("package.json");
108    if !pkg_path.exists() {
109        return false;
110    }
111    let Ok(content) = std::fs::read_to_string(&pkg_path) else {
112        return false;
113    };
114    let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else {
115        return false;
116    };
117    value
118        .get("workspaces")
119        .is_some_and(|v| v.is_array() || v.is_object())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn config_fixable_true_when_config_exists() {
128        let dir = tempfile::tempdir().unwrap();
129        std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
130        assert!(is_config_fixable(dir.path(), None));
131    }
132
133    #[test]
134    fn config_fixable_true_when_can_create_at_root() {
135        let dir = tempfile::tempdir().unwrap();
136        assert!(is_config_fixable(dir.path(), None));
137    }
138
139    #[test]
140    fn config_fixable_false_when_monorepo_subpackage() {
141        let dir = tempfile::tempdir().unwrap();
142        std::fs::write(
143            dir.path().join("pnpm-workspace.yaml"),
144            "packages:\n  - packages/*\n",
145        )
146        .unwrap();
147        let sub = dir.path().join("packages/app");
148        std::fs::create_dir_all(&sub).unwrap();
149        assert!(!is_config_fixable(&sub, None));
150    }
151}