cargo-port 0.2.0

A TUI for inspecting and managing Rust projects
use std::path::Path;
use std::time::Duration;

#[cfg(test)]
use notify::Event;
use notify::event::EventKind;

use super::constants::DELETE_LINT_DEBOUNCE;
use super::constants::LINT_DEBOUNCE;
use crate::constants::CARGO_CONFIG;
use crate::constants::CARGO_CONFIG_TOML;
use crate::constants::CARGO_LOCK;
use crate::constants::CARGO_TOML;
use crate::constants::DOT_CARGO_DIR;
use crate::constants::GIT_DIR;
use crate::constants::RUST_TOOLCHAIN;
use crate::constants::RUST_TOOLCHAIN_TOML;
use crate::constants::TARGET_DIR;
use crate::project::AbsolutePath;

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LintTriggerKind {
    Manifest,
    Lockfile,
    RustSource,
    /// Synthetic trigger fired by the app's post-startup staleness check
    /// (see `App::kick_off_startup_lints`), not produced by a watcher event.
    Startup,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LintEventKind {
    CreateOrModify,
    Remove,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LintTriggerEvent {
    pub project_root: AbsolutePath,
    pub trigger:      LintTriggerKind,
    pub event_kind:   LintEventKind,
}

impl LintTriggerEvent {
    pub const fn is_removal(&self) -> bool { matches!(self.event_kind, LintEventKind::Remove) }

    pub const fn debounce(&self) -> Duration {
        if self.is_removal() {
            DELETE_LINT_DEBOUNCE
        } else {
            LINT_DEBOUNCE
        }
    }
}

/// Kind of trigger for a `cargo metadata` refresh. Driven by the same
/// watcher events that drive lint runs; callers dispatch a refresh on any
/// match. See `docs/cargo_metadata.md` → **In-flight race handling** for
/// why the fingerprint — rather than the kind — decides whether a pending
/// spawn is still relevant.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CargoMetadataTriggerKind {
    Manifest,
    Lockfile,
    Toolchain,
    Config,
}

/// Does `path` (under `project_root`) warrant a `cargo metadata` refresh?
///
/// Hits:
/// - `<any>/Cargo.toml`
/// - `<any>/Cargo.lock`
/// - `<any>/rust-toolchain` or `<any>/rust-toolchain.toml`
/// - `<any>/.cargo/config` or `<any>/.cargo/config.toml`
///
/// Skips events under `target/` and `.git/` directories. The
/// `path.starts_with(project_root)` gate ensures out-of-tree hits do not
/// leak in through the shared recursive watch — the ancestor
/// `.cargo/config` case that lives *above* the project is handled
/// separately by the ancestor watch-set subsystem.
pub(crate) fn classify_cargo_metadata_event_path(
    project_root: &Path,
    path: &Path,
) -> Option<CargoMetadataTriggerKind> {
    if !path.starts_with(project_root) {
        return None;
    }
    if path.components().any(|component| {
        let part = component.as_os_str();
        part == TARGET_DIR || part == GIT_DIR
    }) {
        return None;
    }
    classify_cargo_metadata_basename(path)
}

/// Basename-only variant used by the ancestor `.cargo/` watch-set path,
/// where the `starts_with(project_root)` gate does not apply.
pub(crate) fn classify_cargo_metadata_basename(path: &Path) -> Option<CargoMetadataTriggerKind> {
    let file_name = path.file_name().and_then(|name| name.to_str())?;
    match file_name {
        CARGO_TOML => Some(CargoMetadataTriggerKind::Manifest),
        CARGO_LOCK => Some(CargoMetadataTriggerKind::Lockfile),
        RUST_TOOLCHAIN | RUST_TOOLCHAIN_TOML => Some(CargoMetadataTriggerKind::Toolchain),
        CARGO_CONFIG | CARGO_CONFIG_TOML => {
            let parent_is_dot_cargo = path
                .parent()
                .and_then(|parent| parent.file_name())
                .and_then(|name| name.to_str())
                .is_some_and(|name| name == DOT_CARGO_DIR);
            parent_is_dot_cargo.then_some(CargoMetadataTriggerKind::Config)
        },
        _ => None,
    }
}

#[cfg(test)]
fn classify_event(project_root: &Path, event: &Event) -> Option<LintTriggerEvent> {
    event
        .paths
        .iter()
        .find_map(|path| classify_event_path(project_root, event.kind, path))
}

pub(crate) fn classify_event_path(
    project_root: &Path,
    event_kind: EventKind,
    path: &Path,
) -> Option<LintTriggerEvent> {
    if !path.starts_with(project_root) {
        return None;
    }
    if path.components().any(|component| {
        let part = component.as_os_str();
        part == TARGET_DIR || part == GIT_DIR
    }) {
        return None;
    }

    let file_name = path.file_name().and_then(|name| name.to_str())?;
    let trigger = if file_name == CARGO_TOML {
        LintTriggerKind::Manifest
    } else if file_name == CARGO_LOCK {
        LintTriggerKind::Lockfile
    } else if path.extension().is_some_and(|ext| ext == "rs") {
        LintTriggerKind::RustSource
    } else {
        return None;
    };

    let removal = matches!(event_kind, EventKind::Remove(_));
    let event_kind = if removal {
        LintEventKind::Remove
    } else if matches!(
        event_kind,
        EventKind::Any | EventKind::Create(_) | EventKind::Modify(_)
    ) {
        LintEventKind::CreateOrModify
    } else {
        // Access events (file opens/reads/closes) are not a reason to re-lint.
        // The Linux inotify backend can surface these; ignore them so a bare
        // read never triggers a run. The watcher already masks them out via
        // `EventKindMask::CORE`; this keeps the classifier correct regardless
        // of the watcher's config.
        return None;
    };

    Some(LintTriggerEvent {
        project_root: AbsolutePath::from(project_root),
        trigger,
        event_kind,
    })
}

#[cfg(test)]
#[allow(
    clippy::expect_used,
    reason = "tests should panic on unexpected values"
)]
mod tests {
    use std::path::Path;

