Skip to content

refactor(cli): extract service layer for MCP reuse#34

Open
Telvary wants to merge 4 commits into
MaximeGaudin:mainfrom
Telvary:feat/service-layer-pr1
Open

refactor(cli): extract service layer for MCP reuse#34
Telvary wants to merge 4 commits into
MaximeGaudin:mainfrom
Telvary:feat/service-layer-pr1

Conversation

@Telvary

@Telvary Telvary commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Intent

Void is gaining an MCP server so AI agents can read the inbox, search messages, send replies, and perform other void operations over stdio. Those tools need the same business logic the CLI already implements — but today that logic lives inside command handlers, tightly coupled to clap argument parsing and terminal output.

This PR is the foundation: it extracts read and write operations into a shared service layer that both the CLI and the upcoming MCP server can call. The CLI keeps its existing flags and output format; command handlers become thin wrappers that parse args, call the service, and print the result.

No user-facing behavior change in this PR. The goal is a single source of truth for void operations before wiring up MCP tools in follow-up PRs.

What changed

  • Added crates/void-cli/src/service/ with:
    • reads.rs — inbox, messages, search, contacts, channels, calendar, slack saved, etc.
    • writes.rs — send, reply, forward, archive, mute
    • mod.rs — shared JSON rendering helper
  • Rewired CLI command run() functions to delegate to the service layer
  • Updated CHANGELOG.md under [Unreleased]
  • Bumped anyhow to 1.0.103 (RUSTSEC-2026-0190) to unblock cargo-deny CI

Architecture

CLI command handler          MCP tool handler (future)
        │                              │
        └──────────┬───────────────────┘
                   ▼
         service/reads.rs  ·  service/writes.rs
                   │
                   ▼
            void-core (db, connectors, sync)

Command handlers own how (args, stdout). The service layer owns what (queries, mutations, JSON envelopes).

Deferred to follow-up PRs

  • void mcp subcommand and rmcp/schemars dependencies
  • MCP read/write tools, run tool, triage_inbox prompt
  • Remote-mode write guards, MCP docs and contract tests

Test plan

  • ./scripts/check.sh passes (fmt, clippy, tests)
  • CHANGELOG.md updated
  • Docs — N/A (no behavior or flag changes)

Prepares for the MCP server by moving business logic out of command handlers into service/reads and service/writes, with CLI commands as thin wrappers. No user-facing behavior change.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Telvary Telvary requested a review from MaximeGaudin as a code owner June 29, 2026 18:51
Unblocks cargo-deny CI after unsoundness advisory on 1.0.102.

Co-authored-by: Cursor <cursoragent@cursor.com>

@MaximeGaudin MaximeGaudin left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review — #34: refactor(cli): extract service layer for MCP reuse

Recommendation: Request changes
CI: ✅ all green (Linux/macOS/Windows, MSRV, cargo-deny, coverage, format)


1. Intent & fit

Fits — Well-motivated architectural refactoring. Clean separation of concerns, CHANGELOG updated, conventional commits, focused on one logical change.

2. Security · Verdict: clean ✅

  • Only dep change: anyhow 1.0.102 → 1.0.103 (existing dep, version bump for RUSTSEC-2026-0190). No new crates.
  • No .github/ workflow changes, no malicious patterns, no new filesystem/network access.

3. Code review

Blocker 🚫

reads.rs uses the wrong parse_date_to_ts for messages — timezone regression

service/reads.rs imports parse_date_to_ts from crate::commands::calendar::parsing, which interprets dates as local midnight (.and_local_timezone(Local)):

// calendar/parsing.rs — uses LOCAL timezone
pub(crate) fn parse_date_to_ts(date: &str) -> Option<i64> {
    chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")
        .ok()
        .and_then(|d| d.and_hms_opt(0, 0, 0))
        .and_then(|dt| dt.and_local_timezone(Local).single())
        .map(|dt| dt.timestamp())
}

But the original commands/messages.rs had its own version that used UTC midnight (.and_utc()):

// original messages.rs — used UTC
fn parse_date_to_ts(date: &str) -> Option<i64> {
    chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")
        .ok()
        .and_then(|d| d.and_hms_opt(0, 0, 0))
        .map(|dt| dt.and_utc().timestamp())
}

This changes the behavior of void messages --since/--until — dates are now offset by the user's timezone. For a user in UTC+2, --since 2024-03-15 now means "since 2024-03-14 22:00:00 UTC" instead of "since 2024-03-15 00:00:00 UTC".

Fix: Either add a parse_date_to_ts_utc variant and import it in reads.rs for the messages path, or decide that local-time is the correct interpretation and note the intentional behavior change in CHANGELOG.

Should-fix

  1. Duplicated parse_date_to_ts in writes.rs:492 — defines a private UTC copy instead of sharing from a common location. With the fix above, extract both variants (_utc and _local) into one module.

  2. cleanup_cached_files silently swallows all errors (writes.rs:511: let _ = std::fs::remove_file(path)). The original code logged warn! for non-NotFound errors. This makes file-cleanup failures invisible.

Nits

  • archive_bulk_before is async but awaits nothing — could be sync.
  • Reply/send CLI handlers re-parse at_str after the service function already consumed it (harmless redundancy for the eprintln timestamp).

4. Test coverage · adequate ✅

Unit tests cover the new public APIs (envelope shapes, resolve_send_target, archive validation). Existing integration tests (read_paths.rs, cli_contract.rs) exercise the full path through the service layer. Adequate for a refactoring PR — though notably, no existing test covers --since/--until filtering, which is how the timezone regression slipped through.

Summary

Solid refactoring with a clear rationale and clean architecture. One blocker: the timezone semantic mismatch in parse_date_to_ts silently regresses void messages --since/--until. Fix that, address the error-swallowing in cleanup_cached_files, and this is merge-ready.

Split parse_date_to_ts into explicit local (calendar) and UTC (messages,
archive --before) helpers. Restore debug/warn logging in
cleanup_cached_files. Add regression tests for both semantics.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Telvary

Telvary commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — agreed on all blocking/should-fix items.

Blocker (timezone): Fixed in 5442b71. parse_date_to_ts is now split into explicit parse_date_to_ts_utc (messages --since/--until, archive --before) and parse_date_to_ts_local (calendar). Messages path no longer imports the calendar local parser.

Should-fix (duplicated helper): Removed the private UTC copy in writes.rs; both paths share the parsing module.

Should-fix (cleanup_cached_files): Restored debug! on success and warn! on non-NotFound errors.

Tests: Added regression tests for UTC vs local midnight semantics in calendar/parsing.rs. On the "adequate coverage" point — structurally yes for a refactor, but you're right that the missing date-boundary test is what let the regression through; the new tests are meant to guard that explicitly.

Nits (async bulk archive, send/reply at re-parse): Leaving as-is for this PR — bulk path stays under the existing async archive entry point; CLI re-parse is presentation-only. Happy to tidy in MCP follow-up if you prefer.

CHANGELOG updated under [Unreleased] Fixed for the messages filter restore.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants