scaffold: CLI surface with stubbed kernel and patch verbs

Add the spectral command tree -- kernel quest/test and patch
check/format/commit/create/submit/update -- with every verb wired through
main to a todo!() body, plus the pieces they will share:

- config: resolve the kernel tree from $SPECTRAL_KERNEL, falling back to
  ~/.spectral/linux, patches under ~/.spectral/patches, and confirm a tree
  really is one before anything uses it
- git: a thin wrapper over the git binary, currently only run
- error: one thiserror enum, converted once in main
- patch/checkpatch and patch/maintainers: seams around the tree's own
  scripts/checkpatch.pl and scripts/get_maintainer.pl, so spectral never
  carries a stale copy of the kernel's rules

20 functions are todo!(); the #[allow(dead_code)] sites mark the items
that are unreachable only until their caller is written. fmt, clippy -D
warnings, and test are all clean.
This commit is contained in:
2026-09-14 20:38:20 -04:00
parent 0ee045c71e
commit c25bf94c33
13 changed files with 754 additions and 3 deletions
+149
View File
@@ -0,0 +1,149 @@
//! The whole command surface, in one file, so the shape of the CLI reads at a
//! glance. Nothing here does any work — every variant is dispatched in `main`.
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(
name = "spectral",
version,
about = "A monolithic kernel work wrapper to make it easy.",
subcommand_required = true,
arg_required_else_help = true
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Find work, build it, boot it
Kernel {
#[command(subcommand)]
command: KernelCommand,
},
/// Carry a change from working tree to mailing list
Patch {
#[command(subcommand)]
command: PatchCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum KernelCommand {
/// Fetch a random open issue to work on
Quest(QuestArgs),
/// Build the kernel and boot it under qemu
Test(TestArgs),
}
#[derive(Debug, Args)]
pub struct QuestArgs {
/// Print the picked issue as JSON
#[arg(long)]
pub json: bool,
/// Only consider issues whose title contains this text
#[arg(long, value_name = "TEXT")]
pub filter: Option<String>,
}
#[derive(Debug, Args)]
pub struct TestArgs {
/// Boot the existing build instead of rebuilding first
#[arg(long)]
pub no_build: bool,
/// Rootfs image to boot with; defaults to the tree's own test image
#[arg(long, value_name = "PATH")]
pub rootfs: Option<PathBuf>,
/// Extra argument passed through to qemu (repeatable)
#[arg(long = "qemu-arg", value_name = "ARG", allow_hyphen_values = true)]
pub qemu_args: Vec<String>,
}
#[derive(Debug, Subcommand)]
pub enum PatchCommand {
/// Run checkpatch.pl over the work in progress
Check(CheckArgs),
/// Auto-fix what checkpatch.pl reports
Format(FormatArgs),
/// Commit the work in progress with a kernel-style message
Commit(CommitArgs),
/// Write the diff against the base branch out to <NAME>.patch
Create(CreateArgs),
/// Send a patch, with To/CC taken from get_maintainer.pl
Submit(SubmitArgs),
/// Re-roll a patch as v<N>, renaming the file to match
Update(UpdateArgs),
}
#[derive(Debug, Args)]
pub struct CheckArgs {
/// Check this patch file instead of the working tree
#[arg(value_name = "PATCH")]
pub patch: Option<PathBuf>,
/// Pass --strict to checkpatch.pl
#[arg(long)]
pub strict: bool,
}
#[derive(Debug, Args)]
pub struct FormatArgs {
/// Fix this patch file instead of the working tree
#[arg(value_name = "PATCH")]
pub patch: Option<PathBuf>,
}
#[derive(Debug, Args)]
pub struct CommitArgs {
/// Commit message, without the Signed-off-by line
#[arg(value_name = "MESSAGE")]
pub message: String,
/// Append your Signed-off-by, which the kernel requires
#[arg(short = 's', long)]
pub signoff: bool,
/// Amend the previous commit instead of adding one
#[arg(long)]
pub amend: bool,
}
#[derive(Debug, Args)]
pub struct CreateArgs {
/// Patch name; `.patch` is appended if you leave it off
#[arg(value_name = "NAME")]
pub name: String,
/// Branch or revision to diff against
#[arg(long, value_name = "REV", default_value = "master")]
pub base: String,
/// Directory to write the patch into; defaults to the patch dir
#[arg(long, value_name = "DIR")]
pub out: Option<PathBuf>,
}
#[derive(Debug, Args)]
pub struct SubmitArgs {
/// Patch file to send
#[arg(value_name = "PATCH")]
pub patch: PathBuf,
/// Print the recipients and command instead of sending
#[arg(long)]
pub dry_run: bool,
/// Extra address to CC (repeatable)
#[arg(long = "cc", value_name = "ADDR")]
pub cc: Vec<String>,
/// Message-Id of the patch this one re-rolls, so mail threads correctly
#[arg(long, value_name = "MSGID")]
pub in_reply_to: Option<String>,
}
#[derive(Debug, Args)]
pub struct UpdateArgs {
/// Patch file to re-roll
#[arg(value_name = "PATCH")]
pub patch: PathBuf,
/// Revision to re-roll as; defaults to one past the file's current vN
#[arg(short = 'v', long, value_name = "N")]
pub revision: Option<u32>,
}
+78
View File
@@ -0,0 +1,78 @@
//! Where the kernel tree and the patches live.
//!
//! Everything else asks this module for paths, so the day a config file or
//! `spectral init` arrives, it is the only thing that changes.
#![allow(dead_code)] // accessors are read once the patch verbs stop being stubs
use std::env;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
/// Environment variable that points at the kernel checkout.
pub const ENV_KERNEL_TREE: &str = "SPECTRAL_KERNEL";
/// Tree used when that variable is unset, relative to `$HOME`.
const DEFAULT_KERNEL_TREE: &str = ".spectral/linux";
/// Where written patches land, relative to `$HOME`.
const DEFAULT_PATCH_DIR: &str = ".spectral/patches";
#[derive(Debug, Clone)]
pub struct Config {
kernel_tree: PathBuf,
patch_dir: PathBuf,
}
impl Config {
/// Resolve the tree from `$SPECTRAL_KERNEL`, falling back to
/// `~/.spectral/linux`.
///
/// The tree is not checked for existence here — commands that need it call
/// [`Config::require_kernel_tree`], so `kernel quest` still works before
/// anything is cloned.
///
/// TODO: also read `~/.config/spectral/config.toml` once `spectral init`
/// exists. The environment variable should keep winning over the file.
pub fn load() -> Result<Self> {
let home = home_dir();
let kernel_tree = env::var_os(ENV_KERNEL_TREE)
.map_or_else(|| home.join(DEFAULT_KERNEL_TREE), PathBuf::from);
Ok(Self {
kernel_tree,
patch_dir: home.join(DEFAULT_PATCH_DIR),
})
}
#[must_use]
pub fn kernel_tree(&self) -> &Path {
&self.kernel_tree
}
#[must_use]
pub fn patch_dir(&self) -> &Path {
&self.patch_dir
}
/// The kernel tree, having confirmed it is one.
pub fn require_kernel_tree(&self) -> Result<&Path> {
if self.kernel_tree.join("scripts/checkpatch.pl").is_file() {
Ok(&self.kernel_tree)
} else if self.kernel_tree.exists() {
Err(Error::NotAKernelTree {
path: self.kernel_tree.clone(),
})
} else {
Err(Error::KernelTreeMissing {
path: self.kernel_tree.clone(),
env: ENV_KERNEL_TREE,
})
}
}
}
fn home_dir() -> PathBuf {
env::var_os("HOME").map_or_else(|| PathBuf::from("."), PathBuf::from)
}
+50
View File
@@ -0,0 +1,50 @@
//! One error type for the whole CLI.
//!
//! Anything that can go wrong ends up as an [`Error`], gets a `?` at the call
//! site, and is printed once in `main` — no `unwrap` in the command paths.
use std::path::PathBuf;
/// Shorthand for the crate's result type.
pub type Result<T, E = Error> = std::result::Result<T, E>;
// Variants that no command constructs yet, but that the command paths below
// are written to return. Safe to drop the allow once they are all reached.
#[allow(dead_code)]
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Nothing at the configured path at all.
#[error("no kernel tree at `{path}`; set ${env} or clone one there")]
KernelTreeMissing { path: PathBuf, env: &'static str },
/// Something is there, but it is not a kernel source tree.
#[error("`{path}` is not a kernel source tree (no scripts/checkpatch.pl)")]
NotAKernelTree { path: PathBuf },
/// An external tool ran and failed.
#[error("`{program} {args}` failed (exit {code}):\n{stderr}")]
ExternalCommand {
program: String,
args: String,
code: String,
stderr: String,
},
/// An external tool could not even be started.
#[error("could not run `{program}`")]
CommandSpawn {
program: String,
#[source]
source: std::io::Error,
},
/// A patch file the user named does not exist.
#[error("no patch named `{name}` in `{dir}`")]
PatchNotFound { name: String, dir: PathBuf },
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Http(#[from] reqwest::Error),
}
+84
View File
@@ -0,0 +1,84 @@
//! Thin plumbing over the `git` binary.
//!
//! Nothing in here knows what a kernel is: it starts processes, hands back
//! stdout, and turns a non-zero exit into an [`Error`].
#![allow(dead_code)] // reachable as soon as the patch verbs stop being stubs
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::error::{Error, Result};
/// A git invocation rooted at one repository.
#[derive(Debug, Clone)]
pub struct Git {
repo: PathBuf,
}
impl Git {
pub fn new(repo: impl Into<PathBuf>) -> Self {
Self { repo: repo.into() }
}
#[must_use]
pub fn repo(&self) -> &Path {
&self.repo
}
/// Run git in the repository and return its trimmed stdout.
///
/// This is the only place a git process is started; the semantic
/// operations below are written in terms of it.
pub fn run(&self, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.arg("-C")
.arg(&self.repo)
.args(args)
.output()
.map_err(|source| Error::CommandSpawn {
program: format!("git {}", args.join(" ")),
source,
})?;
if !output.status.success() {
return Err(Error::ExternalCommand {
program: "git".to_owned(),
args: args.join(" "),
code: output
.status
.code()
.map_or_else(|| "signal".to_owned(), |code| code.to_string()),
stderr: String::from_utf8_lossy(&output.stderr)
.trim_end()
.to_owned(),
});
}
Ok(String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_owned())
}
// TODO: each of these is a few lines over `run`, still unwritten.
/// The branch currently checked out, or `HEAD` when detached.
pub fn current_branch(&self) -> Result<String> {
todo!("git rev-parse --abbrev-ref HEAD")
}
/// The working tree diff against `base`, for piping into a patch file.
pub fn diff_against(&self, base: &str) -> Result<String> {
todo!("git diff {base}: needs --stat/--no-prefix decisions before it is real")
}
/// Commit the index, optionally signing off and optionally amending.
pub fn commit(&self, message: &str, signoff: bool, amend: bool) -> Result<()> {
todo!("git commit -m {message:?} -s={signoff} --amend={amend}")
}
/// Resolve a revision to a commit hash.
pub fn rev_parse(&self, rev: &str) -> Result<String> {
todo!("git rev-parse {rev}")
}
}
+4
View File
@@ -0,0 +1,4 @@
//! Finding kernel work, and seeing it boot.
pub mod qemu;
pub mod quest;
+44
View File
@@ -0,0 +1,44 @@
//! `spectral kernel test` — build the tree, then boot it under qemu.
#![allow(dead_code)] // nothing is reachable until `run` stops being a stub
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use crate::cli::TestArgs;
use crate::config::Config;
use crate::error::Result;
/// One qemu run.
#[derive(Debug, Clone)]
pub struct Boot {
/// Image to boot, normally `arch/x86/boot/bzImage`.
pub kernel: PathBuf,
/// Rootfs or initramfs to hand the kernel.
pub rootfs: Option<PathBuf>,
/// Arguments appended to the qemu command line verbatim.
pub extra_args: Vec<String>,
}
/// Build the tree and hand back the path to the bootable image.
pub fn build(kernel_tree: &Path) -> Result<PathBuf> {
todo!(
"make -j$(nproc) in {}, then locate arch/x86/boot/bzImage",
kernel_tree.display()
)
}
/// Boot an image and wait for qemu to exit.
pub async fn boot(kernel_tree: &Path, boot: &Boot) -> Result<ExitStatus> {
// TODO: qemu-system-x86_64 -kernel {boot.kernel} -append "console=ttyS0" \
// -nographic, with -initrd when `rootfs` is set and `extra_args` on
// the end. Stream serial output instead of buffering it.
let _ = (kernel_tree, boot);
todo!("boot under qemu and stream the console")
}
/// `spectral kernel test`: build unless told not to, then boot.
pub async fn run(config: &Config, args: TestArgs) -> Result<()> {
let _ = (config, args);
todo!("build + boot")
}
+75
View File
@@ -0,0 +1,75 @@
//! `spectral kernel quest` — go and find something worth fixing.
//!
//! The source is behind [`QuestSource`] so swapping bugzilla for syzbot, a
//! lore.kernel.org thread, or a local TODO file is one new impl and no change
//! to the command.
#![allow(dead_code)] // nothing is reachable until `run` stops being a stub
use std::future::Future;
use crate::cli::QuestArgs;
use crate::config::Config;
use crate::error::Result;
/// One piece of work, normalised across whatever source it came from.
#[derive(Debug, Clone)]
pub struct Issue {
/// Source-specific identifier, e.g. a bugzilla bug id.
pub id: String,
pub title: String,
/// Subsystem or component, when the source names one.
pub component: Option<String>,
/// Where a human reads the rest.
pub url: String,
}
/// Anything spectral can pull open work from.
///
/// Written as `impl Future` rather than a bare `async fn` so the `Send` bound
/// the async entry point needs is stated in the trait, not inferred at each
/// call site.
pub trait QuestSource {
fn fetch_open(&self) -> impl Future<Output = Result<Vec<Issue>>> + Send;
}
/// Open bugs on bugzilla.kernel.org.
#[derive(Debug, Clone)]
pub struct Bugzilla {
pub base_url: String,
}
impl Default for Bugzilla {
fn default() -> Self {
Self {
base_url: "https://bugzilla.kernel.org".to_owned(),
}
}
}
impl QuestSource for Bugzilla {
async fn fetch_open(&self) -> Result<Vec<Issue>> {
// TODO: GET `{base_url}/buglist.cgi` with status=NEW/ASSIGNED/REOPENED,
// walk the paginated result tables, and pull id + summary + product out
// of each row with `scraper`.
let _ = &self.base_url;
todo!("scrape open bugs from bugzilla.kernel.org")
}
}
/// Fetch the open issues and pick one.
///
/// TODO: apply `args.filter` to the title or component before picking, and
/// print JSON when `args.json` is set.
pub async fn run(config: &Config, args: QuestArgs) -> Result<Issue> {
let _ = (config, args);
todo!("pick one open bug at random")
}
/// Choose one issue at random. `seed` keeps the pick reproducible in tests.
pub fn pick_random(issues: &[Issue], seed: u64) -> Option<&Issue> {
todo!(
"index into {} candidate issues with seed {seed}",
issues.len()
)
}
+72 -2
View File
@@ -1,3 +1,73 @@
fn main() {
println!("Hello, world!");
//! spectral — a monolithic kernel work wrapper to make it easy.
//!
//! `main` does three things: parse, dispatch, render. Anything with a kernel
//! or git in it lives behind one of the modules below.
mod cli;
mod config;
mod error;
mod git;
mod kernel;
mod patch;
use std::process::ExitCode;
use clap::Parser;
use crate::cli::{Cli, Command, KernelCommand, PatchCommand};
use crate::config::Config;
use crate::error::Result;
#[tokio::main]
async fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli).await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("spectral: {error}");
ExitCode::FAILURE
}
}
}
/// The command modules do the work and hand back what they produced; printing
/// it is `main`'s job.
async fn run(cli: Cli) -> Result<()> {
let config = Config::load()?;
match cli.command {
Command::Kernel { command } => match command {
KernelCommand::Quest(args) => {
let issue = kernel::quest::run(&config, args).await?;
println!("{} {}", issue.id, issue.title);
println!("{}", issue.url);
Ok(())
}
KernelCommand::Test(args) => kernel::qemu::run(&config, args).await,
},
Command::Patch { command } => match command {
PatchCommand::Check(args) => {
let report = patch::check(&config, args)?;
print!("{}", report.output);
Ok(())
}
PatchCommand::Format(args) => {
let report = patch::format(&config, args)?;
print!("{}", report.output);
Ok(())
}
PatchCommand::Commit(args) => patch::commit(&config, args),
PatchCommand::Create(args) => {
println!("{}", patch::create(&config, args)?.display());
Ok(())
}
PatchCommand::Submit(args) => patch::submit(&config, args),
PatchCommand::Update(args) => {
println!("{}", patch::update(&config, args)?.display());
Ok(())
}
},
}
}
+47
View File
@@ -0,0 +1,47 @@
//! `scripts/checkpatch.pl`, run the two ways spectral needs it.
#![allow(dead_code)] // reachable as soon as the patch verbs stop being stubs
use std::path::{Path, PathBuf};
use crate::error::Result;
/// What checkpatch.pl should look at.
#[derive(Debug, Clone)]
pub enum Target {
/// The uncommitted working tree, via `--git`.
WorkingTree,
/// A committed range, e.g. `HEAD~1`.
Rev(String),
/// A patch file, via `--file`.
File(PathBuf),
}
/// What checkpatch.pl said.
#[derive(Debug, Clone)]
pub struct Report {
pub errors: usize,
pub warnings: usize,
/// The full checkpatch output, for printing or for `format` to act on.
pub output: String,
}
impl Report {
/// Whether the patch is clean by checkpatch's standards.
#[must_use]
pub fn is_clean(&self) -> bool {
self.errors == 0 && self.warnings == 0
}
}
/// Run checkpatch.pl over `target`.
///
/// `fix` adds `--fix`, which rewrites the patch file in place — only
/// `Target::File` supports it, and the caller is responsible for having the
/// change committed first so a bad fix is one `git checkout` away.
pub fn run(kernel_tree: &Path, target: &Target, strict: bool, fix: bool) -> Result<Report> {
todo!(
"scripts/checkpatch.pl --no-tree in {} on {target:?} (strict={strict}, fix={fix})",
kernel_tree.display()
)
}
+32
View File
@@ -0,0 +1,32 @@
//! `scripts/get_maintainer.pl`, so `patch submit` knows who to mail.
#![allow(dead_code)] // reachable as soon as the patch verbs stop being stubs
use std::path::{Path, PathBuf};
use crate::error::Result;
/// Who a patch goes to, already split into the two headers git send-email wants.
#[derive(Debug, Clone, Default)]
pub struct Recipients {
/// Maintainers listed with a `(M)` role, plus the lists they own.
pub to: Vec<String>,
/// Reviewers, lists, and everyone else worth a courtesy copy.
pub cc: Vec<String>,
/// Files the patch touches, which is what the lookup runs against.
pub touched: Vec<PathBuf>,
}
/// Look up recipients for a patch.
///
/// Runs `get_maintainer.pl --roles=... --git` over the patch's diff, which is
/// what gives us the files it touches as a side effect.
pub fn lookup(kernel_tree: &Path, patch: &Path) -> Result<Recipients> {
todo!("get_maintainer.pl --git on {patch:?} inside {kernel_tree:?}")
}
/// Add extra addresses to the CC list, skipping ones already there.
pub fn add_cc(recipients: &mut Recipients, extra: &[String]) {
let _ = (recipients, extra);
todo!("push the extra addresses onto the CC list, deduped")
}
+83
View File
@@ -0,0 +1,83 @@
//! `spectral patch …` — carry a change from working tree to mailing list.
//!
//! The verbs are all stubs, but the order they are meant to be run in is the
//! point of the module:
//!
//! ```text
//! check ─▶ format ─▶ commit ─▶ create ─▶ submit
//! ▲ │
//! └─ update┘ (v2, v3, …)
//! ```
//!
//! Each verb resolves the tree through [`Config::require_kernel_tree`] and
//! builds a [`crate::git::Git`] over it; that is how they reach the plumbing.
pub mod checkpatch;
pub mod maintainers;
use std::path::{Path, PathBuf};
use crate::cli::{CheckArgs, CommitArgs, CreateArgs, FormatArgs, SubmitArgs, UpdateArgs};
use crate::config::Config;
use crate::error::Result;
/// `checkpatch.pl` over the work in progress.
pub fn check(config: &Config, args: CheckArgs) -> Result<checkpatch::Report> {
let _ = (config, args);
todo!("resolve the target, run checkpatch, report errors and warnings")
}
/// Auto-fix whatever `check` reported.
pub fn format(config: &Config, args: FormatArgs) -> Result<checkpatch::Report> {
let _ = (config, args);
todo!("checkpatch --fix, then re-run check to show what is left")
}
/// Commit the work in progress with a kernel-style message.
pub fn commit(config: &Config, args: CommitArgs) -> Result<()> {
let _ = (config, args);
todo!("git commit, appending Signed-off-by when asked")
}
/// Write the diff against the base branch out as a patch file.
///
/// The name is normalised to end in `.patch` and the file lands in
/// [`Config::patch_dir`] unless `--out` says otherwise. Returns the path
/// written.
pub fn create(config: &Config, args: CreateArgs) -> Result<PathBuf> {
let _ = (config, args);
todo!("git diff <base> > <patch_dir>/<name>.patch")
}
/// Send a patch to whoever `get_maintainer.pl` names.
///
/// `--dry-run` stops one step short: print the recipients and the exact
/// `git send-email` invocation instead of sending it, which is the honest way
/// to review a first submission to a list.
pub fn submit(config: &Config, args: SubmitArgs) -> Result<()> {
let _ = (config, args);
todo!("look up recipients, then git send-email")
}
/// Re-roll a patch as `v<N>`.
///
/// `000-kernel-patch.patch` becomes `v2-000-kernel-patch.patch`; an existing
/// `vN-` prefix is replaced rather than stacked, so re-running it at the same
/// revision is a no-op. `args.revision` overrides the inferred next number.
/// Returns the path of the renamed file.
pub fn update(config: &Config, args: UpdateArgs) -> Result<PathBuf> {
let _ = (config, args);
todo!("work out N, rename the file, leave the contents alone")
}
/// The path `patch` should have once it is revision `revision`.
///
/// Kept separate from [`update`] because it is the rule worth having a test
/// for: strip one leading `vN-`, then prepend `v<N>-`.
#[allow(dead_code)] // called by `update` once that is written
pub fn reroll_path(patch: &Path, revision: u32) -> PathBuf {
todo!(
"strip the vN- prefix from {} if present, then prepend v{revision}-",
patch.display()
)
}