    use notify::event::DataChange;
    use notify::event::ModifyKind;
    use notify::event::RemoveKind;

    use super::*;

    #[test]
    fn relevant_changes_ignore_git_and_target_paths() {
        let project_dir = tempfile::tempdir().expect("tempdir");
        let modify_kind = EventKind::Modify(ModifyKind::Data(DataChange::Any));

        assert_eq!(
            classify_event_path(
                project_dir.path(),
                modify_kind,
                &project_dir.path().join("src/main.rs")
            )
            .expect("src main trigger")
            .trigger,
            LintTriggerKind::RustSource
        );
        assert_eq!(
            classify_event_path(
                project_dir.path(),
                modify_kind,
                &project_dir.path().join("Cargo.toml")
            )
            .expect("manifest trigger")
            .trigger,
            LintTriggerKind::Manifest
        );
        assert!(
            classify_event_path(
                project_dir.path(),
                modify_kind,
                &project_dir.path().join("target/debug/app")
            )
            .is_none()
        );
        assert!(
            classify_event_path(
                project_dir.path(),
                modify_kind,
                &project_dir.path().join(".git/index")
            )
            .is_none()
        );
    }

    #[test]
    fn cargo_metadata_basename_classifier_matches_irrespective_of_project_root() {
        // The basename-only variant is how the (future) ancestor `.cargo/`
        // watch-set subsystem will classify events that live *above* any
        // registered project, where the `starts_with(project_root)` gate
        // on `classify_cargo_metadata_event_path` would filter them out.

        let hits = [
            (
                Path::new("/home/user/.cargo/config.toml"),
                CargoMetadataTriggerKind::Config,
            ),
            (
                Path::new("/home/user/.cargo/config"),
                CargoMetadataTriggerKind::Config,
            ),
            (
                Path::new("/opt/proj/Cargo.toml"),
                CargoMetadataTriggerKind::Manifest,
            ),
            (
                Path::new("/opt/proj/Cargo.lock"),
                CargoMetadataTriggerKind::Lockfile,
            ),
            (
                Path::new("/opt/proj/rust-toolchain"),
                CargoMetadataTriggerKind::Toolchain,
            ),
            (
                Path::new("/opt/proj/rust-toolchain.toml"),
                CargoMetadataTriggerKind::Toolchain,
            ),
        ];
        for (path, expected) in hits {
            assert_eq!(
                classify_cargo_metadata_basename(path),
                Some(expected),
                "expected basename hit for {}",
                path.display()
            );
        }

        // `config.toml` without a `.cargo` parent is a miss — otherwise
        // any ambient TOML file would trigger a refresh.
        let misses = [
            Path::new("/home/user/some/config.toml"),
            Path::new("/etc/config"),
            Path::new("/home/user/Cargo.toml.bak"),
        ];
        for path in misses {
            assert_eq!(
                classify_cargo_metadata_basename(path),
                None,
                "unexpected basename hit for {}",
                path.display()
            );
        }
    }

