This commit is contained in:
Steve Biedermann 2024-05-12 23:27:33 +02:00 committed by Biedermann Steve
parent 26ea87b295
commit eb246b7b29
8 changed files with 232 additions and 208 deletions

42
Cargo.lock generated
View File

@ -145,6 +145,47 @@ dependencies = [
"strsim",
]
[[package]]
name = "clap_complete"
version = "4.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd79504325bf38b10165b02e89b4347300f855f273c4cb30c4a3209e6583275e"
dependencies = [
"clap",
]
[[package]]
name = "clap_complete_command"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183495371ea78d4c9ff638bfc6497d46fed2396e4f9c50aebc1278a4a9919a3d"
dependencies = [
"clap",
"clap_complete",
"clap_complete_fig",
"clap_complete_nushell",
]
[[package]]
name = "clap_complete_fig"
version = "4.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b3e65f91fabdd23cac3d57d39d5d938b4daabd070c335c006dccb866a61110"
dependencies = [
"clap",
"clap_complete",
]
[[package]]
name = "clap_complete_nushell"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d02bc8b1a18ee47c4d2eec3fb5ac034dc68ebea6125b1509e9ccdffcddce66e"
dependencies = [
"clap",
"clap_complete",
]
[[package]]
name = "clap_derive"
version = "4.5.4"
@ -476,6 +517,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"clap_complete_command",
"crossterm 0.27.0",
"indexmap",
"markdown",

View File

@ -1,18 +1,21 @@
[package]
name = "req"
version = "0.1.0"
version = "1.0.0"
edition = "2021"
default-run = "req"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[profile.release]
strip = "symbols"
lto = "fat"
split-debuginfo = "true"
codegen-units = 1
[dependencies]
anyhow = "1.0.83"
clap = { version = "4.5.4", features = ["derive"] }
crossterm = "0.27.0"
clap_complete_command = "0.5.1"
indexmap = { version = "2.2.6", features = ["serde"] }
markdown = "1.0.0-alpha.17"
ratatui = "0.26.2"
regex = "1.10.4"
rsn = "0.1.0"
schemars = { version = "0.8.19", features = ["indexmap2"] }
@ -21,5 +24,3 @@ serde_json = { version = "1.0.117", features = ["indexmap", "preserve_order"] }
serde_yaml = "0.9.34"
stringlit = "2.1.0"
toml = { version = "0.8.12", features = ["indexmap", "preserve_order"] }
tui = "0.19.0"
version_operators = "0.0.1"

View File

@ -7,6 +7,6 @@ mkdir -p out
cargo build
cargo run -q -- schema > out/schema.json
cargo run -q -- demo > out/demo.yml
cargo run -q -- md req.yml > out/requirements.md
cargo run -q -- html req.yml > out/requirements.html
cargo run -q -- check req.yml test_result.txt > out/text_result.md
cargo run -q -- md requirements.yml > out/requirements.md
cargo run -q -- html requirements.yml > out/requirements.html
cargo run -q -- check requirements.yml test_result.txt > out/test_result.md

View File

