Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Developer guide

This guide covers the day-to-day mtui development workflow: the toolchain, the quality gates, how the command layer is structured, and how to add a command. Architecture has the higher-level crate map and the data-format contracts; AGENTS.md at the repo root is the authoritative contributor spec and definition of done.

Toolchain

mtui uses the standard Rust toolchain — no rustup is assumed (the reference dev box runs a Homebrew rustc). Edition 2024, MSRV 1.96, pinned via rust-version in Cargo.toml.

TaskCommand
Build everythingcargo build --workspace (produces both mtui and mtui-mcp)
Run the REPLcargo run -p mtui -- --help
Run the MCP servercargo run -p mtui --bin mtui-mcp -- --help
Formatcargo fmt --all
Lint (warnings are errors)cargo clippy --workspace --all-targets --all-features -- -D warnings
Docs (broken/private links are errors)RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --document-private-items
Testcargo test --workspace
Coveragecargo llvm-cov --workspace --lcov --output-path lcov.info

Repo automation lives in the xtask crate, invoked through the alias in .cargo/config.toml:

TaskCommandEffect
Regenerate packaging artifactscargo xtask gendist/completions/{bash,zsh,fish}/… + dist/man/*.1
Regenerate generated docscargo xtask gen-docsdocs/src/cli.md + docs/src/invocation.md
Build a release tarballcargo xtask package --version <VER> --target <TRIPLE>dist/release/…tar.gz
Probe the teregen v1↔v2 corpuscargo xtask corpus-surveyRead-only 200/404/503/other split by RRID kind
Check for teregen schema driftcargo xtask schema-checkDiffs the live /api/v2/schema against the committed copy
Mint/reuse a teregen v2 auth tokencargo xtask teregen-login [--base <URL>] [--user <NAME>] [--namespace <NS>] [--no-store] [--no-probe]Signs teregen’s SSH challenge with your oscrc key, caches the bearer token, and probes GET /schema; never prints the token
Probe the teregen v2 write clientcargo xtask teregen-put --id <RRID> [--base <URL>] [--user <NAME>] [--file <PATH>] [--if-match <ETAG>|--create] [--dry-run]GETs a document and re-PUTs it with the just-received ETag by default; --file/--if-match/--create drive a backfill or a deliberate negative case; --dry-run prints the request instead of sending it

The .deb/.rpm on the release page have no xtask equivalent — they are built only by the package job in .github/workflows/release.yml.

schema-check needs qam.suse.de, unreachable from GitHub-hosted runners, so it stays a manual dev tool: run cargo xtask schema-check when starting or resuming report-document work, and treat drift as blocking.

Quality gates

CI (.github/workflows/) mirrors the local gate. Run the whole workspace before claiming done:

cargo fmt --all --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --document-private-items
cargo test --workspace                       # default features only
cargo build --workspace --no-default-features # feature matrix (compile-only)
cargo build --workspace --all-features        # feature matrix (compile-only)

Notes that save time:

  • The cost is compilation, not test execution. A cold cargo build --workspace --tests is ~80 s; the test run is ~20-25 s. Give the first cargo test --workspace of a session a generous timeout (≥5 min) and don’t treat a cold-cache timeout as a failure.
  • Test default features only while iterating. On a 10-core, 32 GiB Mac16,10, cargo test --workspace --all-features took 36 s versus 28 s with default features; it adds only notify-rust and no extra runtime signal beyond the compile-only feature matrix. CI only compiles --all-features; it does not test it.
  • Scope tight during dev: cargo test -p <crate> for the crate you’re touching; reserve cargo test --workspace for the final gate.
  • mtui-cli’s lib suite is the slow one (its edit/shell tests spawn real editor/shell subprocesses) — don’t rerun it when working elsewhere.

New or changed code needs ≥80% patch coverage. For a genuinely un-coverable best-effort network/error path, add a focused test or a justified allow — never leave coverage silently red.

Command architecture

Every interactive command implements one Command trait (crates/mtui-core/src/command.rs) and is registered into a central registry by an explicit register_all() (crates/mtui-core/src/registry.rs). The REPL dispatch, tab-completion, the generated command reference, and the mtui-mcp tool synthesiser all iterate that one registry — so the surfaces can’t drift.

The trait has just two required methods; everything else has a default:

MethodSignatureDefault
namefn name(&self) -> &'static str(required)
callasync fn call(&self, session: &mut Session, args: &ArgMatches) -> CommandResult(required) — the per-template command body
aliasesfn aliases(&self) -> &'static [&'static str]&[]
aboutfn about(&self) -> Option<&'static str>None
scopefn scope(&self) -> ScopeScope::Active
requires_canonical_sessionfn requires_canonical_session(&self, argv: &[String]) -> boolfalse — answered per invocation, not per command
skip_hostless_templatesfn skip_hostless_templates(&self) -> booltrue
configurefn configure(&self, cmd: clap::Command) -> clap::Commandidentity (no args)
completefn complete(&self, session: &Session, text: &str, line: &str) -> Vec<String>Vec::new()

The command body is call(), not run(). The trait’s provided run() is the fan-out driver that resolves the target template(s) (per Workflow concepts) and invokes call() once per template — commands normally do not override run().

Commands operate on an explicit Session (config, host targets, loaded templates, display) passed into each call. There are no hidden globals.

How to add a command

  1. Implement the trait in crates/mtui-core/src/commands/<name>.rs. A minimal command (model: commands/whoami.rs):

    #![allow(unused)]
    fn main() {
    use async_trait::async_trait;
    use clap::ArgMatches;
    
    use crate::command::Command;
    use crate::error::CommandResult;
    use crate::session::Session;
    
    /// One-line summary shown by `help` and in the generated CLI reference.
    pub struct Foo;
    
    #[async_trait]
    impl Command for Foo {
        fn name(&self) -> &'static str {
            "foo"
        }
    
        fn about(&self) -> Option<&'static str> {
            Some("Does the foo thing.")
        }
    
        async fn call(&self, session: &mut Session, _args: &ArgMatches) -> CommandResult {
            session.display.println("hello, foo");
            Ok(())
        }
    }
    }

    For a command with arguments, override configure to add clap::Args and read them from args in call; for completion, override complete; for a non-default fan-out policy, override scope. commands/switch.rs is a compact model that exercises all three (a required positional, Scope::Single, requires_canonical_session, and a prefix completer).

  2. Re-export the type from commands/mod.rs (the pub use block).

  3. Register it with one line in register_all() (crates/mtui-core/src/registry.rs):

    #![allow(unused)]
    fn main() {
    registry.register(Arc::new(commands::Foo));
    }

    It is now a REPL command and an MCP tool automatically.

  4. Decide MCP exposure. If the command is REPL-only (needs a TTY) or executes locally, add it to MCP_DENYLIST in registry.rs. The deny-list is intersected with the live registry and consistency-tested, so a stale entry is warned about at boot.

  5. Add tests (see below) with ≥80% patch coverage, and snapshot any new text output.

  6. Regenerate the docs: cargo xtask gen-docs picks the new command up into cli.md automatically (the drift-guard test fails until you commit the regenerated file). Add behavioural prose to Workflow concepts only if the command introduces cross-cutting behaviour the per-command help can’t express.

Testing conventions

  • Unit tests are colocated in a #[cfg(test)] mod tests in the same file (see commands/whoami.rs and commands/switch.rs). Cover arg parsing, call against a mock session, the error path, and completion.
  • Shared scaffolding lives in crates/mtui-core/src/commands/mod.rs’s testkit module: empty_session(), session_with_hosts(...), session_with_targets(...), a FakeReport test double, a capturing display Buffer, and matches(command, argv) — the helper that runs a command’s own clap parser exactly as the engine does.
  • Mock, don’t hit the network or real hosts. SSH goes through MockConnection (crates/mtui-hosts/src/connection/mock.rs, implementing the Connection trait); HTTP goes through wiremock; subprocesses are driven through a command-runner trait or a stub on PATH. Real hosts/containers are gated behind #[ignore] + a CI env flag.
  • Snapshot text contracts with insta (report document goldens, MCP schemas, the lock wire format, display output). Review new snapshots with cargo insta review.
  • Make sure the test can fail. A green run means something only if red was reachable. Watch for fixtures that quietly disarm the assertion: a MockConnection answering empty stdout builds no downgrade command, so an “assert no --oldpackage” passes vacuously; a with_changing_boot_id() mock passes a skip-the-reboot test even when only the dispatch was skipped. For a test written to pin a bug, revert the fix, watch it go red, then restore. The same applies to the fix itself — when the leaf you are gating has other callers (Target::reboot is reached by close and the reboot command, not just the fan-out), gate on the layer that owns the state and probe the sibling paths.
  • A command whose body repoints the active template must be exercised through run, not call. Command::run is what restores the pre-dispatch pointer after call returns; a call-only test cannot observe that restore, so it cannot see a body-level pointer move get reverted.

The one-it.rs-per-crate rule

To keep test-compile time down, each crate’s integration tests are consolidated into a single binary. A crate using this pattern has, in its Cargo.toml:

autotests = false

[[test]]
name = "it"
path = "tests/it.rs"

and tests/it.rs pulls each test file in as a module:

#![allow(unused)]
fn main() {
#[path = "gitea.rs"]
mod gitea;
}

So add a new integration test as a mod line in tests/it.rs, not as a new top-level tests/*.rs (a new top-level file would be silently ignored under autotests = false). Because all the crate’s integration tests then share one process, anything touching a process-global (env vars, the spinner sink) must be serialised with #[serial(...)], and tests must not assume per-binary isolation. insta prefixes each snapshot file with the test-binary name, which is it for these crates: it__<module>__<name>.snap.

Exception: mtui-core. It does not use the it.rs pattern — its integration tests are per-file and its snapshots are named <module>__<name>.snap (no it__ prefix). Add a mtui-core integration test as a normal top-level tests/*.rs there; use the it.rs mod convention in root mtui, mtui-hosts, mtui-datasources, mtui-testreport, mtui-tui, and mtui-mcp.

Debugging the MCP server

The MCP server page is the operator reference; this covers iterating on the server itself (schema synthesis, the deny-list, a new report_* tool).

Start it under HTTP with debug logging:

cargo run -p mtui --bin mtui-mcp -- --transport http --port 8765 --debug

Then drive it with the MCP Inspector without an LLM in the loop:

npx @modelcontextprotocol/inspector

In the UI select Streamable HTTP, set the URL to http://127.0.0.1:8765/mcp, connect, and use the Tools tab to list every synthesised tool and call it with hand-crafted arguments. For a stdio smoke-test, point the Inspector at the binary instead of a URL; it spawns the subprocess and attaches to its stdio streams.

whoami is the cheapest end-to-end check — no loaded report, no hosts — so a successful whoami() confirms schema synthesis, the dispatch wrapper, the session lock, and the response envelope. The report_* tools refuse cleanly with a “no report document loaded” message until a load_template call has run, which is the correct refusal path.

Common issues:

  • HTTP client gets a 404 at the root — the endpoint is /mcp, not /.
  • A new command is missing from the tool surface — check MCP_DENYLIST; if it isn’t denied, confirm it’s registered in register_all() and re-exported from commands/mod.rs. Run with --debug to see the synthesis loop.
  • Tool calls time out — network-bound commands run for minutes; either raise the client’s read timeout or use background=true (see Background jobs). The server emits progress heartbeats for clients that supply a progressToken.

Documentation

The book lives under docs/ (mdBook). Two pages are generated — cli.md from the command registry and invocation.md from the binary parsers — via cargo xtask gen-docs; a drift-guard test (cargo test -p xtask) fails if the checked-in copies are stale. Never hand-edit those two files (or the cli.md preamble, which is emitted by the generator). Every other page is hand-written.

Build the book locally (if mdbook is installed):

mdbook build docs

Issue tracking and commits

Work is tracked in the project’s issue tracker.

Commits follow Conventional Commits (feat/fix/docs/refactor/test/ chore/…), explaining why rather than restating the diff. Reference SUSE Bugzilla as bsc#NNNN / boo#NNNN where applicable. Only commit when asked; inspect git status/git diff first and stage only intended files.