paraglide-launch 0.1.2

Analyze a project and detect deployable services, languages, frameworks, commands, and env vars
Documentation
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
pub struct DirEntry {
    pub path: PathBuf,
    pub name: String,
    pub is_dir: bool,
}

pub trait FileSystem: Send + Sync {
    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>>;
    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
    fn exists(&self, path: &Path) -> bool;
}

// -- LocalFs --

pub struct LocalFs;

impl FileSystem for LocalFs {
    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
        std::fs::read(path)
    }

    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
        let mut entries = Vec::new();
        for entry in std::fs::read_dir(path)? {
            let entry = entry?;
            let ft = entry.file_type()?;
            entries.push(DirEntry {
                path: entry.path(),
                name: entry.file_name().to_string_lossy().into_owned(),
                is_dir: ft.is_dir(),
            });
        }
        Ok(entries)
    }

    fn exists(&self, path: &Path) -> bool {
        path.exists()
    }
}

// -- RootedFs --

/// Wraps LocalFs to root all relative paths to a base directory.
/// Solves the CWD mismatch: walk_local strips paths to relative,
/// but generate needs to read files relative to the scanned root, not CWD.
pub struct RootedFs {
    root: PathBuf,
}

impl RootedFs {
    pub fn new(root: &Path) -> Self {
        Self {
            root: root.to_path_buf(),
        }
    }

    fn resolve(&self, path: &Path) -> PathBuf {
        if path.is_absolute() {
            path.to_path_buf()
        } else {
            self.root.join(path)
        }
    }
}

impl FileSystem for RootedFs {
    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
        std::fs::read(self.resolve(path))
    }

    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
        let resolved = self.resolve(path);
        let mut entries = Vec::new();
        for entry in std::fs::read_dir(&resolved)? {
            let entry = entry?;
            let ft = entry.file_type()?;
            entries.push(DirEntry {
                path: entry.path(),
                name: entry.file_name().to_string_lossy().into_owned(),
                is_dir: ft.is_dir(),
            });
        }
        Ok(entries)
    }

    fn exists(&self, path: &Path) -> bool {
        self.resolve(path).exists()
    }
}

// -- MemoryFs --

pub struct MemoryFs {
    files: HashMap<PathBuf, Vec<u8>>,
}

impl MemoryFs {
    pub fn new(entries: &[(&str, &str)]) -> Self {
        let mut files = HashMap::new();
        for (path, content) in entries {
            files.insert(normalize(path), content.as_bytes().to_vec());
        }
        Self { files }
    }

    pub fn from_bytes(entries: &[(&str, &[u8])]) -> Self {
        let mut files = HashMap::new();
        for (path, content) in entries {
            files.insert(normalize(path), content.to_vec());
        }
        Self { files }
    }
}

impl FileSystem for MemoryFs {
    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
        let key = normalize(&path.to_string_lossy());
        self.files
            .get(&key)
            .cloned()
            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("{}", path.display())))
    }

    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
        let dir = normalize(&path.to_string_lossy());
        let prefix = if dir.as_os_str().is_empty() {
            PathBuf::new()
        } else {
            dir.clone()
        };

        let mut seen = HashSet::new();
        let mut entries = Vec::new();

        for file_path in self.files.keys() {
            let child_name = if prefix.as_os_str().is_empty() {
                // Root directory: take the first component
                file_path.components().next()
            } else if file_path.starts_with(&prefix) {
                // Subdirectory: strip prefix and take the first remaining component
                file_path
                    .strip_prefix(&prefix)
                    .ok()
                    .and_then(|rest| rest.components().next())
            } else {
                None
            };

            if let Some(component) = child_name {
                let name = component.as_os_str().to_string_lossy().into_owned();
                if seen.insert(name.clone()) {
                    let child_path = if prefix.as_os_str().is_empty() {
                        PathBuf::from(&name)
                    } else {
                        prefix.join(&name)
                    };
                    // It's a file if the exact path exists in our map, otherwise it's a directory
                    let is_dir = !self.files.contains_key(&child_path);
                    entries.push(DirEntry {
                        path: child_path,
                        name,
                        is_dir,
                    });
                }
            }
        }

        if entries.is_empty() && !self.exists(path) {
            return Err(io::Error::new(
                io::ErrorKind::NotFound,
                format!("{}", path.display()),
            ));
        }

        Ok(entries)
    }

    fn exists(&self, path: &Path) -> bool {
        let key = normalize(&path.to_string_lossy());
        // Exact file match
        if self.files.contains_key(&key) {
            return true;
        }
        // Root dir always exists (even if empty)
        if key.as_os_str().is_empty() {
            return true;
        }
        // Directory inference: exists if any file has this as a prefix
        self.files.keys().any(|p| p.starts_with(&key))
    }
}

