veryl 0.20.2

A modern hardware description language
use crate::{Format, OptMetadata};
use miette::{IntoDiagnostic, Result, bail};
use veryl_metadata::{Metadata, MetadataOutputV2};

pub struct CmdMetadata {
    opt: OptMetadata,
}

impl CmdMetadata {
    pub fn new(opt: OptMetadata) -> Self {
        Self { opt }
    }

    pub fn exec(&self, metadata: &mut Metadata) -> Result<bool> {
        let text = self.format_metadata(metadata)?;

        println!("{text}");

        Ok(true)
    }

    fn format_metadata(&self, metadata: &mut Metadata) -> Result<String> {
        match (self.opt.format, self.opt.format_version) {
            (Format::Json, None) => serde_json::to_string(metadata).into_diagnostic(),
            (Format::Pretty, None) => Ok(format!("{metadata:#?}")),
            (Format::Json, Some(1)) => serde_json::to_string(metadata).into_diagnostic(),
            (Format::Json, Some(2)) => {
                metadata.update_lockfile()?;
                let output = MetadataOutputV2::from_metadata(metadata)?;
                serde_json::to_string(&output).into_diagnostic()
            }
            (Format::Pretty, Some(_)) => {
                bail!("--format-version is only supported with --format json")
            }
            (Format::Json, Some(version)) => {
                bail!("unsupported --format-version {version}; supported versions: 1, 2")
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{fs, path::Path};

    const TEST_TOML: &str = r#"
[project]
name = "test"
version = "0.1.0"

[metadata.external_tool]
files = ["src/**/*.v"]
"#;

    fn load_metadata() -> (Metadata, tempfile::TempDir) {
        let tempdir = tempfile::tempdir().unwrap();
        let project_dir = tempdir.path().join("test");
        fs::create_dir(&project_dir).unwrap();
        let toml_path = project_dir.join("Veryl.toml");
        fs::write(&toml_path, TEST_TOML).unwrap();
        let metadata = Metadata::load(&toml_path).unwrap();
        (metadata, tempdir)
    }

    fn load_metadata_with_path_dependency() -> (Metadata, tempfile::TempDir) {
        let tempdir = tempfile::tempdir().unwrap();
        let root_dir = tempdir.path().join("root");
        let dependency_dir = tempdir.path().join("dep");
        fs::create_dir(&root_dir).unwrap();
        fs::create_dir(&dependency_dir).unwrap();
        fs::write(
            root_dir.join("Veryl.toml"),
            r#"
[project]
name = "root"
version = "0.1.0"

[dependencies]
dep = {path = "../dep"}
"#,
        )
        .unwrap();
        fs::write(
            dependency_dir.join("Veryl.toml"),
            r#"
[project]
name = "real_dep"
version = "0.1.0"

[metadata.external_tool]
role = "dependency"
"#,
        )
        .unwrap();
        let metadata = Metadata::load(root_dir.join("Veryl.toml")).unwrap();
        (metadata, tempdir)
    }

    fn command(format: Format, format_version: Option<u32>) -> CmdMetadata {
        CmdMetadata::new(OptMetadata {
            format,
            format_version,
        })
    }

    #[test]
    fn json_format_version_1_preserves_internal_metadata_shape() {
        // Given: project metadata with extension-owned metadata.
        let (mut metadata, _tempdir) = load_metadata();

        // When: JSON metadata is formatted with legacy format version 1.
        let versioned_text = command(Format::Json, Some(1))
            .format_metadata(&mut metadata)
            .unwrap();
        let unversioned_text = command(Format::Json, None)
            .format_metadata(&mut metadata)
            .unwrap();
        let versioned_value: serde_json::Value = serde_json::from_str(&versioned_text).unwrap();
        let unversioned_value: serde_json::Value = serde_json::from_str(&unversioned_text).unwrap();

        // Then: version 1 is the same legacy/internal JSON shape as unversioned JSON.
        assert_eq!(versioned_value, unversioned_value);
        assert!(versioned_value.get("format_version").is_none());
        assert!(versioned_value.get("root").is_none());
        assert!(versioned_value.get("project").is_some());
        assert_eq!(
            versioned_value["metadata"]["external_tool"]["files"][0],
            "src/**/*.v"
        );
    }

    #[test]
    fn json_format_version_2_emits_stable_graph_metadata_shape() {
        // Given: project metadata with extension-owned metadata.
        let (mut metadata, _tempdir) = load_metadata();

        // When: JSON metadata is formatted with stable graph format version 2.
        let text = command(Format::Json, Some(2)).format_metadata(&mut metadata);

        // Then: version 2 uses the stable graph metadata contract.
        assert!(
            text.as_ref().is_ok(),
            "format version 2 should be supported: {:?}",
            text.as_ref().err()
        );
        let value: serde_json::Value = serde_json::from_str(&text.unwrap()).unwrap();
        assert_eq!(value["format_version"], 2);
        assert_eq!(value["root"]["name"], "test");
        assert_eq!(
            value["root"]["metadata"]["external_tool"]["files"][0],
            "src/**/*.v"
        );
        assert!(value["dependencies"].as_array().unwrap().is_empty());
        assert!(value.get("metadata").is_none());
        assert!(value.get("project").is_none());
    }

    #[test]
    fn unversioned_json_preserves_internal_metadata_shape() {
        // Given: project metadata with extension-owned metadata.
        let (mut metadata, _tempdir) = load_metadata();

        // When: JSON metadata is formatted without an explicit version.
        let text = command(Format::Json, None)
            .format_metadata(&mut metadata)
            .unwrap();
        let value: serde_json::Value = serde_json::from_str(&text).unwrap();

        // Then: unversioned JSON preserves the existing internal metadata shape.
        assert!(value.get("format_version").is_none());
        assert!(value.get("root").is_none());
        assert!(value.get("project").is_some());
    }

    #[test]
    fn pretty_format_version_is_rejected() {
        // Given: project metadata and pretty output with explicit format versions.
        let (mut metadata, _tempdir) = load_metadata();

        for version in [1, 2] {
            // When: pretty metadata is formatted with an explicit version.
            let error = command(Format::Pretty, Some(version))
                .format_metadata(&mut metadata)
                .unwrap_err();

            // Then: pretty format rejects every explicit version.
            assert!(
                error
                    .to_string()
                    .contains("--format-version is only supported with --format json")
            );
        }
    }

    #[test]
    fn unsupported_format_version_is_rejected() {
        // Given: project metadata.
        let (mut metadata, _tempdir) = load_metadata();

        // When: JSON metadata is formatted with an unsupported version.
        let error = command(Format::Json, Some(3))
            .format_metadata(&mut metadata)
            .unwrap_err();

        // Then: the error reports all supported versions.
        assert!(error.to_string().contains("unsupported --format-version 3"));
        assert!(error.to_string().contains("supported versions: 1, 2"));
    }

    #[test]
    fn json_format_version_2_resolves_missing_lockfile() {
        // Given: project metadata with a path dependency and no existing lockfile.
        let (mut metadata, _tempdir) = load_metadata_with_path_dependency();
        assert!(!metadata.lockfile_path.exists());

        // When: JSON metadata is formatted with stable graph format version 2.
        let text = command(Format::Json, Some(2))
            .format_metadata(&mut metadata)
            .unwrap();
        let value: serde_json::Value = serde_json::from_str(&text).unwrap();

        // Then: version 2 creates the lockfile and emits resolved dependencies.
        assert!(metadata.lockfile_path.exists());
        assert_eq!(value["format_version"], 2);
        assert_eq!(value["root"]["metadata"], serde_json::json!({}));
        assert_eq!(value["dependencies"][0]["name"], "dep");
        assert_eq!(value["dependencies"][0]["project"], "real_dep");
        assert_eq!(value["dependencies"][0]["source"]["kind"], "path");
        let local_path = value["dependencies"][0]["local_path"].as_str().unwrap();
        assert!(Path::new(local_path).is_absolute());
        assert_eq!(
            value["dependencies"][0]["metadata"]["external_tool"]["role"],
            "dependency"
        );
    }

    #[test]
    fn legacy_json_does_not_resolve_missing_lockfile() {
        // Given: project metadata with a path dependency and no existing lockfile.
        let (mut metadata, _tempdir) = load_metadata_with_path_dependency();
        assert!(!metadata.lockfile_path.exists());

        // When: legacy JSON metadata is formatted.
        let _text = command(Format::Json, Some(1))
            .format_metadata(&mut metadata)
            .unwrap();

        // Then: legacy JSON keeps the existing debug behavior and does not create a lockfile.
        assert!(!metadata.lockfile_path.exists());
    }

    #[test]
    fn unversioned_json_does_not_resolve_missing_lockfile() {
        // Given: project metadata with a path dependency and no existing lockfile.
        let (mut metadata, _tempdir) = load_metadata_with_path_dependency();
        assert!(!metadata.lockfile_path.exists());

        // When: unversioned JSON metadata is formatted.
        let _text = command(Format::Json, None)
            .format_metadata(&mut metadata)
            .unwrap();

        // Then: unversioned JSON keeps the existing debug behavior and does not create a lockfile.
        assert!(!metadata.lockfile_path.exists());
    }
}