biovault 0.1.74

A bioinformatics data vault CLI tool
Documentation
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping, Value};
use std::fs;
use std::path::Path;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMetadata {
    pub name: String,
    pub author: String,
    pub workflow: String,
    pub template: Option<String>,
    pub assets: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct ProjectFileNode {
    pub name: String,
    pub path: String,
    pub is_dir: bool,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub children: Vec<ProjectFileNode>,
}

pub fn load_project_metadata(project_root: &Path) -> Result<Option<ProjectMetadata>> {
    let yaml_path = project_root.join("project.yaml");
    if !yaml_path.exists() {
        return Ok(None);
    }

    let content = fs::read_to_string(&yaml_path)
        .with_context(|| format!("Failed to read {}", yaml_path.display()))?;
    let value: Value = serde_yaml::from_str(&content)
        .with_context(|| format!("Invalid YAML in {}", yaml_path.display()))?;

    let mapping = value.as_mapping().cloned().unwrap_or_else(Mapping::new);

    Ok(Some(ProjectMetadata {
        name: mapping
            .get(Value::String("name".into()))
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string(),
        author: mapping
            .get(Value::String("author".into()))
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string(),
        workflow: mapping
            .get(Value::String("workflow".into()))
            .and_then(Value::as_str)
            .unwrap_or("workflow.nf")
            .to_string(),
        template: mapping
            .get(Value::String("template".into()))
            .and_then(Value::as_str)
            .map(|s| s.to_string()),
        assets: mapping
            .get(Value::String("assets".into()))
            .and_then(Value::as_sequence)
            .map(|seq| {
                seq.iter()
                    .filter_map(Value::as_str)
                    .map(|s| s.replace('\\', "/"))
                    .collect()
            })
            .unwrap_or_default(),
    }))
}

pub fn save_project_metadata(project_root: &Path, metadata: &ProjectMetadata) -> Result<()> {
    let yaml_path = project_root.join("project.yaml");
    let mut value = if yaml_path.exists() {
        let content = fs::read_to_string(&yaml_path)
            .with_context(|| format!("Failed to read {}", yaml_path.display()))?;
        serde_yaml::from_str::<Value>(&content)
            .with_context(|| format!("Invalid YAML in {}", yaml_path.display()))?
    } else {
        Value::Mapping(Mapping::new())
    };

    let map = value
        .as_mapping_mut()
        .ok_or_else(|| anyhow::anyhow!("project.yaml must be a mapping"))?;

    map.insert(
        Value::String("name".into()),
        Value::String(metadata.name.clone()),
    );
    map.insert(
        Value::String("author".into()),
        Value::String(metadata.author.clone()),
    );
    map.insert(
        Value::String("workflow".into()),
        Value::String(metadata.workflow.clone()),
    );

    if let Some(template) = metadata.template.as_ref().and_then(|t| {
        let trimmed = t.trim();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed.to_string())
        }
    }) {
        map.insert(Value::String("template".into()), Value::String(template));
    } else {
        map.remove(Value::String("template".into()));
    }

    let mut assets: Vec<String> = metadata
        .assets
        .iter()
        .map(|s| s.replace('\\', "/"))
        .collect();
    assets.sort();
    assets.dedup();

    map.insert(
        Value::String("assets".into()),
        Value::Sequence(assets.into_iter().map(Value::String).collect()),
    );

    let yaml_str = serde_yaml::to_string(&value)
        .with_context(|| format!("Failed to serialize {}", yaml_path.display()))?;
    fs::write(&yaml_path, yaml_str)
        .with_context(|| format!("Failed to write {}", yaml_path.display()))?;

    Ok(())
}

pub fn build_project_file_tree(project_root: &Path) -> Result<Vec<ProjectFileNode>> {
    fn collect(root: &Path, current: &Path) -> Result<Vec<ProjectFileNode>> {
        let mut entries = Vec::new();
        for entry in fs::read_dir(current)
            .with_context(|| format!("Failed to read directory {}", current.display()))?
        {
            let entry = entry?;
            let path = entry.path();
            let name = entry.file_name().to_string_lossy().to_string();

            if name == "." || name == ".." {
                continue;
            }

            if name == ".venv" {
                continue;
            }

            let relative = path
                .strip_prefix(root)
                .unwrap_or(&path)
                .to_string_lossy()
                .replace('\\', "/");

            let file_type = entry.file_type()?;
            if file_type.is_dir() {
                let children = collect(root, &path)?;
                entries.push(ProjectFileNode {
                    name,
                    path: relative,
                    is_dir: true,
                    children,
                });
            } else if name != "project.yaml" {
                entries.push(ProjectFileNode {
                    name,
                    path: relative,
                    is_dir: false,
                    children: Vec::new(),
                });
            }
        }

        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
            (true, false) => std::cmp::Ordering::Less,
            (false, true) => std::cmp::Ordering::Greater,
            _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
        });

        Ok(entries)
    }

    if !project_root.exists() {
        return Ok(Vec::new());
    }

    collect(project_root, project_root)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn load_and_save_metadata_round_trip() {
        let tmp = TempDir::new().unwrap();
        let root = tmp.path();

        let metadata = ProjectMetadata {
            name: "example".into(),
            author: "test@example.com".into(),
            workflow: "workflow.nf".into(),
            template: Some("sheet".into()),
            assets: vec!["schema.yaml".into(), "src/main.py".into()],
        };

        save_project_metadata(root, &metadata).unwrap();
        let loaded = load_project_metadata(root).unwrap().unwrap();
        assert_eq!(loaded.name, "example");
        assert_eq!(loaded.author, "test@example.com");
        assert_eq!(loaded.workflow, "workflow.nf");
        assert_eq!(loaded.template.as_deref(), Some("sheet"));
        assert_eq!(loaded.assets.len(), 2);
    }

    #[test]
    fn build_tree_skips_project_yaml() {
        let tmp = TempDir::new().unwrap();
        let root = tmp.path();
        fs::write(root.join("project.yaml"), "name: test").unwrap();
        fs::write(root.join("workflow.nf"), "// workflow").unwrap();
        fs::create_dir_all(root.join("bioscript")).unwrap();
        fs::write(root.join("bioscript/lib.rs"), "").unwrap();

        let tree = build_project_file_tree(root).unwrap();
        assert_eq!(tree.len(), 2);
        assert!(tree.iter().any(|node| node.name == "workflow.nf"));
        assert!(tree
            .iter()
            .any(|node| node.name == "bioscript" && node.is_dir));
    }
}