blob: 82dbb03f760b01791bf3754aa96d95396e5e7260 [file] [log] [blame]
// Copyright 2021 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.
//! Clidoc generates documentation for host tool commands consisting of their --help output.
use {
anyhow::{bail, Context, Result},
argh::FromArgs,
log::{info, LevelFilter},
std::{
collections::HashSet,
env,
ffi::{OsStr, OsString},
fs::{self, File},
io::{prelude::*, BufWriter},
path::{Path, PathBuf},
process::Command,
},
};
/// CliDoc generates documentation for core Fuchsia developer tools.
#[derive(Debug, FromArgs)]
struct Opt {
// Default input dir is parent dir of this tool, containing host tools exes
// $FUCHSIA_DIR/out/default/host_x64 or $FUCHSIA_DIR/out/default/host-tools
/// set the input folder
#[argh(
option,
short = 'i',
default = "env::current_exe().unwrap().parent().unwrap().to_path_buf()"
)]
in_dir: PathBuf,
/// set the output directory
#[argh(option, short = 'o', default = "PathBuf::from(\".\".to_string())")]
out_dir: PathBuf,
/// reduce text output
#[argh(switch)]
quiet: bool,
/// increase text output
#[argh(switch, short = 'v')]
verbose: bool,
}
// TODO(fxb/69336): Move allow list to its own separate config file.
const ALLOW_LIST: &'static [&'static str] = &[
"bootserver",
"cmc",
"device-finder",
"far",
"fconfig",
"ffx",
"fidl-format",
"fidlc",
"fidlcat",
"fidlgen",
"fpublish",
"fremote",
"fserve",
"fssh",
"fvdl",
"fvm",
"merkleroot",
"minfs",
"pm",
"symbol-index",
"symbolize",
"symbolizer",
"triage",
"zbi",
"zxdb",
];
fn main() -> Result<()> {
let opt: Opt = argh::from_env();
run(opt)
}
fn run(opt: Opt) -> Result<()> {
if opt.quiet && opt.verbose {
bail!("cannot use --quiet and --verbose together");
}
if opt.verbose {
println!("verbose true");
log::set_max_level(LevelFilter::Debug);
} else if opt.quiet {
log::set_max_level(LevelFilter::Warn);
} else {
log::set_max_level(LevelFilter::Info);
}
// Set the directory for the command executables.
let input_path = &opt.in_dir;
info!("Input dir: {}", input_path.display());
// Set the directory to output documentation to.
let output_path = &opt.out_dir;
info!("Output dir: {}", output_path.display());
// Create a set of SDK tools to generate documentation for.
let allow_list: HashSet<OsString> = ALLOW_LIST.iter().cloned().map(OsString::from).collect();
// Create a vector of full paths to each command in the allow_list.
let cmd_paths: Vec<PathBuf> = get_command_paths(&input_path, &allow_list)?;
// Create the directory for doc files if it doesn't exist.
create_output_dir(&output_path)
.context(format!("Unable to create output directory {:?}", output_path))?;
// Write documentation output for each command.
for cmd_path in cmd_paths.iter() {
let cmd_name =
cmd_path.file_name().expect("Could not get file name for command").to_os_string();
let path = path_for(&cmd_name, &output_path);
// Get terminal output for cmd --help for a given command.
let lines: Vec<String> = help_output_for(&cmd_path)?;
let cmd_name = cmd_name.to_str().expect("Could not convert cmd_name to str");
write_formatted_output(&cmd_name, &lines, &path)
.with_context(|| format!("Unable to write {:?} at {:?}", cmd_name, path))?;
}
info!("Generated documentation at dir: {}", &output_path.display());
Ok(())
}
/// Format `lines` (`cmd_name`'s --help output) in markdown and write to the `path`.
fn write_formatted_output(cmd_name: &str, lines: &Vec<String>, path: &Path) -> Result<()> {
// Create a buffer writer to format and write consecutive lines to a file.
let file = File::create(&path).context(format!("create {:?}", path))?;
let mut output_writer = BufWriter::new(file);
// Write out the header.
writeln!(&mut output_writer, "# {}\n", cmd_name)?;
writeln!(&mut output_writer, "```")?;
for line in lines {
// TODO(fxb/69457): Capture all section headers in addition to "Commands" and "Options".
if line == "Commands:" || line == "Options:" {
// End preformatting before writing a section header.
writeln!(&mut output_writer, "```\n")?;
// Write the section heading.
writeln!(&mut output_writer, "## {}\n", line)?;
// Begin preformatting for next section of non-headers.
writeln!(&mut output_writer, "```")?;
} else {
// Write non-header lines unedited.
writeln!(&mut output_writer, "{}", line)?;
}
}
// Close preformatting at the end.
writeln!(&mut output_writer, "```")?;
Ok(())
}
/// Generate a vector of full paths to each command in the allow_list.
fn get_command_paths(input_path: &Path, allow_list: &HashSet<OsString>) -> Result<Vec<PathBuf>> {
// Build a set of all file names in the input_path dir.
let mut files = HashSet::new();
if let Ok(paths) = fs::read_dir(&input_path) {
for path in paths {
if let Ok(path) = path {
files.insert(path.file_name());
}
}
}
// Get the intersection of all files and commands in the allow_list.
let commands: HashSet<_> = files.intersection(&allow_list).collect();
info!("Including tools: {:?}", commands);
// Build full paths to allowed commands found in the input_path dir.
let mut cmd_paths = Vec::new();
for c in commands.iter() {
let path = Path::new(&input_path).join(c);
cmd_paths.push(path);
}
Ok(cmd_paths)
}
/// Create the output dir if doesn't exist, recursively creating subdirs in path.
fn create_output_dir(path: &Path) -> Result<()> {
if !path.exists() {
fs::create_dir_all(path)
.with_context(|| format!("Unable to create output directory {}", path.display()))?;
info!("Created directory {}", path.display());
}
Ok(())
}
/// Get cmd --help output when given a full path to a cmd.
fn help_output_for(tool: &Path) -> Result<Vec<String>> {
let output = Command::new(&tool)
.arg("--help")
.output()
.context(format!("Command failed for {:?}", &tool.display()))?;
let output = output.stdout;
let string_output = String::from_utf8(output).expect("Help string from utf8");
let lines = string_output.lines().map(String::from).collect::<Vec<_>>();
Ok(lines)
}
/// Given a cmd name and a dir, create a full path ending in cmd.md.
fn path_for(file_stem: &OsStr, dir: &PathBuf) -> PathBuf {
let mut path = Path::new(dir).join(file_stem);
path.set_extension("md");
path
}
#[cfg(test)]
mod tests {
use {
super::*,
std::{fs::File, io::Write, path::PathBuf},
};
#[test]
fn write_formatted_output_test() -> Result<()> {
// Format and write a command line tool's help output to a file.
let cmd_name = "host-tool-cmd";
let lines = vec![
"Usage: host-tool-cmd".to_string(),
"Tool description".to_string(),
"Options:".to_string(),
"--help Display usage information".to_string(),
"Commands:".to_string(),
"debug Start debug session:".to_string(),
];
let cmd_output_path = PathBuf::from(r"/tmp/host-tool-cmd.md");
write_formatted_output(&cmd_name, &lines, &cmd_output_path)?;
// Write a hard-coded formatted file.
let formatted_contents = vec![
"# host-tool-cmd\n".to_string(),
"```".to_string(),
"Usage: host-tool-cmd".to_string(),
"Tool description".to_string(),
"```\n".to_string(),
"## Options:\n".to_string(),
"```".to_string(),
"--help Display usage information".to_string(),
"```\n".to_string(),
"## Commands:\n".to_string(),
"```".to_string(),
"debug Start debug session:".to_string(),
"```\n".to_string(),
];
let formatted_file_path = PathBuf::from("/tmp/host-tool-cmd-2.md");
let mut formatted_file =
File::create(&formatted_file_path).expect("Unable to create temp file");
formatted_file
.write(formatted_contents.join("\n").as_bytes())
.expect("Unable to write to temporary file");
// Read the cmd file into a string.
let mut cmd_file = File::open(cmd_output_path).unwrap();
let mut cmd_buffer = String::new();
cmd_file.read_to_string(&mut cmd_buffer).unwrap();
// Read the formatted file into a string.
let mut formatted_file = File::open(formatted_file_path).unwrap();
let mut formatted_buffer = String::new();
formatted_file.read_to_string(&mut formatted_buffer).unwrap();
// Assert write_formatted_output formatted the documentation correctly.
assert_eq!(cmd_buffer, formatted_buffer);
Ok(())
}
}