From c25bf94c33dc81b06aa752c01426d359d7b50104 Mon Sep 17 00:00:00 2001 From: huntedbytheirs Date: Mon, 14 Sep 2026 20:38:20 -0400 Subject: [PATCH] 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. --- Cargo.lock | 33 +++++++++ Cargo.toml | 4 +- src/cli.rs | 149 +++++++++++++++++++++++++++++++++++++++ src/config.rs | 78 ++++++++++++++++++++ src/error.rs | 50 +++++++++++++ src/git.rs | 84 ++++++++++++++++++++++ src/kernel/mod.rs | 4 ++ src/kernel/qemu.rs | 44 ++++++++++++ src/kernel/quest.rs | 75 ++++++++++++++++++++ src/main.rs | 74 ++++++++++++++++++- src/patch/checkpatch.rs | 47 ++++++++++++ src/patch/maintainers.rs | 32 +++++++++ src/patch/mod.rs | 83 ++++++++++++++++++++++ 13 files changed, 754 insertions(+), 3 deletions(-) create mode 100644 src/cli.rs create mode 100644 src/config.rs create mode 100644 src/error.rs create mode 100644 src/git.rs create mode 100644 src/kernel/mod.rs create mode 100644 src/kernel/qemu.rs create mode 100644 src/kernel/quest.rs create mode 100644 src/patch/checkpatch.rs create mode 100644 src/patch/maintainers.rs create mode 100644 src/patch/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 46cc201..9eb3c1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -167,6 +168,18 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "clap_lex" version = "1.1.1" @@ -475,6 +488,12 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "html5ever" version = "0.39.0" @@ -1460,6 +1479,8 @@ dependencies = [ "clap", "reqwest", "scraper", + "thiserror", + "tokio", ] [[package]] @@ -1623,9 +1644,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "tokio-rustls" version = "0.26.5" diff --git a/Cargo.toml b/Cargo.toml index f39ed88..a3f1925 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] -clap = "4.6.7" +clap = { version = "4.6.7", features = ["derive"] } reqwest = "0.13.5" scraper = "0.27.0" +thiserror = "2" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..ff1ebe1 --- /dev/null +++ b/src/cli.rs @@ -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, +} + +#[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, + /// Extra argument passed through to qemu (repeatable) + #[arg(long = "qemu-arg", value_name = "ARG", allow_hyphen_values = true)] + pub qemu_args: Vec, +} + +#[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 .patch + Create(CreateArgs), + /// Send a patch, with To/CC taken from get_maintainer.pl + Submit(SubmitArgs), + /// Re-roll a patch as v, 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, + /// 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, +} + +#[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, +} + +#[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, + /// Message-Id of the patch this one re-rolls, so mail threads correctly + #[arg(long, value_name = "MSGID")] + pub in_reply_to: Option, +} + +#[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, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..0776db7 --- /dev/null +++ b/src/config.rs @@ -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 { + 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) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..9dcfd89 --- /dev/null +++ b/src/error.rs @@ -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 = std::result::Result; + +// 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), +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..8745e2d --- /dev/null +++ b/src/git.rs @@ -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) -> 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 { + 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 { + 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 { + 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 { + todo!("git rev-parse {rev}") + } +} diff --git a/src/kernel/mod.rs b/src/kernel/mod.rs new file mode 100644 index 0000000..04bff9a --- /dev/null +++ b/src/kernel/mod.rs @@ -0,0 +1,4 @@ +//! Finding kernel work, and seeing it boot. + +pub mod qemu; +pub mod quest; diff --git a/src/kernel/qemu.rs b/src/kernel/qemu.rs new file mode 100644 index 0000000..81c0b7e --- /dev/null +++ b/src/kernel/qemu.rs @@ -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, + /// Arguments appended to the qemu command line verbatim. + pub extra_args: Vec, +} + +/// Build the tree and hand back the path to the bootable image. +pub fn build(kernel_tree: &Path) -> Result { + 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 { + // 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") +} diff --git a/src/kernel/quest.rs b/src/kernel/quest.rs new file mode 100644 index 0000000..9c87e58 --- /dev/null +++ b/src/kernel/quest.rs @@ -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, + /// 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>> + 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> { + // 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 { + 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() + ) +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..e136eef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(()) + } + }, + } } diff --git a/src/patch/checkpatch.rs b/src/patch/checkpatch.rs new file mode 100644 index 0000000..658be14 --- /dev/null +++ b/src/patch/checkpatch.rs @@ -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 { + todo!( + "scripts/checkpatch.pl --no-tree in {} on {target:?} (strict={strict}, fix={fix})", + kernel_tree.display() + ) +} diff --git a/src/patch/maintainers.rs b/src/patch/maintainers.rs new file mode 100644 index 0000000..d886645 --- /dev/null +++ b/src/patch/maintainers.rs @@ -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, + /// Reviewers, lists, and everyone else worth a courtesy copy. + pub cc: Vec, + /// Files the patch touches, which is what the lookup runs against. + pub touched: Vec, +} + +/// 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 { + 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") +} diff --git a/src/patch/mod.rs b/src/patch/mod.rs new file mode 100644 index 0000000..1ef7130 --- /dev/null +++ b/src/patch/mod.rs @@ -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 { + 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 { + 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 { + let _ = (config, args); + todo!("git diff > /.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`. +/// +/// `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 { + 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-`. +#[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() + ) +}