forge-pilot 0.1.0

Closed-loop orchestrator over runtime advisories, kernel oracles, and canonical Forge export/import lanes
Documentation
use crate::bootstrap::classify::{classify_language, is_supported_text_file};
use crate::bootstrap::ignore::should_skip_workspace_dir;
use crate::bootstrap::types::{
    BootstrapSourceSkippedFile, SourceFileRecord, MAX_SOURCE_FILE_BYTES,
};
use crate::error::PilotError;
use stack_ids::ContentDigest;
use std::fs;
use std::path::Path;

#[derive(Debug, Clone)]
pub(crate) struct WorkspaceScan {
    pub files: Vec<SourceFileRecord>,
    pub skipped_files: Vec<BootstrapSourceSkippedFile>,
}

pub(crate) fn scan_workspace_source_files(
    workspace_path: &Path,
) -> Result<WorkspaceScan, PilotError> {
    let mut files = Vec::new();
    let mut skipped_files = Vec::new();
    let mut stack = vec![workspace_path.to_path_buf()];

    while let Some(path) = stack.pop() {
        let entries = fs::read_dir(&path).map_err(|error| {
            PilotError::Other(format!(
                "failed to read workspace directory {}: {}",
                path.display(),
                error
            ))
        })?;
        for entry in entries {
            let entry = entry.map_err(|error| {
                PilotError::Other(format!(
                    "failed to read workspace entry under {}: {}",
                    path.display(),
                    error
                ))
            })?;
            let entry_path = entry.path();
            let file_type = entry.file_type().map_err(|error| {
                PilotError::Other(format!(
                    "failed to inspect workspace entry {}: {}",
                    entry_path.display(),
                    error
                ))
            })?;
            if file_type.is_dir() {
                if should_skip_workspace_dir(&entry_path, None) {
                    continue;
                }
                stack.push(entry_path);
                continue;
            }
            if !file_type.is_file() {
                continue;
            }

            let relative_path = normalize_relative_path(workspace_path, &entry_path)?;
            if !is_supported_text_file(&relative_path) {
                continue;
            }

            let bytes = fs::read(&entry_path).map_err(|error| {
                PilotError::Other(format!(
                    "failed to read source file {}: {}",
                    entry_path.display(),
                    error
                ))
            })?;
            if bytes.len() > MAX_SOURCE_FILE_BYTES {
                skipped_files.push(BootstrapSourceSkippedFile {
                    path: relative_path,
                    reason: format!(
                        "file exceeds {} byte bootstrap limit",
                        MAX_SOURCE_FILE_BYTES
                    ),
                });
                continue;
            }

            let content = match String::from_utf8(bytes) {
                Ok(content) => content,
                Err(_) => {
                    skipped_files.push(BootstrapSourceSkippedFile {
                        path: relative_path,
                        reason: "file is not valid UTF-8 text".into(),
                    });
                    continue;
                }
            };

            let content_digest = ContentDigest::compute(content.as_bytes());
            let line_count = content.lines().count();
            let byte_count = content.len();
            let file_extension = Path::new(&relative_path)
                .extension()
                .and_then(|ext| ext.to_str())
                .map(|ext| ext.to_string());
            let language = classify_language(&relative_path)
                .unwrap_or("generic")
                .to_string();

            files.push(SourceFileRecord {
                relative_path,
                content,
                content_digest,
                line_count,
                byte_count,
                file_extension,
                language,
            });
        }
    }

    files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
    skipped_files.sort_by(|left, right| left.path.cmp(&right.path));
    Ok(WorkspaceScan {
        files,
        skipped_files,
    })
}

pub(crate) fn normalize_relative_path(root: &Path, path: &Path) -> Result<String, PilotError> {
    let relative = path.strip_prefix(root).map_err(|error| {
        PilotError::Other(format!(
            "failed to normalize source path {} relative to {}: {}",
            path.display(),
            root.display(),
            error
        ))
    })?;
    Ok(relative
        .components()
        .map(|component| component.as_os_str().to_string_lossy().to_string())
        .collect::<Vec<_>>()
        .join("/"))
}