fallow-cli 2.75.0

CLI for fallow, Rust-native codebase intelligence for TypeScript and JavaScript
Documentation
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};

use fallow_config::{FallowConfig, IgnoreExportRule, OutputFormat};
use fallow_core::results::{AnalysisResults, DuplicateExport};
use rustc_hash::FxHashSet;

pub(super) fn apply_config_fixes(
    root: &Path,
    config_path: Option<&PathBuf>,
    results: &AnalysisResults,
    output: OutputFormat,
    dry_run: bool,
    fixes: &mut Vec<serde_json::Value>,
) -> bool {
    if results.duplicate_exports.is_empty() {
        return false;
    }

    let Some(config_path) = resolve_config_path(root, config_path) else {
        if !matches!(output, OutputFormat::Json) {
            eprintln!(
                "Skipped duplicate-export config fix: no fallow config file at {}. \
                 Run `fallow init` to create one, then re-run `fallow fix --yes`. \
                 (Note: `package.json#fallow` is not a supported config location.)",
                root.display()
            );
        }
        fixes.push(serde_json::json!({
            "type": "add_ignore_exports",
            "config_key": "ignoreExports",
            "skipped": true,
            "skip_reason": "missing_config",
            "description": "Skipped: no fallow config file was found. Run `fallow init` to create one.",
        }));
        return false;
    };

    let entries = ignore_export_entries(root, &config_path, &results.duplicate_exports);
    if entries.is_empty() {
        return false;
    }

    let config_file = display_path(root, &config_path);
    if dry_run {
        if !matches!(output, OutputFormat::Json) {
            eprintln!(
                "Would add {} ignoreExports rule(s) to {}",
                entries.len(),
                config_file
            );
        }
        fixes.push(serde_json::json!({
            "type": "add_ignore_exports",
            "config_key": "ignoreExports",
            "file": config_file,
            "entries": entries,
        }));
        return false;
    }

    match fallow_config::add_ignore_exports_rule(&config_path, &entries) {
        Ok(()) => {
            fixes.push(serde_json::json!({
                "type": "add_ignore_exports",
                "config_key": "ignoreExports",
                "file": config_file,
                "entries": entries,
                "applied": true,
            }));
            false
        }
        Err(e) => {
            eprintln!(
                "Error: failed to write ignoreExports rules to {}: {e}",
                config_path.display()
            );
            true
        }
    }
}

fn resolve_config_path(root: &Path, explicit: Option<&PathBuf>) -> Option<PathBuf> {
    explicit.map_or_else(
        || FallowConfig::find_config_path(root),
        |path| {
            if path.is_absolute() {
                Some(path.clone())
            } else {
                std::env::current_dir()
                    .ok()
                    .map_or_else(|| Some(path.clone()), |cwd| Some(cwd.join(path)))
            }
        },
    )
}

fn ignore_export_entries(
    root: &Path,
    config_path: &Path,
    duplicate_exports: &[DuplicateExport],
) -> Vec<IgnoreExportRule> {
    let config_dir = config_path.parent().unwrap_or(root);
    let mut seen = FxHashSet::default();
    let mut entries = Vec::new();
    for item in duplicate_exports {
        for location in &item.locations {
            let file = relative_from_config_dir(root, config_dir, &location.path);
            if seen.insert(file.clone()) {
                entries.push(IgnoreExportRule {
                    file,
                    exports: vec!["*".to_owned()],
                });
            }
        }
    }
    entries
}

fn relative_from_config_dir(root: &Path, config_dir: &Path, file_path: &Path) -> String {
    let root_relative = file_path.strip_prefix(root).unwrap_or(file_path);
    let config_relative = config_dir
        .strip_prefix(root)
        .unwrap_or_else(|_| Path::new(""));
    lexical_relative(config_relative, root_relative)
        .unwrap_or_else(|| root_relative.to_path_buf())
        .to_string_lossy()
        .replace('\\', "/")
}

fn lexical_relative(from_dir: &Path, to_file: &Path) -> Option<PathBuf> {
    let from = normal_components(from_dir)?;
    let to = normal_components(to_file)?;
    let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
    let mut relative = PathBuf::new();
    for _ in common..from.len() {
        relative.push("..");
    }
    for component in &to[common..] {
        relative.push(component);
    }
    Some(relative)
}

fn normal_components(path: &Path) -> Option<Vec<OsString>> {
    let mut components = Vec::new();
    for component in path.components() {
        match component {
            Component::Normal(value) => components.push(value.to_os_string()),
            Component::CurDir => {}
            Component::ParentDir => components.push(OsString::from("..")),
            Component::RootDir | Component::Prefix(_) => return None,
        }
    }
    Some(components)
}

fn display_path(root: &Path, path: &Path) -> String {
    path.strip_prefix(root)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/")
}

#[cfg(test)]
mod tests {
    use super::*;
    use fallow_core::results::DuplicateLocation;

    fn duplicate(paths: &[PathBuf]) -> DuplicateExport {
        DuplicateExport {
            export_name: "Button".to_owned(),
            locations: paths
                .iter()
                .map(|path| DuplicateLocation {
                    path: path.clone(),
                    line: 1,
                    col: 0,
                })
                .collect(),
        }
    }

    #[test]
    fn config_fix_reanchors_paths_to_workspace_config_dir() {
        let root = Path::new("/repo");
        let config_path = root.join("packages/ui/.fallowrc.json");
        let entries = ignore_export_entries(
            root,
            &config_path,
            &[duplicate(&[
                root.join("packages/ui/src/index.ts"),
                root.join("packages/shared/src/index.ts"),
            ])],
        );

        assert_eq!(entries[0].file, "src/index.ts");
        assert_eq!(entries[1].file, "../shared/src/index.ts");
    }

    #[test]
    fn config_fix_dedupes_exact_files_preserving_first_order() {
        let root = Path::new("/repo");
        let config_path = root.join(".fallowrc.json");
        let entries = ignore_export_entries(
            root,
            &config_path,
            &[duplicate(&[
                root.join("src/a.ts"),
                root.join("src/b.ts"),
                root.join("src/a.ts"),
            ])],
        );

        let files: Vec<&str> = entries.iter().map(|entry| entry.file.as_str()).collect();
        assert_eq!(files, vec!["src/a.ts", "src/b.ts"]);
    }
}