Files
spectral/src/config.rs
T
huntedbytheirs 1c668c3c1e kernel, patch, doctor, config: fill in the rest of Now and Next
kernel quest reads bugzilla's REST API and picks one bug through a seeded
xorshift, so a pick is reproducible in a test and varied in a person's hands.
kernel test builds under spawn_blocking and boots under qemu with the serial
console streamed line by line, and it reports what qemu did instead of
flattening a failed boot into a success.

patch create writes a whole series when --range is given. git format-patch
does the naming and the numbering because that is already its job, the cover
letter comes from --cover-letter, and a series.toml beside the patches records
the range with both ends as hashes, the revision, the files and the blurb.
patch submit takes a directory or several files and sends them in one
git send-email invocation, and patch update re-rolls a directory through
--reroll-count, deleting the superseded revision only once the new one is
complete.

doctor runs its checks in the order a first run hits them, prints one line and
a fix hint for each, and names the first hard failure in the summary. A missing
qemu or a missing mail route warns and keeps exit 0, because --dry-run is where
the loop actually stops on a machine without an SMTP route.

~/.config/spectral/config.toml holds the tree path, the patch directory,
always-CC addresses and an identity, with the environment winning over the file
and the file over the built-in default. spectral init verifies or clones a tree
and writes it once: a second run with the same tree says so and writes nothing,
and a run that would change an existing answer is refused until --force.

tests/ grew a fixture for each of those. The harness controls PATH and git's
configuration, and its PATH can hold a git that answers send-email either way,
so a host without that support still passes rather than deciding the result.

Two review lanes ran over this work and this commit carries their fixes. A
re-roll regenerated base..HEAD, so a commit that landed after create would have
silently joined the set and been mailed: the range is recorded now and a
re-roll regenerates exactly it. A patch file that was already gone made update
fail forever while the sidecar described a revision that was not on disk: a
missing file is the state the re-roll wanted. An empty blurb aborted after the
files were written: it is checked before anything is generated. A tree that is
not a git repository was reported as a missing revision. init cloned before it
refused. The bugzilla request had no timeout, a signal-ended child printed the
bare word "signal", CommandSpawn dropped the io reason, and the printed
send-email line quoted only arguments with spaces, so --in-reply-to <id@host>
pasted as a redirection.

Checked with cargo fmt --all -- --check, cargo clippy --all-targets --locked --
-D warnings, cargo test --locked (96 passed, 4 ignored) and cargo doc --no-deps.
The four ignored tests want the host's own checkpatch, get_maintainer and
bugzilla, and all four pass when asked for.
2026-09-16 21:52:05 -04:00

368 lines
11 KiB
Rust

//! Where the kernel tree and the patches live, and the file that can say so.
//!
//! Everything else asks this module for paths, so the config file and
//! `spectral init` touch nothing else. The precedence is the one the roadmap
//! promised: the environment wins over the file, and the file wins over the
//! built-in default.
use std::env;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::cli::InitArgs;
use crate::error::{Error, Result, exit_code};
/// Environment variable that points at the kernel checkout.
pub const ENV_KERNEL_TREE: &str = "SPECTRAL_KERNEL";
/// Environment variable that points at the patch directory.
pub const ENV_PATCH_DIR: &str = "SPECTRAL_PATCH_DIR";
/// Tree used when nothing else says otherwise, relative to `$HOME`.
const DEFAULT_KERNEL_TREE: &str = ".spectral/linux";
/// Where written patches land by default, relative to `$HOME`.
const DEFAULT_PATCH_DIR: &str = ".spectral/patches";
/// The file, relative to the config directory.
const CONFIG_FILE: &str = "spectral/config.toml";
/// What the config file holds.
///
/// Unknown keys are refused rather than ignored: a mistyped key that silently
/// does nothing is worse than one that stops the run and says so.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
/// Where the kernel tree is.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kernel_tree: Option<PathBuf>,
/// Where written patches go.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patch_dir: Option<PathBuf>,
/// Addresses every patch is copied to, on top of what get_maintainer says.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub always_cc: Vec<String>,
/// Who a patch is sent as.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub send_email: Option<SendEmail>,
}
/// The `[send_email]` table.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SendEmail {
/// `git send-email --from`, when it should differ from git's own identity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
}
/// Where a resolved path came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Origin {
/// One of the two environment variables.
Environment(&'static str),
/// The config file.
File,
/// The built-in default, relative to `$HOME`.
Default,
}
impl Origin {
/// How the report names it.
#[must_use]
pub fn describe(self) -> String {
match self {
Self::Environment(variable) => format!("from ${variable}"),
Self::File => "from the config file".to_owned(),
Self::Default => "the built-in default".to_owned(),
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
kernel_tree: PathBuf,
patch_dir: PathBuf,
tree_origin: Origin,
patch_origin: Origin,
file: ConfigFile,
file_path: PathBuf,
}
impl Config {
/// Resolve both paths: environment first, then the config file, then the
/// default under `$HOME`.
///
/// The tree is not checked for existence here. Commands that need it call
/// [`Config::require_kernel_tree`], which leaves `kernel quest` working
/// before anything is cloned.
pub fn load() -> Result<Self> {
let home = home_dir();
let file_path = config_path(&home);
let file = read_file(&file_path)?;
let (kernel_tree, tree_origin) = resolve(
ENV_KERNEL_TREE,
file.kernel_tree.clone(),
home.join(DEFAULT_KERNEL_TREE),
)?;
let (patch_dir, patch_origin) = resolve(
ENV_PATCH_DIR,
file.patch_dir.clone(),
home.join(DEFAULT_PATCH_DIR),
)?;
Ok(Self {
kernel_tree,
patch_dir,
tree_origin,
patch_origin,
file,
file_path,
})
}
#[must_use]
pub fn kernel_tree(&self) -> &Path {
&self.kernel_tree
}
#[must_use]
pub fn patch_dir(&self) -> &Path {
&self.patch_dir
}
/// Where the tree path came from, for `doctor` to print.
///
/// A path that was resolved for the user and silently turns out to be the
/// wrong one is a worse report than one that says which knob set it.
#[must_use]
pub fn kernel_tree_origin(&self) -> String {
self.tree_origin.describe()
}
/// Where the patch directory came from, for `doctor` to print.
#[must_use]
pub fn patch_dir_origin(&self) -> String {
self.patch_origin.describe()
}
/// The identity a patch is sent as, when the config file names one.
#[must_use]
pub fn send_email_from(&self) -> Option<String> {
self.file
.send_email
.as_ref()
.and_then(|send_email| send_email.from.clone())
}
/// Addresses every patch is copied to.
#[must_use]
pub fn always_cc(&self) -> &[String] {
&self.file.always_cc
}
/// The config file this run read, whether or not it was there.
#[must_use]
pub fn file_path(&self) -> &Path {
&self.file_path
}
/// Resolve the tree and check that it really is a kernel source tree.
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,
})
}
}
}
/// What `spectral init` did.
#[derive(Debug, Clone)]
pub struct Init {
/// The file that was written, or that already said this.
pub config_file: PathBuf,
/// The tree the config now names.
pub kernel_tree: PathBuf,
/// Whether anything was written.
pub wrote: bool,
}
/// `spectral init`: point the config at a tree, verifying or cloning one.
///
/// Idempotent on purpose. A second run that would change nothing says so and
/// writes nothing; a run that would change an existing answer is refused until
/// `--force` says it meant it.
pub fn init(args: &InitArgs) -> Result<Init> {
let home = home_dir();
let config_file = config_path(&home);
let current = read_file(&config_file)?;
let tree = match (&args.tree, &args.clone) {
// The destination is named first: the refusal below has to happen
// before a clone runs, not after one has already landed.
(tree, Some(_)) => tree
.clone()
.unwrap_or_else(|| home.join(DEFAULT_KERNEL_TREE)),
(Some(tree), None) => tree.clone(),
(None, None) => Config::load()?.kernel_tree().to_path_buf(),
};
let mut next = current.clone();
next.kernel_tree = Some(tree.clone());
let changed = next != current;
if changed && config_file.is_file() && !args.force {
return Err(Error::ConfigSaysSomethingElse { path: config_file });
}
if let Some(url) = &args.clone {
clone_tree(url, &tree, args.depth)?;
}
verify_tree(&tree)?;
if changed {
write_file(&config_file, &next)?;
}
Ok(Init {
config_file,
kernel_tree: tree,
wrote: changed,
})
}
/// Clone `url` into `into`.
///
/// A tree already sitting there is left alone: `init` run twice should not
/// re-clone anything.
fn clone_tree(url: &str, into: &Path, depth: Option<u32>) -> Result<()> {
if into.join("scripts/checkpatch.pl").is_file() {
return Ok(());
}
if let Some(parent) = into.parent() {
std::fs::create_dir_all(parent)?;
}
let mut args = vec!["clone".to_owned()];
if let Some(depth) = depth {
args.push("--depth".to_owned());
args.push(depth.to_string());
}
args.push(url.to_owned());
args.push(into.display().to_string());
let output =
Command::new("git")
.args(&args)
.output()
.map_err(|source| Error::CommandSpawn {
program: "git clone".to_owned(),
source,
})?;
if !output.status.success() {
return Err(Error::ExternalCommand {
program: "git".to_owned(),
args: args.join(" "),
code: exit_code(&output.status),
stderr: String::from_utf8_lossy(&output.stderr)
.trim_end()
.to_owned(),
});
}
Ok(())
}
/// Check that a path really is a kernel source tree.
fn verify_tree(tree: &Path) -> Result<()> {
if tree.join("scripts/checkpatch.pl").is_file() {
Ok(())
} else if tree.exists() {
Err(Error::NotAKernelTree {
path: tree.to_path_buf(),
})
} else {
Err(Error::KernelTreeMissing {
path: tree.to_path_buf(),
env: ENV_KERNEL_TREE,
})
}
}
/// Read the config file, or hand back an empty one when it is not there.
fn read_file(path: &Path) -> Result<ConfigFile> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(ConfigFile::default());
}
Err(error) => return Err(Error::Io(error)),
};
toml::from_str(&text).map_err(|source| Error::ConfigUnreadable {
path: path.to_path_buf(),
source,
})
}
/// Write the config file, making its directory first.
fn write_file(path: &Path, file: &ConfigFile) -> Result<()> {
let text = toml::to_string(file).map_err(|source| Error::ConfigUnwritable { source })?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, text)?;
Ok(())
}
/// Where the config file lives: `$XDG_CONFIG_HOME/spectral/config.toml`, or
/// `~/.config/spectral/config.toml` when that variable is unset.
#[must_use]
pub fn config_path(home: &Path) -> PathBuf {
env::var_os("XDG_CONFIG_HOME")
.map_or_else(|| home.join(".config"), PathBuf::from)
.join(CONFIG_FILE)
}
/// Pick one path out of the environment, the file, and the default.
///
/// A relative path in the file is refused rather than resolved against
/// whichever directory the command happened to run in.
fn resolve(
variable: &'static str,
from_file: Option<PathBuf>,
fallback: PathBuf,
) -> Result<(PathBuf, Origin)> {
if let Some(value) = env::var_os(variable) {
return Ok((PathBuf::from(value), Origin::Environment(variable)));
}
if let Some(path) = from_file {
if !path.is_absolute() {
return Err(Error::ConfigPathRelative { variable, path });
}
return Ok((path, Origin::File));
}
Ok((fallback, Origin::Default))
}
fn home_dir() -> PathBuf {
env::var_os("HOME").map_or_else(|| PathBuf::from("."), PathBuf::from)
}