新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端: - 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展 - 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通 - 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦 - AI planner/plan_hint/intent:aichat B 路线并行多轮基础 - patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环 - 压缩超时兜底(F-15 卡死根治) - F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本 - 知识注入 DRY/skills/audit 扩展 清理: - aichat 技术债(误报 allow/死导入/过时注释 30 项) - URGENT.md 删除(11 项加急全解决/迁 todo) - 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
589
crates/df-types/src/augmentation.rs
Normal file
589
crates/df-types/src/augmentation.rs
Normal file
@@ -0,0 +1,589 @@
|
||||
//! Input Augmentation 层领域类型
|
||||
//!
|
||||
//! 解决「@项目 / /技能 → AI 上下文」两个增强通道的结构性问题:
|
||||
//! - [`MentionRef`]:resolve 前,前端选中 mention 后透传的原始引用(携带 id)
|
||||
//! - [`Augmentation`]:resolve 后,注入 LLM 上下文的投影数据(已 join / 已脱敏)
|
||||
//! - [`SanitizedPath`]:path 脱敏 newtype,防止裸 String 误用未脱敏值
|
||||
//! - [`MentionSpanDto`]:chip 元数据,前端按 span 精确替换 mention token
|
||||
//! - [`ResolveError`]:Resolver 投影失败原因
|
||||
//!
|
||||
//! serde 统一 `tag = "kind"`,新增变体向后兼容(老前端遇未知 kind 忽略)。
|
||||
//! 设计详见 plan: mighty-wibbling-papert.md 核心设计 1。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::{IdeaStatus, ProjectId, ProjectStatus, TaskId, TaskStatus};
|
||||
|
||||
// ============================================================
|
||||
// 路径脱敏 newtype
|
||||
// ============================================================
|
||||
|
||||
/// 脱敏后的文件系统路径(已根据 provider locality 过滤)。
|
||||
///
|
||||
/// 用 newtype 包裹,强制调用方走 [`crate::augmentation`] 模块的脱敏入口,
|
||||
/// 防止裸 `String` 误用未脱敏的原始路径(含用户名/公司目录/客户名)直接注入 LLM。
|
||||
///
|
||||
/// 脱敏规则由 `src-tauri` 侧 `augmentation::sanitize` 模块实现:
|
||||
/// - Local provider(localhost/127.0.0.1/0.0.0.0/::1/ollama):保留全路径
|
||||
/// - Remote provider:仅保留 basename
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct SanitizedPath(String);
|
||||
|
||||
impl SanitizedPath {
|
||||
/// 构造一个已脱敏路径。仅在 sanitize 入口调用,业务代码不应直接构造。
|
||||
pub fn new(sanitized: impl Into<String>) -> Self {
|
||||
Self(sanitized.into())
|
||||
}
|
||||
|
||||
/// 以字符串切片访问路径内容。
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// 取回内部的 String 所有权。
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SanitizedPath> for String {
|
||||
fn from(p: SanitizedPath) -> Self {
|
||||
p.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SanitizedPath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MentionRef — resolve 前(前端透传)
|
||||
// ============================================================
|
||||
|
||||
/// 前端选中 mention 后透传的原始引用(resolve 前)。
|
||||
///
|
||||
/// 携带 id(以及展示用 name),供后端 Resolver 投影成 [`Augmentation`]。
|
||||
/// Skill 变体不带 id —— skill 以 name 为唯一键(无全局 id)。
|
||||
///
|
||||
/// serde `tag = "kind"`:序列化形如 `{"kind":"project","id":"...","name":"..."}`。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum MentionRef {
|
||||
/// @项目
|
||||
Project {
|
||||
#[serde(rename = "id")]
|
||||
id: ProjectId,
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
},
|
||||
/// @任务
|
||||
Task {
|
||||
#[serde(rename = "id")]
|
||||
id: TaskId,
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
},
|
||||
/// @灵感
|
||||
Idea {
|
||||
#[serde(rename = "id")]
|
||||
id: String,
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
},
|
||||
/// /技能(name 唯一键)
|
||||
Skill {
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl MentionRef {
|
||||
/// 返回 mention 的 kind 标签(与 serde tag 一致):`project`/`task`/`idea`/`skill`。
|
||||
pub fn kind_str(&self) -> &'static str {
|
||||
match self {
|
||||
MentionRef::Project { .. } => "project",
|
||||
MentionRef::Task { .. } => "task",
|
||||
MentionRef::Idea { .. } => "idea",
|
||||
MentionRef::Skill { .. } => "skill",
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回展示用 label(即 mention 选中时的可见文本)。
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
MentionRef::Project { name, .. }
|
||||
| MentionRef::Task { name, .. }
|
||||
| MentionRef::Idea { name, .. }
|
||||
| MentionRef::Skill { name } => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回 chip 渲染/查找用的稳定标识:有 id 用 id,Skill 用 name。
|
||||
pub fn ref_id(&self) -> &str {
|
||||
match self {
|
||||
MentionRef::Project { id, .. }
|
||||
| MentionRef::Task { id, .. }
|
||||
| MentionRef::Idea { id, .. } => id,
|
||||
MentionRef::Skill { name } => name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Augmentation — resolve 后(注入 LLM 用)
|
||||
// ============================================================
|
||||
|
||||
/// Resolver 投影后的增强上下文(resolve 后,注入用)。
|
||||
///
|
||||
/// 与 [`MentionRef`] 的区别:
|
||||
/// - 字段更丰富(join 后的 status / description / project_name 等)
|
||||
/// - `Project` 带 `path: Option<SanitizedPath>`(已脱敏)
|
||||
/// - `Skill` 带 `body`(已剥 frontmatter 的正文)
|
||||
///
|
||||
/// serde `tag = "kind"`,与 `MentionRef` 同构,前端可共用 chip 渲染逻辑。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Augmentation {
|
||||
/// @项目 注入体
|
||||
Project {
|
||||
#[serde(rename = "id")]
|
||||
id: ProjectId,
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
#[serde(rename = "status")]
|
||||
status: ProjectStatus,
|
||||
#[serde(rename = "description")]
|
||||
description: String,
|
||||
/// 脱敏后的项目路径(远程 provider 仅 basename / 本地全路径);无目录绑定时为 None。
|
||||
#[serde(rename = "path", default, skip_serializing_if = "Option::is_none")]
|
||||
path: Option<SanitizedPath>,
|
||||
},
|
||||
/// @任务 注入体
|
||||
Task {
|
||||
#[serde(rename = "id")]
|
||||
id: TaskId,
|
||||
#[serde(rename = "title")]
|
||||
title: String,
|
||||
#[serde(rename = "status")]
|
||||
status: TaskStatus,
|
||||
#[serde(rename = "description")]
|
||||
description: String,
|
||||
/// 所属项目名(join 展示用);游离任务为 None。
|
||||
#[serde(rename = "project_name", default, skip_serializing_if = "Option::is_none")]
|
||||
project_name: Option<String>,
|
||||
},
|
||||
/// @灵感 注入体
|
||||
Idea {
|
||||
#[serde(rename = "id")]
|
||||
id: String,
|
||||
#[serde(rename = "title")]
|
||||
title: String,
|
||||
#[serde(rename = "status")]
|
||||
status: IdeaStatus,
|
||||
#[serde(rename = "description")]
|
||||
description: String,
|
||||
},
|
||||
/// /技能 注入体
|
||||
Skill {
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
/// 技能来源(plugin/command/user 等),用于审计/展示。
|
||||
#[serde(rename = "source")]
|
||||
source: String,
|
||||
/// 已剥 frontmatter 的技能正文。
|
||||
#[serde(rename = "body")]
|
||||
body: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Augmentation {
|
||||
/// 返回 kind 标签(与 serde tag 一致)。
|
||||
pub fn kind_str(&self) -> &'static str {
|
||||
match self {
|
||||
Augmentation::Project { .. } => "project",
|
||||
Augmentation::Task { .. } => "task",
|
||||
Augmentation::Idea { .. } => "idea",
|
||||
Augmentation::Skill { .. } => "skill",
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回注入段落的小标题(供 build_augmentation_segment 拼接)。
|
||||
pub fn section_label(&self) -> &str {
|
||||
match self {
|
||||
Augmentation::Project { .. } => "项目",
|
||||
Augmentation::Task { .. } => "任务",
|
||||
Augmentation::Idea { .. } => "灵感",
|
||||
Augmentation::Skill { .. } => "技能",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MentionSpanDto — chip 元数据
|
||||
// ============================================================
|
||||
|
||||
/// 用户消息内 mention 区间的元数据,驱动 chip 精确替换。
|
||||
///
|
||||
/// 前端按 `{start, length}` 在原文中切出区间并替换为 chip;若区间被用户编辑破坏
|
||||
/// (长度/内容不再匹配),降级为纯文本展示,避免正则误匹配。
|
||||
///
|
||||
/// 注意:序列化字段名 `refId`(camelCase,对齐前端 TS 类型),Rust 侧用 `ref_id`。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MentionSpanDto {
|
||||
/// 区间起点(字节/字符偏移由前端约定,后端仅透传)。
|
||||
#[serde(rename = "start")]
|
||||
pub start: u32,
|
||||
/// 区间长度。
|
||||
#[serde(rename = "length")]
|
||||
pub length: u32,
|
||||
/// kind 标签:`project`/`task`/`idea`/`skill`。
|
||||
#[serde(rename = "kind")]
|
||||
pub kind: String,
|
||||
/// 引用稳定标识(Project/Task/Idea 为 id,Skill 为 name),前端用于反查。
|
||||
#[serde(rename = "refId")]
|
||||
pub ref_id: String,
|
||||
/// chip 展示文本(项目名/任务标题/技能名)。
|
||||
#[serde(rename = "label")]
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl MentionSpanDto {
|
||||
/// 构造一个 span。kind 取自 MentionRef::kind_str,ref_id 取自 MentionRef::ref_id。
|
||||
pub fn new(start: u32, length: u32, kind: impl Into<String>, ref_id: impl Into<String>, label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
start,
|
||||
length,
|
||||
kind: kind.into(),
|
||||
ref_id: ref_id.into(),
|
||||
label: label.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 区间终点(start + length)。
|
||||
pub fn end(&self) -> u32 {
|
||||
self.start.saturating_add(self.length)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ResolveError — Resolver 投影失败
|
||||
// ============================================================
|
||||
|
||||
/// Resolver 解析 [`MentionRef`] → [`Augmentation`] 的失败原因。
|
||||
///
|
||||
/// `Skip` 表示该 mention 无法 resolve 但不应中断整批注入(如 skill 已删除),
|
||||
/// 调用方应跳过该项继续处理其他 mention。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ResolveError {
|
||||
/// 引用对象不存在(项目/任务/灵感/技能已删除)。
|
||||
#[error("未找到引用: kind={kind}, ref_id={ref_id}")]
|
||||
NotFound {
|
||||
/// kind 标签。
|
||||
kind: String,
|
||||
/// 引用标识。
|
||||
ref_id: String,
|
||||
},
|
||||
|
||||
/// kind 与 Resolver 注册不匹配(如 Project id 传给了 TaskResolver)。
|
||||
#[error("kind 不匹配: expected={expected}, actual={actual}")]
|
||||
KindMismatch {
|
||||
/// 期望的 kind。
|
||||
expected: String,
|
||||
/// 实际的 kind。
|
||||
actual: String,
|
||||
},
|
||||
|
||||
/// 跳过该 mention(如 skill 已被卸载),不视为错误,不中断整批注入。
|
||||
#[error("跳过引用: kind={kind}, ref_id={ref_id}, reason={reason}")]
|
||||
Skip {
|
||||
/// kind 标签。
|
||||
kind: String,
|
||||
/// 引用标识。
|
||||
ref_id: String,
|
||||
/// 跳过原因(审计/日志用)。
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测:serde round-trip + 辅助方法
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{IdeaStatus, ProjectStatus, TaskStatus};
|
||||
|
||||
// ---------- SanitizedPath ----------
|
||||
|
||||
#[test]
|
||||
fn sanitized_path_transparent_serde() {
|
||||
// serde(transparent):JSON 直接是裸字符串,无 {inner} 包裹
|
||||
let p = SanitizedPath::new("E:/wk-lab/devflow");
|
||||
let json = serde_json::to_string(&p).expect("serialize");
|
||||
assert_eq!(json, "\"E:/wk-lab/devflow\"");
|
||||
let back: SanitizedPath = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_path_accessors() {
|
||||
let p = SanitizedPath::new("/tmp/x");
|
||||
assert_eq!(p.as_str(), "/tmp/x");
|
||||
assert_eq!(p.to_string(), "/tmp/x");
|
||||
assert_eq!(p.into_inner(), "/tmp/x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_path_from_to_string() {
|
||||
let p = SanitizedPath::new("a/b");
|
||||
let s: String = p.into();
|
||||
assert_eq!(s, "a/b");
|
||||
}
|
||||
|
||||
// ---------- MentionRef round-trip ----------
|
||||
|
||||
#[test]
|
||||
fn mention_ref_project_round_trip() {
|
||||
let m = MentionRef::Project {
|
||||
id: "p-1".to_string(),
|
||||
name: "devflow".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&m).expect("serialize");
|
||||
// tag=kind, snake_case, 字段名 id/name
|
||||
assert_eq!(
|
||||
json,
|
||||
"{\"kind\":\"project\",\"id\":\"p-1\",\"name\":\"devflow\"}"
|
||||
);
|
||||
let back: MentionRef = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_ref_skill_round_trip() {
|
||||
let m = MentionRef::Skill {
|
||||
name: "review".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&m).expect("serialize");
|
||||
assert_eq!(json, "{\"kind\":\"skill\",\"name\":\"review\"}");
|
||||
let back: MentionRef = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_ref_all_variants_round_trip() {
|
||||
let cases = vec![
|
||||
serde_json::to_value(MentionRef::Project {
|
||||
id: "p".into(),
|
||||
name: "pn".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::to_value(MentionRef::Task {
|
||||
id: "t".into(),
|
||||
name: "tn".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::to_value(MentionRef::Idea {
|
||||
id: "i".into(),
|
||||
name: "in".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::to_value(MentionRef::Skill { name: "s".into() }).unwrap(),
|
||||
];
|
||||
for v in cases {
|
||||
let back: MentionRef = serde_json::from_value(v.clone()).expect("deserialize");
|
||||
let re = serde_json::to_value(&back).expect("serialize");
|
||||
assert_eq!(re, v, "round-trip not stable");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_ref_helpers() {
|
||||
let p = MentionRef::Project {
|
||||
id: "p1".into(),
|
||||
name: "Devflow".into(),
|
||||
};
|
||||
assert_eq!(p.kind_str(), "project");
|
||||
assert_eq!(p.label(), "Devflow");
|
||||
assert_eq!(p.ref_id(), "p1");
|
||||
|
||||
let s = MentionRef::Skill { name: "review".into() };
|
||||
assert_eq!(s.kind_str(), "skill");
|
||||
assert_eq!(s.ref_id(), "review"); // skill ref_id = name
|
||||
assert_eq!(s.label(), "review");
|
||||
}
|
||||
|
||||
// ---------- Augmentation round-trip ----------
|
||||
|
||||
#[test]
|
||||
fn augmentation_project_with_path_round_trip() {
|
||||
let a = Augmentation::Project {
|
||||
id: "p-1".to_string(),
|
||||
name: "devflow".to_string(),
|
||||
status: ProjectStatus::InProgress,
|
||||
description: "AI-native dev tool".to_string(),
|
||||
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert!(json.contains("\"kind\":\"project\""));
|
||||
assert!(json.contains("\"status\":\"in_progress\""));
|
||||
assert!(json.contains("\"path\":\"E:/wk-lab/devflow\""));
|
||||
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augmentation_project_path_none_omitted() {
|
||||
let a = Augmentation::Project {
|
||||
id: "p-2".to_string(),
|
||||
name: "nopath".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
description: String::new(),
|
||||
path: None,
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
// skip_serializing_if 生效:path 字段不出现在 JSON 中
|
||||
// 检查 "path" key(带引号),避免误匹配 name 值里的 path 子串
|
||||
assert!(!json.contains("\"path\""), "path should be omitted, got: {}", json);
|
||||
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augmentation_task_with_project_name_round_trip() {
|
||||
let a = Augmentation::Task {
|
||||
id: "t-1".to_string(),
|
||||
title: "Implement X".to_string(),
|
||||
status: TaskStatus::Todo,
|
||||
description: "do X".to_string(),
|
||||
project_name: Some("devflow".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert!(json.contains("\"kind\":\"task\""));
|
||||
assert!(json.contains("\"status\":\"todo\""));
|
||||
assert!(json.contains("\"project_name\":\"devflow\""));
|
||||
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augmentation_idea_round_trip() {
|
||||
let a = Augmentation::Idea {
|
||||
id: "i-1".to_string(),
|
||||
title: "Multi-round parallel".to_string(),
|
||||
status: IdeaStatus::Approved,
|
||||
description: "desc".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert!(json.contains("\"kind\":\"idea\""));
|
||||
assert!(json.contains("\"status\":\"approved\""));
|
||||
// idea 无 project_name / path 字段
|
||||
assert!(!json.contains("project_name"));
|
||||
assert!(!json.contains("\"path\""));
|
||||
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augmentation_skill_round_trip() {
|
||||
let a = Augmentation::Skill {
|
||||
name: "review".to_string(),
|
||||
source: "command".to_string(),
|
||||
body: "## Review\nDo X".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert_eq!(
|
||||
json,
|
||||
"{\"kind\":\"skill\",\"name\":\"review\",\"source\":\"command\",\"body\":\"## Review\\nDo X\"}"
|
||||
);
|
||||
let back: Augmentation = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augmentation_helpers() {
|
||||
let p = Augmentation::Project {
|
||||
id: "p".into(),
|
||||
name: "n".into(),
|
||||
status: ProjectStatus::Planning,
|
||||
description: String::new(),
|
||||
path: None,
|
||||
};
|
||||
assert_eq!(p.kind_str(), "project");
|
||||
assert_eq!(p.section_label(), "项目");
|
||||
|
||||
let s = Augmentation::Skill {
|
||||
name: "x".into(),
|
||||
source: "y".into(),
|
||||
body: String::new(),
|
||||
};
|
||||
assert_eq!(s.kind_str(), "skill");
|
||||
assert_eq!(s.section_label(), "技能");
|
||||
}
|
||||
|
||||
// ---------- MentionSpanDto round-trip ----------
|
||||
|
||||
#[test]
|
||||
fn mention_span_dto_round_trip() {
|
||||
let span = MentionSpanDto::new(10, 8, "project", "p-1", "devflow");
|
||||
let json = serde_json::to_string(&span).expect("serialize");
|
||||
// refId camelCase
|
||||
assert!(json.contains("\"refId\":\"p-1\""));
|
||||
assert!(json.contains("\"kind\":\"project\""));
|
||||
assert!(json.contains("\"start\":10"));
|
||||
assert!(json.contains("\"length\":8"));
|
||||
assert!(json.contains("\"label\":\"devflow\""));
|
||||
let back: MentionSpanDto = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, span);
|
||||
assert_eq!(span.end(), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_span_dto_end_saturates() {
|
||||
let span = MentionSpanDto::new(u32::MAX, 5, "task", "t", "lbl");
|
||||
// 防溢出:saturating_add 在 u32::MAX + 5 时停在上界
|
||||
assert_eq!(span.end(), u32::MAX);
|
||||
}
|
||||
|
||||
// ---------- ResolveError ----------
|
||||
|
||||
#[test]
|
||||
fn resolve_error_display() {
|
||||
let e = ResolveError::NotFound {
|
||||
kind: "project".into(),
|
||||
ref_id: "p-x".into(),
|
||||
};
|
||||
assert!(format!("{}", e).contains("p-x"));
|
||||
assert!(format!("{}", e).contains("project"));
|
||||
|
||||
let e2 = ResolveError::KindMismatch {
|
||||
expected: "project".into(),
|
||||
actual: "task".into(),
|
||||
};
|
||||
let s = format!("{}", e2);
|
||||
assert!(s.contains("project") && s.contains("task"));
|
||||
|
||||
let e3 = ResolveError::Skip {
|
||||
kind: "skill".into(),
|
||||
ref_id: "removed".into(),
|
||||
reason: "skill uninstalled".into(),
|
||||
};
|
||||
assert!(format!("{}", e3).contains("skill uninstalled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_error_eq() {
|
||||
let a = ResolveError::NotFound {
|
||||
kind: "task".into(),
|
||||
ref_id: "t".into(),
|
||||
};
|
||||
let b = ResolveError::NotFound {
|
||||
kind: "task".into(),
|
||||
ref_id: "t".into(),
|
||||
};
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
//! df-types: 核心类型定义,所有 crate 的基础依赖
|
||||
|
||||
pub mod augmentation;
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod types;
|
||||
|
||||
// 顶层 re-export:常用时间工具(跨 crate 调用方用 df_types::now_millis,避免 types:: 前缀)
|
||||
pub use types::now_millis;
|
||||
|
||||
// Input Augmentation 层领域类型透传(跨 crate 调用方用 df_types::Augmentation 等,
|
||||
// 避免 augmentation:: 前缀)。详见 augmentation 模块文档。
|
||||
pub use augmentation::{Augmentation, MentionRef, MentionSpanDto, ResolveError, SanitizedPath};
|
||||
|
||||
@@ -76,6 +76,22 @@ impl IdeaStatus {
|
||||
IdeaStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从数据库存储的小写字符串解析为 IdeaStatus(与 `as_str` 对偶)。
|
||||
///
|
||||
/// Input Augmentation 层 ProjectResolver/IdeaResolver 把记录的 String status 投影成
|
||||
/// 类型化枚举用。未知字符串返 None(调用方按 Draft 兜底或视作数据异常跳过)。
|
||||
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"draft" => IdeaStatus::Draft,
|
||||
"pending_review" => IdeaStatus::PendingReview,
|
||||
"approved" => IdeaStatus::Approved,
|
||||
"rejected" => IdeaStatus::Rejected,
|
||||
"promoted" => IdeaStatus::Promoted,
|
||||
"archived" => IdeaStatus::Archived,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IdeaStatus {
|
||||
@@ -117,6 +133,23 @@ impl ProjectStatus {
|
||||
ProjectStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从数据库存储的小写字符串解析为 ProjectStatus(与 `as_str` 对偶)。
|
||||
///
|
||||
/// Input Augmentation 层 ProjectResolver/TaskResolver 把记录的 String status 投影成
|
||||
/// 类型化枚举用。未知字符串返 None。
|
||||
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"planning" => ProjectStatus::Planning,
|
||||
"in_progress" => ProjectStatus::InProgress,
|
||||
"testing" => ProjectStatus::Testing,
|
||||
"releasing" => ProjectStatus::Releasing,
|
||||
"completed" => ProjectStatus::Completed,
|
||||
"paused" => ProjectStatus::Paused,
|
||||
"cancelled" => ProjectStatus::Cancelled,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProjectStatus {
|
||||
@@ -181,6 +214,23 @@ impl TaskStatus {
|
||||
"cancelled",
|
||||
]
|
||||
}
|
||||
|
||||
/// 从数据库存储的小写字符串解析为 TaskStatus(与 `as_str` 对偶)。
|
||||
///
|
||||
/// Input Augmentation 层 TaskResolver 把记录的 String status 投影成类型化枚举用。
|
||||
/// 未知字符串返 None。
|
||||
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"todo" => TaskStatus::Todo,
|
||||
"in_progress" => TaskStatus::InProgress,
|
||||
"in_review" => TaskStatus::InReview,
|
||||
"testing" => TaskStatus::Testing,
|
||||
"done" => TaskStatus::Done,
|
||||
"blocked" => TaskStatus::Blocked,
|
||||
"cancelled" => TaskStatus::Cancelled,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskStatus {
|
||||
|
||||
Reference in New Issue
Block a user