feat: finalize latest v0.97.0 updates

This commit is contained in:
coso
2026-03-27 19:22:46 +08:00
parent b06c4a0408
commit 91dc5dd1ca
64 changed files with 9744 additions and 2399 deletions
@@ -34,6 +34,10 @@ const ARTIFACT_BLOCK_TYPE_VALUES: &[&str] = &[
"divider",
];
const MAX_BLOCK_COUNT: usize = 40;
const MARKDOWN_RECOVERY_REASON: &str =
"模型未返回合法的 ArtifactDocument JSON,已按 Markdown 正文自动恢复为可渲染文档。";
const TRUNCATED_JSON_RECOVERY_REASON: &str =
"检测到不完整的 ArtifactDocument JSON,已做闭合修复后继续校验。";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ArtifactDocumentValidationContext {
@@ -64,7 +68,18 @@ pub fn validate_or_fallback_artifact_document(
raw_text: &str,
context: &ArtifactDocumentValidationContext,
) -> ArtifactDocumentValidationOutcome {
let Some(candidate) = extract_artifact_document_candidate(raw_text) else {
let Some((candidate, repaired_truncated_json)) = extract_artifact_document_candidate(raw_text)
else {
if let Some(recovered_candidate) = build_markdown_recovery_candidate(raw_text, context) {
let mut outcome =
validate_or_repair_artifact_document_value(&recovered_candidate, raw_text, context);
outcome.repaired = true;
outcome.fallback_used = true;
outcome
.issues
.insert(0, MARKDOWN_RECOVERY_REASON.to_string());
return outcome;
}
return build_failed_fallback_document(
raw_text,
"模型未返回合法的 ArtifactDocument JSON,已回退为失败态文档。",
@@ -72,7 +87,14 @@ pub fn validate_or_fallback_artifact_document(
);
};
validate_or_repair_artifact_document_value(&candidate, raw_text, context)
let mut outcome = validate_or_repair_artifact_document_value(&candidate, raw_text, context);
if repaired_truncated_json {
outcome.repaired = true;
outcome
.issues
.insert(0, TRUNCATED_JSON_RECOVERY_REASON.to_string());
}
outcome
}
pub fn validate_or_repair_artifact_document_value(
@@ -303,7 +325,53 @@ fn build_failed_fallback_document(
}
}
fn extract_artifact_document_candidate(raw_text: &str) -> Option<Value> {
fn build_markdown_recovery_candidate(
raw_text: &str,
context: &ArtifactDocumentValidationContext,
) -> Option<Value> {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return None;
}
let (heading_title, markdown_body) = extract_markdown_heading_and_body(trimmed);
let title = normalize_title(
heading_title
.or_else(|| context.title_hint.clone())
.or_else(|| extract_first_non_empty_line(trimmed)),
);
let body = if markdown_body.trim().is_empty() {
trimmed.to_string()
} else {
markdown_body
};
let kind = normalize_enum(context.kind_hint.clone(), ARTIFACT_KIND_VALUES, "analysis");
let mut document = Map::new();
document.insert(
"schemaVersion".to_string(),
Value::String(ARTIFACT_DOCUMENT_SCHEMA_VERSION.to_string()),
);
document.insert("kind".to_string(), Value::String(kind));
document.insert("title".to_string(), Value::String(title));
document.insert("status".to_string(), Value::String("draft".to_string()));
document.insert("language".to_string(), Value::String("zh-CN".to_string()));
if let Some(summary) = extract_markdown_summary(body.as_str()) {
document.insert("summary".to_string(), Value::String(summary));
}
document.insert(
"blocks".to_string(),
Value::Array(vec![Value::Object(build_fallback_rich_text_block(
"block-1",
body.as_str(),
Some("markdown_recovery"),
))]),
);
document.insert("sources".to_string(), Value::Array(Vec::new()));
document.insert("metadata".to_string(), Value::Object(Map::new()));
Some(Value::Object(document))
}
fn extract_artifact_document_candidate(raw_text: &str) -> Option<(Value, bool)> {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return None;
@@ -314,6 +382,7 @@ fn extract_artifact_document_candidate(raw_text: &str) -> Option<Value> {
strip_outer_code_fence(trimmed),
extract_first_fenced_payload(trimmed).unwrap_or_default(),
extract_braced_json_candidate(trimmed).unwrap_or_default(),
extract_unclosed_json_candidate(trimmed).unwrap_or_default(),
];
for candidate in candidates {
@@ -321,17 +390,24 @@ fn extract_artifact_document_candidate(raw_text: &str) -> Option<Value> {
if normalized.is_empty() {
continue;
}
let Ok(parsed) = serde_json::from_str::<Value>(normalized) else {
continue;
};
if let Some(document) = unwrap_artifact_document_envelope(&parsed) {
return Some(document.clone());
if let Some(document) = parse_artifact_document_candidate(normalized) {
return Some((document, false));
}
if let Some(repaired) = repair_json_candidate(normalized) {
if let Some(document) = parse_artifact_document_candidate(repaired.as_str()) {
return Some((document, true));
}
}
}
None
}
fn parse_artifact_document_candidate(candidate: &str) -> Option<Value> {
let parsed = serde_json::from_str::<Value>(candidate).ok()?;
unwrap_artifact_document_envelope(&parsed).cloned()
}
fn unwrap_artifact_document_envelope(value: &Value) -> Option<&Value> {
let record = value.as_object()?;
if is_artifact_document_record(record) {
@@ -396,6 +472,79 @@ fn extract_braced_json_candidate(raw: &str) -> Option<String> {
Some(raw[start..=end].trim().to_string())
}
fn extract_unclosed_json_candidate(raw: &str) -> Option<String> {
let start = raw.find('{').or_else(|| raw.find('['))?;
Some(raw[start..].trim().to_string())
}
fn repair_json_candidate(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let mut repaired = String::new();
let mut expected_closers = Vec::new();
let mut in_string = false;
let mut escaping = false;
for ch in trimmed.chars() {
repaired.push(ch);
if in_string {
if escaping {
escaping = false;
continue;
}
match ch {
'\\' => escaping = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match ch {
'"' => in_string = true,
'{' => expected_closers.push('}'),
'[' => expected_closers.push(']'),
'}' | ']' => {
let expected = expected_closers.pop()?;
if ch != expected {
return None;
}
}
_ => {}
}
}
if in_string {
repaired.push('"');
}
repaired = repair_json_tail(repaired);
while let Some(closer) = expected_closers.pop() {
repaired = repair_json_tail(repaired);
repaired.push(closer);
}
Some(repaired)
}
fn repair_json_tail(mut value: String) -> String {
loop {
let trimmed_len = value.trim_end().len();
value.truncate(trimmed_len);
if value.ends_with(':') {
value.push_str(" null");
break;
}
if value.ends_with(',') {
value.pop();
continue;
}
break;
}
value
}
fn normalize_sources(
value: Option<&Value>,
repaired: &mut bool,
@@ -841,6 +990,102 @@ fn build_fallback_markdown(raw_text: &str, reason: &str) -> String {
}
}
fn extract_markdown_heading_and_body(raw_text: &str) -> (Option<String>, String) {
let lines = raw_text.lines().collect::<Vec<_>>();
let first_content_index = lines
.iter()
.position(|line| !line.trim().is_empty())
.unwrap_or(0);
let first_line = lines
.get(first_content_index)
.map(|line| line.trim())
.unwrap_or_default();
let Some(heading) = first_line.strip_prefix('#') else {
return (None, raw_text.trim().to_string());
};
let title = normalize_text(Some(heading.trim_start_matches('#').trim()));
if title.is_none() {
return (None, raw_text.trim().to_string());
}
let mut body_start = first_content_index + 1;
while body_start < lines.len() && lines[body_start].trim().is_empty() {
body_start += 1;
}
let body = lines[body_start..].join("\n").trim().to_string();
(title, body)
}
fn extract_first_non_empty_line(raw_text: &str) -> Option<String> {
raw_text
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(ToString::to_string)
}
fn extract_markdown_summary(markdown: &str) -> Option<String> {
let mut in_code_block = false;
let mut parts = Vec::new();
for line in markdown.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block || trimmed.is_empty() {
if !parts.is_empty() {
break;
}
continue;
}
if trimmed.starts_with('#') {
continue;
}
let normalized = strip_markdown_summary_prefix(trimmed);
if normalized.is_empty() {
continue;
}
parts.push(normalized);
if parts.len() >= 2 {
break;
}
}
if parts.is_empty() {
None
} else {
Some(truncate_text(parts.join(" ").as_str(), 180))
}
}
fn strip_markdown_summary_prefix(line: &str) -> String {
let trimmed = line.trim();
let without_bullet = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix("> "))
.unwrap_or(trimmed)
.trim();
let without_ordered = without_bullet
.find(". ")
.and_then(|index| {
if without_bullet[..index]
.chars()
.all(|ch| ch.is_ascii_digit())
{
without_bullet.get(index + 2..)
} else {
None
}
})
.unwrap_or(without_bullet)
.trim();
without_ordered.to_string()
}
fn normalize_title(value: Option<String>) -> String {
value
.map(|title| truncate_text(&title, 120))
@@ -1132,4 +1377,53 @@ mod tests {
== Some("rich_text")
);
}
#[test]
fn validate_or_fallback_should_repair_truncated_json_before_markdown_recovery() {
let mut context = base_context();
context.source_policy = Some("none".to_string());
let outcome = validate_or_fallback_artifact_document(
"{\n \"schemaVersion\": \"artifact_document.v1\",\n \"kind\": \"analysis\",\n \"title\": \"结构化报告\",\n \"status\": \"ready\",\n \"blocks\": [\n { \"type\": \"hero_summary\", \"summary\": \"摘要\" }\n ],\n \"sources\": [],\n \"metadata\": {\n \"theme\": \"knowledge\"\n }\n",
&context,
);
assert_eq!(outcome.status, "ready");
assert!(!outcome.fallback_used);
assert!(outcome.repaired);
assert!(outcome
.issues
.iter()
.any(|issue| issue.contains("不完整的 ArtifactDocument JSON")));
assert_eq!(outcome.title, "结构化报告");
}
#[test]
fn validate_or_fallback_should_recover_markdown_when_sources_not_required() {
let mut context = base_context();
context.source_policy = Some("none".to_string());
context.title_hint = None;
let outcome = validate_or_fallback_artifact_document(
"# 前端概念方案\n\n我将为你整理一份通用的前端概念方案框架。\n\n## 信息架构\n- 页面结构\n",
&context,
);
assert_eq!(outcome.status, "draft");
assert!(outcome.fallback_used);
assert_eq!(outcome.title, "前端概念方案");
assert!(outcome.repaired);
assert!(outcome
.issues
.iter()
.any(|issue| issue.contains("Markdown 正文自动恢复")));
assert_eq!(
outcome.document.get("status").and_then(Value::as_str),
Some("draft")
);
assert_eq!(
outcome.document.get("summary").and_then(Value::as_str),
Some("我将为你整理一份通用的前端概念方案框架。")
);
}
}
@@ -1,8 +1,8 @@
//! Runtime review decision 模板导出服务
//! Runtime review decision 模板导出与保存服务
//!
//! 将外部 Claude Code / Codex 的分析结论回挂为
//! Lime 工作区内可版本化的人工审核与决策记录模板。
//! 这条链只导出 review-decision 模板,不在 Lime 内自动批准或自动应用修复。
//! 这条链只导出与保存 review-decision,不在 Lime 内自动批准或自动应用修复。
use crate::agent::SessionDetail;
use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
@@ -59,12 +59,15 @@ pub struct RuntimeReviewDecisionTemplateExportResult {
pub pending_request_count: usize,
pub queued_turn_count: usize,
pub default_decision_status: String,
pub decision: RuntimeReviewDecisionContent,
pub decision_status_options: Vec<String>,
pub risk_level_options: Vec<String>,
pub review_checklist: Vec<String>,
pub analysis_artifacts: Vec<RuntimeAnalysisArtifact>,
pub artifacts: Vec<RuntimeReviewDecisionArtifact>,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionDocument {
schema_version: String,
@@ -72,20 +75,20 @@ struct ReviewDecisionDocument {
exported_at: String,
source: ReviewDecisionSource,
review_context: ReviewDecisionContext,
decision: ReviewDecisionContent,
decision: RuntimeReviewDecisionContent,
decision_status_options: Vec<String>,
risk_level_options: Vec<String>,
review_checklist: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionSource {
derived_from: Vec<String>,
upstream_alignment: ReviewDecisionUpstreamAlignment,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionUpstreamAlignment {
execution_environment_reference: String,
@@ -93,7 +96,7 @@ struct ReviewDecisionUpstreamAlignment {
product_surface: String,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionContext {
session_id: String,
@@ -111,7 +114,7 @@ struct ReviewDecisionContext {
analysis_artifacts: Vec<ReviewDecisionArtifactReference>,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionArtifactReference {
kind: String,
@@ -119,25 +122,43 @@ struct ReviewDecisionArtifactReference {
relative_path: String,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionContent {
decision_status: String,
decision_summary: String,
chosen_fix_strategy: String,
risk_level: String,
risk_tags: Vec<String>,
human_reviewer: String,
reviewed_at: Option<String>,
followup_actions: Vec<String>,
regression_requirements: Vec<String>,
notes: String,
pub struct RuntimeReviewDecisionContent {
pub decision_status: String,
pub decision_summary: String,
pub chosen_fix_strategy: String,
pub risk_level: String,
pub risk_tags: Vec<String>,
pub human_reviewer: String,
pub reviewed_at: Option<String>,
pub followup_actions: Vec<String>,
pub regression_requirements: Vec<String>,
pub notes: String,
}
pub fn export_runtime_review_decision_template(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
sync_runtime_review_decision(detail, thread_read, workspace_root, None)
}
pub fn save_runtime_review_decision(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
decision: RuntimeReviewDecisionContent,
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
sync_runtime_review_decision(detail, thread_read, workspace_root, Some(decision))
}
fn sync_runtime_review_decision(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
decision_override: Option<RuntimeReviewDecisionContent>,
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
let session_id = detail.id.trim();
if session_id.is_empty() {
@@ -167,7 +188,13 @@ pub fn export_runtime_review_decision_template(
})?;
let review_checklist = build_review_checklist();
let document = build_review_decision_document(&analysis, &exported_at, &review_checklist);
let existing_decision = load_existing_review_decision_document(&review_absolute_root)?
.map(|document| document.decision);
let mut document = build_review_decision_document(&analysis, &exported_at, &review_checklist);
let decision = decision_override
.or(existing_decision)
.unwrap_or_else(|| document.decision.clone());
document.decision = normalize_review_decision_content(decision, &exported_at);
let markdown = build_review_decision_markdown(&document);
let json = serde_json::to_string_pretty(&document)
.map_err(|error| format!("序列化 review decision json 失败: {error}"))?;
@@ -208,6 +235,9 @@ pub fn export_runtime_review_decision_template(
pending_request_count: analysis.pending_request_count,
queued_turn_count: analysis.queued_turn_count,
default_decision_status: DEFAULT_DECISION_STATUS.to_string(),
decision: document.decision,
decision_status_options: document.decision_status_options,
risk_level_options: document.risk_level_options,
review_checklist,
analysis_artifacts: analysis.artifacts.clone(),
artifacts,
@@ -259,7 +289,7 @@ fn build_review_decision_document(
})
.collect(),
},
decision: ReviewDecisionContent {
decision: RuntimeReviewDecisionContent {
decision_status: DEFAULT_DECISION_STATUS.to_string(),
decision_summary: String::new(),
chosen_fix_strategy: String::new(),
@@ -271,19 +301,8 @@ fn build_review_decision_document(
regression_requirements: Vec::new(),
notes: String::new(),
},
decision_status_options: vec![
"accepted".to_string(),
"deferred".to_string(),
"rejected".to_string(),
"needs_more_evidence".to_string(),
DEFAULT_DECISION_STATUS.to_string(),
],
risk_level_options: vec![
"low".to_string(),
"medium".to_string(),
"high".to_string(),
DEFAULT_RISK_LEVEL.to_string(),
],
decision_status_options: build_decision_status_options(),
risk_level_options: build_risk_level_options(),
review_checklist: review_checklist.to_vec(),
}
}
@@ -302,6 +321,21 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
.map(|artifact| format!("- `{}`:`{}`", artifact.title, artifact.relative_path))
.collect::<Vec<_>>()
.join("\n");
let decision_status_options = document
.decision_status_options
.iter()
.map(|status| format!("`{status}`"))
.collect::<Vec<_>>()
.join(" / ");
let risk_tags = format_markdown_inline_list(&document.decision.risk_tags, "待填写");
let regression_requirements =
format_markdown_list(&document.decision.regression_requirements, "- 待填写");
let followup_actions = format_markdown_list(&document.decision.followup_actions, "- 待填写");
let decision_summary =
format_markdown_text_block(&document.decision.decision_summary, "待填写。");
let chosen_fix_strategy =
format_markdown_text_block(&document.decision.chosen_fix_strategy, "待填写。");
let notes = format_markdown_text_block(&document.decision.notes, "待填写。");
format!(
"# Lime 人工审核与决策记录\n\n\
@@ -330,22 +364,22 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
{checklist}\n\n\
## 4. 决策状态\n\
- 当前值:`{decision_status}`\n\
- 可选值:`accepted` / `deferred` / `rejected` / `needs_more_evidence`\n\n\
- 可选值:{decision_status_options}\n\n\
## 5. 决策摘要\n\
待填写。\n\n\
{decision_summary}\n\n\
## 6. 采用的修复策略\n\
待填写。\n\n\
{chosen_fix_strategy}\n\n\
## 7. 风险等级与标签\n\
- 风险等级:`{risk_level}`\n\
- 风险标签:待填写\n\n\
- 风险标签:{risk_tags}\n\n\
## 8. 回归要求\n\
- 待填写\n\n\
{regression_requirements}\n\n\
## 9. 后续动作\n\
- 待填写\n\n\
{followup_actions}\n\n\
## 10. 审核备注\n\
- 审核人:待填写\n\
- 审核时间:待填写\n\
- 备注:待填写\n",
- 审核人:{human_reviewer}\n\
- 审核时间:{reviewed_at}\n\
- 备注:\n{notes}\n",
decision_status = document.decision.decision_status,
exported_at = document.exported_at,
title = empty_fallback(&document.review_context.title, "未命名"),
@@ -374,7 +408,25 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
} else {
checklist
},
decision_status_options = if decision_status_options.is_empty() {
format!("`{DEFAULT_DECISION_STATUS}`")
} else {
decision_status_options
},
decision_summary = decision_summary,
chosen_fix_strategy = chosen_fix_strategy,
risk_level = document.decision.risk_level,
risk_tags = risk_tags,
regression_requirements = regression_requirements,
followup_actions = followup_actions,
human_reviewer = empty_fallback(&document.decision.human_reviewer, "待填写"),
reviewed_at = document
.decision
.reviewed_at
.as_deref()
.filter(|value: &&str| !value.trim().is_empty())
.unwrap_or("待填写"),
notes = notes,
)
}
@@ -425,6 +477,162 @@ fn write_review_decision_artifact(
})
}
fn load_existing_review_decision_document(
review_absolute_root: &Path,
) -> Result<Option<ReviewDecisionDocument>, String> {
let json_path = review_absolute_root.join(REVIEW_DECISION_JSON_FILE_NAME);
if !json_path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(&json_path).map_err(|error| {
format!(
"读取已有 review decision json 失败 {}: {error}",
json_path.display()
)
})?;
let document = serde_json::from_str::<ReviewDecisionDocument>(&contents).map_err(|error| {
format!(
"解析已有 review decision json 失败 {}: {error}",
json_path.display()
)
})?;
Ok(Some(document))
}
fn normalize_review_decision_content(
decision: RuntimeReviewDecisionContent,
exported_at: &str,
) -> RuntimeReviewDecisionContent {
let decision_status = normalize_review_decision_status(&decision.decision_status);
let decision_summary = normalize_string(&decision.decision_summary);
let chosen_fix_strategy = normalize_string(&decision.chosen_fix_strategy);
let risk_level = normalize_review_risk_level(&decision.risk_level);
let risk_tags = normalize_string_list(&decision.risk_tags);
let human_reviewer = normalize_string(&decision.human_reviewer);
let followup_actions = normalize_string_list(&decision.followup_actions);
let regression_requirements = normalize_string_list(&decision.regression_requirements);
let notes = normalize_string(&decision.notes);
let reviewed_at = normalize_optional_string(decision.reviewed_at.as_deref()).or_else(|| {
let has_review_content = decision_status != DEFAULT_DECISION_STATUS
|| !decision_summary.is_empty()
|| !chosen_fix_strategy.is_empty()
|| risk_level != DEFAULT_RISK_LEVEL
|| !risk_tags.is_empty()
|| !human_reviewer.is_empty()
|| !followup_actions.is_empty()
|| !regression_requirements.is_empty()
|| !notes.is_empty();
has_review_content.then(|| exported_at.to_string())
});
RuntimeReviewDecisionContent {
decision_status,
decision_summary,
chosen_fix_strategy,
risk_level,
risk_tags,
human_reviewer,
reviewed_at,
followup_actions,
regression_requirements,
notes,
}
}
fn build_decision_status_options() -> Vec<String> {
vec![
"accepted".to_string(),
"deferred".to_string(),
"rejected".to_string(),
"needs_more_evidence".to_string(),
DEFAULT_DECISION_STATUS.to_string(),
]
}
fn build_risk_level_options() -> Vec<String> {
vec![
"low".to_string(),
"medium".to_string(),
"high".to_string(),
DEFAULT_RISK_LEVEL.to_string(),
]
}
fn normalize_review_decision_status(value: &str) -> String {
match value.trim() {
"accepted" => "accepted".to_string(),
"deferred" => "deferred".to_string(),
"rejected" => "rejected".to_string(),
"needs_more_evidence" => "needs_more_evidence".to_string(),
"pending_review" => "pending_review".to_string(),
_ => DEFAULT_DECISION_STATUS.to_string(),
}
}
fn normalize_review_risk_level(value: &str) -> String {
match value.trim() {
"low" => "low".to_string(),
"medium" => "medium".to_string(),
"high" => "high".to_string(),
"unknown" => "unknown".to_string(),
_ => DEFAULT_RISK_LEVEL.to_string(),
}
}
fn normalize_string(value: &str) -> String {
value.trim().to_string()
}
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn normalize_string_list(values: &[String]) -> Vec<String> {
values
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.collect()
}
fn format_markdown_text_block(value: &str, placeholder: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
placeholder.to_string()
} else {
trimmed.to_string()
}
}
fn format_markdown_list(values: &[String], placeholder: &str) -> String {
if values.is_empty() {
placeholder.to_string()
} else {
values
.iter()
.map(|value| format!("- {value}"))
.collect::<Vec<_>>()
.join("\n")
}
}
fn format_markdown_inline_list(values: &[String], placeholder: &str) -> String {
if values.is_empty() {
placeholder.to_string()
} else {
values
.iter()
.map(|value| format!("`{value}`"))
.collect::<Vec<_>>()
.join(" / ")
}
}
fn empty_fallback<'a>(value: &'a str, fallback: &'a str) -> &'a str {
if value.trim().is_empty() {
fallback
@@ -622,4 +830,64 @@ mod tests {
assert!(json.contains("\"executionEnvironmentReference\": \"codex\""));
assert!(json.contains("\"runtimeFactSource\": \"aster-rust\""));
}
#[test]
fn should_save_runtime_review_decision_and_keep_it_on_reexport() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let thread_read = build_thread_read();
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
.expect("export");
let saved = save_runtime_review_decision(
&detail,
&thread_read,
temp_dir.path(),
RuntimeReviewDecisionContent {
decision_status: "accepted".to_string(),
decision_summary: "确认最小修复落在 current 主链。".to_string(),
chosen_fix_strategy: "先补 runtime save 命令,再补 Harness UI。".to_string(),
risk_level: "medium".to_string(),
risk_tags: vec!["runtime".to_string(), "harness".to_string()],
human_reviewer: "Lime Maintainer".to_string(),
reviewed_at: Some("2026-03-27T10:30:00Z".to_string()),
followup_actions: vec!["补 UI 回归".to_string()],
regression_requirements: vec![
"npm run test:contracts".to_string(),
"Rust 定向测试".to_string(),
],
notes: "不要把 compat 命令重新接回主线。".to_string(),
},
)
.expect("save");
assert_eq!(saved.decision.decision_status, "accepted");
assert_eq!(saved.decision.risk_level, "medium");
assert_eq!(saved.decision.risk_tags, vec!["runtime", "harness"]);
assert_eq!(saved.decision.human_reviewer, "Lime Maintainer");
let json_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.json");
let markdown_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.md");
let json = fs::read_to_string(&json_path).expect("json");
assert!(json.contains("\"decisionStatus\": \"accepted\""));
assert!(json.contains("\"humanReviewer\": \"Lime Maintainer\""));
assert!(json.contains("\"regressionRequirements\": ["));
let markdown = fs::read_to_string(&markdown_path).expect("markdown");
assert!(markdown.contains("确认最小修复落在 current 主链。"));
assert!(markdown.contains("补 UI 回归"));
assert!(markdown.contains("Lime Maintainer"));
let reexported =
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
.expect("re-export");
assert_eq!(reexported.decision.decision_status, "accepted");
assert_eq!(reexported.decision.human_reviewer, "Lime Maintainer");
}
}