From 7c4c465a18d09f1e99319bab43a26668315b6e0e Mon Sep 17 00:00:00 2001 From: DaiChaoXiong Date: Sun, 9 Aug 2026 21:00:04 +0800 Subject: [PATCH] chore(workspace): sync manifests, docs and lockfile - drop focus-cli from workspace members; remove tokio from focus-tools and focus-harness (sync std I/O); add focus-json to focus-tools - AGENTS.md: reflect 6-crate layout, focus-cli removal, TUI coming later - lockfile updated for the new dependency graph --- AGENTS.md | 29 +++-- Cargo.lock | 17 +-- Cargo.toml | 1 - README.md | 124 ------------------- crates/focus-json/src/parser.rs | 156 ------------------------ crates/focus-json/src/value.rs | 51 -------- crates/focus-json/src/writer.rs | 81 ------------ crates/focus-json/tests/parser_tests.rs | 155 +++++++++++++++++++++++ crates/focus-json/tests/value_tests.rs | 50 ++++++++ crates/focus-json/tests/writer_tests.rs | 79 ++++++++++++ 10 files changed, 300 insertions(+), 443 deletions(-) create mode 100644 crates/focus-json/tests/parser_tests.rs create mode 100644 crates/focus-json/tests/value_tests.rs create mode 100644 crates/focus-json/tests/writer_tests.rs diff --git a/AGENTS.md b/AGENTS.md index 8830261..5f7d54e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,9 +98,8 @@ let b = self.buf.pop_front(); | `focus-core` | `rt`, `sync`, `macros` | | `focus-transport` | `rt`, `net`, `io-util`, `sync` | | `focus-providers` | `rt`, `sync`, `macros` | -| `focus-tools` | `rt`, `process`, `fs`, `io-util`, `sync` | -| `focus-harness` | `rt`, `fs`, `sync` | -| `focus-cli` | `rt-multi-thread`, `macros`, `signal` | +| `focus-tools` | (无 tokio —— 工具为同步 `std` I/O) | +| `focus-harness` | (无 tokio —— 会话/压缩为纯逻辑 + 同步 `std` I/O) | ### 2.3 Rust 版本 @@ -111,7 +110,8 @@ let b = self.buf.pop_front(); ## 3. Workspace 结构 -细粒度 7-crate 拆分,每个 crate 职责单一、边界清晰、可独立测试。 +细粒度 6-crate 拆分,每个 crate 职责单一、边界清晰、可独立测试。 +(`focus-cli` 已删除;TUI 交互层将在后续里程碑以新 crate 加入,届时再更新本图。) ```text focus/ @@ -124,8 +124,7 @@ focus/ │ ├── focus-transport/ # Transport trait + HTTP/1.1 + TLS + SSE 解析 │ ├── focus-providers/ # provider 实现(Anthropic / OpenAI) │ ├── focus-tools/ # 文件工具(read / write / bash) -│ ├── focus-harness/ # 会话持久化 + 上下文压缩 -│ └── focus-cli/ # 二进制入口 +│ └── focus-harness/ # 会话持久化 + 上下文压缩 + 系统提示模板 ├── docs/ │ └── architecture/ # 架构解读文档(已存在) └── tests/ # workspace 级集成测试(可选) @@ -140,13 +139,11 @@ focus-transport ← focus-json, tokio, rustls, tokio-rustls, rustls-native-ce focus-providers ← focus-core, focus-transport focus-tools ← focus-core, tokio focus-harness ← focus-core, focus-json -focus-cli ← focus-core, focus-transport, focus-providers, focus-tools, focus-harness ``` **规则**: - 依赖只能**向下**流(向叶子 crate)。 -- `focus-core` **绝对禁止**依赖 `focus-transport` / `focus-providers` / `focus-tools` / `focus-harness` / `focus-cli`。核心层必须对 I/O 无感知。 -- 任何 crate 禁止依赖 `focus-cli`。 +- `focus-core` **绝对禁止**依赖 `focus-transport` / `focus-providers` / `focus-tools` / `focus-harness`。核心层必须对 I/O 无感知。 - 禁止循环依赖(Cargo 本身会拒绝,但设计上也不允许语义循环)。 ### 3.2 各 crate 职责 @@ -184,9 +181,11 @@ focus-cli ← focus-core, focus-transport, focus-providers, focus-tools - **会话存储**:参考 pi 的设计——树结构(每条 entry 有 `id` + `parentId`),JSONL 追加写。 - **测试**:会话树的构建/查询/分支、压缩的 token 估算与切点算法。 -#### `focus-cli` — 二进制入口 -- **内容**:命令行参数解析、组装 agent(provider + tools + harness)、REPL 或单次执行模式。 -- **禁止**:把业务逻辑放这里。这里只做**组装**。 +#### `focus-cli` — 已删除(由 TUI 替代) +- `focus-cli` 已彻底删除(不再作为 workspace 成员)。后续里程碑将新增 TUI crate + (届时命名,如 `focus-tui`),承担**组装**职责:provider + tools + harness 的装配、 + base_url / api_key / model 等配置(provider 构造时传入)、动态系统提示注入、 + 自动上下文压缩的执行(harness 产出方案 → 调 provider 摘要 → `Agent::replace_messages` 写回)。 ## 4. 代码规范 @@ -240,9 +239,9 @@ focus-cli ← focus-core, focus-transport, focus-providers, focus-tools | 层级 | 位置 | 内容 | |---|---|---| -| **单元测试** | `crates//src/*.rs` 的 `#[cfg(test)] mod tests` | 每个公开函数/类型的边界用例 | +| **单元测试** | `crates//tests/_tests.rs`(按被测模块拆分文件) | 每个公开函数/类型的边界用例 | | **集成测试** | `crates//tests/*.rs` | 跨模块的端到端(如 agent 循环跑完一轮) | -| **mock 驱动** | `focus-core` 内置 `MockProvider` | 用预设的 `StreamEvent` 序列驱动循环 | +| **mock 驱动** | `crates/focus-core/tests/agent_loop.rs` 内联 `MockProvider` | 用预设的 `StreamEvent` 序列驱动循环;仅当前单一测试文件使用时内联,多文件共享时抽为独立测试工具 crate | ### 5.2 TDD 流程 @@ -287,7 +286,7 @@ Conventional Commits 风格: ``` - **type**:`feat` / `fix` / `test` / `refactor` / `docs` / `chore` / `build` -- **scope**:crate 名(`json` / `core` / `transport` / `providers` / `tools` / `harness` / `cli`)或 `workspace` +- **scope**:crate 名(`json` / `core` / `transport` / `providers` / `tools` / `harness` / `tui`)或 `workspace` - **subject**:祈使句,小写开头,不加句号 示例: diff --git a/Cargo.lock b/Cargo.lock index bbf66bd..bcdc64b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,19 +62,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "focus-cli" -version = "0.1.0" -dependencies = [ - "focus-core", - "focus-harness", - "focus-json", - "focus-providers", - "focus-tools", - "focus-transport", - "tokio", -] - [[package]] name = "focus-core" version = "0.1.0" @@ -89,7 +76,6 @@ version = "0.1.0" dependencies = [ "focus-core", "focus-json", - "tokio", ] [[package]] @@ -101,6 +87,7 @@ name = "focus-providers" version = "0.1.0" dependencies = [ "focus-core", + "focus-harness", "focus-json", "focus-transport", "tokio", @@ -111,7 +98,7 @@ name = "focus-tools" version = "0.1.0" dependencies = [ "focus-core", - "tokio", + "focus-json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b77f209..04cb571 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,6 @@ members = [ "crates/focus-providers", "crates/focus-tools", "crates/focus-harness", - "crates/focus-cli", ] # Shared dependency versions. Member crates reference these with diff --git a/README.md b/README.md index 7f19379..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,124 +0,0 @@ -# focus - -An LLM agent framework in Rust, structured as a Cargo workspace. Built to learn -the architecture of [pi](https://github.com/earendil-works/pi) by reimplementing -its core layers with a minimal dependency surface (tokio + rustls only). - -This milestone focuses on calling **Z.ai / 智谱 GLM coding models** -(`glm-5.2`, domestic endpoint) end-to-end. - -## Quick start - -### 1. Create a config file - -Copy the example (it lists providers but **no API keys** — those go in -`auth.json` via `/login`): - -```bash -mkdir -p ~/.focus -cp examples/config.example.json ~/.focus/config.json -``` - -The config (`~/.focus/config.json`) selects a default provider and lists -available ones (host, model). Override the config dir with `FOCUS_CONFIG_DIR`. - -### 2. Log in (save your API key) - -Run `focus` with no arguments to enter the interactive REPL, then use the -`/login` slash command: - -```bash -./target/debug/focus -> /login zai-coding-cn -Enter API key for zai-coding-cn: -Credentials saved to ~/.focus/auth.json -> /quit -``` - -Your key is stored in `~/.focus/auth.json` (mode `0600`). Log out with -`/logout [provider]`. - -### 3. Run - -```bash -cargo build --workspace -# Interactive REPL (multi-turn conversation): -./target/debug/focus -> 你好 -你好!有什么可以帮你的? -> /quit -# Single prompt (one-shot): -./target/debug/focus "用一句话解释什么是 Rust" -# Or pipe via stdin: -echo "写一个快排" | ./target/debug/focus -# Override the model: -./target/debug/focus --model glm-5.2 "hello" -# One-off API key (in-memory only, not saved): -./target/debug/focus --api-key YOUR_KEY "hello" -# Print the resolved config: -./target/debug/focus --config -``` - -`focus --help` shows all options. - -### REPL slash commands - -Inside the REPL (`focus` with no prompt, on a TTY): - -| Command | Action | -|---|---| -| `/login [provider]` | store an API key in `~/.focus/auth.json` | -| `/logout [provider]` | remove a stored credential | -| `/provider [name]` | show or switch the active provider | -| `/model [name]` | show or switch the active model | -| `/system [text]` | show or set the system prompt | -| `/clear` / `/new` | clear the conversation transcript | -| `/config` | print the resolved provider config | -| `/help` | list commands | -| `/quit` | exit focus | - -Plain text is sent to the model as a prompt; the conversation is multi-turn -(the transcript persists across prompts). - -### Credential resolution order - -When running a prompt, the API key is resolved in this order (highest first): - -1. `--api-key` CLI flag (in-memory only, never persisted) -2. `~/.focus/auth.json` entry (written by `focus login`) -3. `config.json` provider `apiKey` field (if present) - -### 3. Run the real-call smoke test (optional) - -```bash -ZAI_API_KEY= cargo test -p focus-providers --test zai_smoke -- --ignored --nocapture -``` - -## Architecture - -See [`docs/architecture/`](./docs/architecture/) for the pi reference analysis. -The workspace mirrors pi's layering: - -``` -focus-json minimal JSON parser/serializer (zero deps) -focus-core domain types, agent loop, Tool/StreamProvider traits -focus-transport HTTP/1.1, TLS (rustls), SSE parser -focus-providers Z.ai / OpenAI-compat + Anthropic providers -focus-cli config loading + single-prompt runner -focus-tools (placeholder) file/shell tools -focus-harness (placeholder) session persistence + compaction -``` - -Dependency flow is strictly one-way: `core` never depends on transport, -providers, or tools. All I/O crosses trait boundaries. - -## Verification - -```bash -cargo fmt --all --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace -``` - -All 95 unit/integration tests pass with zero clippy warnings. See -[`AGENTS.md`](./AGENTS.md) for the full engineering spec. diff --git a/crates/focus-json/src/parser.rs b/crates/focus-json/src/parser.rs index 9529425..a1e1047 100644 --- a/crates/focus-json/src/parser.rs +++ b/crates/focus-json/src/parser.rs @@ -396,159 +396,3 @@ fn utf8_continuation_len(lead: u8) -> usize { _ => 3, } } - -#[cfg(test)] -mod tests { - use super::*; - - fn ok(input: &str) -> JsonValue { - parse(input).unwrap_or_else(|e| panic!("failed to parse {:?}: {}", input, e)) - } - - fn err(input: &str) -> JsonError { - parse(input).expect_err(&format!("expected parse error for {:?}", input)) - } - - // ---- literals --------------------------------------------------------- - - #[test] - fn parses_literals() { - assert_eq!(ok("true"), JsonValue::Bool(true)); - assert_eq!(ok("false"), JsonValue::Bool(false)); - assert_eq!(ok("null"), JsonValue::Null); - } - - #[test] - fn rejects_truncated_keywords() { - assert!(err("tru").message.contains("expected 'true'")); - assert!(err("nul").message.contains("expected 'null'")); - } - - // ---- numbers ---------------------------------------------------------- - - #[test] - fn parses_numbers() { - assert_eq!(ok("0"), JsonValue::Num(0.0)); - assert_eq!(ok("-0"), JsonValue::Num(0.0)); - assert_eq!(ok("42"), JsonValue::Num(42.0)); - assert_eq!(ok("-42"), JsonValue::Num(-42.0)); - assert_eq!(ok("12.345"), JsonValue::Num(12.345)); - assert_eq!(ok("1e10"), JsonValue::Num(1e10)); - assert_eq!(ok("1.5e-3"), JsonValue::Num(1.5e-3)); - assert_eq!(ok("2.5E+4"), JsonValue::Num(2.5e4)); - } - - #[test] - fn rejects_leading_zeros() { - assert!(err("01").message.contains("leading zero")); - assert!(err("00").message.contains("leading zero")); - } - - #[test] - fn rejects_trailing_dot() { - assert!(err("3.").message.contains("decimal")); - } - - #[test] - fn rejects_empty_exponent() { - assert!(err("1e").message.contains("exponent")); - } - - // ---- strings ---------------------------------------------------------- - - #[test] - fn parses_simple_strings() { - assert_eq!(ok(r#""hello""#), JsonValue::Str("hello".into())); - assert_eq!(ok(r#""""#), JsonValue::Str("".into())); - } - - #[test] - fn parses_string_escapes() { - assert_eq!(ok(r#""a\"b""#), JsonValue::Str("a\"b".into())); - assert_eq!(ok(r#""\n\t\r""#), JsonValue::Str("\n\t\r".into())); - assert_eq!(ok(r#""\\""#), JsonValue::Str("\\".into())); - assert_eq!(ok(r#""\/""#), JsonValue::Str("/".into())); - assert_eq!(ok(r#""\b\f""#), JsonValue::Str("\u{0008}\u{000C}".into())); - } - - #[test] - fn parses_unicode_escapes() { - assert_eq!(ok(r#""\u0041""#), JsonValue::Str("A".into())); - assert_eq!(ok(r#""\u4e2d""#), JsonValue::Str("中".into())); - // U+1F600 😀 的代理对。 - // Surrogate pair for U+1F600 😀 - assert_eq!(ok(r#""\uD83D\uDE00""#), JsonValue::Str("😀".into())); - } - - #[test] - fn rejects_lone_surrogates() { - assert!(err(r#""\uD800""#).message.contains("low surrogate")); - assert!(err(r#""\uDC00""#).message.contains("low surrogate")); - } - - #[test] - fn rejects_unterminated_string() { - assert!(err(r#""abc"#).message.contains("unterminated")); - } - - #[test] - fn rejects_unescaped_control_chars() { - assert!(err("\"a\nb\"").message.contains("control character")); - } - - #[test] - fn handles_utf8_multibyte() { - assert_eq!(ok("\"中文\""), JsonValue::Str("中文".into())); - assert_eq!(ok("\"😀\""), JsonValue::Str("😀".into())); - } - - // ---- objects / arrays ------------------------------------------------- - - #[test] - fn parses_empty_containers() { - assert_eq!(ok("{}"), JsonValue::obj()); - assert_eq!(ok("[]"), JsonValue::arr()); - } - - #[test] - fn parses_nested() { - let v = ok(r#"{"a":[1,2,{"b":true}],"c":null}"#); - assert_eq!(v.get_arr("a").unwrap()[2].get_bool("b"), Some(true)); - assert!(v.get("c").unwrap().is_null()); - } - - #[test] - fn handles_whitespace() { - let v = ok(" { \"x\" : 1 } "); - assert_eq!(v.get_num("x"), Some(1.0)); - } - - // ---- structural errors ------------------------------------------------ - - #[test] - fn rejects_trailing_comma() { - // 尾随逗号会被拒绝,因为下一个字符(']' 或 '}')不是合法的值/成员起始。 - // A trailing comma is rejected because the next char (']' or '}') is - // not a valid value/member start. - assert!(parse("[1,2,]").is_err()); - assert!(parse("{\"a\":1,}").is_err()); - } - - #[test] - fn rejects_trailing_characters() { - let e = err("1 2"); - assert!(e.message.contains("trailing")); - } - - #[test] - fn rejects_empty_input() { - assert!(err("").message.contains("end of input")); - assert!(err(" ").message.contains("end of input")); - } - - #[test] - fn rejects_unclosed_containers() { - assert!(err("[1,2").message.contains("end of input")); - assert!(err("{\"a\":1").message.contains("end of input")); - } -} diff --git a/crates/focus-json/src/value.rs b/crates/focus-json/src/value.rs index fb3b7b6..73e01c9 100644 --- a/crates/focus-json/src/value.rs +++ b/crates/focus-json/src/value.rs @@ -255,54 +255,3 @@ impl> From> for JsonValue { JsonValue::Arr(v.into_iter().map(Into::into).collect()) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn object_preserves_insertion_order() { - let mut o = JsonValue::obj(); - o.insert("b", JsonValue::Num(2.0)).unwrap(); - o.insert("a", JsonValue::Num(1.0)).unwrap(); - o.insert("c", JsonValue::Num(3.0)).unwrap(); - let keys: Vec<_> = o.entries().map(|(k, _)| k.as_str()).collect(); - assert_eq!(keys, vec!["b", "a", "c"]); - } - - #[test] - fn insert_replaces_existing_key() { - let mut o = JsonValue::obj(); - o.insert("x", JsonValue::Num(1.0)).unwrap(); - o.insert("x", JsonValue::Num(2.0)).unwrap(); - assert_eq!(o.get_num("x"), Some(2.0)); - assert_eq!(o.entries().count(), 1); - } - - #[test] - fn accessors_return_none_for_wrong_types() { - let s = JsonValue::Str("hi".into()); - assert_eq!(s.get("x"), None); - assert_eq!(s.get_str("x"), None); - - let mut o = JsonValue::obj(); - o.insert("n", JsonValue::Num(1.0)).unwrap(); - assert_eq!(o.get_str("n"), None); - assert_eq!(o.get_num("n"), Some(1.0)); - assert_eq!(o.get_str("missing"), None); - } - - #[test] - fn insert_into_array_errors() { - let mut a = JsonValue::arr(); - let res = a.insert("x", JsonValue::Null); - assert!(res.is_err()); - } - - #[test] - fn push_into_object_errors() { - let mut o = JsonValue::obj(); - let res = o.push(JsonValue::Null); - assert!(res.is_err()); - } -} diff --git a/crates/focus-json/src/writer.rs b/crates/focus-json/src/writer.rs index a1c4f3e..9b97730 100644 --- a/crates/focus-json/src/writer.rs +++ b/crates/focus-json/src/writer.rs @@ -90,84 +90,3 @@ fn write_string(s: &str, out: &mut String) { } out.push('"'); } - -#[cfg(test)] -mod tests { - use super::*; - use crate::parse; - - fn roundtrip(input: &str) { - let parsed = parse(input).unwrap_or_else(|e| panic!("parse failed for {:?}: {}", input, e)); - let serialized = to_string(&parsed); - let reparsed = parse(&serialized).expect("reparse failed"); - assert_eq!(parsed, reparsed, "roundtrip mismatch for {:?}", input); - } - - #[test] - fn serializes_primitives() { - assert_eq!(to_string(&JsonValue::Null), "null"); - assert_eq!(to_string(&JsonValue::Bool(true)), "true"); - assert_eq!(to_string(&JsonValue::Bool(false)), "false"); - assert_eq!(to_string(&JsonValue::Str("hi".into())), r#""hi""#); - } - - #[test] - fn serializes_integers_without_fraction() { - assert_eq!(to_string(&JsonValue::Num(3.0)), "3"); - assert_eq!(to_string(&JsonValue::Num(-42.0)), "-42"); - assert_eq!(to_string(&JsonValue::Num(0.0)), "0"); - } - - #[test] - fn serializes_fractional_numbers() { - assert_eq!(to_string(&JsonValue::Num(12.345)), "12.345"); - } - - #[test] - fn escapes_strings() { - let s = JsonValue::Str("a\"b\nc".into()); - assert_eq!(to_string(&s), r#""a\"b\nc""#); - } - - #[test] - fn escapes_control_chars_as_unicode() { - let s = JsonValue::Str("\u{0001}".into()); - assert_eq!(to_string(&s), r#""\u0001""#); - } - - #[test] - fn serializes_arrays_and_objects() { - let mut o = JsonValue::obj(); - o.insert("a", JsonValue::Num(1.0)).unwrap(); - o.insert( - "b", - JsonValue::Arr(vec![JsonValue::Bool(true), JsonValue::Null]), - ) - .unwrap(); - assert_eq!(to_string(&o), r#"{"a":1,"b":[true,null]}"#); - } - - #[test] - fn roundtrips_various() { - roundtrip(r#"{"name":"focus","nums":[1,2,3],"nested":{"deep":true}}"#); - roundtrip(r#""escape: \\ \" \n \t""#); - roundtrip("12.345159"); - roundtrip("-0"); - roundtrip("[]"); - roundtrip("{}"); - } - - #[test] - fn handles_unicode_in_strings() { - let s = JsonValue::Str("中文😀".into()); - let serialized = to_string(&s); - assert_eq!(serialized, r#""中文😀""#); - assert_eq!(parse(&serialized).unwrap(), s); - } - - #[test] - fn non_finite_becomes_null() { - assert_eq!(to_string(&JsonValue::Num(f64::NAN)), "null"); - assert_eq!(to_string(&JsonValue::Num(f64::INFINITY)), "null"); - } -} diff --git a/crates/focus-json/tests/parser_tests.rs b/crates/focus-json/tests/parser_tests.rs new file mode 100644 index 0000000..7807443 --- /dev/null +++ b/crates/focus-json/tests/parser_tests.rs @@ -0,0 +1,155 @@ +//! parser 模块的集成测试。 +//! Integration tests for the parser module. + +use focus_json::{parse, JsonError, JsonValue}; + +fn ok(input: &str) -> JsonValue { + parse(input).unwrap_or_else(|e| panic!("failed to parse {:?}: {}", input, e)) +} + +fn err(input: &str) -> JsonError { + parse(input).expect_err(&format!("expected parse error for {:?}", input)) +} + +// ---- literals --------------------------------------------------------- + +#[test] +fn parses_literals() { + assert_eq!(ok("true"), JsonValue::Bool(true)); + assert_eq!(ok("false"), JsonValue::Bool(false)); + assert_eq!(ok("null"), JsonValue::Null); +} + +#[test] +fn rejects_truncated_keywords() { + assert!(err("tru").message.contains("expected 'true'")); + assert!(err("nul").message.contains("expected 'null'")); +} + +// ---- numbers ---------------------------------------------------------- + +#[test] +fn parses_numbers() { + assert_eq!(ok("0"), JsonValue::Num(0.0)); + assert_eq!(ok("-0"), JsonValue::Num(0.0)); + assert_eq!(ok("42"), JsonValue::Num(42.0)); + assert_eq!(ok("-42"), JsonValue::Num(-42.0)); + assert_eq!(ok("12.345"), JsonValue::Num(12.345)); + assert_eq!(ok("1e10"), JsonValue::Num(1e10)); + assert_eq!(ok("1.5e-3"), JsonValue::Num(1.5e-3)); + assert_eq!(ok("2.5E+4"), JsonValue::Num(2.5e4)); +} + +#[test] +fn rejects_leading_zeros() { + assert!(err("01").message.contains("leading zero")); + assert!(err("00").message.contains("leading zero")); +} + +#[test] +fn rejects_trailing_dot() { + assert!(err("3.").message.contains("decimal")); +} + +#[test] +fn rejects_empty_exponent() { + assert!(err("1e").message.contains("exponent")); +} + +// ---- strings ---------------------------------------------------------- + +#[test] +fn parses_simple_strings() { + assert_eq!(ok(r#""hello""#), JsonValue::Str("hello".into())); + assert_eq!(ok(r#""""#), JsonValue::Str("".into())); +} + +#[test] +fn parses_string_escapes() { + assert_eq!(ok(r#""a\"b""#), JsonValue::Str("a\"b".into())); + assert_eq!(ok(r#""\n\t\r""#), JsonValue::Str("\n\t\r".into())); + assert_eq!(ok(r#""\\""#), JsonValue::Str("\\".into())); + assert_eq!(ok(r#""\/""#), JsonValue::Str("/".into())); + assert_eq!(ok(r#""\b\f""#), JsonValue::Str("\u{0008}\u{000C}".into())); +} + +#[test] +fn parses_unicode_escapes() { + assert_eq!(ok(r#""\u0041""#), JsonValue::Str("A".into())); + assert_eq!(ok(r#""\u4e2d""#), JsonValue::Str("中".into())); + // U+1F600 😀 的代理对。 + // Surrogate pair for U+1F600 😀 + assert_eq!(ok(r#""\uD83D\uDE00""#), JsonValue::Str("😀".into())); +} + +#[test] +fn rejects_lone_surrogates() { + assert!(err(r#""\uD800""#).message.contains("low surrogate")); + assert!(err(r#""\uDC00""#).message.contains("low surrogate")); +} + +#[test] +fn rejects_unterminated_string() { + assert!(err(r#""abc"#).message.contains("unterminated")); +} + +#[test] +fn rejects_unescaped_control_chars() { + assert!(err("\"a\nb\"").message.contains("control character")); +} + +#[test] +fn handles_utf8_multibyte() { + assert_eq!(ok("\"中文\""), JsonValue::Str("中文".into())); + assert_eq!(ok("\"😀\""), JsonValue::Str("😀".into())); +} + +// ---- objects / arrays ------------------------------------------------- + +#[test] +fn parses_empty_containers() { + assert_eq!(ok("{}"), JsonValue::obj()); + assert_eq!(ok("[]"), JsonValue::arr()); +} + +#[test] +fn parses_nested() { + let v = ok(r#"{"a":[1,2,{"b":true}],"c":null}"#); + assert_eq!(v.get_arr("a").unwrap()[2].get_bool("b"), Some(true)); + assert!(v.get("c").unwrap().is_null()); +} + +#[test] +fn handles_whitespace() { + let v = ok(" { \"x\" : 1 } "); + assert_eq!(v.get_num("x"), Some(1.0)); +} + +// ---- structural errors ------------------------------------------------ + +#[test] +fn rejects_trailing_comma() { + // 尾随逗号会被拒绝,因为下一个字符(']' 或 '}')不是合法的值/成员起始。 + // A trailing comma is rejected because the next char (']' or '}') is + // not a valid value/member start. + assert!(parse("[1,2,]").is_err()); + assert!(parse("{\"a\":1,}").is_err()); +} + +#[test] +fn rejects_trailing_characters() { + let e = err("1 2"); + assert!(e.message.contains("trailing")); +} + +#[test] +fn rejects_empty_input() { + assert!(err("").message.contains("end of input")); + assert!(err(" ").message.contains("end of input")); +} + +#[test] +fn rejects_unclosed_containers() { + assert!(err("[1,2").message.contains("end of input")); + assert!(err("{\"a\":1").message.contains("end of input")); +} diff --git a/crates/focus-json/tests/value_tests.rs b/crates/focus-json/tests/value_tests.rs new file mode 100644 index 0000000..e2ec002 --- /dev/null +++ b/crates/focus-json/tests/value_tests.rs @@ -0,0 +1,50 @@ +//! value 模块(`JsonValue` DOM)的集成测试。 +//! Integration tests for the value module (`JsonValue` DOM). + +use focus_json::JsonValue; + +#[test] +fn object_preserves_insertion_order() { + let mut o = JsonValue::obj(); + o.insert("b", JsonValue::Num(2.0)).unwrap(); + o.insert("a", JsonValue::Num(1.0)).unwrap(); + o.insert("c", JsonValue::Num(3.0)).unwrap(); + let keys: Vec<_> = o.entries().map(|(k, _)| k.as_str()).collect(); + assert_eq!(keys, vec!["b", "a", "c"]); +} + +#[test] +fn insert_replaces_existing_key() { + let mut o = JsonValue::obj(); + o.insert("x", JsonValue::Num(1.0)).unwrap(); + o.insert("x", JsonValue::Num(2.0)).unwrap(); + assert_eq!(o.get_num("x"), Some(2.0)); + assert_eq!(o.entries().count(), 1); +} + +#[test] +fn accessors_return_none_for_wrong_types() { + let s = JsonValue::Str("hi".into()); + assert_eq!(s.get("x"), None); + assert_eq!(s.get_str("x"), None); + + let mut o = JsonValue::obj(); + o.insert("n", JsonValue::Num(1.0)).unwrap(); + assert_eq!(o.get_str("n"), None); + assert_eq!(o.get_num("n"), Some(1.0)); + assert_eq!(o.get_str("missing"), None); +} + +#[test] +fn insert_into_array_errors() { + let mut a = JsonValue::arr(); + let res = a.insert("x", JsonValue::Null); + assert!(res.is_err()); +} + +#[test] +fn push_into_object_errors() { + let mut o = JsonValue::obj(); + let res = o.push(JsonValue::Null); + assert!(res.is_err()); +} diff --git a/crates/focus-json/tests/writer_tests.rs b/crates/focus-json/tests/writer_tests.rs new file mode 100644 index 0000000..52526c9 --- /dev/null +++ b/crates/focus-json/tests/writer_tests.rs @@ -0,0 +1,79 @@ +//! writer 模块(JSON 序列化)的集成测试。 +//! Integration tests for the writer module (JSON serialization). + +use focus_json::{parse, to_string, JsonValue}; + +fn roundtrip(input: &str) { + let parsed = parse(input).unwrap_or_else(|e| panic!("parse failed for {:?}: {}", input, e)); + let serialized = to_string(&parsed); + let reparsed = parse(&serialized).expect("reparse failed"); + assert_eq!(parsed, reparsed, "roundtrip mismatch for {:?}", input); +} + +#[test] +fn serializes_primitives() { + assert_eq!(to_string(&JsonValue::Null), "null"); + assert_eq!(to_string(&JsonValue::Bool(true)), "true"); + assert_eq!(to_string(&JsonValue::Bool(false)), "false"); + assert_eq!(to_string(&JsonValue::Str("hi".into())), r#""hi""#); +} + +#[test] +fn serializes_integers_without_fraction() { + assert_eq!(to_string(&JsonValue::Num(3.0)), "3"); + assert_eq!(to_string(&JsonValue::Num(-42.0)), "-42"); + assert_eq!(to_string(&JsonValue::Num(0.0)), "0"); +} + +#[test] +fn serializes_fractional_numbers() { + assert_eq!(to_string(&JsonValue::Num(12.345)), "12.345"); +} + +#[test] +fn escapes_strings() { + let s = JsonValue::Str("a\"b\nc".into()); + assert_eq!(to_string(&s), r#""a\"b\nc""#); +} + +#[test] +fn escapes_control_chars_as_unicode() { + let s = JsonValue::Str("\u{0001}".into()); + assert_eq!(to_string(&s), r#""\u0001""#); +} + +#[test] +fn serializes_arrays_and_objects() { + let mut o = JsonValue::obj(); + o.insert("a", JsonValue::Num(1.0)).unwrap(); + o.insert( + "b", + JsonValue::Arr(vec![JsonValue::Bool(true), JsonValue::Null]), + ) + .unwrap(); + assert_eq!(to_string(&o), r#"{"a":1,"b":[true,null]}"#); +} + +#[test] +fn roundtrips_various() { + roundtrip(r#"{"name":"focus","nums":[1,2,3],"nested":{"deep":true}}"#); + roundtrip(r#""escape: \\ \" \n \t""#); + roundtrip("12.345159"); + roundtrip("-0"); + roundtrip("[]"); + roundtrip("{}"); +} + +#[test] +fn handles_unicode_in_strings() { + let s = JsonValue::Str("中文😀".into()); + let serialized = to_string(&s); + assert_eq!(serialized, r#""中文😀""#); + assert_eq!(parse(&serialized).unwrap(), s); +} + +#[test] +fn non_finite_becomes_null() { + assert_eq!(to_string(&JsonValue::Num(f64::NAN)), "null"); + assert_eq!(to_string(&JsonValue::Num(f64::INFINITY)), "null"); +}