@ -1,177 +0,0 @@
use crossterm::{
event::{self, Event as CEvent, KeyCode},
execute,
style::Color,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
prelude::Style,
widgets::{Block, Borders, List, ListItem, ListState},
Terminal,
};
use std::io;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use req::*;
enum Event<I> {
Input(I),
Tick,
}
struct App {
project: Project,
topics_list_state: ListState,
requirements_list_state: ListState,
}
impl App {
fn new(project: Project) -> App {
let mut topics_list_state = ListState::default();
topics_list_state.select(Some(0));
let mut requirements_list_state = ListState::default();
requirements_list_state.select(Some(0));
App {
project,
topics_list_state,
requirements_list_state,
}
}
fn draw(&mut self, f: &mut ratatui::Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints(
[
Constraint::Percentage(10), // Project Title
Constraint::Percentage(45), // Topics List
Constraint::Percentage(45), // Requirements List
]
.as_ref(),
)
.split(f.size());
let project_title = Block::default()
.title(self.project.name.clone())
.borders(Borders::ALL);
f.render_widget(project_title, chunks[0]);
let topics: Vec<ListItem> = self
.project
.topics
.iter()
.map(|(name, _)| ListItem::new(name.clone()))
.collect();
let topics_list = List::new(topics)
.block(Block::default().borders(Borders::ALL).title("Topics"))
.highlight_style(Style::default().bg(Color::Blue.into()));
f.render_stateful_widget(topics_list, chunks[1], &mut self.topics_list_state);
let requirements: Vec<ListItem> = self
.project
.topics
.first()
.unwrap()
.1
.requirements
.iter()
.map(|(name, _)| ListItem::new(name.clone()))
.collect();
let requirements_list = List::new(requirements)
.block(Block::default().borders(Borders::ALL).title("Requirements"))
.highlight_style(Style::default().bg(Color::Yellow.into()));
f.render_stateful_widget(
requirements_list,
chunks[2],
&mut self.requirements_list_state,
);
}
fn next_topic(&mut self) {
let n = self.project.topics.len();
if let Some(i) = self.topics_list_state.selected() {
if i >= n - 1 {
self.topics_list_state.select(Some(0));
} else {
self.topics_list_state.select(Some(i + 1));
}
}
}
fn previous_topic(&mut self) {
let n = self.project.topics.len();
if let Some(i) = self.topics_list_state.selected() {
if i == 0 {
self.topics_list_state.select(Some(n - 1));
} else {
self.topics_list_state.select(Some(i - 1));
}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let project = demo_project();
let mut app = App::new(project);
let (tx, rx) = mpsc::channel();
let tick_rate = Duration::from_millis(200);
thread::spawn(move || {
let mut last_tick = Instant::now();
loop {
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if event::poll(timeout).expect("poll works") {
if let CEvent::Key(key) = event::read().expect("read works") {
tx.send(Event::Input(key)).expect("send works");
}
}
if last_tick.elapsed() >= tick_rate {
tx.send(Event::Tick).expect("tick works");
last_tick = Instant::now();
}
}
});
loop {
terminal.draw(|f| app.draw(f))?;
match rx.recv()? {
Event::Input(event) => match event.code {
KeyCode::Char('q') => {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
break;
}
KeyCode::Down => {
app.next_topic();
}
KeyCode::Up => {
app.previous_topic();
}
_ => {}
},
Event::Tick => {}
}
}
Ok(())
}

View File

@ -125,6 +125,7 @@ pub struct Project {
serialize_with = "serialize_version",
deserialize_with = "deserialize_version"
)]
#[schemars(with = "String", regex(pattern = r"^\d\.\d\.\d$"))]
pub version: Version,
#[serde(serialize_with = "my_trim")]
pub description: String,
@ -138,5 +139,5 @@ pub struct Project {
#[must_use]
pub fn demo_project() -> Project {
serde_yaml::from_str(include_str!("../req.yml")).expect("Should never happen!")
serde_yaml::from_str(include_str!("../requirements.yml")).expect("Should never happen!")
}

View File

@ -1,6 +1,6 @@
use std::path::PathBuf;
use clap::Parser;
use clap::{CommandFactory, Parser, Subcommand};
use indexmap::{
map::{Keys, Values},
IndexMap,
@ -35,16 +35,28 @@ fn nl() -> String {
fn check_requirements(
test_results: &str,
output: &mut IndexMap<String, bool>,
output: &mut IndexMap<String, (bool, Vec<String>)>,
requirements: &IndexMap<String, Requirement>,
allowed_requirements: &Regex,
allowed_requirements: &[Regex],
) {
for (id, _) in dbg!(requirements) {
if allowed_requirements.is_match(id) {
if test_results.contains(&format!("{}: failed", id.trim())) {
output.insert(id.trim().to_string(), false);
for (id, _) in requirements {
if allowed_requirements.iter().any(|r| r.is_match(id)) {
let search_string = format!("{}: failed", id.trim());
if test_results.contains(&search_string) {
let errors = test_results.lines().filter_map(|l| {
if l.starts_with(&search_string) {
l.split_once(":")
.map(|(_, txt)| txt)
.and_then(|txt| txt.split_once("-").map(|(_, err)| err.to_string()))
} else {
None
}
});
output.insert(id.trim().to_string(), (false, errors.collect()));
} else if test_results.contains(&format!("{}: passed", id.trim())) {
output.entry(id.trim().to_string()).or_insert(true);
output
.entry(id.trim().to_string())
.or_insert((true, Vec::new()));
};
}
}
@ -52,12 +64,12 @@ fn check_requirements(
fn has_valid_requirements(
mut requirements: Keys<String, Requirement>,
allowed_requirements: &Regex,
allowed_requirements: &[Regex],
) -> bool {
requirements.any(|id| allowed_requirements.is_match(id))
requirements.any(|id| allowed_requirements.iter().any(|r| r.is_match(id)))
}
fn has_valid_topics(mut topics: Values<String, Topic>, allowed_requirements: &Regex) -> bool {
fn has_valid_topics(mut topics: Values<String, Topic>, allowed_requirements: &[Regex]) -> bool {
topics.any(|topic| {
has_valid_requirements(topic.requirements.keys(), allowed_requirements)
|| has_valid_topics(topic.subtopics.values(), allowed_requirements)
@ -68,7 +80,7 @@ fn check_topics(
test_results: &[PathBuf],
output: &mut Vec<String>,
topics: &IndexMap<String, Topic>,
allowed_requirements: &Regex,
allowed_requirements: &[Regex],
level: usize,
) -> anyhow::Result<()> {
if !has_valid_topics(topics.values(), allowed_requirements) {
@ -102,16 +114,19 @@ fn check_topics(
if !topic.requirements.is_empty() {
for (id, req) in &topic.requirements {
let status = if let Some(status) = test_status.get(id) {
let (status, errors) = if let Some((status, errors)) = test_status.get(id) {
if *status {
":white_check_mark:"
(":white_check_mark:", errors.to_owned())
} else {
":x:"
(":x:", errors.to_owned())
}
} else {
":warning:"
(":warning:", Vec::new())
};
output.push(format!("- _{}_ - {}: {status}", id.trim(), req.name));
for err in errors {
output.push(format!(" - {}", err.trim()));
}
}
output.push(nl());
@ -163,7 +178,7 @@ fn add_topics(output: &mut Vec<String>, topics: &IndexMap<String, Topic>, level:
}
}
#[derive(Parser)]
#[derive(Subcommand)]
enum Command {
/// Outputs the JSON schema for the input data
Schema,
@ -184,13 +199,19 @@ enum Command {
Check {
#[arg(short, long, default_value = "REQ-.*")]
/// Regex to select which requirements should be checked
allowed_requirements: String,
allowed_requirements: Vec<String>,
/// The path to the requirements file
requirements: PathBuf,
/// The path to the test output files
#[arg(required=true, num_args=1..)]
test_results: Vec<PathBuf>,
},
/// Generate shell completions
Completions {
/// The shell to generate the completions for
#[arg(value_enum)]
shell: clap_complete_command::Shell,
},
}
#[derive(Parser)]
@ -295,10 +316,14 @@ fn main() -> anyhow::Result<()> {
}
Command::Html { requirements } => {
let output = to_markdown(requirements, false)?;
let template = include_str!("../template.html");
println!(
"{}",
markdown::to_html_with_options(&output, &markdown::Options::gfm())
.map_err(|e| anyhow::anyhow!("{e}"))?
template.replace(
"{{content}}",
&markdown::to_html_with_options(&output, &markdown::Options::gfm())
.map_err(|e| anyhow::anyhow!("{e}"))?
)
);
}
Command::Schema => {
@ -314,7 +339,10 @@ fn main() -> anyhow::Result<()> {
requirements,
test_results,
} => {
let re = Regex::new(&allowed_requirements).unwrap();
let re = allowed_requirements
.into_iter()
.map(|r| Regex::new(&r).expect("Invalid regex!"));
let re: Vec<_> = re.collect();
let project: Project = parse(&std::fs::read_to_string(requirements)?)?;
let mut output = vec![format!("# Test Results - {}", project.name)];
check_topics(&test_results, &mut output, &project.topics, &re, 2)?;
@ -322,6 +350,9 @@ fn main() -> anyhow::Result<()> {
let output = output.join("\n");
println!("{output}");
}
Command::Completions { shell } => {
shell.generate(&mut Args::command(), &mut std::io::stdout());
}
}
Ok(())

126
template.html Normal file
View File

@ -0,0 +1,126 @@
<head>
<style>
/* General body styling */
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
padding: 20px;
max-width: 1000px;
margin: auto;
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
color: #0056b3;
margin-top: 20px;
}
h1 {
border-bottom: 2px solid #eee;
padding-bottom: 10px;
}
h2 {
color: #004494;
}
h3 {
color: #003073;
}
h4,
h5,
h6 {
color: #002652;
font-style: italic;
font-size: 1em;
/* Ensures that subtopic headings don't scale down too much */
font-weight: bold;
/* Adds emphasis to make subtopic headings stand out */
}
/* RFC keywords styling */
p {
font-size: 16px;
}
strong em {
color: #d63447;
/* Bright red for MUST, SHOULD, etc. */
font-style: normal;
/* Override italic style from em */
}
/* Link styling */
a {
color: #007bff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* List styling for better readability */
ul {
list-style-type: none;
padding-left: 20px;
}
ul li {
margin-bottom: 5px;
}
ul>li:not(:has(> p))::before {
content: "• ";
color: #007bff;
/* Matching the link color */
font-size: larger;
}
ul>li:not(:has(> ul))>p::before {
content: "• ";
color: #007bff;
/* Matching the link color */
font-size: larger;
}
/* Nested lists to indicate hierarchy */
ul ul {
padding-left: 20px;
}
/* Version and requirements emphasis */
strong {
font-weight: bold;
}
/* Special styling for configuration sections */
h2 {
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
}
/* Config and Definitions section styling */
ul ul li {
font-size: 14px;
color: #555;
}
/* Detailed requirement items */
li strong em {
display: inline-block;
/* Ensures consistent alignment */
}
</style>
</head>
<body>
{{content}}
</body>