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:
parent
647cf1ba83
commit
7c4c465a18
29
AGENTS.md
29
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/<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**:祈使句,小写开头,不加句号
|
||||
|
||||
示例:
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
124
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: <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.
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
Loading…
Reference in New Issue