diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..bde798d --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +wiki = "run --quiet -p xtask --" diff --git a/.codex/skills/criew-development/SKILL.md b/.codex/skills/criew-development/SKILL.md new file mode 100644 index 0000000..946cc0e --- /dev/null +++ b/.codex/skills/criew-development/SKILL.md @@ -0,0 +1,102 @@ +--- +name: criew-development +description: Repository-specific workflow and coding rules for the CRIEW codebase. Use when modifying or reviewing CRIEW Rust code, TUI behavior, sync/IMAP/reply/patch workflows, migrations, tests, docs, or config, when code or workflow changes may require syncing `docs/wiki/`, and whenever the task must follow `docs/development/code-guildline.md` or `docs/development/code-guildline-cn.md`. +--- + +# Criew Development + +## Overview + +Follow the CRIEW repository's coding rules, architecture boundaries, and validation workflow. +Read the repository docs first, then make focused changes that preserve the current mail, patch, and reply behavior. + +## Start With The Project Docs + +Read the relevant repository docs before editing code. + +- Read `docs/development/code-guildline.md` first for the canonical coding rules. +- Read `docs/development/code-guildline-cn.md` when the user works in Chinese or explicitly asks for the Chinese guideline. +- Read `README.md` or `README-zh.md` before changing user-visible behavior, install steps, naming, or operator workflow. +- Read `docs/architecture/design.md` before changing architecture, module boundaries, sync flow, or data-model assumptions. +- Read `docs/specs/reply-format-spec.md` before changing reply composition, quoting, headers, or send flow. +- Read `docs/reference/config.example.toml` before changing config keys, defaults, or path semantics. + +Treat `docs/development/code-guildline.md` as the priority source when a local convention is unclear. +Then follow tool-enforced rules and the existing style in the touched module. + +## Keep The CRIEW Boundaries + +Use the existing layer split unless the task explicitly asks for architectural rework. + +- Keep `src/app/` focused on use-case orchestration and CLI/TUI entry workflows. +- Keep `src/domain/` focused on core models and business meaning. +- Keep `src/infra/` focused on storage, config, IMAP, sendmail, `b4`, logging, and other external integrations. +- Keep `src/ui/` focused on TUI state, rendering, input handling, and UI tests. +- Keep database changes in `migrations/` and align schema changes with the Rust storage code in the same change. +- Treat `vendor/b4/` as vendored third-party code; avoid editing it unless the task explicitly targets that dependency. + +Preserve the current naming set from the README: +use `CRIEW` for the repository name and `criew` for the crate, CLI, config file, runtime directory, and environment variables. +Do not reintroduce legacy Courier naming. + +## Apply The Coding Rules Directly + +Implement changes in the style required by `docs/development/code-guildline.md`. + +- Prefer descriptive, behavior-accurate names. +- Encode units in names when the type system does not. +- Use assertion-style names for booleans. +- Keep one primary concept per file; split large files early. +- Keep code readable from top to bottom, with high-level flow before helper detail. +- Keep functions small and focused; reduce nesting with early returns. +- Avoid ambiguous boolean arguments; prefer enums or small config structs. +- Express invariants in types when practical. +- Propagate fallible paths with `?`. +- Keep visibility and lint suppression as narrow as possible. +- Add `// SAFETY:` comments for every `unsafe` block and document `# Safety` on unsafe APIs. +- Write comments only when they explain why, constraints, or design tradeoffs. + +## Use A Repository-Focused Workflow + +Follow this order unless the task is trivial. + +1. Locate the relevant module with `rg` and read the surrounding code before proposing changes. +2. Confirm the user-visible behavior from the README or a spec doc when the change affects sync, reply, patch apply/export, config, or startup flow. +3. Keep the change focused on one logical topic. +4. Add or update regression tests when fixing a bug or changing observable behavior. +5. Update docs when behavior, commands, config, or workflow changed, and explicitly decide whether `README.md`, `README-zh.md`, or `docs/wiki/` need updates. + +Prefer updating both `README.md` and `README-zh.md` when the change affects user-facing usage or setup. +When operator workflow, troubleshooting guidance, configuration, or other stable user-facing behavior changed, review the relevant `docs/wiki/` pages and update them in the same task unless the user explicitly scopes wiki work out. Use `criew-wiki-authoring` for the wiki edit itself. +Keep PR-sized changes focused and commit subjects aligned with the repository's Conventional Commit prefixes: +`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. +When asked to create a commit in this repository, use `git commit -s`. +The repository hook at `.githooks/commit-msg` and CI both validate the `Signed-off-by:` trailer. +Simple commits may use only the subject line. +For larger commits, add a body with bullet points that summarize the main changes. + +## Run The Required Validation + +Run the full repository validation set after non-trivial changes when the environment allows it. + +- Keep overall repository coverage at or above 70%. +- Keep newly added code at or above 80% coverage. +- Keep critical-component coverage at or above 85% for critical workflow code that the current change directly touches or materially expands. Treat sync, reply, patch, and other core user-facing workflow paths in scope as critical unless the task clearly falls outside those paths. +- Do not add tests only to tick uncovered lines. Coverage work must defend a real behavior, regression, failure mode, or workflow contract that matters to users or operators. +- Prefer behavior-driven regression tests over line-chasing. If a remaining gap is not worth a brittle or artificial test, leave it uncovered and report the tradeoff clearly. +- Use `./scripts/check-coverage.sh` plus the generated summary report to verify the thresholds above, and review file-level or workflow-level reports for the in-scope critical components. Treat threshold regressions as incomplete work. + +- `cargo fmt --all -- --check` +- `cargo clippy --all-targets --all-features -- -D warnings` +- `cargo test --all-targets --all-features` +- `./scripts/check-coverage.sh` + +If time or environment constraints prevent a command from running, report that clearly and avoid claiming full verification. + +## Pull The Right Context On Demand + +Load extra repository docs only when the task needs them. + +- Read `docs/milestones/mvp-milestones.md` and `docs/milestones/reply-mvp-milestones.md` for historical intent or rollout sequencing. +- Read `docs/milestones/vim-mvp-milestones.md` and `docs/specs/code-preview-vim-prototype.md` for Vim-mode and code-preview behavior. +- Read `src/ui/tui/tests.rs` before extending TUI behavior that already has test coverage. diff --git a/.codex/skills/criew-development/agents/openai.yaml b/.codex/skills/criew-development/agents/openai.yaml new file mode 100644 index 0000000..8dc90d9 --- /dev/null +++ b/.codex/skills/criew-development/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CRIEW Development" + short_description: "CRIEW repo workflow, coding rules, and doc sync checks" + default_prompt: "Use $criew-development to make a focused CRIEW code or config change and assess required README/wiki updates." diff --git a/.codex/skills/criew-wiki-authoring/SKILL.md b/.codex/skills/criew-wiki-authoring/SKILL.md new file mode 100644 index 0000000..69aad76 --- /dev/null +++ b/.codex/skills/criew-wiki-authoring/SKILL.md @@ -0,0 +1,84 @@ +--- +name: criew-wiki-authoring +description: Write and revise pages under `docs/wiki` for the CRIEW GitHub wiki and its MkDocs-backed GitHub Pages site. Use when Codex needs to create, expand, reorganize, or review CRIEW wiki pages such as `Home.md`, `_Sidebar.md`, `_Footer.md`, or topic pages, when code or workflow changes require a wiki sync pass, and when the output or workflow must follow GitHub wiki conventions, the local MkDocs pipeline, and a pragmatic, kernel-documentation writing style. +--- + +# Criew Wiki Authoring + +## Overview + +Write CRIEW wiki pages as maintainer documentation. +Treat `docs/wiki/` as the source GitHub wiki repository, and treat the published website as a derived MkDocs build driven from the main `CRIEW` repository. +Keep the source compatible with both GitHub wiki rendering and the local Pages build pipeline, and prefer a direct, technical style over narrative or marketing copy. +Use this skill not only for standalone wiki work, but also when a code or config change means the existing wiki may now be stale. + +## Follow This Workflow + +1. Confirm the source of truth before writing. +- Read the code, README, spec, config example, or architecture note that defines the behavior. +- Prefer `README.md`, `README-zh.md`, `docs/architecture/design.md`, `docs/specs/`, and `docs/reference/config.example.toml` depending on the topic. +- Do not invent commands, defaults, limitations, or workflows. +- When paired with a code change, identify which existing wiki pages describe the changed behavior and either update them or explicitly confirm that no wiki page is affected. + +2. Check the wiki context. +- List the existing pages in `docs/wiki/` before creating or renaming a page. +- Treat `docs/wiki/` as a separate Git repository. Use `git -C docs/wiki ...` when checking history or status. +- Read `references/publish-workflow.md` before changing local preview, staging, or deployment behavior. +- Keep `Home.md` as the landing page and update it when a new top-level page changes how readers enter the wiki. +- Read `references/style-guide.md` for page rules. +- Read `references/page-patterns.md` when choosing a page shape. + +3. Choose the smallest page that fits the job. +- Use a short reference page for stable facts and concepts. +- Use a workflow page for operator tasks with prerequisites and ordered steps. +- Use a troubleshooting page for symptoms, likely causes, and recovery actions. +- Split a page once it starts carrying more than one primary purpose. + +4. Draft in GitHub wiki form. +- Use page names and filenames that are stable, literal, and easy to scan. +- Prefer normal Markdown links such as `[Configuration](Configuration.md)` because they work in both GitHub wiki and MkDocs. +- Treat `[[Page Name]]` and `[[Page Name|Link text]]` as compatibility syntax only. The local staging script rewrites them for MkDocs, but new content should not depend on that rewrite when a Markdown link is clear enough. +- Use full GitHub URLs when linking from the wiki back into the main CRIEW repository, because the wiki is a separate repository. +- Add `_Sidebar.md` or `_Footer.md` only when shared navigation or repeated context is materially useful. + +5. Check the publish path before finishing. +- Local copy lint goes through `./scripts/wiki-lint.sh`. +- Local preview and local build go through `./scripts/wiki-site.sh serve` and `./scripts/wiki-site.sh build`. +- The staging step copies `docs/wiki/` into `target/wiki-docs/`, turns `Home.md` into the MkDocs `index.md`, excludes special wiki-only files such as `_Sidebar.md` and `_Footer.md`, and normalizes source-only links. +- The published website is built from `mkdocs.yml` and deployed by `.github/workflows/wiki-pages.yml`. +- The lint script requires `autocorrect`. If it is missing, the script downloads a local copy into `target/wiki-venv/bin/` and then reruns the check. +- Treat a clean `./scripts/wiki-lint.sh` result as part of done for every wiki page this skill creates or edits. +- Because `docs/wiki` is a submodule, the main repository deploys the pinned wiki commit, not the remote wiki repository's latest HEAD. A new wiki commit reaches GitHub Pages only after the CRIEW repository updates the `docs/wiki` submodule pointer. +- If you commit inside `docs/wiki`, explicitly ask the user whether the main `CRIEW` repository should also commit the updated `docs/wiki` submodule pointer. Do not assume they want both commits automatically. + +6. Draft in kernel-documentation style. +- Lead with scope and purpose. +- Prefer active voice, short sentences, and concrete nouns. +- State prerequisites, constraints, and side effects before optional detail. +- Prefer commands, paths, config keys, and observable outcomes over abstract explanation. +- Avoid filler, advocacy, roadmap prose, and vague adjectives such as `simple`, `easy`, `powerful`, or `seamless`. +- Name conditions directly when behavior depends on mode, configuration, or state. + +7. Review before finishing. +- Verify commands, file paths, environment variables, config keys, and feature names against the repository. +- Keep language consistent within the page. Match the page's existing language unless the user requests a change. +- Check that headings are informative, examples are minimal, and links resolve to the intended wiki page or repository URL. +- Run `./scripts/wiki-lint.sh` for every text change and revise the page until it passes. If the environment prevents the check, report that explicitly instead of assuming the page is clean. +- When page structure or links changed, run `./scripts/wiki-site.sh build` if the environment allows it. + +## Apply These Page Rules + +- Start each page with a short paragraph that states what the page covers and when to use it. +- Prefer headings such as `Prerequisites`, `Workflow`, `Configuration`, `Troubleshooting`, `Limitations`, and `See also` when they fit the content. +- Keep heading depth shallow unless the page genuinely needs more structure. +- Keep lists parallel and action-oriented. +- Use fenced code blocks for commands, config fragments, and sample output. +- Keep examples minimal but real. +- Write copy that passes `autocorrect`; treat lint failures as defects that need wording changes before the page is considered complete. +- Preserve CRIEW naming: use `CRIEW` for the project and `criew` for the binary, crate, config file, runtime directory, and environment variables. + +## Use The References + +- `references/style-guide.md`: GitHub wiki constraints, linking rules, and writing expectations. +- `references/page-patterns.md`: Reusable page skeletons for `Home.md`, workflow pages, reference pages, and troubleshooting pages. +- `references/publish-workflow.md`: The `docs/wiki -> MkDocs -> GitHub Pages` pipeline, local preview commands, and submodule publication constraints. diff --git a/.codex/skills/criew-wiki-authoring/agents/openai.yaml b/.codex/skills/criew-wiki-authoring/agents/openai.yaml new file mode 100644 index 0000000..bb586d0 --- /dev/null +++ b/.codex/skills/criew-wiki-authoring/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CRIEW Wiki" + short_description: "CRIEW wiki authoring and sync guide" + default_prompt: "Use $criew-wiki-authoring to draft, revise, or sync pages under docs/wiki after CRIEW behavior or workflow changes." diff --git a/.codex/skills/criew-wiki-authoring/references/page-patterns.md b/.codex/skills/criew-wiki-authoring/references/page-patterns.md new file mode 100644 index 0000000..c0a9533 --- /dev/null +++ b/.codex/skills/criew-wiki-authoring/references/page-patterns.md @@ -0,0 +1,122 @@ +# CRIEW Wiki Page Patterns + +## Home Page + +Use this when editing `Home.md`. + +```md +# CRIEW Wiki + +Briefly state what the wiki covers and who should read it. + +## Start here + +- [Install and setup](Install-and-Setup.md) +- [Configuration](Configuration.md) +- [Patch workflow](Patch-Workflow.md) +- [Troubleshooting](Troubleshooting.md) + +## Topic map + +### User workflows + +- [Sync mail](Sync-Mail.md) +- [Review and reply](Review-and-Reply.md) +- [Apply patches](Apply-Patches.md) + +### Reference + +- [Configuration reference](Configuration-Reference.md) +- [Key concepts](Key-Concepts.md) + +### Development + +- [CRIEW repository](https://github.com/ChenMiaoi/CRIEW) +- [Contributor notes](Contributor-Notes.md) +``` + +## Workflow Page + +Use this for task-oriented pages. + +```md +# Page Title + +State the task, operator, and expected outcome. + +## Prerequisites + +- Required environment, files, or state. + +## Workflow + +1. Run the first command or enter the first screen. +2. Describe the expected result. +3. Continue with the next observable step. + +## Verify the result + +- State what success looks like. + +## Troubleshooting + +- Common failure mode and the next action. + +## See also + +- [Related page](Related-Page.md) +``` + +## Reference Page + +Use this for concepts, settings, or stable behavior. + +```md +# Page Title + +State the scope and the object being described. + +## Overview + +Brief definition or behavioral summary. + +## Fields or options + +- `key_name`: State meaning, accepted values, and important default behavior. + +## Constraints + +- State limits, invariants, or caveats. + +## Related behavior + +- Explain how this topic affects adjacent workflows. +``` + +## Troubleshooting Page + +Use this for failure-driven guidance. + +```md +# Page Title + +State the symptom or failure class this page covers. + +## Symptoms + +- Observable error, UI state, or command output. + +## Likely causes + +- Most common cause first. + +## Recovery steps + +1. Verify the suspected cause. +2. Apply the fix. +3. Confirm recovery. + +## Escalation + +- State when the user should stop and inspect code, logs, or configuration in more detail. +``` diff --git a/.codex/skills/criew-wiki-authoring/references/publish-workflow.md b/.codex/skills/criew-wiki-authoring/references/publish-workflow.md new file mode 100644 index 0000000..a20fff5 --- /dev/null +++ b/.codex/skills/criew-wiki-authoring/references/publish-workflow.md @@ -0,0 +1,43 @@ +# CRIEW Wiki Publish Workflow + +## Pipeline + +The publication path is: + +`docs/wiki` source repo -> `scripts/prepare-wiki-site.py` staging -> `mkdocs.yml` build -> `.github/workflows/wiki-pages.yml` deploy -> GitHub Pages + +This means the source of truth stays in the GitHub wiki repository, while the published website is generated from the main `CRIEW` repository. + +## Local Commands + +- `./scripts/wiki-lint.sh`: Check wiki copy with `autocorrect`. If the command is missing, the script downloads a local copy into `target/wiki-venv/bin/`. +- `./scripts/wiki-site.sh prepare`: Stage the wiki into `target/wiki-docs` without installing MkDocs. +- `./scripts/wiki-site.sh serve`: Stage the wiki, install MkDocs into `target/wiki-venv`, and start a local preview server on `0.0.0.0:8000` by default. Override the bind address with `CRIEW_WIKI_DEV_ADDR`. +- `./scripts/wiki-site.sh build`: Stage the wiki, install MkDocs into `target/wiki-venv`, and build `target/wiki-site`. + +Treat `./scripts/wiki-lint.sh` as mandatory for wiki content changes. +Do not consider a new or edited wiki page complete until the copy passes the lint check, unless the environment prevents running the command and that limitation is reported. + +## What The Staging Step Does + +- Copy published wiki content from `docs/wiki/` into `target/wiki-docs/`. +- Rewrite `Home.md` into the MkDocs landing page `index.md`. +- Skip GitHub wiki helper files such as `_Sidebar.md` and `_Footer.md`. +- Rewrite legacy `[[Page]]` wiki links into Markdown links that MkDocs can resolve. + +## CI And Deployment + +- `.github/workflows/wiki-pages.yml` runs `./scripts/wiki-lint.sh` before it builds the site on pull requests that touch the wiki publish pipeline. +- The same workflow deploys to GitHub Pages on pushes to `develop`. +- The workflow uploads the rendered site from `target/wiki-site`. +- This CI step is the enforcement point for the same copy rules that the skill expects locally. Write pages to pass the lint check before handing the change off. + +## Submodule Constraint + +`docs/wiki` is a Git submodule in the main repository. +That means the GitHub Pages deployment uses the wiki commit pinned by the main `CRIEW` repository. +If the standalone wiki repository advances but the main repository does not update the `docs/wiki` gitlink, GitHub Pages will keep publishing the older pinned wiki snapshot. + +When a wiki-only commit is created, +the agent should not automatically create the matching main-repository commit. +It should ask the user whether the current `CRIEW` repository also needs a commit that updates the pinned `docs/wiki` submodule pointer. diff --git a/.codex/skills/criew-wiki-authoring/references/style-guide.md b/.codex/skills/criew-wiki-authoring/references/style-guide.md new file mode 100644 index 0000000..e410a57 --- /dev/null +++ b/.codex/skills/criew-wiki-authoring/references/style-guide.md @@ -0,0 +1,55 @@ +# CRIEW Wiki Style Guide + +## GitHub Wiki Constraints + +- Treat `docs/wiki/` as the source wiki repository, not as a normal docs folder inside the main repo. +- Treat the published website as a derived MkDocs build from the main `CRIEW` repository. +- Keep `Home.md` as the landing page. +- Use `_Sidebar.md` for shared navigation only when the page set is large enough to justify it. +- Use `_Footer.md` only for short, repeated context such as related links or maintenance notes. +- Prefer Markdown pages unless the existing page already uses another supported markup. +- Avoid syntax that GitHub wikis do not support well, such as definition lists, table-of-contents directives, transclusion, or heavy indentation tricks. + +## Page Naming + +- Use stable, descriptive page names. +- Keep filenames portable. Avoid characters that are awkward in URLs or file systems. +- Match the filename to the page topic instead of using vague names such as `Notes` or `Misc`. +- Rename pages only when the old name is clearly wrong or blocks navigation. + +## Linking Rules + +- Prefer standard Markdown links for internal page links, for example `[Configuration](Configuration.md)`. +- Keep `[[Page Name]]` or `[[Page Name|Link text]]` only as compatibility syntax for older pages. `scripts/prepare-wiki-site.py` rewrites them during MkDocs staging. +- Use normal Markdown links for external URLs. +- Use full GitHub URLs when linking from the wiki to files, directories, issues, or pull requests in the main `CRIEW` repository. +- Do not rely on repository-relative paths from the wiki back to the main repo. The wiki is a separate repository, so those links are easy to break. +- Keep link text explicit. Prefer the destination's role over generic text such as `here` or `more`. + +## Writing Style + +- State what the page is for in the opening paragraph. +- Prefer direct statements over explanation-first prose. +- Put prerequisites before steps. +- Put limitations and side effects near the action that triggers them. +- Prefer one fact per sentence when the topic is operational. +- Use active voice and imperative steps for procedures. +- Avoid marketing tone, vision statements, and rhetorical fillers. +- Avoid unexplained claims such as `fast`, `simple`, `robust`, or `advanced`. +- Keep wording compatible with `autocorrect`. If lint flags a phrase, rewrite the sentence instead of suppressing the check. + +## Evidence And Accuracy + +- Verify commands, config keys, and paths against the current repository state. +- Cite the real source in prose when the behavior comes from a spec, config example, or code path. +- Call out assumptions explicitly if the repository does not prove them. +- Prefer a small verified page over a broad speculative page. +- Run `./scripts/wiki-lint.sh` after every copy edit and treat a clean result as required for completion. If the environment prevents the check, report that gap explicitly. + +## Page Maintenance + +- Update `Home.md` when a new page changes the top-level information architecture. +- Merge or split pages when navigation starts to hide the main task flow. +- Keep the lint path working: `./scripts/wiki-lint.sh` installs `autocorrect` on demand when the binary is missing. +- Run `./scripts/wiki-site.sh build` after link or structure changes when the environment allows it. +- Remove stale TODO text before finishing. diff --git a/.githooks/README.md b/.githooks/README.md index 40efab4..fb84355 100644 --- a/.githooks/README.md +++ b/.githooks/README.md @@ -3,7 +3,8 @@ 本目录保存 CRIEW 仓库版本化管理的 Git hooks。 这些 hooks 基于 [docs/code-guildline-cn.md](../docs/code-guildline-cn.md) 中的 -提交规范实现,目标是让提交信息在本地就能得到提示和校验。 +提交规范实现,目标是让提交信息和 `Signed-off-by:` trailer +在本地就能得到提示和校验。 GitHub Actions CI 也会通过 [scripts/check-commit-messages.sh](../scripts/check-commit-messages.sh) 复用这里的 `commit-msg` 规则,避免本地与 CI 漂移。 @@ -37,6 +38,8 @@ git config --get core.hooksPath - 当你执行普通 `git commit` 且提交信息还是空白时,自动插入注释模板 - 提示允许的格式:`feat:`、`feat(scope):`、`fix:`、`docs:`、`refactor:`、`test:`、`chore:` - 提醒主题句使用祈使语气,并尽量控制在 72 字符内 +- 提醒作者自己产生的 commit 使用 `git commit -s` +- 提醒较大提交需要补 body,并用分点条目说明主要改动 不会改写的场景: @@ -51,6 +54,8 @@ git config --get core.hooksPath - 校验提交信息第一条真实主题行 - 要求主题格式为:`: ` 或 `(): ` +- 要求作者自己产生的 commit 带有合法的 `Signed-off-by:` trailer +- 当提交改动较大时,要求 body 中存在分点条目 - 允许的 `type`: - `feat` - `fix` @@ -60,13 +65,37 @@ git config --get core.hooksPath - `chore` - 主题超过 72 字符时给出 warning,但不阻断提交 -放行的特殊场景: +主题特判场景: - `Merge ...` - `Revert ...` - `fixup! ...` - `squash! ...` +说明: + +- `Merge ...` 直接放行 +- `Revert ...` 允许不使用 Conventional Commit 前缀,但仍需 `Signed-off-by:` +- `fixup! ...` / `squash! ...` 会先剥离前缀再做主题校验,仍需 `Signed-off-by:` +- `Merge ...`、`Revert ...`、`fixup! ...`、`squash! ...` 不参与“大提交必须写 body”的额外校验 + +当前“大提交”阈值: + +- 单个 commit 改动至少 6 个文件 +- 或新增/删除总行数至少 150 行 + +推荐提交流程: + +```bash +git commit -s +git commit -s -m "fix(sync): handle empty IMAP checkpoint" +git commit -s + +# Then add a body for a larger change: +# - split sync state handling from UI refresh +# - add regression coverage for startup inbox sync +``` + ## 推荐写法 推荐: @@ -96,6 +125,8 @@ hooks 当前落地的是 `docs/code-guildline-cn.md` 中与提交信息直接相 - `atomic-commits`:一个 commit 一个逻辑变化 - `refactor-then-feature`:重构与功能改动尽量拆开 +- `signed-commits`:作者自己产生的 commit 必须带 `Signed-off-by:` trailer +- `large-commit-body`:较大提交必须带分点 body - Conventional Commit 前缀: - `feat:` - `feat(scope):` diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 1e46338..143f8ac 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -4,26 +4,18 @@ set -euo pipefail message_file=${1:?missing commit message file} -comment_char="$(git config --get core.commentChar || true)" -if [[ -z "${comment_char}" || "${comment_char}" == "auto" ]]; then - comment_char="#" -fi +large_commit_file_threshold=${CRIEW_LARGE_COMMIT_FILE_THRESHOLD:-6} +large_commit_line_threshold=${CRIEW_LARGE_COMMIT_LINE_THRESHOLD:-150} + +cleaned_message="$(git stripspace --strip-comments < "${message_file}")" subject="$( - awk -v comment_char="${comment_char}" ' - { - trimmed = $0 - sub(/^[[:space:]]+/, "", trimmed) - if (trimmed == "") { - next - } - if (substr(trimmed, 1, length(comment_char)) == comment_char) { - next - } - print trimmed + printf '%s\n' "${cleaned_message}" | awk ' + NF { + print exit } - ' "${message_file}" + ' )" if [[ -z "${subject}" ]]; then @@ -31,17 +23,25 @@ if [[ -z "${subject}" ]]; then exit 1 fi -if [[ "${subject}" =~ ^(Merge|Revert)\ ]]; then +if [[ "${subject}" =~ ^Merge\ ]]; then exit 0 fi +skip_conventional_subject_check=0 +skip_large_commit_body_check=0 +if [[ "${subject}" =~ ^Revert\ ]]; then + skip_conventional_subject_check=1 + skip_large_commit_body_check=1 +fi + if [[ "${subject}" =~ ^(fixup\!\ |squash\!\ ) ]]; then + skip_large_commit_body_check=1 subject=${subject#*! } fi conventional_subject_pattern='^(feat|fix|docs|refactor|test|chore)(\([^)[:space:]]+\))?: .+' -if [[ ! "${subject}" =~ ${conventional_subject_pattern} ]]; then +if (( ! skip_conventional_subject_check )) && [[ ! "${subject}" =~ ${conventional_subject_pattern} ]]; then cat >&2 <<'EOF' commit message subject must use one of: feat: summary @@ -66,3 +66,76 @@ if (( subject_length > 72 )); then "warning: commit subject is ${subject_length} characters; keep it within 72 when possible" \ >&2 fi + +trailers="$(printf '%s\n' "${cleaned_message}" | git interpret-trailers --parse)" +signed_off_by_pattern='^Signed-off-by: .+ <.+>$' + +if ! grep -Eq "${signed_off_by_pattern}" <<< "${trailers}"; then + cat >&2 <<'EOF' +commit message must include a Signed-off-by trailer. + +Use `git commit -s` (or `git commit --signoff`) so Git appends: + Signed-off-by: Your Name +EOF + exit 1 +fi + +commit_file_count=${CRIEW_COMMIT_FILE_COUNT:-} +commit_line_count=${CRIEW_COMMIT_LINE_COUNT:-} + +if [[ -z "${commit_file_count}" || -z "${commit_line_count}" ]]; then + if [[ -n "${CRIEW_COMMIT_SHA:-}" ]]; then + diff_numstat="$(git show --format= --numstat --find-renames "${CRIEW_COMMIT_SHA}")" + elif git rev-parse --verify --quiet HEAD >/dev/null; then + diff_numstat="$(git diff --cached --numstat --find-renames HEAD)" + else + diff_numstat="$(git diff --cached --numstat --find-renames --root)" + fi + + read -r commit_file_count commit_line_count <<< "$( + printf '%s\n' "${diff_numstat}" | awk ' + NF >= 3 { + files += 1 + if ($1 != "-") { + lines += $1 + } + if ($2 != "-") { + lines += $2 + } + } + END { + print files + 0, lines + 0 + } + ' + )" +fi + +is_large_commit=0 +if (( commit_file_count >= large_commit_file_threshold || commit_line_count >= large_commit_line_threshold )); then + is_large_commit=1 +fi + +body_lines="$(printf '%s\n' "${cleaned_message}" | sed '1d')" +has_bullet_body=0 +if grep -Eq '^[[:space:]]*[-*][[:space:]]+\S' <<< "${body_lines}"; then + has_bullet_body=1 +fi + +if (( is_large_commit )) && (( ! skip_large_commit_body_check )); then + if (( ! has_bullet_body )); then + cat >&2 < 同步 -> 阅读 -> 应用 patch -> 回复邮件”放进同一条终端内、本地优先的工作流中。 -`CRIEW` 的含义是 `Code Review in Efficient Workflow`。 -仓库名保持大写 `CRIEW`,crate 和 CLI 命令使用小写 `criew`。 +[![build](https://github.com/ChenMiaoi/CRIEW/actions/workflows/ci.yml/badge.svg)](https://github.com/ChenMiaoi/CRIEW/actions/workflows/ci.yml) +[![crates.io](https://img.shields.io/crates/v/criew?label=latest)](https://crates.io/crates/criew) +[![docs](https://docs.rs/criew/badge.svg)](https://docs.rs/criew/) +[![codecov](https://codecov.io/github/ChenMiaoi/CRIEW/graph/badge.svg?token=AH99YLKKPD)](https://codecov.io/github/ChenMiaoi/CRIEW) -![CRIEW TUI demo](docs/media/criew-tui-demo.gif) - -English README: [README.md](README.md) - -## 当前能力 - -- 同步 `lore.kernel.org` 邮件列表,并按 checkpoint 做增量更新 -- 同步真实 IMAP `INBOX`,内置 `My Inbox` 订阅 -- 浏览 thread,识别 `[PATCH vN M/N]` patch series -- 通过 `b4` apply 或导出 patch -- 在 TUI 中撰写回信,并通过 `git send-email` 发送 -- 浏览本地 kernel tree,支持内联 Vim-like 编辑和外部 Vim 编辑 - -## 发布基线 - -`v0.0.1` 是 CRIEW 第一版对外支持的发布基线。 -从 `v0.0.1` 开始,项目只使用 CRIEW 这一套命名: -`criew`、`~/.criew/`、`criew-config.toml`、`criew.db`、`CRIEW_B4_PATH`、`CRIEW_IMAP_PROXY`。 - -更早的 Courier 命名不再视为受支持的升级路径。 -如果你之前测试过 rename 落定前的预发布快照,或者更早打出的 `v0.0.1` tag, -请重新拉取代码或重新安装,并按当前命名重新初始化 CRIEW 运行目录。 - -## 依赖 - -- Rust stable -- Git -- Python 3 - - 当你使用仓库内 `vendor/b4/b4.sh`,或内置的 runtime fallback 时需要 -- `b4` - - CRIEW 的查找顺序是:`[b4].path` -> `CRIEW_B4_PATH` -> `./vendor/b4/b4.sh` -> `~/.criew/vendor/b4/b4.sh` 内置展开副本 -> `b4` in `PATH` -- `git send-email` - - 仅在发送回信时需要 - -建议先运行: - -```bash -criew doctor -``` - -它会检查 `b4`、`git send-email`、git 邮件身份和 IMAP 连接状态。 - -## 安装 - -### 从 crates.io 安装 - -```bash -cargo install criew --locked -``` - -这个安装包会把一个最小可运行的 vendored `b4` fallback 内嵌进二进制。 -当 `[b4].path`、`CRIEW_B4_PATH` 和 `./vendor/b4/b4.sh` 都不可用时, -CRIEW 会在首次需要时把它展开到 `~/.criew/vendor/b4/`。 -这个 fallback 仍然需要系统可用的 Python 3。 - -### 从源码仓库安装 - -如果你希望直接使用工作区里的 `./vendor/b4/b4.sh`,建议递归拉取子模块: - -```bash -git clone --recurse-submodules https://github.com/ChenMiaoi/CRIEW.git -cd CRIEW -cargo install --path . --locked -``` - -如果仓库已经 clone 下来但没有初始化子模块: - -```bash -git submodule update --init --recursive -``` - -### 直接从 GitHub 安装 - -```bash -cargo install --git https://github.com/ChenMiaoi/CRIEW.git --locked criew -``` - -这种方式下,建议你自行通过 `b4.path`、`CRIEW_B4_PATH` 或系统 `PATH` 提供 `b4`。 -如果 checkout 里也带着 `vendor/b4`,CRIEW 也能像源码模式一样直接使用它。 - -### 开发时直接运行 - -```bash -cargo run -- doctor -cargo run -- tui -``` +CRIEW 是一个面向 Linux kernel patch 邮件工作流的 Rust TUI 工具, +把“订阅 -> 同步 -> 阅读 -> 应用 patch -> 回复邮件” +放进同一条终端内、本地优先的工作流中。 +仓库名保持大写 `CRIEW`, +crate 和 CLI 命令使用小写 `criew`。 -## 配置 +完整文档现在以 wiki 为主: +[CRIEW wiki](https://github.com/ChenMiaoi/CRIEW/wiki) -默认配置文件路径: - -```text -~/.criew/criew-config.toml -``` - -默认运行数据目录: - -```text -~/.criew/ -``` - -首次运行时,CRIEW 会自动生成一个最小配置文件。完整示例见 [docs/config.example.toml](docs/config.example.toml)。 - -一个常见配置如下: - -```toml -[source] -mailbox = "io-uring" - -[imap] -email = "you@example.com" -user = "you@example.com" -pass = "app-password" -server = "imap.example.com" -serverport = 993 -encryption = "ssl" - -[kernel] -tree = "/path/to/linux" -``` - -配置说明: - -- 相对路径会相对于配置文件所在目录解析 -- 只使用 lore 同步时,可以完全不配置 `[imap]` -- IMAP 配置完整后,左栏会出现默认启用的 `My Inbox` -- `imap.proxy` 支持 `http://`、`socks5://`、`socks5h://` -- 回信身份优先读取 `git config sendemail.from`,否则回退到 `git config user.name` / `git config user.email` -- `ui.startup_sync` 默认是 `true` -- `ui.inbox_auto_sync_interval_secs` 默认是 `30` -- `~/.courier`、`courier-config.toml`、`courier.db`、`COURIER_B4_PATH`、`COURIER_IMAP_PROXY` 从 `v0.0.1` 起都不再受支持 +![CRIEW TUI demo](docs/media/criew-tui-demo.gif) -## 基本使用 +English README: [README.md](README.md) -### 1. 环境自检 +## 快速开始 ```bash +cargo install criew criew doctor -``` - -### 2. 同步 lore 邮箱 - -```bash criew sync --mailbox io-uring -``` - -### 3. 同步真实 IMAP 收件箱 - -```bash -criew sync --mailbox INBOX -``` - -### 4. 使用本地 `.eml` fixture 调试 - -```bash -criew sync --mailbox test --fixture-dir ./fixtures -``` - -### 5. 启动 TUI - -```bash criew tui ``` -## TUI 常用操作 +启用 IMAP、 +patch apply、 +或回信发送前, +请先阅读 wiki 中对应的页面。 -- `:` 打开命令栏 -- `y` / `n` 启用或禁用当前订阅 -- `Enter` 打开当前 mailbox 或 thread -- `a` apply 当前 patch series -- `d` 导出当前 patch series -- `u` 撤销本次会话中最近一次成功 apply -- `r` 或 `e` 打开回信面板 -- `Tab` 在 Mail 页面和 Code Browser 页面之间切换 +## 文档 -命令栏常见命令: +- [CRIEW wiki](https://github.com/ChenMiaoi/CRIEW/wiki) +- [安装与初始化](https://github.com/ChenMiaoi/CRIEW/wiki/Install-and-Setup) +- [配置说明](https://github.com/ChenMiaoi/CRIEW/wiki/Configuration) +- [同步与 TUI](https://github.com/ChenMiaoi/CRIEW/wiki/Sync-and-TUI) +- [Patch 与回信](https://github.com/ChenMiaoi/CRIEW/wiki/Patch-and-Reply) +- [开发与本地 wiki 构建](https://github.com/ChenMiaoi/CRIEW/wiki/Development) +- [贡献流程](https://github.com/ChenMiaoi/CRIEW/wiki/Contribution) +- [docs.rs API 文档](https://docs.rs/criew/) -- `sync` -- `sync ` -- `config` -- `vim` -- `restart` -- `quit` -- `!` +## 当前版本 -如果 IMAP 配置完整,`My Inbox` 会参与启动自动同步,并在 TUI 保持打开时按配置周期持续做后台增量同步。 -启用的邮件列表订阅也会在 TUI 保持打开时按同一周期做后台增量同步,以持续拉取 Linux lore 和 QEMU GNU archive 上的新邮件。 +当前源码版本和发布 tag 为 `v0.0.2`。 -## 回复邮件 - -CRIEW 的 Reply Panel 会自动填充: - -- `From` -- `To` -- `Cc` -- `Subject` -- `In-Reply-To` -- `References` - -同时会生成符合 kernel 邮件习惯的引用正文模板。发送时,底层走 `git send-email`。 - -## 相关文档 - -- [README.md](README.md): 英文项目说明 -- [docs/config.example.toml](docs/config.example.toml): 配置示例 -- [docs/design.md](docs/design.md): 设计文档 -- [docs/reply-format-spec.md](docs/reply-format-spec.md): 回信格式规范 -- [docs/mvp-milestones.md](docs/mvp-milestones.md): 历史里程碑 -- [docs/reply-mvp-milestones.md](docs/reply-mvp-milestones.md): 回信功能演进记录 - -## 开发 +## 发布基线 -```bash -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --all-targets --all-features -./scripts/check-coverage.sh -``` +`v0.0.1` 是 CRIEW 第一版对外支持的发布基线。 +从 `v0.0.1` 开始, +项目只支持 CRIEW 这一套命名: +`criew`、 +`~/.criew/`, +`criew-config.toml`, +`criew.db`, +`CRIEW_B4_PATH`, +和 `CRIEW_IMAP_PROXY`。 +Courier 时代的命名不再受支持。 ## License CRIEW 自身的 Rust 代码使用 [LGPL-2.1](LICENSE) 许可证发布。 -打包进来的 vendored 组件保留各自上游许可证,包括 `vendor/b4`(GPL-2.0) +打包进来的 vendored 组件保留各自上游许可证, +包括 `vendor/b4`(GPL-2.0) 和 `vendor/b4/patatt`(MIT-0)。 diff --git a/README.md b/README.md index da5b1ae..5053169 100644 --- a/README.md +++ b/README.md @@ -1,236 +1,70 @@ # CRIEW +[![build](https://github.com/ChenMiaoi/CRIEW/actions/workflows/ci.yml/badge.svg)](https://github.com/ChenMiaoi/CRIEW/actions/workflows/ci.yml) +[![crates.io](https://img.shields.io/crates/v/criew?label=latest)](https://crates.io/crates/criew) +[![docs](https://docs.rs/criew/badge.svg)](https://docs.rs/criew/) [![codecov](https://codecov.io/github/ChenMiaoi/CRIEW/graph/badge.svg?token=AH99YLKKPD)](https://codecov.io/github/ChenMiaoi/CRIEW) CRIEW is a Rust TUI for Linux kernel patch mail workflows. -`CRIEW` stands for `Code Review in Efficient Workflow`. -The repository name stays uppercase as `CRIEW`, while the crate and CLI use lowercase `criew`. +It keeps subscription, +sync, +review, +patch application, +and reply in one terminal-first local workflow. +`CRIEW` is the repository name, +while the crate and CLI use lowercase `criew`. -It is built for developers who work on mailing-list-driven review, especially in Linux kernel style flows, and want a terminal-first tool that keeps subscription, sync, review, patch application, and reply in one local workflow. +Full documentation lives in the +[CRIEW wiki](https://github.com/ChenMiaoi/CRIEW/wiki). ![CRIEW TUI demo](docs/media/criew-tui-demo.gif) -Chinese usage guide: [README-zh.md](README-zh.md) - -## Status - -CRIEW is under active development. The current `develop` branch already covers the core workflow: - -- sync mail from `lore.kernel.org` -- sync a real IMAP `INBOX` through the built-in `My Inbox` subscription -- browse threads and detect patch series -- apply or export patches through `b4` -- compose and send replies from the TUI through `git send-email` - -## Release Baseline - -`v0.0.1` is the first supported public baseline for CRIEW. -Starting from `v0.0.1`, the project uses only the CRIEW naming set: -`criew`, `~/.criew/`, `criew-config.toml`, `criew.db`, `CRIEW_B4_PATH`, and `CRIEW_IMAP_PROXY`. - -Earlier Courier-era names are not treated as a supported upgrade path. -If you tested an older pre-release snapshot or an earlier `v0.0.1` tag before this rename settled, -refresh your checkout or reinstall the binary and bootstrap a new CRIEW runtime directory. - -## Features - -- Rust CLI with `criew tui`, `criew sync`, `criew doctor`, and `criew version` -- local SQLite storage with automatic runtime bootstrap -- incremental lore sync with checkpoint-based updates -- real IMAP `INBOX` sync with patch-oriented filtering -- background startup sync for enabled subscriptions -- periodic auto-sync for `My Inbox` -- patch series detection for subjects like `[PATCH vN M/N]` -- patch apply/export workflow powered by `b4` -- undo for the most recent successful apply in the current session -- kernel tree browser with source preview -- inline Vim-like editing and external Vim editing -- reply panel that fills `From`, `To`, `Cc`, `Subject`, `In-Reply-To`, and `References` -- real reply delivery through `git send-email` -- visual config editor, command palette completion, and structured operation logs - -## Requirements - -- Rust stable -- Git -- Python 3 - - needed when using the repo-local `vendor/b4/b4.sh` or the embedded runtime fallback -- `b4` - - CRIEW resolves it in this order: `[b4].path` -> `CRIEW_B4_PATH` -> `./vendor/b4/b4.sh` -> embedded runtime vendor under `~/.criew/vendor/b4/b4.sh` -> `b4` in `PATH` -- `git send-email` - - only required if you want to send replies - -`criew doctor` checks `b4`, `git send-email`, git mail identity, and IMAP connectivity. - -## Installation - -`crates.io` installation is the recommended path. - -### Install from crates.io - -```bash -cargo install criew --locked -``` - -This build keeps a minimal vendored `b4` runtime embedded in the binary. -If `[b4].path`, `CRIEW_B4_PATH`, and `./vendor/b4/b4.sh` are all unavailable, -CRIEW can materialize that fallback under `~/.criew/vendor/b4/` on first use. -Python 3 is still required for that fallback. - -### Install from a clone - -If you want to use the repo-local `./vendor/b4/b4.sh` fallback from a checkout, -clone the repository with submodules: - -```bash -git clone --recurse-submodules https://github.com/ChenMiaoi/CRIEW.git -cd CRIEW -cargo install --path . --locked -``` - -If you already cloned the repository without submodules: - -```bash -git submodule update --init --recursive -``` - -### Install directly from GitHub - -```bash -cargo install --git https://github.com/ChenMiaoi/CRIEW.git --locked criew -``` - -In this mode, you should provide `b4` through `b4.path`, `CRIEW_B4_PATH`, or your system `PATH`. -If the checkout also includes `vendor/b4`, CRIEW can use it the same way as a source clone. - -### Run from source - -```bash -cargo run -- doctor -cargo run -- tui -``` +Chinese quick start: [README-zh.md](README-zh.md) ## Quick Start -### 1. Check your environment - ```bash +cargo install criew criew doctor -``` - -### 2. Prepare configuration - -The default config file is `~/.criew/criew-config.toml`, and the default runtime directory is `~/.criew/`. CRIEW creates a minimal config file automatically on first run. - -See [docs/config.example.toml](docs/config.example.toml) for a complete example. - -Typical configuration: - -```toml -[source] -mailbox = "io-uring" - -[imap] -email = "you@example.com" -user = "you@example.com" -pass = "app-password" -server = "imap.example.com" -serverport = 993 -encryption = "ssl" - -[kernel] -tree = "/path/to/linux" -``` - -Notes: - -- relative paths are resolved from the config file directory -- `[imap]` is optional if you only use lore sync -- when IMAP config is complete, `My Inbox` is enabled by default on first use -- `imap.proxy` supports `http://`, `socks5://`, and `socks5h://` -- reply identity prefers `git config sendemail.from`, then falls back to `git config user.name` and `git config user.email` -- `ui.startup_sync` defaults to `true` -- `ui.inbox_auto_sync_interval_secs` defaults to `30` -- Courier-era names such as `~/.courier`, `courier-config.toml`, `courier.db`, `COURIER_B4_PATH`, and `COURIER_IMAP_PROXY` are intentionally unsupported from `v0.0.1` onward - -### 3. Sync mail - -Sync a lore mailbox: - -```bash criew sync --mailbox io-uring -``` - -Sync a real IMAP inbox: - -```bash -criew sync --mailbox INBOX -``` - -Use local `.eml` fixtures for debugging: - -```bash -criew sync --mailbox test --fixture-dir ./fixtures -``` - -### 4. Start the TUI - -```bash criew tui ``` -Inside the TUI: - -- `:` opens the command palette -- `y` / `n` enable or disable the selected subscription -- `Enter` opens the selected mailbox or thread -- `a` applies the current patch series -- `d` exports the current patch series -- `u` undoes the most recent successful apply from the current session -- `r` or `e` opens the reply panel -- `Tab` switches between the mail page and the code browser - -When IMAP is configured, `My Inbox` joins startup sync and continues periodic background sync while the TUI remains open. -Enabled mailing-list subscriptions also keep doing periodic background sync while the TUI remains open so Linux lore and QEMU archive mailboxes keep pulling new mail. +Use the wiki before enabling IMAP, +patch application, +or reply sending. ## Documentation -- [README-zh.md](README-zh.md): Chinese usage guide -- [docs/config.example.toml](docs/config.example.toml): configuration example -- [docs/design.md](docs/design.md): design notes -- [docs/reply-format-spec.md](docs/reply-format-spec.md): reply panel and sending format -- [docs/mvp-milestones.md](docs/mvp-milestones.md): historical milestone record -- [docs/reply-mvp-milestones.md](docs/reply-mvp-milestones.md): reply workflow evolution +- [CRIEW wiki](https://github.com/ChenMiaoi/CRIEW/wiki) +- [Install and Setup](https://github.com/ChenMiaoi/CRIEW/wiki/Install-and-Setup) +- [Configuration](https://github.com/ChenMiaoi/CRIEW/wiki/Configuration) +- [Sync and TUI](https://github.com/ChenMiaoi/CRIEW/wiki/Sync-and-TUI) +- [Patch and Reply](https://github.com/ChenMiaoi/CRIEW/wiki/Patch-and-Reply) +- [Development](https://github.com/ChenMiaoi/CRIEW/wiki/Development) +- [Contribution](https://github.com/ChenMiaoi/CRIEW/wiki/Contribution) +- [API docs on docs.rs](https://docs.rs/criew/) -## Development +## Current Release -Common development commands: +The current source version and release tag are `v0.0.2`. -```bash -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --all-targets --all-features -./scripts/check-coverage.sh -``` - -The repository includes GitHub Actions CI for `push` and `pull_request` with the same formatting, lint, and test checks. - -## Contributing - -Issues and pull requests are welcome. - -Before sending changes, run: - -```bash -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --all-targets --all-features -``` +## Release Baseline -If you change user-visible behavior, commands, config keys, or workflows, update the relevant documentation in the same change. +`v0.0.1` is the first supported public baseline for CRIEW. +From `v0.0.1` onward, +CRIEW supports only the CRIEW naming set: +`criew`, +`~/.criew/`, +`criew-config.toml`, +`criew.db`, +`CRIEW_B4_PATH`, +and `CRIEW_IMAP_PROXY`. +Courier-era names are unsupported. ## License CRIEW's Rust code is licensed under [LGPL-2.1](LICENSE). -Bundled vendored components keep their upstream licenses, including `vendor/b4` (GPL-2.0) +Bundled vendored components keep their upstream licenses, +including `vendor/b4` (GPL-2.0) and `vendor/b4/patatt` (MIT-0). diff --git a/docs/design.md b/docs/architecture/design.md similarity index 95% rename from docs/design.md rename to docs/architecture/design.md index 0493843..5adef55 100644 --- a/docs/design.md +++ b/docs/architecture/design.md @@ -1,5 +1,27 @@ # CRIEW 设计文档 +## 文档导航 + +1. 项目定位 +2. 目标与非目标 +3. 用户画像与关键场景 +4. 技术选型 +5. 系统架构 +6. 数据模型(逻辑) +7. 核心流程 +8. TUI 设计(MVP) +9. b4 集成策略 +10. 安全与可靠性 +11. MVP 里程碑文档 +12. 测试策略 +13. 开发优先级建议 +14. M1 已决策项与风险更新 +15. M2 已决策项与风险更新 +16. M3 已决策项与风险更新 +17. M6(已完成):真实 IMAP 接入与自邮箱订阅 +18. M7(已完成):回信编辑与预览 +19. M8(已完成):Send Email 发送链路 + ## 1. 项目定位 CRIEW 是一个基于 Rust 的现代 TUI 内核 patch 工作流工具,面向 Linux @@ -231,8 +253,8 @@ kernel 邮件列表协作场景,目标是把「订阅 -> 阅读 -> 过滤 -> 关键操作: -- `j/l`: 页面焦点移动(在左/中/右面板间切换) -- `i/k`: 当前聚焦页面内上下移动(列表项或正文滚动) +- `ui.keymap = "default"`(默认):`j/l` 页面焦点移动,`i/k` 当前聚焦页面内上下移动 +- `ui.keymap = "vim"`:`h/l` 页面焦点移动,`j/k` 当前聚焦页面内上下移动 - `:`: 打开/关闭命令栏(首选) - `Ctrl + Backtick (\`)`: 打开/关闭命令栏(兼容后备) - `y`: 启用左栏当前订阅 @@ -280,10 +302,10 @@ MVP 阶段: MVP 范围与阶段目标已迁移至独立文档: -- `docs/mvp-milestones.md` -- `docs/vim-mvp-milestones.md` -- `docs/reply-format-spec.md` -- `docs/reply-mvp-milestones.md` +- `docs/milestones/mvp-milestones.md` +- `docs/milestones/vim-mvp-milestones.md` +- `docs/specs/reply-format-spec.md` +- `docs/milestones/reply-mvp-milestones.md` ## 12. 测试策略 @@ -314,8 +336,8 @@ MVP 范围与阶段目标已迁移至独立文档: - CLI 命令固定为:`tui`、`sync`、`doctor`、`version`。 - 配置读取采用 TOML,支持 `--config` 路径覆盖和默认目录策略。 - 启动阶段统一执行目录引导与 SQLite 初始化迁移,`schema_version` 作为版本入口。 -- b4 检查顺序固定:配置路径 -> `CRIEW_B4_PATH` -> `vendor/b4/b4.sh` -> 运行时展开的 embedded `vendor/b4/b4.sh` -> `PATH` 中 `b4`。 -- TUI 先交付三栏骨架与状态栏,键位实现 `j/l` 焦点切换和 `i/k` 面板内移动。 +- b4 检查顺序固定:配置路径 -> `CRIEW_B4_PATH` -> `vendor/b4/b4.sh` -> `PATH` 中 `b4`。 +- TUI 三栏骨架与状态栏已交付;主页面键位支持 `default`(`j/l` + `i/k`)与 `vim`(`h/l` + `j/k`)两套方案。 ### 14.2 风险与后续动作 @@ -341,7 +363,7 @@ MVP 范围与阶段目标已迁移至独立文档: - TUI 左栏采用 vger 子系统模板订阅,支持 `y/n` 启停、启用/停用双分组、折叠展开、 `Enter` 打开对应 mailbox Threads。 - 命令栏支持 `sync` / `sync `,并在启动 TUI 时自动同步“已启用订阅”。 -- UI 状态持久化 `enabled_mailboxes`、分组展开状态、active mailbox 到 `ui-state.toml`; +- UI 状态持久化 `enabled_mailboxes`、分组展开状态、active mailbox 和 mail pane 宽度到 `ui-state.toml`; 首次启动默认全部订阅禁用;该基线在 M6 规划中仅对新增 `My Inbox` 订阅例外。 - 右栏预览默认隐藏 RFC 头部,过滤控制字符,切换线程时清屏并重置滚动,避免残留字符。 diff --git a/docs/code-guildline-cn.md b/docs/development/code-guildline-cn.md similarity index 85% rename from docs/code-guildline-cn.md rename to docs/development/code-guildline-cn.md index 373c070..efb230a 100644 --- a/docs/code-guildline-cn.md +++ b/docs/development/code-guildline-cn.md @@ -3,7 +3,7 @@ ## 适用范围与优先级 风格与评审决策的优先级: -1. 本文档(`docs/coding-guideline-cn.md`) +1. 本文档(`docs/development/code-guildline-cn.md`) 2. 工具强制规则(`rustfmt`、`clippy`) 3. 被修改模块的既有本地约定 @@ -84,12 +84,18 @@ - `atomic-commits`:一个 commit 一个逻辑变化。 - `refactor-then-feature`:先重构再功能,分开提交。 - `focused-prs`:一个 PR 聚焦一个主题。 +- `signed-commits`:仓库内提交必须使用 `git commit -s`,确保每个 commit 都带有有效的 `Signed-off-by:` trailer。 +- `large-commit-body`:简单提交可以只有标题行;改动量较大的提交必须带 body,并用分点条目说明主要改动。 提交信息策略(兼容本项目规范与参考的 Asterinas 规则): - 保留本项目 Conventional Commit 前缀:`feat:`、`fix:`、`docs:`、`refactor:`、`test:`、`chore:`。 - 可选使用 scope,格式如:`feat(ci): ...`、`fix(sync): ...`。 - 前缀后的主题句使用祈使语气,描述清晰。 - 主题行尽量不超过 72 字符。 +- 作者自己产生的 commit 一律使用 `git commit -s`;`.githooks/commit-msg` 与 CI 会校验 `Signed-off-by:` trailer。 +- 简单提交可以只写标题行,不强制写 body。 +- 改动量较大的提交必须写 body,并使用 `- ...` 这类分点条目说明做了什么。 +- 当前 hook 与 CI 将“较大提交”定义为:单个 commit 改动至少 6 个文件,或新增/删除总行数至少 150 行。 ## 评审检查清单 @@ -104,6 +110,8 @@ - [ ] 测试验证可观察行为并完成资源清理(`test-visible-behavior`, `test-cleanup`)。 - [ ] `cargo fmt --all -- --check`、`cargo clippy --all-targets --all-features -- -D warnings`、`cargo test --all-targets --all-features` 和 `./scripts/check-coverage.sh` 通过。 - [ ] commit 与 PR 规模原子、主题聚焦(`atomic-commits`, `focused-prs`)。 +- [ ] 作者自己产生的每个 commit 都带有有效的 `Signed-off-by:` trailer(`signed-commits`)。 +- [ ] 较大提交带有分点 body,用来说明主要改动(`large-commit-body`)。 ## 渐进式采用 diff --git a/docs/code-guildline.md b/docs/development/code-guildline.md similarity index 87% rename from docs/code-guildline.md rename to docs/development/code-guildline.md index 9dbb114..9e9f82c 100644 --- a/docs/code-guildline.md +++ b/docs/development/code-guildline.md @@ -3,7 +3,7 @@ ## Scope And Priority Priority order for style and review decisions: -1. This document (`docs/code-guildline.md`) +1. This document (`docs/development/code-guildline.md`) 2. Tool-enforced rules (`rustfmt`, `clippy`) 3. Existing local conventions in the modified module @@ -84,6 +84,8 @@ Must: - `atomic-commits`: keep one logical change per commit. - `refactor-then-feature`: separate refactors from feature changes. - `focused-prs`: keep each pull request focused on a single theme. +- `signed-commits`: create repository commits with `git commit -s` so each commit carries a valid `Signed-off-by:` trailer. +- `large-commit-body`: simple commits may use only the subject line, but larger commits must include a body with bullet points that summarize the main changes. Commit message policy (compatible with this project and the referenced Asterinas rules): @@ -91,6 +93,10 @@ Commit message policy - An optional scope is allowed, such as `feat(ci): ...` or `fix(sync): ...`. - Write the subject line in the imperative mood. - Keep the subject line under 72 characters when practical. +- Use `git commit -s` for authored commits; `.githooks/commit-msg` and CI validate the `Signed-off-by:` trailer. +- Simple commits may use only the subject line. +- Larger commits must include a body with bullet points such as `- add ...` and `- refactor ...`. +- The current hook and CI treat a commit as larger when it touches at least 6 files or changes at least 150 added/deleted lines. ## Review Checklist @@ -105,6 +111,8 @@ Commit message policy - [ ] Tests validate observable behavior and clean up resources (`test-visible-behavior`, `test-cleanup`). - [ ] `cargo fmt --all -- --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test --all-targets --all-features`, and `./scripts/check-coverage.sh` pass. - [ ] Commits and pull requests stay atomic and focused (`atomic-commits`, `focused-prs`). +- [ ] Each authored commit carries a valid `Signed-off-by:` trailer (`signed-commits`). +- [ ] Larger commits include a bullet-point body that explains the main changes (`large-commit-body`). ## Gradual Adoption diff --git a/docs/mvp-milestones.md b/docs/milestones/mvp-milestones.md similarity index 95% rename from docs/mvp-milestones.md rename to docs/milestones/mvp-milestones.md index b321d62..8fa45e0 100644 --- a/docs/mvp-milestones.md +++ b/docs/milestones/mvp-milestones.md @@ -2,11 +2,27 @@ 本文档承载 CRIEW 的 MVP 范围、阶段目标、交付物与验收标准。 +## 文档导航 + +- 使用方式 +- 跨阶段约束 +- M1:基础骨架(必须) +- M2:邮件读取链路(必须,已完成) +- M3:patch 工作流(必须,已完成) +- M4:Code Preview Vim(VM1,必须) +- M5:Code Preview 外部 Vim(VM2,增强,已完成) +- M6:真实 IMAP 接入与自邮箱订阅(必须,已完成) +- M7:回信编辑与预览(必须,已完成) +- M8:Send Email 发送链路(MVP,必须,已完成) +- M9:过滤规则(必须) +- M10:配置体验增强(可选,低优先) +- 维护规则 + ## 使用方式 - 里程碑按顺序推进,默认前一阶段完成后进入下一阶段。 - 每个阶段都要求可演示、可回归、可记录,不接受“仅代码提交”。 -- 阶段完成后,同步更新 `docs/design.md` 的已决策项与风险项。 +- 阶段完成后,同步更新 `docs/architecture/design.md` 的已决策项与风险项。 ## 跨阶段约束 @@ -149,7 +165,7 @@ TUI 主循环,确保项目具备后续迭代基础。 3. 文本缓冲区:支持最小移动、插入、删除、保存能力。 4. 渲染与提示:展示 mode/dirty/command 状态,确保切换无残影。 5. 回归测试:未进入编辑态时,现有全局键位行为保持不变。 -6. 文档同步:与 `docs/vim-mvp-milestones.md`、`docs/code-preview-vim-prototype.md` 保持一致。 +6. 文档同步:与 `docs/milestones/vim-mvp-milestones.md`、`docs/specs/code-preview-vim-prototype.md` 保持一致。 ### 交付物 @@ -296,8 +312,8 @@ TUI 主循环,确保项目具备后续迭代基础。 - 标准回信头部/正文构造器(内核风格)。 - `Send Preview` 预览能力与确认门控。 - 回信相关设计文档: - - `docs/reply-format-spec.md` - - `docs/reply-mvp-milestones.md` + - `docs/specs/reply-format-spec.md` + - `docs/milestones/reply-mvp-milestones.md` ### 验收标准 diff --git a/docs/reply-mvp-milestones.md b/docs/milestones/reply-mvp-milestones.md similarity index 95% rename from docs/reply-mvp-milestones.md rename to docs/milestones/reply-mvp-milestones.md index 905ab5a..ff3a61b 100644 --- a/docs/reply-mvp-milestones.md +++ b/docs/milestones/reply-mvp-milestones.md @@ -5,7 +5,14 @@ 1. `Patch Preview` 的 Vim 模式先完成(VM1)。 2. SMTP 链路先打通(MVP 可直接走 `git send-email`)。 -详细格式规范见 `docs/reply-format-spec.md`。 +详细格式规范见 `docs/specs/reply-format-spec.md`。 + +## 文档导航 + +- RM0:前置依赖就绪(必须) +- RM1:Patch Preview 一步回信(MVP,必须,已完成) +- RM2:自实现 SMTP(增强) +- 非目标(Reply MVP 不做) ## RM0:前置依赖就绪(必须) diff --git a/docs/vim-mvp-milestones.md b/docs/milestones/vim-mvp-milestones.md similarity index 97% rename from docs/vim-mvp-milestones.md rename to docs/milestones/vim-mvp-milestones.md index 8f84878..0dbcee8 100644 --- a/docs/vim-mvp-milestones.md +++ b/docs/milestones/vim-mvp-milestones.md @@ -1,7 +1,12 @@ # CRIEW Vim MVP 里程碑 本文档定义 `Code Preview` 编辑能力的 MVP 路线:先内联 Vim-like(VM1),再切出外部 Vim(VM2)。 -详细交互原型见 `docs/code-preview-vim-prototype.md`。 +详细交互原型见 `docs/specs/code-preview-vim-prototype.md`。 + +## 文档导航 + +- VM1:Code Preview 内联 Vim-like 模式(必须) +- VM2:切出外部 Vim 会话(增强) ## VM1:Code Preview 内联 Vim-like 模式(必须) diff --git a/docs/config.example.toml b/docs/reference/config.example.toml similarity index 73% rename from docs/config.example.toml rename to docs/reference/config.example.toml index 71d9093..dc95a15 100644 --- a/docs/config.example.toml +++ b/docs/reference/config.example.toml @@ -12,6 +12,9 @@ # Required to customize DB location. Default: ~/.criew/db/criew.db database = "./.criew/db/criew.db" +# Required to customize raw mail cache location. Default: ~/.criew/mail/raw +# raw_mail_dir = "./.criew/mail/raw" + # Required to customize patch file location. Default: ~/.criew/patches patch_dir = "./.criew/patches" @@ -28,6 +31,8 @@ filter = "info" [source] # Optional default lore mailbox used by CLI `sync` when no mailbox is passed. # Default: linux-kernel +# Historical compatibility alias: [imap].mailbox still maps to the same value, +# but new config should prefer [source].mailbox. # mailbox = "linux-kernel" # Optional lore base URL. @@ -55,6 +60,30 @@ filter = "info" # Default: true # startup_sync = true +# Optional. Main-page navigation scheme. +# Supported: +# "default" (j/l focus, i/k move) +# "vim" (h/l focus, j/k move, gg/G jump, qq quit) +# "custom" (ui.keymap_base plus ui.custom_keymap overrides) +# Default: "default" +# keymap = "default" + +# Optional. Base preset used when keymap = "custom". +# Supported: "default", "vim" +# Default: inferred from ui.keymap when omitted +# keymap_base = "default" + +# Optional. Custom main-page bindings. +# Each value is an array of 1-2 printable keys. +# [ui.custom_keymap] +# focus_prev = ["j"] +# focus_next = ["l"] +# move_up = ["i"] +# move_down = ["k"] +# jump_top = ["g", "g"] +# jump_bottom = ["G"] +# quick_quit = ["q", "q"] + # Optional. Seconds between `My Inbox` background auto-sync runs while TUI stays open. # Must be greater than 0. Default: 30 # inbox_auto_sync_interval_secs = 30 diff --git a/docs/code-preview-vim-prototype.md b/docs/specs/code-preview-vim-prototype.md similarity index 90% rename from docs/code-preview-vim-prototype.md rename to docs/specs/code-preview-vim-prototype.md index 1b84cf6..dd65c78 100644 --- a/docs/code-preview-vim-prototype.md +++ b/docs/specs/code-preview-vim-prototype.md @@ -1,11 +1,25 @@ # Code Preview Vim 内联编辑原型设计 +## 文档导航 + +1. 背景与目标 +2. 原型范围(P0) +3. 交互原型 +4. 技术方案(原型建议) +5. 代码变更点(草案) +6. 测试原型(建议) +7. 后续迭代(P1/P2) +8. 验收标准(P0) +9. VM2 外部 Vim 会话原型(增强) + ## 1. 背景与目标 当前 `Code Browser` 页面的 `Source Preview` 是只读预览。 -本原型目标是在不离开 `Code Preview` 的前提下提供 Vim 风格编辑体验,并避免和现有全局键位冲突: +本原型目标是在不离开 `Code Preview` 的前提下提供 Vim 风格编辑体验,并避免和主页面全局键位冲突: -- 默认仍是浏览模式,沿用现有键位(`j/l` 焦点切换、`i/k` 移动) +- 默认仍是浏览模式,沿用当前配置选中的主页面键位 +- `ui.keymap = "default"` 时为 `j/l` 焦点切换、`i/k` 移动 +- `ui.keymap = "vim"` 时为 `h/l` 焦点切换、`j/k` 移动 - 仅在用户按 `e` 后,进入 Vim-like 编辑模式 - 编辑完成后可保存并回到浏览模式,预览立即显示最新内容 @@ -17,7 +31,7 @@ - 提供 `NORMAL` / `INSERT` / `COMMAND` 三种模式 - 支持基础移动、插入、删除、保存、退出 - 支持最小 `:` 命令:`:w`、`:q`、`:wq` -- 不改变默认模式下的全局键位语义 +- 不改变默认模式下当前配置所选择的全局键位语义 不在 P0 范围内: @@ -154,7 +168,7 @@ ### 6.2 回归测试 -- 未进入编辑模式时,`j/l/i/k`、`Tab`、命令栏、搜索、patch 操作行为不变 +- 未进入编辑模式时,主页面当前配置键位、`Tab`、命令栏、搜索、patch 操作行为不变 - Code Browser 的 tree 展开/收起行为不变 ## 7. 后续迭代(P1/P2) diff --git a/docs/reply-format-spec.md b/docs/specs/reply-format-spec.md similarity index 83% rename from docs/reply-format-spec.md rename to docs/specs/reply-format-spec.md index 765c6c0..185ed19 100644 --- a/docs/reply-format-spec.md +++ b/docs/specs/reply-format-spec.md @@ -3,6 +3,16 @@ 本文档定义 CRIEW 在 patch review 场景下的回信格式、自动填充规则与发送策略。 目标是让回复行为与内核社区邮件习惯保持一致,并与 `Patch Preview + Vim` 工作流无缝衔接。 +## 文档导航 + +1. 范围与前置条件 +2. 触发与面板行为 +3. 头部默认填充与规范化规则 +4. 正文格式规范 +5. 发送策略 +6. 发送结果与审计 +7. 合规检查清单(MVP) + ## 1. 范围与前置条件 - 本规范仅覆盖“回复已有邮件(reply)”场景,不覆盖“发起全新线程”。 @@ -30,9 +40,17 @@ - `In-Reply-To` - `References` +界面必须清楚区分可编辑头部字段与只读线程字段, +避免用户把 `To` / `Cc` 误认为不可修改的固定元数据。 +打开 `Reply Panel` 时,初始焦点应落在 `From`, +方便用户按 Vim-like 方式向下移动并修改头部字段。 + `Reply Panel` 发送区最小交互: - `Send Preview`:生成并展示最终待发送邮件(头部 + 正文) +- 若正文里没有任何“非空、非引用、非 `On ... wrote:`”的 authored reply line,仍允许打开预览,但必须显示显著警告 +- 预览中的 authored reply line 需要用明显不同于引用内容的视觉样式展示,方便最终人工检查 +- `Send Preview` 复用 `Reply Panel` 的同一块界面区域做视图切换,不额外再叠一个新的对话框 - `Confirm Send`:用户确认无误后触发实际发送 - `Cancel`:取消本次发送,保留当前编辑内容 @@ -127,7 +145,7 @@ MVP 通过 CRIEW 内部发送适配层完成发送: MVP 发送时序: 1. 用户在 `Reply Panel` 点击或触发 `Send Preview`。 -2. 系统渲染最终邮件快照(`From/To/Cc/Subject/In-Reply-To/References/Body`)。 +2. 系统渲染最终邮件快照(`From/To/Cc/Subject/In-Reply-To/References/Body`),并在 authored reply content 为空时显示 warning。 3. 用户执行 `Confirm Send` 后,CRIEW 调用底层发送器(MVP 为 `git send-email`)。 4. 若发送失败,停留在 `Reply Panel` 并保留内容,允许重试。 @@ -172,5 +190,6 @@ git send-email \ 3. `From` 默认来自 git email 身份,允许用户修改,但必须保持有效邮箱地址。 4. `In-Reply-To` / `References` 构造正确。 5. 正文为 `>` 引用风格;用户回复写在不带 `>` 的空白行中,引用层级由保留的历史引用体现。 -6. 用户必须先完成 `Send Preview` 确认,才允许正式发送。 -7. MVP 路径可通过底层 `git send-email` 完成实际发送并留存结果。 +6. `Send Preview` 能明确区分 authored reply line 与引用内容;若没有 authored reply line,会显示 warning,但不阻止用户完成预览确认。 +7. 用户必须先完成 `Send Preview` 确认,才允许正式发送。 +8. MVP 路径可通过底层 `git send-email` 完成实际发送并留存结果。 diff --git a/docs/wiki b/docs/wiki new file mode 160000 index 0000000..bc88703 --- /dev/null +++ b/docs/wiki @@ -0,0 +1 @@ +Subproject commit bc887038f5fe971fc0082eb1c3a5744a46893e03 diff --git a/docs/wiki-requirements.txt b/docs/wiki-requirements.txt new file mode 100644 index 0000000..04e4782 --- /dev/null +++ b/docs/wiki-requirements.txt @@ -0,0 +1,2 @@ +mkdocs>=1.6,<2.0 +mkdocs-material>=9,<10 diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..daed8ff --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,11 @@ +site_name: CRIEW Wiki +site_description: CRIEW GitHub wiki rendered as a MkDocs site. +repo_name: ChenMiaoi/CRIEW +repo_url: https://github.com/ChenMiaoi/CRIEW +edit_uri: "" +docs_dir: target/wiki-docs +site_dir: target/wiki-site +strict: true + +theme: + name: material diff --git a/scripts/check-commit-messages.sh b/scripts/check-commit-messages.sh index 0da5317..a6cc181 100755 --- a/scripts/check-commit-messages.sh +++ b/scripts/check-commit-messages.sh @@ -108,7 +108,7 @@ for commit_sha in "${commits[@]}"; do git show -s --format=%B "${commit_sha}" > "${commit_message_file}" printf '%s\n' "checking commit ${commit_sha}: ${commit_subject}" - if ! "${hook_path}" "${commit_message_file}"; then + if ! CRIEW_COMMIT_SHA="${commit_sha}" "${hook_path}" "${commit_message_file}"; then status=1 fi diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh index 8d3751a..3e3e53a 100755 --- a/scripts/generate-release-notes.sh +++ b/scripts/generate-release-notes.sh @@ -55,6 +55,9 @@ mkdir -p "$(dirname "${output_file}")" { printf '## %s %s\n\n' "${package_name}" "${tag_name}" + printf '[![build](https://github.com/ChenMiaoi/CRIEW/actions/workflows/ci.yml/badge.svg)](https://github.com/ChenMiaoi/CRIEW/actions/workflows/ci.yml)\n' + printf '[![crates.io](https://img.shields.io/crates/v/criew?label=latest)](https://crates.io/crates/criew)\n' + printf '[![docs](https://docs.rs/criew/badge.svg)](https://docs.rs/criew/)\n' printf '[![codecov](https://codecov.io/github/ChenMiaoi/CRIEW/graph/badge.svg?token=AH99YLKKPD)](https://codecov.io/github/ChenMiaoi/CRIEW)\n\n' printf 'Source release for `%s`.\n\n' "$(git rev-list -n 1 "${tag_name}")" printf '### Release Assets\n\n' diff --git a/scripts/prepare-wiki-site.py b/scripts/prepare-wiki-site.py new file mode 100755 index 0000000..d491930 --- /dev/null +++ b/scripts/prepare-wiki-site.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import posixpath +import re +import shutil +import sys +from pathlib import Path, PurePosixPath + +MARKDOWN_SUFFIXES = {".md", ".markdown"} +SPECIAL_WIKI_FILES = {".git", "_Sidebar.md", "_Footer.md"} +WIKI_LINK_RE = re.compile(r"\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]") +MARKDOWN_LINK_RE = re.compile(r"(? argparse.Namespace: + parser = argparse.ArgumentParser( + description="Stage docs/wiki for MkDocs and normalize wiki-only links." + ) + parser.add_argument( + "--source", + default="docs/wiki", + help="GitHub wiki source directory.", + ) + parser.add_argument( + "--output", + default="target/wiki-docs", + help="Prepared MkDocs docs directory.", + ) + return parser.parse_args() + + +def normalize_page_key(name: str) -> str: + page_name = name.strip().removesuffix(".md").removesuffix(".markdown") + page_name = page_name.split("#", 1)[0] + page_name = page_name.rstrip("/") + return re.sub(r"[\s_-]+", " ", page_name).strip().lower() + + +def is_markdown(path: Path) -> bool: + return path.suffix.lower() in MARKDOWN_SUFFIXES + + +def should_skip(rel_path: Path) -> bool: + return rel_path.name in SPECIAL_WIKI_FILES + + +def output_rel_for(source_rel: Path) -> Path: + if source_rel == Path("Home.md"): + return Path("index.md") + return source_rel + + +def build_page_lookup(source_dir: Path) -> tuple[dict[str, PurePosixPath], dict[str, PurePosixPath]]: + page_lookup: dict[str, PurePosixPath] = {} + source_to_output: dict[str, PurePosixPath] = {} + + for source_path in sorted(source_dir.rglob("*")): + if not source_path.is_file(): + continue + + source_rel = source_path.relative_to(source_dir) + if should_skip(source_rel) or not is_markdown(source_rel): + continue + + output_rel = PurePosixPath(output_rel_for(source_rel).as_posix()) + source_key = source_rel.as_posix() + source_to_output[source_key] = output_rel + + page_key = normalize_page_key(source_rel.stem) + existing = page_lookup.get(page_key) + if existing is not None and existing != output_rel: + raise ValueError( + f"duplicate wiki page key '{page_key}' for {existing} and {output_rel}" + ) + page_lookup[page_key] = output_rel + + home_page = page_lookup.get("home") + if home_page is not None: + page_lookup.setdefault("index", home_page) + + return page_lookup, source_to_output + + +def rel_link_to(output_target: PurePosixPath, current_output: PurePosixPath) -> str: + current_parent = current_output.parent + relative = posixpath.relpath(output_target.as_posix(), current_parent.as_posix() or ".") + return relative + + +def rewrite_wiki_links( + text: str, + source_rel: Path, + current_output: PurePosixPath, + page_lookup: dict[str, PurePosixPath], +) -> str: + errors: list[str] = [] + + def replace(match: re.Match[str]) -> str: + target_name = match.group(1).strip() + link_text = match.group(2).strip() if match.group(2) else target_name + target_output = page_lookup.get(normalize_page_key(target_name)) + if target_output is None: + errors.append( + f"{source_rel.as_posix()}: unresolved wiki link [[{target_name}]]" + ) + return match.group(0) + + return f"[{link_text}]({rel_link_to(target_output, current_output)})" + + rewritten = WIKI_LINK_RE.sub(replace, text) + if errors: + raise ValueError("\n".join(errors)) + return rewritten + + +def normalize_local_markdown_target( + target: str, + source_rel: Path, + current_output: PurePosixPath, + source_to_output: dict[str, PurePosixPath], +) -> str | None: + if not target or "://" in target or target.startswith("mailto:"): + return None + + if target.startswith("#") or target.startswith("/"): + return None + + fragment = "" + target_body = target + if "#" in target: + target_body, fragment = target.split("#", 1) + fragment = f"#{fragment}" + + target_body = target_body.strip() + if not target_body: + return None + + current_source = PurePosixPath(source_rel.as_posix()) + candidate_paths = [PurePosixPath(target_body)] + if not PurePosixPath(target_body).suffix: + candidate_paths.append(PurePosixPath(f"{target_body}.md")) + + for candidate in candidate_paths: + joined = current_source.parent.joinpath(candidate) + normalized = PurePosixPath(posixpath.normpath(joined.as_posix())) + output_target = source_to_output.get(normalized.as_posix()) + if output_target is None: + continue + return f"{rel_link_to(output_target, current_output)}{fragment}" + + if target_body == "Home.md": + return f"{rel_link_to(PurePosixPath('index.md'), current_output)}{fragment}" + + return None + + +def rewrite_markdown_links( + text: str, + source_rel: Path, + current_output: PurePosixPath, + source_to_output: dict[str, PurePosixPath], +) -> str: + def replace(match: re.Match[str]) -> str: + label = match.group(1) + target = match.group(2) + normalized = normalize_local_markdown_target( + target, + source_rel, + current_output, + source_to_output, + ) + if normalized is None: + return match.group(0) + return f"[{label}]({normalized})" + + return MARKDOWN_LINK_RE.sub(replace, text) + + +def stage_wiki(source_dir: Path, output_dir: Path) -> None: + if not source_dir.is_dir(): + raise FileNotFoundError(f"wiki source directory not found: {source_dir}") + + if not (source_dir / "Home.md").is_file(): + raise FileNotFoundError( + f"expected {source_dir / 'Home.md'} so MkDocs can build an index page" + ) + + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + page_lookup, source_to_output = build_page_lookup(source_dir) + + for source_path in sorted(source_dir.rglob("*")): + if not source_path.is_file(): + continue + + source_rel = source_path.relative_to(source_dir) + if should_skip(source_rel): + continue + + output_rel = output_rel_for(source_rel) + destination = output_dir / output_rel + destination.parent.mkdir(parents=True, exist_ok=True) + + if not is_markdown(source_rel): + shutil.copy2(source_path, destination) + continue + + text = source_path.read_text(encoding="utf-8") + current_output = PurePosixPath(output_rel.as_posix()) + text = rewrite_wiki_links(text, source_rel, current_output, page_lookup) + text = rewrite_markdown_links(text, source_rel, current_output, source_to_output) + destination.write_text(text, encoding="utf-8") + + +def main() -> int: + args = parse_args() + source_dir = Path(args.source) + output_dir = Path(args.output) + + try: + stage_wiki(source_dir, output_dir) + except Exception as exc: # pragma: no cover - CLI error path + print(exc, file=sys.stderr) + return 1 + + print(f"prepared MkDocs source at {output_dir} from {source_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/wiki-lint.sh b/scripts/wiki-lint.sh new file mode 100755 index 0000000..5e71f35 --- /dev/null +++ b/scripts/wiki-lint.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +set -euo pipefail + +autocorrect_repo="huacnlee/autocorrect" + +latest_autocorrect_release() { + curl --silent "https://api.github.com/repos/${autocorrect_repo}/releases/latest" | + grep '"tag_name":' | + sed -E 's/.*"([^"]+)".*/\1/' +} + +autocorrect_platform() { + uname | tr '[:upper:]' '[:lower:]' +} + +autocorrect_arch() { + uname -m | sed 's/x86_64/amd64/' +} + +autocorrect_libc_suffix() { + if ldd --version 2>&1 | grep -q 'musl'; then + printf '%s\n' '-musl' + return 0 + fi + + printf '%s\n' '' +} + +resolve_autocorrect_bin() { + if command -v autocorrect >/dev/null 2>&1; then + command -v autocorrect + return 0 + fi + + local candidate + for candidate in \ + "${repo_root}/target/wiki-venv/bin/autocorrect" \ + "/opt/homebrew/bin/autocorrect" \ + "/usr/local/bin/autocorrect" \ + "${HOME}/.autocorrect/bin/autocorrect" \ + "${HOME}/.cargo/bin/autocorrect" \ + "${HOME}/bin/autocorrect" \ + "${HOME}/.local/bin/autocorrect" + do + if [[ -x "${candidate}" ]]; then + printf '%s\n' "${candidate}" + return 0 + fi + done + + return 1 +} + +install_autocorrect_into_target() { + local install_dir="${repo_root}/target/wiki-venv/bin" + local install_bin="${install_dir}/autocorrect" + local version platform arch libc_suffix archive_url temp_dir + + mkdir -p "${install_dir}" + + version="$(latest_autocorrect_release)" + platform="$(autocorrect_platform)" + arch="$(autocorrect_arch)" + libc_suffix="$(autocorrect_libc_suffix)" + archive_url="https://github.com/${autocorrect_repo}/releases/download/${version}/autocorrect-${platform}${libc_suffix}-${arch}.tar.gz" + temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/autocorrect-install.XXXXXX")" + + printf '%s\n' "autocorrect is not installed; downloading ${archive_url} into ${install_dir}" >&2 + curl -fsSL -o "${temp_dir}/autocorrect.tar.gz" "${archive_url}" + tar -xzf "${temp_dir}/autocorrect.tar.gz" -C "${temp_dir}" + + if [[ ! -f "${temp_dir}/autocorrect" ]]; then + printf '%s\n' "autocorrect archive from ${archive_url} did not contain an autocorrect binary" >&2 + rm -rf "${temp_dir}" + exit 1 + fi + + mv "${temp_dir}/autocorrect" "${install_bin}" + chmod 0755 "${install_bin}" + rm -rf "${temp_dir}" + + printf '%s\n' "${install_bin}" +} + +ensure_autocorrect() { + local autocorrect_bin + if autocorrect_bin="$(resolve_autocorrect_bin)"; then + printf '%s\n' "${autocorrect_bin}" + return 0 + fi + + autocorrect_bin="$(install_autocorrect_into_target)" + + if [[ -x "${autocorrect_bin}" ]]; then + printf '%s\n' "${autocorrect_bin}" + return 0 + fi + + printf '%s\n' "autocorrect installation completed but no executable was found in ${repo_root}/target/wiki-venv/bin" >&2 + exit 1 +} + +repo_root="$(git rev-parse --show-toplevel)" +cd "${repo_root}" + +autocorrect_bin="$(ensure_autocorrect)" +exec "${autocorrect_bin}" --lint docs/wiki "$@" diff --git a/scripts/wiki-site.sh b/scripts/wiki-site.sh new file mode 100755 index 0000000..61977dd --- /dev/null +++ b/scripts/wiki-site.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: ./scripts/wiki-site.sh [mkdocs args...] + +prepare Stage docs/wiki into target/wiki-docs without installing MkDocs. +serve Prepare the wiki, install MkDocs into target/wiki-venv, and start a local server. +build Prepare the wiki, install MkDocs into target/wiki-venv, and build target/wiki-site. +EOF +} + +repo_root="$(git rev-parse --show-toplevel)" +requirements_file="${repo_root}/docs/wiki-requirements.txt" +venv_dir="${repo_root}/target/wiki-venv" +python_bin="${venv_dir}/bin/python3" +wiki_dev_addr="${CRIEW_WIKI_DEV_ADDR:-0.0.0.0:8000}" + +prepare_wiki() { + python3 "${repo_root}/scripts/prepare-wiki-site.py" +} + +ensure_mkdocs() { + if [[ ! -x "${python_bin}" ]]; then + python3 -m venv "${venv_dir}" + "${python_bin}" -m pip install --upgrade pip + fi + + "${python_bin}" -m pip install -r "${requirements_file}" +} + +command_name="${1:-}" +if [[ -z "${command_name}" ]]; then + usage >&2 + exit 1 +fi + +shift || true + +case "${command_name}" in + prepare) + prepare_wiki + ;; + serve) + prepare_wiki + ensure_mkdocs + exec "${python_bin}" -m mkdocs serve \ + -f "${repo_root}/mkdocs.yml" \ + --dev-addr "${wiki_dev_addr}" \ + "$@" + ;; + build) + prepare_wiki + ensure_mkdocs + exec "${python_bin}" -m mkdocs build -f "${repo_root}/mkdocs.yml" --clean "$@" + ;; + *) + usage >&2 + exit 1 + ;; +esac diff --git a/src/app/cli.rs b/src/app/cli.rs index ba1b803..54cc0bf 100644 --- a/src/app/cli.rs +++ b/src/app/cli.rs @@ -29,8 +29,8 @@ pub enum Command { Tui, /// Execute mailbox sync worker. Sync { - /// Mailbox name to sync (defaults to [source].mailbox or linux-kernel). - /// Use INBOX to trigger real IMAP sync when [imap] config is complete. + /// Mailbox name to sync (defaults to `source.mailbox` or `linux-kernel`). + /// Use `INBOX` to trigger real IMAP sync when the IMAP config is complete. #[arg(long)] mailbox: Option, /// Local fixture directory for offline/local test (.eml files). diff --git a/src/app/mod.rs b/src/app/mod.rs index a93db96..00ad410 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -7,18 +7,26 @@ pub mod cli; pub mod patch; pub mod sync; +use std::path::PathBuf; + use clap::Parser; use crate::infra::b4::{self, B4Status}; use crate::infra::bootstrap; use crate::infra::config::{self, IMAP_INBOX_MAILBOX}; use crate::infra::error::Result; -use crate::infra::imap::{ImapClient, RemoteImapClient}; +use crate::infra::imap::{ImapClient, MailboxSnapshot, RemoteImapClient}; use crate::infra::logging; -use crate::infra::sendmail::{self, GitSendEmailStatus}; +use crate::infra::sendmail::{self, GitSendEmailCheck, GitSendEmailStatus, ReplyIdentity}; const DEFAULT_SYNC_RECONNECT_ATTEMPTS: u8 = 3; +enum DoctorImapStatus { + Skipped(Vec), + Connected(MailboxSnapshot), + Error(String), +} + pub fn run() -> Result<()> { let cli = cli::Cli::parse(); let command = cli.command.unwrap_or(cli::Command::Tui); @@ -55,12 +63,13 @@ pub fn run() -> Result<()> { uidvalidity, reconnect_attempts, } => { - let request = sync::SyncRequest { - mailbox: mailbox.unwrap_or_else(|| runtime.source_mailbox.clone()), + let request = build_sync_request( + &runtime, + mailbox, fixture_dir, uidvalidity, - reconnect_attempts: reconnect_attempts.unwrap_or(DEFAULT_SYNC_RECONNECT_ATTEMPTS), - }; + reconnect_attempts, + ); run_sync_command(&runtime, &bootstrap_state, request, true)?; Ok(()) } @@ -83,6 +92,7 @@ pub fn run() -> Result<()> { ); println!(" lore_base_url: {}", runtime.lore_base_url); println!(" startup_sync: {}", runtime.startup_sync); + println!(" ui_keymap: {}", runtime.ui_keymap.as_str()); println!( " inbox_auto_sync_interval_secs: {}", runtime.inbox_auto_sync_interval_secs @@ -108,120 +118,19 @@ pub fn run() -> Result<()> { bootstrap_state.db.created ); let self_email = config::resolve_self_email(&runtime); + let imap_status = probe_doctor_imap(&runtime); println!( - " self_email: {}", - self_email.email.as_deref().unwrap_or("") - ); - println!( - " self_email_source: {}", - self_email - .source - .map(|source| source.as_str()) - .unwrap_or("") - ); - if let Some(error) = self_email.git_error.as_deref() { - println!(" self_email_lookup: error ({error})"); - } - println!( - " imap_config_status: {}", - if runtime.imap.is_complete() { - "complete" - } else { - "incomplete" - } + "{}", + format_doctor_report( + &runtime, + &bootstrap_state, + &self_email, + &imap_status, + &send_email_status, + &reply_identity, + &b4_status, + ) ); - if runtime.imap.is_complete() { - println!( - " imap_connection_target: {}:{} ({})", - runtime.imap.server.as_deref().unwrap_or(""), - runtime.imap.server_port.unwrap_or_default(), - runtime - .imap - .encryption - .map(|value| value.as_str()) - .unwrap_or("") - ); - - match RemoteImapClient::new(runtime.imap.clone()).and_then(|mut client| { - client.connect()?; - client.select_mailbox(IMAP_INBOX_MAILBOX) - }) { - Ok(snapshot) => { - println!(" imap_connect_status: ok"); - println!(" imap_select_mailbox: {}", IMAP_INBOX_MAILBOX); - println!(" imap_uidvalidity: {}", snapshot.uidvalidity); - println!(" imap_highest_uid: {}", snapshot.highest_uid); - println!( - " imap_highest_modseq: {}", - snapshot - .highest_modseq - .map(|value| value.to_string()) - .unwrap_or_else(|| "".to_string()) - ); - } - Err(error) => { - println!(" imap_connect_status: error ({error})"); - } - } - } else { - let missing = runtime.imap.missing_required_fields(); - println!( - " imap_missing_fields: {}", - if missing.is_empty() { - "".to_string() - } else { - missing.join(", ") - } - ); - println!(" imap_connect_status: skipped"); - } - - match send_email_status.status { - GitSendEmailStatus::Available { path, version } => { - println!(" git_send_email_path: {}", path.display()); - println!(" git_send_email_version: {}", version); - println!(" git_send_email_status: ok"); - } - GitSendEmailStatus::Broken { path, reason } => { - println!(" git_send_email_path: {}", path.display()); - println!(" git_send_email_status: broken ({reason})"); - } - GitSendEmailStatus::Missing => { - println!(" git_send_email_path: "); - println!(" git_send_email_status: missing"); - } - } - - match reply_identity { - Ok(identity) => { - println!(" reply_from: {}", identity.display); - println!(" reply_from_email: {}", identity.email); - println!(" reply_from_source: {}", identity.source.as_str()); - println!(" reply_identity_status: ok"); - } - Err(error) => { - println!(" reply_from: "); - println!(" reply_from_email: "); - println!(" reply_from_source: "); - println!(" reply_identity_status: error ({error})"); - } - } - - match b4_status.status { - B4Status::Available { path, version } => { - println!(" b4_path: {}", path.display()); - println!(" b4_version: {}", version); - println!(" b4_status: ok"); - } - B4Status::Broken { path, reason } => { - println!(" b4_path: {}", path.display()); - println!(" b4_status: broken ({reason})"); - } - B4Status::Missing => { - println!(" b4_path: "); - println!(" b4_status: missing"); - } - } Ok(()) } @@ -249,24 +158,482 @@ fn run_sync_command( ); if print_summary { - println!( - "sync complete: mailbox={} source={} fetched={} inserted={} updated={} rebuilt_roots={} uidvalidity={} last_seen_uid={} highest_modseq={:?} synced_at={} mailbox_rebuilt={}", - summary.mailbox, - summary.source, - summary.fetched, - summary.inserted, - summary.updated, - summary.rebuilt_roots, - summary.uidvalidity, - summary.checkpoint_last_seen_uid, - summary.checkpoint_highest_modseq, - summary - .checkpoint_synced_at - .as_deref() - .unwrap_or(""), - summary.mailbox_rebuilt - ); + println!("{}", format_sync_summary(&summary)); } Ok(summary) } + +fn build_sync_request( + runtime: &config::RuntimeConfig, + mailbox: Option, + fixture_dir: Option, + uidvalidity: Option, + reconnect_attempts: Option, +) -> sync::SyncRequest { + sync::SyncRequest { + mailbox: mailbox.unwrap_or_else(|| runtime.source_mailbox.clone()), + fixture_dir, + uidvalidity, + reconnect_attempts: reconnect_attempts.unwrap_or(DEFAULT_SYNC_RECONNECT_ATTEMPTS), + } +} + +fn probe_doctor_imap(runtime: &config::RuntimeConfig) -> DoctorImapStatus { + if !runtime.imap.is_complete() { + return DoctorImapStatus::Skipped( + runtime + .imap + .missing_required_fields() + .into_iter() + .map(ToOwned::to_owned) + .collect(), + ); + } + + match RemoteImapClient::new(runtime.imap.clone()).and_then(|mut client| { + client.connect()?; + client.select_mailbox(IMAP_INBOX_MAILBOX) + }) { + Ok(snapshot) => DoctorImapStatus::Connected(snapshot), + Err(error) => DoctorImapStatus::Error(error.to_string()), + } +} + +fn format_doctor_report( + runtime: &config::RuntimeConfig, + bootstrap_state: &bootstrap::BootstrapState, + self_email: &config::SelfEmailResolution, + imap_status: &DoctorImapStatus, + send_email_status: &GitSendEmailCheck, + reply_identity: &std::result::Result, + b4_status: &b4::B4Check, +) -> String { + let mut lines = vec![ + "criew doctor".to_string(), + format!(" config_path: {}", runtime.config_path.display()), + format!(" data_dir: {}", runtime.data_dir.display()), + format!(" database_path: {}", runtime.database_path.display()), + format!(" raw_mail_dir: {}", runtime.raw_mail_dir.display()), + format!(" patch_dir: {}", runtime.patch_dir.display()), + format!(" log_dir: {}", runtime.log_dir.display()), + format!(" source_mailbox: {}", runtime.source_mailbox), + format!( + " default_active_mailbox: {}", + runtime.default_active_mailbox() + ), + format!(" lore_base_url: {}", runtime.lore_base_url), + format!(" startup_sync: {}", runtime.startup_sync), + format!( + " inbox_auto_sync_interval_secs: {}", + runtime.inbox_auto_sync_interval_secs + ), + ]; + if runtime.kernel_trees.is_empty() { + lines.push(" kernel_trees: ".to_string()); + } else { + lines.extend( + runtime + .kernel_trees + .iter() + .map(|tree| format!(" kernel_tree: {}", tree.display())), + ); + } + lines.push(format!( + " schema_version: {}", + bootstrap_state.db.schema_version + )); + lines.push(format!( + " schema_version_expected: {}", + crate::infra::db::CURRENT_SCHEMA_VERSION + )); + lines.push(format!( + " migrations_applied: {:?}", + bootstrap_state.db.applied_migrations + )); + lines.push(format!( + " database_created_this_run: {}", + bootstrap_state.db.created + )); + lines.push(format!( + " self_email: {}", + self_email.email.as_deref().unwrap_or("") + )); + lines.push(format!( + " self_email_source: {}", + self_email + .source + .map(|source| source.as_str()) + .unwrap_or("") + )); + if let Some(error) = self_email.git_error.as_deref() { + lines.push(format!(" self_email_lookup: error ({error})")); + } + lines.push(format!( + " imap_config_status: {}", + if runtime.imap.is_complete() { + "complete" + } else { + "incomplete" + } + )); + match imap_status { + DoctorImapStatus::Skipped(missing_fields) => { + lines.push(format!( + " imap_missing_fields: {}", + if missing_fields.is_empty() { + "".to_string() + } else { + missing_fields.join(", ") + } + )); + lines.push(" imap_connect_status: skipped".to_string()); + } + DoctorImapStatus::Connected(snapshot) => { + lines.push(format!( + " imap_connection_target: {}:{} ({})", + runtime.imap.server.as_deref().unwrap_or(""), + runtime.imap.server_port.unwrap_or_default(), + runtime + .imap + .encryption + .map(|value| value.as_str()) + .unwrap_or("") + )); + lines.push(" imap_connect_status: ok".to_string()); + lines.push(format!(" imap_select_mailbox: {}", IMAP_INBOX_MAILBOX)); + lines.push(format!(" imap_uidvalidity: {}", snapshot.uidvalidity)); + lines.push(format!(" imap_highest_uid: {}", snapshot.highest_uid)); + lines.push(format!( + " imap_highest_modseq: {}", + snapshot + .highest_modseq + .map(|value| value.to_string()) + .unwrap_or_else(|| "".to_string()) + )); + } + DoctorImapStatus::Error(error) => { + lines.push(format!( + " imap_connection_target: {}:{} ({})", + runtime.imap.server.as_deref().unwrap_or(""), + runtime.imap.server_port.unwrap_or_default(), + runtime + .imap + .encryption + .map(|value| value.as_str()) + .unwrap_or("") + )); + lines.push(format!(" imap_connect_status: error ({error})")); + } + } + match &send_email_status.status { + GitSendEmailStatus::Available { path, version } => { + lines.push(format!(" git_send_email_path: {}", path.display())); + lines.push(format!(" git_send_email_version: {}", version)); + lines.push(" git_send_email_status: ok".to_string()); + } + GitSendEmailStatus::Broken { path, reason } => { + lines.push(format!(" git_send_email_path: {}", path.display())); + lines.push(format!(" git_send_email_status: broken ({reason})")); + } + GitSendEmailStatus::Missing => { + lines.push(" git_send_email_path: ".to_string()); + lines.push(" git_send_email_status: missing".to_string()); + } + } + match reply_identity { + Ok(identity) => { + lines.push(format!(" reply_from: {}", identity.display)); + lines.push(format!(" reply_from_email: {}", identity.email)); + lines.push(format!(" reply_from_source: {}", identity.source.as_str())); + lines.push(" reply_identity_status: ok".to_string()); + } + Err(error) => { + lines.push(" reply_from: ".to_string()); + lines.push(" reply_from_email: ".to_string()); + lines.push(" reply_from_source: ".to_string()); + lines.push(format!(" reply_identity_status: error ({error})")); + } + } + match &b4_status.status { + B4Status::Available { path, version } => { + lines.push(format!(" b4_path: {}", path.display())); + lines.push(format!(" b4_version: {}", version)); + lines.push(" b4_status: ok".to_string()); + } + B4Status::Broken { path, reason } => { + lines.push(format!(" b4_path: {}", path.display())); + lines.push(format!(" b4_status: broken ({reason})")); + } + B4Status::Missing => { + lines.push(" b4_path: ".to_string()); + lines.push(" b4_status: missing".to_string()); + } + } + lines.join("\n") +} + +fn format_sync_summary(summary: &sync::SyncSummary) -> String { + format!( + "sync complete: mailbox={} source={} fetched={} inserted={} updated={} rebuilt_roots={} uidvalidity={} last_seen_uid={} highest_modseq={:?} synced_at={} mailbox_rebuilt={}", + summary.mailbox, + summary.source, + summary.fetched, + summary.inserted, + summary.updated, + summary.rebuilt_roots, + summary.uidvalidity, + summary.checkpoint_last_seen_uid, + summary.checkpoint_highest_modseq, + summary + .checkpoint_synced_at + .as_deref() + .unwrap_or(""), + summary.mailbox_rebuilt + ) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::infra::b4::B4Status; + use crate::infra::bootstrap::BootstrapState; + use crate::infra::config::{ + DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, ImapConfig, ImapEncryption, RuntimeConfig, + SelfEmailResolution, SelfEmailSource, UiKeymap, + }; + use crate::infra::db::DatabaseState; + use crate::infra::imap::MailboxSnapshot; + use crate::infra::sendmail::{ + GitSendEmailCheck, GitSendEmailStatus, ReplyIdentity, ReplyIdentitySource, + }; + + use super::{ + DoctorImapStatus, build_sync_request, format_doctor_report, format_sync_summary, + probe_doctor_imap, + }; + + fn test_runtime() -> RuntimeConfig { + RuntimeConfig { + config_path: PathBuf::from("/tmp/criew-config.toml"), + data_dir: PathBuf::from("/tmp/criew"), + database_path: PathBuf::from("/tmp/criew/criew.db"), + raw_mail_dir: PathBuf::from("/tmp/criew/raw"), + patch_dir: PathBuf::from("/tmp/criew/patches"), + log_dir: PathBuf::from("/tmp/criew/logs"), + b4_path: None, + log_filter: "info".to_string(), + source_mailbox: "linux-kernel".to_string(), + imap: ImapConfig::default(), + lore_base_url: "https://lore.kernel.org".to_string(), + startup_sync: true, + ui_keymap: UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), + inbox_auto_sync_interval_secs: DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, + kernel_trees: Vec::new(), + } + } + + fn test_bootstrap_state() -> BootstrapState { + BootstrapState { + db: DatabaseState { + path: PathBuf::from("/tmp/criew/criew.db"), + schema_version: crate::infra::db::CURRENT_SCHEMA_VERSION, + created: false, + applied_migrations: vec![1, 2, 3, crate::infra::db::CURRENT_SCHEMA_VERSION], + }, + } + } + + #[test] + fn build_sync_request_uses_runtime_defaults_and_overrides() { + let runtime = test_runtime(); + + let default_request = build_sync_request(&runtime, None, None, None, None); + assert_eq!(default_request.mailbox, "linux-kernel"); + assert_eq!( + default_request.reconnect_attempts, + super::DEFAULT_SYNC_RECONNECT_ATTEMPTS + ); + + let overridden_request = build_sync_request( + &runtime, + Some("INBOX".to_string()), + Some(PathBuf::from("/tmp/fixtures")), + Some(42), + Some(7), + ); + assert_eq!(overridden_request.mailbox, "INBOX"); + assert_eq!( + overridden_request.fixture_dir, + Some(PathBuf::from("/tmp/fixtures")) + ); + assert_eq!(overridden_request.uidvalidity, Some(42)); + assert_eq!(overridden_request.reconnect_attempts, 7); + } + + #[test] + fn format_sync_summary_renders_unknown_synced_at_fallback() { + let summary = crate::app::sync::SyncSummary { + mailbox: "linux-kernel".to_string(), + source: "fixture".to_string(), + fetched: 3, + inserted: 2, + updated: 1, + rebuilt_roots: 4, + mailbox_rebuilt: false, + uidvalidity: 11, + checkpoint_last_seen_uid: 99, + checkpoint_highest_modseq: Some(123), + checkpoint_synced_at: None, + }; + + let rendered = format_sync_summary(&summary); + + assert!(rendered.contains("mailbox=linux-kernel")); + assert!(rendered.contains("source=fixture")); + assert!(rendered.contains("synced_at=")); + } + + #[test] + fn format_doctor_report_covers_incomplete_imap_and_missing_tools() { + let runtime = test_runtime(); + let self_email = SelfEmailResolution { + email: None, + source: None, + git_error: Some("git config failed".to_string()), + }; + let report = format_doctor_report( + &runtime, + &test_bootstrap_state(), + &self_email, + &DoctorImapStatus::Skipped(vec!["imap.user".to_string(), "imap.pass".to_string()]), + &GitSendEmailCheck { + status: GitSendEmailStatus::Missing, + }, + &Err("missing reply identity".to_string()), + &crate::infra::b4::B4Check { + status: B4Status::Missing, + }, + ); + + assert!(report.contains("criew doctor")); + assert!(report.contains("kernel_trees: ")); + assert!(report.contains("self_email_lookup: error (git config failed)")); + assert!(report.contains("imap_config_status: incomplete")); + assert!(report.contains("imap_missing_fields: imap.user, imap.pass")); + assert!(report.contains("imap_connect_status: skipped")); + assert!(report.contains("git_send_email_status: missing")); + assert!(report.contains("reply_identity_status: error (missing reply identity)")); + assert!(report.contains("b4_status: missing")); + } + + #[test] + fn format_doctor_report_covers_connected_imap_and_available_tools() { + let mut runtime = test_runtime(); + runtime.kernel_trees = vec![PathBuf::from("/tmp/linux")]; + runtime.imap = ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }; + let self_email = SelfEmailResolution { + email: Some("me@example.com".to_string()), + source: Some(SelfEmailSource::CriewImapConfig), + git_error: None, + }; + let report = format_doctor_report( + &runtime, + &test_bootstrap_state(), + &self_email, + &DoctorImapStatus::Connected(MailboxSnapshot { + uidvalidity: 7, + highest_uid: 88, + highest_modseq: Some(99), + }), + &GitSendEmailCheck { + status: GitSendEmailStatus::Available { + path: PathBuf::from("/usr/bin/git"), + version: "2.45".to_string(), + }, + }, + &Ok(ReplyIdentity { + display: "CRIEW ".to_string(), + email: "me@example.com".to_string(), + source: ReplyIdentitySource::SendEmailFrom, + }), + &crate::infra::b4::B4Check { + status: B4Status::Available { + path: PathBuf::from("/usr/bin/b4"), + version: "0.14".to_string(), + }, + }, + ); + + assert!(report.contains("kernel_tree: /tmp/linux")); + assert!(report.contains("imap_config_status: complete")); + assert!(report.contains("imap_connection_target: imap.example.com:993 (tls)")); + assert!(report.contains("imap_connect_status: ok")); + assert!(report.contains("imap_uidvalidity: 7")); + assert!(report.contains("git_send_email_status: ok")); + assert!(report.contains("reply_identity_status: ok")); + assert!(report.contains("b4_status: ok")); + } + + #[test] + fn probe_doctor_imap_skips_incomplete_config_without_network_access() { + let status = probe_doctor_imap(&test_runtime()); + + match status { + DoctorImapStatus::Skipped(missing_fields) => { + assert!(missing_fields.contains(&"imap.user".to_string())); + assert!(missing_fields.contains(&"imap.pass".to_string())); + } + _ => panic!("expected incomplete IMAP config to skip probing"), + } + } + + #[test] + fn format_doctor_report_covers_imap_error_and_broken_tools() { + let mut runtime = test_runtime(); + runtime.imap = ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }; + + let report = format_doctor_report( + &runtime, + &test_bootstrap_state(), + &SelfEmailResolution::default(), + &DoctorImapStatus::Error("connect failed".to_string()), + &GitSendEmailCheck { + status: GitSendEmailStatus::Broken { + path: PathBuf::from("/usr/bin/git"), + reason: "missing send-email".to_string(), + }, + }, + &Err("missing reply identity".to_string()), + &crate::infra::b4::B4Check { + status: B4Status::Broken { + path: PathBuf::from("/usr/bin/b4"), + reason: "bad runtime".to_string(), + }, + }, + ); + + assert!(report.contains("imap_connect_status: error (connect failed)")); + assert!(report.contains("git_send_email_status: broken (missing send-email)")); + assert!(report.contains("b4_status: broken (bad runtime)")); + } +} diff --git a/src/app/patch.rs b/src/app/patch.rs index 39a3cf1..8a9b101 100644 --- a/src/app/patch.rs +++ b/src/app/patch.rs @@ -1127,19 +1127,29 @@ fn format_seq(values: &[u32]) -> String { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::fs; - use std::path::PathBuf; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; use std::process::Command as ProcessCommand; use std::time::{SystemTime, UNIX_EPOCH}; + use rusqlite::{Connection, params}; + + use crate::domain::models::PatchSeriesStatus; use crate::infra::config::RuntimeConfig; + use crate::infra::db; + use crate::infra::error::ErrorCode; use crate::infra::mail_store::ThreadRow; + use crate::infra::patch_store; use super::{ APPLY_ARTIFACTS_DIR, PatchAction, SeriesIntegrity, action_args, action_subcommand, - action_working_dir, build_series_index, download_series_name, parse_patch_subject, - parse_seq_total_token, parse_version_token, relocate_new_apply_artifacts, - snapshot_apply_artifacts, subject_is_patch_related, undo_last_apply, + action_working_dir, build_series_index, download_series_name, hydrate_series_statuses, + load_latest_report, parse_patch_subject, parse_seq_total_token, parse_version_token, + relocate_new_apply_artifacts, run_action, snapshot_apply_artifacts, + subject_is_patch_related, undo_last_apply, }; fn temp_dir(label: &str) -> PathBuf { @@ -1147,7 +1157,10 @@ mod tests { .duration_since(UNIX_EPOCH) .expect("system time") .as_nanos(); - let path = std::env::temp_dir().join(format!("criew-patch-{label}-{nonce}")); + let path = std::env::temp_dir().join(format!( + "criew-patch-{label}-{}-{nonce}", + std::process::id() + )); fs::create_dir_all(&path).expect("create temp dir"); path } @@ -1167,6 +1180,32 @@ mod tests { imap: crate::infra::config::ImapConfig::default(), lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), + inbox_auto_sync_interval_secs: + crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, + kernel_trees, + } + } + + fn runtime_in(root: &Path, kernel_trees: Vec) -> RuntimeConfig { + RuntimeConfig { + config_path: root.join("config.toml"), + data_dir: root.join("data"), + database_path: root.join("data").join("criew.db"), + raw_mail_dir: root.join("data").join("raw"), + patch_dir: root.join("data").join("patches"), + log_dir: root.join("data").join("logs"), + b4_path: None, + log_filter: "info".to_string(), + source_mailbox: "io-uring".to_string(), + imap: crate::infra::config::ImapConfig::default(), + lore_base_url: "https://lore.kernel.org".to_string(), + startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees, @@ -1204,6 +1243,41 @@ mod tests { String::from_utf8_lossy(&output.stdout).trim().to_string() } + fn write_script(root: &Path, name: &str, body: &str) -> PathBuf { + let path = root.join(name); + fs::write(&path, body).expect("write script"); + #[cfg(unix)] + { + let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("mark executable"); + } + path + } + + fn seed_mail_rows(path: &Path, rows: &[(i64, &str)]) { + let connection = Connection::open(path).expect("open db"); + for (id, message_id) in rows { + connection + .execute( + " +INSERT INTO mail(id, message_id, subject, from_addr, imap_mailbox, imap_uid) +VALUES (?1, ?2, ?3, ?4, 'io-uring', ?1) +", + params![id, message_id, format!("subject-{id}"), "alice@example.com"], + ) + .expect("insert mail row"); + } + } + + fn initialize_patch_runtime(runtime: &RuntimeConfig, mail_rows: &[(i64, &str)]) { + if let Some(parent) = runtime.database_path.parent() { + fs::create_dir_all(parent).expect("create db parent"); + } + let _ = db::initialize(&runtime.database_path).expect("initialize db"); + seed_mail_rows(&runtime.database_path, mail_rows); + } + fn thread_row( thread_id: i64, mail_id: i64, @@ -1250,6 +1324,162 @@ mod tests { } } + #[test] + fn integrity_helpers_match_user_visible_patch_status_contract() { + let mut summary = sample_summary("[PATCH 1/1] io_uring: demo", 1); + + assert_eq!(summary.present_count(), 1); + assert_eq!(summary.status_label(), "new"); + assert_eq!(SeriesIntegrity::Complete.as_str(), "complete"); + assert_eq!(SeriesIntegrity::Complete.short_label(), "ready"); + assert!(SeriesIntegrity::Complete.is_ready()); + assert_eq!(summary.integrity_reason(), None); + + summary.integrity = SeriesIntegrity::Missing; + summary.missing_seq = vec![2, 4]; + assert_eq!(summary.integrity.as_str(), "missing"); + assert_eq!(summary.integrity.short_label(), "missing"); + assert!(!summary.integrity.is_ready()); + assert_eq!( + summary.integrity_reason().as_deref(), + Some("missing patch index: 2,4") + ); + + summary.integrity = SeriesIntegrity::Duplicate; + summary.duplicate_seq = vec![1]; + assert_eq!(summary.integrity.as_str(), "duplicate"); + assert_eq!(summary.integrity.short_label(), "duplicate"); + assert_eq!( + summary.integrity_reason().as_deref(), + Some("duplicate patch index: 1") + ); + + summary.integrity = SeriesIntegrity::OutOfOrder; + assert_eq!(summary.integrity.as_str(), "out-of-order"); + assert_eq!(summary.integrity.short_label(), "out-of-order"); + assert_eq!( + summary.integrity_reason().as_deref(), + Some("patch order is out-of-order in thread") + ); + + summary.integrity = SeriesIntegrity::Invalid; + assert_eq!(summary.integrity.as_str(), "invalid"); + assert_eq!(summary.integrity.short_label(), "invalid"); + assert_eq!( + summary.integrity_reason().as_deref(), + Some("no valid [PATCH vN M/N] sequence found") + ); + + summary.status = PatchSeriesStatus::Conflict; + assert_eq!(summary.status_label(), "conflict"); + } + + #[test] + fn hydrate_series_statuses_overlays_only_visible_threads() { + let root = temp_dir("hydrate-status"); + let runtime = runtime_in(&root, Vec::new()); + initialize_patch_runtime(&runtime, &[(1, "patch@example.com")]); + + let series = patch_store::upsert_series( + &runtime.database_path, + &patch_store::UpsertSeriesRequest { + mailbox: "io-uring".to_string(), + thread_id: 42, + version: 1, + expected_total: 1, + author: "Alice".to_string(), + subject: "demo".to_string(), + anchor_message_id: "patch@example.com".to_string(), + integrity: "complete".to_string(), + missing_seq: Vec::new(), + duplicate_seq: Vec::new(), + out_of_order: false, + items: vec![patch_store::UpsertSeriesItem { + seq: 1, + total: 1, + mail_id: 1, + message_id: "patch@example.com".to_string(), + subject: "demo".to_string(), + raw_path: None, + sort_ord: 0, + }], + }, + ) + .expect("persist series"); + patch_store::update_series_result( + &runtime.database_path, + series.id, + &patch_store::SeriesResultUpdate { + status: PatchSeriesStatus::Applied, + last_error: None, + last_command: Some("b4 am patch@example.com".to_string()), + last_exit_code: Some(0), + last_stdout: Some("applied".to_string()), + last_stderr: None, + output_path: None, + }, + ) + .expect("update series status"); + + let mut visible_summaries = HashMap::from([ + (42, sample_summary("[PATCH 1/1] io_uring: demo", 1)), + ( + 99, + super::SeriesSummary { + thread_id: 99, + status: PatchSeriesStatus::Failed, + ..sample_summary("[PATCH 1/1] io_uring: another", 1) + }, + ), + ]); + + hydrate_series_statuses(&runtime.database_path, "io-uring", &mut visible_summaries) + .expect("hydrate statuses"); + + assert_eq!( + visible_summaries.get(&42).map(|summary| summary.status), + Some(PatchSeriesStatus::Applied) + ); + assert_eq!( + visible_summaries.get(&99).map(|summary| summary.status), + Some(PatchSeriesStatus::Failed) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn load_latest_report_returns_none_when_thread_has_no_patch_state() { + let root = temp_dir("latest-report-none"); + let runtime = runtime_in(&root, Vec::new()); + initialize_patch_runtime(&runtime, &[]); + + let report = + load_latest_report(&runtime.database_path, "io-uring", 42).expect("load latest report"); + assert!(report.is_none()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_action_rejects_incomplete_series_before_persisting_or_running_b4() { + let root = temp_dir("reject-incomplete"); + let runtime = runtime_in(&root, Vec::new()); + let mut summary = sample_summary("[PATCH 1/2] io_uring: demo", 1); + summary.integrity = SeriesIntegrity::Missing; + summary.expected_total = 2; + summary.missing_seq = vec![2]; + + let error = run_action(&runtime, &summary, PatchAction::Apply).expect_err("reject apply"); + + assert_eq!(error.code(), ErrorCode::Command); + assert!(error.to_string().contains("cannot apply series")); + assert!(error.to_string().contains("missing patch index: 2")); + assert!(!runtime.database_path.exists()); + + let _ = fs::remove_dir_all(root); + } + #[test] fn parses_patch_subject_with_version_and_seq() { let parsed = parse_patch_subject("[PATCH v5 2/9] io_uring: demo").expect("parsed"); @@ -1359,6 +1589,59 @@ mod tests { assert_eq!(series.integrity, SeriesIntegrity::Complete); } + #[test] + fn build_series_index_detects_duplicate_and_out_of_order_series() { + let duplicate_rows = vec![ + thread_row(20, 1, "[PATCH v2 1/2] first", "a@example.com", 0), + thread_row(20, 2, "[PATCH v2 1/2] reroll", "b@example.com", 1), + thread_row(20, 3, "[PATCH v2 2/2] second", "c@example.com", 1), + ]; + let duplicate_index = build_series_index("io-uring", &duplicate_rows); + let duplicate = duplicate_index.get(&20).expect("duplicate series exists"); + assert_eq!(duplicate.integrity, SeriesIntegrity::Duplicate); + assert_eq!(duplicate.duplicate_seq, vec![1]); + assert_eq!( + duplicate.integrity_reason().as_deref(), + Some("duplicate patch index: 1") + ); + assert_eq!(duplicate.integrity.short_label(), "duplicate"); + + let out_of_order_rows = vec![ + thread_row(21, 1, "[PATCH v1 2/2] second", "p2@example.com", 0), + thread_row(21, 2, "[PATCH v1 1/2] first", "p1@example.com", 1), + ]; + let out_of_order_index = build_series_index("io-uring", &out_of_order_rows); + let out_of_order = out_of_order_index + .get(&21) + .expect("out-of-order series exists"); + assert_eq!(out_of_order.integrity, SeriesIntegrity::OutOfOrder); + assert!(out_of_order.out_of_order); + assert_eq!( + out_of_order.integrity_reason().as_deref(), + Some("patch order is out-of-order in thread") + ); + } + + #[test] + fn build_series_index_marks_cover_only_series_invalid() { + let rows = vec![thread_row( + 22, + 1, + "[PATCH v4 0/2] io_uring: cover letter only", + "cover@example.com", + 0, + )]; + let index = build_series_index("io-uring", &rows); + let series = index.get(&22).expect("series exists"); + assert_eq!(series.integrity, SeriesIntegrity::Invalid); + assert!(series.items.is_empty()); + assert_eq!(series.anchor_message_id, "cover@example.com"); + assert_eq!( + series.integrity_reason().as_deref(), + Some("no valid [PATCH vN M/N] sequence found") + ); + } + #[test] fn apply_requires_kernel_tree_configuration() { let runtime = runtime_with_kernel_trees(Vec::new()); @@ -1366,6 +1649,24 @@ mod tests { assert!(error.to_string().contains("[kernel].tree")); } + #[test] + fn apply_requires_existing_kernel_tree_directory() { + let root = temp_dir("apply-missing-tree"); + let missing = root.join("missing-tree"); + let runtime = runtime_in(&root, vec![missing.clone()]); + + let error = action_working_dir(&runtime, PatchAction::Apply).expect_err("missing tree"); + assert!(error.to_string().contains("does not exist")); + + let file_path = root.join("not-a-directory"); + fs::write(&file_path, "file").expect("write file"); + let runtime = runtime_in(&root, vec![file_path.clone()]); + let error = action_working_dir(&runtime, PatchAction::Apply).expect_err("file tree"); + assert!(error.to_string().contains("is not")); + + let _ = fs::remove_dir_all(root); + } + #[test] fn apply_uses_first_kernel_tree_directory() { let first = temp_dir("apply-first"); @@ -1386,6 +1687,187 @@ mod tests { assert!(working_dir.is_none()); } + #[test] + fn run_action_download_records_reviewing_report() { + let root = temp_dir("run-action-download"); + let b4_script = write_script( + &root, + "fake-b4.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 0.14.0'\n exit 0\nfi\nif [ \"$1\" = \"am\" ]; then\n echo 'downloaded patch series'\n exit 0\nfi\nexit 1\n", + ); + let mut runtime = runtime_in(&root, Vec::new()); + runtime.b4_path = Some(b4_script); + initialize_patch_runtime(&runtime, &[(1, "patch@example.com")]); + let summary = sample_summary("io_uring: exported series", 2); + + let result = run_action(&runtime, &summary, PatchAction::Download).expect("download runs"); + + assert_eq!(result.status, PatchSeriesStatus::Reviewing); + assert_eq!(result.exit_code, Some(0)); + assert!(!result.timed_out); + assert!(result.command_line.contains("am")); + assert!(result.summary.starts_with("series downloaded to ")); + let output_path = result.output_path.expect("download path"); + assert!(output_path.starts_with(&runtime.patch_dir)); + assert!(output_path.is_dir()); + + let latest = load_latest_report(&runtime.database_path, "io-uring", summary.thread_id) + .expect("load latest report") + .expect("report exists"); + assert_eq!(latest.status, PatchSeriesStatus::Reviewing); + assert_eq!(latest.last_error, None); + assert_eq!(latest.last_exit_code, Some(0)); + assert_eq!( + latest.last_summary.as_deref(), + Some(result.summary.as_str()) + ); + assert_eq!( + latest.last_command.as_deref(), + Some(result.command_line.as_str()) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_action_apply_marks_conflicts_in_latest_report() { + let root = temp_dir("run-action-conflict"); + let repo = root.join("linux"); + fs::create_dir_all(&repo).expect("create repo"); + run_git(&repo, &["init"]); + run_git(&repo, &["config", "user.name", "CRIEW Test"]); + run_git(&repo, &["config", "user.email", "criew@example.com"]); + fs::write(repo.join("base.txt"), "base\n").expect("write base"); + run_git(&repo, &["add", "base.txt"]); + run_git(&repo, &["commit", "-m", "base"]); + + let b4_script = write_script( + &root, + "fake-b4.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 0.14.0'\n exit 0\nfi\nif [ \"$1\" = \"shazam\" ]; then\n echo 'patch failed at 0001 demo' >&2\n exit 1\nfi\nexit 1\n", + ); + let mut runtime = runtime_in(&root, vec![repo.clone()]); + runtime.b4_path = Some(b4_script); + initialize_patch_runtime(&runtime, &[(1, "patch@example.com")]); + let summary = sample_summary("io_uring: conflict series", 1); + + let result = run_action(&runtime, &summary, PatchAction::Apply).expect("apply runs"); + + assert_eq!(result.status, PatchSeriesStatus::Conflict); + assert_eq!(result.exit_code, Some(1)); + assert!(result.summary.contains("series apply conflict")); + assert!(result.head_before.is_none()); + assert!(result.head_after.is_none()); + + let latest = load_latest_report(&runtime.database_path, "io-uring", summary.thread_id) + .expect("load latest report") + .expect("report exists"); + assert_eq!(latest.status, PatchSeriesStatus::Conflict); + assert_eq!(latest.last_error.as_deref(), Some(result.summary.as_str())); + assert_eq!( + latest.last_summary.as_deref(), + Some(result.summary.as_str()) + ); + assert_eq!(latest.last_exit_code, Some(1)); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_action_apply_rejects_noop_success_when_head_does_not_move() { + let root = temp_dir("run-action-noop"); + let repo = root.join("linux"); + fs::create_dir_all(&repo).expect("create repo"); + run_git(&repo, &["init"]); + run_git(&repo, &["config", "user.name", "CRIEW Test"]); + run_git(&repo, &["config", "user.email", "criew@example.com"]); + fs::write(repo.join("base.txt"), "base\n").expect("write base"); + run_git(&repo, &["add", "base.txt"]); + run_git(&repo, &["commit", "-m", "base"]); + + let b4_script = write_script( + &root, + "fake-b4.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 0.14.0'\n exit 0\nfi\nif [ \"$1\" = \"shazam\" ]; then\n echo 'applied without commit'\n exit 0\nfi\nexit 1\n", + ); + let mut runtime = runtime_in(&root, vec![repo.clone()]); + runtime.b4_path = Some(b4_script); + initialize_patch_runtime(&runtime, &[(1, "patch@example.com")]); + let summary = sample_summary("io_uring: noop series", 1); + + let result = run_action(&runtime, &summary, PatchAction::Apply).expect("apply runs"); + + assert_eq!(result.status, PatchSeriesStatus::Failed); + assert_eq!(result.exit_code, Some(0)); + assert!(result.summary.contains("git HEAD did not move")); + assert!(result.head_before.is_none()); + assert!(result.head_after.is_none()); + + let latest = load_latest_report(&runtime.database_path, "io-uring", summary.thread_id) + .expect("load latest report") + .expect("report exists"); + assert_eq!(latest.status, PatchSeriesStatus::Failed); + assert_eq!(latest.last_error.as_deref(), Some(result.summary.as_str())); + assert_eq!( + latest.last_summary.as_deref(), + Some(result.summary.as_str()) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_action_apply_records_head_change_and_moves_artifacts() { + let root = temp_dir("run-action-apply-success"); + let repo = root.join("linux"); + fs::create_dir_all(&repo).expect("create repo"); + run_git(&repo, &["init"]); + run_git(&repo, &["config", "user.name", "CRIEW Test"]); + run_git(&repo, &["config", "user.email", "criew@example.com"]); + fs::write(repo.join("base.txt"), "base\n").expect("write base"); + run_git(&repo, &["add", "base.txt"]); + run_git(&repo, &["commit", "-m", "base"]); + + let b4_script = write_script( + &root, + "fake-b4.sh", + "#!/bin/sh\nset -e\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 0.14.0'\n exit 0\nfi\nif [ \"$1\" = \"shazam\" ]; then\n printf 'applied by fake b4\\n' > applied.txt\n git add applied.txt\n git commit -m 'apply-series' >/dev/null 2>&1\n printf 'mbx\\n' > demo-series.mbx\n printf 'cover\\n' > demo-series.cover\n exit 0\nfi\nexit 1\n", + ); + let mut runtime = runtime_in(&root, vec![repo.clone()]); + runtime.b4_path = Some(b4_script); + initialize_patch_runtime(&runtime, &[(1, "patch@example.com")]); + let summary = sample_summary("io_uring: applied series", 1); + + let result = run_action(&runtime, &summary, PatchAction::Apply).expect("apply runs"); + + assert_eq!(result.status, PatchSeriesStatus::Applied); + assert_eq!(result.exit_code, Some(0)); + assert!(result.summary.contains("series applied by b4 shazam")); + assert!(result.summary.contains("artifacts moved to")); + let head_before = result.head_before.expect("head before"); + let head_after = result.head_after.expect("head after"); + assert_ne!(head_before, head_after); + + let output_path = result.output_path.expect("artifact path"); + assert!(output_path.starts_with(runtime.patch_dir.join(APPLY_ARTIFACTS_DIR))); + assert!(output_path.join("demo-series.mbx").exists()); + assert!(output_path.join("demo-series.cover").exists()); + assert!(!repo.join("demo-series.mbx").exists()); + assert!(!repo.join("demo-series.cover").exists()); + + let latest = load_latest_report(&runtime.database_path, "io-uring", summary.thread_id) + .expect("load latest report") + .expect("report exists"); + assert_eq!(latest.status, PatchSeriesStatus::Applied); + assert_eq!(latest.last_error, None); + assert_eq!( + latest.last_summary.as_deref(), + Some(result.summary.as_str()) + ); + + let _ = fs::remove_dir_all(root); + } + #[test] fn undo_last_apply_resets_head_to_previous_commit() { let repo = temp_dir("undo-apply"); diff --git a/src/app/sync.rs b/src/app/sync.rs index 1f1d790..b348ba4 100644 --- a/src/app/sync.rs +++ b/src/app/sync.rs @@ -605,6 +605,9 @@ mod tests { imap: crate::infra::config::ImapConfig::default(), lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -731,6 +734,9 @@ mod tests { imap: crate::infra::config::ImapConfig::default(), lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -793,6 +799,9 @@ mod tests { imap: crate::infra::config::ImapConfig::default(), lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -853,6 +862,9 @@ mod tests { }, lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -895,6 +907,9 @@ mod tests { }, lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -937,6 +952,9 @@ mod tests { }, lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), diff --git a/src/infra/b4.rs b/src/infra/b4.rs index 903bdbe..b007efc 100644 --- a/src/infra/b4.rs +++ b/src/infra/b4.rs @@ -13,6 +13,9 @@ use std::time::{Duration, Instant}; use super::b4_vendor; use crate::infra::error::{CriewError, ErrorCode, Result}; +const EXECUTABLE_BUSY_RETRY_ATTEMPTS: u8 = 5; +const EXECUTABLE_BUSY_RETRY_DELAY: Duration = Duration::from_millis(10); + #[derive(Debug, Clone)] pub struct B4Check { pub status: B4Status, @@ -47,7 +50,10 @@ struct ResolvedCommand { } pub fn check(configured_path: Option<&Path>, runtime_data_dir: Option<&Path>) -> B4Check { - let candidates = candidates(configured_path, runtime_data_dir); + check_from_candidates(candidates(configured_path, runtime_data_dir)) +} + +fn check_from_candidates(candidates: Vec) -> B4Check { let mut last_failure: Option<(PathBuf, String)> = None; for candidate in candidates { @@ -84,7 +90,16 @@ pub fn run( working_dir: Option<&Path>, ) -> Result { let resolved = resolve_command(configured_path, runtime_data_dir)?; + run_with_resolved_command(&resolved, subcommand, args, timeout, working_dir) +} +fn run_with_resolved_command( + resolved: &ResolvedCommand, + subcommand: &str, + args: &[String], + timeout: Duration, + working_dir: Option<&Path>, +) -> Result { let mut command = Command::new(&resolved.command); if let Some(working_dir) = working_dir { command.current_dir(working_dir); @@ -96,7 +111,7 @@ pub fn run( command.stdout(Stdio::piped()).stderr(Stdio::piped()); let command_line = render_command_line(&resolved.command, subcommand, args); - let mut child = command.spawn().map_err(|error| { + let mut child = spawn_command_with_retry(&mut command).map_err(|error| { CriewError::with_source( ErrorCode::B4, format!( @@ -152,6 +167,22 @@ pub fn run( } fn candidates(configured_path: Option<&Path>, runtime_data_dir: Option<&Path>) -> Vec { + let env_b4_path = env::var_os("CRIEW_B4_PATH").map(PathBuf::from); + let cwd = env::current_dir().ok(); + candidates_with( + configured_path, + runtime_data_dir, + env_b4_path.as_deref(), + cwd.as_deref(), + ) +} + +fn candidates_with( + configured_path: Option<&Path>, + runtime_data_dir: Option<&Path>, + env_b4_path: Option<&Path>, + cwd: Option<&Path>, +) -> Vec { let mut values = Vec::new(); // Discovery order is from most explicit to most implicit so user config @@ -160,14 +191,13 @@ fn candidates(configured_path: Option<&Path>, runtime_data_dir: Option<&Path>) - values.push(Candidate::Path(path.to_path_buf())); } - if let Ok(env_path) = env::var("CRIEW_B4_PATH") { - let path = PathBuf::from(env_path); + if let Some(path) = env_b4_path { if !path.as_os_str().is_empty() { - values.push(Candidate::Path(path)); + values.push(Candidate::Path(path.to_path_buf())); } } - if let Ok(cwd) = env::current_dir() { + if let Some(cwd) = cwd { values.push(Candidate::Path(cwd.join("vendor/b4/b4.sh"))); } @@ -219,11 +249,26 @@ fn probe(candidate: &Candidate) -> Probe { } } +fn spawn_command_with_retry(command: &mut Command) -> std::io::Result { + let mut attempts_remaining = EXECUTABLE_BUSY_RETRY_ATTEMPTS; + + loop { + match command.spawn() { + Ok(child) => return Ok(child), + Err(error) if is_retryable_executable_busy(&error) && attempts_remaining > 0 => { + attempts_remaining -= 1; + thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY); + } + Err(error) => return Err(error), + } + } +} + fn run_probe(command: T, label: &Path, command_value: String) -> Probe where T: AsRef, { - match Command::new(command).arg("--version").output() { + match output_with_retry(Command::new(command).arg("--version")) { Ok(output) if output.status.success() => { let version = normalize_output(&output.stdout) .or_else(|| normalize_output(&output.stderr)) @@ -253,12 +298,35 @@ where } } +fn output_with_retry(command: &mut Command) -> std::io::Result { + let mut attempts_remaining = EXECUTABLE_BUSY_RETRY_ATTEMPTS; + + loop { + match command.output() { + Ok(output) => return Ok(output), + Err(error) if is_retryable_executable_busy(&error) && attempts_remaining > 0 => { + attempts_remaining -= 1; + thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY); + } + Err(error) => return Err(error), + } + } +} + +fn is_retryable_executable_busy(error: &std::io::Error) -> bool { + error.kind() == std::io::ErrorKind::ExecutableFileBusy +} + fn resolve_command( configured_path: Option<&Path>, runtime_data_dir: Option<&Path>, ) -> Result { + resolve_from_candidates(candidates(configured_path, runtime_data_dir)) +} + +fn resolve_from_candidates(candidates: Vec) -> Result { let mut last_failure: Option<(PathBuf, String)> = None; - for candidate in candidates(configured_path, runtime_data_dir) { + for candidate in candidates { match probe(&candidate) { Probe::Available { command, path, .. } => { return Ok(ResolvedCommand { @@ -318,3 +386,248 @@ fn normalize_output(bytes: &[u8]) -> Option { .find(|line| !line.is_empty()) .map(ToOwned::to_owned) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::io::Write; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use crate::infra::error::ErrorCode; + + use super::{ + B4Status, Candidate, Probe, candidates_with, check_from_candidates, normalize_output, + probe, render_command_line, resolve_from_candidates, run_with_resolved_command, + }; + + fn temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let path = std::env::temp_dir().join(format!("criew-b4-{label}-{nonce}")); + fs::create_dir_all(&path).expect("create temp dir"); + path + } + + fn write_script(root: &Path, name: &str, body: &str) -> PathBuf { + let path = root.join(name); + let staging_path = root.join(format!(".{name}.tmp")); + let mut staging_file = fs::File::create(&staging_path).expect("create staging script"); + staging_file + .write_all(body.as_bytes()) + .expect("write staging script"); + staging_file.sync_all().expect("sync staging script"); + drop(staging_file); + #[cfg(unix)] + { + let mut permissions = fs::metadata(&staging_path) + .expect("staging metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&staging_path, permissions).expect("mark executable"); + } + fs::rename(&staging_path, &path).expect("install script"); + path + } + + #[test] + fn check_prefers_available_configured_script() { + let root = temp_dir("configured-ok"); + let configured_script = write_script( + &root, + "b4-ok.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 1.2.3'\n exit 0\nfi\nexit 0\n", + ); + let fallback_script = write_script( + &root, + "b4-fallback.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 9.9.9'\n exit 0\nfi\nexit 0\n", + ); + + let result = check_from_candidates(vec![ + Candidate::Path(configured_script.clone()), + Candidate::Path(fallback_script), + ]); + + match result.status { + B4Status::Available { path, version } => { + assert_eq!(path, configured_script); + assert_eq!(version, "b4 1.2.3"); + } + status => panic!("expected available status, got {status:?}"), + } + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn check_reports_broken_configured_script_when_no_fallback_exists() { + let root = temp_dir("configured-broken"); + let script = write_script( + &root, + "b4-broken.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'broken runtime' >&2\n exit 1\nfi\nexit 1\n", + ); + + let result = check_from_candidates(vec![Candidate::Path(script.clone())]); + + match result.status { + B4Status::Broken { path, reason } => { + assert_eq!(path, script); + assert_eq!(reason, "broken runtime"); + } + status => panic!("expected broken status, got {status:?}"), + } + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_executes_configured_script_in_requested_workdir() { + let root = temp_dir("run-ok"); + let workdir = root.join("workdir"); + fs::create_dir_all(&workdir).expect("create workdir"); + let script = write_script( + &root, + "b4-run.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 2.0.0'\n exit 0\nfi\nprintf 'cwd=%s\\n' \"$PWD\"\nprintf 'subcommand=%s\\n' \"$1\"\nprintf 'arg1=%s\\n' \"$2\"\nprintf 'arg2=%s\\n' \"$3\"\nprintf 'stderr-line\\n' >&2\n", + ); + + let resolved = resolve_from_candidates(vec![Candidate::Path(script.clone())]) + .expect("resolve configured b4"); + let result = run_with_resolved_command( + &resolved, + "am", + &["--foo".to_string(), "bar baz".to_string()], + Duration::from_secs(1), + Some(&workdir), + ) + .expect("run b4"); + + assert_eq!(result.exit_code, Some(0)); + assert!(!result.timed_out); + assert_eq!( + result.command_line, + format!("{} am --foo 'bar baz'", script.display()) + ); + assert!( + result + .stdout + .contains(&format!("cwd={}", workdir.display())) + ); + assert!(result.stdout.contains("subcommand=am")); + assert!(result.stdout.contains("arg1=--foo")); + assert!(result.stdout.contains("arg2=bar baz")); + assert!(result.stderr.contains("stderr-line")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_marks_timed_out_processes() { + let root = temp_dir("run-timeout"); + let script = write_script( + &root, + "b4-timeout.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 2.1.0'\n exit 0\nfi\nwhile :; do :; done\n", + ); + + let resolved = + resolve_from_candidates(vec![Candidate::Path(script)]).expect("resolve timeout b4"); + let result = + run_with_resolved_command(&resolved, "am", &[], Duration::from_millis(10), None) + .expect("run b4"); + + assert!(result.timed_out); + assert_ne!(result.exit_code, Some(0)); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn candidates_include_config_env_cwd_runtime_and_program_in_order() { + let root = temp_dir("candidates"); + let cwd = root.join("cwd"); + fs::create_dir_all(&cwd).expect("create cwd"); + let configured = root.join("configured-b4"); + let env_path = root.join("env-b4"); + let runtime = root.join("runtime"); + + let values = candidates_with( + Some(&configured), + Some(&runtime), + Some(&env_path), + Some(&cwd), + ); + + assert!(matches!(&values[0], Candidate::Path(path) if path == &configured)); + assert!(matches!(&values[1], Candidate::Path(path) if path == &env_path)); + assert!( + matches!(&values[2], Candidate::Path(path) if path == &cwd.join("vendor/b4/b4.sh")) + ); + assert!(matches!(&values[3], Candidate::EmbeddedVendor(path) if path == &runtime)); + assert!(matches!(&values[4], Candidate::Program(program) if program == "b4")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn probe_reports_missing_and_embedded_vendor_failures() { + let missing = probe(&Candidate::Path(PathBuf::from("/definitely/missing/b4"))); + assert!(matches!(missing, Probe::Missing)); + + let root = temp_dir("embedded-broken"); + let vendor_root = root.join("vendor"); + fs::write(&vendor_root, "not a directory").expect("block vendor root"); + + let broken = probe(&Candidate::EmbeddedVendor(root.clone())); + match broken { + Probe::Broken { path, reason } => { + assert_eq!(path, root.join("vendor/b4/b4.sh")); + assert!(reason.contains("failed to create embedded b4 directory")); + } + _ => panic!("expected broken embedded vendor probe"), + } + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_command_reports_not_found_without_candidates() { + let error = resolve_from_candidates(vec![ + Candidate::Path(PathBuf::from("/definitely/missing/b4")), + Candidate::Program("criew-b4-definitely-missing".to_string()), + ]) + .expect_err("missing b4 should fail"); + + assert_eq!(error.code(), ErrorCode::B4); + assert!(error.to_string().contains("b4 executable not found")); + } + + #[test] + fn render_command_line_quotes_special_tokens() { + let rendered = render_command_line( + "/tmp/demo path/b4.sh", + "am", + &["bar baz".to_string(), "quote'char".to_string()], + ); + + assert_eq!( + rendered, + "'/tmp/demo path/b4.sh' am 'bar baz' 'quote'\\''char'" + ); + } + + #[test] + fn normalize_output_returns_first_non_empty_trimmed_line() { + assert_eq!( + normalize_output(b"\n \n b4 3.0.0 \nnext line\n"), + Some("b4 3.0.0".to_string()) + ); + assert_eq!(normalize_output(b"\n\t \n"), None); + } +} diff --git a/src/infra/b4_vendor.rs b/src/infra/b4_vendor.rs index 044fe4c..c8773cc 100644 --- a/src/infra/b4_vendor.rs +++ b/src/infra/b4_vendor.rs @@ -131,4 +131,51 @@ mod tests { let _ = fs::remove_dir_all(&temp_root); } + + #[test] + fn ensure_installed_is_idempotent_for_existing_runtime_tree() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time before unix epoch") + .as_nanos(); + let temp_root = std::env::temp_dir().join(format!( + "criew-b4-vendor-repeat-test-{}-{nonce}", + std::process::id() + )); + + let first = ensure_installed(&temp_root).expect("first embedded b4 install"); + let second = ensure_installed(&temp_root).expect("second embedded b4 install"); + + assert_eq!(first, second); + if let Some(script) = second { + assert_eq!(script, script_path(&temp_root)); + assert!(script.exists(), "embedded b4 script should still exist"); + } + + let _ = fs::remove_dir_all(&temp_root); + } + + #[test] + fn ensure_installed_reports_directory_conflicts() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time before unix epoch") + .as_nanos(); + let temp_root = std::env::temp_dir().join(format!( + "criew-b4-vendor-conflict-test-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&temp_root).expect("create temp root"); + fs::write(temp_root.join("vendor"), "not a directory").expect("write blocking vendor file"); + + let error = ensure_installed(&temp_root).expect_err("conflicting vendor path should fail"); + + assert!( + error + .to_string() + .contains("failed to create embedded b4 directory") + ); + + let _ = fs::remove_dir_all(&temp_root); + } } diff --git a/src/infra/bootstrap.rs b/src/infra/bootstrap.rs index cfde67e..4103925 100644 --- a/src/infra/bootstrap.rs +++ b/src/infra/bootstrap.rs @@ -106,3 +106,100 @@ fn ensure_runtime_dirs(config: &RuntimeConfig) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + use crate::infra::config::{DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, ImapConfig, UiKeymap}; + use crate::infra::db::CURRENT_SCHEMA_VERSION; + use crate::infra::error::ErrorCode; + + use super::{RuntimeConfig, prepare}; + + fn temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let path = std::env::temp_dir().join(format!("criew-bootstrap-{label}-{nonce}")); + fs::create_dir_all(&path).expect("create temp dir"); + path + } + + fn test_runtime_in(root: PathBuf) -> RuntimeConfig { + RuntimeConfig { + config_path: root.join("config").join("criew-config.toml"), + data_dir: root.join("data"), + database_path: root.join("data").join("db").join("criew.db"), + raw_mail_dir: root.join("data").join("raw"), + patch_dir: root.join("data").join("patches"), + log_dir: root.join("logs"), + b4_path: None, + log_filter: "info".to_string(), + source_mailbox: "linux-kernel".to_string(), + imap: ImapConfig::default(), + lore_base_url: "https://lore.kernel.org".to_string(), + startup_sync: true, + ui_keymap: UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), + inbox_auto_sync_interval_secs: DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, + kernel_trees: Vec::new(), + } + } + + #[test] + fn prepare_creates_runtime_state_and_is_idempotent() { + let root = temp_dir("prepare"); + let runtime = test_runtime_in(root.clone()); + + let first = prepare(&runtime).expect("prepare runtime"); + let second = prepare(&runtime).expect("prepare existing runtime"); + + assert!(runtime.config_path.parent().expect("config dir").is_dir()); + assert!(runtime.data_dir.is_dir()); + assert!(runtime.raw_mail_dir.is_dir()); + assert!(runtime.patch_dir.is_dir()); + assert!(runtime.log_dir.is_dir()); + assert!(runtime.database_path.is_file()); + + assert_eq!(first.db.path, runtime.database_path); + assert!(first.db.created); + assert_eq!(first.db.schema_version, CURRENT_SCHEMA_VERSION); + assert_eq!( + first.db.applied_migrations, + vec![1, 2, 3, CURRENT_SCHEMA_VERSION] + ); + + assert_eq!(second.db.path, runtime.database_path); + assert!(!second.db.created); + assert_eq!(second.db.schema_version, CURRENT_SCHEMA_VERSION); + assert!(second.db.applied_migrations.is_empty()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prepare_reports_runtime_directory_conflicts() { + let root = temp_dir("prepare-conflict"); + let blocked_path = root.join("blocked"); + fs::write(&blocked_path, "not a directory").expect("write blocking file"); + + let mut runtime = test_runtime_in(root.clone()); + runtime.data_dir = blocked_path.join("data"); + + let error = prepare(&runtime).expect_err("conflicting data directory should fail"); + + assert_eq!(error.code(), ErrorCode::Io); + assert!( + error + .to_string() + .contains("failed to create data directory") + ); + + let _ = fs::remove_dir_all(root); + } +} diff --git a/src/infra/config.rs b/src/infra/config.rs index a82d28f..b61d99d 100644 --- a/src/infra/config.rs +++ b/src/infra/config.rs @@ -31,6 +31,16 @@ mailbox = "linux-kernel" [ui] startup_sync = true +# keymap = "default" # Supported: default, vim, custom +# keymap_base = "default" # Base scheme used when keymap = "custom" +# [ui.custom_keymap] +# focus_prev = ["j"] +# focus_next = ["l"] +# move_up = ["i"] +# move_down = ["k"] +# jump_top = ["g", "g"] +# jump_bottom = ["G"] +# quick_quit = ["q", "q"] # inbox_auto_sync_interval_secs = 30 [logging] @@ -56,6 +66,60 @@ impl ImapEncryption { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum UiKeymap { + #[default] + Default, + Vim, + Custom, +} + +impl UiKeymap { + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Vim => "vim", + Self::Custom => "custom", + } + } + + pub fn default_base(self) -> UiKeymapBase { + match self { + Self::Vim => UiKeymapBase::Vim, + Self::Default | Self::Custom => UiKeymapBase::Default, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum UiKeymapBase { + #[default] + Default, + Vim, +} + +impl UiKeymapBase { + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Vim => "vim", + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UiCustomKeymapConfig { + pub focus_prev: Option>, + pub focus_next: Option>, + pub move_up: Option>, + pub move_down: Option>, + pub jump_top: Option>, + pub jump_bottom: Option>, + pub quick_quit: Option>, +} + #[derive(Debug, Clone, Default)] pub struct ImapConfig { pub email: Option, @@ -133,6 +197,9 @@ pub struct RuntimeConfig { pub imap: ImapConfig, pub lore_base_url: String, pub startup_sync: bool, + pub ui_keymap: UiKeymap, + pub ui_keymap_base: UiKeymapBase, + pub ui_custom_keymap: UiCustomKeymapConfig, pub inbox_auto_sync_interval_secs: u64, pub kernel_trees: Vec, } @@ -197,9 +264,24 @@ struct SourceConfig { #[derive(Debug, Default, Deserialize)] struct UiConfig { startup_sync: Option, + keymap: Option, + keymap_base: Option, + #[serde(default)] + custom_keymap: UiCustomKeymapFileConfig, inbox_auto_sync_interval_secs: Option, } +#[derive(Debug, Default, Deserialize)] +struct UiCustomKeymapFileConfig { + focus_prev: Option>, + focus_next: Option>, + move_up: Option>, + move_down: Option>, + jump_top: Option>, + jump_bottom: Option>, + quick_quit: Option>, +} + #[derive(Debug, Default, Deserialize)] struct ImapFileConfig { mailbox: Option, @@ -346,6 +428,13 @@ fn build_runtime_config( let lore_base_url = normalize_optional_string(file_config.source.lore_base_url) .unwrap_or_else(|| DEFAULT_LORE_BASE_URL.to_string()); let startup_sync = file_config.ui.startup_sync.unwrap_or(true); + let ui_keymap = file_config.ui.keymap.unwrap_or_default(); + let ui_keymap_base = file_config + .ui + .keymap_base + .unwrap_or_else(|| ui_keymap.default_base()); + let ui_custom_keymap = build_ui_custom_keymap_config(&file_config.ui.custom_keymap)?; + validate_ui_custom_keymap_config(ui_keymap_base, &ui_custom_keymap)?; let inbox_auto_sync_interval_secs = file_config .ui .inbox_auto_sync_interval_secs @@ -383,6 +472,9 @@ fn build_runtime_config( imap, lore_base_url, startup_sync, + ui_keymap, + ui_keymap_base, + ui_custom_keymap, inbox_auto_sync_interval_secs, kernel_trees, }) @@ -460,6 +552,227 @@ fn build_imap_config(file_config: &ImapFileConfig, config_path: &Path) -> Result build_imap_config_with_env(file_config, config_path, |key| std::env::var(key).ok()) } +fn build_ui_custom_keymap_config( + file_config: &UiCustomKeymapFileConfig, +) -> Result { + Ok(UiCustomKeymapConfig { + focus_prev: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.focus_prev", + file_config.focus_prev.clone(), + 1, + 1, + )?, + focus_next: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.focus_next", + file_config.focus_next.clone(), + 1, + 1, + )?, + move_up: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.move_up", + file_config.move_up.clone(), + 1, + 1, + )?, + move_down: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.move_down", + file_config.move_down.clone(), + 1, + 1, + )?, + jump_top: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.jump_top", + file_config.jump_top.clone(), + 1, + 2, + )?, + jump_bottom: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.jump_bottom", + file_config.jump_bottom.clone(), + 1, + 2, + )?, + quick_quit: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.quick_quit", + file_config.quick_quit.clone(), + 1, + 2, + )?, + }) +} + +fn normalize_ui_custom_keymap_binding( + key: &str, + tokens: Option>, + min_len: usize, + max_len: usize, +) -> Result>> { + let Some(tokens) = tokens else { + return Ok(None); + }; + if tokens.len() < min_len || tokens.len() > max_len { + return Err(CriewError::new( + ErrorCode::ConfigParse, + format!("{key} must contain between {min_len} and {max_len} keys"), + )); + } + + let mut normalized = Vec::with_capacity(tokens.len()); + for token in tokens { + let Some(character) = normalize_ui_custom_keymap_token(&token) else { + return Err(CriewError::new( + ErrorCode::ConfigParse, + format!("{key} contains an unsupported key token: {token}"), + )); + }; + if is_reserved_main_page_keymap_character(character) { + return Err(CriewError::new( + ErrorCode::ConfigParse, + format!("{key} uses reserved key {character}"), + )); + } + normalized.push(character.to_string()); + } + + Ok(Some(normalized)) +} + +fn normalize_ui_custom_keymap_token(token: &str) -> Option { + let trimmed = token.trim(); + let mut characters = trimmed.chars(); + let character = characters.next()?; + if characters.next().is_some() + || character.is_ascii_control() + || character.is_ascii_whitespace() + || character.is_ascii_digit() + { + return None; + } + + Some(character) +} + +fn is_reserved_main_page_keymap_character(character: char) -> bool { + matches!( + character, + ':' | '/' + | 'e' + | 'r' + | 'a' + | 'd' + | 'u' + | 'y' + | 'n' + | '[' + | ']' + | '{' + | '}' + | 'E' + | '-' + | '=' + | '+' + ) +} + +fn validate_ui_custom_keymap_config( + base: UiKeymapBase, + custom: &UiCustomKeymapConfig, +) -> Result<()> { + let mut bindings = vec![ + ( + "focus_prev", + resolved_ui_keymap_binding(base, custom.focus_prev.as_ref(), "focus_prev") + .expect("focus_prev should always resolve"), + ), + ( + "focus_next", + resolved_ui_keymap_binding(base, custom.focus_next.as_ref(), "focus_next") + .expect("focus_next should always resolve"), + ), + ( + "move_up", + resolved_ui_keymap_binding(base, custom.move_up.as_ref(), "move_up") + .expect("move_up should always resolve"), + ), + ( + "move_down", + resolved_ui_keymap_binding(base, custom.move_down.as_ref(), "move_down") + .expect("move_down should always resolve"), + ), + ]; + + if let Some(binding) = resolved_ui_keymap_binding(base, custom.jump_top.as_ref(), "jump_top") { + bindings.push(("jump_top", binding)); + } + if let Some(binding) = + resolved_ui_keymap_binding(base, custom.jump_bottom.as_ref(), "jump_bottom") + { + bindings.push(("jump_bottom", binding)); + } + if let Some(binding) = + resolved_ui_keymap_binding(base, custom.quick_quit.as_ref(), "quick_quit") + { + bindings.push(("quick_quit", binding)); + } + + for (index, (left_name, left_binding)) in bindings.iter().enumerate() { + for (right_name, right_binding) in bindings.iter().skip(index + 1) { + if left_binding == right_binding { + return Err(CriewError::new( + ErrorCode::ConfigParse, + format!("ui.custom_keymap.{left_name} conflicts with {right_name}"), + )); + } + if binding_is_prefix(left_binding, right_binding) + || binding_is_prefix(right_binding, left_binding) + { + return Err(CriewError::new( + ErrorCode::ConfigParse, + format!("ui.custom_keymap.{left_name} has a prefix conflict with {right_name}"), + )); + } + } + } + + Ok(()) +} + +fn resolved_ui_keymap_binding<'a>( + base: UiKeymapBase, + custom: Option<&'a Vec>, + action: &str, +) -> Option> { + if let Some(binding) = custom { + return Some(binding.iter().map(String::as_str).collect()); + } + + preset_ui_keymap_binding(base, action).map(|binding| binding.to_vec()) +} + +fn preset_ui_keymap_binding(base: UiKeymapBase, action: &str) -> Option<&'static [&'static str]> { + match (base, action) { + (UiKeymapBase::Default, "focus_prev") => Some(&["j"]), + (UiKeymapBase::Default, "focus_next") => Some(&["l"]), + (UiKeymapBase::Default, "move_up") => Some(&["i"]), + (UiKeymapBase::Default, "move_down") => Some(&["k"]), + (UiKeymapBase::Default, "jump_top") => None, + (UiKeymapBase::Default, "jump_bottom") => None, + (UiKeymapBase::Default, "quick_quit") => None, + (UiKeymapBase::Vim, "focus_prev") => Some(&["h"]), + (UiKeymapBase::Vim, "focus_next") => Some(&["l"]), + (UiKeymapBase::Vim, "move_up") => Some(&["k"]), + (UiKeymapBase::Vim, "move_down") => Some(&["j"]), + (UiKeymapBase::Vim, "jump_top") => Some(&["g", "g"]), + (UiKeymapBase::Vim, "jump_bottom") => Some(&["G"]), + (UiKeymapBase::Vim, "quick_quit") => Some(&["q", "q"]), + _ => unreachable!("unexpected keymap action"), + } +} + +fn binding_is_prefix(left: &[&str], right: &[&str]) -> bool { + left.len() < right.len() && right.starts_with(left) +} + fn build_imap_config_with_env( file_config: &ImapFileConfig, config_path: &Path, @@ -610,8 +923,8 @@ mod tests { use super::{ DEFAULT_CONFIG_FILE_NAME, DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, IMAP_INBOX_MAILBOX, - ImapFileConfig, LEGACY_CONFIG_FILE_NAME, SelfEmailSource, build_imap_config_with_env, load, - load_with_home, resolve_self_email_with, + ImapFileConfig, LEGACY_CONFIG_FILE_NAME, SelfEmailSource, UiKeymap, UiKeymapBase, + build_imap_config_with_env, load, load_with_home, resolve_self_email_with, }; fn temp_dir(label: &str) -> PathBuf { @@ -651,6 +964,7 @@ lore_base_url = "https://lore.kernel.org" [ui] startup_sync = false +keymap = "vim" inbox_auto_sync_interval_secs = 45 [kernel] @@ -672,6 +986,7 @@ trees = ["./linux-next"] assert_eq!(loaded.source_mailbox, "linux-kernel"); assert_eq!(loaded.lore_base_url, "https://lore.kernel.org"); assert!(!loaded.startup_sync); + assert_eq!(loaded.ui_keymap, UiKeymap::Vim); assert_eq!(loaded.inbox_auto_sync_interval_secs, 45); assert_eq!( loaded.kernel_trees, @@ -697,6 +1012,7 @@ trees = ["./linux-next"] assert_eq!(loaded.patch_dir, home.join(".criew/patches")); assert_eq!(loaded.database_path, home.join(".criew/db/criew.db")); assert!(loaded.startup_sync); + assert_eq!(loaded.ui_keymap, UiKeymap::Default); assert_eq!( loaded.inbox_auto_sync_interval_secs, DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS @@ -710,6 +1026,114 @@ trees = ["./linux-next"] let _ = fs::remove_dir_all(home); } + #[test] + fn loads_custom_ui_keymap_from_config() { + let base = temp_dir("config-custom-keymap"); + let config_path = base.join("config.toml"); + + fs::write( + &config_path, + r#" +[ui] +keymap = "custom" +"#, + ) + .expect("write config"); + + let loaded = load(Some(&config_path)).expect("load config"); + + assert_eq!(loaded.ui_keymap, UiKeymap::Custom); + + let _ = fs::remove_dir_all(base); + } + + #[test] + fn loads_custom_ui_keymap_overrides_and_base_from_config() { + let base = temp_dir("config-custom-keymap-overrides"); + let config_path = base.join("config.toml"); + + fs::write( + &config_path, + r#" +[ui] +keymap = "custom" +keymap_base = "vim" + +[ui.custom_keymap] +focus_prev = ["x"] +quick_quit = ["z", "z"] +"#, + ) + .expect("write config"); + + let loaded = load(Some(&config_path)).expect("load config"); + + assert_eq!(loaded.ui_keymap, UiKeymap::Custom); + assert_eq!(loaded.ui_keymap_base, UiKeymapBase::Vim); + assert_eq!( + loaded.ui_custom_keymap.focus_prev, + Some(vec!["x".to_string()]) + ); + assert_eq!( + loaded.ui_custom_keymap.quick_quit, + Some(vec!["z".to_string(), "z".to_string()]) + ); + + let _ = fs::remove_dir_all(base); + } + + #[test] + fn infers_custom_keymap_base_from_vim_scheme_when_omitted() { + let base = temp_dir("config-vim-infers-custom-base"); + let config_path = base.join("config.toml"); + + fs::write( + &config_path, + r#" +[ui] +keymap = "vim" +"#, + ) + .expect("write config"); + + let loaded = load(Some(&config_path)).expect("load config"); + + assert_eq!(loaded.ui_keymap, UiKeymap::Vim); + assert_eq!(loaded.ui_keymap_base, UiKeymapBase::Vim); + + let _ = fs::remove_dir_all(base); + } + + #[test] + fn rejects_custom_keymap_prefix_conflicts_against_base() { + let base = temp_dir("config-custom-keymap-conflict"); + let config_path = base.join("config.toml"); + + fs::write( + &config_path, + r#" +[ui] +keymap = "custom" +keymap_base = "vim" + +[ui.custom_keymap] +focus_prev = ["g"] +"#, + ) + .expect("write config"); + + let error = match load(Some(&config_path)) { + Ok(_) => panic!("conflicting config should fail"), + Err(error) => error, + }; + assert!( + error.to_string().contains("prefix conflict"), + "unexpected error: {error}" + ); + + let _ = fs::remove_dir_all(base); + } + #[test] fn falls_back_to_config_alias_filename_when_present() { let home = temp_dir("config-legacy-home"); @@ -765,7 +1189,6 @@ imapencryption = "tls" loaded.imap.encryption.map(|value| value.as_str()), Some("tls") ); - assert_eq!(loaded.imap.proxy, None); assert!(loaded.imap.is_complete()); assert_eq!(loaded.default_active_mailbox(), IMAP_INBOX_MAILBOX); diff --git a/src/infra/db.rs b/src/infra/db.rs index ed565cc..11c34dd 100644 --- a/src/infra/db.rs +++ b/src/infra/db.rs @@ -165,6 +165,8 @@ mod tests { use rusqlite::Connection; + use crate::infra::error::ErrorCode; + use super::{CURRENT_SCHEMA_VERSION, initialize}; fn temp_dir(label: &str) -> PathBuf { @@ -200,4 +202,121 @@ mod tests { let _ = fs::remove_dir_all(root); } + + #[test] + fn initialize_is_idempotent_for_existing_database() { + let root = temp_dir("db-reinitialize"); + let db_path = root.join("criew.db"); + + let first = initialize(&db_path).expect("initialize db"); + let second = initialize(&db_path).expect("reinitialize db"); + + assert!(first.created); + assert_eq!(second.path, db_path); + assert!(!second.created); + assert_eq!(second.schema_version, CURRENT_SCHEMA_VERSION); + assert!(second.applied_migrations.is_empty()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn initialize_reports_missing_parent_directory() { + let root = temp_dir("db-missing-parent"); + let db_path = root.join("missing").join("criew.db"); + + let error = initialize(&db_path).expect_err("missing parent directory should fail"); + + assert_eq!(error.code(), ErrorCode::Database); + assert!(error.to_string().contains("failed to open sqlite database")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn initialize_reports_schema_version_table_creation_conflicts() { + let root = temp_dir("db-schema-version-conflict"); + let db_path = root.join("criew.db"); + let connection = Connection::open(&db_path).expect("open sqlite"); + connection + .execute("CREATE VIEW schema_version AS SELECT 1 AS version", []) + .expect("create conflicting view"); + drop(connection); + + let error = initialize(&db_path).expect_err("schema_version conflict should fail"); + + assert_eq!(error.code(), ErrorCode::Database); + assert!(error.to_string().contains("schema") || error.to_string().contains("migration")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn initialize_reports_schema_version_query_failure_for_invalid_table_shape() { + let root = temp_dir("db-schema-version-shape"); + let db_path = root.join("criew.db"); + let connection = Connection::open(&db_path).expect("open sqlite"); + connection + .execute( + "CREATE TABLE schema_version (description TEXT NOT NULL)", + [], + ) + .expect("create malformed schema_version"); + drop(connection); + + let error = initialize(&db_path).expect_err("invalid schema_version shape should fail"); + + assert_eq!(error.code(), ErrorCode::Database); + assert!(error.to_string().contains("failed to query schema version")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn initialize_reports_migration_and_registration_failures() { + let root = temp_dir("db-migration-failures"); + + let conflicting_db_path = root.join("migration-sql.db"); + let connection = Connection::open(&conflicting_db_path).expect("open conflicting sqlite"); + connection + .execute( + "CREATE TABLE schema_version (version INTEGER PRIMARY KEY, description TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT '')", + [], + ) + .expect("create schema_version"); + connection + .execute("CREATE VIEW mail AS SELECT 1 AS id", []) + .expect("create conflicting mail view"); + drop(connection); + + let migration_error = + initialize(&conflicting_db_path).expect_err("migration SQL conflict should fail"); + assert_eq!(migration_error.code(), ErrorCode::Database); + assert!( + migration_error + .to_string() + .contains("failed to run migration 1") + ); + + let missing_column_db_path = root.join("migration-register.db"); + let connection = Connection::open(&missing_column_db_path).expect("open sqlite"); + connection + .execute( + "CREATE TABLE schema_version (version INTEGER PRIMARY KEY)", + [], + ) + .expect("create truncated schema_version"); + drop(connection); + + let register_error = + initialize(&missing_column_db_path).expect_err("migration registration should fail"); + assert_eq!(register_error.code(), ErrorCode::Database); + assert!( + register_error + .to_string() + .contains("failed to register migration 1") + ); + + let _ = fs::remove_dir_all(root); + } } diff --git a/src/infra/imap.rs b/src/infra/imap.rs index 400b3ad..f8dfa22 100644 --- a/src/infra/imap.rs +++ b/src/infra/imap.rs @@ -31,6 +31,12 @@ const IMAP_FETCH_BATCH_SIZE: usize = 100; const GNU_ARCHIVE_INITIAL_MONTH_LIMIT: usize = 2; const GNU_ARCHIVE_UID_STRIDE: u32 = 1_000_000; +#[cfg(test)] +trait TestTransportIo: Read + Write + Send {} + +#[cfg(test)] +impl TestTransportIo for T where T: Read + Write + Send {} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(dead_code)] pub enum ImapErrorKind { @@ -371,7 +377,7 @@ impl LoreImapClient { ) })?; - let status = response.status(); + let status_code = response.status().as_u16(); let body = response.text().map_err(|error| { CriewError::with_source( ErrorCode::Imap, @@ -380,41 +386,21 @@ impl LoreImapClient { ) })?; - if !status.is_success() { - return Err(imap_error( - ImapErrorKind::MailboxSelection, - format!("failed to fetch lore feed {url}: HTTP {status}"), - )); - } - - parse_lore_atom_entries(&body) + parse_lore_feed_response(&url, status_code, &body) } fn fetch_raw_mail(&self, message_url: &str) -> Result> { - let mut last_error: Option = None; - - for raw_url in lore_raw_url_candidates(message_url) { - let response = match self.client.get(&raw_url).send() { - Ok(response) => response, - Err(error) => { - last_error = Some(CriewError::with_source( - ErrorCode::Imap, - format!("failed to fetch lore raw message {raw_url}"), - error, - )); - continue; - } - }; - - if !response.status().is_success() { - last_error = Some(imap_error( - ImapErrorKind::Protocol, - format!( - "failed to fetch lore raw message {raw_url}: HTTP {}", - response.status() - ), - )); - continue; + fetch_lore_raw_with(message_url, |raw_url| { + let response = self.client.get(raw_url).send().map_err(|error| { + CriewError::with_source( + ErrorCode::Imap, + format!("failed to fetch lore raw message {raw_url}"), + error, + ) + })?; + let status_code = response.status().as_u16(); + if !(200..300).contains(&status_code) { + return Ok((status_code, Vec::new())); } let bytes = response.bytes().map_err(|error| { @@ -424,24 +410,88 @@ impl LoreImapClient { error, ) })?; + Ok((status_code, bytes.to_vec())) + }) + } +} + +fn parse_lore_feed_response(url: &str, status_code: u16, body: &str) -> Result> { + if !(200..300).contains(&status_code) { + return Err(imap_error( + ImapErrorKind::MailboxSelection, + format!("failed to fetch lore feed {url}: HTTP {status_code}"), + )); + } + + parse_lore_atom_entries(body) +} - if !bytes.is_empty() { - return Ok(bytes.to_vec()); +fn fetch_lore_raw_with(message_url: &str, mut fetch_response: F) -> Result> +where + F: FnMut(&str) -> Result<(u16, Vec)>, +{ + let mut last_error: Option = None; + + for raw_url in lore_raw_url_candidates(message_url) { + let (status_code, bytes) = match fetch_response(&raw_url) { + Ok(response) => response, + Err(error) => { + last_error = Some(error); + continue; } + }; + if !(200..300).contains(&status_code) { last_error = Some(imap_error( ImapErrorKind::Protocol, - format!("lore raw message is empty: {raw_url}"), + format!("failed to fetch lore raw message {raw_url}: HTTP {status_code}"), )); + continue; } - Err(last_error.unwrap_or_else(|| { - imap_error( - ImapErrorKind::Protocol, - format!("failed to resolve raw message URL for {message_url}"), - ) - })) + if !bytes.is_empty() { + return Ok(bytes); + } + + last_error = Some(imap_error( + ImapErrorKind::Protocol, + format!("lore raw message is empty: {raw_url}"), + )); + } + + Err(last_error.unwrap_or_else(|| { + imap_error( + ImapErrorKind::Protocol, + format!("failed to resolve raw message URL for {message_url}"), + ) + })) +} + +fn build_lore_incremental_mails( + entries: Vec, + since_modseq: Option, + mut fetch_raw_mail: F, +) -> Result> +where + F: FnMut(&str) -> Result>, +{ + let mut fetched = Vec::new(); + for entry in entries { + if since_modseq.is_some_and(|checkpoint| entry.modseq <= checkpoint) { + continue; + } + + let raw = fetch_raw_mail(&entry.message_url)?; + fetched.push(RemoteMail { + uid: 0, + modseq: Some(entry.modseq), + flags: Vec::new(), + raw, + }); } + + fetched.sort_by_key(|mail| mail.modseq.unwrap_or(0)); + Ok(fetched) } impl ImapClient for LoreImapClient { @@ -470,24 +520,9 @@ impl ImapClient for LoreImapClient { ) -> Result> { self.ensure_connected()?; let entries = self.fetch_feed_entries(mailbox)?; - - let mut fetched = Vec::new(); - for entry in entries { - if since_modseq.is_some_and(|checkpoint| entry.modseq <= checkpoint) { - continue; - } - - let raw = self.fetch_raw_mail(&entry.message_url)?; - fetched.push(RemoteMail { - uid: 0, - modseq: Some(entry.modseq), - flags: Vec::new(), - raw, - }); - } - - fetched.sort_by_key(|mail| mail.modseq.unwrap_or(0)); - Ok(fetched) + build_lore_incremental_mails(entries, since_modseq, |message_url| { + self.fetch_raw_mail(message_url) + }) } } @@ -553,7 +588,7 @@ impl GnuArchiveClient { ) })?; - let status = response.status(); + let status_code = response.status().as_u16(); let body = response.text().map_err(|error| { CriewError::with_source( ErrorCode::Imap, @@ -562,14 +597,7 @@ impl GnuArchiveClient { ) })?; - if !status.is_success() { - return Err(imap_error( - ImapErrorKind::MailboxSelection, - format!("failed to fetch GNU archive index {url}: HTTP {status}"), - )); - } - - parse_gnu_archive_month_entries(&body) + parse_gnu_archive_index_response(&url, status_code, &body) } fn fetch_month_mbox(&self, mailbox: &str, month_key: &str) -> Result> { @@ -582,15 +610,10 @@ impl GnuArchiveClient { ) })?; - let status = response.status(); - if !status.is_success() { - return Err(imap_error( - ImapErrorKind::Protocol, - format!("failed to fetch GNU archive mbox {url}: HTTP {status}"), - )); - } + let status_code = response.status().as_u16(); + validate_gnu_archive_mbox_response(&url, status_code)?; - response + let bytes = response .bytes() .map(|bytes| bytes.to_vec()) .map_err(|error| { @@ -599,8 +622,70 @@ impl GnuArchiveClient { format!("failed to read GNU archive mbox body {url}"), error, ) - }) + })?; + + Ok(bytes) + } +} + +fn parse_gnu_archive_index_response( + url: &str, + status_code: u16, + body: &str, +) -> Result> { + if !(200..300).contains(&status_code) { + return Err(imap_error( + ImapErrorKind::MailboxSelection, + format!("failed to fetch GNU archive index {url}: HTTP {status_code}"), + )); + } + + parse_gnu_archive_month_entries(body) +} + +fn validate_gnu_archive_mbox_response(url: &str, status_code: u16) -> Result<()> { + if !(200..300).contains(&status_code) { + return Err(imap_error( + ImapErrorKind::Protocol, + format!("failed to fetch GNU archive mbox {url}: HTTP {status_code}"), + )); + } + + Ok(()) +} + +fn build_gnu_archive_incremental_mails( + months: &[GnuArchiveMonthEntry], + since_modseq: Option, + mut fetch_month_mbox: F, +) -> Result> +where + F: FnMut(&str) -> Result>, +{ + let selected_months = select_gnu_archive_months(months, since_modseq); + + let mut fetched = Vec::new(); + for month in selected_months { + let raw_mbox = fetch_month_mbox(&month.month_key)?; + for (index, raw) in parse_gnu_archive_mbox_messages(&raw_mbox) + .into_iter() + .enumerate() + { + fetched.push(RemoteMail { + uid: gnu_archive_message_uid(&month.month_key, index), + modseq: Some(month.modseq), + flags: Vec::new(), + raw, + }); + } } + + fetched.sort_by(|left, right| { + left.modseq + .cmp(&right.modseq) + .then_with(|| left.uid.cmp(&right.uid)) + }); + Ok(fetched) } impl ImapClient for GnuArchiveClient { @@ -629,30 +714,9 @@ impl ImapClient for GnuArchiveClient { ) -> Result> { self.ensure_connected()?; let months = self.fetch_month_entries(mailbox)?; - let selected_months = select_gnu_archive_months(&months, since_modseq); - - let mut fetched = Vec::new(); - for month in selected_months { - let raw_mbox = self.fetch_month_mbox(mailbox, &month.month_key)?; - for (index, raw) in parse_gnu_archive_mbox_messages(&raw_mbox) - .into_iter() - .enumerate() - { - fetched.push(RemoteMail { - uid: gnu_archive_message_uid(&month.month_key, index), - modseq: Some(month.modseq), - flags: Vec::new(), - raw, - }); - } - } - - fetched.sort_by(|left, right| { - left.modseq - .cmp(&right.modseq) - .then_with(|| left.uid.cmp(&right.uid)) - }); - Ok(fetched) + build_gnu_archive_incremental_mails(&months, since_modseq, |month_key| { + self.fetch_month_mbox(mailbox, month_key) + }) } } @@ -760,6 +824,8 @@ fn collect_incremental_uids( enum ImapTransport { Plain(TcpStream), Tls(Box>), + #[cfg(test)] + Mock(Box), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -795,6 +861,8 @@ impl Read for ImapTransport { match self { Self::Plain(stream) => stream.read(buf), Self::Tls(stream) => stream.read(buf), + #[cfg(test)] + Self::Mock(stream) => stream.read(buf), } } } @@ -804,6 +872,8 @@ impl Write for ImapTransport { match self { Self::Plain(stream) => stream.write(buf), Self::Tls(stream) => stream.write(buf), + #[cfg(test)] + Self::Mock(stream) => stream.write(buf), } } @@ -811,6 +881,8 @@ impl Write for ImapTransport { match self { Self::Plain(stream) => stream.flush(), Self::Tls(stream) => stream.flush(), + #[cfg(test)] + Self::Mock(stream) => stream.flush(), } } } @@ -829,6 +901,16 @@ enum GreetingKind { } impl ImapSession { + #[cfg(test)] + fn with_mock_stream(stream: impl TestTransportIo + 'static) -> Self { + Self { + transport: ImapTransport::Mock(Box::new(stream)), + read_buffer: Vec::new(), + next_tag: 1, + capabilities: HashSet::new(), + } + } + fn connect(config: &ImapConfig) -> Result { let server = config.server.as_deref().ok_or_else(|| { imap_error( @@ -875,6 +957,13 @@ impl ImapSession { "STARTTLS attempted on TLS transport", )); } + #[cfg(test)] + ImapTransport::Mock(_) => { + return Err(imap_error( + ImapErrorKind::Connection, + "STARTTLS attempted on mock transport", + )); + } }; session.read_buffer.clear(); } @@ -2157,8 +2246,10 @@ fn classify(kind: ImapErrorKind) -> &'static str { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::fs; use std::io::{Read, Write}; + use std::net::TcpListener; use std::path::PathBuf; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -2166,13 +2257,18 @@ mod tests { use crate::infra::config::{ImapConfig, ImapEncryption}; use super::{ - FixtureImapClient, GnuArchiveMonthEntry, ImapClient, ImapErrorKind, RemoteImapClient, - ensure_tagged_ok, establish_http_connect_tunnel, establish_socks5_tunnel, - format_uid_sequence_set, gnu_archive_message_uid, lore_raw_url_candidates, - parse_atom_timestamp, parse_fetch_flags, parse_fetch_modseq, parse_fetch_uid, + FixtureImapClient, GnuArchiveClient, GnuArchiveMonthEntry, GreetingKind, ImapClient, + ImapErrorKind, ImapSession, LoreImapClient, MailboxSnapshot, RemoteImapClient, + build_gnu_archive_incremental_mails, build_lore_incremental_mails, + collect_incremental_uids, ensure_tagged_ok, establish_http_connect_tunnel, + establish_socks5_tunnel, fetch_lore_raw_with, format_uid_sequence_set, + gnu_archive_message_uid, lore_raw_url_candidates, normalize_lore_message_url, + parse_atom_timestamp, parse_fetch_flags, parse_fetch_modseq, parse_fetch_uid, parse_flags, + parse_gnu_archive_index_response, parse_gnu_archive_listing_timestamp, parse_gnu_archive_mbox_messages, parse_gnu_archive_month_entries, parse_imap_proxy, - parse_literal_len, parse_lore_atom_entries, parse_status_code_u64, - select_gnu_archive_months, + parse_literal_len, parse_lore_atom_entries, parse_lore_feed_response, + parse_status_code_u64, parse_year_month_key, quote_imap_string, read_http_proxy_response, + select_gnu_archive_months, validate_gnu_archive_mbox_response, }; fn temp_dir(label: &str) -> PathBuf { @@ -2226,6 +2322,189 @@ mod tests { } } + struct WriteFailsStream; + + impl Read for WriteFailsStream { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Ok(0) + } + } + + impl Write for WriteFailsStream { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "write failed", + )) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + struct FlushFailsStream; + + impl Read for FlushFailsStream { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Ok(0) + } + } + + impl Write for FlushFailsStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "flush failed", + )) + } + } + + struct ReadFailsStream; + + impl Read for ReadFailsStream { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "read failed", + )) + } + } + + impl Write for ReadFailsStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[derive(Clone)] + struct StubHttpResponse { + status_code: u16, + content_type: &'static str, + body: Vec, + } + + impl StubHttpResponse { + fn text(status_code: u16, body: impl Into) -> Self { + Self { + status_code, + content_type: "text/plain; charset=utf-8", + body: body.into().into_bytes(), + } + } + + fn bytes(status_code: u16, body: impl Into>) -> Self { + Self { + status_code, + content_type: "application/octet-stream", + body: body.into(), + } + } + } + + fn start_http_server( + expected_requests: usize, + build_routes: F, + ) -> (String, thread::JoinHandle<()>) + where + F: FnOnce(&str) -> HashMap, + { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind http listener"); + let base_url = format!("http://{}", listener.local_addr().expect("listener addr")); + let routes = build_routes(&base_url); + let handle = thread::spawn(move || { + for _ in 0..expected_requests { + let (mut stream, _) = listener.accept().expect("accept http request"); + let path = read_http_request_path(&mut stream); + let response = routes + .get(&path) + .unwrap_or_else(|| panic!("unexpected HTTP path {path}")); + let reason = match response.status_code { + 200 => "OK", + 404 => "Not Found", + _ => "Response", + }; + write!( + stream, + "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n", + response.status_code, + reason, + response.body.len(), + response.content_type, + ) + .expect("write HTTP headers"); + stream.write_all(&response.body).expect("write HTTP body"); + stream.flush().expect("flush HTTP response"); + } + }); + + (base_url, handle) + } + + fn start_http_server_with_raw_responses( + expected_requests: usize, + build_response: F, + ) -> (String, thread::JoinHandle<()>) + where + F: Fn(&str) -> Vec + Send + 'static, + { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind http listener"); + let base_url = format!("http://{}", listener.local_addr().expect("listener addr")); + let handle = thread::spawn(move || { + for _ in 0..expected_requests { + let (mut stream, _) = listener.accept().expect("accept http request"); + let path = read_http_request_path(&mut stream); + let response = build_response(&path); + stream + .write_all(&response) + .expect("write raw HTTP response"); + stream.flush().expect("flush raw HTTP response"); + } + }); + + (base_url, handle) + } + + fn read_http_request_path(stream: &mut impl Read) -> String { + let mut request = Vec::new(); + let mut buf = [0u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + let read = stream.read(&mut buf).expect("read HTTP request"); + if read == 0 { + break; + } + request.extend_from_slice(&buf[..read]); + } + + let request_line = String::from_utf8_lossy(&request); + request_line + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/") + .to_string() + } + + fn complete_imap_config(encryption: ImapEncryption) -> ImapConfig { + ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(encryption), + proxy: None, + } + } + #[test] fn fixture_client_fetches_incremental_messages() { let root = temp_dir("incremental"); @@ -2280,48 +2559,512 @@ mod tests { } #[test] - fn remote_client_accepts_complete_config() { - let client = RemoteImapClient::new(ImapConfig { - email: Some("me@example.com".to_string()), - user: Some("imap-user".to_string()), - pass: Some("imap-pass".to_string()), - server: Some("imap.example.com".to_string()), - server_port: Some(993), - encryption: Some(ImapEncryption::Tls), - proxy: None, - }); + fn fixture_client_requires_connect_and_reports_invalid_sources() { + let missing_root = temp_dir("fixture-missing-root"); + fs::remove_dir_all(&missing_root).expect("remove root"); + let mut client = FixtureImapClient::new(missing_root.clone(), 1); + let error = client.connect().expect_err("missing root should fail"); + assert!(error.to_string().contains("does not exist")); + + let file_root = temp_dir("fixture-file-root"); + let file_path = file_root.join("fixture.eml"); + fs::write(&file_path, "mail").expect("write fixture file"); + let mut file_client = FixtureImapClient::new(file_path.clone(), 1); + let error = file_client.connect().expect_err("file root should fail"); + assert!(error.to_string().contains("is not a directory")); + + let valid_root = temp_dir("fixture-disconnected"); + fs::write( + valid_root.join("1.eml"), + "Message-ID: \nSubject: disconnected\n\nbody\n", + ) + .expect("write fixture mail"); + let mut disconnected = FixtureImapClient::new(valid_root.clone(), 1); + let error = disconnected + .select_mailbox("inbox") + .expect_err("select without connect should fail"); + assert!(error.to_string().contains("client is not connected")); - assert!(client.is_ok()); + disconnected.connect().expect("connect fixture"); + fs::remove_dir_all(&valid_root).expect("remove fixture root after connect"); + let error = disconnected + .select_mailbox("inbox") + .expect_err("missing mailbox directory should fail"); + assert!(error.to_string().contains("mailbox directory")); + + let _ = fs::remove_dir_all(file_root); } #[test] - fn remote_client_rejects_incomplete_config() { - let error = RemoteImapClient::new(ImapConfig { - email: None, - user: Some("imap-user".to_string()), - pass: None, - server: Some("imap.example.com".to_string()), - server_port: Some(993), - encryption: Some(ImapEncryption::Tls), - proxy: None, - }) - .err() - .expect("incomplete config should fail"); + fn fixture_client_scans_subdirectories_deduplicates_uids_and_parses_flags() { + let root = temp_dir("fixture-subdir"); + let mailbox_dir = root.join("inbox"); + fs::create_dir_all(&mailbox_dir).expect("create mailbox dir"); + fs::write( + mailbox_dir.join("1-a.eml"), + "Message-ID: \nSubject: a\nX-Flags: Seen, Flagged\n\nbody\n", + ) + .expect("write first mail"); + thread::sleep(Duration::from_millis(5)); + fs::write( + mailbox_dir.join("1-b.eml"), + "Message-ID: \nSubject: b\nX-Flags: Answered,\n Flagged\n\nbody\n", + ) + .expect("write second mail"); + fs::write( + mailbox_dir.join("note.eml"), + "Message-ID: \nSubject: c\n\nbody\n", + ) + .expect("write third mail"); - assert!(error.to_string().contains("imap.pass")); - } + let mut client = FixtureImapClient::new(root.clone(), 77); + client.connect().expect("connect"); + let snapshot = client.select_mailbox("inbox").expect("select mailbox"); + assert_eq!(snapshot.uidvalidity, 77); + assert_eq!(snapshot.highest_uid, 3); + assert!(snapshot.highest_modseq.is_some()); - #[test] - fn parses_imap_fetch_metadata() { - let line = "* 2 FETCH (UID 2 FLAGS (\\Seen \\Answered) MODSEQ (20) BODY[] {123}"; - assert_eq!(parse_fetch_uid(line), Some(2)); - assert_eq!(parse_fetch_modseq(line), Some(20)); - assert_eq!(parse_literal_len(line), Some(123)); + let fetched = client + .fetch_incremental("inbox", 0, None) + .expect("fetch fixture mail"); assert_eq!( - parse_fetch_flags(line), - vec!["\\Seen".to_string(), "\\Answered".to_string()] + fetched.iter().map(|mail| mail.uid).collect::>(), + vec![1, 2, 3] ); - } + assert_eq!( + fetched[0].flags, + vec!["Seen".to_string(), "Flagged".to_string()] + ); + assert_eq!( + fetched[1].flags, + vec!["Answered".to_string(), "Flagged".to_string()] + ); + + let incremental = client + .fetch_incremental("inbox", 1, None) + .expect("fetch after uid"); + assert_eq!( + incremental.iter().map(|mail| mail.uid).collect::>(), + vec![2, 3] + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn fixture_client_reports_invalid_uidvalidity_marker() { + let root = temp_dir("uidvalidity-invalid"); + fs::write(root.join(".uidvalidity"), "not-a-number\n").expect("write marker"); + fs::write( + root.join("1.eml"), + "Message-ID: \nSubject: x\n\nbody\n", + ) + .expect("write message"); + + let mut client = FixtureImapClient::new(root.clone(), 1); + client.connect().expect("connect"); + let error = client + .select_mailbox("inbox") + .expect_err("invalid UIDVALIDITY should fail"); + assert!(error.to_string().contains("invalid UIDVALIDITY value")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn blank_uidvalidity_marker_falls_back_to_default() { + let root = temp_dir("uidvalidity-blank"); + fs::write(root.join(".uidvalidity"), " \n").expect("write marker"); + fs::write( + root.join("1.eml"), + "Message-ID: \nSubject: x\n\nbody\n", + ) + .expect("write message"); + + let mut client = FixtureImapClient::new(root.clone(), 55); + client.connect().expect("connect"); + let snapshot = client.select_mailbox("inbox").expect("select mailbox"); + assert_eq!(snapshot.uidvalidity, 55); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn remote_client_accepts_complete_config() { + let client = RemoteImapClient::new(complete_imap_config(ImapEncryption::Tls)); + + assert!(client.is_ok()); + } + + #[test] + fn remote_client_rejects_incomplete_config() { + let error = RemoteImapClient::new(ImapConfig { + email: None, + user: Some("imap-user".to_string()), + pass: None, + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }) + .err() + .expect("incomplete config should fail"); + + assert!(error.to_string().contains("imap.pass")); + } + + #[test] + fn lore_client_selects_and_fetches_incremental_from_local_server() { + let first_modseq = parse_atom_timestamp("2026-03-03T09:00:00+00:00").expect("first ts"); + let second_modseq = parse_atom_timestamp("2026-03-03T10:00:00+00:00").expect("second ts"); + let feed = r#" + + + https://lore.kernel.org/io-uring/msg-a/ + 2026-03-03T09:00:00+00:00 + + + + https://lore.kernel.org/io-uring/msg-b/ + 2026-03-03T10:00:00+00:00 + + + +"#; + let base_url = "https://lore.test"; + let mut client = LoreImapClient::new(Some(base_url)).expect("create lore client"); + let error = client + .select_mailbox("io-uring") + .expect_err("select before connect should fail"); + assert!(error.to_string().contains("client is not connected")); + + client.connect().expect("connect lore"); + assert_eq!( + client.feed_url("io-uring"), + "https://lore.test/io-uring/new.atom" + ); + let entries = parse_lore_feed_response(&client.feed_url("io-uring"), 200, feed) + .expect("parse lore feed response"); + let snapshot = MailboxSnapshot { + uidvalidity: 1, + highest_uid: 0, + highest_modseq: entries.iter().map(|entry| entry.modseq).max(), + }; + assert_eq!(snapshot.uidvalidity, 1); + assert_eq!(snapshot.highest_uid, 0); + assert_eq!(snapshot.highest_modseq, Some(second_modseq)); + + let raw_by_url = HashMap::from([( + "https://lore.kernel.org/io-uring/msg-b/raw".to_string(), + b"Message-ID: \n\nbody\n".to_vec(), + )]); + let fetched = build_lore_incremental_mails(entries, Some(first_modseq), |message_url| { + fetch_lore_raw_with(message_url, |raw_url| { + Ok((200, raw_by_url.get(raw_url).cloned().unwrap_or_default())) + }) + }) + .expect("fetch incremental lore"); + assert_eq!(fetched.len(), 1); + assert_eq!(fetched[0].modseq, Some(second_modseq)); + assert!(String::from_utf8_lossy(&fetched[0].raw).contains("msg-b@example.com")); + } + + #[test] + fn lore_client_fetches_feed_and_raw_over_http() { + let first_modseq = parse_atom_timestamp("2026-03-03T09:00:00+00:00").expect("first ts"); + let second_modseq = parse_atom_timestamp("2026-03-03T10:00:00+00:00").expect("second ts"); + let (base_url, handle) = start_http_server(4, |base_url| { + let feed = format!( + r#" + + + {base_url}/io-uring/msg-a/ + 2026-03-03T09:00:00+00:00 + + + + {base_url}/io-uring/msg-b/ + 2026-03-03T10:00:00+00:00 + + + +"# + ); + HashMap::from([ + ( + "/io-uring/new.atom".to_string(), + StubHttpResponse::text(200, feed), + ), + ( + "/io-uring/msg-b/raw".to_string(), + StubHttpResponse::text(404, ""), + ), + ( + "/io-uring/msg-b/raw/".to_string(), + StubHttpResponse::bytes( + 200, + b"Message-ID: \nSubject: message b\n\nbody\n".to_vec(), + ), + ), + ]) + }); + + let mut client = LoreImapClient::new(Some(&base_url)).expect("create lore client"); + client.connect().expect("connect lore"); + + let snapshot = client + .select_mailbox("/io-uring/") + .expect("select lore mailbox"); + assert_eq!(snapshot.uidvalidity, 1); + assert_eq!(snapshot.highest_uid, 0); + assert_eq!(snapshot.highest_modseq, Some(second_modseq)); + + let fetched = client + .fetch_incremental("io-uring", 0, Some(first_modseq)) + .expect("fetch lore mails"); + assert_eq!(fetched.len(), 1); + assert_eq!(fetched[0].modseq, Some(second_modseq)); + assert!(String::from_utf8_lossy(&fetched[0].raw).contains("msg-b@example.com")); + + handle.join().expect("join HTTP server"); + } + + #[test] + fn lore_client_reports_feed_http_errors_from_server() { + let (base_url, handle) = start_http_server(1, |_| { + HashMap::from([( + "/broken/new.atom".to_string(), + StubHttpResponse::text(404, "missing mailbox"), + )]) + }); + + let mut client = LoreImapClient::new(Some(&base_url)).expect("create lore client"); + client.connect().expect("connect lore"); + + let error = client + .select_mailbox("broken") + .expect_err("HTTP errors should fail mailbox selection"); + assert!(error.to_string().contains("HTTP 404")); + + handle.join().expect("join HTTP server"); + } + + #[test] + fn lore_client_checks_status_before_reading_error_body() { + let (base_url, handle) = start_http_server_with_raw_responses(2, move |path| { + match path { + "/io-uring/msg-b/raw" | "/io-uring/msg-b/raw/" => b"HTTP/1.1 404 Not Found\r\nContent-Length: 64\r\nContent-Type: text/plain; charset=utf-8\r\nConnection: close\r\n\r\nshort".to_vec(), + _ => panic!("unexpected HTTP path {path}"), + } + }); + + let mut client = LoreImapClient::new(Some(&base_url)).expect("create lore client"); + client.connect().expect("connect lore"); + + let error = client + .fetch_raw_mail(&format!("{base_url}/io-uring/msg-b/")) + .expect_err("HTTP status should fail before reading body"); + assert!(error.to_string().contains("HTTP 404")); + + handle.join().expect("join HTTP server"); + } + + #[test] + fn lore_client_reports_empty_raw_message_after_trying_candidates() { + let error = fetch_lore_raw_with("https://lore.kernel.org/io-uring/msg-empty/", |_| { + Ok((200, Vec::new())) + }) + .expect_err("empty raw should fail"); + assert!(error.to_string().contains("lore raw message is empty")); + } + + #[test] + fn gnu_archive_client_selects_and_fetches_incremental_from_local_server() { + let feb_modseq = + parse_gnu_archive_listing_timestamp("2026-02-26", "09:12").expect("feb timestamp"); + let html = r#" +
+2026-02 2026-02-26 09:12  855K
+2026-03 2026-03-07 06:37  341K
+
+"#; + let mbox = b"From MAILER-DAEMON Tue Mar 03 04:39:31 2026\nMessage-ID: \nSubject: one\n\nbody\nFrom MAILER-DAEMON Tue Mar 03 04:40:31 2026\nMessage-ID: \nSubject: two\n\nbody two\n"; + let mut client = + GnuArchiveClient::new(Some("https://archive.test")).expect("create archive client"); + let error = client + .select_mailbox("mailbox") + .expect_err("select before connect should fail"); + assert!(error.to_string().contains("client is not connected")); + + client.connect().expect("connect archive"); + assert_eq!(client.index_url("mailbox"), "https://archive.test/mailbox/"); + assert_eq!( + client.month_url("mailbox", "2026-03"), + "https://archive.test/mailbox/2026-03" + ); + let months = parse_gnu_archive_index_response(&client.index_url("mailbox"), 200, html) + .expect("parse archive index"); + let snapshot = MailboxSnapshot { + uidvalidity: 1, + highest_uid: 0, + highest_modseq: months.iter().map(|entry| entry.modseq).max(), + }; + assert_eq!(snapshot.uidvalidity, 1); + assert_eq!(snapshot.highest_uid, 0); + assert!(snapshot.highest_modseq.is_some()); + + let fetched = build_gnu_archive_incremental_mails(&months, Some(feb_modseq), |month_key| { + let url = client.month_url("mailbox", month_key); + validate_gnu_archive_mbox_response(&url, 200)?; + Ok(mbox.to_vec()) + }) + .expect("fetch gnu archive"); + assert_eq!(fetched.len(), 2); + assert_eq!(fetched[0].uid, 314_000_001); + assert_eq!(fetched[1].uid, 314_000_002); + } + + #[test] + fn gnu_archive_client_fetches_index_and_mbox_over_http() { + let feb_modseq = + parse_gnu_archive_listing_timestamp("2026-02-26", "09:12").expect("feb timestamp"); + let (base_url, handle) = start_http_server(3, |_| { + let html = r#" +
+2026-02 2026-02-26 09:12  855K
+2026-03 2026-03-07 06:37  341K
+
+"#; + let mbox = b"From MAILER-DAEMON Tue Mar 03 04:39:31 2026\nMessage-ID: \nSubject: one\n\nbody\nFrom MAILER-DAEMON Tue Mar 03 04:40:31 2026\nMessage-ID: \nSubject: two\n\nbody two\n"; + HashMap::from([ + ("/mailbox/".to_string(), StubHttpResponse::text(200, html)), + ( + "/mailbox/2026-03".to_string(), + StubHttpResponse::bytes(200, mbox.to_vec()), + ), + ]) + }); + + let mut client = GnuArchiveClient::new(Some(&base_url)).expect("create archive client"); + client.connect().expect("connect archive"); + + let snapshot = client + .select_mailbox("/mailbox/") + .expect("select archive mailbox"); + assert_eq!(snapshot.uidvalidity, 1); + assert_eq!(snapshot.highest_uid, 0); + assert!(snapshot.highest_modseq.is_some()); + + let fetched = client + .fetch_incremental("mailbox", 0, Some(feb_modseq)) + .expect("fetch archive month"); + assert_eq!( + fetched.iter().map(|mail| mail.uid).collect::>(), + vec![314_000_001, 314_000_002] + ); + + handle.join().expect("join HTTP server"); + } + + #[test] + fn gnu_archive_client_reports_mbox_http_errors_from_server() { + let feb_modseq = + parse_gnu_archive_listing_timestamp("2026-02-26", "09:12").expect("feb timestamp"); + let (base_url, handle) = start_http_server(2, |_| { + let html = r#" +
+2026-02 2026-02-26 09:12  855K
+2026-03 2026-03-07 06:37  341K
+
+"#; + HashMap::from([ + ("/mailbox/".to_string(), StubHttpResponse::text(200, html)), + ( + "/mailbox/2026-03".to_string(), + StubHttpResponse::text(404, "month not found"), + ), + ]) + }); + + let mut client = GnuArchiveClient::new(Some(&base_url)).expect("create archive client"); + client.connect().expect("connect archive"); + + let error = client + .fetch_incremental("mailbox", 0, Some(feb_modseq)) + .expect_err("HTTP errors should fail archive fetch"); + assert!(error.to_string().contains("HTTP 404")); + + handle.join().expect("join HTTP server"); + } + + #[test] + fn gnu_archive_client_checks_status_before_reading_error_body() { + let feb_modseq = + parse_gnu_archive_listing_timestamp("2026-02-26", "09:12").expect("feb timestamp"); + let index_body = r#" +
+2026-02 2026-02-26 09:12  855K
+2026-03 2026-03-07 06:37  341K
+
+"#; + let (base_url, handle) = start_http_server_with_raw_responses(2, move |path| { + match path { + "/mailbox/" => format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain; charset=utf-8\r\nConnection: close\r\n\r\n{}", + index_body.len(), + index_body, + ) + .into_bytes(), + "/mailbox/2026-03" => b"HTTP/1.1 404 Not Found\r\nContent-Length: 64\r\nContent-Type: text/plain; charset=utf-8\r\nConnection: close\r\n\r\nshort".to_vec(), + _ => panic!("unexpected HTTP path {path}"), + } + }); + + let mut client = GnuArchiveClient::new(Some(&base_url)).expect("create archive client"); + client.connect().expect("connect archive"); + + let error = client + .fetch_incremental("mailbox", 0, Some(feb_modseq)) + .expect_err("HTTP status should fail before reading body"); + assert!(error.to_string().contains("HTTP 404")); + + handle.join().expect("join HTTP server"); + } + + #[test] + fn http_clients_surface_transport_errors_before_receiving_responses() { + let mut lore_client = + LoreImapClient::new(Some("http://127.0.0.1:1")).expect("create lore client"); + lore_client.connect().expect("connect lore"); + let lore_error = lore_client + .select_mailbox("io-uring") + .expect_err("missing listener should fail feed fetch"); + assert!(lore_error.to_string().contains("failed to fetch lore feed")); + + let mut archive_client = + GnuArchiveClient::new(Some("http://127.0.0.1:1")).expect("create archive client"); + archive_client.connect().expect("connect archive"); + let archive_error = archive_client + .select_mailbox("mailbox") + .expect_err("missing listener should fail archive fetch"); + assert!( + archive_error + .to_string() + .contains("failed to fetch GNU archive index") + ); + } + + #[test] + fn parses_imap_fetch_metadata() { + let line = "* 2 FETCH (UID 2 FLAGS (\\Seen \\Answered) MODSEQ (20) BODY[] {123}"; + assert_eq!(parse_fetch_uid(line), Some(2)); + assert_eq!(parse_fetch_modseq(line), Some(20)); + assert_eq!(parse_literal_len(line), Some(123)); + assert_eq!( + parse_fetch_flags(line), + vec!["\\Seen".to_string(), "\\Answered".to_string()] + ); + } #[test] fn parses_select_status_codes() { @@ -2370,6 +3113,38 @@ mod tests { assert_eq!(format_uid_sequence_set(&[9, 7, 8, 8]), "7:9"); } + #[test] + fn imap_proxy_parser_rejects_invalid_proxy_urls() { + let proxy = parse_imap_proxy("http://127.0.0.1").expect("default http port"); + assert_eq!(proxy.redacted_url(), "http://127.0.0.1:80"); + let socks_proxy = parse_imap_proxy("socks5h://127.0.0.1").expect("default socks5 port"); + assert_eq!(socks_proxy.redacted_url(), "socks5://127.0.0.1:1080"); + + let unsupported = + parse_imap_proxy("https://127.0.0.1:443").expect_err("unsupported scheme should fail"); + assert!( + unsupported + .to_string() + .contains("unsupported IMAP proxy scheme") + ); + + let auth = parse_imap_proxy("http://user:pass@127.0.0.1:8080") + .expect_err("proxy auth should fail"); + assert!(auth.to_string().contains("authentication is not supported")); + + let path_error = + parse_imap_proxy("http://127.0.0.1:8080/proxy").expect_err("path should fail"); + assert!( + path_error + .to_string() + .contains("remove path, query, and fragment") + ); + + let missing_host = + parse_imap_proxy("http:///").expect_err("missing host should fail proxy parsing"); + assert!(missing_host.to_string().contains("invalid IMAP proxy URL")); + } + #[test] fn http_proxy_connect_tunnels_imap_socket() { let proxy = parse_imap_proxy("http://127.0.0.1:7890").expect("parse proxy"); @@ -2383,6 +3158,64 @@ mod tests { assert!(request_text.contains("\r\nHost: imap.gmail.com:993\r\n")); } + #[test] + fn http_proxy_helpers_report_truncated_and_rejected_responses() { + let proxy = parse_imap_proxy("http://127.0.0.1:7890").expect("parse proxy"); + + let mut truncated = MockStream::default(); + let error = read_http_proxy_response(&mut truncated, &proxy, "imap.gmail.com:993") + .expect_err("truncated response should fail"); + assert!( + error + .to_string() + .contains("closed the connection before CONNECT") + ); + + let mut rejected = MockStream::with_reads(b"HTTP/1.1 403 Forbidden\r\n\r\n"); + let error = establish_http_connect_tunnel(&mut rejected, &proxy, "imap.gmail.com", 993) + .expect_err("non-2xx connect should fail"); + assert!(error.to_string().contains("rejected CONNECT")); + + let oversized = vec![b'a'; super::HTTP_PROXY_RESPONSE_MAX_BYTES + 1]; + let mut oversized_stream = MockStream::with_reads(&oversized); + let error = read_http_proxy_response(&mut oversized_stream, &proxy, "imap.gmail.com:993") + .expect_err("oversized responses should fail"); + assert!( + error + .to_string() + .contains("sent too much HTTP response data") + ); + + let mut read_error_stream = ReadFailsStream; + let error = read_http_proxy_response(&mut read_error_stream, &proxy, "imap.gmail.com:993") + .expect_err("proxy read failures should surface"); + assert!( + error + .to_string() + .contains("failed while reading IMAP proxy") + ); + + let mut write_error_stream = WriteFailsStream; + let error = + establish_http_connect_tunnel(&mut write_error_stream, &proxy, "imap.gmail.com", 993) + .expect_err("write failures should surface"); + assert!( + error + .to_string() + .contains("failed to send IMAP CONNECT request") + ); + + let mut flush_error_stream = FlushFailsStream; + let error = + establish_http_connect_tunnel(&mut flush_error_stream, &proxy, "imap.gmail.com", 993) + .expect_err("flush failures should surface"); + assert!( + error + .to_string() + .contains("failed to flush IMAP CONNECT request") + ); + } + #[test] fn socks5_proxy_connect_tunnels_imap_socket() { let proxy = parse_imap_proxy("socks5://127.0.0.1:7890").expect("parse proxy"); @@ -2406,6 +3239,498 @@ mod tests { ); } + #[test] + fn socks5_proxy_reports_handshake_and_connect_failures() { + let proxy = parse_imap_proxy("socks5://127.0.0.1:7890").expect("parse proxy"); + + let mut invalid_version = MockStream::with_reads(&[0x04, 0x00]); + let error = establish_socks5_tunnel(&mut invalid_version, &proxy, "imap.gmail.com", 993) + .expect_err("invalid version should fail"); + assert!(error.to_string().contains("invalid SOCKS5 version")); + + let mut unauthenticated = MockStream::with_reads(&[0x05, 0x02]); + let error = establish_socks5_tunnel(&mut unauthenticated, &proxy, "imap.gmail.com", 993) + .expect_err("auth required should fail"); + assert!(error.to_string().contains("does not allow unauthenticated")); + + let mut connect_failed = MockStream::with_reads(&[0x05, 0x00, 0x05, 0x05, 0x00]); + let error = establish_socks5_tunnel(&mut connect_failed, &proxy, "imap.gmail.com", 993) + .expect_err("connect reply should fail"); + assert!(error.to_string().contains("connection refused")); + + let mut greeting_read_error = ReadFailsStream; + let error = + establish_socks5_tunnel(&mut greeting_read_error, &proxy, "imap.gmail.com", 993) + .expect_err("greeting read failures should surface"); + assert!( + error + .to_string() + .contains("failed to read SOCKS5 greeting reply") + ); + + let mut greeting_write_error = WriteFailsStream; + let error = + establish_socks5_tunnel(&mut greeting_write_error, &proxy, "imap.gmail.com", 993) + .expect_err("greeting write failures should surface"); + assert!(error.to_string().contains("failed to send SOCKS5 greeting")); + + let too_long_host = "a".repeat(256); + let mut host_length_stream = MockStream::with_reads(&[0x05, 0x00]); + let error = establish_socks5_tunnel(&mut host_length_stream, &proxy, &too_long_host, 993) + .expect_err("long hostnames should fail"); + assert!(error.to_string().contains("too long for SOCKS5 proxying")); + } + + #[test] + fn socks5_reply_address_reports_decode_failures() { + let proxy = parse_imap_proxy("socks5://127.0.0.1:7890").expect("parse proxy"); + + let mut atyp_error = ReadFailsStream; + let error = super::read_socks5_reply_address(&mut atyp_error, &proxy) + .expect_err("ATYP read failures should surface"); + assert!( + error + .to_string() + .contains("failed to read SOCKS5 reply type") + ); + + let mut domain_length_error = MockStream::with_reads(&[0x03]); + let error = super::read_socks5_reply_address(&mut domain_length_error, &proxy) + .expect_err("domain length failures should surface"); + assert!( + error + .to_string() + .contains("failed to read SOCKS5 domain length") + ); + + let mut bind_address_error = MockStream::with_reads(&[0x01]); + let error = super::read_socks5_reply_address(&mut bind_address_error, &proxy) + .expect_err("bind address failures should surface"); + assert!( + error + .to_string() + .contains("failed to read SOCKS5 bind address") + ); + } + + #[test] + fn imap_session_executes_command_flow_over_mock_transport() { + let responses = concat!( + "* OK hello\r\n", + "* CAPABILITY IMAP4rev1 CONDSTORE\r\n", + "A0001 OK capability\r\n", + "A0002 OK login\r\n", + "* OK [UIDVALIDITY 77] valid\r\n", + "* OK [UIDNEXT 42] next\r\n", + "* OK [HIGHESTMODSEQ 9001] modseq\r\n", + "A0003 OK select\r\n", + "* SEARCH 4 5 5 9\r\n", + "A0004 OK search\r\n", + "* 1 FETCH (UID 4 FLAGS (\\Seen \\Answered) MODSEQ (20) BODY.PEEK[] {5}\r\n", + "hello\r\n", + ")\r\n", + "A0005 OK fetch\r\n" + ); + let mut session = + ImapSession::with_mock_stream(MockStream::with_reads(responses.as_bytes())); + + assert_eq!( + session.read_greeting().expect("read greeting"), + GreetingKind::Ok + ); + let capabilities = session.fetch_capabilities().expect("fetch capabilities"); + assert!(capabilities.contains("IMAP4REV1")); + assert!(capabilities.contains("CONDSTORE")); + session.capabilities = capabilities; + + let config = ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }; + session.login(&config).expect("login"); + + let snapshot = session.select_mailbox("INBOX").expect("select"); + assert_eq!(snapshot.uidvalidity, 77); + assert_eq!(snapshot.highest_uid, 41); + assert_eq!(snapshot.highest_modseq, Some(9001)); + + let uids = session.search_uid_range(4).expect("search uids"); + assert_eq!(uids, vec![4, 5, 9]); + + let fetched = session.fetch_uids(&[4], "BODY.PEEK[]").expect("fetch uids"); + assert_eq!(fetched.len(), 1); + assert_eq!(fetched[0].uid, 4); + assert_eq!(fetched[0].modseq, Some(20)); + assert_eq!( + fetched[0].flags, + vec!["\\Seen".to_string(), "\\Answered".to_string()] + ); + assert_eq!(fetched[0].raw, b"hello".to_vec()); + } + + #[test] + fn imap_session_handles_greeting_login_and_fetch_failures() { + let mut bye_session = + ImapSession::with_mock_stream(MockStream::with_reads(b"* BYE go away\r\n")); + let error = bye_session + .read_greeting() + .expect_err("BYE greeting should fail"); + assert!( + error + .to_string() + .contains("server closed connection during greeting") + ); + + let mut plaintext_session = ImapSession::with_mock_stream(MockStream::default()); + plaintext_session + .capabilities + .insert("LOGINDISABLED".to_string()); + let config = ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(143), + encryption: Some(ImapEncryption::None), + proxy: None, + }; + let error = plaintext_session + .login(&config) + .expect_err("plaintext login should be rejected"); + assert!(error.to_string().contains("disallows LOGIN over plaintext")); + + let mut fetch_session = ImapSession::with_mock_stream(MockStream::with_reads( + b"* 1 FETCH (FLAGS (\\Seen) BODY.PEEK[] {5}\r\nhello\r\n)\r\nA0001 OK fetch\r\n", + )); + let error = fetch_session + .fetch_uid_chunk(&[4], "BODY.PEEK[]") + .expect_err("missing UID should fail"); + assert!(error.to_string().contains("missing UID in FETCH response")); + + let mut eof_session = ImapSession::with_mock_stream(MockStream::default()); + let error = eof_session + .read_line_string() + .expect_err("EOF while reading line should fail"); + assert!( + error + .to_string() + .contains("unexpected EOF while reading IMAP response") + ); + } + + #[test] + fn imap_session_covers_misc_defaults_and_protocol_edges() { + let mut preauth_session = + ImapSession::with_mock_stream(MockStream::with_reads(b"* PREAUTH welcome\r\n")); + assert_eq!( + preauth_session.read_greeting().expect("PREAUTH greeting"), + GreetingKind::Preauth + ); + + let mut unexpected_session = + ImapSession::with_mock_stream(MockStream::with_reads(b"* WHAT welcome\r\n")); + let error = unexpected_session + .read_greeting() + .expect_err("unexpected greeting should fail"); + assert!(error.to_string().contains("unexpected IMAP greeting")); + + let mut missing_user_session = ImapSession::with_mock_stream(MockStream::default()); + let error = missing_user_session + .login(&ImapConfig { + email: None, + user: None, + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }) + .expect_err("missing user should fail"); + assert!(error.to_string().contains("missing imap.user")); + + let mut missing_pass_session = ImapSession::with_mock_stream(MockStream::default()); + let error = missing_pass_session + .login(&ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: None, + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }) + .expect_err("missing pass should fail"); + assert!(error.to_string().contains("missing imap.pass")); + + let mut default_select = ImapSession::with_mock_stream(MockStream::with_reads( + b"* FLAGS (\\Seen)\r\nA0001 OK [READ-WRITE] select\r\n", + )); + let snapshot = default_select + .select_mailbox("INBOX") + .expect("select mailbox with defaults"); + assert_eq!(snapshot.uidvalidity, 1); + assert_eq!(snapshot.highest_uid, 0); + assert_eq!(snapshot.highest_modseq, None); + + let mut noisy_search = ImapSession::with_mock_stream(MockStream::with_reads( + b"* OK noop\r\n* SEARCH 7 nope 9\r\nA0001 OK search\r\n", + )); + let uids = noisy_search.search_uid_range(7).expect("search with noise"); + assert_eq!(uids, vec![7, 9]); + + let mut empty_fetches = ImapSession::with_mock_stream(MockStream::default()); + assert!( + empty_fetches + .fetch_uids(&[], "BODY.PEEK[]") + .expect("empty uid list") + .is_empty() + ); + assert!( + empty_fetches + .fetch_uid_chunk(&[], "BODY.PEEK[]") + .expect("empty uid chunk") + .is_empty() + ); + } + + #[test] + fn imap_session_reports_fetch_trailer_and_io_failures() { + let mut missing_literal = ImapSession::with_mock_stream(MockStream::with_reads( + b"* 1 FETCH (UID 4 FLAGS (\\Seen) BODY.PEEK[]\r\nA0001 OK fetch\r\n", + )); + let error = missing_literal + .fetch_uid_chunk(&[4], "BODY.PEEK[]") + .expect_err("missing literal should fail"); + assert!(error.to_string().contains("missing literal size")); + + let mut truncated_trailer = ImapSession::with_mock_stream(MockStream::with_reads( + b"* 1 FETCH (UID 4 FLAGS (\\Seen) MODSEQ (20) BODY.PEEK[] {5}\r\nhello\r\nA0001 OK fetch\r\n", + )); + let error = truncated_trailer + .fetch_uid_chunk(&[4], "BODY.PEEK[]") + .expect_err("truncated trailer should fail"); + assert!(error.to_string().contains("truncated FETCH trailer")); + + let mut write_error_session = ImapSession::with_mock_stream(WriteFailsStream); + let error = write_error_session + .write_command("A0001", "NOOP") + .expect_err("write failures should surface"); + assert!(error.to_string().contains("failed to write IMAP command")); + + let mut flush_error_session = ImapSession::with_mock_stream(FlushFailsStream); + let error = flush_error_session + .write_command("A0001", "NOOP") + .expect_err("flush failures should surface"); + assert!(error.to_string().contains("failed to flush IMAP command")); + + let mut read_line_error_session = ImapSession::with_mock_stream(ReadFailsStream); + let error = read_line_error_session + .read_line_bytes() + .expect_err("socket read failures should surface"); + assert!( + error + .to_string() + .contains("failed to read from IMAP socket") + ); + + let mut read_literal_error_session = ImapSession::with_mock_stream(ReadFailsStream); + let error = read_literal_error_session + .read_exact_bytes(4) + .expect_err("literal read failures should surface"); + assert!(error.to_string().contains("failed to read IMAP literal")); + } + + #[test] + fn collect_incremental_uids_merges_uid_and_modseq_search_results() { + let responses = concat!( + "* SEARCH 7 8\r\n", + "A0001 OK search\r\n", + "* SEARCH 8 9\r\n", + "A0002 OK search\r\n" + ); + let mut session = + ImapSession::with_mock_stream(MockStream::with_reads(responses.as_bytes())); + let snapshot = MailboxSnapshot { + uidvalidity: 1, + highest_uid: 9, + highest_modseq: Some(30), + }; + + let uids = collect_incremental_uids(&mut session, snapshot, 6, Some(20)) + .expect("collect incremental uids"); + assert_eq!(uids, vec![7, 8, 9]); + } + + #[test] + fn imap_session_connect_rejects_missing_runtime_fields() { + let missing_server = ImapSession::connect(&ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: None, + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }) + .err() + .expect("missing server should fail"); + assert!(missing_server.to_string().contains("missing imap.server")); + + let missing_port = ImapSession::connect(&ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: None, + encryption: Some(ImapEncryption::Tls), + proxy: None, + }) + .err() + .expect("missing port should fail"); + assert!(missing_port.to_string().contains("missing imap.serverport")); + + let missing_encryption = ImapSession::connect(&ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: None, + proxy: None, + }) + .err() + .expect("missing encryption should fail"); + assert!( + missing_encryption + .to_string() + .contains("missing imap.encryption") + ); + } + + #[test] + fn remote_client_requires_connected_session_and_delegates_requests() { + let config = complete_imap_config(ImapEncryption::Tls); + + let mut disconnected = + RemoteImapClient::new(config.clone()).expect("create disconnected remote client"); + let error = disconnected + .select_mailbox("INBOX") + .expect_err("missing session should fail"); + assert!( + error + .to_string() + .contains("remote IMAP session is not connected") + ); + + let mut select_client = RemoteImapClient { + config: config.clone(), + session: Some(ImapSession::with_mock_stream(MockStream::with_reads( + concat!( + "* OK [UIDVALIDITY 11] valid\r\n", + "* OK [UIDNEXT 4] next\r\n", + "* OK [HIGHESTMODSEQ 22] modseq\r\n", + "A0001 OK select\r\n" + ) + .as_bytes(), + ))), + }; + let snapshot = select_client + .select_mailbox("INBOX") + .expect("delegate mailbox selection"); + assert_eq!(snapshot.uidvalidity, 11); + assert_eq!(snapshot.highest_uid, 3); + assert_eq!(snapshot.highest_modseq, Some(22)); + + let mut incremental_client = RemoteImapClient { + config: config.clone(), + session: Some(ImapSession::with_mock_stream(MockStream::with_reads( + concat!( + "* OK [UIDVALIDITY 11] valid\r\n", + "* OK [UIDNEXT 4] next\r\n", + "* OK [HIGHESTMODSEQ 22] modseq\r\n", + "A0001 OK select\r\n", + "* SEARCH 2 3\r\n", + "A0002 OK search\r\n", + "* SEARCH 3\r\n", + "A0003 OK search\r\n", + "* 1 FETCH (UID 2 FLAGS (\\Seen) MODSEQ (21) BODY.PEEK[] {5}\r\n", + "hello\r\n", + ")\r\n", + "* 2 FETCH (UID 3 FLAGS (\\Seen) MODSEQ (22) BODY.PEEK[] {5}\r\n", + "world\r\n", + ")\r\n", + "A0004 OK fetch\r\n" + ) + .as_bytes(), + ))), + }; + let fetched = incremental_client + .fetch_incremental("INBOX", 1, Some(20)) + .expect("delegate incremental fetch"); + assert_eq!( + fetched.iter().map(|mail| mail.uid).collect::>(), + vec![2, 3] + ); + + let mut header_client = RemoteImapClient { + config: config.clone(), + session: Some(ImapSession::with_mock_stream(MockStream::with_reads( + concat!( + "* OK [UIDVALIDITY 11] valid\r\n", + "* OK [UIDNEXT 8] next\r\n", + "* OK [HIGHESTMODSEQ 22] modseq\r\n", + "A0001 OK select\r\n", + "* SEARCH 7\r\n", + "A0002 OK search\r\n", + "* 1 FETCH (UID 7 FLAGS (\\Seen) MODSEQ (22) BODY.PEEK[HEADER.FIELDS (MESSAGE-ID SUBJECT FROM DATE IN-REPLY-TO REFERENCES LIST-ID)] {5}\r\n", + "headr\r\n", + ")\r\n", + "A0003 OK fetch\r\n" + ) + .as_bytes(), + ))), + }; + let header_candidates = header_client + .fetch_header_candidates("INBOX", 6, None) + .expect("delegate header fetch"); + assert_eq!( + header_candidates + .iter() + .map(|mail| mail.uid) + .collect::>(), + vec![7] + ); + + let mut full_uid_client = RemoteImapClient { + config, + session: Some(ImapSession::with_mock_stream(MockStream::with_reads( + concat!( + "* OK [UIDVALIDITY 11] valid\r\n", + "* OK [UIDNEXT 10] next\r\n", + "* OK [HIGHESTMODSEQ 22] modseq\r\n", + "A0001 OK select\r\n", + "* 1 FETCH (UID 9 FLAGS (\\Seen) MODSEQ (22) BODY.PEEK[] {4}\r\n", + "full\r\n", + ")\r\n", + "A0002 OK fetch\r\n" + ) + .as_bytes(), + ))), + }; + let full = full_uid_client + .fetch_full_uids("INBOX", &[9]) + .expect("delegate full UID fetch"); + assert_eq!( + full.iter().map(|mail| mail.uid).collect::>(), + vec![9] + ); + } + #[test] fn parses_lore_atom_entries() { let xml = r#" @@ -2448,6 +3773,32 @@ mod tests { assert!(ts > 0); } + #[test] + fn parsing_helpers_cover_url_normalization_dates_and_flags() { + assert_eq!(quote_imap_string(r#"a"b\c"#), r#""a\"b\\c""#); + assert_eq!( + normalize_lore_message_url("https://lore.kernel.org/io-uring/msg/#fragment?query=1") + .as_deref(), + Some("https://lore.kernel.org/io-uring/msg/") + ); + assert_eq!(normalize_lore_message_url("not-a-url"), None); + + assert_eq!(parse_year_month_key("2026-03"), Some((2026, 3))); + assert_eq!(parse_year_month_key("2026-13"), None); + assert!(parse_gnu_archive_listing_timestamp("2026-03-07", "06:37").is_some()); + + assert_eq!( + parse_flags( + b"Message-ID: \r\nX-Flags: Seen,\r\n Flagged Answered\r\n\r\nbody\r\n" + ), + vec![ + "Seen".to_string(), + "Flagged".to_string(), + "Answered".to_string() + ] + ); + } + #[test] fn parses_gnu_archive_month_entries() { let html = r#" diff --git a/src/infra/mail_store.rs b/src/infra/mail_store.rs index 1a022a2..fc8545b 100644 --- a/src/infra/mail_store.rs +++ b/src/infra/mail_store.rs @@ -1175,7 +1175,7 @@ mod tests { use super::{ IncomingMail, SyncBatch, apply_sync_batch, load_mailbox_state, load_thread_rows_by_mailbox, - prune_mailbox_subjects, + mailbox_message_count, prune_mailbox_subjects, rebuild_all_threads, }; fn temp_dir(label: &str) -> PathBuf { @@ -1200,6 +1200,275 @@ mod tests { } } + fn incoming_at_path(mailbox: &str, uid: u32, raw_path: PathBuf, raw: &str) -> IncomingMail { + let fallback_id = format!("synthetic-{mailbox}-{uid}@local"); + IncomingMail { + mailbox: mailbox.to_string(), + uid, + modseq: Some(uid as u64), + flags: vec!["Seen".to_string()], + raw_path, + parsed: mail_parser::parse_headers(raw.as_bytes(), fallback_id), + } + } + + fn empty_batch(mailbox: &str) -> SyncBatch { + SyncBatch { + mailbox: mailbox.to_string(), + uidvalidity: 1, + highest_uid: 0, + highest_modseq: None, + mails: Vec::new(), + } + } + + #[test] + fn mailbox_state_starts_empty_and_message_count_skips_expunged_rows() { + let root = temp_dir("mailbox-state"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let state = load_mailbox_state(&db_path, "inbox").expect("load initial state"); + assert!(state.is_none()); + + let batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 2, + highest_modseq: Some(2), + mails: vec![ + incoming( + "inbox", + 1, + "Message-ID: \nSubject: keep\nFrom: keep@example.com\n\nbody\n", + ), + incoming( + "inbox", + 2, + "Message-ID: \nSubject: drop\nFrom: drop@example.com\n\nbody\n", + ), + ], + }; + apply_sync_batch(&db_path, batch).expect("seed mailbox"); + + let connection = Connection::open(&db_path).expect("open db"); + connection + .execute( + "UPDATE mail SET is_expunged = 1 WHERE message_id = 'drop@example.com'", + [], + ) + .expect("mark expunged"); + drop(connection); + + let count = mailbox_message_count(&db_path, "inbox").expect("count active mail"); + assert_eq!(count, 1); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn empty_sync_batch_preserves_checkpoint_and_skips_thread_rebuild() { + let root = temp_dir("empty-batch"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let first_batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 5, + highest_modseq: Some(10), + mails: vec![incoming( + "inbox", + 5, + "Message-ID: \nSubject: seed\nFrom: seed@example.com\n\nbody\n", + )], + }; + apply_sync_batch(&db_path, first_batch).expect("first sync"); + + let second_batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 3, + highest_modseq: Some(8), + mails: Vec::new(), + }; + let result = apply_sync_batch(&db_path, second_batch).expect("empty sync"); + + assert_eq!(result.inserted, 0); + assert_eq!(result.updated, 0); + assert_eq!(result.rebuilt_roots, 0); + assert!(!result.mailbox_rebuilt); + assert_eq!(result.state.last_seen_uid, 5); + assert_eq!(result.state.highest_modseq, Some(10)); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn storage_functions_report_clear_errors_for_uninitialized_database() { + let root = temp_dir("uninitialized"); + let db_path = root.join("criew.db"); + Connection::open(&db_path).expect("create raw sqlite database"); + + let load_state_error = load_mailbox_state(&db_path, "inbox") + .expect_err("raw database should reject state lookup"); + assert!( + load_state_error + .to_string() + .contains("failed to load mailbox checkpoint") + ); + + let count_error = mailbox_message_count(&db_path, "inbox") + .expect_err("raw database should reject message counts"); + assert!(count_error.to_string().contains("failed to count mails")); + + let prune_error = prune_mailbox_subjects(&db_path, "inbox", |_| true) + .expect_err("raw database should reject prune queries"); + assert!( + prune_error + .to_string() + .contains("failed to prepare mailbox prune query") + ); + + let sync_error = apply_sync_batch(&db_path, empty_batch("inbox")) + .expect_err("raw database should reject sync writes"); + assert!( + sync_error + .to_string() + .contains("failed to load mailbox checkpoint") + ); + + let rows_error = load_thread_rows_by_mailbox(&db_path, "inbox", 10) + .expect_err("raw database should reject thread row loads"); + assert!( + rows_error + .to_string() + .contains("mailbox-specific thread query") + ); + + let rebuild_error = + rebuild_all_threads(&db_path).expect_err("raw database should reject thread rebuilds"); + assert!( + rebuild_error + .to_string() + .contains("failed to prepare mail graph query") + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn checkpoint_handles_missing_modseq_and_rejects_uidvalidity_overflow() { + let root = temp_dir("checkpoint-modseq"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let mut first_mail = incoming( + "inbox", + 1, + "Message-ID: \nSubject: root\nFrom: root@example.com\n\nbody\n", + ); + first_mail.modseq = None; + let first = apply_sync_batch( + &db_path, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: None, + mails: vec![first_mail], + }, + ) + .expect("persist checkpoint without modseq"); + assert_eq!(first.state.highest_modseq, None); + + let second = apply_sync_batch( + &db_path, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(7), + mails: Vec::new(), + }, + ) + .expect("adopt first modseq checkpoint"); + assert_eq!(second.state.highest_modseq, Some(7)); + + let third = apply_sync_batch( + &db_path, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: None, + mails: Vec::new(), + }, + ) + .expect("preserve previous modseq checkpoint"); + assert_eq!(third.state.highest_modseq, Some(7)); + + let overflow = apply_sync_batch( + &db_path, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: u64::MAX, + highest_uid: 1, + highest_modseq: Some(8), + mails: Vec::new(), + }, + ) + .expect_err("overflowing uidvalidity should fail"); + assert!(overflow.to_string().contains("overflows i64 sqlite field")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn apply_sync_batch_reports_missing_checkpoint_after_trigger_removes_it() { + let root = temp_dir("checkpoint-trigger"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let connection = Connection::open(&db_path).expect("open db"); + connection + .execute( + " +CREATE TRIGGER delete_mailbox_checkpoint +AFTER INSERT ON imap_mailbox_state +BEGIN + DELETE FROM imap_mailbox_state WHERE mailbox = NEW.mailbox; +END +", + [], + ) + .expect("create checkpoint trigger"); + drop(connection); + + let error = apply_sync_batch( + &db_path, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(1), + mails: vec![incoming( + "inbox", + 1, + "Message-ID: \nSubject: root\nFrom: root@example.com\n\nbody\n", + )], + }, + ) + .expect_err("missing checkpoint row should fail"); + assert!( + error + .to_string() + .contains("missing mailbox checkpoint after update") + ); + + let _ = fs::remove_dir_all(root); + } + #[test] fn repeated_sync_is_idempotent() { let root = temp_dir("idempotent"); @@ -1387,6 +1656,106 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn prune_mailbox_subjects_noop_keeps_rows_unchanged() { + let root = temp_dir("prune-noop"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(1), + mails: vec![incoming( + "inbox", + 1, + "Message-ID: \nSubject: [PATCH] keep\nFrom: keep@example.com\n\nbody\n", + )], + }; + apply_sync_batch(&db_path, batch).expect("seed mailbox"); + + let pruned = prune_mailbox_subjects(&db_path, "inbox", |_| true).expect("noop prune"); + assert_eq!(pruned, 0); + assert_eq!( + mailbox_message_count(&db_path, "inbox").expect("count mails"), + 1 + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prune_mailbox_subjects_tolerates_missing_raw_files() { + let root = temp_dir("prune-missing-raw"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let raw_path = root.join("status.eml"); + fs::write(&raw_path, "raw mail").expect("write raw mail"); + let batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(1), + mails: vec![incoming_at_path( + "inbox", + 1, + raw_path.clone(), + "Message-ID: \nSubject: status\nFrom: status@example.com\n\nbody\n", + )], + }; + apply_sync_batch(&db_path, batch).expect("seed mailbox"); + fs::remove_file(&raw_path).expect("remove raw mail before prune"); + + let pruned = + prune_mailbox_subjects(&db_path, "inbox", |_| false).expect("prune missing raw file"); + assert_eq!(pruned, 1); + assert_eq!( + mailbox_message_count(&db_path, "inbox").expect("count remaining mail"), + 0 + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prune_mailbox_subjects_keeps_database_progress_when_raw_cleanup_fails() { + let root = temp_dir("prune-raw-error"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let raw_dir = root.join("raw-dir"); + fs::create_dir_all(&raw_dir).expect("create raw directory"); + let batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(1), + mails: vec![incoming_at_path( + "inbox", + 1, + raw_dir.clone(), + "Message-ID: \nSubject: status\nFrom: status@example.com\n\nbody\n", + )], + }; + apply_sync_batch(&db_path, batch).expect("seed mailbox"); + + let pruned = prune_mailbox_subjects(&db_path, "inbox", |_| false) + .expect("cleanup failures should not roll back prune"); + assert_eq!(pruned, 1); + assert_eq!( + mailbox_message_count(&db_path, "inbox").expect("count remaining mail"), + 0 + ); + assert!( + raw_dir.exists(), + "raw cleanup failure should leave directory intact" + ); + + let _ = fs::remove_dir_all(root); + } + #[test] fn threading_prefers_references_then_in_reply_to() { let root = temp_dir("threading"); @@ -1566,4 +1935,247 @@ WHERE m.message_id = 'grand@example.com' let _ = fs::remove_dir_all(root); } + + #[test] + fn rebuild_all_threads_restores_materialized_rows_after_manual_deletion() { + let root = temp_dir("rebuild-all"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 2, + highest_modseq: Some(2), + mails: vec![ + incoming( + "inbox", + 1, + "Message-ID: \nSubject: root\nFrom: root@example.com\n\nbody\n", + ), + incoming( + "inbox", + 2, + "Message-ID: \nSubject: Re: root\nFrom: reply@example.com\nIn-Reply-To: \nReferences: \n\nbody\n", + ), + ], + }; + apply_sync_batch(&db_path, batch).expect("seed mailbox"); + + let connection = Connection::open(&db_path).expect("open db"); + connection + .execute("DELETE FROM thread_node", []) + .expect("clear thread nodes"); + connection + .execute("DELETE FROM thread", []) + .expect("clear threads"); + drop(connection); + + let rebuilt = rebuild_all_threads(&db_path).expect("rebuild threads"); + assert_eq!(rebuilt, 1); + + let rows = load_thread_rows_by_mailbox(&db_path, "inbox", 20).expect("load rebuilt rows"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].message_id, "root@example.com"); + assert_eq!(rows[1].message_id, "reply@example.com"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn cyclic_references_degrade_to_a_stable_thread_shape() { + let root = temp_dir("cycle"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 2, + highest_modseq: Some(2), + mails: vec![ + incoming( + "inbox", + 1, + "Message-ID: \nSubject: loop a\nFrom: a@example.com\nIn-Reply-To: \nReferences: \n\nbody\n", + ), + incoming( + "inbox", + 2, + "Message-ID: \nSubject: loop b\nFrom: b@example.com\nIn-Reply-To: \nReferences: \n\nbody\n", + ), + ], + }; + apply_sync_batch(&db_path, batch).expect("sync cyclic thread"); + + let rows = load_thread_rows_by_mailbox(&db_path, "inbox", 20).expect("load thread rows"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].depth, 0); + assert_eq!(rows[1].depth, 1); + assert_eq!(rows[0].thread_id, rows[1].thread_id); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rebuild_all_threads_is_noop_for_an_empty_store() { + let root = temp_dir("rebuild-empty"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let rebuilt = rebuild_all_threads(&db_path).expect("rebuild empty store"); + assert_eq!(rebuilt, 0); + assert!( + load_thread_rows_by_mailbox(&db_path, "inbox", 10) + .expect("load empty thread rows") + .is_empty() + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rebuild_all_threads_reports_corrupted_mail_ref_and_thread_node_tables() { + let root = temp_dir("rebuild-corruption"); + + let missing_ref_db = root.join("missing-ref.db"); + db::initialize(&missing_ref_db).expect("initialize missing-ref db"); + apply_sync_batch( + &missing_ref_db, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(1), + mails: vec![incoming( + "inbox", + 1, + "Message-ID: \nSubject: root\nFrom: root@example.com\n\nbody\n", + )], + }, + ) + .expect("seed missing-ref db"); + let connection = Connection::open(&missing_ref_db).expect("open missing-ref db"); + connection + .execute("DROP TABLE mail_ref", []) + .expect("drop mail_ref"); + drop(connection); + + let ref_error = rebuild_all_threads(&missing_ref_db) + .expect_err("missing mail_ref table should fail rebuild"); + assert!( + ref_error + .to_string() + .contains("failed to prepare mail_ref graph query") + ); + + let missing_thread_node_db = root.join("missing-thread-node.db"); + db::initialize(&missing_thread_node_db).expect("initialize missing-thread-node db"); + apply_sync_batch( + &missing_thread_node_db, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(1), + mails: vec![incoming( + "inbox", + 1, + "Message-ID: \nSubject: root2\nFrom: root2@example.com\n\nbody\n", + )], + }, + ) + .expect("seed missing-thread-node db"); + let connection = + Connection::open(&missing_thread_node_db).expect("open missing-thread-node db"); + connection + .execute("DROP TABLE thread_node", []) + .expect("drop thread_node"); + drop(connection); + + let thread_node_error = rebuild_all_threads(&missing_thread_node_db) + .expect_err("missing thread_node table should fail rebuild"); + assert!( + thread_node_error + .to_string() + .contains("failed to clear thread_node") + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn late_arriving_parent_rethreads_existing_descendants() { + let root = temp_dir("late-parent"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let orphan_batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 2, + highest_modseq: Some(2), + mails: vec![ + incoming( + "inbox", + 1, + "Message-ID: \nSubject: Re: root\nFrom: child@example.com\nIn-Reply-To: \nReferences: \n\nbody\n", + ), + incoming( + "inbox", + 2, + "Message-ID: \nSubject: Re: root\nFrom: grand@example.com\nIn-Reply-To: \nReferences: \n\nbody\n", + ), + ], + }; + apply_sync_batch(&db_path, orphan_batch).expect("sync orphaned descendants"); + + let orphan_rows = + load_thread_rows_by_mailbox(&db_path, "inbox", 20).expect("load orphaned rows"); + assert_eq!( + orphan_rows + .iter() + .map(|row| (row.message_id.as_str(), row.depth)) + .collect::>(), + vec![("child@example.com", 0), ("grand@example.com", 1)] + ); + + let parent_batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 3, + highest_modseq: Some(3), + mails: vec![incoming( + "inbox", + 3, + "Message-ID: \nSubject: root\nFrom: root@example.com\n\nbody\n", + )], + }; + let result = apply_sync_batch(&db_path, parent_batch).expect("sync late parent"); + assert_eq!(result.inserted, 1); + assert_eq!(result.updated, 0); + assert_eq!(result.rebuilt_roots, 1); + assert!(!result.mailbox_rebuilt); + + let rethreaded_rows = + load_thread_rows_by_mailbox(&db_path, "inbox", 20).expect("load rethreaded rows"); + assert_eq!( + rethreaded_rows + .iter() + .map(|row| (row.message_id.as_str(), row.depth)) + .collect::>(), + vec![ + ("root@example.com", 0), + ("child@example.com", 1), + ("grand@example.com", 2), + ] + ); + assert!( + rethreaded_rows + .windows(2) + .all(|rows| rows[0].thread_id == rows[1].thread_id) + ); + + let _ = fs::remove_dir_all(root); + } } diff --git a/src/infra/sendmail.rs b/src/infra/sendmail.rs index 42e1716..0fa83ef 100644 --- a/src/infra/sendmail.rs +++ b/src/infra/sendmail.rs @@ -15,6 +15,8 @@ use chrono::Utc; use crate::infra::config::RuntimeConfig; const DEFAULT_SEND_TIMEOUT: Duration = Duration::from_secs(60); +const EXECUTABLE_BUSY_RETRY_ATTEMPTS: u8 = 5; +const EXECUTABLE_BUSY_RETRY_DELAY: Duration = Duration::from_millis(10); const OUTBOX_DIR_NAME: &str = "reply-outbox"; const GIT_SENDEMAIL_FROM_ARGS: &[&str] = &["config", "sendemail.from"]; const GIT_USER_NAME_LOOKUP_ARGS: &[&str] = &["config", "user.name"]; @@ -180,6 +182,15 @@ fn send_with_command_path( runtime: &RuntimeConfig, request: &SendRequest, command_path: Option<&Path>, +) -> SendOutcome { + send_with_options(runtime, request, command_path, DEFAULT_SEND_TIMEOUT) +} + +fn send_with_options( + runtime: &RuntimeConfig, + request: &SendRequest, + command_path: Option<&Path>, + timeout: Duration, ) -> SendOutcome { let started_at = now_timestamp(); let message_id = generate_message_id(&request.from); @@ -234,6 +245,7 @@ fn send_with_command_path( ); } + let draft_path = stabilize_child_path(&draft_path); let command_line = render_command_line( &resolved.display_name, &build_send_email_args(request, &draft_path), @@ -250,7 +262,7 @@ fn send_with_command_path( // surface as structured outcomes the UI can record and display. .env("GIT_TERMINAL_PROMPT", "0"); - let mut child = match command.spawn() { + let mut child = match spawn_command_with_retry(&mut command) { Ok(child) => child, Err(error) => { return failed_outcome( @@ -269,7 +281,7 @@ fn send_with_command_path( match child.try_wait() { Ok(Some(_)) => break, Ok(None) => { - if start.elapsed() >= DEFAULT_SEND_TIMEOUT { + if start.elapsed() >= timeout { timed_out = true; let _ = child.kill(); break; @@ -317,7 +329,7 @@ fn send_with_command_path( stderr, error_summary: Some(format!( "git send-email timed out after {}s", - DEFAULT_SEND_TIMEOUT.as_secs() + timeout.as_secs() )), started_at, finished_at, @@ -361,6 +373,21 @@ fn send_with_command_path( } } +fn spawn_command_with_retry(command: &mut Command) -> std::io::Result { + let mut attempts_remaining = EXECUTABLE_BUSY_RETRY_ATTEMPTS; + + loop { + match command.spawn() { + Ok(child) => return Ok(child), + Err(error) if is_retryable_executable_busy(&error) && attempts_remaining > 0 => { + attempts_remaining -= 1; + thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY); + } + Err(error) => return Err(error), + } + } +} + #[derive(Debug, Clone)] struct ParsedIdentity { display: String, @@ -395,8 +422,9 @@ fn git_candidates(command_path: Option<&Path>) -> Vec { let mut candidates = Vec::new(); if let Some(path) = command_path { candidates.push(GitCandidate::Path(path.to_path_buf())); + } else { + candidates.push(GitCandidate::Program("git")); } - candidates.push(GitCandidate::Program("git")); candidates } @@ -437,7 +465,7 @@ fn run_probe(command: T, args: &[&str], display_path: &Path, command_text: St where T: AsRef, { - match Command::new(command).args(args).output() { + match output_with_retry(Command::new(command).args(args)) { Ok(output) if output.status.success() => Probe::Available { path: display_path.to_path_buf(), version: normalize_output(&output.stdout) @@ -463,7 +491,7 @@ fn run_send_email_probe(command: T, display_path: &Path, command_text: String where T: AsRef + Copy, { - match Command::new(command).args(["send-email", "-h"]).output() { + match output_with_retry(Command::new(command).args(["send-email", "-h"])) { Ok(output) => { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -508,7 +536,7 @@ fn probe_git_version(command: T) -> Option where T: AsRef, { - let output = Command::new(command).arg("--version").output().ok()?; + let output = output_with_retry(Command::new(command).arg("--version")).ok()?; if !output.status.success() { return None; } @@ -576,9 +604,7 @@ fn resolve_git_binary( } fn git_config_value(command: &str, args: &[&str]) -> std::result::Result, String> { - let output = Command::new(command) - .args(args) - .output() + let output = output_with_retry(Command::new(command).args(args)) .map_err(|error| format!("failed to run git {}: {error}", args.join(" ")))?; if !output.status.success() { @@ -597,6 +623,25 @@ fn git_config_value(command: &str, args: &[&str]) -> std::result::Result std::io::Result { + let mut attempts_remaining = EXECUTABLE_BUSY_RETRY_ATTEMPTS; + + loop { + match command.output() { + Ok(output) => return Ok(output), + Err(error) if is_retryable_executable_busy(&error) && attempts_remaining > 0 => { + attempts_remaining -= 1; + thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY); + } + Err(error) => return Err(error), + } + } +} + +fn is_retryable_executable_busy(error: &std::io::Error) -> bool { + error.kind() == std::io::ErrorKind::ExecutableFileBusy +} + fn parse_identity(value: &str) -> Option { let display = normalize_header_value(value); let email = extract_email_address(&display)?; @@ -631,10 +676,7 @@ fn extract_email_address(value: &str) -> Option { fn normalize_message_id(value: &str) -> String { value .trim() - .trim_matches('<') - .trim_matches('>') - .trim_matches('"') - .trim_matches(',') + .trim_matches(|character| matches!(character, '<' | '>' | '"' | ',')) .trim() .to_string() } @@ -699,6 +741,16 @@ fn build_send_email_args(request: &SendRequest, draft_path: &Path) -> Vec PathBuf { + if path.is_absolute() { + return path.to_path_buf(); + } + + std::env::current_dir() + .map(|current_dir| current_dir.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) +} + fn generate_message_id(from: &str) -> String { // Reuse the sender domain when possible so generated ids look like normal // outbound mail and are easier to correlate in archives and mail clients. @@ -792,6 +844,7 @@ fn now_timestamp() -> String { #[cfg(test)] mod tests { use std::fs; + use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -799,8 +852,11 @@ mod tests { use crate::infra::config::RuntimeConfig; use super::{ - GitSendEmailStatus, SendRequest, SendStatus, check_with_command_path, - resolve_reply_identity_with_command_path, send_with_command_path, + GitSendEmailStatus, ReplyIdentitySource, SendRequest, SendStatus, check_with_command_path, + extract_email_address, generate_message_id, normalize_message_id, normalize_output, + render_command_line, render_message_file, resolve_reply_identity_with_command_path, + resolve_working_dir, send_with_command_path, send_with_options, stabilize_child_path, + summarize_failure, }; fn temp_dir(label: &str) -> PathBuf { @@ -808,7 +864,10 @@ mod tests { .duration_since(UNIX_EPOCH) .expect("system time") .as_nanos(); - let path = std::env::temp_dir().join(format!("criew-sendmail-{label}-{nonce}")); + let path = std::env::temp_dir().join(format!( + "criew-sendmail-{label}-{}-{nonce}", + std::process::id() + )); fs::create_dir_all(&path).expect("create temp dir"); path } @@ -827,6 +886,9 @@ mod tests { imap: crate::infra::config::ImapConfig::default(), lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -835,10 +897,19 @@ mod tests { fn write_fake_git(root: &Path, body: &str) -> PathBuf { let path = root.join("fake-git.sh"); - fs::write(&path, body).expect("write fake git"); - let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + let staging_path = root.join(".fake-git.sh.tmp"); + let mut staging_file = fs::File::create(&staging_path).expect("create staging fake git"); + staging_file + .write_all(body.as_bytes()) + .expect("write staging fake git"); + staging_file.sync_all().expect("sync staging fake git"); + drop(staging_file); + let mut permissions = fs::metadata(&staging_path) + .expect("staging metadata") + .permissions(); permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).expect("chmod"); + fs::set_permissions(&staging_path, permissions).expect("chmod"); + fs::rename(&staging_path, &path).expect("install fake git"); path } @@ -911,6 +982,63 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn check_reports_broken_and_missing_send_email() { + let root = temp_dir("check-broken"); + let fake_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'fatal: send-email support missing' >&2\n exit 1\nfi\nexit 1\n", + ); + + match check_with_command_path(Some(&fake_git)).status { + GitSendEmailStatus::Broken { reason, .. } => { + assert_eq!(reason, "fatal: send-email support missing"); + } + other => panic!("unexpected status: {other:?}"), + } + + match check_with_command_path(Some(&root.join("missing-git"))).status { + GitSendEmailStatus::Missing => {} + other => panic!("unexpected status: {other:?}"), + } + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_identity_falls_back_to_user_name_email_and_reports_errors() { + let root = temp_dir("identity-fallback"); + let fallback_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"sendemail.from\" ]; then\n exit 1\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.email\" ]; then\n echo 'fallback@example.com'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.name\" ]; then\n echo ' '\n exit 0\nfi\nexit 1\n", + ); + + let identity = resolve_reply_identity_with_command_path(Some(&fallback_git)) + .expect("resolve fallback identity"); + assert_eq!(identity.display, "fallback@example.com"); + assert_eq!(identity.email, "fallback@example.com"); + assert_eq!(identity.source, ReplyIdentitySource::UserNameEmail); + assert_eq!(identity.source.as_str(), "git config user.name/user.email"); + + let invalid_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"sendemail.from\" ]; then\n echo 'No Email Here'\n exit 0\nfi\nexit 1\n", + ); + let invalid = resolve_reply_identity_with_command_path(Some(&invalid_git)) + .expect_err("invalid sendemail.from should fail"); + assert!(invalid.contains("does not contain a valid email address")); + + let missing_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"sendemail.from\" ]; then\n exit 1\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.email\" ]; then\n exit 1\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.name\" ]; then\n exit 1\nfi\nexit 1\n", + ); + let missing = resolve_reply_identity_with_command_path(Some(&missing_git)) + .expect_err("missing user email should fail"); + assert!(missing.contains("git email identity missing")); + + let _ = fs::remove_dir_all(root); + } + #[test] fn send_success_removes_draft_and_keeps_generated_message_id() { let root = temp_dir("send-success"); @@ -919,7 +1047,7 @@ mod tests { let fake_git = write_fake_git( &root, &format!( - "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n printf '%s\n' \"$@\" > '{}'\n last=''\n for arg in \"$@\"; do\n last=\"$arg\"\n done\n cp \"$last\" '{}'\n echo 'sent'\n exit 0\nfi\nexit 1\n", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n printf '%s\\n' \"$@\" > '{}'\n last=''\n for arg in \"$@\"; do\n last=\"$arg\"\n done\n cp \"$last\" '{}'\n echo 'sent'\n exit 0\nfi\nexit 1\n", capture_args.display(), capture.display() ), @@ -975,6 +1103,118 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn send_reports_unavailable_transport_without_creating_draft() { + let root = temp_dir("send-unavailable"); + let fake_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'fatal: send-email support missing' >&2\n exit 1\nfi\nexit 1\n", + ); + let runtime = test_runtime_in(&root); + + let outcome = send_with_command_path(&runtime, &sample_request(), Some(&fake_git)); + + assert_eq!(outcome.status, SendStatus::Failed); + assert!(outcome.command_line.is_none()); + assert!(outcome.draft_path.is_none()); + assert!( + outcome + .error_summary + .as_deref() + .is_some_and(|summary| summary.contains("git send-email unavailable")) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn send_failure_uses_stdout_or_exit_code_when_stderr_is_empty() { + let root = temp_dir("send-summary-fallbacks"); + let stdout_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n echo 'smtp failed on stdout'\n exit 2\nfi\nexit 1\n", + ); + let runtime = test_runtime_in(&root); + let stdout_outcome = send_with_command_path(&runtime, &sample_request(), Some(&stdout_git)); + assert_eq!(stdout_outcome.status, SendStatus::Failed); + assert_eq!( + stdout_outcome.error_summary.as_deref(), + Some("smtp failed on stdout") + ); + + let exit_code_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n exit 7\nfi\nexit 1\n", + ); + let exit_code_outcome = + send_with_command_path(&runtime, &sample_request(), Some(&exit_code_git)); + assert_eq!(exit_code_outcome.status, SendStatus::Failed); + assert_eq!( + exit_code_outcome.error_summary.as_deref(), + Some("git send-email exited with 7") + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn send_times_out_and_keeps_draft_for_retry() { + let root = temp_dir("send-timeout"); + let fake_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n sleep 1\n exit 0\nfi\nexit 1\n", + ); + let runtime = test_runtime_in(&root); + + let outcome = send_with_options( + &runtime, + &sample_request(), + Some(&fake_git), + std::time::Duration::from_millis(50), + ); + + assert_eq!(outcome.status, SendStatus::TimedOut); + assert!(outcome.timed_out); + assert!( + outcome + .draft_path + .as_ref() + .is_some_and(|path| path.exists()) + ); + assert_eq!( + outcome.error_summary.as_deref(), + Some("git send-email timed out after 0s") + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn send_reports_outbox_creation_failure() { + let root = temp_dir("send-outbox-fail"); + let data_file = root.join("blocked-data"); + fs::write(&data_file, "not a directory").expect("write blocking data file"); + let fake_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nexit 1\n", + ); + let mut runtime = test_runtime_in(&root); + runtime.data_dir = data_file; + + let outcome = send_with_command_path(&runtime, &sample_request(), Some(&fake_git)); + + assert_eq!(outcome.status, SendStatus::Failed); + assert!(outcome.draft_path.is_none()); + assert!( + outcome + .error_summary + .as_deref() + .is_some_and(|summary| summary.contains("failed to create reply outbox")) + ); + + let _ = fs::remove_dir_all(root); + } + #[test] fn send_prefers_configured_kernel_tree_as_working_dir() { let root = temp_dir("send-working-dir"); @@ -1001,4 +1241,107 @@ mod tests { let _ = fs::remove_dir_all(root); } + + #[test] + fn send_passes_absolute_draft_path_when_runtime_data_dir_is_relative() { + let root = temp_dir("send-relative-data-dir"); + let kernel_tree = root.join("linux"); + fs::create_dir_all(&kernel_tree).expect("create kernel tree"); + let capture_arg = root.join("captured-draft-arg.txt"); + let fake_git = write_fake_git( + &root, + &format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n last=''\n for arg in \"$@\"; do\n last=\"$arg\"\n done\n printf '%s' \"$last\" > '{}'\n if [ -f \"$last\" ]; then\n echo 'sent'\n exit 0\n fi\n echo \"missing draft: $last\" >&2\n exit 1\nfi\nexit 1\n", + capture_arg.display() + ), + ); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let relative_data_dir = PathBuf::from(format!( + "target/test-relative-reply-outbox-{}-{nonce}", + std::process::id() + )); + let relative_data_dir_absolute = std::env::current_dir() + .expect("current dir") + .join(&relative_data_dir); + let mut runtime = test_runtime_in(&root); + runtime.data_dir = relative_data_dir; + runtime.kernel_trees = vec![kernel_tree]; + + let outcome = send_with_command_path(&runtime, &sample_request(), Some(&fake_git)); + + assert_eq!(outcome.status, SendStatus::Sent); + let captured_arg = fs::read_to_string(&capture_arg).expect("read captured draft arg"); + assert!(Path::new(&captured_arg).is_absolute()); + assert!(captured_arg.starts_with(relative_data_dir_absolute.to_string_lossy().as_ref())); + + let _ = fs::remove_dir_all(relative_data_dir_absolute); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn message_rendering_and_helper_outputs_follow_reply_contract() { + let mut request = sample_request(); + request.cc.clear(); + request.references = vec![ + ",".to_string(), + "patch@example.com".to_string(), + ]; + + let rendered = render_message_file(&request, "generated@example.com"); + assert!(rendered.contains("From: CRIEW Test ")); + assert!(rendered.contains("To: maintainer@example.com")); + assert!(!rendered.contains("\nCc: ")); + assert!(rendered.contains("Message-ID: ")); + assert!(rendered.contains("In-Reply-To: ")); + assert!(rendered.contains("References: ")); + assert!(rendered.ends_with("reply body\n")); + + assert_eq!( + render_command_line( + "git send-email", + &[ + "--from".to_string(), + "CRIEW Test ".to_string(), + String::new(), + ] + ), + "'git send-email' --from 'CRIEW Test ' ''" + ); + assert_eq!( + normalize_output(b"\n first line \nsecond\n"), + Some("first line".to_string()) + ); + assert_eq!( + summarize_failure(Some(9), "", ""), + Some("git send-email exited with 9".to_string()) + ); + assert_eq!( + normalize_message_id(" , "), + "older@example.com" + ); + assert_eq!( + extract_email_address("CRIEW Test "), + Some("criew@example.com".to_string()) + ); + assert_eq!(extract_email_address("invalid identity"), None); + assert!(generate_message_id("No Email").ends_with("@localhost")); + assert!(stabilize_child_path(Path::new("relative/file.eml")).is_absolute()); + } + + #[test] + fn resolve_working_dir_falls_back_when_kernel_tree_is_missing() { + let root = temp_dir("send-working-dir-fallback"); + let runtime = test_runtime_in(&root); + let current_dir = std::env::current_dir().expect("current dir"); + assert_eq!(resolve_working_dir(&runtime), current_dir); + + let mut with_invalid_tree = test_runtime_in(&root); + with_invalid_tree.kernel_trees = vec![root.join("missing-tree")]; + assert_eq!(resolve_working_dir(&with_invalid_tree), current_dir); + + let _ = fs::remove_dir_all(root); + } } diff --git a/src/infra/ui_state.rs b/src/infra/ui_state.rs index 7ba6353..72ef9e4 100644 --- a/src/infra/ui_state.rs +++ b/src/infra/ui_state.rs @@ -12,6 +12,9 @@ use serde::{Deserialize, Serialize}; use crate::infra::error::{CriewError, ErrorCode, Result}; +pub const DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH: u16 = 23; +pub const DEFAULT_MAIL_PREVIEW_WIDTH: u16 = 90; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UiState { #[serde(default)] @@ -32,12 +35,24 @@ pub struct UiState { pub imap_defaults_initialized: bool, #[serde(default)] pub active_mailbox: Option, + #[serde(default = "default_mail_subscriptions_width")] + pub mail_subscriptions_width: u16, + #[serde(default = "default_mail_preview_width")] + pub mail_preview_width: u16, } fn default_true() -> bool { true } +fn default_mail_subscriptions_width() -> u16 { + DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH +} + +fn default_mail_preview_width() -> u16 { + DEFAULT_MAIL_PREVIEW_WIDTH +} + impl Default for UiState { fn default() -> Self { Self { @@ -50,6 +65,8 @@ impl Default for UiState { disabled_qemu_subsystem_expanded: true, imap_defaults_initialized: false, active_mailbox: None, + mail_subscriptions_width: DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH, + mail_preview_width: DEFAULT_MAIL_PREVIEW_WIDTH, } } } @@ -138,7 +155,10 @@ mod tests { use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; - use super::{UiState, load, path_for_data_dir, save}; + use super::{ + DEFAULT_MAIL_PREVIEW_WIDTH, DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH, UiState, load, + path_for_data_dir, save, + }; fn temp_dir(label: &str) -> PathBuf { let nonce = SystemTime::now() @@ -164,6 +184,8 @@ mod tests { disabled_qemu_subsystem_expanded: false, imap_defaults_initialized: true, active_mailbox: Some("bpf".to_string()), + mail_subscriptions_width: 28, + mail_preview_width: 84, }; save(&path, &state).expect("save state"); @@ -181,6 +203,8 @@ mod tests { assert!(!loaded.disabled_qemu_subsystem_expanded); assert!(loaded.imap_defaults_initialized); assert_eq!(loaded.active_mailbox.as_deref(), Some("bpf")); + assert_eq!(loaded.mail_subscriptions_width, 28); + assert_eq!(loaded.mail_preview_width, 84); let _ = fs::remove_dir_all(root); } @@ -203,6 +227,11 @@ mod tests { assert!(loaded.enabled_qemu_subsystem_expanded); assert!(loaded.disabled_linux_subsystem_expanded); assert!(loaded.disabled_qemu_subsystem_expanded); + assert_eq!( + loaded.mail_subscriptions_width, + DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH + ); + assert_eq!(loaded.mail_preview_width, DEFAULT_MAIL_PREVIEW_WIDTH); let _ = fs::remove_dir_all(root); } diff --git a/src/ui/tui.rs b/src/ui/tui.rs index fb4fc92..9da1cad 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -4,6 +4,7 @@ //! machine stays here so key handling, background work, and side effects remain //! readable in one place. +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::env; use std::fs; @@ -40,6 +41,7 @@ use ratatui::widgets::{Block, Borders}; mod config; mod input; +mod keymap; mod palette; mod preview; mod render; @@ -48,6 +50,11 @@ mod reply; mod tests; use input::{LoopAction, handle_key_event}; +use keymap::{ + KeymapEditorState, PendingMainPageSequenceState, ResolvedMainPageKeymap, draw_keymap_editor, + handle_keymap_editor_key_event, handle_main_page_key_event, main_page_focus_shortcuts, + main_page_move_shortcuts, main_page_navigation_shortcuts, resolve_active_main_page_keymap, +}; use palette::short_commit_id; #[cfg(test)] use palette::{is_palette_open_shortcut, is_palette_toggle, resolve_palette_local_workdir}; @@ -62,8 +69,8 @@ use preview::{MailPreview, load_mail_preview}; #[cfg(test)] use preview::{extract_mail_body_preview, extract_mail_preview}; use reply::{ - PreparedReplyMessage, ReplyIdentity, ReplyPreview, ReplyPreviewRequest, ReplySeed, - build_reply_seed, prepare_reply_message, render_reply_preview, + PreparedReplyMessage, ReplyIdentity, ReplyPreview, ReplyPreviewLine, ReplyPreviewRequest, + ReplySeed, build_reply_seed, prepare_reply_message, render_reply_preview, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -99,6 +106,49 @@ impl Pane { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct MailPaneLayout { + subscriptions_width: u16, + preview_width: u16, +} + +impl Default for MailPaneLayout { + fn default() -> Self { + Self { + subscriptions_width: ui_state::DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH, + preview_width: ui_state::DEFAULT_MAIL_PREVIEW_WIDTH, + } + } +} + +impl MailPaneLayout { + fn from_persisted(state: Option<&UiState>) -> Self { + let defaults = Self::default(); + Self { + subscriptions_width: state + .map(|persisted| persisted.mail_subscriptions_width) + .unwrap_or(defaults.subscriptions_width) + .max(MIN_MAIL_SUBSCRIPTIONS_WIDTH), + preview_width: state + .map(|persisted| persisted.mail_preview_width) + .unwrap_or(defaults.preview_width) + .max(MIN_MAIL_PREVIEW_WIDTH), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HorizontalResizeDirection { + Left, + Right, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MailPaneResizeMode { + Expand, + Shrink, +} + #[derive(Debug, Clone, Copy)] struct PaletteCommand { name: &'static str, @@ -151,6 +201,10 @@ const PALETTE_COMMANDS: &[PaletteCommand] = &[ name: "config", description: "Open visual config editor or update runtime config", }, + PaletteCommand { + name: "keymap", + description: "Open visual keymap editor for main-page navigation", + }, PaletteCommand { name: "vim", description: "Open selected source file in external vim", @@ -158,9 +212,11 @@ const PALETTE_COMMANDS: &[PaletteCommand] = &[ ]; const PALETTE_SYNC_RECONNECT_ATTEMPTS: u8 = 3; +const MAIL_PANE_RESIZE_STEP: u16 = 4; +const MIN_MAIL_PREVIEW_WIDTH: u16 = 40; +const MIN_MAIL_SUBSCRIPTIONS_WIDTH: u16 = 12; const PREVIEW_TAB_SPACES: &str = " "; const PREVIEW_RECIPIENT_PREVIEW_LIMIT: usize = 2; -const PREVIEW_PANE_FIXED_WIDTH: u16 = 90; const THREAD_LINE_MAX_CHARS: usize = 120; const KERNEL_TREE_MAX_ROWS: usize = 2048; const CODE_PREVIEW_MAX_BYTES: usize = 256 * 1024; @@ -186,6 +242,8 @@ const CONFIG_GET_KEYS: &[&str] = &[ "imap.proxy", "source.lore_base_url", "ui.startup_sync", + "ui.keymap", + "ui.keymap_base", "ui.inbox_auto_sync_interval_secs", "kernel.tree", "kernel.trees", @@ -209,6 +267,8 @@ const CONFIG_SET_KEYS: &[&str] = &[ "imap.proxy", "source.lore_base_url", "ui.startup_sync", + "ui.keymap", + "ui.keymap_base", "ui.inbox_auto_sync_interval_secs", "kernel.tree", "kernel.trees", @@ -226,6 +286,10 @@ const CONFIG_EDITOR_FIELDS: &[ConfigEditorField] = &[ key: "ui.startup_sync", description: "Whether enabled subscriptions start syncing automatically after TUI launch.", }, + ConfigEditorField { + key: "ui.keymap", + description: "Main-page navigation scheme. default=j/l+i/k+count, vim=h/l+j/k+count+gg/G+qq, custom=ui.keymap_base plus ui.custom_keymap overrides.", + }, ConfigEditorField { key: "ui.inbox_auto_sync_interval_secs", description: "Seconds between My Inbox background auto-sync runs while TUI stays open.", @@ -298,6 +362,17 @@ const CONFIG_EDITOR_FIELDS: &[ConfigEditorField] = &[ const CODE_EDIT_ENTRY_HINT: &str = "select a source file in Source pane, then press e"; const EXTERNAL_EDITOR_ENTRY_HINT: &str = "select a source file in Source pane, then press E"; +fn shrink_mail_pane_width(width: &mut u16, minimum_width: u16) -> bool { + if *width <= minimum_width { + return false; + } + + *width = width + .saturating_sub(MAIL_PANE_RESIZE_STEP) + .max(minimum_width); + true +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ExternalEditorProcessResult { success: bool, @@ -307,11 +382,17 @@ struct ExternalEditorProcessResult { type ExternalEditorRunner = fn(&str, &Path) -> std::result::Result; type ReplyIdentityResolver = fn() -> std::result::Result; -type SyncRequestExecutor = - fn(&RuntimeConfig, sync_worker::SyncRequest) -> Result; type ReplySendExecutor = fn(&RuntimeConfig, &SendRequest) -> SendOutcome; type MailboxSyncSpawner = fn(RuntimeConfig, Vec) -> Receiver; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PendingMainPageCountState { + count: u16, + ui_page: UiPage, + focus: Pane, + code_focus: CodePaneFocus, +} + #[derive(Debug, Clone)] enum StartupSyncEvent { MailboxStarted { @@ -451,6 +532,103 @@ impl InboxAutoSyncState { } } +#[derive(Debug, Clone, Copy)] +enum ManualSyncOrigin { + PaletteCommand, + SubscriptionOpen, +} + +impl ManualSyncOrigin { + fn log_label(self) -> &'static str { + match self { + Self::PaletteCommand => "palette", + Self::SubscriptionOpen => "subscription_open", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManualSyncRequestOutcome { + Started, + AlreadySyncing, + Busy, +} + +#[derive(Debug)] +struct ManualSyncState { + receiver: Receiver, + mailbox_order: Vec, + mailboxes: HashMap, + total: usize, + completed: usize, + succeeded: usize, + failed: usize, + total_fetched: usize, + total_inserted: usize, + total_updated: usize, + first_error: Option, +} + +impl ManualSyncState { + fn pending_count(&self) -> usize { + self.mailbox_order + .iter() + .filter(|mailbox| { + matches!( + self.mailboxes.get(mailbox.as_str()), + Some(StartupSyncMailboxStatus::Pending) + ) + }) + .count() + } + + fn inflight_mailboxes_display(&self) -> String { + let running: Vec<&str> = self + .mailbox_order + .iter() + .filter_map(|mailbox| { + matches!( + self.mailboxes.get(mailbox.as_str()), + Some(StartupSyncMailboxStatus::InFlight) + ) + .then_some(mailbox.as_str()) + }) + .collect(); + if running.is_empty() { + "-".to_string() + } else { + running.join(",") + } + } + + fn progress_summary(&self) -> String { + format!( + "{}/{} ok={} fail={} queued={} running={}", + self.completed, + self.total, + self.succeeded, + self.failed, + self.pending_count(), + self.inflight_mailboxes_display() + ) + } + + fn mailbox_states_display(&self) -> String { + self.mailbox_order + .iter() + .map(|mailbox| { + let status = self + .mailboxes + .get(mailbox.as_str()) + .copied() + .unwrap_or(StartupSyncMailboxStatus::Pending); + format!("{mailbox}:{}", status.log_label()) + }) + .collect::>() + .join(" ") + } +} + #[derive(Debug)] struct SubscriptionAutoSyncState { receiver: Option>, @@ -723,7 +901,9 @@ struct ReplyPanelState { preview_open: bool, preview_scroll: u16, preview_rendered: String, + preview_lines: Vec, preview_errors: Vec, + preview_warnings: Vec, preview_confirmed: bool, preview_confirmed_at: Option, reply_notice: Option, @@ -747,7 +927,7 @@ impl ReplyPanelState { }, self_addresses, mode: ReplyEditMode::Normal, - section: ReplySection::Body, + section: ReplySection::From, body_row: 0, cursor_col: 0, dirty: false, @@ -756,7 +936,9 @@ impl ReplyPanelState { preview_open: false, preview_scroll: 0, preview_rendered: String::new(), + preview_lines: Vec::new(), preview_errors: Vec::new(), + preview_warnings: Vec::new(), preview_confirmed: false, preview_confirmed_at: None, reply_notice: None, @@ -1064,6 +1246,7 @@ struct AppState { imap_defaults_initialized: bool, ui_page: UiPage, focus: Pane, + mail_pane_layout: MailPaneLayout, code_focus: CodePaneFocus, subscriptions: Vec, enabled_group_expanded: bool, @@ -1081,6 +1264,7 @@ struct AppState { kernel_tree_expanded_paths: HashSet, kernel_tree_row_index: usize, code_preview_scroll: u16, + code_preview_scroll_limit: Cell, code_edit_mode: CodeEditMode, code_edit_target: Option, code_edit_buffer: Vec, @@ -1091,6 +1275,7 @@ struct AppState { reply_panel: Option, thread_index: usize, preview_scroll: u16, + preview_scroll_limit: Cell, selected_mail_preview: Option, started_at: Instant, status: String, @@ -1098,15 +1283,20 @@ struct AppState { palette: CommandPaletteState, search: SearchState, config_editor: ConfigEditorState, + keymap_editor: KeymapEditorState, external_editor_runner: ExternalEditorRunner, reply_identity_resolver: ReplyIdentityResolver, - sync_request_executor: SyncRequestExecutor, reply_send_executor: ReplySendExecutor, mailbox_sync_spawner: MailboxSyncSpawner, + manual_sync_spawner: MailboxSyncSpawner, needs_terminal_refresh: bool, startup_sync: Option, inbox_auto_sync: Option, + manual_sync: Option, subscription_auto_sync: Option, + main_page_keymap: ResolvedMainPageKeymap, + pending_main_page_sequence: Option, + pending_main_page_count: Option, } impl AppState { @@ -1120,6 +1310,7 @@ impl AppState { persisted: Option, ) -> Self { let ui_state_path = ui_state::path_for_data_dir(&runtime.data_dir); + let mail_pane_layout = MailPaneLayout::from_persisted(persisted.as_ref()); let persisted_imap_defaults_initialized = persisted .as_ref() .map(|state| state.imap_defaults_initialized) @@ -1151,6 +1342,7 @@ impl AppState { let kernel_tree_expanded_paths = default_kernel_tree_expanded_paths(&runtime.kernel_trees); let kernel_tree_rows = build_kernel_tree_rows(&runtime.kernel_trees, &kernel_tree_expanded_paths); + let main_page_keymap = resolve_active_main_page_keymap(&runtime); let mut state = Self { active_thread_mailbox, runtime, @@ -1158,6 +1350,7 @@ impl AppState { imap_defaults_initialized: persisted_imap_defaults_initialized, ui_page: UiPage::Mail, focus: Pane::Subscriptions, + mail_pane_layout, code_focus: CodePaneFocus::Tree, subscriptions, enabled_group_expanded: persisted @@ -1193,6 +1386,7 @@ impl AppState { kernel_tree_expanded_paths, kernel_tree_row_index: 0, code_preview_scroll: 0, + code_preview_scroll_limit: Cell::new(u16::MAX), code_edit_mode: CodeEditMode::Browse, code_edit_target: None, code_edit_buffer: Vec::new(), @@ -1203,6 +1397,7 @@ impl AppState { reply_panel: None, thread_index: 0, preview_scroll: 0, + preview_scroll_limit: Cell::new(u16::MAX), selected_mail_preview: None, started_at: Instant::now(), status: String::new(), @@ -1210,15 +1405,20 @@ impl AppState { palette: CommandPaletteState::default(), search: SearchState::default(), config_editor: ConfigEditorState::default(), + keymap_editor: KeymapEditorState::default(), external_editor_runner: run_external_editor_session, reply_identity_resolver: resolve_git_reply_identity, - sync_request_executor: run_sync_request_guarded, reply_send_executor: send_reply_message, mailbox_sync_spawner: spawn_startup_sync_worker, + manual_sync_spawner: spawn_startup_sync_worker, needs_terminal_refresh: false, startup_sync: None, inbox_auto_sync: None, + manual_sync: None, subscription_auto_sync: None, + main_page_keymap, + pending_main_page_sequence: None, + pending_main_page_count: None, }; if state.runtime.imap.is_complete() { state.imap_defaults_initialized = true; @@ -1401,6 +1601,16 @@ impl AppState { }) } + fn manual_sync_mailbox_status(&self, mailbox: &str) -> Option { + self.manual_sync.as_ref().and_then(|state| { + state + .mailboxes + .iter() + .find(|(name, _)| same_mailbox_name(name, mailbox)) + .map(|(_, status)| *status) + }) + } + fn inbox_auto_sync_mailbox_status(&self, mailbox: &str) -> Option { mailbox .eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) @@ -1425,6 +1635,7 @@ impl AppState { fn mailbox_sync_status(&self, mailbox: &str) -> Option { self.startup_sync_mailbox_status(mailbox) + .or_else(|| self.manual_sync_mailbox_status(mailbox)) .or_else(|| self.inbox_auto_sync_mailbox_status(mailbox)) .or_else(|| self.subscription_auto_sync_mailbox_status(mailbox)) } @@ -1436,8 +1647,16 @@ impl AppState { ) } + fn manual_sync_mailbox_pending(&self, mailbox: &str) -> bool { + matches!( + self.manual_sync_mailbox_status(mailbox), + Some(StartupSyncMailboxStatus::Pending | StartupSyncMailboxStatus::InFlight) + ) + } + fn mailbox_sync_pending(&self, mailbox: &str) -> bool { self.startup_sync_mailbox_pending(mailbox) + || self.manual_sync_mailbox_pending(mailbox) || matches!( self.inbox_auto_sync_mailbox_status(mailbox), Some(StartupSyncMailboxStatus::InFlight) @@ -1448,31 +1667,95 @@ impl AppState { ) } - fn startup_sync_progress_text(&self) -> Option { - self.startup_sync + fn background_sync_progress_text(&self) -> Option { + self.manual_sync .as_ref() .map(|state| { - let running = state.inflight_mailboxes_display(); - if running == "-" { - format!("sync {}/{}", state.completed, state.total) - } else { - format!("sync {} {}/{}", running, state.completed, state.total) - } + format!( + "sync {} {}/{} {}", + self.render_progress_bar(state.completed, state.total), + state.completed, + state.total, + state.inflight_mailboxes_display() + ) + }) + .or_else(|| { + self.startup_sync.as_ref().map(|state| { + format!( + "sync {} {}/{} {}", + self.render_progress_bar(state.completed, state.total), + state.completed, + state.total, + state.inflight_mailboxes_display() + ) + }) }) .or_else(|| { self.inbox_auto_sync .as_ref() .filter(|state| state.in_flight()) - .map(|_| "sync INBOX".to_string()) + .map(|_| { + format!( + "sync {} auto {}", + self.render_indeterminate_progress_bar(), + IMAP_INBOX_MAILBOX + ) + }) }) .or_else(|| { self.subscription_auto_sync .as_ref() .filter(|state| state.in_flight()) - .map(|state| format!("sync {}", state.inflight_mailboxes_display())) + .map(|state| { + format!( + "sync {} auto {}", + self.render_indeterminate_progress_bar(), + state.inflight_mailboxes_display() + ) + }) }) } + fn render_progress_bar(&self, completed: usize, total: usize) -> String { + const PROGRESS_BAR_WIDTH: usize = 12; + + let mut cells = vec!['.'; PROGRESS_BAR_WIDTH]; + if total == 0 { + return format!("[{}]", cells.into_iter().collect::()); + } + + let filled = completed.saturating_mul(PROGRESS_BAR_WIDTH) / total; + for cell in cells.iter_mut().take(filled.min(PROGRESS_BAR_WIDTH)) { + *cell = '='; + } + if completed < total { + let pulse_width = PROGRESS_BAR_WIDTH.saturating_sub(filled).max(1); + let pulse_offset = self.sync_animation_tick() % pulse_width; + let pulse_index = (filled + pulse_offset).min(PROGRESS_BAR_WIDTH - 1); + cells[pulse_index] = '>'; + } + + format!("[{}]", cells.into_iter().collect::()) + } + + fn render_indeterminate_progress_bar(&self) -> String { + const PROGRESS_BAR_WIDTH: usize = 12; + const RUNNER_WIDTH: usize = 3; + + let mut cells = vec!['.'; PROGRESS_BAR_WIDTH]; + let start = self.sync_animation_tick() % PROGRESS_BAR_WIDTH; + for step in 0..RUNNER_WIDTH { + let index = (start + step) % PROGRESS_BAR_WIDTH; + cells[index] = '>'; + } + + format!("[{}]", cells.into_iter().collect::()) + } + + fn sync_animation_tick(&self) -> usize { + (self.started_at.elapsed().as_millis() / 200) as usize + } + fn refresh_series_summaries(&mut self) { self.series_summaries = patch_worker::build_series_index(&self.active_thread_mailbox, &self.threads); @@ -1553,39 +1836,160 @@ impl AppState { } } - fn defer_inbox_auto_sync_for_mailbox(&mut self, mailbox: &str) { - if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) { - self.defer_inbox_auto_sync(); - } - } - fn defer_subscription_auto_sync(&mut self) { if let Some(state) = self.subscription_auto_sync.as_mut() { state.next_due_at = Instant::now() + self.runtime.inbox_auto_sync_interval(); } } - fn defer_subscription_auto_sync_for_mailbox(&mut self, mailbox: &str) { - if !mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) { + fn start_manual_sync( + &mut self, + requested_mailboxes: Vec, + origin: ManualSyncOrigin, + ) -> ManualSyncRequestOutcome { + let requested_mailboxes = dedup_mailboxes(requested_mailboxes); + if requested_mailboxes.is_empty() { + self.status = "sync skipped: no mailbox selected".to_string(); + tracing::info!( + op = "manual_sync", + status = "skipped", + reason = "no_mailboxes", + origin = origin.log_label() + ); + return ManualSyncRequestOutcome::Busy; + } + + if let Some(sync_state) = self.manual_sync.as_ref() { + let all_tracked = requested_mailboxes.iter().all(|mailbox| { + sync_state + .mailboxes + .keys() + .any(|tracked| same_mailbox_name(tracked, mailbox)) + }); + self.status = if all_tracked { + format!( + "sync already running in background: {}", + requested_mailboxes.join(", ") + ) + } else { + format!("background sync busy: {}", sync_state.progress_summary()) + }; + tracing::info!( + op = "manual_sync", + status = "skipped", + reason = if all_tracked { + "mailboxes_already_syncing" + } else { + "manual_sync_busy" + }, + origin = origin.log_label(), + requested_mailboxes = %requested_mailboxes.join(",") + ); + return if all_tracked { + ManualSyncRequestOutcome::AlreadySyncing + } else { + ManualSyncRequestOutcome::Busy + }; + } + + let mut skipped_mailboxes = Vec::new(); + let mut queued_mailboxes = Vec::new(); + for mailbox in requested_mailboxes { + if self.mailbox_sync_pending(&mailbox) { + skipped_mailboxes.push(mailbox); + } else { + queued_mailboxes.push(mailbox); + } + } + + if queued_mailboxes.is_empty() { + self.status = format!( + "sync already running in background: {}", + skipped_mailboxes.join(", ") + ); + tracing::info!( + op = "manual_sync", + status = "skipped", + reason = "mailboxes_already_syncing", + origin = origin.log_label(), + requested_mailboxes = %skipped_mailboxes.join(",") + ); + return ManualSyncRequestOutcome::AlreadySyncing; + } + + if queued_mailboxes + .iter() + .any(|mailbox| mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX)) + { + self.defer_inbox_auto_sync(); + } + if queued_mailboxes + .iter() + .any(|mailbox| !mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX)) + { self.defer_subscription_auto_sync(); } + + let receiver = (self.manual_sync_spawner)(self.runtime.clone(), queued_mailboxes.clone()); + self.manual_sync = Some(ManualSyncState { + receiver, + mailbox_order: queued_mailboxes.clone(), + mailboxes: queued_mailboxes + .iter() + .cloned() + .map(|mailbox| (mailbox, StartupSyncMailboxStatus::Pending)) + .collect(), + total: queued_mailboxes.len(), + completed: 0, + succeeded: 0, + failed: 0, + total_fetched: 0, + total_inserted: 0, + total_updated: 0, + first_error: None, + }); + + self.status = if skipped_mailboxes.is_empty() { + format!("sync queued in background: {}", queued_mailboxes.join(", ")) + } else { + format!( + "sync queued in background: {}; skipped already-running: {}", + queued_mailboxes.join(", "), + skipped_mailboxes.join(", ") + ) + }; + if let Some(sync_state) = self.manual_sync.as_ref() { + tracing::info!( + op = "manual_sync", + status = "started", + origin = origin.log_label(), + total = sync_state.total, + completed = sync_state.completed, + succeeded = sync_state.succeeded, + failed = sync_state.failed, + queued = sync_state.pending_count(), + running = %sync_state.inflight_mailboxes_display(), + mailbox_states = %sync_state.mailbox_states_display(), + requested_mailboxes = %sync_state.mailbox_order.join(",") + ); + } + + ManualSyncRequestOutcome::Started } - fn run_sync_request( - &self, - request: sync_worker::SyncRequest, - ) -> Result { - (self.sync_request_executor)(&self.runtime, request) + fn queue_palette_sync(&mut self, requested_mailboxes: Vec) { + let _ = self.start_manual_sync(requested_mailboxes, ManualSyncOrigin::PaletteCommand); } fn maybe_start_inbox_auto_sync(&mut self) { self.reconcile_inbox_auto_sync(); - let startup_pending = self.startup_sync_mailbox_pending(IMAP_INBOX_MAILBOX); + let inbox_sync_pending = self.startup_sync_mailbox_pending(IMAP_INBOX_MAILBOX) + || self.manual_sync_mailbox_pending(IMAP_INBOX_MAILBOX); let now = Instant::now(); let Some(state) = self.inbox_auto_sync.as_mut() else { return; }; - if state.in_flight() || startup_pending || now < state.next_due_at { + if state.in_flight() || inbox_sync_pending || now < state.next_due_at { return; } @@ -1603,14 +2007,18 @@ impl AppState { fn maybe_start_subscription_auto_sync(&mut self) { self.reconcile_subscription_auto_sync(); let mailboxes = self.enabled_background_sync_mailboxes(); - let startup_pending = mailboxes - .iter() - .any(|mailbox| self.startup_sync_mailbox_pending(mailbox)); + let background_pending = mailboxes.iter().any(|mailbox| { + self.startup_sync_mailbox_pending(mailbox) || self.manual_sync_mailbox_pending(mailbox) + }); let now = Instant::now(); let Some(state) = self.subscription_auto_sync.as_mut() else { return; }; - if mailboxes.is_empty() || state.in_flight() || startup_pending || now < state.next_due_at { + if mailboxes.is_empty() + || state.in_flight() + || background_pending + || now < state.next_due_at + { return; } @@ -1660,6 +2068,51 @@ impl AppState { } } + fn pump_manual_sync_events(&mut self) { + let mut events = Vec::new(); + let mut disconnected = false; + { + let Some(sync_state) = self.manual_sync.as_ref() else { + return; + }; + loop { + match sync_state.receiver.try_recv() { + Ok(event) => events.push(event), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + + for event in events { + self.apply_manual_sync_event(event); + } + + if disconnected && self.manual_sync.is_some() { + let (completed, total, succeeded, failed) = self + .manual_sync + .as_ref() + .map(|state| (state.completed, state.total, state.succeeded, state.failed)) + .unwrap_or((0, 0, 0, 0)); + self.manual_sync = None; + self.status = format!( + "background sync worker disconnected (completed={completed}/{total} ok={succeeded} failed={failed})" + ); + tracing::warn!( + op = "manual_sync", + status = "failed", + completed, + total, + succeeded, + failed, + "manual sync worker disconnected unexpectedly" + ); + } + } + fn pump_subscription_auto_sync_events(&mut self) { let mut events = Vec::new(); let mut disconnected = false; @@ -1680,23 +2133,138 @@ impl AppState { } } } - } - - let mut worker_completed = false; - for event in events { - if matches!(event, StartupSyncEvent::WorkerCompleted) { - worker_completed = true; + } + + let mut worker_completed = false; + for event in events { + if matches!(event, StartupSyncEvent::WorkerCompleted) { + worker_completed = true; + } + self.apply_subscription_auto_sync_event(event); + } + + if (disconnected || worker_completed) + && let Some(state) = self.subscription_auto_sync.as_mut() + { + state.receiver = None; + state.in_flight_mailboxes.clear(); + state.next_due_at = Instant::now() + self.runtime.inbox_auto_sync_interval(); + } + } + + fn apply_manual_sync_event(&mut self, event: StartupSyncEvent) { + match event { + StartupSyncEvent::MailboxStarted { + mailbox, + index, + total, + } => { + if let Some(sync_state) = self.manual_sync.as_mut() { + sync_state + .mailboxes + .insert(mailbox.clone(), StartupSyncMailboxStatus::InFlight); + } + self.status = format!("sync [{index}/{total}] syncing {mailbox} in background..."); + if let Some(sync_state) = self.manual_sync.as_ref() { + tracing::info!( + op = "manual_sync", + status = "progress", + phase = "started", + mailbox = %mailbox, + index, + total, + completed = sync_state.completed, + succeeded = sync_state.succeeded, + failed = sync_state.failed, + queued = sync_state.pending_count(), + running = %sync_state.inflight_mailboxes_display(), + mailbox_states = %sync_state.mailbox_states_display() + ); + } + } + StartupSyncEvent::MailboxFinished { + mailbox, + fetched, + inserted, + updated, + } => { + if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) { + self.defer_inbox_auto_sync(); + } else { + self.defer_subscription_auto_sync(); + } + if let Some(sync_state) = self.manual_sync.as_mut() { + sync_state + .mailboxes + .insert(mailbox.clone(), StartupSyncMailboxStatus::Finished); + sync_state.completed += 1; + sync_state.succeeded += 1; + sync_state.total_fetched += fetched; + sync_state.total_inserted += inserted; + sync_state.total_updated += updated; + } + + if let Some(sync_state) = self.manual_sync.as_ref() { + self.status = format!( + "sync [{}/{}] finished {}", + sync_state.completed, sync_state.total, mailbox + ); + tracing::info!( + op = "manual_sync", + status = "succeeded", + phase = "finished", + mailbox = %mailbox, + fetched, + inserted, + updated, + completed = sync_state.completed, + total = sync_state.total, + succeeded = sync_state.succeeded, + failed = sync_state.failed, + queued = sync_state.pending_count(), + running = %sync_state.inflight_mailboxes_display(), + mailbox_states = %sync_state.mailbox_states_display() + ); + } + } + StartupSyncEvent::MailboxFailed { mailbox, error } => { + if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) { + self.defer_inbox_auto_sync(); + } else { + self.defer_subscription_auto_sync(); + } + if let Some(sync_state) = self.manual_sync.as_mut() { + sync_state + .mailboxes + .insert(mailbox.clone(), StartupSyncMailboxStatus::Failed); + sync_state.completed += 1; + sync_state.failed += 1; + if sync_state.first_error.is_none() { + sync_state.first_error = Some(format!("{mailbox}: {error}")); + } + } + self.status = format!("sync failed for {mailbox}: {error}"); + if let Some(sync_state) = self.manual_sync.as_ref() { + tracing::error!( + op = "manual_sync", + status = "failed", + phase = "finished", + mailbox = %mailbox, + error = %error, + completed = sync_state.completed, + total = sync_state.total, + succeeded = sync_state.succeeded, + failed = sync_state.failed, + queued = sync_state.pending_count(), + running = %sync_state.inflight_mailboxes_display(), + mailbox_states = %sync_state.mailbox_states_display() + ); + } } - self.apply_subscription_auto_sync_event(event); + StartupSyncEvent::WorkerCompleted => {} } - if (disconnected || worker_completed) - && let Some(state) = self.subscription_auto_sync.as_mut() - { - state.receiver = None; - state.in_flight_mailboxes.clear(); - state.next_due_at = Instant::now() + self.runtime.inbox_auto_sync_interval(); - } + self.maybe_finish_manual_sync(); } fn apply_inbox_auto_sync_event(&mut self, event: StartupSyncEvent) { @@ -1719,7 +2287,17 @@ impl AppState { state.next_due_at = Instant::now() + self.runtime.inbox_auto_sync_interval(); } if same_mailbox_name(&mailbox, &self.active_thread_mailbox) { - self.reload_mailbox_threads_preserving_selection(&mailbox); + if let Err(error) = self.reload_mailbox_threads_preserving_selection(&mailbox) { + tracing::error!( + op = "inbox_auto_sync", + status = "failed", + mailbox = %mailbox, + error = %error + ); + self.status = format!( + "background sync ok but failed to reload threads for {mailbox}: {error}" + ); + } } if inserted > 0 || updated > 0 { self.status = format!( @@ -1777,7 +2355,17 @@ impl AppState { .retain(|in_flight| !same_mailbox_name(in_flight, &mailbox)); } if same_mailbox_name(&mailbox, &self.active_thread_mailbox) { - self.reload_mailbox_threads_preserving_selection(&mailbox); + if let Err(error) = self.reload_mailbox_threads_preserving_selection(&mailbox) { + tracing::error!( + op = "subscription_auto_sync", + status = "failed", + mailbox = %mailbox, + error = %error + ); + self.status = format!( + "background sync ok but failed to reload threads for {mailbox}: {error}" + ); + } } if inserted > 0 || updated > 0 { self.status = format!( @@ -2062,13 +2650,82 @@ impl AppState { ); } + fn maybe_finish_manual_sync(&mut self) { + let Some(sync_state) = self.manual_sync.as_ref() else { + return; + }; + if sync_state.completed < sync_state.total { + return; + } + + let succeeded = sync_state.succeeded; + let failed = sync_state.failed; + let total = sync_state.total; + let total_fetched = sync_state.total_fetched; + let total_inserted = sync_state.total_inserted; + let total_updated = sync_state.total_updated; + let first_error = sync_state.first_error.clone(); + let first_error_text = first_error + .clone() + .unwrap_or_else(|| "worker reported no success".to_string()); + let mailbox_states = sync_state.mailbox_states_display(); + let active_mailbox = self.active_thread_mailbox.clone(); + let should_reload_active_mailbox = sync_state.mailboxes.iter().any(|(mailbox, status)| { + same_mailbox_name(mailbox, &active_mailbox) + && *status == StartupSyncMailboxStatus::Finished + }); + + self.manual_sync = None; + + if should_reload_active_mailbox + && let Err(error) = self.reload_mailbox_threads_preserving_selection(&active_mailbox) + { + tracing::error!( + op = "manual_sync", + status = "failed", + mailbox = %active_mailbox, + error = %error + ); + self.status = + format!("sync ok but failed to reload threads for {active_mailbox}: {error}"); + return; + } + + self.status = if failed == 0 { + format!( + "sync finished: ok={succeeded} total={total} fetched={total_fetched} inserted={total_inserted} updated={total_updated}" + ) + } else if succeeded == 0 { + format!("sync failed: {first_error_text}") + } else { + format!( + "sync finished with failures: ok={succeeded} failed={failed} fetched={total_fetched} inserted={total_inserted} updated={total_updated}" + ) + }; + tracing::info!( + op = "manual_sync", + status = if failed == 0 { + "succeeded" + } else if succeeded == 0 { + "failed" + } else { + "partial" + }, + succeeded, + failed, + total, + total_fetched, + total_inserted, + total_updated, + first_error = %first_error.as_deref().unwrap_or("-"), + mailbox_states = %mailbox_states + ); + } + fn reload_active_mailbox_threads_after_sync(&mut self) { - match mail_store::load_thread_rows_by_mailbox( - &self.runtime.database_path, - &self.active_thread_mailbox, - 500, - ) { - Ok(rows) => self.replace_threads(rows), + let mailbox = self.active_thread_mailbox.clone(); + match self.reload_mailbox_threads_preserving_selection(&mailbox) { + Ok(()) => {} Err(error) => { tracing::error!( op = "startup_sync", @@ -2084,26 +2741,14 @@ impl AppState { } } - fn reload_mailbox_threads_preserving_selection(&mut self, mailbox: &str) { - match mail_store::load_thread_rows_by_mailbox(&self.runtime.database_path, mailbox, 500) { - Ok(rows) => { - if same_mailbox_name(mailbox, &self.active_thread_mailbox) { - self.replace_threads_preserving_selection(rows); - } - } - Err(error) => { - tracing::error!( - op = "background_auto_sync", - status = "failed", - mailbox = %mailbox, - error = %error - ); - self.status = format!( - "background sync ok but failed to reload threads for {}: {}", - mailbox, error - ); - } + fn reload_mailbox_threads_preserving_selection(&mut self, mailbox: &str) -> Result<()> { + let rows = + mail_store::load_thread_rows_by_mailbox(&self.runtime.database_path, mailbox, 500)?; + if same_mailbox_name(mailbox, &self.active_thread_mailbox) { + self.replace_threads_preserving_selection(rows); } + + Ok(()) } fn to_ui_state(&self) -> UiState { @@ -2117,6 +2762,8 @@ impl AppState { disabled_qemu_subsystem_expanded: self.disabled_qemu_subsystem_expanded, imap_defaults_initialized: self.imap_defaults_initialized, active_mailbox: Some(self.active_thread_mailbox.clone()), + mail_subscriptions_width: self.mail_pane_layout.subscriptions_width, + mail_preview_width: self.mail_pane_layout.preview_width, } } @@ -2438,6 +3085,7 @@ impl AppState { format!("showing threads for {}", mailbox), true, ); + self.focus = Pane::Threads; } Ok(_) => { if self.mailbox_sync_pending(&mailbox) { @@ -2447,73 +3095,23 @@ impl AppState { format!("{mailbox} is syncing in background; page stays responsive"), true, ); + self.focus = Pane::Threads; return; } - tracing::info!( - op = "subscription_sync", - status = "started", - mailbox = %mailbox - ); - let request = sync_worker::SyncRequest { - mailbox: mailbox.clone(), - fixture_dir: None, - uidvalidity: None, - reconnect_attempts: PALETTE_SYNC_RECONNECT_ATTEMPTS, - }; - - let sync_result = self.run_sync_request(request); - self.defer_inbox_auto_sync_for_mailbox(&mailbox); - self.defer_subscription_auto_sync_for_mailbox(&mailbox); - - match sync_result { - Ok(summary) => match mail_store::load_thread_rows_by_mailbox( - &self.runtime.database_path, - &mailbox, - 500, - ) { - Ok(fresh_rows) => { - tracing::info!( - op = "subscription_sync", - status = "succeeded", - mailbox = %mailbox, - fetched = summary.fetched, - inserted = summary.inserted, - updated = summary.updated - ); - self.show_mailbox_threads( - &mailbox, - fresh_rows, - format!( - "synced {}: fetched={} inserted={} updated={}", - mailbox, summary.fetched, summary.inserted, summary.updated - ), - true, - ); - } - Err(error) => { - tracing::error!( - mailbox = %mailbox, - error = %error, - "sync succeeded but reload thread rows failed" - ); - self.status = format!( - "sync ok but failed to reload threads for {}: {error}", - mailbox - ); - } - }, - Err(error) => { - tracing::error!( - op = "subscription_sync", - status = "failed", - mailbox = %mailbox, - error = %error, - "subscription sync failed" - ); - self.status = format!("failed to sync {}: {error}", mailbox); + let outcome = self + .start_manual_sync(vec![mailbox.clone()], ManualSyncOrigin::SubscriptionOpen); + let background_status = match outcome { + ManualSyncRequestOutcome::Started + | ManualSyncRequestOutcome::AlreadySyncing => { + format!("{mailbox} is syncing in background; page stays responsive") } - } + ManualSyncRequestOutcome::Busy => { + "another background sync is running; page stays responsive".to_string() + } + }; + self.show_mailbox_threads(&mailbox, Vec::new(), background_status, true); + self.focus = Pane::Threads; } Err(error) => { tracing::error!( @@ -2593,7 +3191,10 @@ impl AppState { thread.mail_id, thread.thread_id, )); - self.status = format!("reply panel opened for <{}>", thread.message_id); + self.status = format!( + "reply panel opened for <{}>; edit From/To/Cc/Subject before Send Preview", + thread.message_id + ); } fn close_reply_panel(&mut self, status: impl Into) { @@ -2636,7 +3237,12 @@ impl AppState { }; panel.reply_notice = None; - let ReplyPreview { content, errors } = render_reply_preview(ReplyPreviewRequest { + let ReplyPreview { + content, + lines, + errors, + warnings, + } = render_reply_preview(ReplyPreviewRequest { from: &panel.from, to: &panel.to, cc: &panel.cc, @@ -2647,14 +3253,21 @@ impl AppState { self_addresses: &panel.self_addresses, }); panel.preview_rendered = content; + panel.preview_lines = lines; panel.preview_errors = errors; + panel.preview_warnings = warnings; panel.preview_open = true; panel.preview_scroll = 0; - if panel.preview_errors.is_empty() { - self.status = "send preview ready; press Enter/c to confirm".to_string(); - } else { + if !panel.preview_errors.is_empty() { self.status = format!("send preview blocked: {}", panel.preview_errors.join("; ")); + } else if !panel.preview_warnings.is_empty() { + self.status = format!( + "send preview warning: {}; press Enter/c to confirm anyway", + panel.preview_warnings.join("; ") + ); + } else { + self.status = "send preview ready; press Enter/c to confirm".to_string(); } } @@ -3103,6 +3716,108 @@ impl AppState { } } + fn resize_mail_panes( + &mut self, + direction: HorizontalResizeDirection, + resize_mode: MailPaneResizeMode, + ) { + let did_resize = match (self.focus, direction, resize_mode) { + (Pane::Subscriptions, HorizontalResizeDirection::Left, _) => false, + (Pane::Subscriptions, HorizontalResizeDirection::Right, MailPaneResizeMode::Expand) => { + self.grow_mail_subscriptions_pane() + } + (Pane::Subscriptions, HorizontalResizeDirection::Right, MailPaneResizeMode::Shrink) => { + self.shrink_mail_subscriptions_pane() + } + (Pane::Threads, HorizontalResizeDirection::Left, MailPaneResizeMode::Expand) => { + self.shrink_mail_subscriptions_pane() + } + (Pane::Threads, HorizontalResizeDirection::Left, MailPaneResizeMode::Shrink) => { + self.grow_mail_subscriptions_pane() + } + (Pane::Threads, HorizontalResizeDirection::Right, MailPaneResizeMode::Expand) => { + self.shrink_mail_preview_pane() + } + (Pane::Threads, HorizontalResizeDirection::Right, MailPaneResizeMode::Shrink) => { + self.grow_mail_preview_pane() + } + (Pane::Preview, HorizontalResizeDirection::Left, MailPaneResizeMode::Expand) => { + self.grow_mail_preview_pane() + } + (Pane::Preview, HorizontalResizeDirection::Left, MailPaneResizeMode::Shrink) => { + self.shrink_mail_preview_pane() + } + (Pane::Preview, HorizontalResizeDirection::Right, _) => false, + }; + + if did_resize { + self.persist_ui_state(); + self.status = format!( + "mail panes saved: subscriptions {} cols, preview {} cols", + self.mail_pane_layout.subscriptions_width, self.mail_pane_layout.preview_width + ); + } else { + self.status = match resize_mode { + MailPaneResizeMode::Expand => "mail pane cannot expand in that direction", + MailPaneResizeMode::Shrink => "mail pane cannot shrink in that direction", + } + .to_string(); + } + } + + fn grow_mail_subscriptions_pane(&mut self) -> bool { + self.mail_pane_layout.subscriptions_width = self + .mail_pane_layout + .subscriptions_width + .saturating_add(MAIL_PANE_RESIZE_STEP); + true + } + + fn shrink_mail_subscriptions_pane(&mut self) -> bool { + shrink_mail_pane_width( + &mut self.mail_pane_layout.subscriptions_width, + MIN_MAIL_SUBSCRIPTIONS_WIDTH, + ) + } + + fn grow_mail_preview_pane(&mut self) -> bool { + self.mail_pane_layout.preview_width = self + .mail_pane_layout + .preview_width + .saturating_add(MAIL_PANE_RESIZE_STEP); + true + } + + fn shrink_mail_preview_pane(&mut self) -> bool { + shrink_mail_pane_width( + &mut self.mail_pane_layout.preview_width, + MIN_MAIL_PREVIEW_WIDTH, + ) + } + + fn select_filtered_thread_at(&mut self, filtered_index: usize) { + debug_assert!(filtered_index < self.filtered_thread_indices.len()); + self.thread_index = filtered_index; + self.preview_scroll = 0; + self.refresh_selected_mail_preview(); + } + + fn select_previous_thread(&mut self) { + if self.thread_index == 0 { + return; + } + + self.select_filtered_thread_at(self.thread_index - 1); + } + + fn select_next_thread(&mut self) { + if self.thread_index + 1 >= self.filtered_thread_indices.len() { + return; + } + + self.select_filtered_thread_at(self.thread_index + 1); + } + fn move_up(&mut self) { match self.ui_page { UiPage::Mail => match self.focus { @@ -3110,20 +3825,22 @@ impl AppState { self.move_subscription_up(); } Pane::Threads => { - if self.thread_index > 0 { - self.thread_index -= 1; - self.preview_scroll = 0; - self.refresh_selected_mail_preview(); - } + self.select_previous_thread(); } Pane::Preview => { - self.preview_scroll = self.preview_scroll.saturating_sub(1); + self.preview_scroll = self + .preview_scroll + .min(self.preview_scroll_limit.get()) + .saturating_sub(1); } }, UiPage::CodeBrowser => match self.code_focus { CodePaneFocus::Tree => self.move_kernel_tree_up(), CodePaneFocus::Source => { - self.code_preview_scroll = self.code_preview_scroll.saturating_sub(1); + self.code_preview_scroll = self + .code_preview_scroll + .min(self.code_preview_scroll_limit.get()) + .saturating_sub(1); } }, } @@ -3136,20 +3853,26 @@ impl AppState { self.move_subscription_down(); } Pane::Threads => { - if self.thread_index + 1 < self.filtered_thread_indices.len() { - self.thread_index += 1; - self.preview_scroll = 0; - self.refresh_selected_mail_preview(); - } + self.select_next_thread(); } Pane::Preview => { - self.preview_scroll = self.preview_scroll.saturating_add(1); + let preview_scroll_limit = self.preview_scroll_limit.get(); + self.preview_scroll = self + .preview_scroll + .min(preview_scroll_limit) + .saturating_add(1) + .min(preview_scroll_limit); } }, UiPage::CodeBrowser => match self.code_focus { CodePaneFocus::Tree => self.move_kernel_tree_down(), CodePaneFocus::Source => { - self.code_preview_scroll = self.code_preview_scroll.saturating_add(1); + let code_preview_scroll_limit = self.code_preview_scroll_limit.get(); + self.code_preview_scroll = self + .code_preview_scroll + .min(code_preview_scroll_limit) + .saturating_add(1) + .min(code_preview_scroll_limit); } }, } @@ -3161,6 +3884,129 @@ impl AppState { self.status = "search mode".to_string(); } + fn jump_current_pane_to_start(&mut self) { + match self.ui_page { + UiPage::Mail => match self.focus { + Pane::Subscriptions => { + self.subscription_row_index = 0; + self.clamp_subscription_row_selection(); + } + Pane::Threads => { + if !self.filtered_thread_indices.is_empty() { + self.select_filtered_thread_at(0); + } + } + Pane::Preview => { + self.preview_scroll = 0; + } + }, + UiPage::CodeBrowser => match self.code_focus { + CodePaneFocus::Tree => { + let previous_file = + self.selected_kernel_tree_file_path().map(Path::to_path_buf); + self.kernel_tree_row_index = 0; + let next_file = self.selected_kernel_tree_file_path().map(Path::to_path_buf); + if previous_file != next_file { + self.code_preview_scroll = 0; + } + } + CodePaneFocus::Source => { + self.code_preview_scroll = 0; + } + }, + } + } + + fn jump_current_pane_to_end(&mut self) { + match self.ui_page { + UiPage::Mail => match self.focus { + Pane::Subscriptions => { + let rows = self.subscription_rows(); + if rows.is_empty() { + return; + } + self.subscription_row_index = rows.len().saturating_sub(1); + self.clamp_subscription_row_selection(); + } + Pane::Threads => { + if !self.filtered_thread_indices.is_empty() { + self.select_filtered_thread_at( + self.filtered_thread_indices.len().saturating_sub(1), + ); + } + } + Pane::Preview => { + self.preview_scroll = u16::MAX; + } + }, + UiPage::CodeBrowser => match self.code_focus { + CodePaneFocus::Tree => { + if self.kernel_tree_rows.is_empty() { + self.kernel_tree_row_index = 0; + return; + } + let previous_file = + self.selected_kernel_tree_file_path().map(Path::to_path_buf); + self.kernel_tree_row_index = self.kernel_tree_rows.len().saturating_sub(1); + let next_file = self.selected_kernel_tree_file_path().map(Path::to_path_buf); + if previous_file != next_file { + self.code_preview_scroll = 0; + } + } + CodePaneFocus::Source => { + self.code_preview_scroll = self.code_preview_scroll_limit.get(); + } + }, + } + } + + fn pending_main_page_count_state(&self, count: u16) -> PendingMainPageCountState { + PendingMainPageCountState { + count, + ui_page: self.ui_page, + focus: self.focus, + code_focus: self.code_focus, + } + } + + fn clear_pending_main_page_inputs(&mut self) { + self.pending_main_page_sequence = None; + self.pending_main_page_count = None; + } + + fn clear_pending_main_page_count(&mut self) { + self.pending_main_page_count = None; + } + + fn has_pending_main_page_count(&self) -> bool { + self.pending_main_page_count.is_some_and(|state| { + state.ui_page == self.ui_page + && state.focus == self.focus + && state.code_focus == self.code_focus + }) + } + + fn push_pending_main_page_count_digit(&mut self, digit: u16) { + let next_count = self + .pending_main_page_count + .filter(|state| { + state.ui_page == self.ui_page + && state.focus == self.focus + && state.code_focus == self.code_focus + }) + .map(|state| state.count.saturating_mul(10).saturating_add(digit)) + .unwrap_or(digit); + self.pending_main_page_count = Some(self.pending_main_page_count_state(next_count)); + } + + fn take_pending_main_page_count(&mut self) -> Option { + let pending_state = self.pending_main_page_count.take()?; + let same_scope = pending_state.ui_page == self.ui_page + && pending_state.focus == self.focus + && pending_state.code_focus == self.code_focus; + same_scope.then_some(pending_state.count) + } + fn close_search(&mut self) { self.search.active = false; self.search.input.clear(); @@ -3196,6 +4042,13 @@ impl AppState { self.status = "command palette closed".to_string(); } + fn dismiss_palette(&mut self) { + self.palette.open = false; + self.palette.input.clear(); + self.palette.clear_completion(); + self.palette.clear_local_result(); + } + fn is_code_edit_active(&self) -> bool { self.code_edit_mode.is_active() } @@ -3736,6 +4589,21 @@ fn spawn_startup_sync_worker( receiver } +fn dedup_mailboxes(mailboxes: Vec) -> Vec { + let mut deduped: Vec = Vec::new(); + for mailbox in mailboxes { + if deduped + .iter() + .any(|existing| same_mailbox_name(existing, &mailbox)) + { + continue; + } + deduped.push(mailbox); + } + + deduped +} + fn run_sync_request_guarded( runtime: &RuntimeConfig, request: sync_worker::SyncRequest, @@ -3982,8 +4850,18 @@ fn reply_command_line_logical_row(panel: &ReplyPanelState) -> usize { reply_body_line_logical_row(panel.body.len()) + 1 } -fn reply_field_prefix_width(label: &str) -> usize { - 1 + 1 + label.chars().count() + 2 +fn reply_editable_field_prefix(section: ReplySection) -> &'static str { + match section { + ReplySection::From => "[edit] From: ", + ReplySection::To => "[edit] To: ", + ReplySection::Cc => "[edit] Cc: ", + ReplySection::Subject => "[edit] Subject: ", + ReplySection::Body => "", + } +} + +fn reply_field_prefix_width(section: ReplySection) -> usize { + 1 + 1 + reply_editable_field_prefix(section).chars().count() } fn reply_body_prefix_width(body_row: usize) -> usize { @@ -4087,6 +4965,7 @@ fn tui_loop( // Pump worker events before drawing so each frame reflects the newest // background sync state and can request a full refresh when needed. state.pump_startup_sync_events(); + state.pump_manual_sync_events(); state.pump_inbox_auto_sync_events(); state.pump_subscription_auto_sync_events(); state.maybe_start_inbox_auto_sync(); diff --git a/src/ui/tui/config.rs b/src/ui/tui/config.rs index 012a704..b28c619 100644 --- a/src/ui/tui/config.rs +++ b/src/ui/tui/config.rs @@ -15,9 +15,9 @@ use super::palette::PaletteCompletionContext; use super::render::{centered_rect, truncate_with_ellipsis}; use super::*; -struct ConfigFileUpdate { - rendered_value: String, - runtime: RuntimeConfig, +pub(super) struct ConfigFileUpdate { + pub(super) rendered_value: String, + pub(super) runtime: RuntimeConfig, } impl AppState { @@ -330,7 +330,7 @@ fn read_config_key_from_file( Ok(lookup_config_key(&table, key).cloned()) } -fn update_config_key_in_file( +pub(super) fn update_config_key_in_file( config_path: &Path, key: &str, value_literal: &str, @@ -354,7 +354,7 @@ fn update_config_key_in_file( }) } -fn remove_config_key_from_file( +pub(super) fn remove_config_key_from_file( config_path: &Path, key: &str, ) -> std::result::Result, String> { @@ -532,7 +532,7 @@ fn config_editor_field_index(key: &str) -> Option { .position(|field| field.key.eq_ignore_ascii_case(key)) } -fn apply_runtime_update(state: &mut AppState, runtime: RuntimeConfig) { +pub(super) fn apply_runtime_update(state: &mut AppState, runtime: RuntimeConfig) { let old_inbox_auto_sync_interval_secs = state.runtime.inbox_auto_sync_interval_secs; let selected_path_hint = state.selected_kernel_tree_path(); let enabled_mailboxes: HashSet = state.enabled_mailboxes().into_iter().collect(); @@ -540,6 +540,7 @@ fn apply_runtime_update(state: &mut AppState, runtime: RuntimeConfig) { // Preserve current UI intent across config reloads so editing one setting // does not unexpectedly wipe mailbox enablement or the active tree focus. state.runtime = runtime; + state.rebuild_main_page_keymap(); state.ui_state_path = ui_state::path_for_data_dir(&state.runtime.data_dir); state.subscriptions = default_subscriptions( &state.runtime, @@ -610,6 +611,8 @@ fn effective_config_value(state: &AppState, key: &str) -> Option { "imap.proxy" => state.runtime.imap.proxy.clone(), "source.lore_base_url" => Some(state.runtime.lore_base_url.clone()), "ui.startup_sync" => Some(state.runtime.startup_sync.to_string()), + "ui.keymap" => Some(state.runtime.ui_keymap.as_str().to_string()), + "ui.keymap_base" => Some(state.runtime.ui_keymap_base.as_str().to_string()), "ui.inbox_auto_sync_interval_secs" => { Some(state.runtime.inbox_auto_sync_interval_secs.to_string()) } @@ -826,6 +829,27 @@ fn config_value_suggestions(state: &AppState, key: Option<&String>) -> Vec [ + ("default", "j/l focus, i/k move"), + ("vim", "h/l focus, j/k move, gg/G jump, qq quit"), + ("custom", "ui.keymap_base plus ui.custom_keymap overrides"), + ] + .iter() + .map(|(value, description)| PaletteSuggestion { + value: (*value).to_string(), + description: Some((*description).to_string()), + }) + .collect(), + "ui.keymap_base" => [ + ("default", "j/l focus, i/k move"), + ("vim", "h/l focus, j/k move, gg/G jump, qq quit"), + ] + .iter() + .map(|(value, description)| PaletteSuggestion { + value: (*value).to_string(), + description: Some((*description).to_string()), + }) + .collect(), "ui.inbox_auto_sync_interval_secs" => ["15", "30", "60", "300"] .iter() .map(|value| PaletteSuggestion { diff --git a/src/ui/tui/input.rs b/src/ui/tui/input.rs index cdd14df..50b6ab9 100644 --- a/src/ui/tui/input.rs +++ b/src/ui/tui/input.rs @@ -19,6 +19,36 @@ pub(super) enum LoopAction { Restart, } +pub(super) fn pending_main_page_move_count(state: &mut AppState) -> u16 { + state.take_pending_main_page_count().unwrap_or(1) +} + +fn handle_main_page_count_prefix(state: &mut AppState, key: KeyEvent) -> bool { + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) + { + state.clear_pending_main_page_count(); + return false; + } + + let KeyCode::Char(character) = key.code else { + return false; + }; + if !character.is_ascii_digit() { + return false; + } + if character == '0' && !state.has_pending_main_page_count() { + return false; + } + + let digit = character + .to_digit(10) + .expect("ascii digit should convert to decimal") as u16; + state.push_pending_main_page_count_digit(digit); + true +} + pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { tracing::debug!( key = ?key, @@ -27,6 +57,7 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio code_focus = ?state.code_focus, code_edit_mode = ?state.code_edit_mode, config_editor_open = state.config_editor.open, + keymap_editor_open = state.keymap_editor.open, palette_open = state.palette.open, search_active = state.search.active, "user key event" @@ -35,10 +66,17 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio // Modal UI surfaces take precedence over the base page shortcuts so keys // keep local meaning while a dialog, editor, or search interaction is open. if state.config_editor.open { + state.clear_pending_main_page_inputs(); return handle_config_editor_key_event(state, key); } + if state.keymap_editor.open { + state.clear_pending_main_page_inputs(); + return handle_keymap_editor_key_event(state, key); + } + if state.palette.open { + state.clear_pending_main_page_inputs(); if is_palette_toggle(key) { state.close_palette(); return LoopAction::Continue; @@ -47,22 +85,36 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio } if state.search.active { + state.clear_pending_main_page_inputs(); return handle_search_key_event(state, key); } if state.reply_panel.is_some() { + state.clear_pending_main_page_inputs(); return handle_reply_key_event(state, key); } if state.is_code_edit_active() { + state.clear_pending_main_page_inputs(); return handle_code_edit_key_event(state, key); } if is_palette_open_shortcut(key) { + state.clear_pending_main_page_inputs(); state.toggle_palette(); return LoopAction::Continue; } + if let Some(action) = handle_main_page_key_event(state, key) { + return action; + } + + if handle_main_page_count_prefix(state, key) { + return LoopAction::Continue; + } + + state.clear_pending_main_page_count(); + match key.code { KeyCode::Char('/') => { if matches!(state.ui_page, UiPage::Mail) { @@ -85,6 +137,18 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio { state.set_current_subscription_enabled(false); } + KeyCode::Char('-') + if matches!(state.ui_page, UiPage::Mail) && matches!(state.focus, Pane::Preview) => + { + state.select_previous_thread(); + } + KeyCode::Char(character) + if matches!(state.ui_page, UiPage::Mail) + && matches!(state.focus, Pane::Preview) + && matches!(character, '=' | '+') => + { + state.select_next_thread(); + } KeyCode::Char('e') if matches!(state.ui_page, UiPage::Mail) && matches!(state.focus, Pane::Preview) => { @@ -95,6 +159,18 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio { state.open_reply_panel(false); } + KeyCode::Char('[') if matches!(state.ui_page, UiPage::Mail) => { + state.resize_mail_panes(HorizontalResizeDirection::Left, MailPaneResizeMode::Expand); + } + KeyCode::Char(']') if matches!(state.ui_page, UiPage::Mail) => { + state.resize_mail_panes(HorizontalResizeDirection::Right, MailPaneResizeMode::Expand); + } + KeyCode::Char('{') if matches!(state.ui_page, UiPage::Mail) => { + state.resize_mail_panes(HorizontalResizeDirection::Left, MailPaneResizeMode::Shrink); + } + KeyCode::Char('}') if matches!(state.ui_page, UiPage::Mail) => { + state.resize_mail_panes(HorizontalResizeDirection::Right, MailPaneResizeMode::Shrink); + } KeyCode::Char('e') if matches!(state.ui_page, UiPage::CodeBrowser) => { state.enter_code_edit_mode(); } @@ -102,10 +178,6 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio state.open_external_editor(); } KeyCode::Tab => state.toggle_ui_page(), - KeyCode::Char('j') => state.move_focus_previous(), - KeyCode::Char('l') => state.move_focus_next(), - KeyCode::Char('i') => state.move_up(), - KeyCode::Char('k') => state.move_down(), KeyCode::Char(character) if matches!(state.ui_page, UiPage::Mail) && matches!(state.focus, Pane::Threads) @@ -133,6 +205,7 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio Pane::Threads => { if let Some(thread) = state.selected_thread() { state.status = format!("selected {}", thread.message_id); + state.focus = Pane::Preview; } } Pane::Preview => {} @@ -544,14 +617,22 @@ fn handle_palette_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { "quit" | "exit" => return LoopAction::Exit, "restart" => return LoopAction::Restart, "help" => { - state.status = "commands: quit, exit, restart, help, sync [mailbox], config ..., vim, ! | keys: j/l focus, i/k move, y/n enable, a apply, d download, u undo apply, e reply/inline edit, r reply, E external vim".to_string(); + state.status = format!( + "commands: quit, exit, restart, help, sync [mailbox], config ..., keymap, vim, ! | keys: {} focus, {} move, [ ] expand pane, {{ }} shrink pane, -/= preview switch, y/n enable, a apply, d download, u undo apply, e reply/inline edit, r reply, E external vim", + main_page_focus_shortcuts(&state.main_page_keymap), + main_page_move_shortcuts(&state.main_page_keymap) + ); } value if value.split_whitespace().next() == Some("sync") => { run_palette_sync(state, value); + state.dismiss_palette(); } value if value.split_whitespace().next() == Some("config") => { run_palette_config(state, value); } + "keymap" => { + state.open_keymap_editor(); + } "vim" => { state.open_external_editor(); } diff --git a/src/ui/tui/keymap.rs b/src/ui/tui/keymap.rs new file mode 100644 index 0000000..870d200 --- /dev/null +++ b/src/ui/tui/keymap.rs @@ -0,0 +1,1017 @@ +//! Main-page keymap resolution, matching, and editor UI. +//! +//! CRIEW keeps top-level navigation configurable while preserving the +//! hard-coded rescue keys that operators rely on to recover from a bad custom +//! layout. This module resolves the active scheme from runtime config, matches +//! multi-key chords, and exposes the `:keymap` editor modal. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; + +use crate::infra::config::{RuntimeConfig, UiCustomKeymapConfig, UiKeymap, UiKeymapBase}; + +use super::config::{apply_runtime_update, remove_config_key_from_file, update_config_key_in_file}; +use super::input::LoopAction; +use super::input::pending_main_page_move_count; +use super::render::{centered_rect, truncate_with_ellipsis}; +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum MainPageAction { + FocusPrevious, + FocusNext, + MoveUp, + MoveDown, + JumpTop, + JumpBottom, + QuickQuit, +} + +impl MainPageAction { + const ALL: [Self; 7] = [ + Self::FocusPrevious, + Self::FocusNext, + Self::MoveUp, + Self::MoveDown, + Self::JumpTop, + Self::JumpBottom, + Self::QuickQuit, + ]; + + fn key(self) -> &'static str { + match self { + Self::FocusPrevious => "focus_prev", + Self::FocusNext => "focus_next", + Self::MoveUp => "move_up", + Self::MoveDown => "move_down", + Self::JumpTop => "jump_top", + Self::JumpBottom => "jump_bottom", + Self::QuickQuit => "quick_quit", + } + } + + fn label(self) -> &'static str { + match self { + Self::FocusPrevious => "Focus Previous", + Self::FocusNext => "Focus Next", + Self::MoveUp => "Move Up", + Self::MoveDown => "Move Down", + Self::JumpTop => "Jump Top", + Self::JumpBottom => "Jump Bottom", + Self::QuickQuit => "Quick Quit", + } + } + + fn description(self) -> &'static str { + match self { + Self::FocusPrevious => "Move focus to the previous top-level pane.", + Self::FocusNext => "Move focus to the next top-level pane.", + Self::MoveUp => "Move up inside the currently focused pane.", + Self::MoveDown => "Move down inside the currently focused pane.", + Self::JumpTop => "Jump to the top of the active pane or preview.", + Self::JumpBottom => "Jump to the bottom of the active pane or preview.", + Self::QuickQuit => "Exit CRIEW without opening the command palette.", + } + } + + fn max_tokens(self) -> usize { + match self { + Self::FocusPrevious | Self::FocusNext | Self::MoveUp | Self::MoveDown => 1, + Self::JumpTop | Self::JumpBottom | Self::QuickQuit => 2, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct KeySequence { + tokens: Vec, +} + +impl KeySequence { + fn from_chars(tokens: Vec) -> Self { + Self { tokens } + } + + fn from_config_tokens(tokens: &[String]) -> Self { + Self { + tokens: tokens + .iter() + .filter_map(|token| token.chars().next()) + .collect(), + } + } + + fn display(&self) -> String { + self.tokens.iter().collect() + } + + fn as_chars(&self) -> &[char] { + &self.tokens + } + + fn starts_with(&self, input: &[char]) -> bool { + self.tokens.starts_with(input) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ResolvedMainPageKeymap { + focus_previous: KeySequence, + focus_next: KeySequence, + move_up: KeySequence, + move_down: KeySequence, + move_shortcut_order: MoveShortcutOrder, + jump_top: Option, + jump_bottom: Option, + quick_quit: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MoveShortcutOrder { + UpThenDown, + DownThenUp, +} + +impl ResolvedMainPageKeymap { + fn binding(&self, action: MainPageAction) -> Option<&KeySequence> { + match action { + MainPageAction::FocusPrevious => Some(&self.focus_previous), + MainPageAction::FocusNext => Some(&self.focus_next), + MainPageAction::MoveUp => Some(&self.move_up), + MainPageAction::MoveDown => Some(&self.move_down), + MainPageAction::JumpTop => self.jump_top.as_ref(), + MainPageAction::JumpBottom => self.jump_bottom.as_ref(), + MainPageAction::QuickQuit => self.quick_quit.as_ref(), + } + } + + fn action_for_sequence(&self, input: &[char]) -> Option { + MainPageAction::ALL.into_iter().find(|action| { + self.binding(*action) + .is_some_and(|binding| binding.as_chars() == input) + }) + } + + fn has_pending_prefix(&self, input: &[char]) -> bool { + MainPageAction::ALL.into_iter().any(|action| { + self.binding(action) + .is_some_and(|binding| binding.as_chars() != input && binding.starts_with(input)) + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(super) enum KeymapEditorMode { + #[default] + Browse, + Capture, +} + +#[derive(Debug, Default)] +pub(super) struct KeymapEditorState { + pub(super) open: bool, + pub(super) selected_field: usize, + pub(super) mode: KeymapEditorMode, + pub(super) capture_tokens: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeymapEditorField { + ActiveScheme, + CustomBase, + Action(MainPageAction), +} + +impl KeymapEditorField { + fn label(self) -> &'static str { + match self { + Self::ActiveScheme => "Active Scheme", + Self::CustomBase => "Custom Base", + Self::Action(action) => action.label(), + } + } +} + +const KEYMAP_EDITOR_FIELDS: &[KeymapEditorField] = &[ + KeymapEditorField::ActiveScheme, + KeymapEditorField::CustomBase, + KeymapEditorField::Action(MainPageAction::FocusPrevious), + KeymapEditorField::Action(MainPageAction::FocusNext), + KeymapEditorField::Action(MainPageAction::MoveUp), + KeymapEditorField::Action(MainPageAction::MoveDown), + KeymapEditorField::Action(MainPageAction::JumpTop), + KeymapEditorField::Action(MainPageAction::JumpBottom), + KeymapEditorField::Action(MainPageAction::QuickQuit), +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct PendingMainPageSequenceState { + tokens: Vec, + ui_page: UiPage, + focus: Pane, + code_focus: CodePaneFocus, +} + +impl AppState { + pub(super) fn open_keymap_editor(&mut self) { + self.palette.open = false; + self.palette.input.clear(); + self.palette.clear_completion(); + self.palette.clear_local_result(); + self.keymap_editor.open = true; + self.keymap_editor.mode = KeymapEditorMode::Browse; + self.keymap_editor.capture_tokens.clear(); + self.status = "keymap editor opened".to_string(); + } + + pub(super) fn close_keymap_editor(&mut self) { + self.keymap_editor.open = false; + self.keymap_editor.mode = KeymapEditorMode::Browse; + self.keymap_editor.capture_tokens.clear(); + self.status = "keymap editor closed".to_string(); + } + + fn selected_keymap_editor_field(&self) -> KeymapEditorField { + let index = self + .keymap_editor + .selected_field + .min(KEYMAP_EDITOR_FIELDS.len().saturating_sub(1)); + KEYMAP_EDITOR_FIELDS[index] + } + + fn move_keymap_editor_up(&mut self) { + if self.keymap_editor.selected_field > 0 { + self.keymap_editor.selected_field -= 1; + } + } + + fn move_keymap_editor_down(&mut self) { + if self.keymap_editor.selected_field + 1 < KEYMAP_EDITOR_FIELDS.len() { + self.keymap_editor.selected_field += 1; + } + } + + fn pending_main_page_sequence_state(&self, tokens: Vec) -> PendingMainPageSequenceState { + PendingMainPageSequenceState { + tokens, + ui_page: self.ui_page, + focus: self.focus, + code_focus: self.code_focus, + } + } + + fn take_pending_main_page_sequence(&mut self) -> Option> { + let pending = self.pending_main_page_sequence.take()?; + let same_scope = pending.ui_page == self.ui_page + && pending.focus == self.focus + && pending.code_focus == self.code_focus; + same_scope.then_some(pending.tokens) + } + + pub(super) fn rebuild_main_page_keymap(&mut self) { + self.main_page_keymap = resolve_active_main_page_keymap(&self.runtime); + self.pending_main_page_sequence = None; + } + + fn start_keymap_binding_capture(&mut self) { + let KeymapEditorField::Action(action) = self.selected_keymap_editor_field() else { + self.status = "select a key binding row to record".to_string(); + return; + }; + + self.keymap_editor.mode = KeymapEditorMode::Capture; + self.keymap_editor.capture_tokens.clear(); + self.status = format!("recording {}", action.key()); + } + + fn cancel_keymap_binding_capture(&mut self) { + self.keymap_editor.mode = KeymapEditorMode::Browse; + self.keymap_editor.capture_tokens.clear(); + self.status = "key binding capture cancelled".to_string(); + } + + fn capture_keymap_binding_token(&mut self, action: MainPageAction, key: KeyEvent) { + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) + { + self.status = "modifiers are not supported in main-page keymap bindings".to_string(); + return; + } + + let KeyCode::Char(character) = key.code else { + self.status = "only printable character keys are supported".to_string(); + return; + }; + if !is_bindable_keymap_character(character) { + self.status = format!("unsupported binding key: {character}"); + return; + } + if is_reserved_keymap_character(character) { + self.status = format!("reserved binding key: {character}"); + return; + } + if self.keymap_editor.capture_tokens.len() >= action.max_tokens() { + self.status = format!( + "{} accepts at most {} key(s)", + action.key(), + action.max_tokens() + ); + return; + } + + self.keymap_editor.capture_tokens.push(character); + self.status = format!( + "recording {} = {}", + action.key(), + self.keymap_editor.capture_tokens.iter().collect::() + ); + } + + fn save_keymap_binding_capture(&mut self) { + let KeymapEditorField::Action(action) = self.selected_keymap_editor_field() else { + self.cancel_keymap_binding_capture(); + return; + }; + if self.keymap_editor.capture_tokens.is_empty() { + self.status = format!( + "empty binding for {}; press x to reset instead", + action.key() + ); + return; + } + + let literal = format_toml_key_sequence_literal(&self.keymap_editor.capture_tokens); + match update_config_key_in_file( + &self.runtime.config_path, + &format!("ui.custom_keymap.{}", action.key()), + &literal, + ) { + Ok(update) => { + apply_runtime_update(self, update.runtime); + self.keymap_editor.mode = KeymapEditorMode::Browse; + self.keymap_editor.capture_tokens.clear(); + self.status = format!( + "custom binding updated: {} = {}", + action.key(), + update.rendered_value + ); + } + Err(error) => { + self.status = format!("failed to set custom binding {}: {error}", action.key()); + } + } + } + + fn cycle_keymap_editor_value(&mut self, forward: bool) { + match self.selected_keymap_editor_field() { + KeymapEditorField::ActiveScheme => { + let next = cycle_keymap_scheme(self.runtime.ui_keymap, forward); + match update_config_key_in_file( + &self.runtime.config_path, + "ui.keymap", + next.as_str(), + ) { + Ok(update) => { + apply_runtime_update(self, update.runtime); + self.status = format!("active keymap set to {}", next.as_str()); + } + Err(error) => { + self.status = format!("failed to set ui.keymap: {error}"); + } + } + } + KeymapEditorField::CustomBase => { + let next = cycle_keymap_base(self.runtime.ui_keymap_base, forward); + match update_config_key_in_file( + &self.runtime.config_path, + "ui.keymap_base", + next.as_str(), + ) { + Ok(update) => { + apply_runtime_update(self, update.runtime); + self.status = format!("custom keymap base set to {}", next.as_str()); + } + Err(error) => { + self.status = format!("failed to set ui.keymap_base: {error}"); + } + } + } + KeymapEditorField::Action(_) => { + self.status = "press Enter or e to record a custom binding".to_string(); + } + } + } + + fn reset_selected_keymap_field(&mut self) { + match self.selected_keymap_editor_field() { + KeymapEditorField::ActiveScheme => { + match remove_config_key_from_file(&self.runtime.config_path, "ui.keymap") { + Ok(Some(runtime)) => { + apply_runtime_update(self, runtime); + self.status = "ui.keymap reset to default".to_string(); + } + Ok(None) => { + self.status = "ui.keymap already uses the default".to_string(); + } + Err(error) => { + self.status = format!("failed to reset ui.keymap: {error}"); + } + } + } + KeymapEditorField::CustomBase => { + match remove_config_key_from_file(&self.runtime.config_path, "ui.keymap_base") { + Ok(Some(runtime)) => { + apply_runtime_update(self, runtime); + self.status = "ui.keymap_base reset to inferred default".to_string(); + } + Ok(None) => { + self.status = + "ui.keymap_base already uses the inferred default".to_string(); + } + Err(error) => { + self.status = format!("failed to reset ui.keymap_base: {error}"); + } + } + } + KeymapEditorField::Action(action) => { + match remove_config_key_from_file( + &self.runtime.config_path, + &format!("ui.custom_keymap.{}", action.key()), + ) { + Ok(Some(runtime)) => { + apply_runtime_update(self, runtime); + self.status = format!("custom binding reset: {}", action.key()); + } + Ok(None) => { + self.status = format!("custom binding already inherited: {}", action.key()); + } + Err(error) => { + self.status = + format!("failed to reset custom binding {}: {error}", action.key()); + } + } + } + } + } + + fn reset_all_custom_key_bindings(&mut self) { + match remove_config_key_from_file(&self.runtime.config_path, "ui.custom_keymap") { + Ok(Some(runtime)) => { + apply_runtime_update(self, runtime); + self.status = "all custom key bindings reset".to_string(); + } + Ok(None) => { + self.status = "no custom key bindings to reset".to_string(); + } + Err(error) => { + self.status = format!("failed to reset custom key bindings: {error}"); + } + } + } +} + +pub(super) fn resolve_active_main_page_keymap(runtime: &RuntimeConfig) -> ResolvedMainPageKeymap { + match runtime.ui_keymap { + UiKeymap::Default => preset_main_page_keymap(UiKeymapBase::Default), + UiKeymap::Vim => preset_main_page_keymap(UiKeymapBase::Vim), + UiKeymap::Custom => resolve_custom_main_page_keymap(runtime), + } +} + +pub(super) fn resolve_custom_main_page_keymap(runtime: &RuntimeConfig) -> ResolvedMainPageKeymap { + let base = preset_main_page_keymap(runtime.ui_keymap_base); + overlay_custom_keymap(base, &runtime.ui_custom_keymap) +} + +pub(super) fn main_page_focus_shortcuts(keymap: &ResolvedMainPageKeymap) -> String { + format!( + "{}/{}", + keymap.focus_previous.display(), + keymap.focus_next.display() + ) +} + +pub(super) fn main_page_move_shortcuts(keymap: &ResolvedMainPageKeymap) -> String { + match keymap.move_shortcut_order { + MoveShortcutOrder::UpThenDown => { + format!( + "{}/{}", + keymap.move_up.display(), + keymap.move_down.display() + ) + } + MoveShortcutOrder::DownThenUp => { + format!( + "{}/{}", + keymap.move_down.display(), + keymap.move_up.display() + ) + } + } +} + +pub(super) fn main_page_navigation_shortcuts(keymap: &ResolvedMainPageKeymap) -> String { + format!( + "{} focus | {} move", + main_page_focus_shortcuts(keymap), + main_page_move_shortcuts(keymap) + ) +} + +pub(super) fn handle_main_page_key_event( + state: &mut AppState, + key: KeyEvent, +) -> Option { + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) + { + state.pending_main_page_sequence = None; + return None; + } + + let KeyCode::Char(character) = key.code else { + state.pending_main_page_sequence = None; + return None; + }; + + let pending_prefix = state.take_pending_main_page_sequence().unwrap_or_default(); + let combined = extend_sequence(&pending_prefix, character); + if let Some(action) = state.main_page_keymap.action_for_sequence(&combined) { + state.pending_main_page_sequence = None; + return Some(execute_main_page_action(state, action)); + } + if state.main_page_keymap.has_pending_prefix(&combined) { + state.clear_pending_main_page_count(); + set_pending_main_page_status(state, &combined); + state.pending_main_page_sequence = Some(state.pending_main_page_sequence_state(combined)); + return Some(LoopAction::Continue); + } + + let single = vec![character]; + if let Some(action) = state.main_page_keymap.action_for_sequence(&single) { + state.pending_main_page_sequence = None; + return Some(execute_main_page_action(state, action)); + } + if state.main_page_keymap.has_pending_prefix(&single) { + state.clear_pending_main_page_count(); + set_pending_main_page_status(state, &single); + state.pending_main_page_sequence = Some(state.pending_main_page_sequence_state(single)); + return Some(LoopAction::Continue); + } + + state.pending_main_page_sequence = None; + None +} + +pub(super) fn handle_keymap_editor_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { + match state.keymap_editor.mode { + KeymapEditorMode::Browse => match key.code { + KeyCode::Esc => state.close_keymap_editor(), + KeyCode::Up | KeyCode::Char('i') => state.move_keymap_editor_up(), + KeyCode::Down | KeyCode::Char('k') => state.move_keymap_editor_down(), + KeyCode::Left => state.cycle_keymap_editor_value(false), + KeyCode::Right | KeyCode::Tab => state.cycle_keymap_editor_value(true), + KeyCode::Enter | KeyCode::Char('e') => match state.selected_keymap_editor_field() { + KeymapEditorField::Action(_) => state.start_keymap_binding_capture(), + _ => state.cycle_keymap_editor_value(true), + }, + KeyCode::Char('x') => state.reset_selected_keymap_field(), + KeyCode::Char('R') => state.reset_all_custom_key_bindings(), + _ => {} + }, + KeymapEditorMode::Capture => match key.code { + KeyCode::Esc => state.cancel_keymap_binding_capture(), + KeyCode::Enter => state.save_keymap_binding_capture(), + KeyCode::Backspace => { + state.keymap_editor.capture_tokens.pop(); + } + _ => { + let KeymapEditorField::Action(action) = state.selected_keymap_editor_field() else { + return LoopAction::Continue; + }; + state.capture_keymap_binding_token(action, key); + } + }, + } + + LoopAction::Continue +} + +pub(super) fn draw_keymap_editor(frame: &mut Frame<'_>, state: &AppState) { + let custom_keymap = resolve_custom_main_page_keymap(&state.runtime); + let area = centered_rect(88, 78, frame.area()); + frame.render_widget(Clear, area); + + let block = Block::default() + .title("Keymap Editor") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Min(10), + Constraint::Length(4), + ]) + .split(inner); + + let header = format!( + "active={} | custom base={} | command=:keymap", + state.runtime.ui_keymap.as_str(), + state.runtime.ui_keymap_base.as_str() + ); + frame.render_widget( + Paragraph::new(header).wrap(Wrap { trim: false }), + sections[0], + ); + + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(42), Constraint::Percentage(58)]) + .split(sections[1]); + + let selected_index = state + .keymap_editor + .selected_field + .min(KEYMAP_EDITOR_FIELDS.len().saturating_sub(1)); + let mut list_state = ListState::default(); + list_state.select(Some(selected_index)); + + let items: Vec = KEYMAP_EDITOR_FIELDS + .iter() + .map(|field| { + let value = keymap_editor_field_value(*field, state, &custom_keymap); + let text = truncate_with_ellipsis( + &format!("{:<14} {}", field.label(), value), + body[0].width.saturating_sub(3) as usize, + ); + ListItem::new(text) + }) + .collect(); + let list = List::new(items) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + frame.render_stateful_widget(list, body[0], &mut list_state); + + let details = + Paragraph::new(keymap_editor_details(state, &custom_keymap)).wrap(Wrap { trim: false }); + frame.render_widget(details, body[1]); + + let footer = match state.keymap_editor.mode { + KeymapEditorMode::Browse => { + "Up/Down move | Left/Right/Tab cycle | Enter/e record | x reset | R reset custom | Esc close" + } + KeymapEditorMode::Capture => { + "Type 1-2 printable keys | Backspace delete | Enter save | Esc cancel" + } + }; + frame.render_widget( + Paragraph::new(footer).wrap(Wrap { trim: false }), + sections[2], + ); +} + +fn preset_main_page_keymap(base: UiKeymapBase) -> ResolvedMainPageKeymap { + match base { + UiKeymapBase::Default => ResolvedMainPageKeymap { + focus_previous: KeySequence::from_chars(vec!['j']), + focus_next: KeySequence::from_chars(vec!['l']), + move_up: KeySequence::from_chars(vec!['i']), + move_down: KeySequence::from_chars(vec!['k']), + move_shortcut_order: MoveShortcutOrder::UpThenDown, + jump_top: None, + jump_bottom: None, + quick_quit: None, + }, + UiKeymapBase::Vim => ResolvedMainPageKeymap { + focus_previous: KeySequence::from_chars(vec!['h']), + focus_next: KeySequence::from_chars(vec!['l']), + move_up: KeySequence::from_chars(vec!['k']), + move_down: KeySequence::from_chars(vec!['j']), + move_shortcut_order: MoveShortcutOrder::DownThenUp, + jump_top: Some(KeySequence::from_chars(vec!['g', 'g'])), + jump_bottom: Some(KeySequence::from_chars(vec!['G'])), + quick_quit: Some(KeySequence::from_chars(vec!['q', 'q'])), + }, + } +} + +fn overlay_custom_keymap( + base: ResolvedMainPageKeymap, + custom: &UiCustomKeymapConfig, +) -> ResolvedMainPageKeymap { + let ResolvedMainPageKeymap { + focus_previous, + focus_next, + move_up, + move_down, + move_shortcut_order, + jump_top, + jump_bottom, + quick_quit, + } = base; + ResolvedMainPageKeymap { + focus_previous: custom + .focus_prev + .as_deref() + .map(KeySequence::from_config_tokens) + .unwrap_or(focus_previous), + focus_next: custom + .focus_next + .as_deref() + .map(KeySequence::from_config_tokens) + .unwrap_or(focus_next), + move_up: custom + .move_up + .as_deref() + .map(KeySequence::from_config_tokens) + .unwrap_or(move_up), + move_down: custom + .move_down + .as_deref() + .map(KeySequence::from_config_tokens) + .unwrap_or(move_down), + move_shortcut_order, + jump_top: custom + .jump_top + .as_deref() + .map(KeySequence::from_config_tokens) + .or(jump_top), + jump_bottom: custom + .jump_bottom + .as_deref() + .map(KeySequence::from_config_tokens) + .or(jump_bottom), + quick_quit: custom + .quick_quit + .as_deref() + .map(KeySequence::from_config_tokens) + .or(quick_quit), + } +} + +fn execute_main_page_action(state: &mut AppState, action: MainPageAction) -> LoopAction { + match action { + MainPageAction::FocusPrevious => { + state.clear_pending_main_page_count(); + state.move_focus_previous(); + LoopAction::Continue + } + MainPageAction::FocusNext => { + state.clear_pending_main_page_count(); + state.move_focus_next(); + LoopAction::Continue + } + MainPageAction::MoveUp => { + for _ in 0..pending_main_page_move_count(state) { + state.move_up(); + } + LoopAction::Continue + } + MainPageAction::MoveDown => { + for _ in 0..pending_main_page_move_count(state) { + state.move_down(); + } + LoopAction::Continue + } + MainPageAction::JumpTop => { + state.clear_pending_main_page_count(); + state.jump_current_pane_to_start(); + LoopAction::Continue + } + MainPageAction::JumpBottom => { + state.clear_pending_main_page_count(); + state.jump_current_pane_to_end(); + LoopAction::Continue + } + MainPageAction::QuickQuit => { + state.clear_pending_main_page_count(); + LoopAction::Exit + } + } +} + +fn set_pending_main_page_status(state: &mut AppState, input: &[char]) { + let Some(quick_quit) = state.main_page_keymap.binding(MainPageAction::QuickQuit) else { + return; + }; + if !quick_quit.starts_with(input) || quick_quit.as_chars() == input { + return; + } + + state.status = format!( + "press {} to quit or use command palette quit/exit", + quick_quit.display() + ); +} + +fn extend_sequence(prefix: &[char], next: char) -> Vec { + let mut sequence = prefix.to_vec(); + sequence.push(next); + sequence +} + +fn cycle_keymap_scheme(current: UiKeymap, forward: bool) -> UiKeymap { + const ORDER: [UiKeymap; 3] = [UiKeymap::Default, UiKeymap::Vim, UiKeymap::Custom]; + cycle_enum_value(&ORDER, current, forward) +} + +fn cycle_keymap_base(current: UiKeymapBase, forward: bool) -> UiKeymapBase { + const ORDER: [UiKeymapBase; 2] = [UiKeymapBase::Default, UiKeymapBase::Vim]; + cycle_enum_value(&ORDER, current, forward) +} + +fn cycle_enum_value(values: &[T], current: T, forward: bool) -> T { + let index = values + .iter() + .position(|value| *value == current) + .unwrap_or(0); + if forward { + values[(index + 1) % values.len()] + } else if index == 0 { + values[values.len().saturating_sub(1)] + } else { + values[index - 1] + } +} + +fn keymap_editor_field_value( + field: KeymapEditorField, + state: &AppState, + custom_keymap: &ResolvedMainPageKeymap, +) -> String { + match field { + KeymapEditorField::ActiveScheme => state.runtime.ui_keymap.as_str().to_string(), + KeymapEditorField::CustomBase => state.runtime.ui_keymap_base.as_str().to_string(), + KeymapEditorField::Action(action) => format!( + "{} [{}]", + render_binding_text(state.main_page_keymap.binding(action)), + active_keymap_binding_source_label(state, action, custom_keymap) + ), + } +} + +fn keymap_editor_details(state: &AppState, custom_keymap: &ResolvedMainPageKeymap) -> String { + match state.selected_keymap_editor_field() { + KeymapEditorField::ActiveScheme => format!( + "Select which main-page keymap is active now.\n\n\ + default: j/l focus, i/k move\n\ + vim: h/l focus, j/k move, gg/G jump, qq quit\n\ + custom: use ui.keymap_base plus ui.custom_keymap overrides\n\n\ + Current active shortcuts: {}\n", + main_page_navigation_shortcuts(&state.main_page_keymap) + ), + KeymapEditorField::CustomBase => format!( + "Choose the preset that seeds the custom scheme.\n\n\ + The custom scheme inherits any action without an override.\n\ + Current base: {}\n\ + Current custom shortcuts: {}\n", + state.runtime.ui_keymap_base.as_str(), + main_page_navigation_shortcuts(custom_keymap) + ), + KeymapEditorField::Action(action) => { + let custom_override = action_custom_binding(&state.runtime, action); + let active_binding = render_binding_text(state.main_page_keymap.binding(action)); + let custom_binding = render_binding_text(custom_keymap.binding(action)); + let source = if custom_override.is_some() { + "custom override".to_string() + } else { + format!("base {}", state.runtime.ui_keymap_base.as_str()) + }; + let capture = if matches!(state.keymap_editor.mode, KeymapEditorMode::Capture) { + format!( + "\nCapture buffer: {}\n", + state + .keymap_editor + .capture_tokens + .iter() + .collect::() + ) + } else { + String::new() + }; + format!( + "{}\n\n\ + {}\n\n\ + Active scheme: {}\n\ + Active binding now: {}\n\ + Custom binding: {}\n\ + Custom source: {}\n\ + Config key: ui.custom_keymap.{}\n\ + Override in file: {}\n{}", + action.label(), + action.description(), + state.runtime.ui_keymap.as_str(), + active_binding, + custom_binding, + source, + action.key(), + custom_override + .map(|binding| format_toml_key_sequence_literal(&binding)) + .unwrap_or_else(|| "".to_string()), + capture + ) + } + } +} + +fn action_custom_binding(runtime: &RuntimeConfig, action: MainPageAction) -> Option> { + let binding = match action { + MainPageAction::FocusPrevious => runtime.ui_custom_keymap.focus_prev.as_ref(), + MainPageAction::FocusNext => runtime.ui_custom_keymap.focus_next.as_ref(), + MainPageAction::MoveUp => runtime.ui_custom_keymap.move_up.as_ref(), + MainPageAction::MoveDown => runtime.ui_custom_keymap.move_down.as_ref(), + MainPageAction::JumpTop => runtime.ui_custom_keymap.jump_top.as_ref(), + MainPageAction::JumpBottom => runtime.ui_custom_keymap.jump_bottom.as_ref(), + MainPageAction::QuickQuit => runtime.ui_custom_keymap.quick_quit.as_ref(), + }?; + Some( + binding + .iter() + .filter_map(|token| token.chars().next()) + .collect(), + ) +} + +fn active_keymap_binding_source_label( + state: &AppState, + action: MainPageAction, + custom_keymap: &ResolvedMainPageKeymap, +) -> String { + match state.runtime.ui_keymap { + UiKeymap::Default => "default".to_string(), + UiKeymap::Vim => "vim".to_string(), + UiKeymap::Custom => { + if action_custom_binding(&state.runtime, action).is_some() { + "custom".to_string() + } else if custom_keymap.binding(action).is_some() { + format!("base {}", state.runtime.ui_keymap_base.as_str()) + } else { + "none".to_string() + } + } + } +} + +fn render_binding_text(binding: Option<&KeySequence>) -> String { + binding + .map(KeySequence::display) + .unwrap_or_else(|| "".to_string()) +} + +fn format_toml_key_sequence_literal(tokens: &[char]) -> String { + format!( + "[{}]", + tokens + .iter() + .map(|token| format!("\"{}\"", escape_toml_char(*token))) + .collect::>() + .join(", ") + ) +} + +fn escape_toml_char(character: char) -> String { + match character { + '"' => "\\\"".to_string(), + '\\' => "\\\\".to_string(), + _ => character.to_string(), + } +} + +fn is_bindable_keymap_character(character: char) -> bool { + !character.is_ascii_control() && !character.is_ascii_whitespace() && !character.is_ascii_digit() +} + +fn is_reserved_keymap_character(character: char) -> bool { + matches!( + character, + ':' | '/' + | 'e' + | 'r' + | 'a' + | 'd' + | 'u' + | 'y' + | 'n' + | '[' + | ']' + | '{' + | '}' + | 'E' + | '-' + | '=' + | '+' + ) +} diff --git a/src/ui/tui/palette.rs b/src/ui/tui/palette.rs index b884c55..8b46750 100644 --- a/src/ui/tui/palette.rs +++ b/src/ui/tui/palette.rs @@ -191,148 +191,7 @@ pub(super) fn run_palette_sync(state: &mut AppState, command: &str) { command = %command, mailboxes = %mailboxes.join(",") ); - - let mut success = 0usize; - let mut failed = 0usize; - let mut total_fetched = 0usize; - let mut total_inserted = 0usize; - let mut total_updated = 0usize; - let mut first_error: Option = None; - let mut defer_inbox_auto_sync = false; - let mut defer_subscription_auto_sync = false; - let total = mailboxes.len(); - - for (index, mailbox) in mailboxes.into_iter().enumerate() { - if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) { - defer_inbox_auto_sync = true; - } - if !mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) { - defer_subscription_auto_sync = true; - } - tracing::info!( - op = "sync", - status = "progress", - phase = "started", - mailbox = %mailbox, - index = index + 1, - total, - completed = success + failed, - succeeded = success, - failed - ); - let request = sync_worker::SyncRequest { - mailbox: mailbox.clone(), - fixture_dir: None, - uidvalidity: None, - reconnect_attempts: PALETTE_SYNC_RECONNECT_ATTEMPTS, - }; - - match state.run_sync_request(request) { - Ok(summary) => { - success += 1; - total_fetched += summary.fetched; - total_inserted += summary.inserted; - total_updated += summary.updated; - tracing::info!( - op = "sync", - status = "succeeded", - phase = "finished", - mailbox = %mailbox, - index = index + 1, - total, - completed = success + failed, - succeeded = success, - failed, - fetched = summary.fetched, - inserted = summary.inserted, - updated = summary.updated - ); - } - Err(error) => { - failed += 1; - if first_error.is_none() { - first_error = Some(format!("{mailbox}: {error}")); - } - tracing::error!( - op = "sync", - status = "failed", - phase = "finished", - mailbox = %mailbox, - index = index + 1, - total, - completed = success + failed, - succeeded = success, - failed, - error = %error - ); - } - } - } - - if defer_inbox_auto_sync { - state.defer_inbox_auto_sync(); - } - if defer_subscription_auto_sync { - state.defer_subscription_auto_sync(); - } - - if success > 0 - && let Ok(rows) = mail_store::load_thread_rows_by_mailbox( - &state.runtime.database_path, - &state.active_thread_mailbox, - 500, - ) - { - state.replace_threads(rows); - } - - let first_error_text = first_error - .as_deref() - .unwrap_or("unknown error") - .to_string(); - - state.status = if failed == 0 { - format!( - "sync ok: mailboxes={} fetched={} inserted={} updated={}", - success, total_fetched, total_inserted, total_updated - ) - } else if success == 0 { - format!("sync failed: {}", first_error_text) - } else { - format!( - "sync partial: ok={} failed={} fetched={} inserted={} updated={} first_error={}", - success, failed, total_fetched, total_inserted, total_updated, first_error_text - ) - }; - - tracing::info!( - op = "sync", - status = if failed == 0 { - "succeeded" - } else if success == 0 { - "failed" - } else { - "partial" - }, - command = %command, - success, - failed, - fetched = total_fetched, - inserted = total_inserted, - updated = total_updated, - first_error = %first_error_text - ); - - if failed > 0 { - tracing::error!( - op = "sync", - status = "failed", - command = %command, - success, - failed, - first_error = %first_error_text - ); - } + state.queue_palette_sync(mailboxes); } pub(super) fn apply_palette_completion(state: &mut AppState) { diff --git a/src/ui/tui/render.rs b/src/ui/tui/render.rs index 2d23126..d5ce6f2 100644 --- a/src/ui/tui/render.rs +++ b/src/ui/tui/render.rs @@ -17,11 +17,18 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragra use super::config::draw_config_editor; use super::palette::palette_overlay_suggestions; +use super::reply::ReplyPreviewLineKind; use super::*; const REPLY_BODY_GUIDE_COLUMN: usize = 80; const HEADER_BG: Color = Color::Blue; +#[derive(Clone, Copy)] +enum VerticalScrollWrapMode { + Disabled, + Enabled, +} + pub(super) fn draw( frame: &mut Frame<'_>, state: &AppState, @@ -39,8 +46,25 @@ pub(super) fn draw( ]) .split(frame.area()); - let uptime = state.started_at.elapsed().as_secs(); - let mut header = vec![ + let uptime_label = format_uptime_label(state.started_at.elapsed().as_secs()); + let sync_progress_text = state + .background_sync_progress_text() + .map(|value| sanitize_inline_ui_text(&value)); + let header_sections = if let Some(progress_text) = sync_progress_text.as_ref() { + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(1), + Constraint::Length(progress_text.chars().count().min(64) as u16 + 1), + ]) + .split(areas[0]) + } else { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(1)]) + .split(areas[0]) + }; + let header = vec![ Span::styled( " CRIEW ", Style::default() @@ -74,32 +98,37 @@ pub(super) fn draw( ), Span::styled(" | ", Style::default().fg(Color::White).bg(HEADER_BG)), Span::styled( - format!("up {}s", uptime), + format!("keymap {}", state.runtime.ui_keymap.as_str()), Style::default() .fg(Color::White) .bg(HEADER_BG) .add_modifier(Modifier::DIM), ), - ]; - if let Some(sync) = state.startup_sync_progress_text() { - header.push(Span::styled( - " | ", - Style::default().fg(Color::White).bg(HEADER_BG), - )); - header.push(Span::styled( - sanitize_inline_ui_text(&sync), + Span::styled(" | ", Style::default().fg(Color::White).bg(HEADER_BG)), + Span::styled( + format!("up {uptime_label}"), Style::default() - .fg(Color::Yellow) + .fg(Color::White) .bg(HEADER_BG) - .add_modifier(Modifier::BOLD), - )); - } + .add_modifier(Modifier::DIM), + ), + ]; + let header_background = Paragraph::new("").style(Style::default().bg(HEADER_BG)); + frame.render_widget(header_background, areas[0]); + let header_widget = Paragraph::new(Line::from(header)).style(Style::default().bg(HEADER_BG)); - frame.render_widget(header_widget, areas[0]); + frame.render_widget(header_widget, header_sections[0]); + + if let Some(progress_text) = sync_progress_text { + let progress = Paragraph::new(format!("{progress_text} ")) + .alignment(Alignment::Right) + .style(Style::default().fg(Color::Yellow).bg(HEADER_BG)); + frame.render_widget(progress, header_sections[1]); + } match state.ui_page { UiPage::Mail => { - let panes = mail_page_panes(areas[1]); + let panes = mail_page_panes(areas[1], state.mail_pane_layout); draw_subscriptions(frame, panes[0], state); draw_threads(frame, panes[1], state); draw_preview(frame, panes[2], state, config); @@ -116,24 +145,35 @@ pub(super) fn draw( .as_ref() .is_some_and(|panel| panel.preview_open) { - "j/k scroll preview | Enter/c confirm | Esc close | S send" + "j/k scroll preview | Enter/c confirm | Esc close | S send".to_string() } else if state .reply_panel .as_ref() .is_some_and(|panel| panel.reply_notice.is_some()) { - "Enter/Esc close notice | P preview | S send" + "Enter/Esc close notice | P preview | S send".to_string() } else { - "Esc normal/close | Enter/o open below+insert | h/j/k/l move | i insert | x delete | p send preview | S send | :preview :send :q :q!" + "Esc normal/close | Enter/o open below+insert | h/j/k/l move | i insert | x delete | p send preview | S send | :preview :send :q :q!".to_string() } } - UiPage::Mail => "/ search | Tab page | : palette | Enter | e/r reply", + UiPage::Mail if state.palette.open => { + "/ search | Tab page | : palette | Enter | e/r reply".to_string() + } + UiPage::Mail => format!( + "/ search | Tab page | : palette | Enter | e/r reply | [ ] expand pane | {{ }} shrink pane | {}", + main_page_navigation_shortcuts(&state.main_page_keymap) + ), UiPage::CodeBrowser if state.is_code_edit_active() => { - "Esc normal/exit | h/j/k/l move | i insert | x delete | s save | E external vim | :w :q :q! :wq :vim" + "Esc normal/exit | h/j/k/l move | i insert | x delete | s save | E external vim | :w :q :q! :wq :vim".to_string() } - UiPage::CodeBrowser => { + UiPage::CodeBrowser if state.palette.open => { "Tab page | : palette | Enter expand/collapse | e inline edit | E external vim" + .to_string() } + UiPage::CodeBrowser => format!( + "Tab page | : palette | Enter expand/collapse | e inline edit | E external vim | {}", + main_page_navigation_shortcuts(&state.main_page_keymap) + ), }; let footer_background = Paragraph::new("").style(Style::default().fg(Color::White).bg(Color::DarkGray)); @@ -168,11 +208,28 @@ pub(super) fn draw( if state.config_editor.open { draw_config_editor(frame, state); } + if state.keymap_editor.open { + draw_keymap_editor(frame, state); + } if state.reply_panel.is_some() { draw_reply_panel(frame, state); } } +fn format_uptime_label(uptime_secs: u64) -> String { + let hours = uptime_secs / 3_600; + let minutes = (uptime_secs % 3_600) / 60; + let seconds = uptime_secs % 60; + + if hours > 0 { + return format!("{hours:02}h:{minutes:02}m:{seconds:02}s"); + } + if minutes > 0 { + return format!("{minutes:02}m:{seconds:02}s"); + } + format!("{seconds}s") +} + fn footer_status_text(status: &str) -> Option { let sanitized = sanitize_inline_ui_text(status); if sanitized.trim().is_empty() { @@ -200,12 +257,12 @@ fn draw_code_browser_page(frame: &mut Frame<'_>, area: Rect, state: &AppState) { draw_code_source_preview(frame, panes[1], state); } -pub(super) fn mail_page_panes(area: Rect) -> [Rect; 3] { +pub(super) fn mail_page_panes(area: Rect, layout: MailPaneLayout) -> [Rect; 3] { if area.width == 0 { return [area, area, area]; } - let preview_width = area.width.min(PREVIEW_PANE_FIXED_WIDTH); + let preview_width = area.width.min(layout.preview_width); let left_width = area.width.saturating_sub(preview_width); let preview = Rect { x: area.x + left_width, @@ -224,18 +281,22 @@ pub(super) fn mail_page_panes(area: Rect) -> [Rect; 3] { return [empty, empty, preview]; } - let left = Rect { + let subscriptions_width = left_width.min(layout.subscriptions_width); + let threads_width = left_width.saturating_sub(subscriptions_width); + let subscriptions = Rect { x: area.x, y: area.y, - width: left_width, + width: subscriptions_width, + height: area.height, + }; + let threads = Rect { + x: area.x + subscriptions_width, + y: area.y, + width: threads_width, height: area.height, }; - let left_panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Ratio(1, 4), Constraint::Ratio(3, 4)]) - .split(left); - [left_panes[0], left_panes[1], preview] + [subscriptions, threads, preview] } fn draw_subscriptions(frame: &mut Frame<'_>, area: Rect, state: &AppState) { @@ -322,8 +383,24 @@ fn draw_code_source_preview(frame: &mut Frame<'_>, area: Rect, state: &AppState) frame.render_widget(block, area); frame.render_widget(Clear, inner_area); - let paragraph = - Paragraph::new(load_code_source_preview(state)).scroll((state.code_preview_scroll, 0)); + let preview = load_code_source_preview(state); + let code_preview_scroll_limit = max_vertical_scroll( + &preview, + inner_area.width, + inner_area.height, + VerticalScrollWrapMode::Disabled, + ); + state + .code_preview_scroll_limit + .set(code_preview_scroll_limit); + let scroll = clamp_vertical_scroll( + &preview, + inner_area.width, + inner_area.height, + state.code_preview_scroll, + VerticalScrollWrapMode::Disabled, + ); + let paragraph = Paragraph::new(preview).scroll((scroll, 0)); frame.render_widget(paragraph, inner_area); if let Some(cursor_position) = code_edit_cursor_position(state, inner_area) { @@ -533,13 +610,85 @@ fn draw_preview(frame: &mut Frame<'_>, area: Rect, state: &AppState, config: &Ru inner_area }; + let preview_scroll_limit = max_vertical_scroll( + preview.as_ref(), + content_area.width, + content_area.height, + VerticalScrollWrapMode::Enabled, + ); + state.preview_scroll_limit.set(preview_scroll_limit); + let scroll = clamp_vertical_scroll( + preview.as_ref(), + content_area.width, + content_area.height, + state.preview_scroll, + VerticalScrollWrapMode::Enabled, + ); let paragraph = Paragraph::new(preview.as_ref()) - .scroll((state.preview_scroll, 0)) + .scroll((scroll, 0)) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, content_area); } +fn clamp_vertical_scroll( + text: &str, + area_width: u16, + area_height: u16, + requested_scroll: u16, + wrap_mode: VerticalScrollWrapMode, +) -> u16 { + requested_scroll.min(max_vertical_scroll( + text, + area_width, + area_height, + wrap_mode, + )) +} + +fn max_vertical_scroll( + text: &str, + area_width: u16, + area_height: u16, + wrap_mode: VerticalScrollWrapMode, +) -> u16 { + if area_height == 0 { + return 0; + } + + let visible_lines = area_height as usize; + let total_lines = visual_line_count(text, area_width, wrap_mode); + total_lines + .saturating_sub(visible_lines) + .min(u16::MAX as usize) as u16 +} + +fn visual_line_count(text: &str, area_width: u16, wrap_mode: VerticalScrollWrapMode) -> usize { + match wrap_mode { + VerticalScrollWrapMode::Disabled => text.split('\n').count().max(1), + VerticalScrollWrapMode::Enabled => { + if area_width == 0 { + return 0; + } + + text.split('\n') + .map(|line| wrapped_visual_line_count(line, area_width)) + .sum::() + .max(1) + } + } +} + +fn wrapped_visual_line_count(line: &str, area_width: u16) -> usize { + if area_width == 0 { + return 0; + } + + let width = area_width as usize; + let display_width = display_column(line, line.chars().count()).max(1); + display_width.saturating_add(width - 1) / width +} + fn load_series_preview(state: &AppState, config: &RuntimeConfig, thread_id: i64) -> Option { let series = state.series_summaries.get(&thread_id)?; let mut lines = vec![ @@ -723,8 +872,16 @@ fn draw_reply_panel(frame: &mut Frame<'_>, state: &AppState) { }; let area = centered_rect(88, 84, frame.area()); - frame.render_widget(Clear, area); + if panel.preview_open { + draw_send_preview_panel(frame, area, panel); + return; + } + if panel.reply_notice.is_some() { + draw_reply_notice_panel(frame, area, panel); + return; + } + frame.render_widget(Clear, area); let title = format!( "Reply Panel [{} dirty:{} confirmed:{} focus:{}]", panel.mode.label(), @@ -748,7 +905,7 @@ fn draw_reply_panel(frame: &mut Frame<'_>, state: &AppState) { let body_area = sections[1]; let header_block = Block::default() - .title("Headers (From/To/Cc/Subject editable)") + .title("Headers ([edit] / [read-only])") .borders(Borders::ALL) .border_style(reply_panel_section_style( matches!( @@ -803,39 +960,37 @@ fn draw_reply_panel(frame: &mut Frame<'_>, state: &AppState) { if let Some(cursor) = reply_panel_cursor_position(panel, header_inner, body_content_area) { frame.set_cursor_position(cursor); } - - if panel.preview_open { - draw_send_preview_overlay(frame, panel); - } else if panel.reply_notice.is_some() { - draw_reply_notice_overlay(frame, panel); - } } fn render_reply_header_content(panel: &ReplyPanelState) -> String { [ format!( - "{} From: {}", + "{} {}{}", reply_section_marker(panel, ReplySection::From), + reply_editable_field_prefix(ReplySection::From), sanitize_source_preview_text(&panel.from) ), format!( - "{} To: {}", + "{} {}{}", reply_section_marker(panel, ReplySection::To), + reply_editable_field_prefix(ReplySection::To), sanitize_source_preview_text(&panel.to) ), format!( - "{} Cc: {}", + "{} {}{}", reply_section_marker(panel, ReplySection::Cc), + reply_editable_field_prefix(ReplySection::Cc), sanitize_source_preview_text(&panel.cc) ), format!( - "{} Subject: {}", + "{} {}{}", reply_section_marker(panel, ReplySection::Subject), + reply_editable_field_prefix(ReplySection::Subject), sanitize_source_preview_text(&panel.subject) ), - format!(" In-Reply-To: <{}>", panel.in_reply_to), + format!(" [read-only] In-Reply-To: <{}>", panel.in_reply_to), format!( - " References: {}", + " [read-only] References: {}", panel .references .iter() @@ -943,22 +1098,26 @@ fn reply_panel_cursor_position( ReplySection::From => reply_fixed_cursor_position( header_inner, 0, - reply_field_prefix_width("From") + display_column(&panel.from, panel.cursor_col), + reply_field_prefix_width(ReplySection::From) + + display_column(&panel.from, panel.cursor_col), ), ReplySection::To => reply_fixed_cursor_position( header_inner, 1, - reply_field_prefix_width("To") + display_column(&panel.to, panel.cursor_col), + reply_field_prefix_width(ReplySection::To) + + display_column(&panel.to, panel.cursor_col), ), ReplySection::Cc => reply_fixed_cursor_position( header_inner, 2, - reply_field_prefix_width("Cc") + display_column(&panel.cc, panel.cursor_col), + reply_field_prefix_width(ReplySection::Cc) + + display_column(&panel.cc, panel.cursor_col), ), ReplySection::Subject => reply_fixed_cursor_position( header_inner, 3, - reply_field_prefix_width("Subject") + display_column(&panel.subject, panel.cursor_col), + reply_field_prefix_width(ReplySection::Subject) + + display_column(&panel.subject, panel.cursor_col), ), ReplySection::Body => { let line = panel @@ -1019,14 +1178,17 @@ fn reply_body_cursor_position( )) } -fn draw_send_preview_overlay(frame: &mut Frame<'_>, panel: &ReplyPanelState) { - let area = centered_rect(82, 78, frame.area()); +fn draw_send_preview_panel(frame: &mut Frame<'_>, area: Rect, panel: &ReplyPanelState) { frame.render_widget(Clear, area); - let title = if panel.preview_errors.is_empty() { - "Send Preview" - } else { + let title = if !panel.preview_errors.is_empty() { "Send Preview [invalid]" + } else if !panel.preview_warnings.is_empty() { + "Send Preview [warning]" + } else if has_authored_reply_lines(panel) { + "Send Preview [reply highlighted]" + } else { + "Send Preview" }; let block = Block::default() .title(title) @@ -1035,47 +1197,164 @@ fn draw_send_preview_overlay(frame: &mut Frame<'_>, panel: &ReplyPanelState) { let inner = block.inner(area); frame.render_widget(block, area); - let content_area = if panel.preview_errors.is_empty() { - inner - } else { - let error_text = panel - .preview_errors - .iter() - .map(|value| format!("- {value}")) - .collect::>() - .join("\n"); - let error_height = error_text - .lines() - .count() - .min(inner.height.saturating_sub(1) as usize) as u16; - let sections = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(error_height), Constraint::Min(1)]) - .split(inner); - let warning = Paragraph::new(error_text) - .style( - Style::default() - .fg(Color::Black) - .bg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - .wrap(Wrap { trim: false }); - frame.render_widget(warning, sections[0]); - sections[1] - }; - - let preview = Paragraph::new(panel.preview_rendered.clone()) + let content_area = draw_send_preview_messages(frame, inner, panel); + let preview = Paragraph::new(render_reply_preview_text(panel)) .scroll((panel.preview_scroll, 0)) .wrap(Wrap { trim: false }); frame.render_widget(preview, content_area); } -fn draw_reply_notice_overlay(frame: &mut Frame<'_>, panel: &ReplyPanelState) { +fn draw_send_preview_messages(frame: &mut Frame<'_>, area: Rect, panel: &ReplyPanelState) -> Rect { + let mut remaining_height = area.height.saturating_sub(1); + let error_height = preview_message_height(&panel.preview_errors, remaining_height); + remaining_height = remaining_height.saturating_sub(error_height); + let warning_height = preview_message_height(&panel.preview_warnings, remaining_height); + remaining_height = remaining_height.saturating_sub(warning_height); + let info_messages = preview_info_messages(panel); + let info_height = preview_message_height(&info_messages, remaining_height); + + if error_height == 0 && warning_height == 0 && info_height == 0 { + return area; + } + + let mut constraints = Vec::new(); + if error_height > 0 { + constraints.push(Constraint::Length(error_height)); + } + if warning_height > 0 { + constraints.push(Constraint::Length(warning_height)); + } + if info_height > 0 { + constraints.push(Constraint::Length(info_height)); + } + constraints.push(Constraint::Min(1)); + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + let mut section_index = 0usize; + if error_height > 0 { + draw_send_preview_message_block( + frame, + sections[section_index], + &panel.preview_errors, + Style::default() + .fg(Color::White) + .bg(Color::Red) + .add_modifier(Modifier::BOLD), + ); + section_index += 1; + } + if warning_height > 0 { + draw_send_preview_message_block( + frame, + sections[section_index], + &panel.preview_warnings, + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + section_index += 1; + } + if info_height > 0 { + draw_send_preview_message_block( + frame, + sections[section_index], + &info_messages, + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + section_index += 1; + } + + sections[section_index] +} + +fn preview_message_height(messages: &[String], remaining_height: u16) -> u16 { + if messages.is_empty() || remaining_height == 0 { + return 0; + } + + let line_count = messages + .iter() + .map(|message| message.lines().count().max(1)) + .sum::(); + line_count.min(remaining_height as usize) as u16 +} + +fn draw_send_preview_message_block( + frame: &mut Frame<'_>, + area: Rect, + messages: &[String], + style: Style, +) { + let text = messages + .iter() + .map(|value| format!("- {value}")) + .collect::>() + .join("\n"); + let paragraph = Paragraph::new(text).style(style).wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +} + +fn render_reply_preview_text(panel: &ReplyPanelState) -> Text<'static> { + Text::from( + panel + .preview_lines + .iter() + .map(|line| { + Line::from(Span::styled( + line.text.clone(), + reply_preview_line_style(line.kind), + )) + }) + .collect::>>(), + ) +} + +fn preview_info_messages(panel: &ReplyPanelState) -> Vec { + if has_authored_reply_lines(panel) { + vec!["Your authored reply lines are highlighted below.".to_string()] + } else { + Vec::new() + } +} + +fn has_authored_reply_lines(panel: &ReplyPanelState) -> bool { + panel + .preview_lines + .iter() + .any(|line| matches!(line.kind, ReplyPreviewLineKind::Authored)) +} + +fn reply_preview_line_style(kind: ReplyPreviewLineKind) -> Style { + match kind { + ReplyPreviewLineKind::Header => Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ReplyPreviewLineKind::Blank => Style::default(), + ReplyPreviewLineKind::Authored => Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ReplyPreviewLineKind::QuoteAttribution => Style::default().fg(Color::Cyan), + ReplyPreviewLineKind::Quoted => Style::default().fg(Color::White), + ReplyPreviewLineKind::Placeholder => Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + } +} + +fn draw_reply_notice_panel(frame: &mut Frame<'_>, area: Rect, panel: &ReplyPanelState) { let Some(notice) = panel.reply_notice.as_ref() else { return; }; - let area = centered_rect(62, 34, frame.area()); frame.render_widget(Clear, area); let border = match notice.kind { @@ -1090,6 +1369,14 @@ fn draw_reply_notice_overlay(frame: &mut Frame<'_>, panel: &ReplyPanelState) { frame.render_widget(block, area); frame.render_widget(Clear, inner); + let content_sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(35), + Constraint::Min(3), + Constraint::Percentage(35), + ]) + .split(inner); let text = format!("{}\n\n{}", notice.message, notice.hint); let paragraph = Paragraph::new(Text::from(text)) .alignment(Alignment::Center) @@ -1099,7 +1386,7 @@ fn draw_reply_notice_overlay(frame: &mut Frame<'_>, panel: &ReplyPanelState) { .add_modifier(Modifier::BOLD), ) .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, inner); + frame.render_widget(paragraph, content_sections[1]); } fn code_edit_source_line_logical_row(buffer_row: usize) -> usize { @@ -1321,3 +1608,15 @@ pub(super) fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect horizontal[1] } + +#[cfg(test)] +mod tests { + use super::format_uptime_label; + + #[test] + fn uptime_label_uses_the_largest_needed_unit() { + assert_eq!(format_uptime_label(59), "59s"); + assert_eq!(format_uptime_label(61), "01m:01s"); + assert_eq!(format_uptime_label(3_661), "01h:01m:01s"); + } +} diff --git a/src/ui/tui/reply.rs b/src/ui/tui/reply.rs index 134ab6e..ac04455 100644 --- a/src/ui/tui/reply.rs +++ b/src/ui/tui/reply.rs @@ -33,10 +33,28 @@ pub(super) struct ReplySeed { pub body: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ReplyPreviewLineKind { + Header, + Blank, + Authored, + QuoteAttribution, + Quoted, + Placeholder, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ReplyPreviewLine { + pub kind: ReplyPreviewLineKind, + pub text: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct ReplyPreview { pub content: String, + pub lines: Vec, pub errors: Vec, + pub warnings: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -51,6 +69,7 @@ pub(super) struct PreparedReplyMessage { pub body: String, } +#[derive(Debug, Clone, Copy)] pub(super) struct ReplyPreviewRequest<'a> { pub from: &'a str, pub to: &'a str, @@ -157,9 +176,13 @@ pub(super) fn build_reply_seed( pub(super) fn render_reply_preview(request: ReplyPreviewRequest<'_>) -> ReplyPreview { let (prepared, errors) = prepare_reply_message(request); + let body_lines = render_reply_preview_body_lines(request.body); + let lines = render_prepared_reply_preview_lines(&prepared, &body_lines); ReplyPreview { - content: render_prepared_reply_preview(&prepared), + content: render_reply_preview_text(&lines), + lines, errors, + warnings: build_reply_preview_warnings(&body_lines), } } @@ -239,17 +262,53 @@ pub(super) fn prepare_reply_message( ) } -fn render_prepared_reply_preview(message: &PreparedReplyMessage) -> String { - format!( - "From: {}\nTo: {}\nCc: {}\nSubject: {}\nIn-Reply-To: {}\nReferences: {}\n\n{}", - message.from, - render_recipient_line(&message.to), - render_recipient_line(&message.cc), - message.subject, - render_message_id(&message.in_reply_to), - render_references_line(&message.references), - message.body, - ) +fn render_prepared_reply_preview_lines( + message: &PreparedReplyMessage, + body_lines: &[ReplyPreviewLine], +) -> Vec { + let mut lines = vec![ + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Header, + text: format!("From: {}", message.from), + }, + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Header, + text: format!("To: {}", render_recipient_line(&message.to)), + }, + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Header, + text: format!("Cc: {}", render_recipient_line(&message.cc)), + }, + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Header, + text: format!("Subject: {}", message.subject), + }, + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Header, + text: format!("In-Reply-To: {}", render_message_id(&message.in_reply_to)), + }, + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Header, + text: format!( + "References: {}", + render_references_line(&message.references) + ), + }, + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Blank, + text: String::new(), + }, + ]; + lines.extend(body_lines.iter().cloned()); + lines +} + +fn render_reply_preview_text(lines: &[ReplyPreviewLine]) -> String { + lines + .iter() + .map(|line| line.text.as_str()) + .collect::>() + .join("\n") } fn git_config_value(args: &[&str]) -> std::result::Result, String> { @@ -469,27 +528,68 @@ where fn normalize_message_id(value: &str) -> String { value .trim() - .trim_matches('<') - .trim_matches('>') - .trim_matches('"') - .trim_matches(',') + .trim_matches(|character| matches!(character, '<' | '>' | '"' | ',')) .trim() .to_string() } fn render_reply_body(body: &[String]) -> String { - let rendered = body + render_reply_preview_text(&render_reply_preview_body_lines(body)) +} + +fn render_reply_preview_body_lines(body: &[String]) -> Vec { + let normalized_body = body .iter() - .map(|line| line.trim_end()) - .collect::>() - .join("\n"); - if rendered.trim().is_empty() { - "".to_string() + .map(|line| line.trim_end().to_string()) + .collect::>(); + if normalized_body.iter().all(|line| line.trim().is_empty()) { + return vec![ReplyPreviewLine { + kind: ReplyPreviewLineKind::Placeholder, + text: "".to_string(), + }]; + } + + normalized_body + .into_iter() + .map(classify_reply_preview_line) + .collect() +} + +fn classify_reply_preview_line(text: String) -> ReplyPreviewLine { + let trimmed = text.trim(); + let kind = if trimmed.is_empty() { + ReplyPreviewLineKind::Blank + } else if trimmed.starts_with('>') { + ReplyPreviewLineKind::Quoted + } else if is_quote_attribution_line(trimmed) { + ReplyPreviewLineKind::QuoteAttribution } else { - rendered + ReplyPreviewLineKind::Authored + }; + + ReplyPreviewLine { kind, text } +} + +fn build_reply_preview_warnings(body_lines: &[ReplyPreviewLine]) -> Vec { + if body_lines + .iter() + .any(|line| matches!(line.kind, ReplyPreviewLineKind::Authored)) + { + Vec::new() + } else { + vec![ + "draft has no authored reply content; only quoted text and generated scaffolding are visible" + .to_string(), + ] } } +fn is_quote_attribution_line(value: &str) -> bool { + value + .strip_prefix("On ") + .is_some_and(|remaining| remaining.ends_with(" wrote:")) +} + fn render_recipient_line(values: &[String]) -> String { if values.is_empty() { "".to_string() @@ -621,8 +721,9 @@ mod tests { use crate::infra::mail_store::ThreadRow; use super::{ - ReplyIdentity, ReplyPreviewRequest, build_reply_seed, extract_email_address, - normalize_reply_subject, render_reply_preview, + ReplyIdentity, ReplyPreviewLineKind, ReplyPreviewRequest, build_reply_seed, + extract_email_address, normalize_reply_subject, prepare_reply_message, + render_reply_preview, }; fn sample_thread(subject: &str, message_id: &str) -> ThreadRow { @@ -703,6 +804,100 @@ mod tests { assert!(seed.cc.is_empty()); } + #[test] + fn build_reply_seed_falls_back_to_thread_metadata_and_empty_body() { + let raw = b"Message-ID: \r\n\r\n"; + let mut thread = sample_thread("[PATCH] fallback", "thread@example.com"); + thread.from_addr = "Thread Author ".to_string(); + thread.date = Some("Sat, 7 Mar 2026 12:00:00 +0000".to_string()); + + let seed = build_reply_seed(raw, &thread, &identity(), &[identity().email.clone()]); + + assert_eq!(seed.to, "Thread Author "); + assert!(seed.cc.is_empty()); + assert_eq!(seed.subject, "Re: [PATCH] fallback"); + assert_eq!(seed.in_reply_to, "patch@example.com"); + assert_eq!(seed.references, vec!["patch@example.com"]); + assert_eq!( + seed.body[1], + "On Sat, 7 Mar 2026 12:00:00 +0000, Thread Author wrote:" + ); + assert_eq!(seed.body[2], "> "); + } + + #[test] + fn build_reply_seed_handles_folded_headers_and_blank_body_lines() { + let raw = b"Message-ID: \r\nSubject: [PATCH] folded\r\nFrom: Alice \r\nTo: \"Doe, Jane\" ,\r\n Bob \r\nCc: Carol ;\r\n\tCRIEW Test \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nline one\r\n\r\nline two\r\n"; + let thread = sample_thread("[PATCH] folded", "patch@example.com"); + + let seed = build_reply_seed(raw, &thread, &identity(), &[identity().email.clone()]); + + assert_eq!( + seed.to, + "\"Doe, Jane\" , Bob " + ); + assert_eq!(seed.cc, "Carol "); + assert_eq!(seed.body[2], "> line one"); + assert_eq!(seed.body[3], ">"); + assert_eq!(seed.body[4], "> line two"); + } + + #[test] + fn prepare_reply_message_uses_parent_when_references_missing() { + let (message, errors) = prepare_reply_message(ReplyPreviewRequest { + from: "CRIEW Test ", + to: "Bob ", + cc: "", + subject: "[PATCH] demo", + in_reply_to: " , ", + references: &[], + body: &[ + "line one ".to_string(), + String::new(), + "line two".to_string(), + ], + self_addresses: &[identity().email.clone()], + }); + + assert!(errors.is_empty()); + assert_eq!(message.subject, "Re: [PATCH] demo"); + assert_eq!(message.in_reply_to, "parent@example.com"); + assert_eq!(message.references, vec!["parent@example.com"]); + assert_eq!(message.body, "line one\n\nline two"); + } + + #[test] + fn prepare_reply_message_adds_parent_to_existing_references_and_filters_self() { + let (message, errors) = prepare_reply_message(ReplyPreviewRequest { + from: " CRIEW Test ", + to: "Bob ; \"Doe, Jane\" ; CRIEW Test ", + cc: "Carol , criew@example.com", + subject: "fwd: [PATCH] demo", + in_reply_to: " , ", + references: &["".to_string()], + body: &["body".to_string()], + self_addresses: &[identity().email.clone(), "alias@example.com".to_string()], + }); + + assert!(errors.is_empty()); + assert_eq!( + message.to, + vec![ + "Bob ".to_string(), + "\"Doe, Jane\" ".to_string() + ] + ); + assert_eq!(message.cc, vec!["Carol ".to_string()]); + assert_eq!(message.subject, "Re: [PATCH] demo"); + assert_eq!( + message.references, + vec![ + "older@example.com".to_string(), + "parent@example.com".to_string() + ] + ); + } + #[test] fn preview_validation_reports_missing_recipients() { let preview = render_reply_preview(ReplyPreviewRequest { @@ -723,6 +918,12 @@ mod tests { .iter() .any(|value| value.contains("no recipients")) ); + assert!( + preview + .warnings + .iter() + .any(|value| value.contains("no authored reply content")) + ); assert!(preview.content.contains("To: ")); } @@ -747,6 +948,121 @@ mod tests { ); } + #[test] + fn render_reply_preview_reports_missing_headers_and_renders_placeholders() { + let preview = render_reply_preview(ReplyPreviewRequest { + from: "CRIEW Test", + to: "", + cc: "", + subject: " ", + in_reply_to: " ", + references: &[], + body: &[" ".to_string(), String::new()], + self_addresses: &[], + }); + + assert!( + preview + .errors + .iter() + .any(|value| value == "From is missing a valid email address") + ); + assert!( + preview + .errors + .iter() + .any(|value| value == "reply preview has no recipients after removing self") + ); + assert!( + preview + .errors + .iter() + .any(|value| value == "Subject is empty") + ); + assert!( + preview + .errors + .iter() + .any(|value| value == "In-Reply-To is missing") + ); + assert!(preview.content.contains("To: ")); + assert!(preview.content.contains("Cc: ")); + assert!(preview.content.contains("Subject: Re:")); + assert!(preview.content.contains("In-Reply-To: ")); + assert!(preview.content.contains("References: ")); + assert!(preview.content.ends_with("")); + assert!( + preview + .warnings + .iter() + .any(|value| value.contains("no authored reply content")) + ); + } + + #[test] + fn render_reply_preview_warns_when_body_only_contains_generated_quote_scaffolding() { + let preview = render_reply_preview(ReplyPreviewRequest { + from: "CRIEW Test ", + to: "Bob ", + cc: "", + subject: "Re: [PATCH] demo", + in_reply_to: "patch@example.com", + references: &["patch@example.com".to_string()], + body: &[ + String::new(), + "On Fri, 6 Mar 2026 09:30:00 +0000, Alice wrote:".to_string(), + "> quoted line".to_string(), + ">".to_string(), + ], + self_addresses: &[identity().email.clone()], + }); + + assert!(preview.errors.is_empty()); + assert_eq!(preview.warnings.len(), 1); + assert!( + preview.warnings[0].contains("no authored reply content"), + "unexpected warning: {}", + preview.warnings[0] + ); + assert!( + preview + .lines + .iter() + .any(|line| matches!(line.kind, ReplyPreviewLineKind::QuoteAttribution)) + ); + assert!( + preview + .lines + .iter() + .any(|line| matches!(line.kind, ReplyPreviewLineKind::Quoted)) + ); + } + + #[test] + fn render_reply_preview_marks_authored_reply_lines() { + let preview = render_reply_preview(ReplyPreviewRequest { + from: "CRIEW Test ", + to: "Bob ", + cc: "", + subject: "Re: [PATCH] demo", + in_reply_to: "patch@example.com", + references: &["patch@example.com".to_string()], + body: &[ + "Looks good to me.".to_string(), + String::new(), + "On Fri, 6 Mar 2026 09:30:00 +0000, Alice wrote:".to_string(), + "> quoted line".to_string(), + ], + self_addresses: &[identity().email.clone()], + }); + + assert!(preview.errors.is_empty()); + assert!(preview.warnings.is_empty()); + assert!(preview.lines.iter().any(|line| { + matches!(line.kind, ReplyPreviewLineKind::Authored) && line.text == "Looks good to me." + })); + } + #[test] fn extracts_email_from_display_or_bare_value() { assert_eq!( @@ -757,5 +1073,7 @@ mod tests { extract_email_address("alice@example.com"), Some("alice@example.com".to_string()) ); + assert_eq!(extract_email_address("Alice Example"), None); + assert_eq!(extract_email_address("<>"), None); } } diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 2451d2c..624c5c4 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -13,29 +13,32 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::layout::{Constraint, Direction, Layout, Position, Rect}; +use ratatui::style::{Color, Modifier}; use crate::domain::subscriptions::SubscriptionCategory; use crate::infra::bootstrap::BootstrapState; -use crate::infra::config::{IMAP_INBOX_MAILBOX, RuntimeConfig}; +use crate::infra::config::{IMAP_INBOX_MAILBOX, RuntimeConfig, UiKeymap}; use crate::infra::db; use crate::infra::db::DatabaseState; use crate::infra::mail_parser; use crate::infra::mail_store::{self, IncomingMail, SyncBatch, ThreadRow}; use crate::infra::reply_store::{self, ReplySendStatus}; use crate::infra::sendmail::{SendOutcome, SendRequest, SendStatus}; -use crate::infra::ui_state::UiState; +use crate::infra::ui_state::{self, UiState}; use super::palette::run_palette_sync; use super::preview::preview_warning_message; use super::reply::ReplyIdentity; use super::{ - AppState, CodeEditMode, CodePaneFocus, ExternalEditorProcessResult, LoopAction, MY_INBOX_LABEL, - Pane, ReplyEditMode, ReplySection, StartupSyncEvent, StartupSyncMailboxStatus, - StartupSyncState, SubscriptionItem, UiPage, catch_sync_panic, code_edit_cursor_position, draw, - extract_mail_body_preview, extract_mail_preview, handle_key_event, is_palette_open_shortcut, - is_palette_toggle, load_source_file_preview, mail_page_panes, matching_commands, - pick_external_editor, resolve_palette_local_workdir, run_external_editor_session_with, - sanitize_inline_ui_text, subscription_line, thread_line, + AppState, CodeEditMode, CodePaneFocus, ExternalEditorProcessResult, LoopAction, + MIN_MAIL_PREVIEW_WIDTH, MIN_MAIL_SUBSCRIPTIONS_WIDTH, MY_INBOX_LABEL, MailPaneLayout, + ManualSyncOrigin, ManualSyncRequestOutcome, ManualSyncState, Pane, ReplyEditMode, ReplySection, + StartupSyncEvent, StartupSyncMailboxStatus, StartupSyncState, SubscriptionItem, UiPage, + catch_sync_panic, code_edit_cursor_position, draw, extract_mail_body_preview, + extract_mail_preview, handle_key_event, is_palette_open_shortcut, is_palette_toggle, + load_source_file_preview, mail_page_panes, matching_commands, pick_external_editor, + resolve_palette_local_workdir, run_external_editor_session_with, sanitize_inline_ui_text, + subscription_line, thread_line, }; fn temp_dir(label: &str) -> PathBuf { @@ -81,6 +84,16 @@ fn sample_thread_with_raw( } } +fn sample_threads(count: usize) -> Vec { + (0..count) + .map(|index| { + let subject = format!("t{index}"); + let message_id = format!("{index}@example.com"); + sample_thread(&subject, &message_id, index as u16) + }) + .collect() +} + fn sample_thread_in_thread( thread_id: i64, mail_id: i64, @@ -101,6 +114,35 @@ fn sample_thread_in_thread( } } +fn rendered_row_text(terminal: &Terminal, row: u16) -> String { + let buffer = terminal.backend().buffer(); + let width = buffer.area().width as usize; + let start = row as usize * width; + let end = start + width; + buffer.content()[start..end] + .iter() + .map(|cell| cell.symbol()) + .collect::() +} + +fn rendered_cell_style_for_substring( + terminal: &Terminal, + needle: &str, +) -> Option<(Color, Color, Modifier)> { + let buffer = terminal.backend().buffer(); + let width = buffer.area().width as usize; + + for row in 0..buffer.area().height { + let row_text = rendered_row_text(terminal, row); + if let Some(column) = row_text.find(needle) { + let cell = &buffer.content()[row as usize * width + column]; + return Some((cell.fg, cell.bg, cell.modifier)); + } + } + + None +} + fn test_runtime_in(root: PathBuf) -> RuntimeConfig { RuntimeConfig { config_path: root.join("config.toml"), @@ -115,6 +157,9 @@ fn test_runtime_in(root: PathBuf) -> RuntimeConfig { imap: crate::infra::config::ImapConfig::default(), lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), } @@ -220,6 +265,43 @@ fn startup_sync_state(mailboxes: &[(&str, StartupSyncMailboxStatus)]) -> Startup } } +fn manual_sync_state(mailboxes: &[(&str, StartupSyncMailboxStatus)]) -> ManualSyncState { + let (_sender, receiver) = mpsc::channel(); + ManualSyncState { + receiver, + mailbox_order: mailboxes + .iter() + .map(|(mailbox, _)| (*mailbox).to_string()) + .collect(), + mailboxes: mailboxes + .iter() + .map(|(mailbox, status)| ((*mailbox).to_string(), *status)) + .collect(), + total: mailboxes.len(), + completed: mailboxes + .iter() + .filter(|(_, status)| { + matches!( + status, + StartupSyncMailboxStatus::Finished | StartupSyncMailboxStatus::Failed + ) + }) + .count(), + succeeded: mailboxes + .iter() + .filter(|(_, status)| matches!(status, StartupSyncMailboxStatus::Finished)) + .count(), + failed: mailboxes + .iter() + .filter(|(_, status)| matches!(status, StartupSyncMailboxStatus::Failed)) + .count(), + total_fetched: 0, + total_inserted: 0, + total_updated: 0, + first_error: None, + } +} + fn test_bootstrap(runtime: &RuntimeConfig) -> BootstrapState { BootstrapState { db: DatabaseState { @@ -306,6 +388,31 @@ fn inbox_auto_sync_waits_for_startup_sync_to_finish() { ); } +#[test] +fn inbox_auto_sync_waits_for_manual_sync_to_finish() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.mailbox_sync_spawner = mailbox_sync_spawner_stub; + state.manual_sync = Some(manual_sync_state(&[( + IMAP_INBOX_MAILBOX, + StartupSyncMailboxStatus::InFlight, + )])); + state + .inbox_auto_sync + .as_mut() + .expect("inbox auto-sync state") + .next_due_at = Instant::now() - Duration::from_secs(1); + + state.maybe_start_inbox_auto_sync(); + + assert!( + state + .inbox_auto_sync + .as_ref() + .and_then(|sync| sync.receiver.as_ref()) + .is_none() + ); +} + #[test] fn subscription_auto_sync_starts_when_due_for_enabled_linux_subscription() { let mut state = AppState::new(vec![], test_runtime()); @@ -366,6 +473,38 @@ fn subscription_auto_sync_waits_for_startup_sync_to_finish() { ); } +#[test] +fn subscription_auto_sync_waits_for_manual_sync_to_finish() { + let mut state = AppState::new(vec![], test_runtime()); + state.mailbox_sync_spawner = mailbox_sync_spawner_stub; + let qemu_devel_index = state + .subscriptions + .iter() + .position(|item| item.mailbox == "qemu-devel") + .expect("qemu-devel subscription exists"); + state.subscriptions[qemu_devel_index].enabled = true; + state.reconcile_subscription_auto_sync(); + state.manual_sync = Some(manual_sync_state(&[( + "qemu-devel", + StartupSyncMailboxStatus::InFlight, + )])); + state + .subscription_auto_sync + .as_mut() + .expect("subscription auto-sync state") + .next_due_at = Instant::now() - Duration::from_secs(1); + + state.maybe_start_subscription_auto_sync(); + + assert!( + state + .subscription_auto_sync + .as_ref() + .and_then(|sync| sync.receiver.as_ref()) + .is_none() + ); +} + fn external_editor_mock_success( _editor: &str, file_path: &Path, @@ -426,26 +565,15 @@ fn reply_send_mock_failure(_runtime: &RuntimeConfig, _request: &SendRequest) -> } } -fn sync_request_mock_success( - _runtime: &RuntimeConfig, - request: crate::app::sync::SyncRequest, -) -> crate::infra::error::Result { - Ok(crate::app::sync::SyncSummary { - mailbox: request.mailbox, - source: "mock".to_string(), - fetched: 1, - inserted: 1, - updated: 0, - rebuilt_roots: 0, - mailbox_rebuilt: false, - uidvalidity: 1, - checkpoint_last_seen_uid: 1, - checkpoint_highest_modseq: Some(1), - checkpoint_synced_at: Some("2026-03-07T10:00:02Z".to_string()), - }) +fn mailbox_sync_spawner_stub( + _runtime: RuntimeConfig, + _mailboxes: Vec, +) -> mpsc::Receiver { + let (_sender, receiver) = mpsc::channel(); + receiver } -fn mailbox_sync_spawner_stub( +fn manual_sync_spawner_idle( _runtime: RuntimeConfig, _mailboxes: Vec, ) -> mpsc::Receiver { @@ -453,17 +581,65 @@ fn mailbox_sync_spawner_stub( receiver } +fn manual_sync_spawner_seed_success( + runtime: RuntimeConfig, + mailboxes: Vec, +) -> mpsc::Receiver { + let (sender, receiver) = mpsc::channel(); + let total = mailboxes.len(); + + for (index, mailbox) in mailboxes.into_iter().enumerate() { + sender + .send(StartupSyncEvent::MailboxStarted { + mailbox: mailbox.clone(), + index: index + 1, + total, + }) + .expect("send mailbox started"); + seed_mailbox_thread( + &runtime.database_path, + &mailbox, + index as u32 + 1, + &format!("{mailbox}-{index}@example.com"), + &format!("{mailbox} thread"), + ); + sender + .send(StartupSyncEvent::MailboxFinished { + mailbox, + fetched: 1, + inserted: 1, + updated: 0, + }) + .expect("send mailbox finished"); + } + sender + .send(StartupSyncEvent::WorkerCompleted) + .expect("send worker completed"); + + receiver +} + +fn type_text(state: &mut AppState, text: &str) { + for character in text.chars() { + let _ = handle_key_event( + state, + KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE), + ); + } +} + #[test] fn empty_query_returns_all_palette_commands() { let all = matching_commands(""); - assert_eq!(all.len(), 7); + assert_eq!(all.len(), 8); assert_eq!(all[0].name, "config"); assert_eq!(all[1].name, "exit"); assert_eq!(all[2].name, "help"); - assert_eq!(all[3].name, "quit"); - assert_eq!(all[4].name, "restart"); - assert_eq!(all[5].name, "sync"); - assert_eq!(all[6].name, "vim"); + assert_eq!(all[3].name, "keymap"); + assert_eq!(all[4].name, "quit"); + assert_eq!(all[5].name, "restart"); + assert_eq!(all[6].name, "sync"); + assert_eq!(all[7].name, "vim"); } #[test] @@ -557,7 +733,7 @@ fn external_editor_session_restores_terminal_after_editor_exit() { #[test] fn mail_page_layout_keeps_preview_at_fixed_90_columns() { - let panes = mail_page_panes(Rect::new(0, 0, 180, 20)); + let panes = mail_page_panes(Rect::new(0, 0, 180, 20), MailPaneLayout::default()); assert_eq!(panes[2].width, 90); assert_eq!(panes[2].x, 90); @@ -568,13 +744,28 @@ fn mail_page_layout_keeps_preview_at_fixed_90_columns() { #[test] fn mail_page_layout_falls_back_to_available_width_when_terminal_is_narrow() { - let panes = mail_page_panes(Rect::new(0, 0, 60, 20)); + let panes = mail_page_panes(Rect::new(0, 0, 60, 20), MailPaneLayout::default()); assert_eq!(panes[2].width, 60); assert_eq!(panes[0].width, 0); assert_eq!(panes[1].width, 0); } +#[test] +fn mail_page_layout_uses_persisted_fixed_mail_pane_widths() { + let panes = mail_page_panes( + Rect::new(0, 0, 180, 20), + MailPaneLayout { + subscriptions_width: 31, + preview_width: 84, + }, + ); + + assert_eq!(panes[0].width, 31); + assert_eq!(panes[1].width, 65); + assert_eq!(panes[2].width, 84); +} + #[test] fn subscription_line_shows_marker_and_mailbox_name_only() { let enabled = SubscriptionItem { @@ -679,12 +870,48 @@ fn command_palette_help_includes_keyboard_shortcuts() { assert!(state.status.contains("keys:")); assert!(state.status.contains("j/l focus")); assert!(state.status.contains("i/k move")); + assert!(state.status.contains("[ ] expand pane")); + assert!(state.status.contains("{ } shrink pane")); + assert!(state.status.contains("-/= preview switch")); assert!(state.status.contains("y/n enable")); assert!(state.status.contains("a apply")); assert!(state.status.contains("d download")); assert!(state.status.contains("u undo apply")); } +#[test] +fn command_palette_help_uses_vim_keymap_labels() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new(vec![], runtime); + state.palette.open = true; + state.palette.input = "help".to_string(); + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(action, LoopAction::Continue)); + assert!(state.status.contains("h/l focus")); + assert!(state.status.contains("j/k move")); +} + +#[test] +fn keymap_palette_command_opens_keymap_editor() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = "keymap".to_string(); + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(action, LoopAction::Continue)); + assert!(state.keymap_editor.open); + assert!(!state.palette.open); + assert_eq!(state.status, "keymap editor opened"); +} + #[test] fn config_palette_get_and_set_roundtrip() { let root = temp_dir("palette-config"); @@ -735,1977 +962,4516 @@ mailbox = "inbox" } #[test] -fn config_command_opens_visual_editor() { - let mut state = AppState::new(vec![], test_runtime()); - state.palette.open = true; - state.palette.input = "config".to_string(); - - let action = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ); - - assert!(matches!(action, LoopAction::Continue)); - assert!(state.config_editor.open); - assert!(!state.palette.open); - assert_eq!(state.selected_config_editor_field().key, "source.mailbox"); -} - -#[test] -fn config_editor_saves_selected_value() { - let root = temp_dir("config-editor-save"); +fn config_palette_set_keymap_updates_navigation_immediately() { + let root = temp_dir("palette-keymap"); let config_path = root.join("criew-config.toml"); fs::write( &config_path, r#" -[source] -mailbox = "inbox" +[ui] +keymap = "default" "#, ) .expect("write config file"); let mut runtime = test_runtime(); runtime.config_path = config_path.clone(); - let mut state = AppState::new(vec![], runtime); - - state.open_config_editor(Some("source.mailbox")); - state.start_config_editor_edit(); - state.config_editor.input = "io-uring".to_string(); + let stale_runtime = runtime.clone(); + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + runtime, + ); + state.palette.open = true; + state.palette.input = "config set ui.keymap vim".to_string(); let _ = handle_key_event( &mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - assert_eq!(state.runtime.source_mailbox, "io-uring"); - assert!(!state.config_editor.open || state.config_editor.input.is_empty()); + assert_eq!(state.runtime.ui_keymap, UiKeymap::Vim); let persisted = fs::read_to_string(&config_path).expect("read config file"); - assert!(persisted.contains("mailbox = \"io-uring\"")); + assert!(persisted.contains("keymap = \"vim\"")); + state.palette.open = false; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Threads)); + assert_eq!(state.thread_index, 1); + + let bootstrap = test_bootstrap(&stale_runtime); + let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &stale_runtime, &bootstrap)) + .expect("draw updated keymap footer"); + let rendered = format!("{}", terminal.backend()); + assert!(rendered.contains("h/l focus | j/k move")); + assert!(!rendered.contains("j/l focus | i/k move")); + assert!(!rendered.contains("gg/G jump")); + assert!(!rendered.contains("qq quit")); let _ = fs::remove_dir_all(root); } #[test] -fn config_editor_tab_cycles_boolean_presets() { - let root = temp_dir("config-editor-toggle"); +fn keymap_editor_custom_binding_updates_navigation_immediately() { + let root = temp_dir("keymap-editor-custom"); let config_path = root.join("criew-config.toml"); fs::write( &config_path, r#" [ui] -startup_sync = true +keymap = "custom" +keymap_base = "default" "#, ) .expect("write config file"); let mut runtime = test_runtime(); runtime.config_path = config_path.clone(); - let mut state = AppState::new(vec![], runtime); + runtime.ui_keymap = UiKeymap::Custom; + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + runtime, + ); - state.open_config_editor(Some("ui.startup_sync")); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + state.palette.open = true; + state.palette.input = "keymap".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(state.keymap_editor.open); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!( + state.keymap_editor.mode, + super::keymap::KeymapEditorMode::Capture + )); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(matches!( + state.keymap_editor.mode, + super::keymap::KeymapEditorMode::Browse + )); + assert_eq!( + state.runtime.ui_custom_keymap.focus_prev, + Some(vec!["h".to_string()]) + ); - assert!(!state.runtime.startup_sync); let persisted = fs::read_to_string(&config_path).expect("read config file"); - assert!(persisted.contains("startup_sync = false")); + assert!(persisted.contains("focus_prev = [\"h\"]")); + + state.keymap_editor.open = false; + state.focus = Pane::Threads; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Subscriptions)); + + state.focus = Pane::Threads; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Threads)); let _ = fs::remove_dir_all(root); } #[test] -fn config_editor_saves_inbox_auto_sync_interval() { - let root = temp_dir("config-editor-auto-sync-interval"); +fn custom_bindings_do_not_leak_into_default_or_vim_schemes() { + let root = temp_dir("keymap-scheme-isolation"); let config_path = root.join("criew-config.toml"); fs::write( &config_path, r#" [ui] -inbox_auto_sync_interval_secs = 30 +keymap = "default" +keymap_base = "default" + +[ui.custom_keymap] +focus_prev = ["x"] "#, ) .expect("write config file"); - let mut runtime = test_runtime_with_imap(); + let mut runtime = test_runtime(); runtime.config_path = config_path.clone(); - let mut state = AppState::new(vec![], runtime); - - state.open_config_editor(Some("ui.inbox_auto_sync_interval_secs")); - state.start_config_editor_edit(); - state.config_editor.input = "45".to_string(); - + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + runtime, + ); + state.palette.open = true; + state.palette.input = "keymap".to_string(); let _ = handle_key_event( &mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); + assert!(state.keymap_editor.open); - assert_eq!(state.runtime.inbox_auto_sync_interval_secs, 45); - let persisted = fs::read_to_string(&config_path).expect("read config file"); - assert!(persisted.contains("inbox_auto_sync_interval_secs = 45")); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + assert_eq!(state.runtime.ui_keymap, UiKeymap::Vim); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - let _ = fs::remove_dir_all(root); -} + state.focus = Pane::Threads; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Threads)); -#[test] -fn config_editor_can_unset_optional_key() { - let root = temp_dir("config-editor-unset"); - let config_path = root.join("criew-config.toml"); - fs::write( - &config_path, - r#" -[b4] -path = "/usr/bin/b4" -"#, - ) - .expect("write config file"); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Subscriptions)); - let mut runtime = test_runtime(); - runtime.config_path = config_path.clone(); - let mut state = AppState::new(vec![], runtime); + state.palette.open = true; + state.palette.input = "keymap".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(state.keymap_editor.open); - state.open_config_editor(Some("b4.path")); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + assert_eq!(state.runtime.ui_keymap, UiKeymap::Custom); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + assert_eq!(state.runtime.ui_keymap, UiKeymap::Default); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + state.focus = Pane::Threads; let _ = handle_key_event( &mut state, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), ); + assert!(matches!(state.focus, Pane::Threads)); - assert!(state.runtime.b4_path.is_none()); - let persisted = fs::read_to_string(&config_path).expect("read config file"); - assert!(!persisted.contains("path = ")); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Subscriptions)); let _ = fs::remove_dir_all(root); } #[test] -fn config_editor_rejects_invalid_runtime_value_without_writing_file() { - let root = temp_dir("config-editor-invalid"); +fn keymap_editor_lists_active_bindings_for_selected_scheme() { + let root = temp_dir("keymap-editor-active-bindings"); let config_path = root.join("criew-config.toml"); fs::write( &config_path, r#" [ui] -startup_sync = true +keymap = "default" +keymap_base = "default" + +[ui.custom_keymap] +focus_prev = ["x"] "#, ) .expect("write config file"); let mut runtime = test_runtime(); - runtime.config_path = config_path.clone(); - let mut state = AppState::new(vec![], runtime); + runtime.config_path = config_path; + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); + state.open_keymap_editor(); + state.keymap_editor.selected_field = 2; - state.open_config_editor(Some("ui.startup_sync")); - state.start_config_editor_edit(); - state.config_editor.input = "maybe".to_string(); + let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw default keymap editor"); + let rendered_default = format!("{}", terminal.backend()); + assert!(rendered_default.contains("j [default]")); + state.keymap_editor.selected_field = 0; let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ); - - assert!( - state - .status - .contains("failed to set config key ui.startup_sync") + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), ); - assert!(state.runtime.startup_sync); - let persisted = fs::read_to_string(&config_path).expect("read config file"); - assert!(persisted.contains("startup_sync = true")); - assert!(!persisted.contains("maybe")); + state.keymap_editor.selected_field = 2; + let runtime_vim = state.runtime.clone(); + let bootstrap_vim = test_bootstrap(&runtime_vim); + terminal + .draw(|frame| draw(frame, &state, &runtime_vim, &bootstrap_vim)) + .expect("draw vim keymap editor"); + let rendered_vim = format!("{}", terminal.backend()); + assert!(rendered_vim.contains("h [vim]")); let _ = fs::remove_dir_all(root); } #[test] -fn config_editor_rejects_zero_inbox_auto_sync_interval_without_writing_file() { - let root = temp_dir("config-editor-zero-auto-sync-interval"); +fn config_get_ui_keymap_returns_current_value() { + let root = temp_dir("get-keymap"); let config_path = root.join("criew-config.toml"); fs::write( &config_path, r#" [ui] -inbox_auto_sync_interval_secs = 30 +keymap = "vim" "#, ) .expect("write config file"); - let mut runtime = test_runtime_with_imap(); + let mut runtime = test_runtime(); runtime.config_path = config_path.clone(); + runtime.ui_keymap = UiKeymap::Vim; let mut state = AppState::new(vec![], runtime); - state.open_config_editor(Some("ui.inbox_auto_sync_interval_secs")); - state.start_config_editor_edit(); - state.config_editor.input = "0".to_string(); - + state.palette.open = true; + state.palette.input = "config get ui.keymap".to_string(); let _ = handle_key_event( &mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - assert!( - state - .status - .contains("failed to set config key ui.inbox_auto_sync_interval_secs") + state.status.contains("vim"), + "config get should report vim, got: {}", + state.status ); - assert_eq!(state.runtime.inbox_auto_sync_interval_secs, 30); - let persisted = fs::read_to_string(&config_path).expect("read config file"); - assert!(persisted.contains("inbox_auto_sync_interval_secs = 30")); let _ = fs::remove_dir_all(root); } #[test] -fn config_palette_set_does_not_overwrite_scalar_parent_keys() { - let root = temp_dir("config-palette-scalar-parent"); - let config_path = root.join("criew-config.toml"); - fs::write(&config_path, "source = \"broken\"\n").expect("write config file"); - +fn loaded_vim_keymap_drives_navigation_keys() { let mut runtime = test_runtime(); - runtime.config_path = config_path.clone(); - let mut state = AppState::new(vec![], runtime); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + runtime, + ); + state.subscription_index = 1; - state.palette.open = true; - state.palette.input = "config set source.mailbox io-uring".to_string(); + // Default keymap key 'j' would move focus left; in vim mode it moves down. let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), ); + assert!(matches!(state.focus, Pane::Threads)); - assert!(state.status.contains("cannot set source.mailbox")); - assert_eq!(state.runtime.source_mailbox, "inbox"); - let persisted = fs::read_to_string(&config_path).expect("read config file"); - assert_eq!(persisted, "source = \"broken\"\n"); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert_eq!(state.thread_index, 1, "j should move down in vim keymap"); - let _ = fs::remove_dir_all(root); + // 'i' should NOT navigate (it is not a vim navigation key). + let prev_index = state.thread_index; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + assert_eq!( + state.thread_index, prev_index, + "i should not navigate in vim keymap" + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + ); + assert!( + matches!(state.focus, Pane::Subscriptions), + "h should move focus left in vim keymap" + ); } #[test] -fn config_editor_overlay_is_rendered() { - let runtime = test_runtime(); - let bootstrap = test_bootstrap(&runtime); - let mut state = AppState::new(vec![], runtime.clone()); - state.open_config_editor(Some("source.mailbox")); +fn default_keymap_supports_counted_ik_navigation() { + let mut state = AppState::new(sample_threads(15), test_runtime()); - let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal"); - terminal - .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) - .expect("draw config editor"); - let rendered = format!("{}", terminal.backend()); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Threads)); - assert!(rendered.contains("Runtime Config")); - assert!(rendered.contains("source.mailbox")); - assert!(rendered.contains("Selected Field")); -} + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE), + ); + let action_down = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert!(matches!(action_down, LoopAction::Continue)); + assert_eq!(state.thread_index, 12); -#[test] -fn ctrl_backtick_toggles_command_palette() { - let key = KeyEvent::new(KeyCode::Char('`'), KeyModifiers::CONTROL); - assert!(is_palette_toggle(key)); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE), + ); + let action_up = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + assert!(matches!(action_up, LoopAction::Continue)); + assert_eq!(state.thread_index, 7); } #[test] -fn colon_opens_command_palette() { - let mut state = AppState::new(vec![], test_runtime()); +fn vim_keymap_supports_counted_jk_navigation() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new(sample_threads(15), runtime); - let key = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT); - assert!(is_palette_open_shortcut(key)); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Threads)); - let action = handle_key_event(&mut state, key); - assert!(matches!(action, LoopAction::Continue)); - assert!(state.palette.open); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE), + ); + let action_down = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(action_down, LoopAction::Continue)); + assert_eq!(state.thread_index, 12); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE), + ); + let action_up = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert!(matches!(action_up, LoopAction::Continue)); + assert_eq!(state.thread_index, 7); } #[test] -fn palette_tab_completes_top_level_command() { - let mut state = AppState::new(vec![], test_runtime()); - state.palette.open = true; - state.palette.input = "co".to_string(); +fn counted_main_page_navigation_does_not_leak_into_focus_changes() { + let mut state = AppState::new(sample_threads(4), test_runtime()); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE), + ); + let focus_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + assert!(matches!(focus_action, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Threads)); - assert_eq!(state.palette.input, "config "); + let move_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert!(matches!(move_action, LoopAction::Continue)); + assert_eq!(state.thread_index, 1); } #[test] -fn palette_tab_completes_config_subcommand_and_key() { - let mut state = AppState::new(vec![], test_runtime()); - state.palette.open = true; - state.palette.input = "config g".to_string(); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(state.palette.input, "config get "); - - state.palette.input = "config get source.m".to_string(); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(state.palette.input, "config get source.mailbox "); -} +fn preview_focus_supports_minus_equals_shifted_equals_and_plus_thread_navigation() { + let mut state = AppState::new(sample_threads(4), test_runtime()); -#[test] -fn palette_tab_completes_sync_mailbox() { - let mut state = AppState::new(vec![], test_runtime()); - state.palette.open = true; - state.palette.input = "sync bp".to_string(); - - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Preview)); + assert_eq!(state.thread_index, 1); - assert_eq!(state.palette.input, "sync bpf "); -} + state.preview_scroll = 7; + let action_previous = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE), + ); + assert!(matches!(action_previous, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Preview)); + assert_eq!(state.thread_index, 0); + assert_eq!(state.preview_scroll, 0); -#[test] -fn palette_double_tab_lists_config_arguments() { - let mut state = AppState::new(vec![], test_runtime()); - state.palette.open = true; - state.palette.input = "config".to_string(); + state.preview_scroll = 5; + let action_next_with_equals = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('='), KeyModifiers::NONE), + ); + assert!(matches!(action_next_with_equals, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Preview)); + assert_eq!(state.thread_index, 1); + assert_eq!(state.preview_scroll, 0); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(state.palette.input, "config "); - assert!(!state.palette.show_suggestions); + state.preview_scroll = 5; + let action_next_with_shifted_equals = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('='), KeyModifiers::SHIFT), + ); + assert!(matches!( + action_next_with_shifted_equals, + LoopAction::Continue + )); + assert!(matches!(state.focus, Pane::Preview)); + assert_eq!(state.thread_index, 2); + assert_eq!(state.preview_scroll, 0); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert!(state.palette.show_suggestions); - let values: Vec = state - .palette - .suggestions - .iter() - .map(|item| item.value.clone()) - .collect(); - assert!(values.contains(&"show".to_string())); - assert!(values.contains(&"get".to_string())); - assert!(values.contains(&"set".to_string())); - assert!(values.contains(&"help".to_string())); + state.preview_scroll = 5; + let action_next_with_plus = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('+'), KeyModifiers::SHIFT), + ); + assert!(matches!(action_next_with_plus, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Preview)); + assert_eq!(state.thread_index, 3); + assert_eq!(state.preview_scroll, 0); } #[test] -fn palette_tab_completes_local_command_path() { - let tree_root = temp_dir("palette-bang-complete"); - fs::write(tree_root.join("echo-local"), "#!/bin/sh\n").expect("write executable"); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); +fn resize_shortcuts_follow_the_focused_mail_pane_and_persist_layout() { + let root = temp_dir("mail-pane-resize"); + let runtime = test_runtime_in(root.clone()); let mut state = AppState::new(vec![], runtime); - state.palette.open = true; - state.palette.input = "!./ec".to_string(); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + let expand_subscriptions = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE), + ); + assert!(matches!(expand_subscriptions, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.subscriptions_width, 27); + assert_eq!(state.mail_pane_layout.preview_width, 90); - assert_eq!(state.palette.input, "!./echo-local "); + let shrink_subscriptions = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('}'), KeyModifiers::SHIFT), + ); + assert!(matches!(shrink_subscriptions, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.subscriptions_width, 23); - let _ = fs::remove_dir_all(tree_root); -} + state.focus = Pane::Threads; + let expand_threads_left = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE), + ); + assert!(matches!(expand_threads_left, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.subscriptions_width, 19); -#[test] -fn local_command_mode_uses_kernel_tree_as_workdir() { - let tree_root = temp_dir("palette-bang-kernel-tree"); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); - let state = AppState::new(vec![], runtime); + let shrink_threads_left = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('{'), KeyModifiers::SHIFT), + ); + assert!(matches!(shrink_threads_left, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.subscriptions_width, 23); - let workdir = resolve_palette_local_workdir(&state).expect("resolve local workdir"); - assert_eq!(workdir, tree_root); + let expand_threads_right = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE), + ); + assert!(matches!(expand_threads_right, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.preview_width, 86); - let _ = fs::remove_dir_all(workdir); -} + let shrink_threads_right = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('}'), KeyModifiers::SHIFT), + ); + assert!(matches!(shrink_threads_right, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.preview_width, 90); -#[test] -fn local_command_mode_falls_back_to_home_workdir() { - let state = AppState::new(vec![], test_runtime()); - let resolved = resolve_palette_local_workdir(&state); - match std::env::var("HOME") { - Ok(home) => assert_eq!(resolved.expect("resolve home"), PathBuf::from(home)), - Err(_) => assert!(resolved.is_err()), - } + state.focus = Pane::Preview; + let expand_preview = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE), + ); + assert!(matches!(expand_preview, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.preview_width, 94); + + let shrink_preview = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('{'), KeyModifiers::SHIFT), + ); + assert!(matches!(shrink_preview, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.preview_width, 90); + + let persisted = ui_state::load(&state.ui_state_path) + .expect("load persisted ui state") + .expect("ui state exists"); + assert_eq!(persisted.mail_subscriptions_width, 23); + assert_eq!(persisted.mail_preview_width, 90); + + let _ = fs::remove_dir_all(root); } #[test] -fn palette_bang_executes_local_command() { - let tree_root = temp_dir("palette-bang-exec"); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); - let mut state = AppState::new(vec![], runtime); - state.palette.open = true; - state.palette.input = "!pwd".to_string(); +fn resize_shortcuts_stop_at_fixed_edges_and_minimum_mail_pane_widths() { + let mut state = AppState::new(vec![], test_runtime()); - let _ = handle_key_event( + let fixed_edge_action = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE), ); - - assert!(state.status.contains("local command ok")); - assert!(state.status.contains(&tree_root.display().to_string())); - let local_result = state - .palette - .last_local_result - .as_ref() - .expect("local result should exist"); - assert_eq!(local_result.command, "pwd"); - assert!( - local_result - .output - .contains(&tree_root.display().to_string()) + assert!(matches!(fixed_edge_action, LoopAction::Continue)); + assert_eq!( + state.mail_pane_layout.subscriptions_width, + ui_state::DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH ); + assert_eq!(state.status, "mail pane cannot expand in that direction"); - let _ = fs::remove_dir_all(tree_root); -} + state.mail_pane_layout.subscriptions_width = MIN_MAIL_SUBSCRIPTIONS_WIDTH; -#[test] -fn command_palette_renders_local_command_result() { - let tree_root = temp_dir("palette-bang-render"); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); - let bootstrap = test_bootstrap(&runtime); - let mut state = AppState::new(vec![], runtime.clone()); - state.palette.open = true; - state.palette.input = "!pwd".to_string(); + let min_subscriptions_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('}'), KeyModifiers::SHIFT), + ); + assert!(matches!(min_subscriptions_action, LoopAction::Continue)); + assert_eq!( + state.mail_pane_layout.subscriptions_width, + MIN_MAIL_SUBSCRIPTIONS_WIDTH + ); + assert_eq!(state.status, "mail pane cannot shrink in that direction"); - let _ = handle_key_event( + state.focus = Pane::Preview; + let preview_fixed_edge_action = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE), ); + assert!(matches!(preview_fixed_edge_action, LoopAction::Continue)); + assert_eq!(state.status, "mail pane cannot expand in that direction"); - let mut terminal = Terminal::new(TestBackend::new(120, 30)).expect("create test terminal"); - terminal - .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) - .expect("draw frame"); - let rendered = format!("{}", terminal.backend()); - assert!(rendered.contains("Local Result")); - assert!(rendered.contains("!pwd")); + state.mail_pane_layout.preview_width = MIN_MAIL_PREVIEW_WIDTH; - let _ = fs::remove_dir_all(tree_root); + let min_preview_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('{'), KeyModifiers::SHIFT), + ); + assert!(matches!(min_preview_action, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.preview_width, MIN_MAIL_PREVIEW_WIDTH); + assert_eq!(state.status, "mail pane cannot shrink in that direction"); } #[test] -fn tab_toggles_between_mail_page_and_code_browser_page() { - let tree_root = temp_dir("kernel-tree-tab"); - fs::create_dir_all(tree_root.join("io_uring")).expect("create kernel dir"); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); - let mut state = AppState::new(vec![], runtime); +fn vim_keymap_supports_gg_and_capital_g_jumps_on_mail_panes() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + sample_thread("t2", "c@example.com", 2), + ], + runtime, + ); - assert!(matches!(state.ui_page, UiPage::Mail)); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert!(matches!(state.ui_page, UiPage::CodeBrowser)); + let subscription_rows = state.subscription_rows(); + assert!(!subscription_rows.is_empty()); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert!(matches!(state.ui_page, UiPage::Mail)); + let action_bottom = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert!(matches!(action_bottom, LoopAction::Continue)); + assert_eq!( + state.subscription_row_index, + subscription_rows.len().saturating_sub(1) + ); - let _ = fs::remove_dir_all(tree_root); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + let action_top = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(action_top, LoopAction::Continue)); + assert_eq!(state.subscription_row_index, 0); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + let action_thread_bottom = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert!(matches!(action_thread_bottom, LoopAction::Continue)); + assert_eq!(state.thread_index, 2); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + let action_thread_top = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(action_thread_top, LoopAction::Continue)); + assert_eq!(state.thread_index, 0); + + state.focus = Pane::Preview; + state.preview_scroll = 7; + let action_preview_bottom = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert!(matches!(action_preview_bottom, LoopAction::Continue)); + assert_eq!(state.preview_scroll, u16::MAX); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + let action_preview_top = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(action_preview_top, LoopAction::Continue)); + assert_eq!(state.preview_scroll, 0); } #[test] -fn kernel_tree_enter_expands_and_collapses_selected_directory() { - let tree_root = temp_dir("kernel-tree-expand"); - let dir_a = tree_root.join("a"); - let dir_b = dir_a.join("b"); - let dir_c = tree_root.join("c"); - fs::create_dir_all(&dir_b).expect("create nested directory"); - fs::create_dir_all(&dir_c).expect("create sibling directory"); +fn vim_keymap_supports_gg_and_capital_g_jumps_in_code_browser() { + let tree_root = temp_dir("vim-jump-code-browser"); + let file_path = tree_root.join("demo.c"); + let source = (1..=40) + .map(|line| format!("int line_{line} = {line};")) + .collect::>() + .join("\n"); + fs::write(&file_path, format!("{source}\n")).expect("write file"); + + let mut runtime = test_runtime_with_kernel_tree(tree_root.clone()); + runtime.ui_keymap = UiKeymap::Vim; + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); - let mut state = AppState::new(vec![], runtime); let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert!(matches!(state.ui_page, UiPage::CodeBrowser)); - let index_a = state - .kernel_tree_rows - .iter() - .position(|row| row.path == dir_a) - .expect("directory a row exists"); - state.kernel_tree_row_index = index_a; - assert!(state.kernel_tree_rows[index_a].expandable); - assert!(!state.kernel_tree_rows.iter().any(|row| row.path == dir_b)); + let action_tree_bottom = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert!(matches!(action_tree_bottom, LoopAction::Continue)); + assert_eq!( + state.kernel_tree_row_index, + state.kernel_tree_rows.len().saturating_sub(1) + ); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), ); - assert!(state.kernel_tree_rows.iter().any(|row| row.path == dir_b)); + let action_tree_top = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(action_tree_top, LoopAction::Continue)); + assert_eq!(state.kernel_tree_row_index, 0); - let index_a_after_expand = state + state.code_focus = CodePaneFocus::Source; + state.kernel_tree_row_index = state .kernel_tree_rows .iter() - .position(|row| row.path == dir_a) - .expect("directory a row exists after expand"); - state.kernel_tree_row_index = index_a_after_expand; + .position(|row| row.path == file_path) + .expect("file row exists"); + + let mut terminal = Terminal::new(TestBackend::new(140, 18)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw source preview before vim jumps"); + + state.code_preview_scroll = 9; + let action_source_bottom = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert!(matches!(action_source_bottom, LoopAction::Continue)); + let code_preview_scroll_limit = state.code_preview_scroll_limit.get(); + assert!(code_preview_scroll_limit > 0); + assert_eq!(state.code_preview_scroll, code_preview_scroll_limit); + + let action_source_up = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert!(matches!(action_source_up, LoopAction::Continue)); + assert_eq!( + state.code_preview_scroll, + code_preview_scroll_limit.saturating_sub(1) + ); + let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), ); - assert!(!state.kernel_tree_rows.iter().any(|row| row.path == dir_b)); + let action_source_top = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(action_source_top, LoopAction::Continue)); + assert_eq!(state.code_preview_scroll, 0); let _ = fs::remove_dir_all(tree_root); } #[test] -fn kernel_tree_lists_files_and_source_preview_preserves_indentation() { - let tree_root = temp_dir("kernel-tree-files"); - let dir_a = tree_root.join("a"); - let file_path = dir_a.join("demo.c"); - fs::create_dir_all(&dir_a).expect("create directory"); - fs::write( - &file_path, - "fn demo() {\n\tif true {\n return;\n\t}\n}\n", - ) - .expect("write source file"); - - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); +fn vim_keymap_supports_qq_quit_chord() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; let mut state = AppState::new(vec![], runtime); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert!(matches!(state.ui_page, UiPage::CodeBrowser)); - let index_a = state - .kernel_tree_rows - .iter() - .position(|row| row.path == dir_a) - .expect("directory a row exists"); - state.kernel_tree_row_index = index_a; - let _ = handle_key_event( + let first = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + assert!(matches!(first, LoopAction::Continue)); + assert_eq!( + state.status, + "press qq to quit or use command palette quit/exit" ); - let file_index = state - .kernel_tree_rows - .iter() - .position(|row| row.path == file_path) - .expect("file row exists"); - state.kernel_tree_row_index = file_index; - - let preview = load_source_file_preview(&file_path); - assert!(preview.contains(" if true {")); - assert!(preview.contains(" return;")); - assert!(!preview.contains('\t')); - - let _ = fs::remove_dir_all(tree_root); + let second = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + assert!(matches!(second, LoopAction::Exit)); } #[test] -fn code_edit_mode_enters_only_on_source_file_focus() { - let tree_root = temp_dir("code-edit-enter"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "fn demo() {}\n").expect("write demo file"); +fn vim_chords_do_not_leak_into_right_preview_pane() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + runtime, + ); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); - let mut state = AppState::new(vec![], runtime); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert!(matches!(state.ui_page, UiPage::CodeBrowser)); + let arm_quit = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + assert!(matches!(arm_quit, LoopAction::Continue)); - state.code_focus = CodePaneFocus::Tree; let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), ); - assert!(matches!(state.code_edit_mode, CodeEditMode::Browse)); - assert!(state.status.contains("select a source file")); - - state.code_focus = CodePaneFocus::Source; - state.kernel_tree_row_index = 0; let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), ); - assert!(matches!(state.code_edit_mode, CodeEditMode::Browse)); - assert!(state.status.contains("select a source file")); + assert!(matches!(state.focus, Pane::Preview)); - let file_index = state - .kernel_tree_rows - .iter() - .position(|row| row.path == file_path) - .expect("find source file"); - state.kernel_tree_row_index = file_index; - let _ = handle_key_event( + state.preview_scroll = 11; + let first_g = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), ); - assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); - assert_eq!(state.code_edit_target.as_ref(), Some(&file_path)); + assert!(matches!(first_g, LoopAction::Continue)); + assert_eq!(state.preview_scroll, 11); - let _ = fs::remove_dir_all(tree_root); + let second_g = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(second_g, LoopAction::Continue)); + assert_eq!(state.preview_scroll, 0); } #[test] -fn code_edit_insert_save_and_escape_exit_updates_file() { - let tree_root = temp_dir("code-edit-save-esc"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "alpha\nbeta\n").expect("write demo file"); - - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); - let mut state = AppState::new(vec![], runtime); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - state.code_focus = CodePaneFocus::Source; - let file_index = state - .kernel_tree_rows - .iter() - .position(|row| row.path == file_path) - .expect("find source file"); - state.kernel_tree_row_index = file_index; +fn preview_pane_shift_g_keeps_tui_renderable() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new( + vec![sample_thread("preview thread", "preview@example.com", 0)], + runtime.clone(), + ); + state.focus = Pane::Preview; - let _ = handle_key_event( + let action = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), ); - assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); + assert!(matches!(action, LoopAction::Continue)); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw after preview shift-g"); + let rendered = format!("{}", terminal.backend()); + + assert!(rendered.contains("Preview")); +} + +#[test] +fn preview_pane_can_move_up_after_reaching_bottom() { + let root = temp_dir("preview-scroll-bottom"); + let raw_path = root.join("preview.eml"); + let body = (1..=40) + .map(|line| format!("preview line {line:02}")) + .collect::>() + .join("\n"); + fs::write( + &raw_path, + format!( + "Message-ID: \r\nSubject: preview scroll\r\nFrom: preview@example.com\r\n\r\n{body}\n" + ), + ) + .expect("write raw mail"); + + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "preview scroll", + "preview-scroll@example.com", + 0, + raw_path.clone(), + )], + runtime.clone(), ); - assert!(matches!(state.code_edit_mode, CodeEditMode::VimInsert)); + state.focus = Pane::Preview; + + let mut terminal = Terminal::new(TestBackend::new(120, 16)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw initial preview frame"); + + let preview_scroll_limit = state.preview_scroll_limit.get(); + assert!(preview_scroll_limit > 0); + + for _ in 0..(preview_scroll_limit as usize + 10) { + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + } + assert_eq!(state.preview_scroll, preview_scroll_limit); + + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw bottom preview frame"); + let bottom_frame = format!("{}", terminal.backend()); + let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('!'), KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), ); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); + assert_eq!(state.preview_scroll, preview_scroll_limit.saturating_sub(1)); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw preview frame after moving up"); + let after_up_frame = format!("{}", terminal.backend()); + + assert_ne!(after_up_frame, bottom_frame); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn preview_scroll_limit_accounts_for_wrapped_long_lines() { + let root = temp_dir("preview-wrap-scroll"); + let raw_path = root.join("wrapped-preview.eml"); + let wrapped_body = format!("{} WRAP_TAIL_TOKEN\n", "x".repeat(380)); + fs::write( + &raw_path, + format!( + "Message-ID: \r\nSubject: wrapped preview\r\nFrom: preview@example.com\r\n\r\n{wrapped_body}" + ), + ) + .expect("write wrapped preview mail"); + + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "wrapped preview", + "wrapped-preview@example.com", + 0, + raw_path.clone(), + )], + runtime.clone(), ); - let saved = fs::read_to_string(&file_path).expect("read saved file"); - assert!(saved.starts_with("!alpha")); + state.focus = Pane::Preview; - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(matches!(state.code_edit_mode, CodeEditMode::Browse)); - let preview = load_source_file_preview(&file_path); - assert!(preview.contains("!alpha")); + let mut terminal = Terminal::new(TestBackend::new(40, 10)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw wrapped preview"); + let top_frame = format!("{}", terminal.backend()); + let preview_scroll_limit = state.preview_scroll_limit.get(); + assert!(preview_scroll_limit > 0); + assert!(!top_frame.contains("WRAP_TAIL_TOKEN")); + + for _ in 0..(preview_scroll_limit as usize + 5) { + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + } - let _ = fs::remove_dir_all(tree_root); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw wrapped preview after scrolling"); + let bottom_frame = format!("{}", terminal.backend()); + + assert!(bottom_frame.contains("WRAP_TAIL_TOKEN")); + + let _ = fs::remove_dir_all(root); } #[test] -fn code_edit_command_mode_handles_dirty_q_w_and_wq() { - let tree_root = temp_dir("code-edit-command"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "hello\n").expect("write demo file"); +fn source_pane_shift_g_keeps_tui_renderable() { + let tree_root = temp_dir("source-pane-shift-g"); + let file_path = tree_root.join("demo.c"); + fs::write( + &file_path, + "int line_1;\nint line_2;\nint line_3;\nint line_4;\nint line_5;\n", + ) + .expect("write source file"); + + let mut runtime = test_runtime_with_kernel_tree(tree_root.clone()); + runtime.ui_keymap = UiKeymap::Vim; + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); - let mut state = AppState::new(vec![], runtime); let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(matches!(state.ui_page, UiPage::CodeBrowser)); state.code_focus = CodePaneFocus::Source; - let file_index = state + state.kernel_tree_row_index = state .kernel_tree_rows .iter() .position(|row| row.path == file_path) - .expect("find source file"); - state.kernel_tree_row_index = file_index; + .expect("file row exists"); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), - ); - let _ = handle_key_event( + let action = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), ); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(action, LoopAction::Continue)); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), - ); - let _ = handle_key_event( + let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw after source shift-g"); + let rendered = format!("{}", terminal.backend()); + + assert!(rendered.contains("Source Preview")); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn config_command_opens_visual_editor() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = "config".to_string(); + + let action = handle_key_event( &mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); - assert!(state.code_edit_dirty); - assert!(state.status.contains("unsaved changes")); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ); - assert!(!state.code_edit_dirty); - let saved_once = fs::read_to_string(&file_path).expect("read saved file"); - assert!(saved_once.starts_with("xhello")); + assert!(matches!(action, LoopAction::Continue)); + assert!(state.config_editor.open); + assert!(!state.palette.open); + assert_eq!(state.selected_config_editor_field().key, "source.mailbox"); +} - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), - ); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(state.code_edit_dirty); +#[test] +fn config_editor_saves_selected_value() { + let root = temp_dir("config-editor-save"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[source] +mailbox = "inbox" +"#, + ) + .expect("write config file"); + + let mut runtime = test_runtime(); + runtime.config_path = config_path.clone(); + let mut state = AppState::new(vec![], runtime); + + state.open_config_editor(Some("source.mailbox")); + state.start_config_editor_edit(); + state.config_editor.input = "io-uring".to_string(); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), - ); let _ = handle_key_event( &mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - assert!(matches!(state.code_edit_mode, CodeEditMode::Browse)); - let saved_twice = fs::read_to_string(&file_path).expect("read saved file"); - assert!(saved_twice.starts_with("xyhello")); - let _ = fs::remove_dir_all(tree_root); + assert_eq!(state.runtime.source_mailbox, "io-uring"); + assert!(!state.config_editor.open || state.config_editor.input.is_empty()); + let persisted = fs::read_to_string(&config_path).expect("read config file"); + assert!(persisted.contains("mailbox = \"io-uring\"")); + + let _ = fs::remove_dir_all(root); } #[test] -fn code_edit_command_mode_rejects_unsupported_command() { - let tree_root = temp_dir("code-edit-unsupported-command"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "hello\n").expect("write demo file"); +fn config_editor_tab_cycles_boolean_presets() { + let root = temp_dir("config-editor-toggle"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[ui] +startup_sync = true +"#, + ) + .expect("write config file"); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut runtime = test_runtime(); + runtime.config_path = config_path.clone(); let mut state = AppState::new(vec![], runtime); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - state.code_focus = CodePaneFocus::Source; - let file_index = state - .kernel_tree_rows - .iter() - .position(|row| row.path == file_path) - .expect("find source file"); - state.kernel_tree_row_index = file_index; - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), - ); - assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ); + state.open_config_editor(Some("ui.startup_sync")); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); - assert!(state.status.contains("unsupported command")); + assert!(!state.runtime.startup_sync); + let persisted = fs::read_to_string(&config_path).expect("read config file"); + assert!(persisted.contains("startup_sync = false")); - let _ = fs::remove_dir_all(tree_root); + let _ = fs::remove_dir_all(root); } #[test] -fn code_edit_command_mode_supports_force_quit_without_saving() { - let tree_root = temp_dir("code-edit-force-quit"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "hello\n").expect("write demo file"); +fn config_editor_saves_inbox_auto_sync_interval() { + let root = temp_dir("config-editor-auto-sync-interval"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[ui] +inbox_auto_sync_interval_secs = 30 +"#, + ) + .expect("write config file"); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut runtime = test_runtime_with_imap(); + runtime.config_path = config_path.clone(); let mut state = AppState::new(vec![], runtime); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - state.code_focus = CodePaneFocus::Source; - let file_index = state - .kernel_tree_rows - .iter() - .position(|row| row.path == file_path) - .expect("find source file"); - state.kernel_tree_row_index = file_index; - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), - ); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(state.code_edit_dirty); + state.open_config_editor(Some("ui.inbox_auto_sync_interval_secs")); + state.start_config_editor_edit(); + state.config_editor.input = "45".to_string(); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('!'), KeyModifiers::SHIFT), - ); let _ = handle_key_event( &mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - assert!(matches!(state.code_edit_mode, CodeEditMode::Browse)); - assert!(state.status.contains("discarded unsaved changes")); - let disk = fs::read_to_string(&file_path).expect("read file"); - assert_eq!(disk, "hello\n"); + assert_eq!(state.runtime.inbox_auto_sync_interval_secs, 45); + let persisted = fs::read_to_string(&config_path).expect("read config file"); + assert!(persisted.contains("inbox_auto_sync_interval_secs = 45")); - let _ = fs::remove_dir_all(tree_root); + let _ = fs::remove_dir_all(root); } #[test] -fn code_browser_external_vim_key_updates_selected_file_preview() { - let tree_root = temp_dir("external-vim-key"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "before\n").expect("write demo file"); +fn config_editor_can_unset_optional_key() { + let root = temp_dir("config-editor-unset"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[b4] +path = "/usr/bin/b4" +"#, + ) + .expect("write config file"); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut runtime = test_runtime(); + runtime.config_path = config_path.clone(); let mut state = AppState::new(vec![], runtime); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - state.code_focus = CodePaneFocus::Source; - let file_index = state - .kernel_tree_rows - .iter() - .position(|row| row.path == file_path) - .expect("find source file"); - state.kernel_tree_row_index = file_index; - state.external_editor_runner = external_editor_mock_success; + state.open_config_editor(Some("b4.path")); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), ); - assert!(state.status.contains("external vim exited successfully")); - let preview = load_source_file_preview(&file_path); - assert!(preview.contains("externally edited")); + assert!(state.runtime.b4_path.is_none()); + let persisted = fs::read_to_string(&config_path).expect("read config file"); + assert!(!persisted.contains("path = ")); - let _ = fs::remove_dir_all(tree_root); + let _ = fs::remove_dir_all(root); } #[test] -fn code_edit_external_vim_rejects_dirty_buffer() { - let tree_root = temp_dir("external-vim-dirty"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "hello\n").expect("write demo file"); +fn config_editor_rejects_invalid_runtime_value_without_writing_file() { + let root = temp_dir("config-editor-invalid"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[ui] +startup_sync = true +"#, + ) + .expect("write config file"); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut runtime = test_runtime(); + runtime.config_path = config_path.clone(); let mut state = AppState::new(vec![], runtime); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - state.code_focus = CodePaneFocus::Source; - let file_index = state - .kernel_tree_rows - .iter() - .position(|row| row.path == file_path) - .expect("find source file"); - state.kernel_tree_row_index = file_index; - state.external_editor_runner = external_editor_mock_success; + + state.open_config_editor(Some("ui.startup_sync")); + state.start_config_editor_edit(); + state.config_editor.input = "maybe".to_string(); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + + assert!( + state + .status + .contains("failed to set config key ui.startup_sync") ); + assert!(state.runtime.startup_sync); + let persisted = fs::read_to_string(&config_path).expect("read config file"); + assert!(persisted.contains("startup_sync = true")); + assert!(!persisted.contains("maybe")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn config_editor_rejects_zero_inbox_auto_sync_interval_without_writing_file() { + let root = temp_dir("config-editor-zero-auto-sync-interval"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[ui] +inbox_auto_sync_interval_secs = 30 +"#, + ) + .expect("write config file"); + + let mut runtime = test_runtime_with_imap(); + runtime.config_path = config_path.clone(); + let mut state = AppState::new(vec![], runtime); + + state.open_config_editor(Some("ui.inbox_auto_sync_interval_secs")); + state.start_config_editor_edit(); + state.config_editor.input = "0".to_string(); + let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(state.code_edit_dirty); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT), + assert!( + state + .status + .contains("failed to set config key ui.inbox_auto_sync_interval_secs") ); + assert_eq!(state.runtime.inbox_auto_sync_interval_secs, 30); + let persisted = fs::read_to_string(&config_path).expect("read config file"); + assert!(persisted.contains("inbox_auto_sync_interval_secs = 30")); + + let _ = fs::remove_dir_all(root); +} +#[test] +fn config_editor_reports_unsupported_key_hint_and_allows_keyboard_navigation() { + let mut state = AppState::new(vec![], test_runtime()); + + state.open_config_editor(Some("unsupported.key")); + + assert!(state.config_editor.open); assert!( state .status - .contains("unsaved changes, run :w before external vim") + .contains("config editor does not support unsupported.key") ); - assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); - assert!(state.code_edit_dirty); - let disk = fs::read_to_string(&file_path).expect("read file"); - assert_eq!(disk, "hello\n"); + assert_eq!(state.selected_config_editor_field().key, "source.mailbox"); - let _ = fs::remove_dir_all(tree_root); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_ne!(state.selected_config_editor_field().key, "source.mailbox"); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(state.selected_config_editor_field().key, "source.mailbox"); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!state.config_editor.open); + assert_eq!(state.status, "config editor closed"); } #[test] -fn code_edit_command_mode_vim_reloads_buffer_after_external_edit() { - let tree_root = temp_dir("external-vim-command"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "hello\n").expect("write demo file"); +fn config_editor_edit_mode_handles_char_backspace_tab_and_escape() { + let root = temp_dir("config-editor-keyboard"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[ui] +startup_sync = true +"#, + ) + .expect("write config file"); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut runtime = test_runtime(); + runtime.config_path = config_path; let mut state = AppState::new(vec![], runtime); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - state.code_focus = CodePaneFocus::Source; - let file_index = state - .kernel_tree_rows - .iter() - .position(|row| row.path == file_path) - .expect("find source file"); - state.kernel_tree_row_index = file_index; - state.external_editor_runner = external_editor_mock_success; + state.open_config_editor(Some("ui.startup_sync")); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE), - ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); + assert!(matches!( + state.config_editor.mode, + super::ConfigEditorMode::Edit + )); + assert_eq!(state.config_editor.input, "true"); + let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), ); + assert_eq!(state.config_editor.input, "truex"); + let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), ); + assert_eq!(state.config_editor.input, "true"); - assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); - assert!(!state.code_edit_dirty); - assert_eq!( - state.code_edit_buffer.first().map(String::as_str), - Some("externally edited") - ); - assert!(state.status.contains("external vim exited successfully")); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(state.config_editor.input, "false"); + assert_eq!(state.status, "preset value selected for ui.startup_sync"); - let _ = fs::remove_dir_all(tree_root); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!( + state.config_editor.mode, + super::ConfigEditorMode::Browse + )); + assert!(state.config_editor.input.is_empty()); + assert_eq!(state.status, "config edit cancelled"); + + let _ = fs::remove_dir_all(root); } #[test] -fn command_palette_vim_runs_external_editor() { - let tree_root = temp_dir("external-vim-palette"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "before\n").expect("write demo file"); +fn config_palette_help_and_usage_are_reported() { + let mut state = AppState::new(vec![], test_runtime()); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); - let mut state = AppState::new(vec![], runtime); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - state.code_focus = CodePaneFocus::Source; - let file_index = state - .kernel_tree_rows - .iter() - .position(|row| row.path == file_path) - .expect("find source file"); - state.kernel_tree_row_index = file_index; - state.external_editor_runner = external_editor_mock_success; state.palette.open = true; - state.palette.input = "vim".to_string(); - + state.palette.input = "config help".to_string(); let _ = handle_key_event( &mut state, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); + assert!(state.status.contains("config usage:")); - assert!(state.status.contains("external vim exited successfully")); - let preview = load_source_file_preview(&file_path); - assert!(preview.contains("externally edited")); + state.palette.open = true; + state.palette.input = "config get".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert_eq!(state.status, "usage: config get "); - let _ = fs::remove_dir_all(tree_root); + state.palette.open = true; + state.palette.input = "config set".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert_eq!(state.status, "usage: config set "); } #[test] -fn external_vim_launch_failure_keeps_tui_interactive() { - let tree_root = temp_dir("external-vim-failure"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "before\n").expect("write demo file"); - - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); - let mut state = AppState::new(vec![], runtime); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - state.code_focus = CodePaneFocus::Source; - let file_index = state - .kernel_tree_rows - .iter() - .position(|row| row.path == file_path) - .expect("find source file"); - state.kernel_tree_row_index = file_index; - state.external_editor_runner = external_editor_mock_failure; +fn config_palette_reports_effective_and_missing_values() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = "config get source.mailbox".to_string(); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!( + state + .status + .contains("config effective source.mailbox = inbox (default/runtime)") ); - assert!(state.status.contains("external vim failed")); + state.palette.open = true; + state.palette.input = "config get no.such.key".to_string(); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - assert!(matches!(state.code_focus, CodePaneFocus::Tree)); - - let _ = fs::remove_dir_all(tree_root); + assert_eq!(state.status, "config key not found: no.such.key"); } #[test] -fn external_vim_marks_terminal_refresh_needed_after_return() { - let tree_root = temp_dir("external-vim-refresh"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "before\n").expect("write demo file"); +fn config_palette_set_does_not_overwrite_scalar_parent_keys() { + let root = temp_dir("config-palette-scalar-parent"); + let config_path = root.join("criew-config.toml"); + fs::write(&config_path, "source = \"broken\"\n").expect("write config file"); - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut runtime = test_runtime(); + runtime.config_path = config_path.clone(); let mut state = AppState::new(vec![], runtime); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - state.code_focus = CodePaneFocus::Source; - let file_index = state - .kernel_tree_rows - .iter() - .position(|row| row.path == file_path) - .expect("find source file"); - state.kernel_tree_row_index = file_index; - state.external_editor_runner = external_editor_mock_success; - assert!(!state.needs_terminal_refresh); + state.palette.open = true; + state.palette.input = "config set source.mailbox io-uring".to_string(); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - assert!(state.needs_terminal_refresh); - assert!(state.take_terminal_refresh_needed()); - assert!(!state.needs_terminal_refresh); - let _ = fs::remove_dir_all(tree_root); + assert!(state.status.contains("cannot set source.mailbox")); + assert_eq!(state.runtime.source_mailbox, "inbox"); + let persisted = fs::read_to_string(&config_path).expect("read config file"); + assert_eq!(persisted, "source = \"broken\"\n"); + + let _ = fs::remove_dir_all(root); } #[test] -fn code_edit_draw_sets_terminal_cursor_position() { - let tree_root = temp_dir("code-edit-cursor"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "hello\nworld\n").expect("write demo file"); - - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); +fn config_editor_overlay_is_rendered() { + let runtime = test_runtime(); let bootstrap = test_bootstrap(&runtime); let mut state = AppState::new(vec![], runtime.clone()); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - state.code_focus = CodePaneFocus::Source; - let file_index = state - .kernel_tree_rows - .iter() - .position(|row| row.path == file_path) - .expect("find source file"); - state.kernel_tree_row_index = file_index; - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), - ); - assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); + state.open_config_editor(Some("source.mailbox")); - let mut terminal = Terminal::new(TestBackend::new(120, 30)).expect("create test terminal"); - let mut expected_cursor: Option<(u16, u16)> = None; + let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal"); terminal - .draw(|frame| { - let areas = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Min(10), - Constraint::Length(1), - ]) - .split(frame.area()); - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) - .split(areas[1]); - let inner_area = Rect::new( - panes[1].x + 1, - panes[1].y + 1, - panes[1].width.saturating_sub(2), - panes[1].height.saturating_sub(2), - ); - expected_cursor = code_edit_cursor_position(&state, inner_area); - draw(frame, &state, &runtime, &bootstrap); - }) - .expect("draw frame"); - - let expected = expected_cursor.expect("cursor position should be visible"); - terminal - .backend_mut() - .assert_cursor_position(Position::new(expected.0, expected.1)); + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw config editor"); + let rendered = format!("{}", terminal.backend()); - let _ = fs::remove_dir_all(tree_root); + assert!(rendered.contains("Runtime Config")); + assert!(rendered.contains("source.mailbox")); + assert!(rendered.contains("Selected Field")); } #[test] -fn code_browser_navigation_keys_unchanged_when_not_editing() { - let tree_root = temp_dir("code-edit-regression"); - let file_path = tree_root.join("demo.rs"); - fs::write(&file_path, "line1\nline2\n").expect("write demo file"); +fn ctrl_backtick_toggles_command_palette() { + let key = KeyEvent::new(KeyCode::Char('`'), KeyModifiers::CONTROL); + assert!(is_palette_toggle(key)); +} - let runtime = test_runtime_with_kernel_tree(tree_root.clone()); - let mut state = AppState::new(vec![], runtime); - let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert!(matches!(state.ui_page, UiPage::CodeBrowser)); - assert!(matches!(state.code_focus, CodePaneFocus::Tree)); +#[test] +fn colon_opens_command_palette() { + let mut state = AppState::new(vec![], test_runtime()); - state.code_focus = CodePaneFocus::Source; - state.code_preview_scroll = 2; - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), - ); - assert_eq!(state.code_preview_scroll, 1); - assert!(matches!(state.code_edit_mode, CodeEditMode::Browse)); + let key = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT); + assert!(is_palette_open_shortcut(key)); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), - ); - assert_eq!(state.code_preview_scroll, 2); + let action = handle_key_event(&mut state, key); + assert!(matches!(action, LoopAction::Continue)); + assert!(state.palette.open); +} - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), - ); - assert!(matches!(state.code_focus, CodePaneFocus::Tree)); +#[test] +fn palette_tab_completes_top_level_command() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = "co".to_string(); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), - ); - assert!(matches!(state.code_focus, CodePaneFocus::Source)); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - let _ = fs::remove_dir_all(tree_root); + assert_eq!(state.palette.input, "config "); } #[test] -fn enter_on_subscription_opens_threads_without_toggling_enabled_state() { +fn palette_tab_completes_config_subcommand_and_key() { let mut state = AppState::new(vec![], test_runtime()); - state.focus = Pane::Subscriptions; - let initial = state.subscriptions[0].enabled; - - let action = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ); + state.palette.open = true; + state.palette.input = "config g".to_string(); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(state.palette.input, "config get "); - assert!(matches!(action, LoopAction::Continue)); - assert_eq!(state.subscriptions[0].enabled, initial); + state.palette.input = "config get source.m".to_string(); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(state.palette.input, "config get source.mailbox "); } #[test] -fn enter_on_group_header_toggles_expand_and_collapse() { +fn palette_tab_completes_sync_mailbox() { let mut state = AppState::new(vec![], test_runtime()); - state.focus = Pane::Subscriptions; - state.subscription_row_index = 0; + state.palette.open = true; + state.palette.input = "sync bp".to_string(); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ); - assert!(!state.enabled_group_expanded); - let rows_after_collapse = state.subscription_rows(); - assert!(rows_after_collapse[0].text.starts_with('▶')); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ); - assert!(state.enabled_group_expanded); - let rows_after_expand = state.subscription_rows(); - assert!(rows_after_expand[0].text.starts_with('▼')); + assert_eq!(state.palette.input, "sync bpf "); } #[test] -fn enter_on_category_header_toggles_expand_and_collapse() { +fn palette_double_tab_lists_config_arguments() { let mut state = AppState::new(vec![], test_runtime()); - state.focus = Pane::Subscriptions; - state.subscription_row_index = state - .subscription_rows() - .iter() - .position(|row| row.text.contains("linux subsystem")) - .expect("linux category header exists"); + state.palette.open = true; + state.palette.input = "config".to_string(); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ); - assert!(!state.disabled_linux_subsystem_expanded); - let rows_after_collapse = state.subscription_rows(); - let linux_header_after_collapse = rows_after_collapse - .iter() - .find(|row| row.text.contains("linux subsystem")) - .expect("linux category header exists after collapse"); - assert!(linux_header_after_collapse.text.starts_with(" ▶")); - assert!( - !rows_after_collapse - .iter() - .any(|row| row.text.contains("[n] io-uring")) - ); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(state.palette.input, "config "); + assert!(!state.palette.show_suggestions); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ); - assert!(state.disabled_linux_subsystem_expanded); - let rows_after_expand = state.subscription_rows(); - let linux_header_after_expand = rows_after_expand + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(state.palette.show_suggestions); + let values: Vec = state + .palette + .suggestions .iter() - .find(|row| row.text.contains("linux subsystem")) - .expect("linux category header exists after expand"); - assert!(linux_header_after_expand.text.starts_with(" ▼")); - assert!( - rows_after_expand - .iter() - .any(|row| row.text.contains("[n] io-uring")) - ); + .map(|item| item.value.clone()) + .collect(); + assert!(values.contains(&"show".to_string())); + assert!(values.contains(&"get".to_string())); + assert!(values.contains(&"set".to_string())); + assert!(values.contains(&"help".to_string())); } #[test] -fn first_open_starts_with_all_subscriptions_disabled() { - let state = AppState::new(vec![], test_runtime()); - assert!(state.subscriptions.iter().all(|item| !item.enabled)); -} +fn palette_tab_completes_local_command_path() { + let tree_root = temp_dir("palette-bang-complete"); + fs::write(tree_root.join("echo-local"), "#!/bin/sh\n").expect("write executable"); + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + state.palette.open = true; + state.palette.input = "!./ec".to_string(); -#[test] -fn subscription_rows_show_linux_and_qemu_categories() { - let state = AppState::new(vec![], test_runtime()); - let rows = state.subscription_rows(); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - let linux_header = rows - .iter() - .position(|row| row.text.contains("linux subsystem")) - .expect("linux category header exists"); - let qemu_header = rows - .iter() - .position(|row| row.text.contains("qemu subsystem")) - .expect("qemu category header exists"); - let qemu_devel = rows - .iter() - .position(|row| row.text.contains("[n] qemu-devel")) - .expect("qemu-devel row exists"); + assert_eq!(state.palette.input, "!./echo-local "); - assert!(linux_header < qemu_header); - assert!(qemu_header < qemu_devel); + let _ = fs::remove_dir_all(tree_root); } #[test] -fn qemu_mailbox_case_variants_reuse_the_default_subscription() { - let mut runtime = test_runtime(); - runtime.source_mailbox = "QEMU-devel".to_string(); +fn local_command_mode_uses_kernel_tree_as_workdir() { + let tree_root = temp_dir("palette-bang-kernel-tree"); + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let state = AppState::new(vec![], runtime); - let state = AppState::new_with_ui_state( - vec![], - runtime, - Some(UiState { - enabled_mailboxes: vec!["QEMU-devel".to_string()], - active_mailbox: Some("QEMU-devel".to_string()), - ..UiState::default() - }), - ); + let workdir = resolve_palette_local_workdir(&state).expect("resolve local workdir"); + assert_eq!(workdir, tree_root); - let qemu_devel_items: Vec<&SubscriptionItem> = state - .subscriptions - .iter() - .filter(|item| item.mailbox.eq_ignore_ascii_case("qemu-devel")) - .collect(); + let _ = fs::remove_dir_all(workdir); +} - assert_eq!(qemu_devel_items.len(), 1); - assert_eq!(qemu_devel_items[0].mailbox, "qemu-devel"); - assert!(qemu_devel_items[0].enabled); - assert_eq!( - qemu_devel_items[0].category, - Some(SubscriptionCategory::QemuSubsystem) - ); - assert_eq!( - state.subscriptions[state.subscription_index].mailbox, - "qemu-devel" - ); +#[test] +fn local_command_mode_falls_back_to_home_workdir() { + let state = AppState::new(vec![], test_runtime()); + let resolved = resolve_palette_local_workdir(&state); + match std::env::var("HOME") { + Ok(home) => assert_eq!(resolved.expect("resolve home"), PathBuf::from(home)), + Err(_) => assert!(resolved.is_err()), + } } #[test] -fn first_open_with_complete_imap_enables_my_inbox() { - let state = AppState::new(vec![], test_runtime_with_imap()); - let my_inbox = state - .subscriptions +fn palette_bang_executes_local_command() { + let tree_root = temp_dir("palette-bang-exec"); + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + state.palette.open = true; + state.palette.input = "!pwd".to_string(); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(state.status.contains("local command ok")); + assert!(state.status.contains(&tree_root.display().to_string())); + let local_result = state + .palette + .last_local_result + .as_ref() + .expect("local result should exist"); + assert_eq!(local_result.command, "pwd"); + assert!( + local_result + .output + .contains(&tree_root.display().to_string()) + ); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn command_palette_renders_local_command_result() { + let tree_root = temp_dir("palette-bang-render"); + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); + state.palette.open = true; + state.palette.input = "!pwd".to_string(); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + let mut terminal = Terminal::new(TestBackend::new(180, 30)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw frame"); + let rendered = format!("{}", terminal.backend()); + assert!(rendered.contains("Local Result")); + assert!(rendered.contains("!pwd")); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn tab_toggles_between_mail_page_and_code_browser_page() { + let tree_root = temp_dir("kernel-tree-tab"); + fs::create_dir_all(tree_root.join("io_uring")).expect("create kernel dir"); + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + + assert!(matches!(state.ui_page, UiPage::Mail)); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(matches!(state.ui_page, UiPage::CodeBrowser)); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(matches!(state.ui_page, UiPage::Mail)); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn kernel_tree_enter_expands_and_collapses_selected_directory() { + let tree_root = temp_dir("kernel-tree-expand"); + let dir_a = tree_root.join("a"); + let dir_b = dir_a.join("b"); + let dir_c = tree_root.join("c"); + fs::create_dir_all(&dir_b).expect("create nested directory"); + fs::create_dir_all(&dir_c).expect("create sibling directory"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(matches!(state.ui_page, UiPage::CodeBrowser)); + + let index_a = state + .kernel_tree_rows .iter() - .find(|item| item.mailbox == IMAP_INBOX_MAILBOX) - .expect("my inbox subscription exists"); + .position(|row| row.path == dir_a) + .expect("directory a row exists"); + state.kernel_tree_row_index = index_a; + assert!(state.kernel_tree_rows[index_a].expandable); + assert!(!state.kernel_tree_rows.iter().any(|row| row.path == dir_b)); - assert!(my_inbox.enabled); - assert_eq!(my_inbox.label, MY_INBOX_LABEL); - assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(state.kernel_tree_rows.iter().any(|row| row.path == dir_b)); + + let index_a_after_expand = state + .kernel_tree_rows + .iter() + .position(|row| row.path == dir_a) + .expect("directory a row exists after expand"); + state.kernel_tree_row_index = index_a_after_expand; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(!state.kernel_tree_rows.iter().any(|row| row.path == dir_b)); + + let _ = fs::remove_dir_all(tree_root); } #[test] -fn legacy_ui_state_with_complete_imap_enables_my_inbox_once() { - let state = AppState::new_with_ui_state( - vec![], - test_runtime_with_imap(), - Some(UiState { - enabled_mailboxes: vec!["io-uring".to_string()], - enabled_group_expanded: true, - disabled_group_expanded: true, - enabled_linux_subsystem_expanded: true, - enabled_qemu_subsystem_expanded: true, - disabled_linux_subsystem_expanded: true, - disabled_qemu_subsystem_expanded: true, - imap_defaults_initialized: false, - active_mailbox: Some("io-uring".to_string()), - }), +fn kernel_tree_lists_files_and_source_preview_preserves_indentation() { + let tree_root = temp_dir("kernel-tree-files"); + let dir_a = tree_root.join("a"); + let file_path = dir_a.join("demo.c"); + fs::create_dir_all(&dir_a).expect("create directory"); + fs::write( + &file_path, + "fn demo() {\n\tif true {\n return;\n\t}\n}\n", + ) + .expect("write source file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(matches!(state.ui_page, UiPage::CodeBrowser)); + + let index_a = state + .kernel_tree_rows + .iter() + .position(|row| row.path == dir_a) + .expect("directory a row exists"); + state.kernel_tree_row_index = index_a; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - let my_inbox = state - .subscriptions + let file_index = state + .kernel_tree_rows .iter() - .find(|item| item.mailbox == IMAP_INBOX_MAILBOX) - .expect("my inbox subscription exists"); + .position(|row| row.path == file_path) + .expect("file row exists"); + state.kernel_tree_row_index = file_index; - assert!(my_inbox.enabled); - assert!(state.imap_defaults_initialized); + let preview = load_source_file_preview(&file_path); + assert!(preview.contains(" if true {")); + assert!(preview.contains(" return;")); + assert!(!preview.contains('\t')); + + let _ = fs::remove_dir_all(tree_root); } #[test] -fn initialized_ui_state_keeps_my_inbox_disabled_when_user_opted_out() { - let state = AppState::new_with_ui_state( - vec![], - test_runtime_with_imap(), - Some(UiState { - enabled_mailboxes: vec!["io-uring".to_string()], - enabled_group_expanded: true, - disabled_group_expanded: true, - enabled_linux_subsystem_expanded: true, - enabled_qemu_subsystem_expanded: true, - disabled_linux_subsystem_expanded: true, - disabled_qemu_subsystem_expanded: true, - imap_defaults_initialized: true, - active_mailbox: Some("io-uring".to_string()), - }), +fn code_edit_mode_enters_only_on_source_file_focus() { + let tree_root = temp_dir("code-edit-enter"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "fn demo() {}\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(matches!(state.ui_page, UiPage::CodeBrowser)); + + state.code_focus = CodePaneFocus::Tree; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); + assert!(matches!(state.code_edit_mode, CodeEditMode::Browse)); + assert!(state.status.contains("select a source file")); - let my_inbox = state - .subscriptions + state.code_focus = CodePaneFocus::Source; + state.kernel_tree_row_index = 0; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + assert!(matches!(state.code_edit_mode, CodeEditMode::Browse)); + assert!(state.status.contains("select a source file")); + + let file_index = state + .kernel_tree_rows .iter() - .find(|item| item.mailbox == IMAP_INBOX_MAILBOX) - .expect("my inbox subscription exists"); + .position(|row| row.path == file_path) + .expect("find source file"); + state.kernel_tree_row_index = file_index; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); + assert_eq!(state.code_edit_target.as_ref(), Some(&file_path)); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn code_edit_insert_save_and_escape_exit_updates_file() { + let tree_root = temp_dir("code-edit-save-esc"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "alpha\nbeta\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + state.code_focus = CodePaneFocus::Source; + let file_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("find source file"); + state.kernel_tree_row_index = file_index; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + assert!(matches!(state.code_edit_mode, CodeEditMode::VimInsert)); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('!'), KeyModifiers::SHIFT), + ); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), + ); + let saved = fs::read_to_string(&file_path).expect("read saved file"); + assert!(saved.starts_with("!alpha")); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(state.code_edit_mode, CodeEditMode::Browse)); + let preview = load_source_file_preview(&file_path); + assert!(preview.contains("!alpha")); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn code_edit_command_mode_handles_dirty_q_w_and_wq() { + let tree_root = temp_dir("code-edit-command"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "hello\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + state.code_focus = CodePaneFocus::Source; + let file_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("find source file"); + state.kernel_tree_row_index = file_index; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); + assert!(state.code_edit_dirty); + assert!(state.status.contains("unsaved changes")); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(!state.code_edit_dirty); + let saved_once = fs::read_to_string(&file_path).expect("read saved file"); + assert!(saved_once.starts_with("xhello")); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), + ); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(state.code_edit_dirty); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(state.code_edit_mode, CodeEditMode::Browse)); + let saved_twice = fs::read_to_string(&file_path).expect("read saved file"); + assert!(saved_twice.starts_with("xyhello")); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn code_edit_command_mode_rejects_unsupported_command() { + let tree_root = temp_dir("code-edit-unsupported-command"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "hello\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + state.code_focus = CodePaneFocus::Source; + let file_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("find source file"); + state.kernel_tree_row_index = file_index; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); + assert!(state.status.contains("unsupported command")); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn code_edit_command_mode_supports_force_quit_without_saving() { + let tree_root = temp_dir("code-edit-force-quit"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "hello\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + state.code_focus = CodePaneFocus::Source; + let file_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("find source file"); + state.kernel_tree_row_index = file_index; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(state.code_edit_dirty); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('!'), KeyModifiers::SHIFT), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(matches!(state.code_edit_mode, CodeEditMode::Browse)); + assert!(state.status.contains("discarded unsaved changes")); + let disk = fs::read_to_string(&file_path).expect("read file"); + assert_eq!(disk, "hello\n"); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn code_browser_external_vim_key_updates_selected_file_preview() { + let tree_root = temp_dir("external-vim-key"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "before\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + state.code_focus = CodePaneFocus::Source; + let file_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("find source file"); + state.kernel_tree_row_index = file_index; + state.external_editor_runner = external_editor_mock_success; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT), + ); + + assert!(state.status.contains("external vim exited successfully")); + let preview = load_source_file_preview(&file_path); + assert!(preview.contains("externally edited")); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn code_edit_external_vim_rejects_dirty_buffer() { + let tree_root = temp_dir("external-vim-dirty"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "hello\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + state.code_focus = CodePaneFocus::Source; + let file_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("find source file"); + state.kernel_tree_row_index = file_index; + state.external_editor_runner = external_editor_mock_success; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(state.code_edit_dirty); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT), + ); + + assert!( + state + .status + .contains("unsaved changes, run :w before external vim") + ); + assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); + assert!(state.code_edit_dirty); + let disk = fs::read_to_string(&file_path).expect("read file"); + assert_eq!(disk, "hello\n"); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn code_edit_command_mode_vim_reloads_buffer_after_external_edit() { + let tree_root = temp_dir("external-vim-command"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "hello\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + state.code_focus = CodePaneFocus::Source; + let file_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("find source file"); + state.kernel_tree_row_index = file_index; + state.external_editor_runner = external_editor_mock_success; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); + assert!(!state.code_edit_dirty); + assert_eq!( + state.code_edit_buffer.first().map(String::as_str), + Some("externally edited") + ); + assert!(state.status.contains("external vim exited successfully")); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn command_palette_vim_runs_external_editor() { + let tree_root = temp_dir("external-vim-palette"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "before\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + state.code_focus = CodePaneFocus::Source; + let file_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("find source file"); + state.kernel_tree_row_index = file_index; + state.external_editor_runner = external_editor_mock_success; + state.palette.open = true; + state.palette.input = "vim".to_string(); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(state.status.contains("external vim exited successfully")); + let preview = load_source_file_preview(&file_path); + assert!(preview.contains("externally edited")); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn external_vim_launch_failure_keeps_tui_interactive() { + let tree_root = temp_dir("external-vim-failure"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "before\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + state.code_focus = CodePaneFocus::Source; + let file_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("find source file"); + state.kernel_tree_row_index = file_index; + state.external_editor_runner = external_editor_mock_failure; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT), + ); + assert!(state.status.contains("external vim failed")); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(state.code_focus, CodePaneFocus::Tree)); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn external_vim_marks_terminal_refresh_needed_after_return() { + let tree_root = temp_dir("external-vim-refresh"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "before\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + state.code_focus = CodePaneFocus::Source; + let file_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("find source file"); + state.kernel_tree_row_index = file_index; + state.external_editor_runner = external_editor_mock_success; + + assert!(!state.needs_terminal_refresh); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT), + ); + assert!(state.needs_terminal_refresh); + assert!(state.take_terminal_refresh_needed()); + assert!(!state.needs_terminal_refresh); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn code_edit_draw_sets_terminal_cursor_position() { + let tree_root = temp_dir("code-edit-cursor"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "hello\nworld\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + state.code_focus = CodePaneFocus::Source; + let file_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("find source file"); + state.kernel_tree_row_index = file_index; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); + + let mut terminal = Terminal::new(TestBackend::new(180, 30)).expect("create test terminal"); + let mut expected_cursor: Option<(u16, u16)> = None; + terminal + .draw(|frame| { + let areas = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(10), + Constraint::Length(1), + ]) + .split(frame.area()); + let panes = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(areas[1]); + let inner_area = Rect::new( + panes[1].x + 1, + panes[1].y + 1, + panes[1].width.saturating_sub(2), + panes[1].height.saturating_sub(2), + ); + expected_cursor = code_edit_cursor_position(&state, inner_area); + draw(frame, &state, &runtime, &bootstrap); + }) + .expect("draw frame"); + + let expected = expected_cursor.expect("cursor position should be visible"); + terminal + .backend_mut() + .assert_cursor_position(Position::new(expected.0, expected.1)); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn code_browser_navigation_keys_unchanged_when_not_editing() { + let tree_root = temp_dir("code-edit-regression"); + let file_path = tree_root.join("demo.rs"); + fs::write(&file_path, "line1\nline2\n").expect("write demo file"); + + let runtime = test_runtime_with_kernel_tree(tree_root.clone()); + let mut state = AppState::new(vec![], runtime); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(matches!(state.ui_page, UiPage::CodeBrowser)); + assert!(matches!(state.code_focus, CodePaneFocus::Tree)); + + state.code_focus = CodePaneFocus::Source; + state.code_preview_scroll = 2; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + assert_eq!(state.code_preview_scroll, 1); + assert!(matches!(state.code_edit_mode, CodeEditMode::Browse)); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert_eq!(state.code_preview_scroll, 2); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(state.code_focus, CodePaneFocus::Tree)); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + assert!(matches!(state.code_focus, CodePaneFocus::Source)); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn enter_on_subscription_opens_threads_and_focuses_threads_pane_without_toggling_enabled_state() { + let root = temp_dir("enter-open-subscription"); + let runtime = test_runtime_with_imap_in(root.clone()); + seed_mailbox_thread( + &runtime.database_path, + "io-uring", + 1, + "io-uring@example.com", + "io-uring thread", + ); + + let mut state = AppState::new_with_ui_state( + vec![], + runtime, + Some(UiState { + enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string(), "io-uring".to_string()], + active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + ..UiState::default() + }), + ); + state.focus = Pane::Subscriptions; + let io_uring_index = state + .subscriptions + .iter() + .position(|item| item.mailbox == "io-uring") + .expect("io-uring subscription exists"); + state.subscription_index = io_uring_index; + state.sync_subscription_row_to_selected_item(); + let initial = state.subscriptions[io_uring_index].enabled; + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(matches!(action, LoopAction::Continue)); + assert_eq!(state.subscriptions[io_uring_index].enabled, initial); + assert!(matches!(state.focus, Pane::Threads)); + assert_eq!(state.active_thread_mailbox, "io-uring"); + assert_eq!(state.threads.len(), 1); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn enter_on_group_header_toggles_expand_and_collapse() { + let mut state = AppState::new(vec![], test_runtime()); + state.focus = Pane::Subscriptions; + state.subscription_row_index = 0; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(!state.enabled_group_expanded); + let rows_after_collapse = state.subscription_rows(); + assert!(rows_after_collapse[0].text.starts_with('▶')); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(state.enabled_group_expanded); + let rows_after_expand = state.subscription_rows(); + assert!(rows_after_expand[0].text.starts_with('▼')); +} + +#[test] +fn enter_on_category_header_toggles_expand_and_collapse() { + let mut state = AppState::new(vec![], test_runtime()); + state.focus = Pane::Subscriptions; + state.subscription_row_index = state + .subscription_rows() + .iter() + .position(|row| row.text.contains("linux subsystem")) + .expect("linux category header exists"); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(!state.disabled_linux_subsystem_expanded); + let rows_after_collapse = state.subscription_rows(); + let linux_header_after_collapse = rows_after_collapse + .iter() + .find(|row| row.text.contains("linux subsystem")) + .expect("linux category header exists after collapse"); + assert!(linux_header_after_collapse.text.starts_with(" ▶")); + assert!( + !rows_after_collapse + .iter() + .any(|row| row.text.contains("[n] io-uring")) + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(state.disabled_linux_subsystem_expanded); + let rows_after_expand = state.subscription_rows(); + let linux_header_after_expand = rows_after_expand + .iter() + .find(|row| row.text.contains("linux subsystem")) + .expect("linux category header exists after expand"); + assert!(linux_header_after_expand.text.starts_with(" ▼")); + assert!( + rows_after_expand + .iter() + .any(|row| row.text.contains("[n] io-uring")) + ); +} + +#[test] +fn first_open_starts_with_all_subscriptions_disabled() { + let state = AppState::new(vec![], test_runtime()); + assert!(state.subscriptions.iter().all(|item| !item.enabled)); +} + +#[test] +fn subscription_rows_show_linux_and_qemu_categories() { + let state = AppState::new(vec![], test_runtime()); + let rows = state.subscription_rows(); + + let linux_header = rows + .iter() + .position(|row| row.text.contains("linux subsystem")) + .expect("linux category header exists"); + let qemu_header = rows + .iter() + .position(|row| row.text.contains("qemu subsystem")) + .expect("qemu category header exists"); + let qemu_devel = rows + .iter() + .position(|row| row.text.contains("[n] qemu-devel")) + .expect("qemu-devel row exists"); + + assert!(linux_header < qemu_header); + assert!(qemu_header < qemu_devel); +} + +#[test] +fn qemu_mailbox_case_variants_reuse_the_default_subscription() { + let mut runtime = test_runtime(); + runtime.source_mailbox = "QEMU-devel".to_string(); + + let state = AppState::new_with_ui_state( + vec![], + runtime, + Some(UiState { + enabled_mailboxes: vec!["QEMU-devel".to_string()], + active_mailbox: Some("QEMU-devel".to_string()), + ..UiState::default() + }), + ); + + let qemu_devel_items: Vec<&SubscriptionItem> = state + .subscriptions + .iter() + .filter(|item| item.mailbox.eq_ignore_ascii_case("qemu-devel")) + .collect(); + + assert_eq!(qemu_devel_items.len(), 1); + assert_eq!(qemu_devel_items[0].mailbox, "qemu-devel"); + assert!(qemu_devel_items[0].enabled); + assert_eq!( + qemu_devel_items[0].category, + Some(SubscriptionCategory::QemuSubsystem) + ); + assert_eq!( + state.subscriptions[state.subscription_index].mailbox, + "qemu-devel" + ); +} + +#[test] +fn first_open_with_complete_imap_enables_my_inbox() { + let state = AppState::new(vec![], test_runtime_with_imap()); + let my_inbox = state + .subscriptions + .iter() + .find(|item| item.mailbox == IMAP_INBOX_MAILBOX) + .expect("my inbox subscription exists"); + + assert!(my_inbox.enabled); + assert_eq!(my_inbox.label, MY_INBOX_LABEL); + assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); +} + +#[test] +fn app_state_restores_and_re_persists_mail_pane_layout_from_ui_state() { + let state = AppState::new_with_ui_state( + vec![], + test_runtime(), + Some(UiState { + mail_subscriptions_width: 29, + mail_preview_width: 82, + ..UiState::default() + }), + ); + + assert_eq!(state.mail_pane_layout.subscriptions_width, 29); + assert_eq!(state.mail_pane_layout.preview_width, 82); + + let persisted = state.to_ui_state(); + assert_eq!(persisted.mail_subscriptions_width, 29); + assert_eq!(persisted.mail_preview_width, 82); +} + +#[test] +fn legacy_ui_state_with_complete_imap_enables_my_inbox_once() { + let state = AppState::new_with_ui_state( + vec![], + test_runtime_with_imap(), + Some(UiState { + enabled_mailboxes: vec!["io-uring".to_string()], + enabled_group_expanded: true, + disabled_group_expanded: true, + enabled_linux_subsystem_expanded: true, + enabled_qemu_subsystem_expanded: true, + disabled_linux_subsystem_expanded: true, + disabled_qemu_subsystem_expanded: true, + imap_defaults_initialized: false, + active_mailbox: Some("io-uring".to_string()), + ..UiState::default() + }), + ); + + let my_inbox = state + .subscriptions + .iter() + .find(|item| item.mailbox == IMAP_INBOX_MAILBOX) + .expect("my inbox subscription exists"); + + assert!(my_inbox.enabled); + assert!(state.imap_defaults_initialized); +} + +#[test] +fn initialized_ui_state_keeps_my_inbox_disabled_when_user_opted_out() { + let state = AppState::new_with_ui_state( + vec![], + test_runtime_with_imap(), + Some(UiState { + enabled_mailboxes: vec!["io-uring".to_string()], + enabled_group_expanded: true, + disabled_group_expanded: true, + enabled_linux_subsystem_expanded: true, + enabled_qemu_subsystem_expanded: true, + disabled_linux_subsystem_expanded: true, + disabled_qemu_subsystem_expanded: true, + imap_defaults_initialized: true, + active_mailbox: Some("io-uring".to_string()), + ..UiState::default() + }), + ); + + let my_inbox = state + .subscriptions + .iter() + .find(|item| item.mailbox == IMAP_INBOX_MAILBOX) + .expect("my inbox subscription exists"); + + assert!(!my_inbox.enabled); + assert!(state.imap_defaults_initialized); +} + +#[test] +fn catch_sync_panic_converts_panics_into_errors() { + let error = catch_sync_panic("INBOX", || -> crate::infra::error::Result<()> { + panic!("boom"); + }) + .expect_err("panic should become criew error"); + + assert!(error.to_string().contains("sync panicked for INBOX: boom")); +} + +#[test] +fn empty_active_inbox_recovers_to_cached_enabled_mailbox() { + let root = temp_dir("imap-fallback-cache"); + let runtime = test_runtime_with_imap_in(root.clone()); + seed_mailbox_thread( + &runtime.database_path, + "kvm", + 1, + "kvm@example.com", + "kvm thread", + ); + + let mut state = AppState::new_with_ui_state( + vec![], + runtime, + Some(UiState { + enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string(), "kvm".to_string()], + enabled_group_expanded: true, + disabled_group_expanded: true, + enabled_linux_subsystem_expanded: true, + enabled_qemu_subsystem_expanded: true, + disabled_linux_subsystem_expanded: true, + disabled_qemu_subsystem_expanded: true, + imap_defaults_initialized: true, + active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + ..UiState::default() + }), + ); + + assert!(state.recover_from_empty_active_mailbox("inbox unavailable")); + assert_eq!(state.active_thread_mailbox, "kvm"); + assert_eq!(state.threads.len(), 1); + assert!(state.status.contains("showing threads for kvm")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn startup_sync_failure_for_empty_inbox_falls_back_to_cached_mailbox() { + let root = temp_dir("imap-fallback-startup"); + let runtime = test_runtime_with_imap_in(root.clone()); + seed_mailbox_thread( + &runtime.database_path, + "io-uring", + 1, + "io-uring@example.com", + "io_uring thread", + ); + + let mut state = AppState::new_with_ui_state( + vec![], + runtime, + Some(UiState { + enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string(), "io-uring".to_string()], + enabled_group_expanded: true, + disabled_group_expanded: true, + enabled_linux_subsystem_expanded: true, + enabled_qemu_subsystem_expanded: true, + disabled_linux_subsystem_expanded: true, + disabled_qemu_subsystem_expanded: true, + imap_defaults_initialized: true, + active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + ..UiState::default() + }), + ); + + state.apply_startup_sync_event(StartupSyncEvent::MailboxFailed { + mailbox: IMAP_INBOX_MAILBOX.to_string(), + error: "imap unavailable".to_string(), + }); + + assert_eq!(state.active_thread_mailbox, "io-uring"); + assert_eq!(state.threads.len(), 1); + assert!(state.status.contains("showing threads for io-uring")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn command_palette_sync_queues_background_job_and_resets_my_inbox_auto_sync_deadline() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.manual_sync_spawner = manual_sync_spawner_idle; + state + .inbox_auto_sync + .as_mut() + .expect("inbox auto-sync state") + .next_due_at = Instant::now() - Duration::from_secs(1); + + run_palette_sync(&mut state, "sync INBOX"); + + assert!(state.status.contains("sync queued in background")); + assert!(state.manual_sync.is_some()); + assert!( + state + .inbox_auto_sync + .as_ref() + .expect("inbox auto-sync state") + .next_due_at + > Instant::now() + Duration::from_secs(20) + ); +} + +#[test] +fn opening_empty_inbox_queues_background_sync_and_defers_next_auto_sync_tick() { + let root = temp_dir("imap-open-inbox-sync"); + let runtime = test_runtime_with_imap_in(root.clone()); + fs::create_dir_all(runtime.database_path.parent().expect("db parent")) + .expect("create db parent"); + db::initialize(&runtime.database_path).expect("initialize db"); + + let mut state = AppState::new(vec![], runtime); + state.manual_sync_spawner = manual_sync_spawner_idle; + state + .inbox_auto_sync + .as_mut() + .expect("inbox auto-sync state") + .next_due_at = Instant::now() - Duration::from_secs(1); + + state.open_threads_for_selected_subscription(); + + assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); + assert!(state.threads.is_empty()); + assert!(state.status.contains("syncing in background")); + assert!(state.manual_sync.is_some()); + assert!(matches!(state.focus, Pane::Threads)); + assert!( + state + .inbox_auto_sync + .as_ref() + .expect("inbox auto-sync state") + .next_due_at + > Instant::now() + Duration::from_secs(20) + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn manual_sync_same_mailbox_request_reports_already_syncing() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.manual_sync = Some(manual_sync_state(&[( + IMAP_INBOX_MAILBOX, + StartupSyncMailboxStatus::InFlight, + )])); + + let outcome = + state.start_manual_sync(vec!["inbox".to_string()], ManualSyncOrigin::PaletteCommand); + + assert_eq!(outcome, ManualSyncRequestOutcome::AlreadySyncing); + assert!(state.status.contains("sync already running in background")); +} + +#[test] +fn manual_sync_different_mailbox_request_reports_busy() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.manual_sync = Some(manual_sync_state(&[( + "io-uring", + StartupSyncMailboxStatus::InFlight, + )])); + + let outcome = state.start_manual_sync( + vec![IMAP_INBOX_MAILBOX.to_string()], + ManualSyncOrigin::PaletteCommand, + ); + + assert_eq!(outcome, ManualSyncRequestOutcome::Busy); + assert!(state.status.contains("background sync busy")); + assert!(state.status.contains("0/1")); +} + +#[test] +fn manual_sync_dedups_case_variants_and_defers_auto_sync_deadlines() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.manual_sync_spawner = manual_sync_spawner_idle; + let io_uring_index = state + .subscriptions + .iter() + .position(|item| item.mailbox == "io-uring") + .expect("io-uring subscription exists"); + state.subscriptions[io_uring_index].enabled = true; + state.reconcile_subscription_auto_sync(); + state + .inbox_auto_sync + .as_mut() + .expect("inbox auto-sync state") + .next_due_at = Instant::now() - Duration::from_secs(1); + state + .subscription_auto_sync + .as_mut() + .expect("subscription auto-sync state") + .next_due_at = Instant::now() - Duration::from_secs(1); + + let outcome = state.start_manual_sync( + vec![ + IMAP_INBOX_MAILBOX.to_string(), + "inbox".to_string(), + "io-uring".to_string(), + "IO-URING".to_string(), + ], + ManualSyncOrigin::PaletteCommand, + ); + + assert_eq!(outcome, ManualSyncRequestOutcome::Started); + let sync_state = state.manual_sync.as_ref().expect("manual sync state"); + assert_eq!( + sync_state.mailbox_order, + vec!["INBOX".to_string(), "io-uring".to_string()] + ); + assert!( + state + .inbox_auto_sync + .as_ref() + .expect("inbox auto-sync state") + .next_due_at + > Instant::now() + Duration::from_secs(20) + ); + assert!( + state + .subscription_auto_sync + .as_ref() + .expect("subscription auto-sync state") + .next_due_at + > Instant::now() + Duration::from_secs(20) + ); +} + +#[test] +fn command_palette_sync_queues_background_job_and_resets_subscription_auto_sync_deadline() { + let mut state = AppState::new(vec![], test_runtime()); + state.manual_sync_spawner = manual_sync_spawner_idle; + let io_uring_index = state + .subscriptions + .iter() + .position(|item| item.mailbox == "io-uring") + .expect("io-uring subscription exists"); + state.subscriptions[io_uring_index].enabled = true; + state.reconcile_subscription_auto_sync(); + state + .subscription_auto_sync + .as_mut() + .expect("subscription auto-sync state") + .next_due_at = Instant::now() - Duration::from_secs(1); + + run_palette_sync(&mut state, "sync io-uring"); + + assert!(state.status.contains("sync queued in background")); + assert!(state.manual_sync.is_some()); + assert!( + state + .subscription_auto_sync + .as_ref() + .expect("subscription auto-sync state") + .next_due_at + > Instant::now() + Duration::from_secs(20) + ); +} + +#[test] +fn manual_sync_completion_refreshes_active_mailbox_after_worker_finishes() { + let root = temp_dir("manual-sync-finish-refresh"); + let runtime = test_runtime_with_imap_in(root.clone()); + fs::create_dir_all(runtime.database_path.parent().expect("db parent")) + .expect("create db parent"); + db::initialize(&runtime.database_path).expect("initialize db"); + + let mut state = AppState::new(vec![], runtime); + state.manual_sync_spawner = manual_sync_spawner_seed_success; + + state.open_threads_for_selected_subscription(); + assert!(state.threads.is_empty()); + assert!(state.manual_sync.is_some()); + + state.pump_manual_sync_events(); + + assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); + assert_eq!(state.threads.len(), 1); + assert!(state.status.contains("sync finished: ok=1")); + assert!(state.manual_sync.is_none()); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn manual_sync_failure_finishes_with_first_error_summary() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.manual_sync = Some(manual_sync_state(&[( + IMAP_INBOX_MAILBOX, + StartupSyncMailboxStatus::Pending, + )])); + + state.apply_manual_sync_event(StartupSyncEvent::MailboxFailed { + mailbox: IMAP_INBOX_MAILBOX.to_string(), + error: "imap unavailable".to_string(), + }); + + assert!(state.manual_sync.is_none()); + assert_eq!(state.status, "sync failed: INBOX: imap unavailable"); +} + +#[test] +fn manual_sync_partial_failure_reports_partial_summary() { + let mut state = AppState::new(vec![], test_runtime()); + state.manual_sync = Some(manual_sync_state(&[ + ("io-uring", StartupSyncMailboxStatus::Pending), + ("kvm", StartupSyncMailboxStatus::Pending), + ])); + + state.apply_manual_sync_event(StartupSyncEvent::MailboxFinished { + mailbox: "io-uring".to_string(), + fetched: 2, + inserted: 1, + updated: 0, + }); + assert!(state.manual_sync.is_some()); + + state.apply_manual_sync_event(StartupSyncEvent::MailboxFailed { + mailbox: "kvm".to_string(), + error: "network timeout".to_string(), + }); + + assert!(state.manual_sync.is_none()); + assert!(state.status.contains("sync finished with failures")); + assert!(state.status.contains("ok=1 failed=1")); + assert!(state.status.contains("fetched=2 inserted=1 updated=0")); +} + +#[test] +fn manual_sync_worker_disconnect_reports_failure_summary() { + let mut state = AppState::new(vec![], test_runtime()); + state.manual_sync_spawner = manual_sync_spawner_idle; + + let outcome = state.start_manual_sync( + vec!["io-uring".to_string()], + ManualSyncOrigin::PaletteCommand, + ); + assert_eq!(outcome, ManualSyncRequestOutcome::Started); + + state.pump_manual_sync_events(); + + assert!(state.manual_sync.is_none()); + assert!(state.status.contains("background sync worker disconnected")); +} + +#[test] +fn enter_on_mailbox_pending_startup_sync_stays_non_blocking() { + let root = temp_dir("imap-pending-enter"); + let runtime = test_runtime_with_imap_in(root.clone()); + fs::create_dir_all(runtime.database_path.parent().expect("db parent")) + .expect("create db parent"); + db::initialize(&runtime.database_path).expect("initialize db"); + + let mut state = AppState::new_with_ui_state( + vec![], + runtime, + Some(UiState { + enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string()], + enabled_group_expanded: true, + disabled_group_expanded: true, + enabled_linux_subsystem_expanded: true, + enabled_qemu_subsystem_expanded: true, + disabled_linux_subsystem_expanded: true, + disabled_qemu_subsystem_expanded: true, + imap_defaults_initialized: true, + active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + ..UiState::default() + }), + ); + state.focus = Pane::Subscriptions; + state.startup_sync = Some(startup_sync_state(&[( + IMAP_INBOX_MAILBOX, + StartupSyncMailboxStatus::InFlight, + )])); + + state.open_threads_for_selected_subscription(); + + assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); + assert!(matches!(state.focus, Pane::Threads)); + assert!(state.threads.is_empty()); + assert!(state.status.contains("syncing in background")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn enter_on_mailbox_pending_manual_sync_stays_non_blocking() { + let root = temp_dir("imap-pending-manual-enter"); + let runtime = test_runtime_with_imap_in(root.clone()); + fs::create_dir_all(runtime.database_path.parent().expect("db parent")) + .expect("create db parent"); + db::initialize(&runtime.database_path).expect("initialize db"); + + let mut state = AppState::new_with_ui_state( + vec![], + runtime, + Some(UiState { + enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string()], + enabled_group_expanded: true, + disabled_group_expanded: true, + enabled_linux_subsystem_expanded: true, + enabled_qemu_subsystem_expanded: true, + disabled_linux_subsystem_expanded: true, + disabled_qemu_subsystem_expanded: true, + imap_defaults_initialized: true, + active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + ..UiState::default() + }), + ); + state.focus = Pane::Subscriptions; + state.manual_sync = Some(manual_sync_state(&[( + IMAP_INBOX_MAILBOX, + StartupSyncMailboxStatus::InFlight, + )])); + + state.open_threads_for_selected_subscription(); + + assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); + assert!(state.threads.is_empty()); + assert!(state.status.contains("syncing in background")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn opening_empty_mailbox_while_other_manual_sync_is_busy_shows_busy_hint() { + let root = temp_dir("manual-sync-busy-open"); + let runtime = test_runtime_with_imap_in(root.clone()); + fs::create_dir_all(runtime.database_path.parent().expect("db parent")) + .expect("create db parent"); + db::initialize(&runtime.database_path).expect("initialize db"); + + let mut state = AppState::new(vec![], runtime); + state.manual_sync = Some(manual_sync_state(&[( + "io-uring", + StartupSyncMailboxStatus::InFlight, + )])); + + state.open_threads_for_selected_subscription(); + + assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); + assert!(state.threads.is_empty()); + assert!(state.status.contains("another background sync is running")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn background_success_does_not_steal_focus_from_pending_inbox() { + let root = temp_dir("imap-pending-focus"); + let runtime = test_runtime_with_imap_in(root.clone()); + seed_mailbox_thread( + &runtime.database_path, + "kvm", + 1, + "kvm@example.com", + "kvm thread", + ); + + let mut state = AppState::new_with_ui_state( + vec![], + runtime, + Some(UiState { + enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string(), "kvm".to_string()], + enabled_group_expanded: true, + disabled_group_expanded: true, + enabled_linux_subsystem_expanded: true, + enabled_qemu_subsystem_expanded: true, + disabled_linux_subsystem_expanded: true, + disabled_qemu_subsystem_expanded: true, + imap_defaults_initialized: true, + active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + ..UiState::default() + }), + ); + state.startup_sync = Some(startup_sync_state(&[ + (IMAP_INBOX_MAILBOX, StartupSyncMailboxStatus::InFlight), + ("kvm", StartupSyncMailboxStatus::Pending), + ])); + + state.apply_startup_sync_event(StartupSyncEvent::MailboxFinished { + mailbox: "kvm".to_string(), + fetched: 1, + inserted: 1, + updated: 0, + }); + + assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); + assert!(state.threads.is_empty()); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn y_and_n_toggle_subscription_and_keep_grouped_sort_order() { + let mut state = AppState::new(vec![], test_runtime()); + state.focus = Pane::Subscriptions; + + let target_index = state + .subscriptions + .iter() + .position(|item| item.mailbox == "bpf") + .expect("bpf subscription exists"); + state.subscription_index = target_index; + state.sync_subscription_row_to_selected_item(); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), + ); + + let bpf_after_enable = state + .subscriptions + .iter() + .position(|item| item.mailbox == "bpf") + .expect("bpf exists after enable"); + assert!(state.subscriptions[bpf_after_enable].enabled); + + let first_disabled = state + .subscriptions + .iter() + .position(|item| !item.enabled) + .expect("has disabled subscriptions"); + assert!(bpf_after_enable < first_disabled); + + let enabled_group = &state.subscriptions[..first_disabled]; + assert!( + enabled_group + .windows(2) + .all(|pair| subscription_sort_key(&pair[0]) <= subscription_sort_key(&pair[1])) + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + ); + + let bpf_after_disable = state + .subscriptions + .iter() + .position(|item| item.mailbox == "bpf") + .expect("bpf exists after disable"); + assert!(!state.subscriptions[bpf_after_disable].enabled); + + let last_enabled = state.subscriptions.iter().rposition(|item| item.enabled); + if let Some(last_enabled) = last_enabled { + assert!(bpf_after_disable > last_enabled); + + let disabled_group = &state.subscriptions[last_enabled + 1..]; + assert!( + disabled_group + .windows(2) + .all(|pair| subscription_sort_key(&pair[0]) <= subscription_sort_key(&pair[1])) + ); + } else { + assert!(state.subscriptions.iter().all(|item| !item.enabled)); + assert!( + state + .subscriptions + .windows(2) + .all(|pair| subscription_sort_key(&pair[0]) <= subscription_sort_key(&pair[1])) + ); + } +} + +#[test] +fn slash_opens_search_and_filters_threads() { + let mut state = AppState::new( + vec![ + sample_thread("[PATCH] mm cleanup", "a@example.com", 0), + sample_thread("[PATCH] net fix", "b@example.com", 0), + ], + test_runtime(), + ); + + let action_search = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), + ); + assert!(matches!(action_search, LoopAction::Continue)); + assert!(state.search.active); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(!state.search.active); + assert_eq!(state.filtered_thread_indices.len(), 1); + let selected = state.selected_thread().expect("selected thread"); + assert_eq!(selected.message_id, "b@example.com"); +} + +#[test] +fn search_on_code_browser_reports_mail_only_scope() { + let mut state = AppState::new(vec![], test_runtime()); + state.ui_page = UiPage::CodeBrowser; + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), + ); + + assert!(matches!(action, LoopAction::Continue)); + assert!(!state.search.active); + assert_eq!(state.status, "search is only available on mail page"); +} + +#[test] +fn search_backspace_and_escape_clear_pending_query() { + let mut state = AppState::new(vec![], test_runtime()); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), + ); + type_text(&mut state, "ab"); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + assert_eq!(state.search.input, "a"); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!state.search.active); + assert!(state.search.input.is_empty()); + assert_eq!(state.status, "search cancelled"); +} + +#[test] +fn ctrl_backtick_closes_open_palette() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = "help".to_string(); + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('`'), KeyModifiers::CONTROL), + ); + + assert!(matches!(action, LoopAction::Continue)); + assert!(!state.palette.open); + assert!(state.palette.input.is_empty()); + assert_eq!(state.status, "command palette closed"); +} + +#[test] +fn palette_reports_empty_and_unknown_commands() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = " ".to_string(); + + let empty_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(empty_action, LoopAction::Continue)); + assert_eq!(state.status, "empty command"); + + state.palette.open = true; + state.palette.input = "wat".to_string(); + let unknown_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(unknown_action, LoopAction::Continue)); + assert_eq!(state.status, "unknown command: wat"); +} + +#[test] +fn palette_escape_backspace_and_char_input_update_buffer() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = "he".to_string(); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + assert_eq!(state.palette.input, "h"); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + assert_eq!(state.palette.input, "hi"); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!state.palette.open); + assert!(state.palette.input.is_empty()); +} + +#[test] +fn palette_sync_command_runs_via_handle_key_event() { + let mut state = AppState::new(vec![], test_runtime()); + state.manual_sync_spawner = manual_sync_spawner_idle; + state.palette.open = true; + state.palette.input = "sync io-uring".to_string(); + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(matches!(action, LoopAction::Continue)); + assert!(state.status.contains("sync queued in background")); + assert!(state.manual_sync.is_some()); + assert!(!state.palette.open); + assert!(state.palette.input.is_empty()); +} + +#[test] +fn palette_bang_reports_empty_local_command() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = "! ".to_string(); + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(matches!(action, LoopAction::Continue)); + assert_eq!(state.status, "empty local command after !"); +} + +#[test] +fn enter_on_thread_focuses_preview_and_sets_selected_status_message() { + let mut state = AppState::new( + vec![sample_thread("normal mail", "plain@example.com", 0)], + test_runtime(), + ); + state.focus = Pane::Threads; + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(matches!(action, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Preview)); + assert_eq!(state.status, "selected plain@example.com"); +} + +#[test] +fn escape_quit_and_ctrl_c_show_exit_guidance() { + let mut state = AppState::new(vec![], test_runtime()); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + state.status, + "open command palette with : (preferred) or Ctrl+`" + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + assert_eq!( + state.status, + "q emergency exit disabled; use command palette quit/exit" + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert_eq!( + state.status, + "Ctrl+C is disabled, use command palette quit/exit" + ); +} + +#[test] +fn jl_focus_and_ik_move_selection() { + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + test_runtime(), + ); + state.subscription_index = 1; + + let action_l = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + assert!(matches!(action_l, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Threads)); + + let action_i = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + assert!(matches!(action_i, LoopAction::Continue)); + assert_eq!(state.thread_index, 0); + + let action_k = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert!(matches!(action_k, LoopAction::Continue)); + assert_eq!(state.thread_index, 1); + + let action_j = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(action_j, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Subscriptions)); +} + +#[test] +fn vim_keymap_uses_hl_focus_and_jk_move_selection() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + runtime, + ); + state.subscription_index = 1; + + let action_l = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + assert!(matches!(action_l, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Threads)); + + let action_j = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(action_j, LoopAction::Continue)); + assert_eq!(state.thread_index, 1); + + let action_k = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert!(matches!(action_k, LoopAction::Continue)); + assert_eq!(state.thread_index, 0); + + let action_h = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + ); + assert!(matches!(action_h, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Subscriptions)); +} + +#[test] +fn a_d_and_u_require_patch_series_or_apply_snapshot_on_thread_focus() { + let mut state = AppState::new( + vec![sample_thread("normal mail", "plain@example.com", 0)], + test_runtime(), + ); + state.focus = Pane::Threads; + + let action_apply = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + ); + assert!(matches!(action_apply, LoopAction::Continue)); + assert!(state.status.contains("not a patch series")); + + let action_download = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), + ); + assert!(matches!(action_download, LoopAction::Continue)); + assert!(state.status.contains("not a patch series")); + + let action_undo = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE), + ); + assert!(matches!(action_undo, LoopAction::Continue)); + assert!(state.status.contains("no apply action to undo")); +} + +#[test] +fn inline_ui_text_collapses_multiline_errors() { + let sanitized = sanitize_inline_ui_text( + "sync failed:\nCould not automatically determine provider\r\n\tline2", + ); + + assert_eq!( + sanitized, + "sync failed: Could not automatically determine provider line2" + ); +} + +#[test] +fn preview_hides_rfc_headers_and_keeps_body() { + let raw = b"Message-ID: \r\nSubject: test\r\nFrom: a@example.com\r\n\r\nhello\nworld\n"; + let preview = extract_mail_body_preview(raw); + assert!(!preview.contains("Message-ID:")); + assert!(!preview.contains("Subject: test")); + assert!(preview.contains("hello")); + assert!(preview.contains("world")); +} + +#[test] +fn preview_skips_first_mime_part_headers() { + let raw = b"Content-Type: multipart/alternative; boundary=\"abc\"\r\n\r\n--abc\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\nplain body line\r\n--abc--\r\n"; + let preview = extract_mail_body_preview(raw); + assert!(!preview.contains("Content-Transfer-Encoding")); + assert!(preview.contains("plain body line")); +} + +#[test] +fn preview_strips_control_characters() { + let raw = + b"Message-ID: \r\nSubject: test\r\n\r\nline1\x1b[31m\x07\nline2\tok\r\n"; + let preview = extract_mail_body_preview(raw); + assert!(!preview.contains('\u{001b}')); + assert!(!preview.contains('\u{0007}')); + assert!(!preview.contains('\t')); + assert!(preview.contains("line1")); + assert!(preview.contains("line2 ok")); +} + +#[test] +fn preview_shows_from_sent_to_cc_headers() { + let raw = b"From: Chen Miao \r\nDate: Monday, March 2, 2026 5:29 PM\r\nTo: Daniel Baluta ; Simona Toaca \r\nCc: Team One \r\nSubject: [PATCH] demo\r\n\r\nmail body line\n"; + let preview = extract_mail_preview(raw, "(no subject)", "", None); + + assert!(preview.contains("From: Chen Miao ")); + assert!(preview.contains("Sent: Monday, March 2, 2026 5:29 PM")); + assert!(preview.contains( + "To: Daniel Baluta ; Simona Toaca " + )); + assert!(preview.contains("Cc: Team One ")); + assert!(preview.contains("Subject: [PATCH] demo")); + assert!(preview.contains("mail body line")); +} + +#[test] +fn preview_truncates_to_and_cc_recipient_lists() { + let raw = b"From: sender@example.com\r\nDate: Tue, 3 Mar 2026 12:00:00 +0000\r\nTo: A , B , C \r\nCc: X ; Y ; Z \r\nSubject: test\r\n\r\nbody\n"; + let preview = extract_mail_preview(raw, "(no subject)", "", None); + + assert!(preview.contains("To: A ; B ; ...")); + assert!(preview.contains("Cc: X ; Y ; ...")); + assert!(!preview.contains("C ")); + assert!(!preview.contains("Z ")); +} + +#[test] +fn preview_warns_for_multipart_mail() { + let raw = b"Content-Type: multipart/alternative; boundary=\"abc\"\r\n\r\n--abc\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nplain body line\r\n--abc--\r\n"; + let warning = preview_warning_message(raw).expect("warning expected"); + + assert!(warning.contains("NON-PLAIN-TEXT MAIL")); + assert!(warning.contains("Parse artifacts/errors are normal")); + assert!(warning.contains("Content-Type: multipart/alternative; boundary=\"abc\"")); +} + +#[test] +fn preview_warns_for_encoded_html_mail() { + let raw = b"Content-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nhello\r\n"; + let warning = preview_warning_message(raw).expect("warning expected"); + + assert!(warning.contains("NON-PLAIN-TEXT MAIL")); + assert!(warning.contains("Content-Type: text/html; charset=utf-8")); + assert!(warning.contains("Transfer-Encoding: quoted-printable")); +} + +#[test] +fn multiline_sync_error_does_not_break_footer_or_palette_render() { + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); + state.status = "sync failed: E1007:\nCould not automatically determine provider".to_string(); + state.palette.open = true; + + let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw multiline status"); + let rendered = format!("{}", terminal.backend()); + + assert!(rendered.contains("sync failed: E1007: Could not automatically determine provider")); + assert!(rendered.contains("Command Palette")); +} + +#[test] +fn header_shows_criew_brand_and_default_footer_hides_empty_status() { + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let state = AppState::new(vec![], runtime.clone()); + + let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw branded header"); + let rendered = format!("{}", terminal.backend()); + + assert!(rendered.contains("CRIEW")); + assert!(rendered.contains(env!("CARGO_PKG_VERSION"))); + assert!(rendered.contains("Mail / inbox")); + assert!(rendered.contains("keymap default")); + assert!(!rendered.contains("db schema")); + assert!(!rendered.contains("db:")); + assert!(!rendered.contains("status:")); + assert!(!rendered.contains(" ready ")); +} + +#[test] +fn header_shows_custom_keymap_scheme_when_configured() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Custom; + let bootstrap = test_bootstrap(&runtime); + let state = AppState::new(vec![], runtime.clone()); + + let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw custom keymap header"); + let rendered = format!("{}", terminal.backend()); + + assert!(rendered.contains("keymap custom")); +} + +#[test] +fn header_formats_uptime_with_clock_units() { + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); + state.started_at = Instant::now() - Duration::from_secs(61); + + let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw formatted uptime header"); + let rendered = format!("{}", terminal.backend()); + + assert!(rendered.contains("up 01m:")); + assert!(!rendered.contains("up 61s")); +} + +#[test] +fn startup_sync_progress_bar_renders_at_right_edge_of_header() { + let runtime = test_runtime_in(PathBuf::from("/t")); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); + state.startup_sync = Some(startup_sync_state(&[ + ("INBOX", StartupSyncMailboxStatus::InFlight), + ("io-uring", StartupSyncMailboxStatus::Pending), + ("kvm", StartupSyncMailboxStatus::Finished), + ])); + state.status = "startup sync [1/3] syncing INBOX...".to_string(); + + let mut terminal = Terminal::new(TestBackend::new(260, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw startup sync"); + let rendered = format!("{}", terminal.backend()); + let header_row = rendered_row_text(&terminal, 0); + let footer_row = rendered_row_text(&terminal, terminal.backend().buffer().area().height - 1); + let progress_text = sanitize_inline_ui_text( + &state + .background_sync_progress_text() + .expect("background sync progress"), + ); + + assert!(rendered.contains("Mail / inbox")); + assert!(rendered.contains("sync [")); + assert!(rendered.contains("1/3")); + assert!(rendered.contains("INBOX")); + assert!(rendered.contains("startup sync [1/3] syncing INBOX...")); + assert!(header_row.trim_end().ends_with(&progress_text)); + assert!(header_row.ends_with(' ')); + assert!(!footer_row.contains(&progress_text)); +} + +#[test] +fn background_sync_progress_text_prefers_manual_sync_over_other_sources() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.manual_sync = Some(manual_sync_state(&[( + "io-uring", + StartupSyncMailboxStatus::InFlight, + )])); + state.startup_sync = Some(startup_sync_state(&[( + IMAP_INBOX_MAILBOX, + StartupSyncMailboxStatus::InFlight, + )])); + state + .inbox_auto_sync + .as_mut() + .expect("inbox auto-sync state") + .receiver = Some(mpsc::channel().1); + + let progress = state + .background_sync_progress_text() + .expect("background progress"); + + assert!(progress.contains("0/1")); + assert!(progress.contains("io-uring")); + assert!(!progress.contains("auto INBOX")); +} + +#[test] +fn background_sync_progress_text_reports_auto_sync_sources() { + let mut inbox_state = AppState::new(vec![], test_runtime_with_imap()); + inbox_state + .inbox_auto_sync + .as_mut() + .expect("inbox auto-sync state") + .receiver = Some(mpsc::channel().1); + let inbox_progress = inbox_state + .background_sync_progress_text() + .expect("inbox progress"); + assert!(inbox_progress.contains("auto INBOX")); + assert_eq!(inbox_progress.matches('>').count(), 3); + + let mut subscription_state = AppState::new(vec![], test_runtime()); + let io_uring_index = subscription_state + .subscriptions + .iter() + .position(|item| item.mailbox == "io-uring") + .expect("io-uring subscription exists"); + subscription_state.subscriptions[io_uring_index].enabled = true; + subscription_state.reconcile_subscription_auto_sync(); + let state = subscription_state + .subscription_auto_sync + .as_mut() + .expect("subscription auto-sync state"); + state.receiver = Some(mpsc::channel().1); + state.in_flight_mailboxes.insert("io-uring".to_string()); + + let subscription_progress = subscription_state + .background_sync_progress_text() + .expect("subscription progress"); + assert!(subscription_progress.contains("auto io-uring")); + assert_eq!(subscription_progress.matches('>').count(), 3); +} + +#[test] +fn progress_bar_helpers_cover_zero_total_and_completed_states() { + let state = AppState::new(vec![], test_runtime()); - assert!(!my_inbox.enabled); - assert!(state.imap_defaults_initialized); + let zero_total = state.render_progress_bar(0, 0); + let completed = state.render_progress_bar(3, 3); + let indeterminate = state.render_indeterminate_progress_bar(); + + assert_eq!(zero_total, "[............]"); + assert!(completed.starts_with('[')); + assert!(completed.ends_with(']')); + assert_eq!(completed.matches('=').count(), 12); + assert_eq!(completed.matches('>').count(), 0); + assert_eq!(indeterminate.len(), 14); + assert_eq!(indeterminate.matches('>').count(), 3); } #[test] -fn catch_sync_panic_converts_panics_into_errors() { - let error = catch_sync_panic("INBOX", || -> crate::infra::error::Result<()> { - panic!("boom"); - }) - .expect_err("panic should become criew error"); +fn manual_sync_progress_bar_is_rendered_at_right_edge_of_header() { + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); + state.manual_sync = Some(manual_sync_state(&[ + (IMAP_INBOX_MAILBOX, StartupSyncMailboxStatus::InFlight), + ("io-uring", StartupSyncMailboxStatus::Pending), + ])); - assert!(error.to_string().contains("sync panicked for INBOX: boom")); + let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw manual sync progress"); + let rendered = format!("{}", terminal.backend()); + let header_row = rendered_row_text(&terminal, 0); + let footer_row = rendered_row_text(&terminal, terminal.backend().buffer().area().height - 1); + let progress_text = sanitize_inline_ui_text( + &state + .background_sync_progress_text() + .expect("background sync progress"), + ); + + assert!(rendered.contains("sync [")); + assert!(rendered.contains("0/2")); + assert!(rendered.contains(IMAP_INBOX_MAILBOX)); + assert!(header_row.trim_end().ends_with(&progress_text)); + assert!(header_row.ends_with(' ')); + assert!(!footer_row.contains(&progress_text)); } #[test] -fn empty_active_inbox_recovers_to_cached_enabled_mailbox() { - let root = temp_dir("imap-fallback-cache"); - let runtime = test_runtime_with_imap_in(root.clone()); - seed_mailbox_thread( - &runtime.database_path, - "kvm", - 1, - "kvm@example.com", - "kvm thread", +fn mail_preview_e_opens_reply_panel_with_autofilled_headers() { + let root = temp_dir("reply-open"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: CRIEW Test , Bob \r\nCc: Alice \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + runtime.clone(), ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; - let mut state = AppState::new_with_ui_state( - vec![], - runtime, - Some(UiState { - enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string(), "kvm".to_string()], - enabled_group_expanded: true, - disabled_group_expanded: true, - enabled_linux_subsystem_expanded: true, - enabled_qemu_subsystem_expanded: true, - disabled_linux_subsystem_expanded: true, - disabled_qemu_subsystem_expanded: true, - imap_defaults_initialized: true, - active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), - }), + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); - assert!(state.recover_from_empty_active_mailbox("inbox unavailable")); - assert_eq!(state.active_thread_mailbox, "kvm"); - assert_eq!(state.threads.len(), 1); - assert!(state.status.contains("showing threads for kvm")); + let panel = state.reply_panel.as_ref().expect("reply panel should open"); + assert_eq!(panel.from, "CRIEW Test "); + assert_eq!(panel.to, "Bob "); + assert_eq!(panel.cc, "Alice "); + assert_eq!(panel.subject, "Re: [PATCH] demo"); + assert_eq!(panel.in_reply_to, "patch@example.com"); + assert_eq!(panel.references, vec!["patch@example.com"]); + assert_eq!(panel.section, ReplySection::From); + + let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw reply panel"); + let rendered = format!("{}", terminal.backend()); + assert!(rendered.contains("Reply Panel")); + assert!(rendered.contains("focus:From")); + assert!(rendered.contains("Headers ([edit] / [read-only])")); + assert!(rendered.contains("Reply Body")); + assert!(rendered.contains("[edit] To: Bob ")); + assert!(rendered.contains("[edit] Cc: Alice ")); + assert!(rendered.contains("[read-only] In-Reply-To: ")); + assert!(rendered.contains("Subject: Re: [PATCH] demo")); + assert!( + state + .status + .contains("edit From/To/Cc/Subject before Send Preview") + ); let _ = fs::remove_dir_all(root); } #[test] -fn startup_sync_failure_for_empty_inbox_falls_back_to_cached_mailbox() { - let root = temp_dir("imap-fallback-startup"); - let runtime = test_runtime_with_imap_in(root.clone()); - seed_mailbox_thread( - &runtime.database_path, - "io-uring", - 1, - "io-uring@example.com", - "io_uring thread", +fn reply_panel_body_renders_80_column_guide_marker() { + let root = temp_dir("reply-body-guide"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + runtime.clone(), ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; - let mut state = AppState::new_with_ui_state( - vec![], - runtime, - Some(UiState { - enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string(), "io-uring".to_string()], - enabled_group_expanded: true, - disabled_group_expanded: true, - enabled_linux_subsystem_expanded: true, - enabled_qemu_subsystem_expanded: true, - disabled_linux_subsystem_expanded: true, - disabled_qemu_subsystem_expanded: true, - imap_defaults_initialized: true, - active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), - }), + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); + let panel = state.reply_panel.as_mut().expect("reply panel should open"); + panel.body = vec!["short line".to_string(), String::new()]; + panel.body_row = 0; - state.apply_startup_sync_event(StartupSyncEvent::MailboxFailed { - mailbox: IMAP_INBOX_MAILBOX.to_string(), - error: "imap unavailable".to_string(), - }); + let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw reply panel with guide"); + let rendered = format!("{}", terminal.backend()); - assert_eq!(state.active_thread_mailbox, "io-uring"); - assert_eq!(state.threads.len(), 1); - assert!(state.status.contains("showing threads for io-uring")); + assert!(rendered.contains("Reply Body")); + assert!(rendered.contains("80 cols")); let _ = fs::remove_dir_all(root); } #[test] -fn command_palette_sync_resets_my_inbox_auto_sync_deadline() { - let mut state = AppState::new(vec![], test_runtime_with_imap()); - state.sync_request_executor = sync_request_mock_success; - state - .inbox_auto_sync - .as_mut() - .expect("inbox auto-sync state") - .next_due_at = Instant::now() - Duration::from_secs(1); +fn reply_send_preview_requires_confirmation_before_send() { + let root = temp_dir("reply-send-gate"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); - run_palette_sync(&mut state, "sync INBOX"); + let runtime = test_runtime_in(root.clone()); + seed_mailbox_thread( + &runtime.database_path, + "inbox", + 1, + "patch@example.com", + "[PATCH] demo", + ); + + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + runtime.clone(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; + state.reply_send_executor = reply_send_mock_success; - assert!(state.status.contains("sync ok")); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), + ); + assert!(state.status.contains("run Send Preview and confirm first")); assert!( state - .inbox_auto_sync + .reply_panel .as_ref() - .expect("inbox auto-sync state") - .next_due_at - > Instant::now() + Duration::from_secs(20) + .and_then(|panel| panel.reply_notice.as_ref()) + .is_some_and(|notice| notice.title == "Send Blocked") ); -} - -#[test] -fn foreground_inbox_sync_defers_next_auto_sync_tick() { - let root = temp_dir("imap-open-inbox-sync"); - let runtime = test_runtime_with_imap_in(root.clone()); - fs::create_dir_all(runtime.database_path.parent().expect("db parent")) - .expect("create db parent"); - db::initialize(&runtime.database_path).expect("initialize db"); - - let mut state = AppState::new(vec![], runtime); - state.sync_request_executor = sync_request_mock_success; - state - .inbox_auto_sync - .as_mut() - .expect("inbox auto-sync state") - .next_due_at = Instant::now() - Duration::from_secs(1); - - state.open_threads_for_selected_subscription(); - assert!(state.status.contains("synced INBOX")); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + ); assert!( state - .inbox_auto_sync + .reply_panel .as_ref() - .expect("inbox auto-sync state") - .next_due_at - > Instant::now() + Duration::from_secs(20) + .is_some_and(|panel| panel.preview_open) + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), ); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn command_palette_sync_resets_subscription_auto_sync_deadline() { - let mut state = AppState::new(vec![], test_runtime()); - state.sync_request_executor = sync_request_mock_success; - let io_uring_index = state - .subscriptions - .iter() - .position(|item| item.mailbox == "io-uring") - .expect("io-uring subscription exists"); - state.subscriptions[io_uring_index].enabled = true; - state.reconcile_subscription_auto_sync(); - state - .subscription_auto_sync - .as_mut() - .expect("subscription auto-sync state") - .next_due_at = Instant::now() - Duration::from_secs(1); - - run_palette_sync(&mut state, "sync io-uring"); - - assert!(state.status.contains("sync ok")); assert!( state - .subscription_auto_sync + .reply_panel .as_ref() - .expect("subscription auto-sync state") - .next_due_at - > Instant::now() + Duration::from_secs(20) + .is_some_and(|panel| panel.preview_confirmed) ); -} - -#[test] -fn enter_on_mailbox_pending_startup_sync_stays_non_blocking() { - let root = temp_dir("imap-pending-enter"); - let runtime = test_runtime_with_imap_in(root.clone()); - fs::create_dir_all(runtime.database_path.parent().expect("db parent")) - .expect("create db parent"); - db::initialize(&runtime.database_path).expect("initialize db"); - - let mut state = AppState::new_with_ui_state( - vec![], - runtime, - Some(UiState { - enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string()], - enabled_group_expanded: true, - disabled_group_expanded: true, - enabled_linux_subsystem_expanded: true, - enabled_qemu_subsystem_expanded: true, - disabled_linux_subsystem_expanded: true, - disabled_qemu_subsystem_expanded: true, - imap_defaults_initialized: true, - active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), - }), + assert!( + state + .reply_panel + .as_ref() + .and_then(|panel| panel.reply_notice.as_ref()) + .is_some_and(|notice| notice.title == "Ready To Send") ); - state.focus = Pane::Subscriptions; - state.startup_sync = Some(startup_sync_state(&[( - IMAP_INBOX_MAILBOX, - StartupSyncMailboxStatus::InFlight, - )])); - state.open_threads_for_selected_subscription(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), + ); + assert!(state.status.contains("reply sent as ")); + assert!(state.reply_panel.is_none()); - assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); - assert!(state.threads.is_empty()); - assert!(state.status.contains("syncing in background")); + let record = reply_store::latest_reply_send_for_mail(&runtime.database_path, 1) + .expect("load latest reply send") + .expect("reply send record"); + assert_eq!(record.status, ReplySendStatus::Sent); + assert_eq!(record.message_id, "sent@example.com"); + assert_eq!(record.subject, "Re: [PATCH] demo"); let _ = fs::remove_dir_all(root); } #[test] -fn background_success_does_not_steal_focus_from_pending_inbox() { - let root = temp_dir("imap-pending-focus"); - let runtime = test_runtime_with_imap_in(root.clone()); - seed_mailbox_thread( - &runtime.database_path, - "kvm", - 1, - "kvm@example.com", - "kvm thread", +fn reply_send_blocked_notice_and_ready_notice_replace_reply_panel_view() { + let root = temp_dir("reply-notice-overlay"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + runtime.clone(), ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; - let mut state = AppState::new_with_ui_state( - vec![], - runtime, - Some(UiState { - enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string(), "kvm".to_string()], - enabled_group_expanded: true, - disabled_group_expanded: true, - enabled_linux_subsystem_expanded: true, - enabled_qemu_subsystem_expanded: true, - disabled_linux_subsystem_expanded: true, - disabled_qemu_subsystem_expanded: true, - imap_defaults_initialized: true, - active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), - }), + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), ); - state.startup_sync = Some(startup_sync_state(&[ - (IMAP_INBOX_MAILBOX, StartupSyncMailboxStatus::InFlight), - ("kvm", StartupSyncMailboxStatus::Pending), - ])); - state.apply_startup_sync_event(StartupSyncEvent::MailboxFinished { - mailbox: "kvm".to_string(), - fetched: 1, - inserted: 1, - updated: 0, - }); + let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw blocked notice"); + let rendered = format!("{}", terminal.backend()); + assert!(rendered.contains("Send Blocked")); + assert!(rendered.contains("You must open Send Preview")); + assert!(!rendered.contains("Headers ([edit] / [read-only])")); + assert!(!rendered.contains("Reply Body")); - assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); - assert!(state.threads.is_empty()); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), + ); + let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw ready notice"); + let rendered = format!("{}", terminal.backend()); + assert!(rendered.contains("Ready To Send")); + assert!(rendered.contains("Press S to send the reply")); + assert!(!rendered.contains("Headers ([edit] / [read-only])")); + assert!(!rendered.contains("Reply Body")); let _ = fs::remove_dir_all(root); } #[test] -fn y_and_n_toggle_subscription_and_keep_grouped_sort_order() { - let mut state = AppState::new(vec![], test_runtime()); - state.focus = Pane::Subscriptions; +fn reply_send_failure_keeps_panel_open_and_persists_failure() { + let root = temp_dir("reply-send-failure"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); - let target_index = state - .subscriptions - .iter() - .position(|item| item.mailbox == "bpf") - .expect("bpf subscription exists"); - state.subscription_index = target_index; - state.sync_subscription_row_to_selected_item(); + let runtime = test_runtime_in(root.clone()); + seed_mailbox_thread( + &runtime.database_path, + "inbox", + 1, + "patch@example.com", + "[PATCH] demo", + ); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + runtime.clone(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; + state.reply_send_executor = reply_send_mock_failure; let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); - - let bpf_after_enable = state - .subscriptions - .iter() - .position(|item| item.mailbox == "bpf") - .expect("bpf exists after enable"); - assert!(state.subscriptions[bpf_after_enable].enabled); - - let first_disabled = state - .subscriptions - .iter() - .position(|item| !item.enabled) - .expect("has disabled subscriptions"); - assert!(bpf_after_enable < first_disabled); - - let enabled_group = &state.subscriptions[..first_disabled]; - assert!( - enabled_group - .windows(2) - .all(|pair| subscription_sort_key(&pair[0]) <= subscription_sort_key(&pair[1])) + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), ); - let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), ); - let bpf_after_disable = state - .subscriptions - .iter() - .position(|item| item.mailbox == "bpf") - .expect("bpf exists after disable"); - assert!(!state.subscriptions[bpf_after_disable].enabled); + assert!(state.status.contains("smtp auth failed")); + assert!(state.reply_panel.is_some()); - let last_enabled = state.subscriptions.iter().rposition(|item| item.enabled); - if let Some(last_enabled) = last_enabled { - assert!(bpf_after_disable > last_enabled); + let record = reply_store::latest_reply_send_for_mail(&runtime.database_path, 1) + .expect("load latest reply send") + .expect("reply send record"); + assert_eq!(record.status, ReplySendStatus::Failed); + assert_eq!(record.message_id, "failed@example.com"); + assert_eq!(record.error_summary.as_deref(), Some("smtp auth failed")); - let disabled_group = &state.subscriptions[last_enabled + 1..]; - assert!( - disabled_group - .windows(2) - .all(|pair| subscription_sort_key(&pair[0]) <= subscription_sort_key(&pair[1])) - ); - } else { - assert!(state.subscriptions.iter().all(|item| !item.enabled)); - assert!( - state - .subscriptions - .windows(2) - .all(|pair| subscription_sort_key(&pair[0]) <= subscription_sort_key(&pair[1])) - ); - } + let _ = fs::remove_dir_all(root); } #[test] -fn slash_opens_search_and_filters_threads() { +fn reply_preview_validation_blocks_confirm_on_missing_recipients() { + let root = temp_dir("reply-preview-validation"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + let mut state = AppState::new( - vec![ - sample_thread("[PATCH] mm cleanup", "a@example.com", 0), - sample_thread("[PATCH] net fix", "b@example.com", 0), - ], + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], test_runtime(), ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; - let action_search = handle_key_event( + let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); - assert!(matches!(action_search, LoopAction::Continue)); - assert!(state.search.active); + if let Some(panel) = state.reply_panel.as_mut() { + panel.to.clear(); + panel.cc = "criew@example.com".to_string(); + } let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + assert!( + state + .reply_panel + .as_ref() + .is_some_and(|panel| !panel.preview_errors.is_empty()) ); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), ); - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + assert!(state.status.contains("cannot confirm send preview")); + assert!( + !state + .reply_panel + .as_ref() + .is_some_and(|panel| panel.preview_confirmed) ); - assert!(!state.search.active); - assert_eq!(state.filtered_thread_indices.len(), 1); - let selected = state.selected_thread().expect("selected thread"); - assert_eq!(selected.message_id, "b@example.com"); + let _ = fs::remove_dir_all(root); } #[test] -fn jl_focus_and_ik_move_selection() { +fn reply_send_preview_warns_but_allows_confirm_without_authored_reply_text() { + let root = temp_dir("reply-preview-empty-authored"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); let mut state = AppState::new( - vec![ - sample_thread("t0", "a@example.com", 0), - sample_thread("t1", "b@example.com", 1), - ], - test_runtime(), + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + runtime.clone(), ); - state.subscription_index = 1; + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; - let action_l = handle_key_event( + let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); - assert!(matches!(action_l, LoopAction::Continue)); - assert!(matches!(state.focus, Pane::Threads)); - - let action_i = handle_key_event( + let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), ); - assert!(matches!(action_i, LoopAction::Continue)); - assert_eq!(state.thread_index, 0); - let action_k = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + let panel = state.reply_panel.as_ref().expect("reply panel"); + assert!(panel.preview_open); + assert!(panel.preview_errors.is_empty()); + assert!( + panel + .preview_warnings + .iter() + .any(|value| value.contains("no authored reply content")) ); - assert!(matches!(action_k, LoopAction::Continue)); - assert_eq!(state.thread_index, 1); + assert!(state.status.contains("send preview warning")); - let action_j = handle_key_event( + let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw warning preview"); + let rendered = format!("{}", terminal.backend()); + assert!(rendered.contains("Send Preview [warning]")); + assert!(rendered.contains("draft has no authored reply content")); + assert!(!rendered.contains("Headers ([edit] / [read-only])")); + assert!(!rendered.contains("Reply Body")); + + let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), ); - assert!(matches!(action_j, LoopAction::Continue)); - assert!(matches!(state.focus, Pane::Subscriptions)); + assert!( + state + .reply_panel + .as_ref() + .is_some_and(|panel| panel.preview_confirmed) + ); + assert_eq!(state.status, "send preview confirmed; ready to send"); + + let _ = fs::remove_dir_all(root); } #[test] -fn a_d_and_u_require_patch_series_or_apply_snapshot_on_thread_focus() { - let mut state = AppState::new( - vec![sample_thread("normal mail", "plain@example.com", 0)], - test_runtime(), - ); - state.focus = Pane::Threads; +fn reply_send_preview_highlights_authored_lines_and_keeps_quotes_bright() { + let root = temp_dir("reply-preview-highlighted-authored"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); - let action_apply = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + runtime.clone(), ); - assert!(matches!(action_apply, LoopAction::Continue)); - assert!(state.status.contains("not a patch series")); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; - let action_download = handle_key_event( + let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); - assert!(matches!(action_download, LoopAction::Continue)); - assert!(state.status.contains("not a patch series")); - - let action_undo = handle_key_event( + if let Some(panel) = state.reply_panel.as_mut() { + panel.body = vec![ + "Looks good to me.".to_string(), + String::new(), + "On Fri, 6 Mar 2026 09:30:00 +0000, Alice wrote:".to_string(), + "> body line".to_string(), + ]; + panel.mark_dirty(); + } + let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE), - ); - assert!(matches!(action_undo, LoopAction::Continue)); - assert!(state.status.contains("no apply action to undo")); -} - -#[test] -fn inline_ui_text_collapses_multiline_errors() { - let sanitized = sanitize_inline_ui_text( - "sync failed:\nCould not automatically determine provider\r\n\tline2", - ); - - assert_eq!( - sanitized, - "sync failed: Could not automatically determine provider line2" + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), ); -} - -#[test] -fn preview_hides_rfc_headers_and_keeps_body() { - let raw = b"Message-ID: \r\nSubject: test\r\nFrom: a@example.com\r\n\r\nhello\nworld\n"; - let preview = extract_mail_body_preview(raw); - assert!(!preview.contains("Message-ID:")); - assert!(!preview.contains("Subject: test")); - assert!(preview.contains("hello")); - assert!(preview.contains("world")); -} -#[test] -fn preview_skips_first_mime_part_headers() { - let raw = b"Content-Type: multipart/alternative; boundary=\"abc\"\r\n\r\n--abc\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\nplain body line\r\n--abc--\r\n"; - let preview = extract_mail_body_preview(raw); - assert!(!preview.contains("Content-Transfer-Encoding")); - assert!(preview.contains("plain body line")); -} + let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw highlighted preview"); + let rendered = format!("{}", terminal.backend()); + assert!(rendered.contains("Send Preview [reply highlighted]")); + assert!(rendered.contains("Your authored reply lines are highlighted below.")); + assert!(!rendered.contains("Headers ([edit] / [read-only])")); + assert!(!rendered.contains("Reply Body")); + + let (authored_fg, authored_bg, authored_modifier) = + rendered_cell_style_for_substring(&terminal, "Looks good to me.") + .expect("authored line style"); + assert_eq!(authored_fg, Color::Black); + assert_eq!(authored_bg, Color::Yellow); + assert!(authored_modifier.contains(Modifier::BOLD)); + + let (quoted_fg, quoted_bg, _) = + rendered_cell_style_for_substring(&terminal, "> body line").expect("quoted line style"); + assert_eq!(quoted_fg, Color::White); + assert_eq!(quoted_bg, Color::Reset); -#[test] -fn preview_strips_control_characters() { - let raw = - b"Message-ID: \r\nSubject: test\r\n\r\nline1\x1b[31m\x07\nline2\tok\r\n"; - let preview = extract_mail_body_preview(raw); - assert!(!preview.contains('\u{001b}')); - assert!(!preview.contains('\u{0007}')); - assert!(!preview.contains('\t')); - assert!(preview.contains("line1")); - assert!(preview.contains("line2 ok")); + let _ = fs::remove_dir_all(root); } #[test] -fn preview_shows_from_sent_to_cc_headers() { - let raw = b"From: Chen Miao \r\nDate: Monday, March 2, 2026 5:29 PM\r\nTo: Daniel Baluta ; Simona Toaca \r\nCc: Team One \r\nSubject: [PATCH] demo\r\n\r\nmail body line\n"; - let preview = extract_mail_preview(raw, "(no subject)", "", None); - - assert!(preview.contains("From: Chen Miao ")); - assert!(preview.contains("Sent: Monday, March 2, 2026 5:29 PM")); - assert!(preview.contains( - "To: Daniel Baluta ; Simona Toaca " - )); - assert!(preview.contains("Cc: Team One ")); - assert!(preview.contains("Subject: [PATCH] demo")); - assert!(preview.contains("mail body line")); -} +fn reply_send_preview_uses_edited_header_values() { + let root = temp_dir("reply-preview-edited-headers"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nCc: Carol \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); -#[test] -fn preview_truncates_to_and_cc_recipient_lists() { - let raw = b"From: sender@example.com\r\nDate: Tue, 3 Mar 2026 12:00:00 +0000\r\nTo: A , B , C \r\nCc: X ; Y ; Z \r\nSubject: test\r\n\r\nbody\n"; - let preview = extract_mail_preview(raw, "(no subject)", "", None); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; - assert!(preview.contains("To: A ; B ; ...")); - assert!(preview.contains("Cc: X ; Y ; ...")); - assert!(!preview.contains("C ")); - assert!(!preview.contains("Z ")); -} + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + if let Some(panel) = state.reply_panel.as_mut() { + panel.from = "Reviewer Bot ".to_string(); + panel.to = "Maintainer ".to_string(); + panel.cc = "List ".to_string(); + panel.mark_dirty(); + } -#[test] -fn preview_warns_for_multipart_mail() { - let raw = b"Content-Type: multipart/alternative; boundary=\"abc\"\r\n\r\n--abc\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nplain body line\r\n--abc--\r\n"; - let warning = preview_warning_message(raw).expect("warning expected"); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + ); - assert!(warning.contains("NON-PLAIN-TEXT MAIL")); - assert!(warning.contains("Parse artifacts/errors are normal")); - assert!(warning.contains("Content-Type: multipart/alternative; boundary=\"abc\"")); + let panel = state + .reply_panel + .as_ref() + .expect("reply panel should stay open"); + assert!(panel.preview_open); + assert!(panel.preview_errors.is_empty()); + assert!( + panel + .preview_rendered + .contains("From: Reviewer Bot ") + ); + assert!( + panel + .preview_rendered + .contains("To: Maintainer ") + ); + assert!( + panel + .preview_rendered + .contains("Cc: List ") + ); + + let _ = fs::remove_dir_all(root); } #[test] -fn preview_warns_for_encoded_html_mail() { - let raw = b"Content-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nhello\r\n"; - let warning = preview_warning_message(raw).expect("warning expected"); +fn mail_page_r_opens_reply_panel_from_threads_focus() { + let root = temp_dir("reply-open-r"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); - assert!(warning.contains("NON-PLAIN-TEXT MAIL")); - assert!(warning.contains("Content-Type: text/html; charset=utf-8")); - assert!(warning.contains("Transfer-Encoding: quoted-printable")); -} + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + state.focus = Pane::Threads; + state.reply_identity_resolver = reply_identity_mock; -#[test] -fn multiline_sync_error_does_not_break_footer_or_palette_render() { - let runtime = test_runtime(); - let bootstrap = test_bootstrap(&runtime); - let mut state = AppState::new(vec![], runtime.clone()); - state.status = "sync failed: E1007:\nCould not automatically determine provider".to_string(); - state.palette.open = true; + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), + ); - let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); - terminal - .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) - .expect("draw multiline status"); - let rendered = format!("{}", terminal.backend()); + assert!(matches!(action, LoopAction::Continue)); + assert!(state.reply_panel.is_some()); + assert!( + state + .status + .contains("reply panel opened for ") + ); - assert!(rendered.contains("sync failed: E1007: Could not automatically determine provider")); - assert!(rendered.contains("Command Palette")); + let _ = fs::remove_dir_all(root); } #[test] -fn header_shows_criew_brand_and_default_footer_hides_empty_status() { - let runtime = test_runtime(); - let bootstrap = test_bootstrap(&runtime); - let state = AppState::new(vec![], runtime.clone()); +fn reply_notice_escape_closes_blocked_notice() { + let root = temp_dir("reply-notice-esc"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); - let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); - terminal - .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) - .expect("draw branded header"); - let rendered = format!("{}", terminal.backend()); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; - assert!(rendered.contains("CRIEW")); - assert!(rendered.contains(env!("CARGO_PKG_VERSION"))); - assert!(rendered.contains("Mail / inbox")); - assert!(!rendered.contains("db schema")); - assert!(!rendered.contains("db:")); - assert!(!rendered.contains("status:")); - assert!(!rendered.contains(" ready ")); -} + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), + ); + assert!( + state + .reply_panel + .as_ref() + .and_then(|panel| panel.reply_notice.as_ref()) + .is_some() + ); -#[test] -fn startup_sync_summary_stays_in_header_not_footer_middle() { - let runtime = test_runtime_in(PathBuf::from("/t")); - let bootstrap = test_bootstrap(&runtime); - let mut state = AppState::new(vec![], runtime.clone()); - state.startup_sync = Some(startup_sync_state(&[ - ("INBOX", StartupSyncMailboxStatus::InFlight), - ("io-uring", StartupSyncMailboxStatus::Pending), - ("kvm", StartupSyncMailboxStatus::Finished), - ])); - state.status = "startup sync [1/3] syncing INBOX...".to_string(); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - let mut terminal = Terminal::new(TestBackend::new(260, 35)).expect("create test terminal"); - terminal - .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) - .expect("draw startup sync"); - let rendered = format!("{}", terminal.backend()); - let progress_summary = "sync INBOX 1/3"; + assert!( + state + .reply_panel + .as_ref() + .is_some_and(|panel| panel.reply_notice.is_none()) + ); + assert_eq!(state.status, "reply notice closed"); - assert!(rendered.contains("Mail / inbox")); - assert!(rendered.contains(progress_summary)); - assert!(rendered.contains("startup sync [1/3] syncing INBOX...")); - assert_eq!(rendered.matches(progress_summary).count(), 1); + let _ = fs::remove_dir_all(root); } #[test] -fn mail_preview_e_opens_reply_panel_with_autofilled_headers() { - let root = temp_dir("reply-open"); +fn reply_send_preview_scrolls_with_j_and_k() { + let root = temp_dir("reply-preview-scroll"); let raw = root.join("patch.eml"); fs::write( - &raw, - b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: CRIEW Test , Bob \r\nCc: Alice \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", - ) - .expect("write raw reply fixture"); + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); - let runtime = test_runtime(); - let bootstrap = test_bootstrap(&runtime); let mut state = AppState::new( vec![sample_thread_with_raw( "[PATCH] demo", @@ -2713,7 +5479,7 @@ fn mail_preview_e_opens_reply_panel_with_autofilled_headers() { 0, raw.clone(), )], - runtime.clone(), + test_runtime(), ); state.focus = Pane::Preview; state.reply_identity_resolver = reply_identity_mock; @@ -2722,31 +5488,49 @@ fn mail_preview_e_opens_reply_panel_with_autofilled_headers() { &mut state, KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + ); + assert!( + state + .reply_panel + .as_ref() + .is_some_and(|panel| panel.preview_open) + ); - let panel = state.reply_panel.as_ref().expect("reply panel should open"); - assert_eq!(panel.from, "CRIEW Test "); - assert_eq!(panel.to, "Bob "); - assert_eq!(panel.cc, "Alice "); - assert_eq!(panel.subject, "Re: [PATCH] demo"); - assert_eq!(panel.in_reply_to, "patch@example.com"); - assert_eq!(panel.references, vec!["patch@example.com"]); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert_eq!( + state + .reply_panel + .as_ref() + .expect("reply panel") + .preview_scroll, + 1 + ); - let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal"); - terminal - .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) - .expect("draw reply panel"); - let rendered = format!("{}", terminal.backend()); - assert!(rendered.contains("Reply Panel")); - assert!(rendered.contains("Headers (From/To/Cc/Subject editable)")); - assert!(rendered.contains("Reply Body")); - assert!(rendered.contains("Subject: Re: [PATCH] demo")); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert_eq!( + state + .reply_panel + .as_ref() + .expect("reply panel") + .preview_scroll, + 0 + ); let _ = fs::remove_dir_all(root); } #[test] -fn reply_panel_body_renders_80_column_guide_marker() { - let root = temp_dir("reply-body-guide"); +fn reply_send_preview_escape_closes_preview() { + let root = temp_dir("reply-preview-esc"); let raw = root.join("patch.eml"); fs::write( &raw, @@ -2754,8 +5538,6 @@ fn reply_panel_body_renders_80_column_guide_marker() { ) .expect("write raw reply fixture"); - let runtime = test_runtime(); - let bootstrap = test_bootstrap(&runtime); let mut state = AppState::new( vec![sample_thread_with_raw( "[PATCH] demo", @@ -2763,7 +5545,7 @@ fn reply_panel_body_renders_80_column_guide_marker() { 0, raw.clone(), )], - runtime.clone(), + test_runtime(), ); state.focus = Pane::Preview; state.reply_identity_resolver = reply_identity_mock; @@ -2772,40 +5554,32 @@ fn reply_panel_body_renders_80_column_guide_marker() { &mut state, KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); - let panel = state.reply_panel.as_mut().expect("reply panel should open"); - panel.body = vec!["short line".to_string(), String::new()]; - panel.body_row = 0; - - let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("create test terminal"); - terminal - .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) - .expect("draw reply panel with guide"); - let rendered = format!("{}", terminal.backend()); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + ); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(rendered.contains("Reply Body")); - assert!(rendered.contains("80 cols")); + assert!( + state + .reply_panel + .as_ref() + .is_some_and(|panel| !panel.preview_open) + ); + assert_eq!(state.status, "send preview closed"); let _ = fs::remove_dir_all(root); } #[test] -fn reply_send_preview_requires_confirmation_before_send() { - let root = temp_dir("reply-send-gate"); +fn reply_notice_enter_closes_blocked_notice() { + let root = temp_dir("reply-notice-enter"); let raw = root.join("patch.eml"); - fs::write( - &raw, - b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", - ) - .expect("write raw reply fixture"); - - let runtime = test_runtime_in(root.clone()); - seed_mailbox_thread( - &runtime.database_path, - "inbox", - 1, - "patch@example.com", - "[PATCH] demo", - ); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); let mut state = AppState::new( vec![sample_thread_with_raw( @@ -2814,11 +5588,10 @@ fn reply_send_preview_requires_confirmation_before_send() { 0, raw.clone(), )], - runtime.clone(), + test_runtime(), ); state.focus = Pane::Preview; state.reply_identity_resolver = reply_identity_mock; - state.reply_send_executor = reply_send_mock_success; let _ = handle_key_event( &mut state, @@ -2828,72 +5601,32 @@ fn reply_send_preview_requires_confirmation_before_send() { &mut state, KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), ); - assert!(state.status.contains("run Send Preview and confirm first")); - assert!( - state - .reply_panel - .as_ref() - .and_then(|panel| panel.reply_notice.as_ref()) - .is_some_and(|notice| notice.title == "Send Blocked") - ); - - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), - ); - assert!( - state - .reply_panel - .as_ref() - .is_some_and(|panel| panel.preview_open) - ); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), - ); - assert!( - state - .reply_panel - .as_ref() - .is_some_and(|panel| panel.preview_confirmed) + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); + assert!( state .reply_panel .as_ref() - .and_then(|panel| panel.reply_notice.as_ref()) - .is_some_and(|notice| notice.title == "Ready To Send") - ); - - let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), + .is_some_and(|panel| panel.reply_notice.is_none()) ); - assert!(state.status.contains("reply sent as ")); - assert!(state.reply_panel.is_none()); - - let record = reply_store::latest_reply_send_for_mail(&runtime.database_path, 1) - .expect("load latest reply send") - .expect("reply send record"); - assert_eq!(record.status, ReplySendStatus::Sent); - assert_eq!(record.message_id, "sent@example.com"); - assert_eq!(record.subject, "Re: [PATCH] demo"); + assert_eq!(state.status, "reply notice closed"); let _ = fs::remove_dir_all(root); } #[test] -fn reply_send_blocked_notice_and_ready_notice_are_rendered_as_overlays() { - let root = temp_dir("reply-notice-overlay"); +fn reply_command_mode_handles_empty_unsupported_and_discard_commands() { + let root = temp_dir("reply-command-mode"); let raw = root.join("patch.eml"); fs::write( - &raw, - b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", - ) - .expect("write raw reply fixture"); + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); - let runtime = test_runtime(); - let bootstrap = test_bootstrap(&runtime); let mut state = AppState::new( vec![sample_thread_with_raw( "[PATCH] demo", @@ -2901,7 +5634,7 @@ fn reply_send_blocked_notice_and_ready_notice_are_rendered_as_overlays() { 0, raw.clone(), )], - runtime.clone(), + test_runtime(), ); state.focus = Pane::Preview; state.reply_identity_resolver = reply_identity_mock; @@ -2912,54 +5645,50 @@ fn reply_send_blocked_notice_and_ready_notice_are_rendered_as_overlays() { ); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); + assert_eq!(state.status, "empty command"); - let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal"); - terminal - .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) - .expect("draw blocked notice"); - let rendered = format!("{}", terminal.backend()); - assert!(rendered.contains("Send Blocked")); - assert!(rendered.contains("You must open Send Preview")); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), + ); + type_text(&mut state, "zzz"); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert_eq!(state.status, "unsupported command: :zzz"); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), ); + type_text(&mut state, "q!"); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal"); - terminal - .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) - .expect("draw ready notice"); - let rendered = format!("{}", terminal.backend()); - assert!(rendered.contains("Ready To Send")); - assert!(rendered.contains("Press S to send the reply")); + assert!(state.reply_panel.is_none()); + assert_eq!(state.status, "discarded reply draft"); let _ = fs::remove_dir_all(root); } #[test] -fn reply_send_failure_keeps_panel_open_and_persists_failure() { - let root = temp_dir("reply-send-failure"); +fn reply_command_mode_escape_and_backspace_restore_normal_mode() { + let root = temp_dir("reply-command-cancel"); let raw = root.join("patch.eml"); fs::write( - &raw, - b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", - ) - .expect("write raw reply fixture"); + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); - let runtime = test_runtime_in(root.clone()); - seed_mailbox_thread( - &runtime.database_path, - "inbox", - 1, - "patch@example.com", - "[PATCH] demo", - ); let mut state = AppState::new( vec![sample_thread_with_raw( "[PATCH] demo", @@ -2967,11 +5696,10 @@ fn reply_send_failure_keeps_panel_open_and_persists_failure() { 0, raw.clone(), )], - runtime.clone(), + test_runtime(), ); state.focus = Pane::Preview; state.reply_identity_resolver = reply_identity_mock; - state.reply_send_executor = reply_send_mock_failure; let _ = handle_key_event( &mut state, @@ -2979,39 +5707,116 @@ fn reply_send_failure_keeps_panel_open_and_persists_failure() { ); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), ); + type_text(&mut state, "ab"); let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + assert_eq!( + state + .reply_panel + .as_ref() + .expect("reply panel") + .command_input, + "a" + ); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let panel = state.reply_panel.as_ref().expect("reply panel"); + assert!(matches!(panel.mode, ReplyEditMode::Normal)); + assert!(panel.command_input.is_empty()); + assert_eq!(state.status, "reply command cancelled"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reply_command_q_closes_clean_panel_but_blocks_dirty_draft() { + let root = temp_dir("reply-command-q"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let mut clean_state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), ); + clean_state.focus = Pane::Preview; + clean_state.reply_identity_resolver = reply_identity_mock; + let _ = handle_key_event( - &mut state, - KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), + &mut clean_state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut clean_state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), ); + type_text(&mut clean_state, "q"); + let _ = handle_key_event( + &mut clean_state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(clean_state.reply_panel.is_none()); + assert_eq!(clean_state.status, "closed reply panel"); - assert!(state.status.contains("smtp auth failed")); - assert!(state.reply_panel.is_some()); + let mut dirty_state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + dirty_state.focus = Pane::Preview; + dirty_state.reply_identity_resolver = reply_identity_mock; - let record = reply_store::latest_reply_send_for_mail(&runtime.database_path, 1) - .expect("load latest reply send") - .expect("reply send record"); - assert_eq!(record.status, ReplySendStatus::Failed); - assert_eq!(record.message_id, "failed@example.com"); - assert_eq!(record.error_summary.as_deref(), Some("smtp auth failed")); + let _ = handle_key_event( + &mut dirty_state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + if let Some(panel) = dirty_state.reply_panel.as_mut() { + panel.mark_dirty(); + } + let _ = handle_key_event( + &mut dirty_state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), + ); + type_text(&mut dirty_state, "q"); + let _ = handle_key_event( + &mut dirty_state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(dirty_state.reply_panel.is_some()); + assert_eq!( + dirty_state.status, + "unsaved reply draft, run :q! to discard" + ); let _ = fs::remove_dir_all(root); } #[test] -fn reply_preview_validation_blocks_confirm_on_missing_recipients() { - let root = temp_dir("reply-preview-validation"); +fn reply_command_preview_and_preview_enter_cover_remaining_preview_shortcuts() { + let root = temp_dir("reply-command-preview"); let raw = root.join("patch.eml"); fs::write( - &raw, - b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", - ) - .expect("write raw reply fixture"); + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); let mut state = AppState::new( vec![sample_thread_with_raw( @@ -3029,45 +5834,46 @@ fn reply_preview_validation_blocks_confirm_on_missing_recipients() { &mut state, KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); - if let Some(panel) = state.reply_panel.as_mut() { - panel.to.clear(); - panel.cc = "criew@example.com".to_string(); - } - let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), + ); + type_text(&mut state, "preview"); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); assert!( state .reply_panel .as_ref() - .is_some_and(|panel| !panel.preview_errors.is_empty()) + .is_some_and(|panel| panel.preview_open) ); + let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - assert!(state.status.contains("cannot confirm send preview")); assert!( - !state + state .reply_panel .as_ref() .is_some_and(|panel| panel.preview_confirmed) ); + assert_eq!(state.status, "send preview confirmed; ready to send"); let _ = fs::remove_dir_all(root); } #[test] -fn reply_send_preview_uses_edited_header_values() { - let root = temp_dir("reply-preview-edited-headers"); +fn reply_insert_mode_tab_and_backspace_modify_body() { + let root = temp_dir("reply-insert-tab"); let raw = root.join("patch.eml"); fs::write( - &raw, - b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nCc: Carol \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", - ) - .expect("write raw reply fixture"); + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); let mut state = AppState::new( vec![sample_thread_with_raw( @@ -3086,37 +5892,30 @@ fn reply_send_preview_uses_edited_header_values() { KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); if let Some(panel) = state.reply_panel.as_mut() { - panel.from = "Reviewer Bot ".to_string(); - panel.to = "Maintainer ".to_string(); - panel.cc = "List ".to_string(); - panel.mark_dirty(); + panel.section = ReplySection::Body; + panel.body = vec![String::new()]; + panel.body_row = 0; + panel.cursor_col = 0; + panel.adjust_scroll(); } let _ = handle_key_event( &mut state, - KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), ); - - let panel = state - .reply_panel - .as_ref() - .expect("reply panel should stay open"); - assert!(panel.preview_open); - assert!(panel.preview_errors.is_empty()); - assert!( - panel - .preview_rendered - .contains("From: Reviewer Bot ") + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!( + state.reply_panel.as_ref().expect("reply panel").body[0], + " " ); - assert!( - panel - .preview_rendered - .contains("To: Maintainer ") + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), ); - assert!( - panel - .preview_rendered - .contains("Cc: List ") + assert_eq!( + state.reply_panel.as_ref().expect("reply panel").body[0], + " " ); let _ = fs::remove_dir_all(root); @@ -3469,7 +6268,7 @@ fn threads_panel_renders_thread_group_headers() { ); state.focus = Pane::Threads; - let mut terminal = Terminal::new(TestBackend::new(120, 30)).expect("create test terminal"); + let mut terminal = Terminal::new(TestBackend::new(180, 30)).expect("create test terminal"); terminal .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) .expect("draw frame"); diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..6622e3b --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "xtask" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..e429b84 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,282 @@ +use std::env; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +fn main() -> ExitCode { + match run(env::args_os().skip(1).collect()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} + +fn run(args: Vec) -> Result<(), XtaskError> { + match ParseOutcome::parse(args)? { + ParseOutcome::Help => { + println!("{}", WikiCommand::help()); + Ok(()) + } + ParseOutcome::Invocation(invocation) => invocation.run(), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WikiCommand { + Build, + Check, + Lint, + Prepare, + Serve, +} + +impl WikiCommand { + fn parse(value: &OsStr) -> Option { + match value.to_str()? { + "build" => Some(Self::Build), + "check" => Some(Self::Check), + "lint" => Some(Self::Lint), + "prepare" => Some(Self::Prepare), + "serve" => Some(Self::Serve), + _ => None, + } + } + + fn help() -> &'static str { + "\ +Usage: cargo wiki [args...] + +build Run ./scripts/wiki-site.sh build [mkdocs args...] +check Run wiki lint and then wiki build +lint Run ./scripts/wiki-lint.sh [autocorrect args...] +prepare Run ./scripts/wiki-site.sh prepare +serve Run ./scripts/wiki-site.sh serve [mkdocs args...]" + } +} + +#[derive(Debug, Eq, PartialEq)] +struct WikiInvocation { + command: WikiCommand, + extra_args: Vec, +} + +#[derive(Debug, Eq, PartialEq)] +enum ParseOutcome { + Help, + Invocation(WikiInvocation), +} + +impl ParseOutcome { + fn parse(args: Vec) -> Result { + let mut args = args.into_iter(); + let Some(command_name) = args.next() else { + return Err(UsageError::new("missing wiki command")); + }; + + if command_name == "help" || command_name == "--help" || command_name == "-h" { + return Ok(Self::Help); + } + + let Some(command) = WikiCommand::parse(&command_name) else { + let invalid_name = command_name.to_string_lossy().into_owned(); + return Err(UsageError::new(format!( + "unknown wiki command '{invalid_name}'" + ))); + }; + + Ok(Self::Invocation(WikiInvocation { + command, + extra_args: args.collect(), + })) + } +} + +impl WikiInvocation { + fn run(self) -> Result<(), XtaskError> { + let repo_root = repo_root()?; + + match self.command { + WikiCommand::Lint => run_script( + repo_root.join("scripts/wiki-lint.sh"), + self.extra_args, + &repo_root, + ), + WikiCommand::Prepare => run_script_with_prefix( + "scripts/wiki-site.sh", + ["prepare"], + self.extra_args, + &repo_root, + ), + WikiCommand::Build => run_script_with_prefix( + "scripts/wiki-site.sh", + ["build"], + self.extra_args, + &repo_root, + ), + WikiCommand::Serve => run_script_with_prefix( + "scripts/wiki-site.sh", + ["serve"], + self.extra_args, + &repo_root, + ), + WikiCommand::Check => { + if !self.extra_args.is_empty() { + return Err(XtaskError::Usage(UsageError::new( + "cargo wiki check does not accept extra arguments", + ))); + } + + run_script(repo_root.join("scripts/wiki-lint.sh"), [], &repo_root)?; + run_script_with_prefix("scripts/wiki-site.sh", ["build"], [], &repo_root) + } + } + } +} + +fn run_script_with_prefix( + script_relative_path: &str, + prefix_args: [&str; N], + extra_args: impl IntoIterator, + repo_root: &Path, +) -> Result<(), XtaskError> { + let args = prefix_args + .into_iter() + .map(OsString::from) + .chain(extra_args) + .collect::>(); + run_script(repo_root.join(script_relative_path), args, repo_root) +} + +fn run_script( + script_path: PathBuf, + args: impl IntoIterator, + repo_root: &Path, +) -> Result<(), XtaskError> { + let status = Command::new(&script_path) + .args(args) + .current_dir(repo_root) + .status() + .map_err(|source| XtaskError::CommandSpawn { + command: script_path.display().to_string(), + source, + })?; + + if status.success() { + return Ok(()); + } + + Err(XtaskError::CommandFailed { + command: script_path.display().to_string(), + status, + }) +} + +fn repo_root() -> Result { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir.parent().map(Path::to_path_buf).ok_or_else(|| { + XtaskError::RepoRoot(format!( + "xtask manifest directory '{}' does not have a repository root parent", + manifest_dir.display() + )) + }) +} + +#[derive(Debug)] +enum XtaskError { + CommandFailed { + command: String, + status: std::process::ExitStatus, + }, + CommandSpawn { + command: String, + source: std::io::Error, + }, + RepoRoot(String), + Usage(UsageError), +} + +impl fmt::Display for XtaskError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CommandFailed { command, status } => { + write!(f, "command '{command}' exited with status {status}") + } + Self::CommandSpawn { command, source } => { + write!(f, "failed to run '{command}': {source}") + } + Self::RepoRoot(message) => f.write_str(message), + Self::Usage(error) => write!(f, "{error}"), + } + } +} + +impl From for XtaskError { + fn from(value: UsageError) -> Self { + Self::Usage(value) + } +} + +#[derive(Debug, Eq, PartialEq)] +struct UsageError { + message: String, +} + +impl UsageError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for UsageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}\n\n{}", self.message, WikiCommand::help()) + } +} + +#[cfg(test)] +mod tests { + use super::{ParseOutcome, UsageError, WikiCommand}; + use std::ffi::OsString; + + #[test] + fn parses_build_command_with_extra_args() { + let outcome = ParseOutcome::parse(vec![ + OsString::from("build"), + OsString::from("--strict"), + OsString::from("--dirtyreload"), + ]) + .unwrap(); + let ParseOutcome::Invocation(invocation) = outcome else { + panic!("expected invocation parse outcome"); + }; + + assert_eq!(invocation.command, WikiCommand::Build); + assert_eq!( + invocation.extra_args, + vec![OsString::from("--strict"), OsString::from("--dirtyreload")] + ); + } + + #[test] + fn rejects_missing_command() { + let error = ParseOutcome::parse(Vec::new()).unwrap_err(); + assert_eq!(error, UsageError::new("missing wiki command")); + } + + #[test] + fn rejects_unknown_command() { + let error = ParseOutcome::parse(vec![OsString::from("deploy")]).unwrap_err(); + assert_eq!(error, UsageError::new("unknown wiki command 'deploy'")); + } + + #[test] + fn parses_help_without_error() { + let outcome = ParseOutcome::parse(vec![OsString::from("--help")]).unwrap(); + assert_eq!(outcome, ParseOutcome::Help); + } +}