From f46a406db741edf4e16cc33fecf695d78f3e64e3 Mon Sep 17 00:00:00 2001 From: ztzzh Date: Mon, 24 Aug 2026 23:05:57 +0800 Subject: [PATCH 1/2] feat(cli): unify success command result model --- src/cli/src/implementation/catalog.rs | 81 +++++++------ src/cli/src/implementation/pipeline.rs | 39 +++---- src/cli/src/lib.rs | 3 +- src/cli/src/output.rs | 53 +++++++++ src/cli/src/spec/blueprint.rs | 39 +++---- src/cli/src/spec/contract.rs | 40 +++++-- src/cli/src/spec/mod.rs | 23 ++-- src/cli/tests/blueprint_test.rs | 3 +- src/cli/tests/cli_test.rs | 155 ++++++++++++++++++++++++- src/cli/tests/spec_test.rs | 2 +- 10 files changed, 317 insertions(+), 121 deletions(-) create mode 100644 src/cli/src/output.rs diff --git a/src/cli/src/implementation/catalog.rs b/src/cli/src/implementation/catalog.rs index 03e71a4..df7a3a1 100644 --- a/src/cli/src/implementation/catalog.rs +++ b/src/cli/src/implementation/catalog.rs @@ -263,15 +263,18 @@ pub fn run_with_mode(args: &CatalogArgs, mode: OutputMode) -> Result<(), CliErro provider, source, artifact_type, - } => add( + } => add_with_mode( path, name.as_deref(), provider.as_deref(), source.as_deref(), artifact_type.clone(), + mode, ), - CatalogAction::SetStatus { name, status } => set_status(name, status.clone()), - CatalogAction::Rm { name } => rm(name), + CatalogAction::SetStatus { name, status } => { + set_status_with_mode(name, status.clone(), mode) + } + CatalogAction::Rm { name } => rm_with_mode(name, mode), } } @@ -281,14 +284,7 @@ fn list(mode: OutputMode) -> Result<(), CliError> { if mode == OutputMode::Text { println!("catalog 为空"); } else { - println!( - "{}", - serde_json::json!({ - "ok": true, - "command": "catalog list", - "items": [], - }) - ); + crate::output::print_success("catalog list", serde_json::json!({"items": []}))?; } return Ok(()); } @@ -308,14 +304,7 @@ fn list(mode: OutputMode) -> Result<(), CliError> { } OutputMode::Json => { let items: Vec<_> = registry.entries().values().collect(); - println!( - "{}", - serde_json::json!({ - "ok": true, - "command": "catalog list", - "items": items, - }) - ); + crate::output::print_success("catalog list", serde_json::json!({"items": items}))?; } } Ok(()) @@ -340,14 +329,9 @@ fn show(name: &str, mode: OutputMode) -> Result<(), CliError> { println!("来源: {s}"); } } - OutputMode::Json => println!( - "{}", - serde_json::json!({ - "ok": true, - "command": "catalog show", - "volume": v, - }) - ), + OutputMode::Json => { + crate::output::print_success("catalog show", serde_json::json!({"volume": v}))? + } } Ok(()) } @@ -355,12 +339,13 @@ fn show(name: &str, mode: OutputMode) -> Result<(), CliError> { } } -fn add( +fn add_with_mode( path_str: &str, name: Option<&str>, provider: Option<&str>, source: Option<&str>, artifact_type: VolumeArtifactType, + mode: OutputMode, ) -> Result<(), CliError> { let volume = register_volume(RegisterVolume { path: path_str, @@ -371,13 +356,28 @@ fn add( artifact_type, })?; - println!("✓ 已注册 volume: {}", volume.name); + match mode { + OutputMode::Text => println!("✓ 已注册 volume: {}", volume.name), + OutputMode::Json => { + crate::output::print_success("catalog add", serde_json::json!({"volume": volume}))? + } + } Ok(()) } -fn set_status(name: &str, status: VolumeStatus) -> Result<(), CliError> { +fn set_status_with_mode( + name: &str, + status: VolumeStatus, + mode: OutputMode, +) -> Result<(), CliError> { let volume = set_volume_status_in(&util::catalog_dir(), name, status)?; - println!("✓ 已更新 volume 状态: {} -> {}", volume.name, volume.status); + match mode { + OutputMode::Text => println!("✓ 已更新 volume 状态: {} -> {}", volume.name, volume.status), + OutputMode::Json => crate::output::print_success( + "catalog set-status", + serde_json::json!({"volume": volume}), + )?, + } Ok(()) } @@ -400,11 +400,16 @@ pub fn set_volume_status_in( Ok(volume) } -fn rm(name: &str) -> Result<(), CliError> { +fn rm_with_mode(name: &str, mode: OutputMode) -> Result<(), CliError> { let mut registry = open_registry(); match registry.remove(name) { Ok(Some(_)) => { - println!("✓ 已删除 volume: {name}"); + match mode { + OutputMode::Text => println!("✓ 已删除 volume: {name}"), + OutputMode::Json => { + crate::output::print_success("catalog rm", serde_json::json!({"name": name}))? + } + } Ok(()) } Ok(None) => Err(CliError::new(format!("未找到 volume: {name}"))), @@ -623,12 +628,13 @@ mod tests { unsafe { std::env::set_var("CATALOG_DIR", &catalog_dir); } - let err = add( + let err = add_with_mode( "/nonexistent/file.csv", None, None, None, VolumeArtifactType::PreReview, + OutputMode::Text, ) .unwrap_err(); unsafe { @@ -730,12 +736,13 @@ mod tests { unsafe { std::env::set_var("CATALOG_DIR", &catalog_dir); } - let result = add( + let result = add_with_mode( file.to_str().unwrap(), Some("DATA-1"), Some("process"), None, VolumeArtifactType::PreReview, + OutputMode::Text, ); unsafe { std::env::remove_var("CATALOG_DIR"); @@ -756,7 +763,7 @@ mod tests { std::env::set_var("CATALOG_DIR", &catalog_dir); } let name = seed_volume(&catalog_dir); - let result = rm(&name); + let result = rm_with_mode(&name, OutputMode::Text); unsafe { std::env::remove_var("CATALOG_DIR"); } @@ -784,7 +791,7 @@ mod tests { unsafe { std::env::set_var("CATALOG_DIR", &catalog_dir); } - let err = rm("ghost").unwrap_err(); + let err = rm_with_mode("ghost", OutputMode::Text).unwrap_err(); unsafe { std::env::remove_var("CATALOG_DIR"); } diff --git a/src/cli/src/implementation/pipeline.rs b/src/cli/src/implementation/pipeline.rs index 888ffa9..bef36be 100644 --- a/src/cli/src/implementation/pipeline.rs +++ b/src/cli/src/implementation/pipeline.rs @@ -47,8 +47,7 @@ fn cmd_list(dir: &str, mode: OutputMode) -> Result<(), CliError> { let dir_path = Path::new(dir); if dir_path.is_dir() { let names = definition_names(dir_path); - render_list(&names, mode); - return Ok(()); + return render_list(&names, mode); } let output = cue_export(&["export", "--out", "json", dir]) @@ -61,8 +60,7 @@ fn cmd_list(dir: &str, mode: OutputMode) -> Result<(), CliError> { let value: Value = serde_json::from_slice(&output.stdout) .map_err(|e| CliError::new(format!("cue 输出不是合法 JSON: {e}")))?; let names = collect_defined_names(&value); - render_list(&names, mode); - Ok(()) + render_list(&names, mode) } fn cmd_show(dir: &str, name: &str, mode: OutputMode) -> Result<(), CliError> { @@ -70,8 +68,7 @@ fn cmd_show(dir: &str, name: &str, mode: OutputMode) -> Result<(), CliError> { if let Some(path) = find_definition(dir_path, name) { let content = std::fs::read_to_string(&path) .map_err(|err| CliError::new(format!("读取 Pipeline 失败: {err}")))?; - render_show(name, &content, mode)?; - return Ok(()); + return render_show(name, &content, mode); } let key = crate::util::to_camel(name); @@ -84,26 +81,21 @@ fn cmd_show(dir: &str, name: &str, mode: OutputMode) -> Result<(), CliError> { .map_err(|e| CliError::new(format!("cue 输出不是合法 JSON: {e}")))?; let content = serde_json::to_string_pretty(&value) .map_err(|e| CliError::new(format!("序列化失败: {e}")))?; - render_show(name, &content, mode)?; - Ok(()) + render_show(name, &content, mode) } -fn render_list(names: &[String], mode: OutputMode) { +fn render_list(names: &[String], mode: OutputMode) -> Result<(), CliError> { match mode { OutputMode::Text => { println!("可用的 Pipeline:"); for name in names { println!(" - {name}"); } + Ok(()) + } + OutputMode::Json => { + crate::output::print_success("pipeline list", serde_json::json!({"items": names})) } - OutputMode::Json => println!( - "{}", - serde_json::json!({ - "ok": true, - "command": "pipeline list", - "items": names, - }) - ), } } @@ -114,15 +106,10 @@ fn render_show(name: &str, content: &str, mode: OutputMode) -> Result<(), CliErr let definition: serde_json::Value = serde_yaml::from_str(content) .or_else(|_| serde_json::from_str(content)) .map_err(|err| CliError::new(format!("Pipeline 不是合法结构化数据: {err}")))?; - println!( - "{}", - serde_json::json!({ - "ok": true, - "command": "pipeline show", - "name": name, - "pipeline": definition, - }) - ); + crate::output::print_success( + "pipeline show", + serde_json::json!({"name": name, "pipeline": definition}), + )?; } } Ok(()) diff --git a/src/cli/src/lib.rs b/src/cli/src/lib.rs index d3faa79..dd50343 100644 --- a/src/cli/src/lib.rs +++ b/src/cli/src/lib.rs @@ -5,6 +5,7 @@ use clap::Subcommand; pub mod doctor; pub mod error; pub mod implementation; +pub mod output; pub mod registry; pub mod review; pub mod runtime; @@ -89,7 +90,7 @@ fn dispatch_command( Commands::Version(args) => spec::version::run(args), Commands::Doctor(args) => doctor::run(args), Commands::Blueprint(args) => spec::blueprint::run_with_mode(args, mode), - Commands::Contract(args) => spec::contract::run(args), + Commands::Contract(args) => spec::contract::run_with_mode(args, mode), Commands::Pipeline(args) => implementation::pipeline::run_with_mode(args, mode), Commands::Catalog(args) => implementation::catalog::run_with_mode(args, mode), Commands::Implement(args) => { diff --git a/src/cli/src/output.rs b/src/cli/src/output.rs new file mode 100644 index 0000000..f985174 --- /dev/null +++ b/src/cli/src/output.rs @@ -0,0 +1,53 @@ +//! 结构化命令输出模型。 + +use serde::Serialize; + +use crate::error::CliError; + +/// 所有已迁移命令的 JSON 成功结果外层结构。 +#[derive(Debug, Serialize)] +pub struct SuccessEnvelope { + pub ok: bool, + pub command: String, + pub data: T, +} + +impl SuccessEnvelope { + pub fn new(command: impl Into, data: T) -> Self { + Self { + ok: true, + command: command.into(), + data, + } + } +} + +/// 打印统一 JSON 成功结果。 +pub fn print_success(command: &str, data: T) -> Result<(), CliError> { + let envelope = SuccessEnvelope::new(command, data); + let output = serde_json::to_string(&envelope) + .map_err(|err| CliError::new(format!("序列化 JSON 成功结果失败: {err}")))?; + println!("{output}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn success_envelope_serializes_with_stable_top_level_fields() { + let envelope = SuccessEnvelope::new( + "pipeline list", + serde_json::json!({ + "items": ["normalize"], + }), + ); + + let value = serde_json::to_value(envelope).unwrap(); + + assert_eq!(value["ok"], true); + assert_eq!(value["command"], "pipeline list"); + assert_eq!(value["data"]["items"][0], "normalize"); + } +} diff --git a/src/cli/src/spec/blueprint.rs b/src/cli/src/spec/blueprint.rs index eca923a..0776bc8 100644 --- a/src/cli/src/spec/blueprint.rs +++ b/src/cli/src/spec/blueprint.rs @@ -46,8 +46,7 @@ fn cmd_list(dir: &str, mode: OutputMode) -> Result<(), CliError> { let dir_path = Path::new(dir); if dir_path.is_dir() { let names = definition_names(dir_path); - render_list(&names, mode); - return Ok(()); + return render_list(&names, mode); } let output = cue_export(&["export", "--out", "json", dir]).map_err(|_| { @@ -61,8 +60,7 @@ fn cmd_list(dir: &str, mode: OutputMode) -> Result<(), CliError> { let value: Value = serde_json::from_slice(&output.stdout) .map_err(|e| CliError::new(format!("cue 输出不是合法 JSON: {e}")))?; let names = collect_defined_names(&value); - render_list(&names, mode); - Ok(()) + render_list(&names, mode) } fn cmd_show(dir: &str, name: &str, mode: OutputMode) -> Result<(), CliError> { @@ -70,8 +68,7 @@ fn cmd_show(dir: &str, name: &str, mode: OutputMode) -> Result<(), CliError> { if let Some(path) = find_definition(dir_path, name) { let content = std::fs::read_to_string(&path) .map_err(|err| CliError::new(format!("读取 Blueprint 失败: {err}")))?; - render_show(name, &content, mode)?; - return Ok(()); + return render_show(name, &content, mode); } let key = crate::util::to_camel(name); @@ -84,26 +81,21 @@ fn cmd_show(dir: &str, name: &str, mode: OutputMode) -> Result<(), CliError> { .map_err(|e| CliError::new(format!("cue 输出不是合法 JSON: {e}")))?; let content = serde_json::to_string_pretty(&value) .map_err(|e| CliError::new(format!("序列化失败: {e}")))?; - render_show(name, &content, mode)?; - Ok(()) + render_show(name, &content, mode) } -fn render_list(names: &[String], mode: OutputMode) { +fn render_list(names: &[String], mode: OutputMode) -> Result<(), CliError> { match mode { OutputMode::Text => { println!("可用的 Blueprint:"); for name in names { println!(" - {name}"); } + Ok(()) + } + OutputMode::Json => { + crate::output::print_success("blueprint list", serde_json::json!({"items": names})) } - OutputMode::Json => println!( - "{}", - serde_json::json!({ - "ok": true, - "command": "blueprint list", - "items": names, - }) - ), } } @@ -114,15 +106,10 @@ fn render_show(name: &str, content: &str, mode: OutputMode) -> Result<(), CliErr let definition: serde_json::Value = serde_yaml::from_str(content) .or_else(|_| serde_json::from_str(content)) .map_err(|err| CliError::new(format!("Blueprint 不是合法结构化数据: {err}")))?; - println!( - "{}", - serde_json::json!({ - "ok": true, - "command": "blueprint show", - "name": name, - "blueprint": definition, - }) - ); + crate::output::print_success( + "blueprint show", + serde_json::json!({"name": name, "blueprint": definition}), + )?; } } Ok(()) diff --git a/src/cli/src/spec/contract.rs b/src/cli/src/spec/contract.rs index 464c3ea..04efe61 100644 --- a/src/cli/src/spec/contract.rs +++ b/src/cli/src/spec/contract.rs @@ -3,6 +3,7 @@ use clap::{Args, Subcommand}; use std::path::{Path, PathBuf}; +use crate::OutputMode; use crate::error::CliError; #[derive(Args)] @@ -63,31 +64,48 @@ fn find_contract(dir: &Path, name: &str) -> Option { /// 契约查看命令入口(list / show)。 pub fn run(args: &ContractArgs) -> Result<(), CliError> { + run_with_mode(args, OutputMode::Text) +} + +pub fn run_with_mode(args: &ContractArgs, mode: OutputMode) -> Result<(), CliError> { let dir = contract_dir(); match &args.action { - ContractAction::List => cmd_list(&dir), - ContractAction::Show { name } => cmd_show(&dir, name), + ContractAction::List => cmd_list_with_mode(&dir, mode), + ContractAction::Show { name } => cmd_show_with_mode(&dir, name, mode), } } -fn cmd_list(dir: &Path) -> Result<(), CliError> { +fn cmd_list_with_mode(dir: &Path, mode: OutputMode) -> Result<(), CliError> { if !dir.is_dir() { return Err(CliError::new(format!("契约目录不存在: {}", dir.display()))); } let names = contract_names(dir); - println!("可用的 Contract:"); - for name in names { - println!(" - {name}"); + match mode { + OutputMode::Text => { + println!("可用的 Contract:"); + for name in names { + println!(" - {name}"); + } + Ok(()) + } + OutputMode::Json => { + crate::output::print_success("contract list", serde_json::json!({"items": names})) + } } - Ok(()) } -fn cmd_show(dir: &Path, name: &str) -> Result<(), CliError> { +fn cmd_show_with_mode(dir: &Path, name: &str, mode: OutputMode) -> Result<(), CliError> { let path = find_contract(dir, name) .ok_or_else(|| CliError::new(format!("未找到 Contract: {name}")))?; let content = std::fs::read_to_string(&path) .map_err(|err| CliError::new(format!("读取契约失败: {err}")))?; - println!("{content}"); + match mode { + OutputMode::Text => println!("{content}"), + OutputMode::Json => crate::output::print_success( + "contract show", + serde_json::json!({"name": name, "content": content}), + )?, + } Ok(()) } @@ -159,7 +177,7 @@ mod tests { fn cmd_list_reports_missing_dir_without_exiting() { let root = temp_dir("qtcloud-contract-list-missing"); let missing = root.join("nope"); - let err = cmd_list(&missing).unwrap_err(); + let err = cmd_list_with_mode(&missing, OutputMode::Text).unwrap_err(); assert!(err.to_string().contains("契约目录不存在"), "{}", err); std::fs::remove_dir_all(&root).ok(); } @@ -167,7 +185,7 @@ mod tests { #[test] fn cmd_show_reports_missing_contract_without_exiting() { let root = temp_dir("qtcloud-contract-show-missing"); - let err = cmd_show(&root, "ghost").unwrap_err(); + let err = cmd_show_with_mode(&root, "ghost", OutputMode::Text).unwrap_err(); assert_eq!(err.to_string(), "未找到 Contract: ghost"); std::fs::remove_dir_all(&root).ok(); } diff --git a/src/cli/src/spec/mod.rs b/src/cli/src/spec/mod.rs index aced7e2..9e21f6d 100644 --- a/src/cli/src/spec/mod.rs +++ b/src/cli/src/spec/mod.rs @@ -188,14 +188,9 @@ fn wrap_file(input: &str, output: &Option, mode: OutputMode) -> Result<( .map_err(|err| CliError::new(format!("写入 Specification YAML 失败: {err}")))?; match mode { OutputMode::Text => println!("已生成: {}", output_path.display()), - OutputMode::Json => println!( - "{}", - serde_json::json!({ - "ok": true, - "command": "spec wrap", - "output": output_path, - }) - ), + OutputMode::Json => { + crate::output::print_success("spec wrap", serde_json::json!({"output": output_path}))? + } } Ok(()) } @@ -219,14 +214,10 @@ fn validate_file(input: &str, mode: OutputMode) -> Result<(), CliError> { match mode { OutputMode::Text => println!("Specification OK: {}", blueprint.name), - OutputMode::Json => println!( - "{}", - serde_json::json!({ - "ok": true, - "command": "spec validate", - "name": blueprint.name, - }) - ), + OutputMode::Json => crate::output::print_success( + "spec validate", + serde_json::json!({"name": blueprint.name}), + )?, } Ok(()) } diff --git a/src/cli/tests/blueprint_test.rs b/src/cli/tests/blueprint_test.rs index 02fe981..4567d0f 100644 --- a/src/cli/tests/blueprint_test.rs +++ b/src/cli/tests/blueprint_test.rs @@ -53,8 +53,9 @@ fn test_blueprint_list_json_returns_items() { assert!(output.status.success()); assert!(output.stderr.is_empty()); let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], true); assert_eq!(report["command"], "blueprint list"); - assert_eq!(report["items"][0], "customer"); + assert_eq!(report["data"]["items"][0], "customer"); std::fs::remove_dir_all(&tmp).ok(); } diff --git a/src/cli/tests/cli_test.rs b/src/cli/tests/cli_test.rs index 05ae54c..4abd291 100644 --- a/src/cli/tests/cli_test.rs +++ b/src/cli/tests/cli_test.rs @@ -71,8 +71,9 @@ fn test_global_json_formats_pipeline_list_success() { assert!(output.status.success()); assert!(output.stderr.is_empty()); let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], true); assert_eq!(report["command"], "pipeline list"); - assert_eq!(report["items"][0], "normalize"); + assert_eq!(report["data"]["items"][0], "normalize"); std::fs::remove_dir_all(&root).ok(); } @@ -102,6 +103,7 @@ fn test_global_json_formats_spec_wrap_success() { let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(report["ok"], true); assert_eq!(report["command"], "spec wrap"); + assert!(report["data"]["output"].is_string()); assert!(output_path.is_file()); std::fs::remove_dir_all(&root).ok(); @@ -145,7 +147,156 @@ fn test_global_json_formats_catalog_list_success() { assert!(output.stderr.is_empty()); let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(report["command"], "catalog list"); - assert_eq!(report["items"][0]["name"], "report"); + assert_eq!(report["data"]["items"][0]["name"], "report"); + + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn test_global_json_formats_pipeline_show_success() { + let root = std::env::temp_dir().join(format!( + "qtcloud-global-json-pipeline-show-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("normalize.yaml"), "name: normalize\n").unwrap(); + + let output = cli() + .env("PIPELINE_DIR", &root) + .arg("--json") + .arg("pipeline") + .arg("show") + .arg("normalize") + .output() + .unwrap(); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], true); + assert_eq!(report["command"], "pipeline show"); + assert_eq!(report["data"]["name"], "normalize"); + assert_eq!(report["data"]["pipeline"]["name"], "normalize"); + + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn test_global_json_formats_blueprint_show_success() { + let root = std::env::temp_dir().join(format!( + "qtcloud-global-json-blueprint-show-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("customer.yaml"), "name: customer\n").unwrap(); + + let output = cli() + .env("BLUEPRINT_DIR", &root) + .arg("--json") + .arg("blueprint") + .arg("show") + .arg("customer") + .output() + .unwrap(); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], true); + assert_eq!(report["command"], "blueprint show"); + assert_eq!(report["data"]["name"], "customer"); + assert_eq!(report["data"]["blueprint"]["name"], "customer"); + + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn test_global_json_formats_contract_list_and_show_success() { + let root = std::env::temp_dir().join(format!( + "qtcloud-global-json-contract-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("customer.yaml"), "name: customer\n").unwrap(); + + let list = cli() + .env("CONTRACT_DIR", &root) + .arg("--json") + .arg("contract") + .arg("list") + .output() + .unwrap(); + assert!(list.status.success()); + assert!(list.stderr.is_empty()); + let list_report: serde_json::Value = serde_json::from_slice(&list.stdout).unwrap(); + assert_eq!(list_report["ok"], true); + assert_eq!(list_report["command"], "contract list"); + assert_eq!(list_report["data"]["items"][0], "customer"); + + let show = cli() + .env("CONTRACT_DIR", &root) + .arg("--json") + .arg("contract") + .arg("show") + .arg("customer") + .output() + .unwrap(); + assert!(show.status.success()); + assert!(show.stderr.is_empty()); + let show_report: serde_json::Value = serde_json::from_slice(&show.stdout).unwrap(); + assert_eq!(show_report["ok"], true); + assert_eq!(show_report["command"], "contract show"); + assert_eq!(show_report["data"]["name"], "customer"); + assert_eq!(show_report["data"]["content"], "name: customer\n"); + + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn test_global_json_formats_catalog_show_and_add_success() { + let root = std::env::temp_dir().join(format!( + "qtcloud-global-json-catalog-write-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let data = root.join("report.csv"); + std::fs::write(&data, "a,b\n1,2\n").unwrap(); + + let add = cli() + .env("CATALOG_DIR", &root) + .arg("--json") + .arg("catalog") + .arg("add") + .arg(&data) + .arg("--name") + .arg("report") + .output() + .unwrap(); + assert!(add.status.success()); + assert!(add.stderr.is_empty()); + let add_report: serde_json::Value = serde_json::from_slice(&add.stdout).unwrap(); + assert_eq!(add_report["ok"], true); + assert_eq!(add_report["command"], "catalog add"); + assert_eq!(add_report["data"]["volume"]["name"], "report"); + + let show = cli() + .env("CATALOG_DIR", &root) + .arg("--json") + .arg("catalog") + .arg("show") + .arg("report") + .output() + .unwrap(); + assert!(show.status.success()); + assert!(show.stderr.is_empty()); + let show_report: serde_json::Value = serde_json::from_slice(&show.stdout).unwrap(); + assert_eq!(show_report["ok"], true); + assert_eq!(show_report["command"], "catalog show"); + assert_eq!(show_report["data"]["volume"]["name"], "report"); std::fs::remove_dir_all(&root).ok(); } diff --git a/src/cli/tests/spec_test.rs b/src/cli/tests/spec_test.rs index c5a7c11..fa12e80 100644 --- a/src/cli/tests/spec_test.rs +++ b/src/cli/tests/spec_test.rs @@ -122,7 +122,7 @@ fn test_spec_validate_json_outputs_success_object() { let report: serde_json::Value = serde_json::from_slice(&validate.stdout).unwrap(); assert_eq!(report["ok"], true); assert_eq!(report["command"], "spec validate"); - assert_eq!(report["name"], "sample"); + assert_eq!(report["data"]["name"], "sample"); std::fs::remove_dir_all(&root).ok(); } From 59535c53b10c90d93f5a74d7fa47061d3ea61178 Mon Sep 17 00:00:00 2001 From: ztzzh Date: Mon, 24 Aug 2026 23:06:21 +0800 Subject: [PATCH 2/2] docs(cli): add release lifecycle records --- src/cli/ROADMAP.md | 6 +++--- src/cli/TODO.md | 2 +- src/cli/docs/deploy.md | 32 ++++++++++++++++++++++++++++++++ src/cli/docs/index.md | 8 ++++++++ src/cli/docs/monitor.md | 25 +++++++++++++++++++++++++ src/cli/docs/operate.md | 25 +++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 src/cli/docs/deploy.md create mode 100644 src/cli/docs/monitor.md create mode 100644 src/cli/docs/operate.md diff --git a/src/cli/ROADMAP.md b/src/cli/ROADMAP.md index 9074de1..824dfff 100644 --- a/src/cli/ROADMAP.md +++ b/src/cli/ROADMAP.md @@ -21,7 +21,7 @@ - [x] R / Stata runtime:补充运行时实现、codegen prompt 和执行注册(`src/runtime/`、`src/stage/implement.rs`、`src/stage/process.rs`) - [x] doctor 检查表改由 runtime 注册表驱动,并覆盖已注册运行时(`src/doctor.rs`、`src/runtime/mod.rs`) - [x] 全局 `--json` 错误通道和稳定错误码首切片(`src/main.rs`、`src/cli.rs`、`src/error.rs`) -- [ ] 成功命令统一结果模型:优先迁移无副作用查看命令和 catalog/pipeline 命令(`src/cli.rs`、`src/spec/`、`src/implementation/`) +- [x] 成功命令统一结果模型:优先迁移无副作用查看命令和 catalog/pipeline 命令(`src/cli.rs`、`src/spec/`、`src/implementation/`) ### Changed @@ -30,8 +30,8 @@ - [x] 传输 provider 使用枚举或结构化解析,替代字符串匹配(`src/stage/transfer.rs`、`src/storage/mod.rs`) - [x] process 使用结构化 Blueprint states 引用,替代逗号分隔的 pipeline 字符串(`src/stage/process.rs`) - [x] 将命令分发移入库层,使 `Commands` 和统一参数注入路径可单测(`src/main.rs`、`src/cli.rs`、`src/lib.rs`) -- [ ] 补充 deploy、operate、monitor 阶段记录(`docs/`) -- [ ] 核对并补齐父仓库各平台 release workflow,确保 Linux、Windows、macOS 制品与发布记录一致(`../../.github/workflows/release-cli.yml`) +- [x] 补充 deploy、operate、monitor 阶段记录(`docs/`) +- [x] 核对并补齐父仓库各平台 release workflow,确保 Linux、Windows、macOS 制品与发布记录一致(`../../.github/workflows/release-cli.yml`) ## Provider 依赖项(不作为 0.3.0 发布门槛) diff --git a/src/cli/TODO.md b/src/cli/TODO.md index 53b3674..281568b 100644 --- a/src/cli/TODO.md +++ b/src/cli/TODO.md @@ -47,7 +47,7 @@ - [x] 将 `run_command` 分发移入库层,使 `Commands` 和参数注入路径可单测(`src/cli.rs`、`src/lib.rs`) - [x] 在 `src/stage/transfer.rs` 使用结构化 provider 枚举,替代字符串匹配 - [x] 在 `src/stage/process.rs` 使用结构化 Blueprint states 引用,替代逗号分隔字符串 -- [ ] 将无副作用查看命令和 catalog/pipeline 命令迁移到统一成功结果模型(`src/cli.rs`、`src/spec/`、`src/implementation/`) +- [x] 将无副作用查看命令和 catalog/pipeline 命令迁移到统一成功结果模型(`src/cli.rs`、`src/spec/`、`src/implementation/`) ## 0.3.0 传输测试与发布 diff --git a/src/cli/docs/deploy.md b/src/cli/docs/deploy.md new file mode 100644 index 0000000..8cf4780 --- /dev/null +++ b/src/cli/docs/deploy.md @@ -0,0 +1,32 @@ +# deploy — 发布状态 + +本页记录 `qtcloud-data` CLI 的发布与制品状态,写实为准。 + +## 当前状态 + +- 版本目标:`cli/v0.3.0` +- 发布入口:`qtcloud-devops release publish` +- 现在还没有正式发布完成 + +## 制品矩阵 + +`src/cli/Cargo.toml` 的 `cargo-dist` 目标是: + +- `x86_64-unknown-linux-gnu` +- `x86_64-pc-windows-msvc` +- `aarch64-apple-darwin` +- `x86_64-apple-darwin` + +对应的 release workflow 已补齐 Linux、Windows、macOS arm64、macOS Intel 的构建与上传条目。 + +## 发布审计 + +2026-08-24 的 `qtcloud-devops release audit -v cli/v0.3.0 --scope cli` 结果: + +- 版本号格式:通过 +- 配置文件一致性:通过 +- CHANGELOG:通过 +- 工作区状态:未通过,存在未提交变更 +- 标签冲突:未通过,`cli/v0.3.0` 已存在 +- GitHub Release:未通过,body 与 CHANGELOG 不同步 + diff --git a/src/cli/docs/index.md b/src/cli/docs/index.md index cedcd7f..31d0023 100644 --- a/src/cli/docs/index.md +++ b/src/cli/docs/index.md @@ -22,6 +22,14 @@ 贡献与发布流程见 [CONTRIBUTING.md](../CONTRIBUTING.md)。 +## 发布生命周期记录 + +| 文档 | 作用 | +|------|------| +| [deploy.md](deploy.md) | 发布状态、制品矩阵、当前 release audit 结果 | +| [operate.md](operate.md) | 安装与运行入口、可用 smoke 命令 | +| [monitor.md](monitor.md) | 发布后验证项、已完成检查与待办 | + ## 命令结构总览 ``` diff --git a/src/cli/docs/monitor.md b/src/cli/docs/monitor.md new file mode 100644 index 0000000..0f7e2f6 --- /dev/null +++ b/src/cli/docs/monitor.md @@ -0,0 +1,25 @@ +# monitor — 发布后验证 + +本页记录发布后要看的验证项,以及当前已完成的本地检查。 + +## 已完成 + +- `cargo fmt --check` +- `cargo test --locked` +- `cargo clippy --locked -- -D warnings` +- `qtcloud-devops release audit -v cli/v0.3.0 --scope cli` + +## 当前结果 + +- 格式检查:通过 +- 测试:通过 +- clippy:通过 +- release audit:部分通过,仍有工作区、标签和 GitHub Release 不一致问题 + +## 发布后还要补 + +- `qtcloud-devops release status` +- `gh release view cli/v0.3.0` +- `cargo info qtcloud-data-cli --registry crates-io` +- `cargo install qtcloud-data-cli --version ` + diff --git a/src/cli/docs/operate.md b/src/cli/docs/operate.md new file mode 100644 index 0000000..e96719a --- /dev/null +++ b/src/cli/docs/operate.md @@ -0,0 +1,25 @@ +# operate — 安装与运行 + +本页记录本地安装和运行入口。 + +## 本地运行 + +```bash +cargo build --locked +qtcloud-data --help +qtcloud-data doctor --no-fail +qtcloud-data spec --help +qtcloud-data process --help +``` + +## 发布后安装 + +```bash +cargo install qtcloud-data-cli --version +qtcloud-data --help +``` + +## 说明 + +以上入口用于确认二进制可启动、命令树可解析、基础 smoke 命令可执行。 +