From 36061ca21300388ac3c8a30e87f6a8f2caeff871 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:28:44 +0800 Subject: [PATCH] feat(yaml): append and remove list entries, keeping comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `set` and `insert` only ever touched scalars, so the patch protocol could express exactly one thing: replace a value that already exists. **Every structural edit was therefore impossible from the UI** — creating a key, adding a rule, defining a proxy, assigning a route. None of those are a replacement. That was not a missing feature so much as a ceiling: the configuration page could read anything and create nothing. ## `tw_yaml::append` / `tw_yaml::remove` Block sequences only, and they work on the text rather than a re- serialised document, so comments and layout survive — which is the whole reason this crate exists. Positions come from walking lines, not from the container nodes' byte ranges. Those ranges run on to the next token (the note at the top of the file says so), and slicing with them takes the following entry's opening with it. Four behaviours worth naming, each with a test: · A blank line between entries belongs to the **following** entry. Attached to the preceding one, deleting a middle entry glues its neighbours together. · Indentation is copied from the entry already there. A four-space file gets four-space entries — it is the user's file. · A trailing comment on the last entry stays on the last entry. · Nested sequences work, which is what the routes page needs: `/routes/默认/rules`. Empty sequences are refused with a message that says so, rather than writing something that will not parse. `clients: []` cannot be appended to yet; validation already requires at least one client, so the case that matters in practice is covered. The structural guard mirrors the scalar one: re-parse in memory and count the entries. The scalar path asserts "the value reads back"; this one asserts "the list is exactly one longer". Writing a file that cannot be parsed is what this layer exists to prevent, and it would otherwise only surface at the next load. ## `PatchOp::Append` / `PatchOp::Remove` `Remove` takes a path to **the entry**, by name — `/clients/codex` — not an index. That is the rule already written on `PatchOp`: an index points at something else after the user reorders, and that failure is silent. The index is resolved internally, from a name the user wrote, with no reordering in between. A new entry can carry nested structure in one write, so creating a key and assigning its routes is a single operation. Two writes would leave a moment where a key exists with no routes — a state the user can see. Co-Authored-By: Claude Opus 5 --- crates/tw-api/src/lib.rs | 22 ++- crates/tw-control/src/config.rs | 99 ++++++++++-- crates/tw-yaml/src/lib.rs | 272 ++++++++++++++++++++++++++++++++ 3 files changed, 380 insertions(+), 13 deletions(-) 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}"); + } +}