//! 项目交付状态聚合。 //! //! 本模块只采集和呈现信号,不替项目判断功能是否应交付。Git 本地状态、Gitea //! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。 use std::{ collections::BTreeMap, io::{self, Read}, net::{IpAddr, SocketAddr, ToSocketAddrs}, path::Path, process::Command, time::Duration, }; use serde::{Deserialize, Serialize}; const CAPABILITY_PROFILE_PATH: &str = ".lian-jing/capabilities.yaml"; const MAX_PROFILE_BYTES: u64 = 16 * 1024; const MAX_PROBE_BYTES: u64 = 8 * 1024; #[derive(Debug, Clone, Deserialize)] struct CapabilityProfile { version: u64, delivery: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct DeliveryCapabilities { targets: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct DeliveryTargets { production: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ProductionDeliveryProfile { pub provider: String, pub components: BTreeMap, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct DeliveryComponentProfile { pub deploy_context: String, pub probe: HttpJsonProbe, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct HttpJsonProbe { pub kind: String, pub url: String, pub revision_field: String, pub healthy_field: String, } #[derive(Debug, Clone, Serialize, PartialEq)] pub struct ProbeEvidence { pub state: String, pub revision: Option, pub healthy: Option, pub detail: String, } impl ProbeEvidence { fn unavailable(detail: impl Into) -> Self { Self { state: "unavailable".into(), revision: None, healthy: None, detail: detail.into(), } } } /// 项目拥有此声明;炼境只读取和校验,不分发或修改项目配置。 pub fn read_production_profile(root: &Path) -> Result, String> { let path = root.join(CAPABILITY_PROFILE_PATH); let metadata = match std::fs::metadata(&path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(format!("无法读取 delivery profile:{error}")), }; if !metadata.is_file() || metadata.len() > MAX_PROFILE_BYTES { return Err("delivery profile 不是普通文件或超过 16 KiB".into()); } let content = std::fs::read_to_string(&path) .map_err(|error| format!("无法读取 delivery profile:{error}"))?; parse_production_profile(&content) } pub fn parse_production_profile( content: &str, ) -> Result, String> { if content.len() as u64 > MAX_PROFILE_BYTES { return Err("delivery profile 超过 16 KiB".into()); } let profile: CapabilityProfile = serde_yaml::from_str(content) .map_err(|error| format!("delivery profile YAML 无效:{error}"))?; if profile.version != 1 { return Err(format!( "不支持 delivery profile version={}", profile.version )); } let Some(production) = profile .delivery .and_then(|delivery| delivery.targets) .and_then(|targets| targets.production) else { return Ok(None); }; if production.provider != "gitea-actions" || production.components.is_empty() || production.components.len() > 4 { return Err("production 仅支持 gitea-actions 且须声明 1–4 个组件".into()); } for (component, config) in &production.components { if !valid_identifier(component) || !valid_context(&config.deploy_context) { return Err(format!("组件 {component:?} 的名称或 deploy_context 无效")); } if config.probe.kind != "http-json" || !valid_identifier(&config.probe.revision_field) || !valid_identifier(&config.probe.healthy_field) { return Err(format!("组件 {component:?} 的 probe 字段无效")); } validate_probe_url(&config.probe.url)?; } Ok(Some(production)) } fn valid_identifier(value: &str) -> bool { !value.is_empty() && value.len() <= 64 && value .bytes() .all(|ch| ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-') } fn valid_context(value: &str) -> bool { !value.is_empty() && value.len() <= 128 && value.trim() == value && !value.chars().any(char::is_control) } fn validate_probe_url(raw: &str) -> Result<(), String> { if raw.len() > 2048 { return Err("探针 URL 超过长度上限".into()); } let url = tauri::Url::parse(raw).map_err(|_| "探针 URL 无效".to_string())?; if !matches!(url.scheme(), "http" | "https") || !url.username().is_empty() || url.password().is_some() || url.query().is_some() || url.fragment().is_some() { return Err("探针仅允许无凭证、无查询参数的 http/https URL".into()); } let host = url.host_str().ok_or("探针 URL 缺少主机名")?; let host_lower = host.to_ascii_lowercase(); if host_lower == "localhost" || host_lower.ends_with(".localhost") || host_lower.ends_with(".local") || host_lower.ends_with(".internal") { return Err("探针不允许本机或内网域名".into()); } let ip_host = host.trim_start_matches('[').trim_end_matches(']'); if let Ok(ip) = ip_host.parse::() { if !is_public_probe_ip(ip) { return Err("探针不允许本机或内网 IP".into()); } } Ok(()) } fn is_public_probe_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(ip) => { let octets = ip.octets(); !ip.is_private() && !ip.is_loopback() && !ip.is_link_local() && !ip.is_multicast() && !ip.is_unspecified() && !ip.is_broadcast() && octets[0] != 0 && !(octets[0] == 100 && (64..=127).contains(&octets[1])) } IpAddr::V6(ip) => { if let Some(ipv4) = ip.to_ipv4() { return is_public_probe_ip(IpAddr::V4(ipv4)); } !ip.is_loopback() && !ip.is_unique_local() && !ip.is_unicast_link_local() && !ip.is_multicast() && !ip.is_unspecified() } } } fn retain_public_probe_addresses( addresses: impl IntoIterator, ) -> io::Result> { let safe: Vec<_> = addresses .into_iter() .filter(|address| is_public_probe_ip(address.ip())) .collect(); if safe.is_empty() { return Err(io::Error::new( io::ErrorKind::PermissionDenied, "探针域名未解析到允许的公网地址", )); } Ok(safe) } /// 探针只用项目声明的 URL 发起无认证 GET,不接受 Agent 动态 URL 或请求头。 pub fn probe_http_json(probe: &HttpJsonProbe) -> ProbeEvidence { if let Err(detail) = validate_probe_url(&probe.url) { return ProbeEvidence::unavailable(detail); } let agent = ureq::AgentBuilder::new() .try_proxy_from_env(false) .resolver(|addr: &str| retain_public_probe_addresses(addr.to_socket_addrs()?)) .redirects(0) .timeout(Duration::from_secs(4)) .timeout_connect(Duration::from_secs(3)) .timeout_read(Duration::from_secs(3)) .timeout_write(Duration::from_secs(3)) .build(); let response = match agent.get(&probe.url).call() { Ok(response) => response, Err(error) => return ProbeEvidence::unavailable(format!("探针请求失败:{error}")), }; let body = match read_bounded_probe(response.into_reader()) { Ok(body) => body, Err(detail) => return ProbeEvidence::unavailable(detail), }; parse_probe_response(&body, probe) } fn read_bounded_probe(reader: impl Read) -> Result, String> { let mut body = Vec::new(); reader .take(MAX_PROBE_BYTES + 1) .read_to_end(&mut body) .map_err(|error| format!("探针响应不可读:{error}"))?; if body.len() as u64 > MAX_PROBE_BYTES { return Err("探针响应超过 8 KiB".into()); } Ok(body) } fn inspect_declared_deployment( conn: &rusqlite::Connection, project_id: &str, root: &Path, ) -> DeliverySignal { let profile = match read_production_profile(root) { Ok(None) => { return DeliverySignal::new( "not_configured", "project_capabilities", "项目未声明 production delivery profile", serde_json::json!({}), ); } Err(detail) => { return DeliverySignal::new( "unavailable", "project_capabilities", detail, serde_json::json!({}), ); } Ok(Some(profile)) => profile, }; let contexts: Vec = profile .components .values() .map(|component| component.deploy_context.clone()) .collect(); let remote = crate::commands::gitea::get_production_context_signals(conn, project_id, &contexts); let (target_sha, deploy_signals) = match remote { Ok((sha, signals)) => (Some(sha), signals), Err(signal) => ( None, contexts .iter() .map(|context| (context.clone(), signal.clone())) .collect(), ), }; let components = profile .components .iter() .map(|(name, component)| { ( name.clone(), ( component.deploy_context.clone(), deploy_signals .get(&component.deploy_context) .cloned() .unwrap_or_else(|| { DeliverySignal::new( "unknown", "gitea", "部署 context 未返回结果", serde_json::json!({}), ) }), probe_http_json(&component.probe), ), ) }) .collect(); aggregate_deployment(target_sha.as_deref(), components) } type ComponentEvidence = (String, DeliverySignal, ProbeEvidence); /// 纯函数:部署过程与当前运行事实必须同时成立;不把 CI 或 HTTP 单独升格为成功。 fn aggregate_deployment( target_sha: Option<&str>, components: BTreeMap, ) -> DeliverySignal { let any_failed = components .values() .any(|(_, deploy, _)| deploy.state == "failure"); let any_pending = components .values() .any(|(_, deploy, _)| deploy.state == "pending"); let any_skipped = components .values() .any(|(_, deploy, _)| deploy.state == "skipped"); let any_unhealthy = components .values() .any(|(_, _, probe)| probe.state == "unhealthy"); let any_drifted = target_sha.is_some_and(|target| { components.values().any(|(_, deploy, probe)| { deploy.state == "success" && probe .revision .as_deref() .is_some_and(|running| running != target) }) }); let all_deployed = target_sha.is_some() && !components.is_empty() && components.values().all(|(_, deploy, probe)| { deploy.state == "success" && probe.state == "healthy" && probe.revision.as_deref() == target_sha }); let any_unavailable = components .values() .any(|(_, deploy, probe)| deploy.state == "unavailable" || probe.state == "unavailable"); let (state, detail) = if any_failed { ( "failed", "Gitea 明确报告部署失败;同时保留探针所见的运行版本", ) } else if any_pending { ( "pending", "部署 context 尚未结束,当前运行版本可能仍是旧版本", ) } else if any_unhealthy { ("unhealthy", "运行版本可读,但项目技术健康字段为 false") } else if any_drifted { ("drifted", "当前运行 SHA 与 Gitea 默认分支目标 SHA 不一致") } else if all_deployed { ( "deployed", "Gitea 部署 context 成功且当前运行 SHA 与目标一致", ) } else if any_unavailable { ("unavailable", "Gitea 部署状态或运行探针不可达") } else if any_skipped { ( "skipped", "本次默认分支提交未触发部署;保留当前运行版本,不将正常跳过误报为漂移", ) } else { ("unknown", "缺少足以判断 production 部署的双证据") }; let running_revision = (components.len() == 1) .then(|| { components .values() .next() .and_then(|(_, _, probe)| probe.revision.clone()) }) .flatten(); let deployed_at = (components.len() == 1) .then(|| { components .values() .next() .and_then(|(_, deploy, _)| { (deploy.state == "success") .then(|| deploy.data["status"]["updated_at"].as_str()) .flatten() }) .map(str::to_owned) }) .flatten(); let mut evidence = Vec::new(); let component_data: BTreeMap = components .iter() .map(|(name, (context, deploy, probe))| { evidence.push(serde_json::json!({ "component": name, "source": "gitea", "state": deploy.state, })); evidence.push(serde_json::json!({ "component": name, "source": "http_probe", "state": probe.state, })); let revision_match = target_sha .and_then(|target| probe.revision.as_deref().map(|running| running == target)); ( name.clone(), serde_json::json!({ "deploy_context": context, "deploy": deploy, "health": probe, "revision_match": revision_match, }), ) }) .collect(); DeliverySignal::new( state, "gitea+http_probe", detail, serde_json::json!({ "environment": "production", "target_revision": target_sha, "running_revision": running_revision, "deployed_at": deployed_at, "components": component_data, "evidence": evidence, }), ) } fn parse_probe_response(body: &[u8], probe: &HttpJsonProbe) -> ProbeEvidence { let Ok(value) = serde_json::from_slice::(body) else { return ProbeEvidence::unavailable("探针未返回有效 JSON"); }; let revision = value .get(&probe.revision_field) .and_then(|value| value.as_str()) .map(str::to_owned); let healthy = value .get(&probe.healthy_field) .and_then(|value| value.as_bool()); if revision .as_deref() .is_none_or(|value| value.len() != 40 || !value.bytes().all(|ch| ch.is_ascii_hexdigit())) || healthy.is_none() { return ProbeEvidence::unavailable("探针缺少完整 Git SHA 或布尔健康字段"); } ProbeEvidence { state: if healthy == Some(true) { "healthy" } else { "unhealthy" } .into(), revision, healthy, detail: "已读取项目声明的只读 HTTP JSON 探针".into(), } } #[derive(Debug, Serialize, Clone, PartialEq)] pub struct DeliverySignal { /// 领域状态,例如 `passed`、`open`、`unknown`、`unavailable`。 pub state: String, /// 产生该信号的系统,例如 `gitea`、`local_git`、`deployment_report`。 pub source: String, /// 面向 Agent 的可读解释;不得把未知状态描述成成功。 pub detail: String, /// 结构化原始摘要,便于不同 Agent 作自己的交付判断。 pub data: serde_json::Value, } impl DeliverySignal { pub fn new( state: impl Into, source: impl Into, detail: impl Into, data: serde_json::Value, ) -> Self { Self { state: state.into(), source: source.into(), detail: detail.into(), data, } } } #[derive(Debug, Serialize, Clone, PartialEq)] pub struct RecentCommit { pub sha: String, pub short_sha: String, pub committed_at: String, pub subject: String, } #[derive(Debug, Serialize, Clone, PartialEq)] pub struct GitDeliveryStatus { /// `clean` / `dirty` / `unavailable`。 pub state: String, pub source: String, pub detail: String, pub branch: Option, pub changed_files: usize, pub recent_commit: Option, } #[derive(Debug, Serialize, Clone, PartialEq)] pub struct DeliveryStatus { pub project_id: String, pub git: GitDeliveryStatus, pub pull_request: DeliverySignal, pub ci: DeliverySignal, pub review: DeliverySignal, pub deployment: DeliverySignal, } /// 聚合已注册项目的交付信号。连接由 MCP/Tauri 边界传入,方便 in-memory DB 测试。 pub fn get_delivery_status( conn: &rusqlite::Connection, project_id: &str, project_root: &Path, ) -> DeliveryStatus { let git = inspect_git_status(project_root); let sha = git.recent_commit.as_ref().map(|commit| commit.sha.as_str()); let gitea = crate::commands::gitea::get_delivery_signals(conn, project_id, git.branch.as_deref(), sha); DeliveryStatus { project_id: project_id.to_string(), git, pull_request: gitea.pull_request, ci: gitea.ci, review: gitea.review, deployment: inspect_declared_deployment(conn, project_id, project_root), } } /// 读取本地 Git,不依赖网络。失败也返回结构化状态,使 MCP 调用可继续展示其他来源。 pub fn inspect_git_status(project_root: &Path) -> GitDeliveryStatus { let porcelain = match run_git(project_root, ["status", "--porcelain=v1", "--branch"]) { Ok(output) => output, Err(detail) => { return GitDeliveryStatus { state: "unavailable".into(), source: "local_git".into(), detail, branch: None, changed_files: 0, recent_commit: None, } } }; let (branch, changed_files) = parse_porcelain_status(&porcelain); let recent_commit = run_git( project_root, ["log", "-1", "--format=%H%x1f%h%x1f%cI%x1f%s"], ) .ok() .and_then(|output| parse_recent_commit(&output)); let state = if changed_files == 0 { "clean" } else { "dirty" }; GitDeliveryStatus { state: state.into(), source: "local_git".into(), detail: if changed_files == 0 { "工作区无未提交改动".into() } else { format!("工作区有 {changed_files} 个未提交路径") }, branch, changed_files, recent_commit, } } fn run_git(project_root: &Path, args: [&str; N]) -> Result { let output = Command::new("git") .arg("-C") .arg(project_root) .args(args) .output() .map_err(|e| format!("无法执行本地 git:{e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(if stderr.is_empty() { "本地 git 命令失败".into() } else { format!("本地 git 命令失败:{stderr}") }); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } pub fn parse_porcelain_status(output: &str) -> (Option, usize) { let mut lines = output.lines(); let branch = lines.next().and_then(|line| { line.strip_prefix("## ").and_then(|value| { let name = value .strip_prefix("No commits yet on ") .unwrap_or(value) .split("...") .next() .unwrap_or(value) .trim() .trim_end_matches(" [gone]"); (!name.is_empty() && !name.starts_with("HEAD (")).then(|| name.to_string()) }) }); (branch, lines.filter(|line| !line.trim().is_empty()).count()) } pub fn parse_recent_commit(output: &str) -> Option { let mut fields = output.trim().split('\u{1f}'); let sha = fields.next()?.trim(); let short_sha = fields.next()?.trim(); let committed_at = fields.next()?.trim(); let subject = fields.next()?.trim(); (!sha.is_empty()).then(|| RecentCommit { sha: sha.to_string(), short_sha: short_sha.to_string(), committed_at: committed_at.to_string(), subject: subject.to_string(), }) } #[cfg(test)] mod tests { use super::*; #[test] fn porcelain_parser_reports_branch_and_dirty_paths() { let (branch, changed_files) = parse_porcelain_status( "## feat/delivery...origin/feat/delivery [ahead 1]\n M src/a.rs\n?? docs/new.md\n", ); assert_eq!(branch.as_deref(), Some("feat/delivery")); assert_eq!(changed_files, 2); } #[test] fn porcelain_parser_handles_unborn_and_detached_heads() { let (branch, _) = parse_porcelain_status("## No commits yet on feat/new\n?? file.rs\n"); assert_eq!(branch.as_deref(), Some("feat/new")); let (branch, _) = parse_porcelain_status("## HEAD (detached at abc123)\n"); assert!(branch.is_none()); } #[test] fn recent_commit_parser_keeps_delivery_anchor() { let commit = parse_recent_commit( "1234567890abcdef\u{1f}12345678\u{1f}2026-09-21T10:00:00+09:00\u{1f}feat(mcp): status\n", ) .expect("应解析 commit"); assert_eq!(commit.short_sha, "12345678"); assert_eq!(commit.subject, "feat(mcp): status"); } #[test] fn unavailable_local_git_is_explicit() { let status = inspect_git_status(Path::new("E:/definitely-not-a-project")); assert_eq!(status.state, "unavailable"); assert!(status.recent_commit.is_none()); } #[test] fn no_gitea_binding_does_not_fabricate_delivery_success() { let conn = crate::db::conn_with_schema(); let status = get_delivery_status(&conn, "unbound", Path::new("E:/definitely-not-a-project")); assert_eq!(status.pull_request.state, "not_configured"); assert_eq!(status.ci.state, "not_configured"); assert_eq!(status.deployment.state, "not_configured"); } const PROFILE: &str = r#" version: 1 delivery: targets: production: provider: gitea-actions components: api: deploy_context: "CI — DeepSeek Agent / deploy-api (push)" probe: kind: http-json url: https://api3.yljt.info/health/version revision_field: revision healthy_field: ok "#; #[test] fn production_profile_has_fixed_read_only_shape() { let profile = parse_production_profile(PROFILE).unwrap().unwrap(); assert_eq!(profile.provider, "gitea-actions"); let api = &profile.components["api"]; assert_eq!( api.deploy_context, "CI — DeepSeek Agent / deploy-api (push)" ); assert_eq!(api.probe.url, "https://api3.yljt.info/health/version"); assert!(parse_production_profile( &PROFILE.replace("provider: gitea-actions", "provider: ssh") ) .is_err()); assert!(parse_production_profile(&PROFILE.replace( "healthy_field: ok", "healthy_field: ok\n headers: {Authorization: secret}" )) .is_err()); assert!(parse_production_profile("version: 1\nplanning: {}\n") .unwrap() .is_none()); } #[test] fn probe_url_rejects_credentials_and_non_http_sources() { for url in [ "file:///etc/passwd", "https://user:secret@api3.yljt.info/health/version", "https://api3.yljt.info/health/version?token=secret", "http://127.0.0.1:27190/mcp", "http://[::1]/version", "http://[::ffff:127.0.0.1]/version", "http://192.168.1.10/version", "http://localhost/version", ] { assert!(validate_probe_url(url).is_err(), "{url} 应被拒绝"); } assert!(validate_probe_url("https://api3.yljt.info/health/version").is_ok()); } #[test] fn probe_dns_resolver_never_returns_private_addresses() { let loopback: SocketAddr = "127.0.0.1:443".parse().unwrap(); let private: SocketAddr = "192.168.1.1:443".parse().unwrap(); let carrier_nat: SocketAddr = "100.64.0.1:443".parse().unwrap(); let public: SocketAddr = "8.8.8.8:443".parse().unwrap(); assert!(retain_public_probe_addresses([loopback, private, carrier_nat]).is_err()); assert_eq!( retain_public_probe_addresses([loopback, public, private]).unwrap(), vec![public] ); assert!(validate_probe_url("http://100.64.0.1/version").is_err()); } #[test] fn missing_profile_is_not_configured_and_probe_body_is_strict() { let empty = std::env::temp_dir().join(format!( "lianjing_delivery_profile_absent_{}", std::process::id() )); assert!(read_production_profile(&empty).unwrap().is_none()); let probe = parse_production_profile(PROFILE) .unwrap() .unwrap() .components .remove("api") .unwrap() .probe; let sha = "a".repeat(40); let healthy = parse_probe_response( format!(r#"{{"ok":true,"revision":"{sha}"}}"#).as_bytes(), &probe, ); assert_eq!(healthy.state, "healthy"); assert_eq!(healthy.revision.as_deref(), Some(sha.as_str())); let unhealthy = parse_probe_response( format!(r#"{{"ok":false,"revision":"{sha}"}}"#).as_bytes(), &probe, ); assert_eq!(unhealthy.state, "unhealthy"); assert_eq!(parse_probe_response(b"{}", &probe).state, "unavailable"); } #[test] fn oversized_and_timed_out_probe_bodies_are_unavailable() { let oversized = vec![b'x'; MAX_PROBE_BYTES as usize + 1]; assert!(read_bounded_probe(std::io::Cursor::new(oversized)).is_err()); struct TimedOut; impl Read for TimedOut { fn read(&mut self, _: &mut [u8]) -> std::io::Result { Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout")) } } assert!(read_bounded_probe(TimedOut) .unwrap_err() .contains("timeout")); } fn aggregate_fixture( deploy_state: &str, probe_state: &str, running: Option<&str>, ) -> DeliverySignal { let mut components = BTreeMap::new(); components.insert( "api".to_string(), ( "CI — DeepSeek Agent / deploy-api (push)".to_string(), DeliverySignal::new( deploy_state, "gitea", "fixture", serde_json::json!({"status": {"updated_at": "2026-09-22T03:38:55Z"}}), ), ProbeEvidence { state: probe_state.to_string(), revision: running.map(str::to_owned), healthy: Some(probe_state == "healthy"), detail: "fixture".into(), }, ), ); aggregate_deployment(Some(&"a".repeat(40)), components) } #[test] fn deployment_requires_both_evidence_sources_and_exact_sha() { let target = "a".repeat(40); let old = "b".repeat(40); let deployed = aggregate_fixture("success", "healthy", Some(&target)); assert_eq!(deployed.state, "deployed"); assert_eq!(deployed.data["target_revision"], target); assert_eq!(deployed.data["running_revision"], target); assert_eq!(deployed.data["deployed_at"], "2026-09-22T03:38:55Z"); assert_eq!(deployed.data["evidence"].as_array().unwrap().len(), 2); let drifted = aggregate_fixture("success", "healthy", Some(&old)); assert_eq!(drifted.state, "drifted"); assert_eq!(drifted.data["running_revision"], old); let failed = aggregate_fixture("failure", "healthy", Some(&old)); assert_eq!(failed.state, "failed"); assert_eq!(failed.data["running_revision"], old); assert!(failed.data["deployed_at"].is_null()); let pending = aggregate_fixture("pending", "healthy", Some(&old)); assert_eq!(pending.state, "pending"); assert_eq!(pending.data["running_revision"], old); assert!(pending.data["deployed_at"].is_null()); assert_eq!( aggregate_fixture("success", "unhealthy", Some(&target)).state, "unhealthy" ); assert_eq!( aggregate_fixture("success", "unavailable", None).state, "unavailable" ); let skipped = aggregate_fixture("skipped", "healthy", Some(&old)); assert_eq!(skipped.state, "skipped"); assert_eq!(skipped.data["running_revision"], old); assert!(skipped.data["deployed_at"].is_null()); assert_eq!( aggregate_fixture("skipped", "unavailable", None).state, "unavailable" ); assert_eq!( aggregate_fixture("unknown", "healthy", Some(&old)).state, "unknown" ); assert_eq!( aggregate_fixture("unknown", "healthy", Some(&target)).state, "unknown" ); } }