veloq-vis 0.4.1

Source-neutral visualization scene and SVG rendering helpers for VeloQ.
Documentation
use std::path::{Component, Path};

use crate::VisualizationError;
use crate::model::WrittenSvgArtifact;

pub fn write_svg_artifact(
    artifact_root: &Path,
    relative_dir: &Path,
    file_name: &str,
    svg: &str,
) -> Result<WrittenSvgArtifact, VisualizationError> {
    validate_relative_path(relative_dir)?;
    validate_svg_file_name(file_name)?;

    let dir = artifact_root.join(relative_dir);
    std::fs::create_dir_all(&dir).map_err(|source| VisualizationError::CreateArtifactDir {
        path: dir.display().to_string(),
        source,
    })?;

    let path = dir.join(file_name);
    let tmp_path = path.with_file_name(format!("{file_name}.tmp.{}", std::process::id()));
    std::fs::write(&tmp_path, svg).map_err(|source| VisualizationError::WriteArtifact {
        path: tmp_path.display().to_string(),
        source,
    })?;
    std::fs::rename(&tmp_path, &path).map_err(|source| {
        let _ = std::fs::remove_file(&tmp_path);
        VisualizationError::PublishArtifact {
            path: path.display().to_string(),
            source,
        }
    })?;

    let relative_path = relative_dir
        .join(file_name)
        .to_string_lossy()
        .replace('\\', "/");
    Ok(WrittenSvgArtifact {
        path,
        relative_path,
        format: "svg",
    })
}

fn validate_relative_path(path: &Path) -> Result<(), VisualizationError> {
    if path.is_absolute() {
        return Err(VisualizationError::UnsafeRelativePath);
    }
    for component in path.components() {
        match component {
            Component::Normal(_) => {}
            Component::CurDir => {}
            _ => return Err(VisualizationError::UnsafeRelativePath),
        }
    }
    Ok(())
}

fn validate_svg_file_name(file_name: &str) -> Result<(), VisualizationError> {
    let path = Path::new(file_name);
    let mut components = path.components();
    let Some(Component::Normal(_)) = components.next() else {
        return Err(VisualizationError::UnsafeSvgFileName);
    };
    if components.next().is_some() || path.extension().and_then(|e| e.to_str()) != Some("svg") {
        return Err(VisualizationError::UnsafeSvgFileName);
    }
    Ok(())
}

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

    #[test]
    fn write_svg_artifact_returns_portable_relative_path() -> anyhow::Result<()> {
        let dir = tempfile::tempdir()?;
        let written = write_svg_artifact(
            dir.path(),
            Path::new("figures/nsys"),
            "timeline.svg",
            "<svg/>",
        )?;
        assert_eq!(written.relative_path, "figures/nsys/timeline.svg");
        assert_eq!(written.format, "svg");
        assert!(written.path.exists());
        Ok(())
    }

    #[test]
    fn write_svg_artifact_rejects_parent_relative_dir() -> anyhow::Result<()> {
        let dir = tempfile::tempdir()?;
        let err = match write_svg_artifact(dir.path(), Path::new("../x"), "timeline.svg", "<svg/>")
        {
            Ok(_) => anyhow::bail!("unsafe path should fail"),
            Err(err) => err,
        };
        assert!(matches!(err, VisualizationError::UnsafeRelativePath));
        Ok(())
    }
}