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.
This commit is contained in:
+305
-16
@@ -1,48 +1,130 @@
|
||||
//! Where the kernel tree and the patches live.
|
||||
//! Where the kernel tree and the patches live, and the file that can say so.
|
||||
//!
|
||||
//! Everything else asks this module for paths, so adding a config file or a
|
||||
//! `spectral init` only touches this file.
|
||||
|
||||
#![allow(dead_code)] // accessors are read once the patch verbs stop being stubs
|
||||
//! 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 crate::error::{Error, Result};
|
||||
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";
|
||||
|
||||
/// Tree used when that variable is unset, relative to `$HOME`.
|
||||
/// 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, relative to `$HOME`.
|
||||
/// 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 the tree from `$SPECTRAL_KERNEL`, falling back to
|
||||
/// `~/.spectral/linux`.
|
||||
/// 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.
|
||||
///
|
||||
/// 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);
|
||||
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: home.join(DEFAULT_PATCH_DIR),
|
||||
patch_dir,
|
||||
tree_origin,
|
||||
patch_origin,
|
||||
file,
|
||||
file_path,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +138,42 @@ impl Config {
|
||||
&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() {
|
||||
@@ -73,6 +191,177 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user