sylphx-cli 0.2.10

Sylphx Platform CLI — dogfoods the Rust Management SDK
use std::process::Command;

use anyhow::{bail, Context};
use reqwest::Method;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use sylphx_sdk_management::ManagementClient;
use url::Url;

pub(crate) struct PublishCandidateOptions<'a> {
    pub project_id: &'a str,
    pub repository: Option<&'a str>,
    pub target_ref: &'a str,
    pub base_revision: Option<&'a str>,
    pub source_revision: Option<&'a str>,
    pub tree_oid: Option<&'a str>,
    pub work_item_id: &'a str,
    pub producer_attempt_id: &'a str,
    pub service_id: Option<&'a str>,
    pub idempotency_key: Option<&'a str>,
    pub org_id: Option<&'a str>,
}

fn git_output(args: &[&str]) -> anyhow::Result<String> {
    let output = Command::new("git")
        .args(args)
        .output()
        .with_context(|| format!("run git {}", args.join(" ")))?;
    if !output.status.success() {
        bail!(
            "git {} failed: {}",
            args.join(" "),
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    let value = String::from_utf8(output.stdout)
        .context("git output is not utf-8")?
        .trim()
        .to_string();
    if value.is_empty() {
        bail!("git {} returned an empty value", args.join(" "));
    }
    Ok(value)
}

fn canonical_repository_identity(raw: &str) -> anyhow::Result<String> {
    let raw = raw.trim().trim_end_matches(".git");
    let path = if let Some(path) = raw.strip_prefix("git@") {
        let (host, repo) = path
            .split_once(':')
            .context("scp-style git remote must contain ':'")?;
        format!("{host}/{repo}")
    } else if raw.contains("://") {
        let parsed = Url::parse(raw).context("parse git remote URL")?;
        if !matches!(parsed.scheme(), "https" | "http" | "ssh" | "git") {
            bail!("git remote URL scheme is not a supported network provider");
        }
        if parsed.query().is_some() || parsed.fragment().is_some() {
            bail!("git remote URL must not contain a query or fragment");
        }
        let host = parsed.host_str().context("git remote URL has no host")?;
        let authority = match parsed.port() {
            Some(port) => format!("{host}:{port}"),
            None => host.to_string(),
        };
        format!("{authority}/{}", parsed.path().trim_matches('/'))
    } else {
        raw.to_string()
    };
    let normalized = path.trim_matches('/').trim_end_matches(".git").to_string();
    if normalized.split('/').count() < 3 || normalized.contains(' ') || normalized.contains("..") {
        bail!("git remote does not normalize to host/owner/repository");
    }
    Ok(normalized)
}

fn git_tree_evidence_digest(tree_oid: &str) -> anyhow::Result<String> {
    let tree_oid = tree_oid.trim().to_ascii_lowercase();
    if !matches!(tree_oid.len(), 40 | 64)
        || !tree_oid
            .chars()
            .all(|character| character.is_ascii_hexdigit())
    {
        bail!("tree oid must be a full 40- or 64-character hexadecimal object id");
    }
    Ok(format!(
        "sha256:{:x}",
        Sha256::digest(format!("platform.git-tree.v1:{tree_oid}").as_bytes())
    ))
}

fn full_git_revision(value: &str, name: &str) -> anyhow::Result<String> {
    let value = value.trim().to_ascii_lowercase();
    if value.len() != 40 || !value.chars().all(|character| character.is_ascii_hexdigit()) {
        bail!("{name} must resolve to a full 40-character Git revision");
    }
    Ok(value)
}

fn publish_body(options: &PublishCandidateOptions<'_>) -> anyhow::Result<Value> {
    let repository = match options.repository {
        Some(repository) => canonical_repository_identity(repository)?,
        None => canonical_repository_identity(&git_output(&["remote", "get-url", "origin"])?)?,
    };
    let source_revision = full_git_revision(
        &match options.source_revision {
            Some(revision) => revision.to_string(),
            None => git_output(&["rev-parse", "HEAD"])?,
        },
        "source revision",
    )?;
    let remote_target = format!("refs/remotes/origin/{}", options.target_ref);
    let base_revision = full_git_revision(
        &match options.base_revision {
            Some(revision) => revision.to_string(),
            None => git_output(&["rev-parse", &remote_target])?,
        },
        "base revision",
    )?;
    if source_revision == base_revision {
        bail!("source revision equals the current target base; there is no Candidate to publish");
    }
    let tree_oid = match options.tree_oid {
        Some(tree_oid) => tree_oid.to_string(),
        None => git_output(&["rev-parse", &format!("{source_revision}^{{tree}}")])?,
    };
    let tree_digest = git_tree_evidence_digest(&tree_oid)?;
    let idempotency_key = options
        .idempotency_key
        .map(str::to_string)
        .unwrap_or_else(|| {
            format!(
                "internal:{}:{source_revision}",
                repository.to_ascii_lowercase()
            )
        });

    let mut body = json!({
        "content": {
            "repository_identity": repository,
            "target_ref": options.target_ref,
            "base_revision": base_revision,
            "source_revision": source_revision,
            "tree_digest": tree_digest,
            "work_item_id": options.work_item_id,
            "producer_attempt_id": options.producer_attempt_id,
        },
        "origin": "internal_publish",
        "idempotency_key": idempotency_key,
    });
    if let Some(service_id) = options.service_id {
        body["service_id"] = json!(service_id);
    }
    Ok(body)
}

pub(crate) async fn publish_candidate(
    client: &ManagementClient,
    options: &PublishCandidateOptions<'_>,
) -> anyhow::Result<Value> {
    let body = publish_body(options)?;
    let path = format!("/projects/{}/candidates", options.project_id);
    let (_status, response) = client
        .api(Method::POST, &path, Some(&body), options.org_id)
        .await
        .context("publish Platform Candidate")?;
    Ok(response)
}

pub(crate) async fn get_candidate(
    client: &ManagementClient,
    project_id: &str,
    candidate_id: &str,
    org_id: Option<&str>,
) -> anyhow::Result<Value> {
    let path = format!("/projects/{project_id}/candidates/{candidate_id}");
    let (_status, response) = client
        .api(Method::GET, &path, None, org_id)
        .await
        .context("read Platform Candidate")?;
    Ok(response)
}

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

    #[test]
    fn repository_identity_normalizes_common_git_remotes() {
        assert_eq!(
            canonical_repository_identity("git@github.com:SylphxAI/platform.git").unwrap(),
            "github.com/SylphxAI/platform"
        );
        assert_eq!(
            canonical_repository_identity("https://github.com/SylphxAI/platform.git").unwrap(),
            "github.com/SylphxAI/platform"
        );
        assert_eq!(
            canonical_repository_identity(
                "https://x-access-token:do-not-leak@github.com/SylphxAI/platform.git"
            )
            .unwrap(),
            "github.com/SylphxAI/platform"
        );
        assert_eq!(
            canonical_repository_identity(
                "ssh://git@github.example.com:2222/SylphxAI/platform.git"
            )
            .unwrap(),
            "github.example.com:2222/SylphxAI/platform"
        );
        assert!(canonical_repository_identity("platform").is_err());
        assert!(canonical_repository_identity("file:///tmp/platform").is_err());
    }

    #[test]
    fn tree_evidence_digest_matches_platform_domain_contract() {
        let oid = "a".repeat(40);
        let expected = format!(
            "sha256:{:x}",
            Sha256::digest(format!("platform.git-tree.v1:{oid}").as_bytes())
        );
        assert_eq!(git_tree_evidence_digest(&oid).unwrap(), expected);
        assert!(git_tree_evidence_digest("short").is_err());
    }

    #[test]
    fn publish_body_contains_lineage_but_not_platform_policy_authority() {
        let body = publish_body(&PublishCandidateOptions {
            project_id: "prj_demo",
            repository: Some("github.com/SylphxAI/platform"),
            target_ref: "main",
            base_revision: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
            source_revision: Some("dddddddddddddddddddddddddddddddddddddddd"),
            tree_oid: Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
            work_item_id: "wi_demo",
            producer_attempt_id: "att_demo",
            service_id: None,
            idempotency_key: None,
            org_id: None,
        })
        .unwrap();

        assert_eq!(body["content"]["work_item_id"], "wi_demo");
        assert_eq!(body["content"]["producer_attempt_id"], "att_demo");
        assert!(body["content"].get("policy_profile_digest").is_none());
        assert!(body.get("classification").is_none());
    }
}