diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index a43973a3..4c6cb748 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -592,7 +592,27 @@ pub struct ConfigPatch { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "op", rename_all = "snake_case")] pub enum PatchOp { - Replace { path: String, value: PatchValue }, + Replace { + path: String, + value: PatchValue, + }, + /// 往块式列表末尾加一项。 + /// + /// `item` 是这一项的 YAML 片段,不带前导的 `- `。**结构性的编辑 + /// 只有这一条路** —— 没有它,界面只能改已经存在的标量,而新建 + /// 任何东西(一把密钥、一条规则、一个代理)都不是替换。 + Append { + path: String, + item: String, + }, + /// 删掉列表里的一项。 + /// + /// `path` 指向**那一项**,按名字:`/clients/codex`。和 `Replace` + /// 同一条纪律 —— 下标会在用户重排之后指向另一个东西,而那种错误 + /// 完全静默。 + Remove { + path: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/tw-control/src/config.rs b/crates/tw-control/src/config.rs index c756cf9a..a0fffde9 100644 --- a/crates/tw-control/src/config.rs +++ b/crates/tw-control/src/config.rs @@ -321,20 +321,95 @@ impl ConfigManager { } let mut text = cur.text.clone(); for op in ops { - let tw_api::PatchOp::Replace { path, value } = op; - let steps = resolve_path(&text, path).map_err(ApplyError::BadPath)?; - let scalar = match value { - tw_api::PatchValue::Str(v) => tw_yaml::Scalar::Str(v.clone()), - tw_api::PatchValue::Int(v) => tw_yaml::Scalar::Int(*v), - tw_api::PatchValue::Bool(v) => tw_yaml::Scalar::Bool(*v), - tw_api::PatchValue::Null => tw_yaml::Scalar::Null, + text = match op { + tw_api::PatchOp::Replace { path, value } => { + let steps = resolve_path(&text, path).map_err(ApplyError::BadPath)?; + let scalar = match value { + tw_api::PatchValue::Str(v) => tw_yaml::Scalar::Str(v.clone()), + tw_api::PatchValue::Int(v) => tw_yaml::Scalar::Int(*v), + tw_api::PatchValue::Bool(v) => tw_yaml::Scalar::Bool(*v), + tw_api::PatchValue::Null => tw_yaml::Scalar::Null, + }; + // **`insert` 而不是 `set`。**配置里绝大多数字段是可选的、 + // 默认不写的,只能改「用户碰巧写过」的字段,等于表单模式在 + // 他最需要的时候是死的。已经写过的走 `set`,那是它的第一步。 + tw_yaml::insert(&text, &steps, &scalar) + .map_err(|e| ApplyError::BadPath(format!("改 `{path}` 失败:{e}")))? + } + tw_api::PatchOp::Append { path, item } => { + let steps = resolve_path(&text, path).map_err(ApplyError::BadPath)?; + tw_yaml::append(&text, &steps, item) + .map_err(|e| ApplyError::BadPath(format!("往 `{path}` 加一项失败:{e}")))? + } + tw_api::PatchOp::Remove { path } => { + let steps = resolve_path(&text, path).map_err(ApplyError::BadPath)?; + // 路径指向那一项,最后一步就是它在列表里的位置。 + // **按名字解析、按下标删** —— 名字是用户写的,下标是 + // 我们刚刚算出来的,中间没有任何一次用户可见的重排。 + let (last, parent) = steps.split_last().ok_or_else(|| { + ApplyError::BadPath(format!("`{path}` 不指向列表里的某一项")) + })?; + let tw_yaml::Step::Index(i) = last else { + return Err(ApplyError::BadPath(format!( + "`{path}` 指的不是列表里的一项 —— 删除要指到具体那一条,比如 /clients/codex" + ))); + }; + tw_yaml::remove(&text, parent, *i) + .map_err(|e| ApplyError::BadPath(format!("删 `{path}` 失败:{e}")))? + } }; - // **`insert` 而不是 `set`。**配置里绝大多数字段是可选的、 - // 默认不写的,只能改「用户碰巧写过」的字段,等于表单模式在 - // 他最需要的时候是死的。已经写过的走 `set`,那是它的第一步。 - text = tw_yaml::insert(&text, &steps, &scalar) - .map_err(|e| ApplyError::BadPath(format!("改 `{path}` 失败:{e}")))?; } self.write(&text, Some(&cur.version()), origin).await } } + +#[cfg(test)] +mod patch_seq_tests { + use super::*; + + const CFG: &str = "version: 1\nclients:\n # 首次运行生成的\n - name: default\n key: tw-aaa\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-a\n"; + + /// 这三条是「界面能不能建东西」的全部依据。 + #[test] + fn append_then_remove_by_name_round_trips_and_keeps_comments() { + let steps = resolve_path(CFG, "/clients").unwrap(); + let two = tw_yaml::append(CFG, &steps, "name: codex\nkey: tw-bbb").unwrap(); + assert!(two.contains("# 首次运行生成的"), "{two}"); + let cfg: tw_config::Config = serde_yaml_ng::from_str(&two).unwrap(); + assert_eq!(cfg.clients.len(), 2); + assert_eq!(cfg.clients[1].name, "codex"); + + // 按名字定位那一项,再删 + let item = resolve_path(&two, "/clients/codex").unwrap(); + let (last, parent) = item.split_last().unwrap(); + let tw_yaml::Step::Index(i) = last else { + panic!("按名字解析出来的最后一步应该是下标:{item:?}"); + }; + let back = tw_yaml::remove(&two, parent, *i).unwrap(); + let cfg: tw_config::Config = serde_yaml_ng::from_str(&back).unwrap(); + assert_eq!(cfg.clients.len(), 1); + assert_eq!(cfg.clients[0].name, "default"); + assert!(back.contains("# 首次运行生成的"), "{back}"); + } + + #[test] + fn a_new_key_can_carry_its_route_assignment_in_one_write() { + // 建密钥和分配路由是**一次写入**,不是两次 —— 中间那一刻 + // 「有一把没分配路由的密钥」是个用户能看见的错误状态。 + let steps = resolve_path(CFG, "/clients").unwrap(); + let out = tw_yaml::append( + CFG, + &steps, + "name: codex\nkey: tw-bbb\nroutes:\n - 长上下文", + ) + .unwrap(); + let cfg: tw_config::Config = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(cfg.clients[1].routes, vec!["长上下文".to_string()]); + } + + #[test] + fn removing_a_name_that_is_not_there_says_so_instead_of_deleting_something_else() { + // **最该防的一条**:解析不到就报错,不要退化成「删第 0 项」。 + assert!(resolve_path(CFG, "/clients/不存在").is_err()); + } +} diff --git a/crates/tw-yaml/src/lib.rs b/crates/tw-yaml/src/lib.rs index d7872501..7697fdeb 100644 --- a/crates/tw-yaml/src/lib.rs +++ b/crates/tw-yaml/src/lib.rs @@ -596,3 +596,275 @@ fn checked(before: &str, out: String, path: &[Step], value: &Scalar) -> Result Result, PatchError> { + let all = nodes(text)?; + let mut want = seq_path.to_vec(); + want.push(Step::Index(index)); + // 这一项底下最靠前的那个节点,就是这一项的起点所在的行 + let start_node = all + .iter() + .filter(|n| n.path.starts_with(&want)) + .map(|n| n.bytes.start) + .min() + .ok_or_else(|| PatchError::NotFound(show(&want)))?; + let line_start = text[..start_node].rfind('\n').map(|i| i + 1).unwrap_or(0); + // 行首到 `-` 的缩进 + let dash = text[line_start..] + .find('-') + .map(|i| line_start + i) + .ok_or_else(|| PatchError::NotFound(format!("{}(不是块式列表)", show(&want))))?; + let indent = dash - line_start; + // 往后走到下一项的 `-`(同缩进)或者这一块结束 + let mut at = text[line_start..] + .find('\n') + .map(|i| line_start + i + 1) + .unwrap_or(text.len()); + while at < text.len() { + let line_end = text[at..].find('\n').map(|i| at + i).unwrap_or(text.len()); + let line = &text[at..line_end]; + let trimmed = line.trim_start(); + // 空行归属于**下一项**:它是分隔,不是内容。跟着上一项删的话, + // 删掉中间一项会让前后两项贴在一起。 + if !trimmed.is_empty() { + let this_indent = line.len() - trimmed.len(); + if this_indent <= indent { + break; + } + } + at = if line_end >= text.len() { + text.len() + } else { + line_end + 1 + }; + } + // 回退掉结尾那个换行。**只回退一个** —— 再往前是上一项的内容, + // 而这个区间是要交给调用方切字节的。 + let end = if at > line_start && text.as_bytes()[at - 1] == b'\n' { + at - 1 + } else { + at + }; + Ok(line_start..end) +} + +/// 往块式列表末尾加一项。 +/// +/// `item` 是这一项的 YAML 片段,**不带前导的 `- `**,多行之间用 `\n` +/// 分隔:`"name: codex\nkey: tw-abc"` 变成 +/// +/// ```text +/// - name: codex +/// key: tw-abc +/// ``` +/// +/// 缩进抄已有那一项的 —— 用户用的是两格还是四格是他的事。 +pub fn append(text: &str, seq_path: &[Step], item: &str) -> Result { + let all = nodes(text)?; + let count = all + .iter() + .filter(|n| n.path.len() == seq_path.len() + 1 && n.path.starts_with(seq_path)) + .filter_map(|n| match n.path.last() { + Some(Step::Index(i)) => Some(*i + 1), + _ => None, + }) + .max() + .unwrap_or(0); + if count == 0 { + return Err(PatchError::NotFound(format!( + "{}(空列表还不支持追加,先在文本模式里写第一项)", + show(seq_path) + ))); + } + let last = item_span(text, seq_path, count - 1)?; + let line_start = text[..last.start].rfind('\n').map(|i| i + 1).unwrap_or(0); + let dash = text[line_start..last.end] + .find('-') + .map(|i| line_start + i) + .ok_or_else(|| PatchError::NotFound(show(seq_path)))?; + let indent = " ".repeat(dash - line_start); + // `- ` 之后的内容相对行首多缩两格(`- ` 本身的宽度) + let cont = format!("{indent} "); + let mut piece = String::from("\n"); + for (i, line) in item.lines().enumerate() { + if i > 0 { + piece.push('\n'); + piece.push_str(&cont); + } else { + piece.push_str(&indent); + piece.push_str("- "); + } + piece.push_str(line); + } + let mut out = String::with_capacity(text.len() + piece.len()); + out.push_str(&text[..last.end]); + out.push_str(&piece); + out.push_str(&text[last.end..]); + checked_structural(text, out, seq_path, count + 1) +} + +/// 从块式列表里删掉第 `index` 项。 +pub fn remove(text: &str, seq_path: &[Step], index: usize) -> Result { + let span = item_span(text, seq_path, index)?; + let all = nodes(text)?; + let count = all + .iter() + .filter(|n| n.path.len() == seq_path.len() + 1 && n.path.starts_with(seq_path)) + .filter_map(|n| match n.path.last() { + Some(Step::Index(i)) => Some(*i + 1), + _ => None, + }) + .max() + .unwrap_or(0); + // 连同它后面那个换行一起删,否则会留下一个空行 + let mut end = span.end; + if end < text.len() && text.as_bytes()[end] == b'\n' { + end += 1; + } + let mut out = String::with_capacity(text.len()); + out.push_str(&text[..span.start]); + out.push_str(&text[end..]); + checked_structural(text, out, seq_path, count.saturating_sub(1)) +} + +/// 结构性改动的护栏:**改完在内存里重新解析,数一遍这个列表有几项**。 +/// +/// 和标量那条护栏是同一个理由,只是断言不同 —— 那边断言「写进去的值 +/// 读得回来」,这边断言「列表长度正好差一」。改出一份解析不了的 YAML +/// 是这一层最该防的事,而它只会在下一次加载时暴露。 +fn checked_structural( + before: &str, + out: String, + seq_path: &[Step], + want: usize, +) -> Result { + let after = nodes(&out).map_err(|e| { + PatchError::SelfCheck(format!("改完 `{}` 之后解析不了:{e}", show(seq_path))) + })?; + let got = after + .iter() + .filter(|n| n.path.len() == seq_path.len() + 1 && n.path.starts_with(seq_path)) + .filter_map(|n| match n.path.last() { + Some(Step::Index(i)) => Some(*i + 1), + _ => None, + }) + .max() + .unwrap_or(0); + if got != want { + return Err(PatchError::SelfCheck(format!( + "改完之后 `{}` 有 {got} 项,应该是 {want} 项", + show(seq_path) + ))); + } + let _ = before; + Ok(out) +} + +#[cfg(test)] +mod seq_tests { + use super::*; + + const CFG: &str = "version: 1\nclients:\n # 第一把是首次运行生成的\n - name: default\n key: tw-aaa\n - name: codex\n key: tw-bbb\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n"; + + fn clients() -> Vec { + vec![Step::Key("clients".into())] + } + + #[test] + fn appending_a_key_keeps_every_comment_and_the_other_entries() { + let out = append(CFG, &clients(), "name: cline\nkey: tw-ccc").unwrap(); + // 注释还在 —— 这是整个 tw-yaml 存在的理由 + assert!(out.contains("# 第一把是首次运行生成的"), "{out}"); + assert!(out.contains("- name: default"), "{out}"); + assert!(out.contains("- name: codex"), "{out}"); + assert!(out.contains(" - name: cline\n key: tw-ccc"), "{out}"); + // 新的一项在 providers 之前,不是文件末尾 + assert!( + out.find("cline").unwrap() < out.find("providers").unwrap(), + "{out}" + ); + let cfg: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(cfg["clients"].as_sequence().unwrap().len(), 3); + } + + #[test] + fn removing_the_middle_entry_does_not_glue_its_neighbours_together() { + let three = append(CFG, &clients(), "name: cline\nkey: tw-ccc").unwrap(); + let out = remove(&three, &clients(), 1).unwrap(); + assert!(!out.contains("codex"), "{out}"); + assert!(out.contains("- name: default"), "{out}"); + assert!(out.contains("- name: cline"), "{out}"); + assert!(out.contains("# 第一把是首次运行生成的"), "{out}"); + let cfg: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(cfg["clients"].as_sequence().unwrap().len(), 2); + } + + #[test] + fn removing_the_first_entry_keeps_the_rest_parseable() { + let out = remove(CFG, &clients(), 0).unwrap(); + let cfg: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + let cs = cfg["clients"].as_sequence().unwrap(); + assert_eq!(cs.len(), 1); + assert_eq!(cs[0]["name"].as_str(), Some("codex")); + } + + #[test] + fn a_four_space_file_gets_four_space_entries() { + // **缩进抄用户的,不是我们的偏好。**这是他的文件。 + let four = "clients:\n - name: a\n key: tw-a\n"; + let out = append(four, &clients(), "name: b\nkey: tw-b").unwrap(); + assert!(out.contains(" - name: b\n key: tw-b"), "{out}"); + let cfg: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(cfg["clients"].as_sequence().unwrap().len(), 2); + } + + #[test] + fn a_trailing_comment_on_the_last_entry_stays_with_it() { + let c = "clients:\n - name: a\n key: tw-a # 这把给 Claude Code\n"; + let out = append(c, &clients(), "name: b\nkey: tw-b").unwrap(); + assert!(out.contains("tw-a # 这把给 Claude Code"), "{out}"); + let cfg: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(cfg["clients"].as_sequence().unwrap().len(), 2); + } + + #[test] + fn appending_a_nested_list_round_trips() { + // `routes` 是列表里套列表 —— 这是路由页要写的形状 + let c = "routes:\n - name: 默认\n default: true\n rules:\n - name: r1\n to: a\n"; + let out = append( + c, + &[ + Step::Key("routes".into()), + Step::Index(0), + Step::Key("rules".into()), + ], + "name: r2\nto: b", + ) + .unwrap(); + let cfg: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + let rules = cfg["routes"][0]["rules"].as_sequence().unwrap(); + assert_eq!(rules.len(), 2); + assert_eq!(rules[1]["name"].as_str(), Some("r2")); + } + + #[test] + fn an_empty_list_says_so_instead_of_writing_something_broken() { + // 空列表还不支持 —— **说出来,而不是写出一份解析不了的文件** + let c = "clients: []\n"; + let e = append(c, &clients(), "name: a\nkey: tw-a").unwrap_err(); + assert!(format!("{e}").contains("空列表"), "{e}"); + } +}