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
This commit is contained in:
DaiChaoXiong 2026-08-09 21:00:04 +08:00
parent 647cf1ba83
commit 7c4c465a18
10 changed files with 300 additions and 443 deletions

View File

@ -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` — 二进制入口
- **内容**:命令行参数解析、组装 agentprovider + 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/<name>/src/*.rs` 的 `#[cfg(test)] mod tests` | 每个公开函数/类型的边界用例 |
| **单元测试** | `crates/<name>/tests/<module>_tests.rs`(按被测模块拆分文件) | 每个公开函数/类型的边界用例 |
| **集成测试** | `crates/<name>/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**:祈使句,小写开头,不加句号
示例:

17
Cargo.lock generated
View File

@ -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]]

View File

@ -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

124
README.md
View File

@ -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: <paste your 智谱 API key>
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=<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.

View File

@ -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"));
}
}

View File

@ -255,54 +255,3 @@ impl<T: Into<JsonValue>> From<Vec<T>> 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());
}
}

View File

@ -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");
}
}

View File

@ -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"));
}

View File

@ -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());
}

View File

@ -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");
}