rebecca-core 0.2.0

Core planning, safety, scanning, and history models for Rebecca.
Documentation
use std::collections::{BTreeSet, VecDeque};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use crate::error::{RebeccaError, Result};
use crate::scan::ScanCancellationToken;

use super::catalog::{
    cachedir_tag_context_match, cachedir_tag_definition, is_known_project_artifact_dir_name,
    rule_match_for_directory, should_prune_scan_dir,
};
use super::{
    ProjectArtifactCandidate, ProjectArtifactContextMatch, ProjectArtifactDefinition,
    ProjectArtifactScanOptions,
};

const CACHEDIR_TAG_FILE_NAME: &str = "CACHEDIR.TAG";
const CACHEDIR_TAG_SIGNATURE: &str = "Signature: 8a477f597d28d172789f06886806bc55";

pub fn discover_project_artifacts(
    options: &ProjectArtifactScanOptions,
    cancellation: &ScanCancellationToken,
) -> Result<Vec<ProjectArtifactCandidate>> {
    let mut candidates = Vec::new();
    let mut seen_paths = BTreeSet::new();

    for root in &options.roots {
        scan_root(
            root,
            options.max_depth,
            cancellation,
            &mut seen_paths,
            &mut candidates,
        )?;
    }

    candidates.sort_by(|left, right| {
        left.path
            .cmp(&right.path)
            .then_with(|| left.definition.rule_id.cmp(right.definition.rule_id))
    });
    Ok(candidates)
}

pub fn recently_modified_reason(path: &Path, min_age_days: u64) -> Option<String> {
    if min_age_days == 0 || !is_recently_modified(path, min_age_days, SystemTime::now()) {
        return None;
    }

    Some(format!(
        "project artifact was modified within the last {}",
        format_days(min_age_days)
    ))
}

fn is_recently_modified(path: &Path, min_age_days: u64, now: SystemTime) -> bool {
    let Ok(metadata) = fs::symlink_metadata(path) else {
        return false;
    };
    let Ok(modified) = metadata.modified() else {
        return false;
    };

    let age = now.duration_since(modified).unwrap_or(Duration::ZERO);
    age.as_secs() < min_age_days.saturating_mul(24 * 60 * 60)
}

fn format_days(days: u64) -> String {
    if days == 1 {
        "1 day".to_string()
    } else {
        format!("{days} days")
    }
}

fn scan_root(
    root: &Path,
    max_depth: usize,
    cancellation: &ScanCancellationToken,
    seen_paths: &mut BTreeSet<String>,
    candidates: &mut Vec<ProjectArtifactCandidate>,
) -> Result<()> {
    check_cancelled(cancellation)?;

    let metadata = match fs::symlink_metadata(root) {
        Ok(metadata) => metadata,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(err) => return Err(err.into()),
    };

    if !metadata.is_dir() || crate::safety::is_reparse_like(&metadata) {
        return Ok(());
    }

    let mut pending = VecDeque::from([(root.to_path_buf(), 0_usize)]);
    while let Some((dir, depth)) = pending.pop_front() {
        check_cancelled(cancellation)?;

        if let Some(name) = dir.file_name().and_then(|name| name.to_str()) {
            if let Some(rule_match) = rule_match_for_directory(&dir, name) {
                push_candidate(
                    rule_match.definition,
                    dir,
                    rule_match.context,
                    seen_paths,
                    candidates,
                );
                continue;
            }

            if should_prune_scan_dir(name) {
                continue;
            }

            if is_known_project_artifact_dir_name(name) {
                continue;
            }
        }

        if depth > 0 && has_valid_cachedir_tag(&dir) {
            push_candidate(
                cachedir_tag_definition(),
                dir.clone(),
                cachedir_tag_context_match(&dir),
                seen_paths,
                candidates,
            );
            continue;
        }

        if depth >= max_depth {
            continue;
        }

        let entries = match fs::read_dir(&dir) {
            Ok(entries) => entries,
            Err(err) => {
                tracing::debug!(
                    path = %dir.display(),
                    error = %err,
                    "project artifact directory read skipped"
                );
                continue;
            }
        };
        let mut child_dirs = Vec::new();

        for entry in entries {
            let entry = match entry {
                Ok(entry) => entry,
                Err(err) => {
                    tracing::debug!(
                        path = %dir.display(),
                        error = %err,
                        "project artifact directory entry skipped"
                    );
                    continue;
                }
            };
            let path = entry.path();
            let metadata = match fs::symlink_metadata(&path) {
                Ok(metadata) => metadata,
                Err(err) => {
                    tracing::debug!(
                        path = %path.display(),
                        error = %err,
                        "project artifact metadata read skipped"
                    );
                    continue;
                }
            };

            if metadata.is_dir() && !crate::safety::is_reparse_like(&metadata) {
                child_dirs.push(path);
            }
        }

        child_dirs.sort();
        for child in child_dirs {
            pending.push_back((child, depth.saturating_add(1)));
        }
    }

    Ok(())
}

fn has_valid_cachedir_tag(dir: &Path) -> bool {
    let tag = dir.join(CACHEDIR_TAG_FILE_NAME);
    let Ok(metadata) = fs::symlink_metadata(&tag) else {
        return false;
    };

    if !metadata.is_file() || crate::safety::is_reparse_like(&metadata) {
        return false;
    }

    let Ok(contents) = fs::read_to_string(&tag) else {
        return false;
    };

    contents.starts_with(CACHEDIR_TAG_SIGNATURE)
}

fn push_candidate(
    definition: ProjectArtifactDefinition,
    path: PathBuf,
    context: ProjectArtifactContextMatch,
    seen_paths: &mut BTreeSet<String>,
    candidates: &mut Vec<ProjectArtifactCandidate>,
) {
    let key = path.as_os_str().to_string_lossy().replace('\\', "/");
    if seen_paths.insert(key.to_ascii_lowercase()) {
        candidates.push(ProjectArtifactCandidate {
            definition,
            path: path.clone(),
            context,
            modified_at_unix_seconds: modified_at_unix_seconds(&path),
        });
    }
}

fn modified_at_unix_seconds(path: &Path) -> Option<u64> {
    let metadata = fs::symlink_metadata(path).ok()?;
    metadata
        .modified()
        .ok()
        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
        .map(|duration| duration.as_secs())
}

fn check_cancelled(cancellation: &ScanCancellationToken) -> Result<()> {
    if cancellation.is_cancelled() {
        return Err(RebeccaError::OperationCancelled(
            "project artifact scan was cancelled".to_string(),
        ));
    }

    Ok(())
}