|
|
|
@@ -0,0 +1,420 @@
|
|
|
|
|
//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 → system prompt 稳定尾段)。
|
|
|
|
|
//!
|
|
|
|
|
//! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读——
|
|
|
|
|
//! - `devflow.md`:agent 生态自己的项目认知文件,v1 取两个分区(规范纪律 / 领单);
|
|
|
|
|
//! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。
|
|
|
|
|
//! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。
|
|
|
|
|
//!
|
|
|
|
|
//! 缓存红线:投影段纯追加在 system prompt 全部既有段之后,内容按会话缓存——
|
|
|
|
|
//! 同一会话(含其派生的子代理)只在首次组装时读一次盘,此后复用同一文本,
|
|
|
|
|
//! 保证段内容会话内逐字节不变,不击穿 provider 的 prompt 缓存前缀。
|
|
|
|
|
//! 会话粒度取 conv_id 作缓存键:新会话重新读盘(拿到文件最新内容),旧会话不受扰动。
|
|
|
|
|
//!
|
|
|
|
|
//! 海纳川流:两文件缺失 / 读取失败 / 内容为空 → 投影段为空,prompt 与不接本模块
|
|
|
|
|
//! 逐字一致(规则文件是增强输入,缺失不影响内生运作)。
|
|
|
|
|
//!
|
|
|
|
|
//! 裁剪纪律:注入的 token 才是成本——按分区截取 + 字符上限,禁全量灌入。
|
|
|
|
|
//! 超限按 Unicode 字符截断(中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。
|
|
|
|
|
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::sync::{Mutex, OnceLock};
|
|
|
|
|
|
|
|
|
|
use crate::state::AllowedDirs;
|
|
|
|
|
|
|
|
|
|
// ── 裁剪上限(字符数,中文一字一符) ──
|
|
|
|
|
//
|
|
|
|
|
// 分区上限兜住单分区体量,总上限兜住整段体量;取值按"规范纪律最厚、领单次之、
|
|
|
|
|
// CLAUDE.md 与纪律区同量级"的内容形态分配,叠加后总上限仍可能触发二次截断。
|
|
|
|
|
/// 规范纪律分区上限
|
|
|
|
|
const DISCIPLINE_MAX_CHARS: usize = 4000;
|
|
|
|
|
/// 领单分区上限
|
|
|
|
|
const CLAIM_MAX_CHARS: usize = 2000;
|
|
|
|
|
/// CLAUDE.md 并入段上限
|
|
|
|
|
const CLAUDE_MD_MAX_CHARS: usize = 4000;
|
|
|
|
|
/// 投影段总上限(各分区叠加后的二次硬闸)
|
|
|
|
|
const SECTION_MAX_CHARS: usize = 6000;
|
|
|
|
|
|
|
|
|
|
/// 投影段缓存上限(条目)。会话粒度缓存,长跑进程防内存膨胀;超限整体清空
|
|
|
|
|
/// (清空后下次组装重读盘,内容只会更新不会错,正确性不受损)。
|
|
|
|
|
const SECTION_CACHE_MAX: usize = 64;
|
|
|
|
|
|
|
|
|
|
/// devflow.md 规范分区识别的标题关键字(领单优先判,防"领单纪律"被纪律关键字截胡)
|
|
|
|
|
const CLAIM_HEADING_KEY: &str = "领单";
|
|
|
|
|
const DISCIPLINE_HEADING_KEYS: [&str; 2] = ["纪律", "规范"];
|
|
|
|
|
|
|
|
|
|
static SECTION_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
|
|
|
|
|
|
|
|
|
fn section_cache() -> &'static Mutex<HashMap<String, String>> {
|
|
|
|
|
SECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 工作目录解析:首个持久授权目录(排序取首稳定)。
|
|
|
|
|
///
|
|
|
|
|
/// 与主链被监视工作区取同一约定,保证主会话与子代理对"项目根"的认知一致;
|
|
|
|
|
/// HashSet 迭代序不定,必须排序后取首才是稳定值。
|
|
|
|
|
pub(crate) fn work_dir_of(allowed: &AllowedDirs) -> Option<String> {
|
|
|
|
|
let mut dirs: Vec<PathBuf> = allowed.persistent.iter().cloned().collect();
|
|
|
|
|
dirs.sort();
|
|
|
|
|
dirs.first().map(|p| p.to_string_lossy().to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 把投影段纯追加到 system prompt 尾部(两条组装路径的统一入口)。
|
|
|
|
|
///
|
|
|
|
|
/// 无投影内容时原样返回(调用方 prompt 逐字不变);有则 `\n\n` 接续,
|
|
|
|
|
/// 调用方既有段内容不受任何影响(稳定尾段语义)。
|
|
|
|
|
pub(crate) async fn append_projection(prompt: String, work_dir: Option<&str>, conv_id: &str) -> String {
|
|
|
|
|
match projection_section(work_dir, conv_id).await {
|
|
|
|
|
Some(seg) => format!("{}\n\n{}", prompt, seg),
|
|
|
|
|
None => prompt,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 取会话的投影段:缓存命中直接复用(同会话零重复读盘);未命中读盘一次并入缓存。
|
|
|
|
|
///
|
|
|
|
|
/// 缓存值空串 = 本会话已知无投影(文件缺失),同样走缓存避免每轮白读盘。
|
|
|
|
|
/// 子代理以父会话 conv_id 作键,天然复用父会话已读内容。
|
|
|
|
|
async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option<String> {
|
|
|
|
|
{
|
|
|
|
|
let cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
|
|
|
|
|
if let Some(hit) = cache.get(conv_id) {
|
|
|
|
|
return (!hit.is_empty()).then(|| hit.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let dir = work_dir.map(PathBuf::from);
|
|
|
|
|
// 文件读放阻塞线程池(约定与 system prompt 指纹查询一致),失败按缺失处理
|
|
|
|
|
let (devflow, claude) = tokio::task::spawn_blocking(move || read_pair(dir))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap_or_else(|_| (None, None));
|
|
|
|
|
let (discipline, claiming) = devflow
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(extract_partitions)
|
|
|
|
|
.unwrap_or((String::new(), String::new()));
|
|
|
|
|
let section = build_section(&discipline, &claiming, claude.as_deref());
|
|
|
|
|
let mut cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
|
|
|
|
|
if cache.len() >= SECTION_CACHE_MAX {
|
|
|
|
|
cache.clear();
|
|
|
|
|
}
|
|
|
|
|
cache.insert(conv_id.to_string(), section.clone().unwrap_or_default());
|
|
|
|
|
section
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 读工作目录下的两份规则文件,内容 trim 后返回;目录缺失 / 读失败 / 空白内容 → None。
|
|
|
|
|
fn read_pair(dir: Option<PathBuf>) -> (Option<String>, Option<String>) {
|
|
|
|
|
let Some(dir) = dir else {
|
|
|
|
|
return (None, None);
|
|
|
|
|
};
|
|
|
|
|
(read_trimmed(&dir.join("devflow.md")), read_trimmed(&dir.join("CLAUDE.md")))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_trimmed(path: &Path) -> Option<String> {
|
|
|
|
|
std::fs::read_to_string(path)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|s| s.trim().to_string())
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 拼装投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。
|
|
|
|
|
/// 三路输入全空 → None(调用方零追加)。
|
|
|
|
|
fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Option<String> {
|
|
|
|
|
let claude = claude.map(str::trim).filter(|c| !c.is_empty());
|
|
|
|
|
if discipline.is_empty() && claiming.is_empty() && claude.is_none() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let has_devflow = !discipline.is_empty() || !claiming.is_empty();
|
|
|
|
|
let mut sec = String::from("## 项目认知投影\n以下规则来自项目仓库文件(会话启动时读取)");
|
|
|
|
|
// 优先级只在双文件并存时注明,单文件无冲突可注明
|
|
|
|
|
if has_devflow && claude.is_some() {
|
|
|
|
|
sec.push_str(",devflow.md 与 CLAUDE.md 并存,冲突以 devflow.md 为准");
|
|
|
|
|
}
|
|
|
|
|
sec.push(':');
|
|
|
|
|
|
|
|
|
|
if !discipline.is_empty() {
|
|
|
|
|
sec.push_str("\n\n### 规范纪律(devflow.md)\n");
|
|
|
|
|
sec.push_str(&cap_chars(discipline, DISCIPLINE_MAX_CHARS));
|
|
|
|
|
}
|
|
|
|
|
if !claiming.is_empty() {
|
|
|
|
|
sec.push_str("\n\n### 领单纪律(devflow.md)\n");
|
|
|
|
|
sec.push_str(&cap_chars(claiming, CLAIM_MAX_CHARS));
|
|
|
|
|
}
|
|
|
|
|
if let Some(cla) = claude {
|
|
|
|
|
sec.push_str("\n\n### 项目规范(CLAUDE.md)\n");
|
|
|
|
|
sec.push_str(&cap_chars(cla, CLAUDE_MD_MAX_CHARS));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 总上限二次硬闸:各分区上限叠加后仍可能超,整段再截一次
|
|
|
|
|
if sec.chars().count() > SECTION_MAX_CHARS {
|
|
|
|
|
let cut: String = sec.chars().take(SECTION_MAX_CHARS).collect();
|
|
|
|
|
return Some(format!("{}\n…(投影段超出总上限已截断)", cut));
|
|
|
|
|
}
|
|
|
|
|
Some(sec)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 按字符数截断并注明(纯函数):超 `max` 截断追加标注,否则原样返回。
|
|
|
|
|
fn cap_chars(s: &str, max: usize) -> String {
|
|
|
|
|
if s.chars().count() <= max {
|
|
|
|
|
return s.to_string();
|
|
|
|
|
}
|
|
|
|
|
let cut: String = s.chars().take(max).collect();
|
|
|
|
|
format!("{}\n…(超上限已截断)", cut)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── devflow.md 分区解析 ──
|
|
|
|
|
//
|
|
|
|
|
// v1 取两个分区:规范纪律 / 领单。识别方式 = markdown 标题关键字命中,命中块的
|
|
|
|
|
// 更深层子标题视为块内子结构一并保留;未命中关键标题的块不注入(状态/警示类
|
|
|
|
|
// 分区不属 v1 范围,天然被裁掉)。全文无命中分区的扁平写法回退为整文件按
|
|
|
|
|
// 规范纪律处理——规则文件形态与 CLAUDE.md 同构,不因没写小节标题而失传。
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
|
|
|
enum Partition {
|
|
|
|
|
Discipline,
|
|
|
|
|
Claim,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 识别 markdown 标题行,返回 (级别, 标题文本);非标题 / 纯装饰线 → None。
|
|
|
|
|
fn heading_of(line: &str) -> Option<(usize, &str)> {
|
|
|
|
|
let t = line.trim_start();
|
|
|
|
|
let level = t.len() - t.trim_start_matches('#').len();
|
|
|
|
|
if level == 0 || level > 6 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let text = t[level..].trim();
|
|
|
|
|
if text.is_empty() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some((level, text))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn classify_heading(text: &str) -> Option<Partition> {
|
|
|
|
|
if text.contains(CLAIM_HEADING_KEY) {
|
|
|
|
|
return Some(Partition::Claim);
|
|
|
|
|
}
|
|
|
|
|
if DISCIPLINE_HEADING_KEYS.iter().any(|k| text.contains(k)) {
|
|
|
|
|
return Some(Partition::Discipline);
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 从 devflow.md 全文提取 (规范纪律, 领单) 两个分区正文(纯函数,未截断)。
|
|
|
|
|
fn extract_partitions(content: &str) -> (String, String) {
|
|
|
|
|
let mut discipline: Vec<String> = Vec::new();
|
|
|
|
|
let mut claiming: Vec<String> = Vec::new();
|
|
|
|
|
// 当前块:(分区, 起始标题级别, 正文);正文不含起始标题行(投影段会重加小标题)
|
|
|
|
|
let mut cur: Option<(Partition, usize, String)> = None;
|
|
|
|
|
|
|
|
|
|
for line in content.lines() {
|
|
|
|
|
let heading = heading_of(line);
|
|
|
|
|
// 块边界:块内遇到不深于起始层级的标题 → 收块,由新标题决定是否开下一块
|
|
|
|
|
let boundary = matches!((&cur, &heading), (Some((_, lv, _)), Some((h_lv, _))) if h_lv <= lv);
|
|
|
|
|
if boundary {
|
|
|
|
|
let (kind, _, body) = cur.take().expect("boundary 判定保证 cur 存在");
|
|
|
|
|
push_block(kind, body, &mut discipline, &mut claiming);
|
|
|
|
|
} else if let Some((_, _, body)) = cur.as_mut() {
|
|
|
|
|
// 更深层级标题属块内子结构,连行保留
|
|
|
|
|
body.push_str(line);
|
|
|
|
|
body.push('\n');
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let Some((lv, text)) = heading {
|
|
|
|
|
if let Some(kind) = classify_heading(text) {
|
|
|
|
|
cur = Some((kind, lv, String::new()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some((kind, _, body)) = cur.take() {
|
|
|
|
|
push_block(kind, body, &mut discipline, &mut claiming);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let dev = discipline.join("\n").trim().to_string();
|
|
|
|
|
let claim = claiming.join("\n").trim().to_string();
|
|
|
|
|
if dev.is_empty() && claim.is_empty() {
|
|
|
|
|
return (content.trim().to_string(), String::new());
|
|
|
|
|
}
|
|
|
|
|
(dev, claim)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn push_block(kind: Partition, body: String, discipline: &mut Vec<String>, claiming: &mut Vec<String>) {
|
|
|
|
|
let trimmed = body.trim();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
match kind {
|
|
|
|
|
Partition::Discipline => discipline.push(trimmed.to_string()),
|
|
|
|
|
Partition::Claim => claiming.push(trimmed.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
|
|
|
|
|
|
/// 测试临时目录计数器:进程内唯一,防并发测试互踩 + 防缓存键串扰
|
|
|
|
|
static TMP_SEQ: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
|
|
|
|
|
|
fn unique_tag() -> String {
|
|
|
|
|
format!("{}-{}", std::process::id(), TMP_SEQ.fetch_add(1, Ordering::SeqCst))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn temp_project_dir() -> PathBuf {
|
|
|
|
|
let dir = std::env::temp_dir().join(format!("devflow-md-test-{}", unique_tag()));
|
|
|
|
|
std::fs::create_dir_all(&dir).expect("建临时目录");
|
|
|
|
|
dir
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_file(dir: &Path, name: &str, content: &str) {
|
|
|
|
|
std::fs::write(dir.join(name), content).expect("写测试文件");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 零回归:无两文件 → prompt 逐字不变 ──
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn missing_files_leave_prompt_unchanged() {
|
|
|
|
|
let base = "BASE PROMPT 段落".to_string();
|
|
|
|
|
let dir = temp_project_dir();
|
|
|
|
|
let out = append_projection(base.clone(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
|
|
|
|
|
assert_eq!(out, base, "两文件缺失时 prompt 必须逐字不变");
|
|
|
|
|
let out_no_dir = append_projection(base.clone(), None, &format!("conv-{}", unique_tag())).await;
|
|
|
|
|
assert_eq!(out_no_dir, base, "无授权目录时 prompt 必须逐字不变");
|
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 只有 CLAUDE.md → 并入生效 ──
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn claude_only_merged() {
|
|
|
|
|
let dir = temp_project_dir();
|
|
|
|
|
write_file(&dir, "CLAUDE.md", "# 项目约定\n- 提交信息用中文");
|
|
|
|
|
let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
|
|
|
|
|
assert!(out.starts_with("BASE"), "既有段必须是前缀");
|
|
|
|
|
assert!(out.contains("提交信息用中文"), "CLAUDE.md 正文须并入");
|
|
|
|
|
assert!(out.contains("### 项目规范(CLAUDE.md)"), "并入段须带 CLAUDE.md 标注");
|
|
|
|
|
assert!(!out.contains("devflow.md"), "仅 CLAUDE.md 时不得出现 devflow.md 标注");
|
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 双文件并存 → 优先级正确(devflow.md 在前 + 冲突注明,双内容齐备) ──
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn both_files_devflow_priority() {
|
|
|
|
|
let dir = temp_project_dir();
|
|
|
|
|
write_file(&dir, "devflow.md", "## 规范纪律\n- 禁 sed 改代码\n\n## 领单纪律\n- 领单后改 [→]");
|
|
|
|
|
write_file(&dir, "CLAUDE.md", "# 旧约定\n- 旧规则条目");
|
|
|
|
|
let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
|
|
|
|
|
let dev_pos = out.find("禁 sed 改代码").expect("devflow.md 纪律正文须注入");
|
|
|
|
|
let claim_pos = out.find("领单后改").expect("领单分区须注入");
|
|
|
|
|
let claude_pos = out.find("旧规则条目").expect("CLAUDE.md 正文须并入");
|
|
|
|
|
assert!(dev_pos < claude_pos && claim_pos < claude_pos, "devflow.md 内容必须排在 CLAUDE.md 之前(优先级)");
|
|
|
|
|
assert!(out.contains("冲突以 devflow.md 为准"), "双文件并存须注明优先级");
|
|
|
|
|
assert!(out.contains("### 规范纪律(devflow.md)"));
|
|
|
|
|
assert!(out.contains("### 领单纪律(devflow.md)"));
|
|
|
|
|
assert!(out.contains("### 项目规范(CLAUDE.md)"));
|
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 超长裁剪:分区上限 + 总上限生效并注明 ──
|
|
|
|
|
#[test]
|
|
|
|
|
fn caps_truncate_with_marker() {
|
|
|
|
|
let long = "纪".repeat(DISCIPLINE_MAX_CHARS + 100);
|
|
|
|
|
let section = build_section(&long, "", None).expect("非空输入必有段");
|
|
|
|
|
let discipline_part = section.split("### 规范纪律(devflow.md)\n").nth(1).unwrap_or("");
|
|
|
|
|
assert!(discipline_part.contains("超上限已截断"), "分区超限须注明");
|
|
|
|
|
let kept = discipline_part.split("\n…(超上限已截断)").next().unwrap();
|
|
|
|
|
assert_eq!(kept.chars().count(), DISCIPLINE_MAX_CHARS, "分区保留长度须恰为上限");
|
|
|
|
|
|
|
|
|
|
// 总上限:各分区压线 + CLAUDE.md 超量,叠加必超总闸
|
|
|
|
|
let discipline = "a".repeat(DISCIPLINE_MAX_CHARS);
|
|
|
|
|
let claiming = "b".repeat(CLAIM_MAX_CHARS);
|
|
|
|
|
let claude = "c".repeat(CLAUDE_MD_MAX_CHARS);
|
|
|
|
|
let section = build_section(&discipline, &claiming, Some(&claude)).expect("非空输入必有段");
|
|
|
|
|
assert!(section.chars().count() <= SECTION_MAX_CHARS + "…(投影段超出总上限已截断)".len());
|
|
|
|
|
assert!(section.ends_with("…(投影段超出总上限已截断)"), "总超限须整段截断并注明");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 稳定尾段:前段逐字保留,投影段独占尾部 ──
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn stable_tail_append() {
|
|
|
|
|
let dir = temp_project_dir();
|
|
|
|
|
write_file(&dir, "CLAUDE.md", "- 规则条目");
|
|
|
|
|
let base = "## 行为准则\n- 既有段内容\n";
|
|
|
|
|
let out = append_projection(base.to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
|
|
|
|
|
assert!(out.starts_with(base), "既有段必须逐字保留为前缀");
|
|
|
|
|
let tail = out.strip_prefix(base).unwrap();
|
|
|
|
|
assert!(tail.starts_with("\n\n## 项目认知投影"), "投影段须紧跟既有段之后成稳定尾段");
|
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 会话缓存:同会话只读一次盘,文件中途改写不影响本会话;新会话读到新内容 ──
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn cached_per_conversation() {
|
|
|
|
|
let dir = temp_project_dir();
|
|
|
|
|
let tag = unique_tag();
|
|
|
|
|
write_file(&dir, "CLAUDE.md", "- 第一版规则");
|
|
|
|
|
let conv = format!("conv-{}", tag);
|
|
|
|
|
let first = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &conv).await;
|
|
|
|
|
write_file(&dir, "CLAUDE.md", "- 第二版规则");
|
|
|
|
|
let again = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &conv).await;
|
|
|
|
|
assert_eq!(first, again, "同会话第二次组装必须复用缓存(文件改写不影响)");
|
|
|
|
|
assert!(first.contains("第一版规则"));
|
|
|
|
|
let fresh = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}-new", tag)).await;
|
|
|
|
|
assert!(fresh.contains("第二版规则"), "新会话须重读盘拿到最新内容");
|
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 分区解析:关键字命中 / 子标题保留 / 未命中块裁掉 / 扁平回退 ──
|
|
|
|
|
#[test]
|
|
|
|
|
fn partition_extraction() {
|
|
|
|
|
let content = "\
|
|
|
|
|
# 项目认知
|
|
|
|
|
|
|
|
|
|
前言段落应被裁掉。
|
|
|
|
|
|
|
|
|
|
## 规范纪律
|
|
|
|
|
- 纪律条目一
|
|
|
|
|
|
|
|
|
|
### 提交规范
|
|
|
|
|
- 子标题下的纪律条目
|
|
|
|
|
|
|
|
|
|
## 看板摘要
|
|
|
|
|
- 未命中关键字的块应被裁掉
|
|
|
|
|
|
|
|
|
|
## 领单纪律
|
|
|
|
|
- 领单条目一
|
|
|
|
|
";
|
|
|
|
|
let (dev, claim) = extract_partitions(content);
|
|
|
|
|
assert!(dev.contains("纪律条目一"), "规范纪律块须命中");
|
|
|
|
|
assert!(dev.contains("子标题下的纪律条目"), "块内子标题结构须保留");
|
|
|
|
|
assert!(!dev.contains("看板摘要") && !dev.contains("前言段落"), "未命中块与前言须裁掉");
|
|
|
|
|
assert_eq!(claim, "- 领单条目一", "领单分区须独立提取");
|
|
|
|
|
|
|
|
|
|
// 扁平文件(无命中标题)回退:整文件按规范纪律
|
|
|
|
|
let (dev, claim) = extract_partitions("- 扁平规则一\n- 扁平规则二\n");
|
|
|
|
|
assert_eq!(dev, "- 扁平规则一\n- 扁平规则二");
|
|
|
|
|
assert!(claim.is_empty());
|
|
|
|
|
|
|
|
|
|
// 深层标题起始的块:同级才截断
|
|
|
|
|
let (dev, claim) = extract_partitions("### 领单\n- 深层领单条目\n\n## 其他\n- 其他块内容\n");
|
|
|
|
|
assert_eq!(claim, "- 深层领单条目", "深层起始块到同级标题截断");
|
|
|
|
|
assert!(!dev.contains("其他块内容"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 工作目录:排序取首稳定(HashSet 迭代序不定,不排序结果不可复现) ──
|
|
|
|
|
#[test]
|
|
|
|
|
fn work_dir_sorted_first() {
|
|
|
|
|
let mut allowed = AllowedDirs::default();
|
|
|
|
|
allowed.persistent.insert(PathBuf::from("E:/wk-b/b-project"));
|
|
|
|
|
allowed.persistent.insert(PathBuf::from("E:/wk-a/a-project"));
|
|
|
|
|
assert_eq!(work_dir_of(&allowed).as_deref(), Some("E:/wk-a/a-project"));
|
|
|
|
|
let empty = AllowedDirs::default();
|
|
|
|
|
assert_eq!(work_dir_of(&empty), None, "无授权目录返 None");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 构建纯函数:全空 None / 单区缺省不产生空小节 ──
|
|
|
|
|
#[test]
|
|
|
|
|
fn build_section_edge_cases() {
|
|
|
|
|
assert!(build_section("", "", None).is_none(), "三路全空须 None");
|
|
|
|
|
assert!(build_section("", "", Some(" ")).is_none(), "空白 CLAUDE.md 视同缺失");
|
|
|
|
|
let only_claim = build_section("", "- 领单规则", None).expect("单分区非空须有段");
|
|
|
|
|
assert!(only_claim.contains("### 领单纪律(devflow.md)"));
|
|
|
|
|
assert!(!only_claim.contains("### 规范纪律"), "空分区不得产生空小节");
|
|
|
|
|
assert!(!only_claim.contains("冲突以"), "单文件不得注明双文件优先级");
|
|
|
|
|
}
|
|
|
|
|
}
|