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.
368 lines
11 KiB
Rust
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)
|
|
}
|