From f23ab7335d75824b7ce2286995233cd54bfa753a Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:00:11 +0800 Subject: [PATCH] fix(yaml): append to a list the file does not have yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `append` required an existing, non-empty sequence. Almost none of the lists it needs to reach are written until someone uses them: `routes`, `proxies`, `allow_from`, a key's `allow` are all optional, and the starting configuration is six lines. So **every "create the first one" in the UI failed at exactly the moment a new user reached for it** — the first route, the first proxy, the first CIDR in the allow list. End-to-end testing against a running gateway is what surfaced it; the unit tests all started from a file that already had the list. Three starting points now work, all producing block sequences: the key is absent, the key holds `[]`, or the key is there with nothing under it. ## Two positioning bugs, both caught by the self-check Neither reached disk — the re-parse guard rejected the result — but both would have been silent corruption without it. **The last child's byte range is not the last key's line.** A block sequence's value node starts at its first `-`, not at `key:`. Taking the indentation from there copies a list item's indentation, and the insertion point lands *inside* the list: appending `routes` to a config whose last key was `clients` wrote `routes:` between two clients. The block is now measured across every node under the parent, and the indentation is the smallest one in that block. **A map that is itself a list entry keeps its first key on the dash line.** ` - name: a` indents two, while the keys live at four, so the smallest indentation in the block is two and the new key lands outside the entry. Lines starting with `-` are excluded from that measurement, falling back to dash + 2 when the entry is a single line. Four tests, one per path that a first-time user actually walks: a key that is not in the file, `[]` becoming a block list, comments surviving the creation of a new key, and a nested key (`clients[].allow`) that does not exist yet. Co-Authored-By: Claude Opus 5 --- crates/tw-yaml/src/lib.rs | 207 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 198 insertions(+), 9 deletions(-) diff --git a/crates/tw-yaml/src/lib.rs b/crates/tw-yaml/src/lib.rs index 7697fdeb..c351c4cb 100644 --- a/crates/tw-yaml/src/lib.rs +++ b/crates/tw-yaml/src/lib.rs @@ -684,10 +684,11 @@ pub fn append(text: &str, seq_path: &[Step], item: &str) -> Result Result Result { + let Some((last, parent)) = seq_path.split_last() else { + return Err(PatchError::NotFound(show(seq_path))); + }; + let Step::Key(key) = last else { + return Err(PatchError::NotFound(show(seq_path))); + }; + let all = nodes(text)?; + let p = all + .iter() + .find(|n| n.path == parent) + .ok_or_else(|| PatchError::NotFound(show(parent)))?; + if !matches!(p.kind, NodeKind::Map) || p.anchored { + return Err(PatchError::NotFound(show(parent))); + } + + // 这一块从哪到哪。 + // + // **不能拿「最后一个直接子节点」的位置。**块式列表的值节点,起点是 + // 第一个 `-` 那一行而不是 `key:` 那一行 —— 照它抄缩进会抄成列表项的 + // 缩进,插入点也会落在列表中间。(这正是第一版写出来的东西: + // `routes:` 被插进了 `clients` 的两项之间。) + // + // 所以按整块算:范围取这个父节点底下**所有**节点的最远处,缩进取 + // 这块里非空行的最小缩进 —— 那正是这一层键的缩进。 + let block_end = all + .iter() + .filter(|n| n.path.starts_with(parent) && n.path.len() > 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 existing = all.iter().find(|n| n.path == seq_path); + let piece = { + let cont = format!("{indent} "); + let mut out = String::new(); + for (i, line) in item.lines().enumerate() { + if i > 0 { + out.push('\n'); + out.push_str(&cont); + } else { + out.push_str(&indent); + out.push_str(" - "); + } + out.push_str(line); + } + out + }; + + let mut out = String::with_capacity(text.len() + piece.len() + key.len() + 4); + match existing { + // `key: []` 或者 `key:` 空着 —— 换掉那一行的值部分 + Some(n) => { + let vline_start = text[..n.bytes.start] + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0); + let vline_end = text[vline_start..] + .find('\n') + .map(|i| vline_start + i) + .unwrap_or(text.len()); + let vindent: String = text[vline_start..] + .chars() + .take_while(|c| *c == ' ') + .collect(); + let piece = piece.replacen(&indent, &vindent, 1); + out.push_str(&text[..vline_start]); + out.push_str(&vindent); + out.push_str(key); + out.push_str(":\n"); + out.push_str(&piece); + out.push_str(&text[vline_end..]); + } + // 键根本没写 —— 在这一块末尾补一整段 + None => { + out.push_str(&text[..end]); + out.push('\n'); + out.push_str(&indent); + out.push_str(key); + out.push_str(":\n"); + out.push_str(&piece); + out.push_str(&text[end..]); + } + } + checked_structural(text, out, seq_path, 1) +} + /// 从块式列表里删掉第 `index` 项。 pub fn remove(text: &str, seq_path: &[Step], index: usize) -> Result { let span = item_span(text, seq_path, index)?; @@ -860,11 +986,74 @@ mod seq_tests { assert_eq!(rules[1]["name"].as_str(), Some("r2")); } + /// **这四条是「第一次用」的全部路径。**`routes`、`proxies`、 + /// `allow_from` 这些键在配置里默认根本不写(第一天的配置只有六行), + /// 所以「建第一条路由」「加第一个代理」都从这里过 —— 不支持的话, + /// 界面上每个新建功能都在用户第一次点它的时候失败。 + #[test] + fn a_key_that_is_not_in_the_file_yet_gets_created_with_its_first_entry() { + let c = "version: 1\nclients:\n - name: a\n key: tw-a\n"; + let out = append( + c, + &[Step::Key("routes".into())], + "name: 默认\nrules:\n - name: 兜底\n to: official", + ) + .unwrap(); + let cfg: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + let rs = cfg["routes"].as_sequence().unwrap(); + assert_eq!(rs.len(), 1); + assert_eq!(rs[0]["name"].as_str(), Some("默认")); + assert_eq!(rs[0]["rules"].as_sequence().unwrap().len(), 1); + // 原来的内容一个字没动 + assert!(out.contains("- name: a\n key: tw-a"), "{out}"); + } + #[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}"); + fn a_flow_empty_list_becomes_a_block_list() { + let c = "version: 1\nproxies: []\n"; + let out = append( + c, + &[Step::Key("proxies".into())], + "name: 翻墙\ntype: socks5h\naddr: 127.0.0.1:1080", + ) + .unwrap(); + let cfg: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(cfg["proxies"].as_sequence().unwrap().len(), 1); + assert!(!out.contains("[]"), "流式的空列表该被换掉:{out}"); + } + + #[test] + fn creating_a_key_keeps_the_comments_around_it() { + let c = + "version: 1\n# 这台机器上的上游\nproviders:\n - name: 官方\n base_url: https://x\n"; + let out = append( + c, + &[Step::Key("proxies".into())], + "name: p\naddr: 1.2.3.4:1080", + ) + .unwrap(); + assert!(out.contains("# 这台机器上的上游"), "{out}"); + let cfg: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(cfg["providers"].as_sequence().unwrap().len(), 1); + assert_eq!(cfg["proxies"].as_sequence().unwrap().len(), 1); + } + + #[test] + fn a_nested_key_that_does_not_exist_yet_works_too() { + // `clients[].allow` 也是默认不写的 —— 「限制这把密钥能看的模型」 + // 第一次点也走这条路 + let c = "clients:\n - name: a\n key: tw-a\n"; + let out = append( + c, + &[ + Step::Key("clients".into()), + Step::Index(0), + Step::Key("allow".into()), + ], + "claude-haiku-*", + ) + .unwrap(); + let cfg: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap(); + assert_eq!(cfg["clients"][0]["allow"].as_sequence().unwrap().len(), 1); } }