From e25d1da275d3a166a73fc183ff741186ed9864a3 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:55:32 +0800 Subject: [PATCH 1/2] fix(engine): keep the implicit default route once a real route exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The implicit default route was synthesised only when the file declared no routes at all. So the moment a user created their first one, the route that every unbound key walks vanished, and loading the config failed with "`default_route` points at `默认`, but there is no such route" — that is, adding a route broke the gateway, and the message pointed at a field the user never touched. Synthesise it whenever nothing answers to `default_route` instead. A `default_route` the user wrote by hand is tracked separately, so a typo in it still fails loudly rather than being papered over by a synthesised route of that name. Co-Authored-By: Claude Opus 5 --- crates/tw-engine/src/engine.rs | 52 ++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/crates/tw-engine/src/engine.rs b/crates/tw-engine/src/engine.rs index 5b51b8fa..8f1ab575 100644 --- a/crates/tw-engine/src/engine.rs +++ b/crates/tw-engine/src/engine.rs @@ -436,6 +436,13 @@ pub struct Engine { sets: Vec, /// 没绑定时走哪条。 default_route: String, + /// 这个名字是配置里写的,还是我们兜的。 + /// + /// **写错了要报错,没写不算错。**两者都表现为「找不到这条路由」, + /// 但一个是打字错误(必须拦),一个是这个字段本来就可以不写(必须 + /// 能用)。不区分的话,二选一都会错:要么打错的名字被静默兜掉, + /// 要么没写这个字段的配置加载不了。 + default_is_explicit: bool, /// 密钥名 → 它绑的那条路由名。没有条目 = 走默认。 bound: std::collections::HashMap, groups: Vec, @@ -461,8 +468,19 @@ impl Engine { // 上游」都说不出口(tw-config::validate 里那段注释)。 // // 这条今天已经立过一次,又被这里破坏了一次。测试抓住了。 + let default_is_explicit = default_route.is_some(); let default_route = default_route.unwrap_or_else(|| DEFAULT_ROUTE.to_string()); - if sets.iter().all(|s| s.rules.is_empty()) && !providers.is_empty() { + // **默认路由必须永远存在。**判据是「有没有叫这个名字的路由」, + // 不是「一条规则都没有」—— + // + // 后者有个陷阱:配置里一添第一条自定义路由,隐式的那条默认就 + // 不再合成,于是 `default_route` 指向一个不存在的名字、整份配置 + // 失效。也就是说**「建第一条路由」这个动作本身会把配置写坏**, + // 而那正是界面上最常走的一步。端到端测出来的。 + if !default_is_explicit + && !sets.iter().any(|s| s.name == default_route) + && !providers.is_empty() + { const ALL: &str = "__all__"; groups.push(Group { name: ALL.to_string(), @@ -479,17 +497,15 @@ impl Engine { deny: None, guard: None, }; - match sets.iter_mut().find(|s| s.name == default_route) { - Some(d) => d.rules.push(fallback), - None => sets.push(RouteSet { - name: default_route.clone(), - rules: vec![fallback], - }), - } + sets.push(RouteSet { + name: default_route.clone(), + rules: vec![fallback], + }); } Self { sets, default_route, + default_is_explicit, bound, groups, providers, @@ -560,7 +576,7 @@ impl Engine { } // 默认路由指了一个不存在的名字。**没绑路由的密钥会一条规则都 // 不过** —— 请求全部落到「没有任何规则命中」,而配置看起来完整。 - if !self.sets.is_empty() && !self.sets.iter().any(|s| s.name == self.default_route) { + if self.default_is_explicit && !self.sets.iter().any(|s| s.name == self.default_route) { return Err(RouteError::UnknownDefaultRoute(self.default_route.clone())); } // 绑了一个不存在的路由名 —— 那把密钥会静默地退回默认路由, @@ -908,6 +924,24 @@ mod tests { assert_eq!(e.validate(), Err(RouteError::DuplicateRoute("重名".into()))); } + #[test] + fn adding_the_first_custom_route_does_not_break_the_implicit_default() { + // **这个动作是界面上最常走的一步**,而它一度会把配置写坏: + // 合成条件原本是「一条规则都没有」,所以添了第一条自定义路由 + // 之后隐式的默认就不再合成,`default_route` 指向一个不存在的 + // 名字,整份配置失效。端到端测出来的。 + let e = Engine::new( + vec!["a".into()], + vec![], + vec![set_of("长上下文", "a")], + None, + Default::default(), + ); + assert!(e.validate().is_ok(), "{:?}", e.validate()); + // 没绑路由的密钥仍然有规则可走 + assert!(!e.rules_for_client("谁都行").is_empty()); + } + #[test] fn a_default_route_that_names_nothing_is_caught_at_load() { // **没绑路由的密钥会一条规则都过不到** —— 请求全部落到 From f173564e4f6531670f0aec1772bd6644ac39f712 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:55:32 +0800 Subject: [PATCH 2/2] fix(yaml): make every first-time write in the UI actually land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walking the desktop UI end to end against a running gateway, from a config that contains only clients and providers, five of its write paths failed. All five share a shape: the key being written is absent from the file, which is the normal state for most of the schema — the first day's config is six lines. * A top-level key went in wherever the last node ended, and a container node's byte range is an empty marker at the *start* of its content. With a block sequence last in the file, `default_route:` landed between two list items and the guard rejected the result as unparseable. `insert` and `append_first` had each hit this separately, so the block-and-indent logic now lives in one place. * Writing into a section that does not exist yet was refused. `limits` and `client_probes` are never written by default, so "change one item in it" *is* their first write; refusing made those settings dead in the UI. Missing intermediate maps are now created. Missing list *entries* are still refused — those have names, and inventing one is a guess. * A value slot that is empty (`key:` with nothing after it) has its marker on the colon, not after it, so writing through it produced `allow[]:`. * Overwriting a list with a scalar wrote a second key of the same name beside it. That is the three-state `allow` control's way back to "no restriction", and it failed with "this is a bug, please file an issue". * Removing the last entry of a list left `key:`, which is null, not an empty list. For `allow` those are "follow the client's dialect" and "allow nothing" — deleting the last pattern quietly turned the guard off. It also swallowed the blank line the user had put between two sections. Adds `clear_seq` and a `Clear` patch op: `allow: []` and an absent `allow` are different answers, so the protocol needs both. `Limits` was the one struct reachable from the config that accepted unknown fields, so a mistyped limit reported success and did nothing. 19 write paths now round-trip against a live gateway, and the resulting file differs from the original only in the lines the run meant to change. Co-Authored-By: Claude Opus 5 --- crates/tw-api/src/lib.rs | 8 + crates/tw-control/src/config.rs | 8 + crates/tw-types/src/lib.rs | 5 + crates/tw-yaml/src/lib.rs | 769 +++++++++++++++++++++++++------- crates/tw-yaml/tests/insert.rs | 17 +- 5 files changed, 635 insertions(+), 172 deletions(-) diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index 59066261..fd889ae1 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -680,6 +680,14 @@ pub enum PatchOp { Remove { path: String, }, + /// 把一个列表清成空的(`[]`)。 + /// + /// **和「把这个键删掉」不是一回事**,所以它不是 `Remove` 的循环: + /// `allow` 不写 = 跟客户端方言走,`allow: []` = 一个都不给。界面上 + /// 那是两个不同的选项。 + Clear { + path: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/tw-control/src/config.rs b/crates/tw-control/src/config.rs index bed862bc..9cacd500 100644 --- a/crates/tw-control/src/config.rs +++ b/crates/tw-control/src/config.rs @@ -357,6 +357,14 @@ impl ConfigManager { tw_yaml::remove(&text, parent, *i) .map_err(|e| ApplyError::BadPath(format!("删 `{path}` 失败:{e}")))? } + tw_api::PatchOp::Clear { path } => { + // 要清的列表可能根本还没写进文件 —— 「一个都不给」 + // 正是用户第一次碰 `allow` 的那一下。`resolve_path` + // 对没写过的键会原样留成 Key,走得通。 + let steps = resolve_path(&text, path).map_err(ApplyError::BadPath)?; + tw_yaml::clear_seq(&text, &steps) + .map_err(|e| ApplyError::BadPath(format!("清空 `{path}` 失败:{e}")))? + } }; } self.write(&text, Some(&cur.version()), origin).await diff --git a/crates/tw-types/src/lib.rs b/crates/tw-types/src/lib.rs index 201840e4..85c76246 100644 --- a/crates/tw-types/src/lib.rs +++ b/crates/tw-types/src/lib.rs @@ -299,6 +299,11 @@ pub fn substitute_template( /// 并发上限。住在 tw-types 是因为配置和数据面都要认 /// 它,而它本身只是几个数字 —— 不该为此让 tw-config 依赖 tw-gateway。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +// 配置里的每一个结构都拒未知字段,唯独这一个在 tw-types 里,漏了。 +// 表现是 `max_body_bytes: 8388608` 写进去不报错、也不生效 —— 用户改 +// 了个上限,界面说写成功了,什么都没发生。(这一层别的类型是聊天接口 +// 的线上类型,上游随时会加字段,那些恰恰不能拒。) +#[serde(deny_unknown_fields)] pub struct Limits { /// 全局并发 #[serde(default = "d_max_concurrent")] diff --git a/crates/tw-yaml/src/lib.rs b/crates/tw-yaml/src/lib.rs index c351c4cb..a0e8a88c 100644 --- a/crates/tw-yaml/src/lib.rs +++ b/crates/tw-yaml/src/lib.rs @@ -379,6 +379,93 @@ fn advance(stack: &mut [Frame], pending: &mut Option) { *pending = None; } +/// 引出 `at` 这个值的那个冒号在哪,返回它**后面**一个字节。 +/// +/// 容器的值起点是它内容的第一个 token(`[`、第一个 `-`、第一个子键), +/// 键写在它前面、常常在上一行。中间可能隔着整行注释,而注释里完全可能 +/// 带冒号(`# 见 https://x/y`),所以按行回退、跳过空行和注释行。 +fn after_key_colon(text: &str, at: usize) -> Option { + fn strip_comment(line: &str) -> &str { + match line.find(" #") { + Some(i) => &line[..i], + None => line, + } + } + let mut line_start = text[..at].rfind('\n').map(|i| i + 1).unwrap_or(0); + // 键和值在同一行上(`allow: []`) + if let Some(i) = text[line_start..at].rfind(':') { + return Some(line_start + i + 1); + } + while line_start > 0 { + let prev_end = line_start - 1; + let prev_start = text[..prev_end].rfind('\n').map(|i| i + 1).unwrap_or(0); + let line = &text[prev_start..prev_end]; + let trimmed = line.trim(); + if !trimmed.is_empty() && !trimmed.starts_with('#') { + return strip_comment(line).rfind(':').map(|i| prev_start + i + 1); + } + line_start = prev_start; + } + None +} + +/// 拿一个标量盖掉一整块列表或映射。 +/// +/// **三态里的「回来那一步」走的是这条路。**`allow` 从一张清单退回 +/// 「什么都不限」时写进去的是 `~`,原来那一整块要跟着消失。没有它的 +/// 话,`insert` 会在旁边再写一个同名键 —— 护栏拦得住,但用户看到的是 +/// 「这是个 bug,请贴到 issue 里」,而他只是想把设过的东西撤回去。 +fn overwrite_block( + text: &str, + all: &[Node], + node: &Node, + rendered: &str, +) -> Result { + let at = after_key_colon(text, node.bytes.start) + .ok_or_else(|| PatchError::NotFound(show(&node.path)))?; + // 有后代就按整块算;`key: []` 这种没有后代,就到本行行尾 + let end = block_of(text, all, &node.path) + .map(|(e, _)| e) + .unwrap_or_else(|| { + text[node.bytes.start..] + .find('\n') + .map(|i| node.bytes.start + i) + .unwrap_or(text.len()) + }); + if end < at { + return Err(PatchError::NotFound(show(&node.path))); + } + let mut out = String::with_capacity(text.len() + rendered.len()); + out.push_str(&text[..at]); + out.push(' '); + out.push_str(rendered); + out.push_str(&text[end..]); + Ok(out) +} + +/// 值该写在哪一段字节上。 +/// +/// **值是空的时候(`key:` 后面什么都没有),解析器给的标记停在冒号 +/// 上面而不是后面** —— 照它直接写,`allow` 会变成 `allow[]:`,`note` +/// 会变成 `note:hi` 里那个叫 `note:hi` 的标量。配置里「写了键、还没填 +/// 值」很常见,而这两种产物都解析不回原来那个键。 +fn value_slot(text: &str, at: &Range) -> Range { + if !at.is_empty() { + return at.clone(); + } + let line_end = text[at.start..] + .find('\n') + .map(|i| at.start + i) + .unwrap_or(text.len()); + match text[at.start..line_end].find(':') { + Some(i) => { + let after = at.start + i + 1; + after..after + } + None => at.clone(), + } +} + /// 改一个标量值,**只动它那一段字节**。 /// /// 三道护栏,其中第三道在运行时(不只是测试时): @@ -399,10 +486,18 @@ pub fn set(text: &str, path: &[Step], value: &Scalar) -> Result Result Option<(usize, String)> { + let (mut start, mut last) = (usize::MAX, 0usize); + for n in all + .iter() + .filter(|n| n.path.len() > parent.len() && n.path.starts_with(parent)) + { + start = start.min(n.bytes.start); + last = last.max(n.bytes.end.min(text.len())); + } + if start == usize::MAX { + return None; + } + let first_line = text[..start].rfind('\n').map(|i| i + 1).unwrap_or(0); + let end = text[last..] + .find('\n') + .map(|i| last + i) + .unwrap_or(text.len()); + // **跳过 `-` 开头的行和注释行。**映射本身是列表的一项时 + // (`clients[0]`),它的第一个键写在 `- name: a` 这一行上,那一行的 + // 缩进是短划线的,比键实际所在的列少两格;注释则是用户想顶格写就 + // 顶格写的,跟这一层的缩进无关。 + let indent = text[first_line..end] + .lines() + .filter(|l| { + let t = l.trim_start(); + !t.is_empty() && !t.starts_with('-') && !t.starts_with('#') + }) + .map(|l| l.len() - l.trim_start().len()) + .min() + .or_else(|| { + // 整块只有一行 `- name: a` —— 键在短划线后面两格 + text[first_line..end] + .lines() + .find(|l| !l.trim().is_empty()) + .map(|l| l.len() - l.trim_start().len() + 2) + }) + .unwrap_or(0); + Some((end, " ".repeat(indent))) +} + +/// 这个列表现在有几项。 +fn count_items(all: &[Node], seq_path: &[Step]) -> usize { + 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) +} + +/// 把 `at` 这段值换成 `[]`,必要时在冒号后面补空格。 +fn put_empty_seq(text: &str, at: &Range) -> String { + let slot = value_slot(text, at); + let mut out = String::with_capacity(text.len() + 3); + out.push_str(&text[..slot.start]); + if !text[slot.start..].starts_with(' ') { + out.push(' '); + } + out.push_str("[]"); + out.push_str(&text[slot.end..]); + out +} + +/// 把 `path` 这个键写进文档,值是**已经渲染好的一段文本**。 /// -/// 只插**标量**,而且父节点必须已经是个映射。新增一个列表项(多一个 -/// provider)仍然走文本模式 —— 那是结构性改动,退路说得很清楚。 +/// 返回新文本,以及多出来的节点数 —— 护栏要拿它对账。 /// -/// 插在父映射**最后一个子键的下一行**,缩进抄那一行的。不去猜「该插在 -/// 哪两行之间」—— 那只会打乱用户自己排的顺序。 -pub fn insert(text: &str, path: &[Step], value: &Scalar) -> Result { - if find(text, path).is_ok() { - // 已经有了就是一次普通的替换 —— 调用方不用先问一遍 - return set(text, path, value); - } - let Some((last, parent)) = path.split_last() else { - return Err(PatchError::NotFound(show(path))); - }; - let Step::Key(key) = last else { +/// **缺的中间层会一起补出来。**`limits`、`client_probes` 这些整段默认 +/// 不写(「第一天的配置是六行」),于是「改里面某一项」就是它们的第一次 +/// 写入;要求父节点先存在,等于这些设置项在界面上从头到尾是死的。 +fn splice_key( + text: &str, + all: &[Node], + path: &[Step], + rendered: &str, +) -> Result<(String, usize), PatchError> { + if path.is_empty() { return Err(PatchError::NotFound(show(path))); - }; - let all = nodes(text)?; - // 父节点得存在,而且得是个映射 - let p = all + } + // 最深的那个已经存在、而且能往里写的祖先。根一定在。 + let mut depth = path.len() - 1; + while depth > 0 { + match all.iter().find(|n| n.path == path[..depth]) { + Some(n) if n.anchored => return Err(PatchError::AnchorOrAlias(show(&path[..depth]))), + Some(n) if matches!(n.kind, NodeKind::Map) => break, + // 存在但不是映射 —— 往里插字段是在改它的类型,不干 + Some(_) => return Err(PatchError::NotFound(show(&path[..depth]))), + None => depth -= 1, + } + } + let anchor = &path[..depth]; + let a = all .iter() - .find(|n| n.path == parent) - .ok_or_else(|| PatchError::NotFound(show(parent)))?; - if !matches!(p.kind, NodeKind::Map) { - return Err(PatchError::NotFound(show(parent))); + .find(|n| n.path == anchor) + .ok_or_else(|| PatchError::NotFound(show(anchor)))?; + if a.anchored { + return Err(PatchError::AnchorOrAlias(show(anchor))); } - if p.anchored { - return Err(PatchError::AnchorOrAlias(show(parent))); - } - // **行内写法(`{ a: 1, b: 2 }`)要插在花括号里面。** - // - // 按块式的做法在下一行插,产出的是一份解析不了的 YAML —— 护栏会 - // 拦住,但那时用户看到的是「这是个 bug,请贴到 issue 里」,而他 - // 只是用了一种完全合法的写法。格式保留语料里本来就列了 - // 「流式与块式混排」。 - if text[p.bytes.start..].starts_with('{') { - let close = flow_end(text, p.bytes.start) - .ok_or_else(|| PatchError::NotFound(format!("{}(行内映射没收尾)", show(parent))))?; - let inner = text[p.bytes.start + 1..close].trim(); - let rendered = render_scalar(value, ScalarStyle::Plain); + if !matches!(a.kind, NodeKind::Map) { + return Err(PatchError::NotFound(show(anchor))); + } + // 要补出来的那几层键,从锚点往下数 + let mut keys = Vec::with_capacity(path.len() - depth); + for step in &path[depth..] { + match step { + Step::Key(k) => keys.push(k.as_str()), + // 列表项凭空造不出来 —— 那是 append 的事 + Step::Index(_) => return Err(PatchError::NotFound(show(path))), + } + } + + // **行内写法(`{ a: 1, b: 2 }`)要插在花括号里面。**按块式在下一行 + // 插,产出的是一份解析不了的 YAML —— 护栏会拦住,但那时用户看到的 + // 是「这是个 bug,请贴到 issue 里」,而他只是用了一种完全合法的写法。 + // 格式保留语料里本来就列了「流式与块式混排」。 + if text[a.bytes.start..].starts_with('{') { + // 行内映射里再补一层块式的中间键,写不出来 + if keys.len() > 1 { + return Err(PatchError::NotFound(show(&path[..path.len() - 1]))); + } + let key = keys[0]; + let close = flow_end(text, a.bytes.start) + .ok_or_else(|| PatchError::NotFound(format!("{}(行内映射没收尾)", show(anchor))))?; + let inner = text[a.bytes.start + 1..close].trim(); let piece = if inner.is_empty() { format!("{key}: {rendered}") } else { // 抄已有的逗号风格:`{a: 1, b: 2}` 和 `{a: 1,b: 2}` 都有人 // 写,跟着来比统一成我们的偏好更不打扰 —— 这是他的文件 - let spaced = text[p.bytes.start..close].contains(", "); + let spaced = text[a.bytes.start..close].contains(", "); format!("{}{key}: {rendered}", if spaced { ", " } else { "," }) }; // 收尾的 `}` 前面有空格(`{ a: 1 }`)就插在那个空格之前 let mut at = close; - while at > p.bytes.start + 1 && text.as_bytes()[at - 1] == b' ' { + while at > a.bytes.start + 1 && text.as_bytes()[at - 1] == b' ' { at -= 1; } let mut out = String::with_capacity(text.len() + piece.len()); out.push_str(&text[..at]); out.push_str(&piece); out.push_str(&text[at..]); - return checked(text, out, path, value); + return Ok((out, 1)); } - // 父映射的直接子节点里,位置最靠后的那个 - let last_child = all - .iter() - .filter(|n| n.path.len() == parent.len() + 1 && n.path.starts_with(parent)) - .max_by_key(|n| n.bytes.end) - .ok_or_else(|| PatchError::NotFound(show(parent)))?; - // **缩进抄那一行的**,而不是按层数算 —— 用户可能用的是 4 空格, - // 也可能是列表项里那种 `- name: x` 的对齐 - let line_start = text[..last_child.bytes.start] - .rfind('\n') - .map(|i| i + 1) - .unwrap_or(0); - let indent: String = text[line_start..] - .chars() - .take_while(|c| *c == ' ') - .collect(); - // 那一行的结尾。**容器子节点的 `bytes.end` 会跑过头**(注释里说了 - // 它一路跑到下一个 token),所以从行首往后找换行,而不是信它 - let after = text[line_start..] - .find('\n') - .map(|i| line_start + i) - .unwrap_or(text.len()); - // 子节点跨了多行(它自己是个映射/列表)时,往后走到这一块的末尾 - let mut end = after.max(last_child.bytes.end.min(text.len())); - if end > after { - end = text[end..] - .find('\n') - .map(|i| end + i) - .unwrap_or(text.len()); + let (end, indent) = + block_of(text, all, anchor).ok_or_else(|| PatchError::NotFound(show(anchor)))?; + let mut piece = String::new(); + for (i, k) in keys.iter().enumerate() { + piece.push('\n'); + piece.push_str(&indent); + for _ in 0..i { + piece.push_str(" "); + } + piece.push_str(k); + piece.push(':'); + if i + 1 == keys.len() { + piece.push(' '); + piece.push_str(rendered); + } } - let rendered = render_scalar(value, ScalarStyle::Plain); - let mut out = String::with_capacity(text.len() + key.len() + rendered.len() + 4); + let mut out = String::with_capacity(text.len() + piece.len()); out.push_str(&text[..end]); - out.push('\n'); - out.push_str(&indent); - out.push_str(key); - out.push_str(": "); - out.push_str(&rendered); + out.push_str(&piece); out.push_str(&text[end..]); + Ok((out, keys.len())) +} - checked(text, out, path, value) +/// 往映射里**插一个新的标量键**。 +/// +/// # 为什么必须有它 +/// +/// `set` 只能改已经写在文件里的键。而配置里绝大多数字段是可选的、 +/// 默认不写的(`protocol`、`billing`、`trust`、`session_affinity`…)—— +/// 于是表单模式只能改那些「用户碰巧写过」的字段,**用户想设一个他从来 +/// 没设过的值时,界面是死的**。而那正是他最需要界面的时刻。 +/// +/// # 边界 +/// +/// 只插**标量**,而且父节点必须已经是个映射。新增一个列表项(多一个 +/// provider)仍然走文本模式 —— 那是结构性改动,退路说得很清楚。 +/// +/// 插在父映射**最后一个子键的下一行**,缩进抄那一行的。不去猜「该插在 +/// 哪两行之间」—— 那只会打乱用户自己排的顺序。 +pub fn insert(text: &str, path: &[Step], value: &Scalar) -> Result { + if find(text, path).is_ok() { + // 已经有了就是一次普通的替换 —— 调用方不用先问一遍 + return set(text, path, value); + } + let all = nodes(text)?; + let rendered = render_scalar(value, ScalarStyle::Plain); + // 这个位置已经是个列表或映射 —— 整块换掉,而不是在旁边再写一个 + // 同名键。`find` 只认标量,所以走到这儿的容器看着像「还没有」。 + if let Some(n) = all.iter().find(|n| n.path == path) { + if n.anchored { + return Err(PatchError::AnchorOrAlias(show(path))); + } + let gone = all + .iter() + .filter(|x| x.path.len() > path.len() && x.path.starts_with(path)) + .count(); + let out = overwrite_block(text, &all, n, &rendered)?; + return checked(out, path, value, all.len() - gone); + } + let (out, added) = splice_key(text, &all, path, &rendered)?; + checked(out, path, value, all.len() + added) } /// 行内映射从 `{` 开始的那个位置,找到配对的 `}`。 @@ -574,7 +776,12 @@ fn flow_end(text: &str, open: usize) -> Option { /// `insert` 的三道护栏。**两条路(块式、行内)共用一份** —— 各写一份的 /// 话,迟早有一条上的检查会比另一条松。 -fn checked(before: &str, out: String, path: &[Step], value: &Scalar) -> Result { +fn checked( + out: String, + path: &[Step], + value: &Scalar, + expected: usize, +) -> Result { let back = find(&out, path) .map_err(|e| PatchError::SelfCheck(format!("插完之后 `{}` 找不回来:{e}", show(path))))?; if back.value != value.as_yaml_text() { @@ -585,12 +792,10 @@ fn checked(before: &str, out: String, path: &[Step], value: &Scalar) -> Result Result line_end + 1 }; } - // 回退掉结尾那个换行。**只回退一个** —— 再往前是上一项的内容, + // **结尾的空行退回去。**上面那个循环跳过空行,是为了让「项里夹着 + // 空行」不被误判成这一项结束了;代价是 `at` 会停在那些空行的后面。 + // 不退的话,删掉最后一项会连用户分隔两个段落的那个空行一起删掉 —— + // 一次删一行,几次之后整份配置就挤成一坨了。 + let mut cut = 0usize; + let mut pos = 0usize; + for line in text[line_start..at].split_inclusive('\n') { + if !line.trim().is_empty() { + cut = pos + line.len(); + } + pos += line.len(); + } + let at = line_start + cut.max(1); + // 回退掉结尾那个换行。**只回退一个** —— 再往前是这一项的内容, // 而这个区间是要交给调用方切字节的。 let end = if at > line_start && text.as_bytes()[at - 1] == b'\n' { at - 1 @@ -674,15 +892,7 @@ fn item_span(text: &str, seq_path: &[Step], index: usize) -> Result /// 缩进抄已有那一项的 —— 用户用的是两格还是四格是他的事。 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); + let count = count_items(&all, seq_path); if count == 0 { // **第一项要单独处理,而且这条路径是常走的**:`routes`、`proxies`、 // `allow_from` 这些键在配置里默认根本不写(「第一天的配置是六行」), @@ -732,60 +942,16 @@ fn append_first(text: &str, seq_path: &[Step], item: &str) -> Result parent.len()) - .map(|n| n.bytes.end.min(text.len())) - .max() - .ok_or_else(|| PatchError::NotFound(show(parent)))?; - let block_start = all - .iter() - .filter(|n| n.path.starts_with(parent) && n.path.len() > parent.len()) - .map(|n| n.bytes.start) - .min() - .unwrap_or(0); - let first_line = text[..block_start].rfind('\n').map(|i| i + 1).unwrap_or(0); - let end = text[block_end..] - .find('\n') - .map(|i| block_end + i) - .unwrap_or(text.len()); - // **跳过以 `-` 开头的行。**映射本身是列表的一项时(`clients[0]`), - // 它的第一个键写在 `- name: a` 这一行上 —— 那一行的缩进是短划线的 - // 缩进,比键实际所在的列少两格。照它算出来的新键会缩到列表项外面去。 - let key_indents: Vec = text[first_line..end] - .lines() - .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with('-')) - .map(|l| l.len() - l.trim_start().len()) - .collect(); - let indent: String = match key_indents.iter().min() { - Some(n) => " ".repeat(*n), - // 整块只有一行 `- name: a` —— 键在短划线后面两格 - None => { - let dash = text[first_line..end] - .lines() - .find(|l| !l.trim().is_empty()) - .map(|l| l.len() - l.trim_start().len() + 2) - .unwrap_or(0); - " ".repeat(dash) - } - }; + let (end, indent) = + block_of(text, &all, parent).ok_or_else(|| PatchError::NotFound(show(parent)))?; let existing = all.iter().find(|n| n.path == seq_path); let piece = { @@ -846,15 +1012,7 @@ fn append_first(text: &str, seq_path: &[Step], item: &str) -> Result 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 count = count_items(&all, seq_path); // 连同它后面那个换行一起删,否则会留下一个空行 let mut end = span.end; if end < text.len() && text.as_bytes()[end] == b'\n' { @@ -863,9 +1021,52 @@ pub fn remove(text: &str, seq_path: &[Step], index: usize) -> Result Result { + let mut cur = text.to_string(); + loop { + let n = count_items(&nodes(&cur)?, seq_path); + if n == 0 { + break; + } + cur = remove(&cur, seq_path, n - 1)?; + } + let all = nodes(&cur)?; + let out = match all.iter().find(|n| n.path == seq_path) { + Some(n) if n.anchored => return Err(PatchError::AnchorOrAlias(show(seq_path))), + // 空值(`allow:`),或者别的什么标量 —— 换成 `[]` + Some(n) if matches!(n.kind, NodeKind::Scalar { .. }) => put_empty_seq(&cur, &n.bytes), + // 本来就是空列表 + Some(_) => cur.clone(), + // 这个键根本没写 —— 和 insert 一样在父映射末尾补一行 + None => splice_key(&cur, &all, seq_path, "[]")?.0, + }; + checked_structural(&cur, out, seq_path, 0) +} + /// 结构性改动的护栏:**改完在内存里重新解析,数一遍这个列表有几项**。 /// /// 和标量那条护栏是同一个理由,只是断言不同 —— 那边断言「写进去的值 @@ -880,15 +1081,7 @@ fn checked_structural( 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); + let got = count_items(&after, seq_path); if got != want { return Err(PatchError::SelfCheck(format!( "改完之后 `{}` 有 {got} 项,应该是 {want} 项", @@ -1057,3 +1250,241 @@ mod seq_tests { assert_eq!(cfg["clients"][0]["allow"].as_sequence().unwrap().len(), 1); } } + +/// 界面上每一个「第一次」都从这儿过:第一条路由、第一个代理、第一次动 +/// 一整段默认不写的设置、把白名单收成一个都不给。这些路径在真机上一条 +/// 条炸过 —— 它们共同的性质是**默认配置里不存在**,所以只写「改一个已 +/// 经在的值」的测试永远照不到。 +#[cfg(test)] +mod first_write_tests { + use super::*; + + /// 尾部是块式列表的文件,往**顶层**插一个键。 + /// + /// 容器节点的 `bytes` 是内容起点上的一个空区间,照它算位置会把 + /// `default_route:` 插进 `routes` 的列表项中间,产出一份解析不了的 + /// YAML。真机上「把默认路由换成自定义那条」就是这么失败的。 + #[test] + fn a_top_level_key_lands_at_the_top_level_not_inside_the_last_list() { + let cfg = "version: 1\nclients:\n - name: demo\n key: tw-a\nroutes:\n - name: 长文\n rules:\n - name: 兜底\n to: relay\n"; + let out = insert(cfg, &[Step::key("default_route")], &Scalar::s("长文")).unwrap(); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(back["default_route"].as_str(), Some("长文")); + assert_eq!(back["routes"].as_sequence().unwrap().len(), 1); + assert_eq!(back["routes"][0]["rules"].as_sequence().unwrap().len(), 1); + // 顶格,不是缩在列表里 + assert!(out.contains("\ndefault_route: 长文"), "{out}"); + } + + /// 一整段还没写过的设置,改它里面的某一项。 + /// + /// `limits`、`client_probes` 这些默认根本不在文件里,「改里面某一 + /// 项」就是它们的第一次写入。父节点必须先存在的话,这些设置在界面 + /// 上从头到尾是死的。 + #[test] + fn writing_into_a_section_that_does_not_exist_yet_creates_it() { + let cfg = "version: 1\nlisten:\n gateway:\n port: 18780\n"; + let out = insert( + cfg, + &[Step::key("limits"), Step::key("max_body_bytes")], + &Scalar::Int(8_388_608), + ) + .unwrap(); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(back["limits"]["max_body_bytes"].as_i64(), Some(8_388_608)); + // 原来的东西一个没动 + assert_eq!(back["listen"]["gateway"]["port"].as_i64(), Some(18780)); + assert!( + out.contains("\nlimits:\n max_body_bytes: 8388608"), + "{out}" + ); + } + + #[test] + fn a_missing_section_two_levels_down_gets_both_levels() { + let cfg = "version: 1\n"; + let out = insert( + cfg, + &[ + Step::key("client_probes"), + Step::key("claude-code"), + Step::key("mode"), + ], + &Scalar::s("off"), + ) + .unwrap(); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!( + back["client_probes"]["claude-code"]["mode"].as_str(), + Some("off") + ); + } + + /// `key:` 后面空着的时候改它的值。 + /// + /// 那种键的值区间是空的、紧贴在冒号后面,照直写进去得到的是 + /// `key:x` —— 一个名叫 `key:x` 的标量。 + #[test] + fn setting_a_key_whose_value_is_blank_does_not_glue_it_to_the_colon() { + let cfg = "version: 1\nnote:\n"; + let out = set(cfg, &[Step::key("note")], &Scalar::s("hi")).unwrap(); + assert!(out.contains("note: hi"), "{out}"); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(back["note"].as_str(), Some("hi")); + } + + const ALLOW: &str = "clients:\n - name: demo\n key: tw-a\n allow:\n # 只给这些\n - claude-*\n - gpt-*\n"; + + fn allow_path() -> Vec { + vec![Step::key("clients"), Step::Index(0), Step::key("allow")] + } + + /// **「一个都不给」和「这个键不写」是两个不同的选项。** + #[test] + fn clearing_a_list_leaves_an_empty_list_not_a_missing_key() { + let out = clear_seq(ALLOW, &allow_path()).unwrap(); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + let allow = &back["clients"][0]["allow"]; + assert!( + allow.is_sequence(), + "读回来应该是个列表,实际是 {allow:?}\n{out}" + ); + assert_eq!(allow.as_sequence().unwrap().len(), 0, "{out}"); + // 用户写的注释还在 + assert!(out.contains("# 只给这些"), "{out}"); + } + + #[test] + fn clearing_a_list_that_was_never_written_creates_it_empty() { + let cfg = "clients:\n - name: demo\n key: tw-a\n"; + let out = clear_seq(cfg, &allow_path()).unwrap(); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!( + back["clients"][0]["allow"].as_sequence().map(|s| s.len()), + Some(0), + "{out}" + ); + assert_eq!(back["clients"][0]["key"].as_str(), Some("tw-a")); + } + + #[test] + fn clearing_an_already_empty_list_is_a_no_op() { + let cfg = "clients:\n - name: demo\n allow: []\n"; + let out = clear_seq(cfg, &allow_path()).unwrap(); + assert_eq!(out, cfg); + } + + /// 删掉最后一条白名单 = 一个都不给,**不是**退回「跟方言走」。 + #[test] + fn removing_the_last_entry_leaves_an_empty_list_not_a_null() { + let one = remove(ALLOW, &allow_path(), 1).unwrap(); + let out = remove(&one, &allow_path(), 0).unwrap(); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + let allow = &back["clients"][0]["allow"]; + assert!(!allow.is_null(), "删空之后成了 null:\n{out}"); + assert_eq!(allow.as_sequence().unwrap().len(), 0, "{out}"); + } + + /// 清空之后还能再加回去 —— `[]` 得能变回块式列表。 + #[test] + fn a_cleared_list_can_be_appended_to_again() { + let empty = clear_seq(ALLOW, &allow_path()).unwrap(); + let out = append(&empty, &allow_path(), "claude-3-5-*").unwrap(); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!( + back["clients"][0]["allow"].as_sequence().unwrap().len(), + 1, + "{out}" + ); + } +} + +/// 可选字段的**回头路**。 +/// +/// 设过的东西要能撤回去,不然界面上每个可选字段都是一扇单向门:`allow` +/// 一旦限定就永远限定,`default_route` 一旦指定就再也回不到那条隐式的 +/// 默认路由。撤回写的是 `~` —— 对 `Option` 来说和「这个键不写」 +/// 是同一个值。 +#[cfg(test)] +mod undo_tests { + use super::*; + + fn allow() -> Vec { + vec![Step::key("clients"), Step::Index(0), Step::key("allow")] + } + + fn null_out(text: &str, path: &[Step]) -> String { + insert(text, path, &Scalar::Null).unwrap() + } + + #[test] + fn nulling_a_block_list_replaces_it_instead_of_writing_a_second_key() { + let cfg = "clients:\n - name: demo\n allow:\n - claude-*\n - gpt-*\n key: tw-a\n"; + let out = null_out(cfg, &allow()); + assert_eq!(out.matches("allow").count(), 1, "同名键写了两遍:\n{out}"); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert!(back["clients"][0]["allow"].is_null(), "{out}"); + // 它后面的键还在原位 + assert_eq!(back["clients"][0]["key"].as_str(), Some("tw-a"), "{out}"); + } + + #[test] + fn nulling_an_empty_flow_list_stays_on_its_own_line() { + let cfg = "clients:\n - name: demo\n allow: []\n key: tw-a\n"; + let out = null_out(cfg, &allow()); + assert!(out.contains("allow: ~"), "{out}"); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(back["clients"][0]["key"].as_str(), Some("tw-a"), "{out}"); + } + + /// 键和值之间夹着注释,而注释里有冒号。 + /// + /// 往回找冒号时照直 `rfind` 会找到 `https://` 那个,把值切在注释 + /// 中间。 + #[test] + fn a_comment_with_a_colon_between_key_and_value_does_not_confuse_it() { + let cfg = "clients:\n - name: demo\n allow:\n # 见 https://example.invalid/x\n - claude-*\n"; + let out = null_out(cfg, &allow()); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert!(back["clients"][0]["allow"].is_null(), "{out}"); + assert_eq!(back["clients"][0]["name"].as_str(), Some("demo"), "{out}"); + } + + /// 用户用来分段的空行不归上一项所有。 + /// + /// 不然「删掉最后一条」会连那个空行一起删 —— 一次删一行,几次之后 + /// 整份配置就挤成一坨了,而没有任何一次改动看起来是错的。 + #[test] + fn removing_the_last_entry_keeps_the_blank_line_that_separates_sections() { + let cfg = "clients:\n - name: demo\n allow:\n - claude-*\n\nproviders:\n - name: relay\n"; + let out = remove(cfg, &allow(), 0).unwrap(); + assert!( + out.contains("\n\nproviders:"), + "分段的空行被吃掉了:\n{out}" + ); + } + + /// 撤回之后还能再设回来 —— 这扇门两边都要能走。 + #[test] + fn a_nulled_field_can_be_filled_in_again() { + let cfg = "clients:\n - name: demo\n allow:\n - claude-*\n"; + let empty = null_out(cfg, &allow()); + let out = append(&empty, &allow(), "gpt-*").unwrap(); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!( + back["clients"][0]["allow"].as_sequence().unwrap().len(), + 1, + "{out}" + ); + } + + /// 顶层的可选标量:`default_route` 撤回到那条隐式的默认路由。 + #[test] + fn a_top_level_optional_scalar_can_be_unset() { + let cfg = "version: 1\ndefault_route: 长文\nroutes:\n - name: 长文\n"; + let out = null_out(cfg, &[Step::key("default_route")]); + let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert!(back["default_route"].is_null(), "{out}"); + assert_eq!(back["routes"].as_sequence().unwrap().len(), 1, "{out}"); + } +} diff --git a/crates/tw-yaml/tests/insert.rs b/crates/tw-yaml/tests/insert.rs index ed5b929f..3861bbe8 100644 --- a/crates/tw-yaml/tests/insert.rs +++ b/crates/tw-yaml/tests/insert.rs @@ -83,10 +83,21 @@ fn a_boolean_reads_back_as_a_boolean() { } #[test] -fn a_missing_parent_is_refused_rather_than_invented() { - // 猜结构是不能接受的 —— 这个文件会去改用户唯一的配置 +fn a_missing_list_entry_is_refused_rather_than_invented() { + // **列表项凭空造不出来。**`providers[9]` 不存在的时候,「给第九家 + // 上游设 protocol」这句话本身没有意义 —— 猜一个出来是在改用户唯一 + // 的配置。新增一项走 append,那条路上有名字。 assert!(insert(CFG, &path!["providers", 9, "protocol"], &Scalar::s("x")).is_err()); - assert!(insert(CFG, &path!["没有这一段", "x"], &Scalar::s("x")).is_err()); +} + +#[test] +fn a_missing_section_is_created_because_that_is_its_first_write() { + // 缺的**映射**是另一回事:`limits`、`client_probes` 这些整段默认 + // 不写,「改里面某一项」就是它们的第一次写入。拒绝掉等于这些设置 + // 项在界面上从头到尾是死的 —— 而那正是用户最需要界面的时刻。 + let out = insert(CFG, &path!["还没这一段", "x"], &Scalar::s("v")).unwrap(); + let v: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(v["还没这一段"]["x"].as_str(), Some("v")); } #[test]