    #[test]
    fn cargo_metadata_classifier_hits_manifest_lock_toolchain_and_cargo_config() {
        let project_dir = tempfile::tempdir().expect("tempdir");
        let root = project_dir.path();

        let hits = [
            (root.join("Cargo.toml"), CargoMetadataTriggerKind::Manifest),
            (root.join("Cargo.lock"), CargoMetadataTriggerKind::Lockfile),
            (
                root.join("rust-toolchain.toml"),
                CargoMetadataTriggerKind::Toolchain,
            ),
            (
                root.join("rust-toolchain"),
                CargoMetadataTriggerKind::Toolchain,
            ),
            (
                root.join(".cargo/config.toml"),
                CargoMetadataTriggerKind::Config,
            ),
            (root.join(".cargo/config"), CargoMetadataTriggerKind::Config),
            (
                root.join("nested/member/Cargo.toml"),
                CargoMetadataTriggerKind::Manifest,
            ),
        ];
        for (path, expected) in hits {
            assert_eq!(
                classify_cargo_metadata_event_path(root, &path),
                Some(expected),
                "expected metadata trigger for {}",
                path.display()
            );
        }

        let misses = [
            root.join("src/main.rs"),
            root.join("README.md"),
            root.join("Cargo.toml.bak"),
            root.join("target/debug/build.lock"),
            root.join(".git/config"),
            // `config.toml` *not* under a `.cargo/` parent must miss.
            root.join("docs/config.toml"),
        ];
        for path in &misses {
            assert_eq!(
                classify_cargo_metadata_event_path(root, path),
                None,
                "unexpected metadata trigger for {}",
                path.display()
            );
        }
    }

    #[test]
    fn remove_events_use_longer_debounce() {
        let project_dir = tempfile::tempdir().expect("tempdir");
        let source_path = project_dir.path().join("src/lib.rs");
        let remove_event = Event {
            kind:  EventKind::Remove(RemoveKind::File),
            paths: vec![source_path.clone()],
            attrs: notify::event::EventAttributes::default(),
        };
        let modify_event = Event {
            kind:  EventKind::Modify(ModifyKind::Data(DataChange::Any)),
            paths: vec![source_path],
            attrs: notify::event::EventAttributes::default(),
        };

        assert_eq!(
            classify_event(project_dir.path(), &remove_event)
                .expect("remove trigger")
                .debounce(),
            DELETE_LINT_DEBOUNCE
        );
        assert_eq!(
            classify_event(project_dir.path(), &modify_event)
                .expect("modify trigger")
                .debounce(),
            LINT_DEBOUNCE
        );
    }
}