blob: f05d23e130d8f3071ba7ae94e5a01e7adf006523 [file] [log] [blame]
// Copyright 2022 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use {
anyhow::{bail, format_err, Result},
argh::FromArgs,
serde::{de::DeserializeOwned, Deserialize},
serde_yaml,
std::{
fs::{File, OpenOptions},
io::{stdin, stdout, BufRead, BufReader, Read, Write},
path::PathBuf,
process::Command,
},
};
#[derive(FromArgs, Debug, Default, PartialEq)]
#[argh(
description = "Creates a new RFC prepopulated with common fields.
The command attempts to auto-populate the author sections from your git configuration.",
example = "To create a new RFC run the following command and enter the requested information.
You can also use options to provide values for parameters that you don't wish to provide
interactively.
```
$ fx rfc --title 'Back to the Fuchsia'
Description: Fuchsia is a modern OS
...
Area(s): 1, 2
Issues: 123, 456
Reviewers: foo@google.com, bar@google.com
```
Your RFC markdown file and its metadata are added to //docs/contribute/governance/rfcs/",
note = "To learn more about writing RFCs, see
https://fuchsia.dev/fuchsia-src/contribute/governance/rfcs"
)]
pub struct CreateRfcArgs {
/// title to use for the RFC.
#[argh(option)]
pub title: Option<String>,
/// short description to use for the RFC.
#[argh(option)]
pub short_description: Option<String>,
/// emails of the RFC authors.
#[argh(option, long = "author")]
pub authors: Vec<String>,
/// areas of the RFC.
#[argh(option, long = "area")]
pub areas: Vec<String>,
/// monorail issues associated with the RFC.
#[argh(option, long = "issue")]
pub issues: Vec<String>,
/// emails of the anticipated RFC reviewers.
#[argh(option, long = "reviewer")]
pub reviewers: Vec<String>,
}
const AREAS_FILE: &str = "_areas.yaml";
const FX_RFC_GENERATED: &str = "<!-- Generated with `fx rfc` -->";
const META_FILE: &str = "_rfcs.yaml";
const RFC_NAME_PLACEHOLDER: &str = "RFC-NNNN";
const RFCS_PATH: &str = "docs/contribute/governance/rfcs";
const STATUS_PLACEHOLDER: &str = "Pending";
const TEMPLATE_FILE: &str = "TEMPLATE.md";
const TOC_FILE: &str = "_toc.yaml";
const TODO_COMMENT: &str = "# TODO: DO NOT SUBMIT, update.";
fn main() -> Result<(), anyhow::Error> {
let args: CreateRfcArgs = argh::from_env();
let handle = stdin().lock();
let cwd = std::env::current_dir()?;
let author_email_provider = GitConfig::new();
rfc_impl(
CommandLine { args, writer: &mut stdout(), reader: handle, author_email_provider },
cwd,
)
}
fn rfc_impl<A, R, W>(mut cli: CommandLine<'_, A, R, W>, cwd: PathBuf) -> Result<()>
where
A: AuthorEmailProvider,
R: BufRead,
W: Write,
{
let fuchsia_dir_root = get_fuchsia_dir_root(cwd)?.ok_or(format_err!(
"This command must be run from within the Fuchsia Platform Source Tree"
))?;
let rfcs_dir = fuchsia_dir_root.join(RFCS_PATH);
let title = cli.get_title()?;
let short_description = cli.get_short_description()?;
let authors = cli.get_authors()?;
let valid_areas = load_yaml::<Vec<String>>(rfcs_dir.join(AREAS_FILE))?;
let area = cli.get_areas(valid_areas)?;
let issue = cli.get_issues()?;
let reviewers = cli.get_reviewers()?;
let file = filename_from_title(&title);
let toc_meta = TocMetadata {
title: format!("{}: {}", RFC_NAME_PLACEHOLDER, title),
path: format!("/{}/{}", RFCS_PATH, file),
};
let mut rfc_meta = RfcMetadata {
name: RFC_NAME_PLACEHOLDER.to_string(),
title,
short_description,
authors,
file,
area,
issue,
reviewers,
status: STATUS_PLACEHOLDER.to_string(),
gerrit_change_id: vec![],
submitted: String::new(),
reviewed: String::new(),
};
rfc_meta.sort();
create_rfc(&rfc_meta, &rfcs_dir)?;
append_rfc_meta(rfc_meta, rfcs_dir.join(META_FILE))?;
append_rfc_meta(toc_meta, rfcs_dir.join(TOC_FILE))?;
Ok(())
}
struct CommandLine<'a, A, R, W> {
args: CreateRfcArgs,
author_email_provider: A,
reader: R,
writer: &'a mut W,
}
impl<'a, A, R, W> CommandLine<'a, A, R, W>
where
W: Write,
R: BufRead,
A: AuthorEmailProvider,
{
pub fn get_title(&mut self) -> Result<String> {
match &self.args.title {
Some(title) => Ok(title.clone()),
None => self.prompt("Title"),
}
}
pub fn get_short_description(&mut self) -> Result<String> {
match &self.args.short_description {
Some(description) => Ok(description.clone()),
None => self.prompt("Short description"),
}
}
pub fn get_authors(&mut self) -> Result<Vec<String>> {
let default_author = self.author_email_provider.get_email().ok();
let mut authors = self.strings_or_prompt(
self.args.authors.clone(),
format!(
"Authors (comma-separated){}",
default_author.as_ref().map(|a| format!(" [default: {}]", a)).unwrap_or_default()
),
)?;
if authors.is_empty() && default_author.is_some() {
authors.push(default_author.unwrap());
}
Ok(authors)
}
pub fn get_areas(&mut self, valid_areas: Vec<String>) -> Result<Vec<String>> {
if self.args.areas.is_empty() {
return self.request_areas(valid_areas);
}
let args_areas_len = self.args.areas.len();
let input_areas =
self.args.areas.clone().into_iter().map(|a| a.to_lowercase()).collect::<Vec<_>>();
let areas_found = valid_areas
.iter()
.filter(|a| input_areas.contains(&a.to_lowercase()))
.cloned()
.collect::<Vec<_>>();
if areas_found.len() == args_areas_len {
Ok(areas_found)
} else {
self.println("Invalid areas provided. Please select valid ones.")?;
self.request_areas(valid_areas)
}
}
pub fn get_issues(&mut self) -> Result<Vec<String>> {
self.strings_or_prompt(self.args.issues.clone(), "Monorail issue (comma-separated)")
}
pub fn get_reviewers(&mut self) -> Result<Vec<String>> {
self.strings_or_prompt(self.args.reviewers.clone(), "Reviewers (comma-separated emails)")
}
fn strings_or_prompt(
&mut self,
list: Vec<String>,
prompt: impl AsRef<str>,
) -> Result<Vec<String>> {
if list.is_empty() {
let input = self.prompt(prompt)?;
if input.trim().is_empty() {
return Ok(vec![]);
}
Ok(string_list(input))
} else {
Ok(list)
}
}
fn request_areas(&mut self, valid_areas: Vec<String>) -> Result<Vec<String>> {
for (i, area) in valid_areas.iter().enumerate() {
self.println(format!("[{:>2}] {}", i, area))?;
}
let areas =
areas_from_numbers(valid_areas, self.prompt("Area (comma-separated numbers)")?)?;
Ok(areas)
}
fn prompt(&mut self, text: impl AsRef<str>) -> Result<String> {
write!(&mut self.writer, "{}: ", text.as_ref())?;
self.writer.flush().unwrap();
let mut line = String::new();
self.reader.read_line(&mut line)?;
Ok(line.trim().to_string())
}
fn println(&mut self, text: impl AsRef<str>) -> Result<()> {
writeln!(&mut self.writer, "{}", text.as_ref())?;
Ok(())
}
}
// Note: not using serde_yaml since we have a bit of a special formatting for arrays. It doesn't
// seem that serde_yaml supports formatting arrays as `[]` like we do in our YAML files. Using
// serde_yaml would require updating the format of our YAML files, in particular _rfcs.yaml.
trait AppendYaml {
fn append_yaml(&self, w: impl Write) -> Result<()>;
}
struct RfcMetadata {
name: String,
title: String,
short_description: String,
authors: Vec<String>,
file: String,
area: Vec<String>,
issue: Vec<String>,
gerrit_change_id: Vec<String>,
status: String,
reviewers: Vec<String>,
submitted: String,
reviewed: String,
}
impl RfcMetadata {
fn sort(&mut self) {
self.authors.sort();
self.area.sort();
self.issue.sort();
self.gerrit_change_id.sort();
self.reviewers.sort();
}
}
impl AppendYaml for RfcMetadata {
fn append_yaml(&self, mut w: impl Write) -> Result<()> {
w.write_all(b"\n")?;
write_one(&mut w, 0, "- name", &self.name, "'", true)?;
write_one(&mut w, 2, "title", &self.title, "'", self.title.is_empty())?;
write_one(
&mut w,
2,
"short_description",
&self.short_description,
"'",
self.short_description.is_empty(),
)?;
write_list(&mut w, 2, "authors", &self.authors)?;
write_one(&mut w, 2, "file", &self.file, "'", false)?;
write_list(&mut w, 2, "area", &self.area)?;
write_list(&mut w, 2, "issue", &self.issue)?;
write_list(&mut w, 2, "gerrit_change_id", &self.gerrit_change_id)?;
write_one(&mut w, 2, "status", &self.status, "'", true)?;
write_list(&mut w, 2, "reviewers", &self.reviewers)?;
write_one(&mut w, 2, "submitted", &self.submitted, "'", true)?;
write_one(&mut w, 2, "reviewed", &self.reviewed, "'", true)?;
Ok(())
}
}
#[derive(Deserialize)]
struct TocMetadata {
title: String,
path: String,
}
impl AppendYaml for TocMetadata {
fn append_yaml(&self, mut w: impl Write) -> Result<()> {
write_one(&mut w, 2, "- title", &self.title, "\"", true)?;
write_one(&mut w, 4, "path", &self.path, "", true)?;
Ok(())
}
}
trait AuthorEmailProvider {
fn get_email(&self) -> Result<String>;
}
struct GitConfig {}
impl GitConfig {
fn new() -> Self {
Self {}
}
}
impl AuthorEmailProvider for GitConfig {
fn get_email(&self) -> Result<String> {
let result = Command::new("sh").arg("-c").arg("git config --get user.email").output()?;
Ok(String::from_utf8(result.stdout)?.trim().to_string())
}
}
fn write_one<T, W>(
w: &mut W,
spaces: usize,
prefix: &str,
value: T,
quote_char: &str,
comment: bool,
) -> Result<()>
where
W: Write,
T: std::fmt::Display,
{
write!(w, "{}{}: {}{}{}", " ".repeat(spaces), prefix, quote_char, value, quote_char)?;
if comment {
writeln!(w, " {}", TODO_COMMENT)?;
} else {
write!(w, "\n")?;
}
Ok(())
}
fn write_list<T, W>(w: &mut W, spaces: usize, prefix: &str, list: &[T]) -> Result<()>
where
W: Write,
T: std::fmt::Display,
{
write!(w, "{}{}: [", " ".repeat(spaces), prefix)?;
for (i, item) in list.iter().enumerate() {
write!(w, "'{}'", item)?;
if i < list.len() - 1 {
write!(w, ", ")?;
}
}
if list.is_empty() {
writeln!(w, "''] {}", TODO_COMMENT)?;
} else {
writeln!(w, "]")?;
}
Ok(())
}
fn filename_from_title(title: &str) -> String {
let filename = format!(
"NNNN_{}.md",
title.to_lowercase().split(' ').map(|s| s.trim()).collect::<Vec<_>>().join("_"),
);
filename.replace(std::path::is_separator, "_")
}
fn areas_from_numbers(valid_areas: Vec<String>, input_numbers: String) -> Result<Vec<String>> {
let indexes = input_numbers
.split(',')
.map(|s| s.trim().parse::<usize>())
.collect::<Result<Vec<usize>, _>>()
.map_err(|_| format_err!("Invalid area index found"))?;
if !indexes.iter().all(|i| *i < valid_areas.len()) {
bail!("Invalid area indexes");
}
Ok(indexes.into_iter().map(|i| valid_areas[i].clone()).collect())
}
fn string_list(input_authors: String) -> Vec<String> {
input_authors.split(',').map(|s| s.trim().to_string()).collect()
}
fn get_fuchsia_dir_root(current_dir: PathBuf) -> Result<Option<PathBuf>> {
let mut current_dir = current_dir;
loop {
let jiri_root_path = current_dir.join(".jiri_root/");
if jiri_root_path.exists() {
return Ok(Some(current_dir));
}
if !current_dir.pop() {
break;
}
}
Ok(None)
}
fn load_yaml<T>(path: PathBuf) -> Result<T>
where
T: DeserializeOwned,
{
let file = File::open(path)?;
let reader = BufReader::new(file);
Ok(serde_yaml::from_reader(reader).expect("can read yaml"))
}
fn append_rfc_meta<T>(data: T, path: PathBuf) -> Result<()>
where
T: AppendYaml,
{
let file = OpenOptions::new().write(true).append(true).open(path)?;
data.append_yaml(file)?;
Ok(())
}
fn create_rfc(meta: &RfcMetadata, path: &PathBuf) -> Result<()> {
let mut template_file = File::open(path.join(TEMPLATE_FILE))?;
let mut template = String::new();
template_file.read_to_string(&mut template)?;
let template = template.replace(
r#"{% set rfcid = "RFC-0000" %}"#,
r#"{% set rfcid = "RFC-NNNN" %} <!-- TODO: DO NOT SUBMIT, update number -->"#,
);
let reviewers =
meta.reviewers.iter().map(|r| format!("- {}", r)).collect::<Vec<_>>().join("\n");
let reviewers_index = template.find("_Reviewers:_").unwrap();
let consulted_index = template.find("_Consulted:_").unwrap();
let template = format!(
"{}\n{}_Reviewers:_\n\n{}\n\n{}",
FX_RFC_GENERATED,
&template[..reviewers_index],
reviewers,
&template[consulted_index..]
);
let mut rfc_file = File::create(path.join(&meta.file))?;
rfc_file.write_all(template.as_bytes())?;
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::{tempdir, TempDir};
const CMD_NAME: &'static [&'static str] = &["rfc"];
struct FakeEmailProvider {}
impl AuthorEmailProvider for FakeEmailProvider {
fn get_email(&self) -> Result<String> {
Ok("fuchsia-hacker@google.com".to_string())
}
}
struct FakeDir {
root: TempDir,
rfcs_path: PathBuf,
}
impl FakeDir {
pub fn new() -> Self {
let root = tempdir().expect("create tempdir");
let rfcs_path = root.path().join(RFCS_PATH);
fs::create_dir_all(&rfcs_path).expect("create root");
fs::create_dir_all(root.path().join(".jiri_root")).expect("create .jiri_root");
init_file(rfcs_path.join(META_FILE), include_str!("../test_data/rfcs.before.yaml"));
init_file(rfcs_path.join(TOC_FILE), include_str!("../test_data/toc.before.yaml"));
init_file(rfcs_path.join(AREAS_FILE), include_str!("../test_data/areas.yaml"));
init_file(
rfcs_path.join(TEMPLATE_FILE),
include_str!("../../../docs/contribute/governance/rfcs/TEMPLATE.md"),
);
Self { root, rfcs_path }
}
pub fn some_path(&self) -> PathBuf {
let path = self.root.path().join("some/path");
fs::create_dir_all(&path).expect("create some path");
path
}
pub fn rfc_file_exists(&self, file: &str) -> bool {
self.rfcs_path.join(file).exists()
}
pub fn validate_rfcs_file(&self, file: &str, expected: &str) {
let path = self.rfcs_path.join(file);
let mut file = File::open(&path).expect(&format!("open: {}", path.display()));
let mut contents = String::new();
file.read_to_string(&mut contents).expect(&format!("read file: {}", path.display()));
assert_eq!(contents, expected);
}
}
fn init_file(path: PathBuf, contents: &str) {
let mut file = File::create(path).expect("created file");
file.write_all(contents.as_bytes()).expect("wrote file");
}
fn full_args() -> CreateRfcArgs {
CreateRfcArgs {
title: Some("Back to the Fuchsia".to_string()),
short_description: Some("Fuchsia is a modern OS".to_string()),
authors: vec!["foo@google.com".to_string(), "bar@google.com".to_string()],
areas: vec!["Pink".to_string()],
issues: vec!["5678".to_string(), "1234".to_string()],
reviewers: vec!["quux@google.com".to_string(), "baz@google.com".to_string()],
}
}
#[test]
fn empty_args() {
assert_eq!(CreateRfcArgs::from_args(CMD_NAME, &[]), Ok(CreateRfcArgs::default()))
}
#[test]
fn args_with_options() {
assert_eq!(
CreateRfcArgs::from_args(
CMD_NAME,
&[
"--title",
"Back to the Fuchsia",
"--short-description",
"Fuchsia is a modern OS",
"--author",
"foo@google.com",
"--author",
"bar@google.com",
"--area",
"Pink",
"--issue",
"1234",
"--issue",
"5678",
"--reviewer",
"baz@google.com",
]
),
Ok(CreateRfcArgs {
title: Some("Back to the Fuchsia".to_string()),
short_description: Some("Fuchsia is a modern OS".to_string()),
authors: vec!["foo@google.com".to_string(), "bar@google.com".to_string(),],
areas: vec!["Pink".to_string(),],
issues: vec!["1234".to_string(), "5678".to_string(),],
reviewers: vec!["baz@google.com".to_string()]
})
);
}
#[test]
fn cli_with_all_optional_args() {
let dir = FakeDir::new();
// When the CLI receives all parameters through arguments, we don't expect any input.
let stdin = b"";
let mut stdout = Vec::<u8>::new();
let args = full_args();
rfc_impl(
CommandLine {
args,
writer: &mut stdout,
reader: &stdin[..],
author_email_provider: FakeEmailProvider {},
},
// Call this command from somewhere inside the fake fuchsia platform tree.
dir.some_path(),
)
.expect("succeeds");
// We don't expect any output when the CLI receives all parameters through arguments.
assert!(stdout.is_empty());
dir.validate_rfcs_file(META_FILE, include_str!("../test_data/rfcs.golden.yaml"));
dir.validate_rfcs_file(TOC_FILE, include_str!("../test_data/toc.golden.yaml"));
dir.validate_rfcs_file(
"NNNN_back_to_the_fuchsia.md",
include_str!("../test_data/rfc.golden.md"),
);
}
#[test]
fn cli_with_no_optional_args() {
let dir = FakeDir::new();
let stdin = vec![
"Back to the Fuchsia",
"Fuchsia is a modern OS",
"foo@google.com,bar@google.com",
"1",
"5678, 1234",
"quux@google.com, baz@google.com",
]
.join("\n")
.into_bytes();
let mut stdout = Vec::<u8>::new();
let args = CreateRfcArgs::default();
rfc_impl(
CommandLine {
args,
writer: &mut stdout,
reader: &stdin[..],
author_email_provider: FakeEmailProvider {},
},
dir.root.path().to_path_buf(),
)
.expect("succeeds");
assert_eq!(
std::str::from_utf8(&stdout).expect("stdout bytes"),
vec![
"Title: ",
"Short description: ",
"Authors (comma-separated) [default: fuchsia-hacker@google.com]: ",
"[ 0] Magenta\n[ 1] Pink\n[ 2] Purple\n",
"Area (comma-separated numbers): ",
"Monorail issue (comma-separated): ",
"Reviewers (comma-separated emails): "
]
.join("")
);
dir.validate_rfcs_file(META_FILE, include_str!("../test_data/rfcs.golden.yaml"));
dir.validate_rfcs_file(TOC_FILE, include_str!("../test_data/toc.golden.yaml"));
dir.validate_rfcs_file(
"NNNN_back_to_the_fuchsia.md",
include_str!("../test_data/rfc.golden.md"),
);
}
#[test]
fn areas_are_validated_from_stdin() {
let stdin = b"2";
let mut stdout = Vec::<u8>::new();
let args = CreateRfcArgs::default();
let mut cli = CommandLine {
args,
writer: &mut stdout,
reader: &stdin[..],
author_email_provider: FakeEmailProvider {},
};
assert!(cli.get_areas(vec!["A".to_string(), "B".to_string()]).is_err());
}
#[test]
fn areas_are_validated_from_args() {
let stdin = b"1";
let mut stdout = Vec::<u8>::new();
let mut args = CreateRfcArgs::default();
args.areas = vec!["C".to_string()];
let mut cli = CommandLine {
args,
writer: &mut stdout,
reader: &stdin[..],
author_email_provider: FakeEmailProvider {},
};
assert_eq!(
cli.get_areas(vec!["A".to_string(), "B".to_string()]).expect("got areas"),
vec!["B".to_string()]
);
assert_eq!(
std::str::from_utf8(&stdout).expect("valid stdout"),
vec![
"Invalid areas provided. Please select valid ones.",
"[ 0] A",
"[ 1] B",
"Area (comma-separated numbers): ",
]
.join("\n")
);
}
#[test]
fn default_author_is_set_when_no_author_given() {
let stdin = b"";
let mut stdout = Vec::<u8>::new();
let mut cli = CommandLine {
args: CreateRfcArgs::default(),
writer: &mut stdout,
reader: &stdin[..],
author_email_provider: FakeEmailProvider {},
};
assert_eq!(
cli.get_authors().expect("got authors"),
vec!["fuchsia-hacker@google.com".to_string()]
);
assert_eq!(
std::str::from_utf8(&stdout).expect("valid stdout"),
"Authors (comma-separated) [default: fuchsia-hacker@google.com]: "
);
}
#[test]
fn accepts_a_title_with_slashes() {
let dir = FakeDir::new();
let stdin = vec!["Hello/There"].join("\n").into_bytes();
let mut stdout = Vec::<u8>::new();
let mut args = full_args();
args.title = None;
rfc_impl(
CommandLine {
args,
writer: &mut stdout,
reader: &stdin[..],
author_email_provider: FakeEmailProvider {},
},
dir.root.path().to_path_buf(),
)
.expect("succeeds");
assert_eq!(std::str::from_utf8(&stdout).expect("stdout bytes"), "Title: ");
assert!(dir.rfc_file_exists("NNNN_hello_there.md"));
}
}