/// Normalize a dir string for use as a HashMap key in the signal pipeline.
/// Strips leading "./", trailing "/", normalizes empty to ".".
/// Returns a borrow when no transformation is needed.
pub(crate) fn normalize_dir<'a>(dir: &'a str) -> Cow<'a, str> {
    let stripped_prefix = dir.strip_prefix("./");
    let d = stripped_prefix.unwrap_or(dir);
    let stripped_suffix = d.strip_suffix('/');
    let d = stripped_suffix.unwrap_or(d);
    if d.is_empty() {
        Cow::Owned(".".to_string())
    } else if stripped_prefix.is_some() || stripped_suffix.is_some() {
        Cow::Owned(d.to_string())
    } else {
        Cow::Borrowed(dir)
    }
}

/// Normalize a path string: strip leading "./", trailing "/"
fn normalize(s: &str) -> PathBuf {
    let s = s.strip_prefix("./").unwrap_or(s);
    let s = s.strip_suffix('/').unwrap_or(s);
    if s == "." || s.is_empty() {
        PathBuf::new()
    } else {
        PathBuf::from(s)
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    #[test]
    fn memory_fs_read_file() {
        let fs = MemoryFs::new(&[("hello.txt", "world")]);
        let content = fs.read_file(Path::new("hello.txt")).unwrap();
        assert_eq!(content, b"world");
    }

    #[test]
    fn memory_fs_read_file_not_found() {
        let fs = MemoryFs::new(&[]);
        assert!(fs.read_file(Path::new("missing.txt")).is_err());
    }

    #[test]
    fn memory_fs_read_dir_root() {
        let fs = MemoryFs::new(&[
            ("package.json", "{}"),
            ("src/main.rs", "fn main() {}"),
            ("src/lib.rs", ""),
        ]);
        let entries = fs.read_dir(Path::new(".")).unwrap();
        let names: HashSet<_> = entries.iter().map(|e| e.name.as_str()).collect();
        assert_eq!(names, HashSet::from(["package.json", "src"]));
        assert!(entries.iter().find(|e| e.name == "src").unwrap().is_dir);
        assert!(
            !entries
                .iter()
                .find(|e| e.name == "package.json")
                .unwrap()
                .is_dir
        );
    }

    #[test]
    fn memory_fs_read_dir_subdirectory() {
        let fs = MemoryFs::new(&[("apps/api/package.json", "{}"), ("apps/web/index.ts", "")]);
        let entries = fs.read_dir(Path::new("apps")).unwrap();
        let names: HashSet<_> = entries.iter().map(|e| e.name.as_str()).collect();
        assert_eq!(names, HashSet::from(["api", "web"]));
        assert!(entries.iter().all(|e| e.is_dir));
    }

    #[test]
    fn memory_fs_exists_file() {
        let fs = MemoryFs::new(&[("a/b/c.txt", "content")]);
        assert!(fs.exists(Path::new("a/b/c.txt")));
        assert!(fs.exists(Path::new("a/b")));
        assert!(fs.exists(Path::new("a")));
        assert!(!fs.exists(Path::new("x")));
    }

    #[test]
    fn memory_fs_exists_root() {
        let fs = MemoryFs::new(&[("file.txt", "")]);
        assert!(fs.exists(Path::new(".")));
        assert!(fs.exists(Path::new("")));
    }

    #[test]
    fn memory_fs_empty() {
        let fs = MemoryFs::new(&[]);
        assert!(!fs.exists(Path::new("anything")));
        // Root always exists (even if empty) — matches real filesystem behavior
        assert!(fs.exists(Path::new(".")));
        // read_dir returns empty vec, not an error
        assert!(fs.read_dir(Path::new(".")).unwrap().is_empty());
    }

    #[test]
    fn memory_fs_normalized_paths() {
        let fs = MemoryFs::new(&[("./src/main.rs", "fn main() {}")]);
        assert!(fs.read_file(Path::new("src/main.rs")).is_ok());
        assert!(fs.exists(Path::new("src")));
    }
}