#![deny(missing_docs)]
#![warn(clippy::all, clippy::nursery, clippy::pedantic, clippy::cargo)]
mod asciicast;
mod error;
mod instruction;
mod shell;
mod util;
pub use asciicast::AsciiCast;
pub use error::{Error, ErrorType};
use instruction::{Instruction, InstructionTrait};
use optfield::optfield;
use shell::execute_command;
use std::{
borrow::Cow,
io::{BufRead, Write},
path::PathBuf,
};
pub const VERSION: &str = env!("CARGO_PKG_VERSION", "can't determine version");
#[derive(Debug, PartialEq, Clone, Copy)]
enum FrontMatterState {
None,
Start,
End,
}
impl FrontMatterState {
fn next(&mut self) -> Result<(), ErrorType> {
match self {
Self::None => *self = Self::Start,
Self::Start => *self = Self::End,
Self::End => return Err(ErrorType::FrontMatterExists),
}
Ok(())
}
fn end(&mut self) -> Result<(), ErrorType> {
match self {
Self::None => *self = Self::End,
Self::Start => return Err(ErrorType::ExpectedKeyValuePair),
Self::End => {} }
Ok(())
}
}
#[optfield(TemporaryConfiguration,
rewrap,
field_doc,
doc = "Temporary configuration for the script.",
attrs = add(derive(Default)),
merge_fn = merge, // Merge function, only used in `Configuration::combine`
)]
#[derive(Clone, Debug, PartialEq)]
struct Configuration {
prompt: String,
secondary_prompt: String,
line_continuation: String,
hidden: bool,
expect: Option<bool>,
interval: u128,
start_lag: u128,
end_lag: u128,
}
impl Configuration {
fn new() -> Self {
Self::default()
}
fn combine(&self, temporary: TemporaryConfiguration) -> Cow<Self> {
if temporary.is_empty() {
Cow::Borrowed(self)
} else {
let mut config = self.clone();
config.merge(temporary);
Cow::Owned(config)
}
}
}
impl Default for Configuration {
fn default() -> Self {
Self {
prompt: "$ ".to_string(),
secondary_prompt: "> ".to_string(),
line_continuation: " \\".to_string(),
hidden: false,
expect: Some(true),
interval: 100_000,
start_lag: 0,
end_lag: 0,
}
}
}
impl TemporaryConfiguration {
fn new() -> Self {
Self::default()
}
const fn is_empty(&self) -> bool {
self.prompt.is_none()
&& self.secondary_prompt.is_none()
&& self.line_continuation.is_none()
&& self.hidden.is_none()
&& self.expect.is_none()
&& self.interval.is_none()
&& self.start_lag.is_none()
&& self.end_lag.is_none()
}
fn get(&mut self, consume: bool) -> Self {
if consume {
std::mem::take(self)
} else {
self.clone()
}
}
}
struct ParseContext {
front_matter_state: FrontMatterState,
start: char,
expect_continuation: bool,
}
impl ParseContext {
const fn new() -> Self {
Self {
front_matter_state: FrontMatterState::None,
start: ' ',
expect_continuation: false,
}
}
#[cfg(test)]
const fn with_start(&self, start: char) -> Self {
Self { start, ..*self }
}
}
struct ExecutionContext {
persistent: Configuration,
temporary: TemporaryConfiguration,
shell: Vec<String>,
directory: PathBuf,
elapsed: u128,
execute: bool,
preview: bool,
command: String,
}
impl ExecutionContext {
fn new() -> Self {
Self {
persistent: Configuration::new(),
temporary: TemporaryConfiguration::new(),
shell: vec!["bash".to_string(), "-c".to_string()],
directory: PathBuf::from(".")
.canonicalize()
.expect("Failed to canonicalize current directory"),
elapsed: 0,
execute: false,
preview: false,
command: String::new(),
}
}
fn preview(&self, s: &str) {
if self.preview {
print!("{s}");
}
}
}
#[derive(Debug, Default)]
pub struct CastWright {
execute: bool,
timestamp: bool,
preview: bool,
}
impl CastWright {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn execute(self, execute: bool) -> Self {
Self { execute, ..self }
}
#[must_use]
pub const fn timestamp(self, timestamp: bool) -> Self {
Self { timestamp, ..self }
}
#[must_use]
pub const fn preview(self, preview: bool) -> Self {
Self { preview, ..self }
}
pub fn run(&self, reader: &mut impl BufRead, writer: &mut impl Write) -> Result<(), Error> {
let mut parse_context = ParseContext::new();
let mut execution_context = ExecutionContext::new();
let mut cast = AsciiCast::new(writer);
let mut line_cnt = 0;
execution_context.execute = self.execute;
execution_context.preview = self.preview;
if self.timestamp {
let timestamp = util::timestamp().map_err(|e| e.with_line(0))?;
cast.timestamp(timestamp).map_err(|e| e.with_line(0))?;
}
for (line_number, line) in reader.lines().enumerate() {
Self::run_line(line, &mut parse_context, &mut execution_context, &mut cast)
.map_err(|e| e.with_line(line_number + 1))?;
line_cnt += 1;
}
cast.finish().map_err(|e| e.with_line(line_cnt))?;
if parse_context.front_matter_state == FrontMatterState::Start {
Err(ErrorType::ExpectedClosingDelimiter.with_line(line_cnt + 1))
} else if parse_context.expect_continuation {
Err(ErrorType::ExpectedContinuation.with_line(line_cnt + 1))
} else {
Ok(())
}
}
fn run_line(
line: Result<String, std::io::Error>,
parse_context: &mut ParseContext,
execution_context: &mut ExecutionContext,
cast: &mut AsciiCast<impl std::io::Write>,
) -> Result<(), ErrorType> {
let instruction = Instruction::parse(&line?, parse_context)?;
instruction.execute(execution_context, cast)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::BufReader;
#[test]
fn version_correct() {
let content = std::fs::read_to_string("Cargo.toml").unwrap();
assert!(content.contains(&format!("version = \"{VERSION}\"")));
}
#[test]
fn expected_key_value_pair() {
let text = r#"
---
$echo "Hello, World!"
---
"#;
let text = text.trim();
let mut reader = BufReader::new(text.as_bytes());
assert_eq!(
CastWright::new()
.run(&mut reader, &mut std::io::sink())
.unwrap_err(),
ErrorType::ExpectedKeyValuePair.with_line(2)
);
}
#[test]
fn expected_closing_delimiter() {
let text = r"
---
width: 123
";
let text = text.trim();
let mut reader = BufReader::new(text.as_bytes());
assert_eq!(
CastWright::new()
.run(&mut reader, &mut std::io::sink())
.unwrap_err(),
ErrorType::ExpectedClosingDelimiter.with_line(3)
);
}
#[test]
fn front_matter_exists() {
let text = r"
---
width: 123
---
---
";
let text = text.trim();
let mut reader = BufReader::new(text.as_bytes());
assert_eq!(
CastWright::new()
.run(&mut reader, &mut std::io::sink())
.unwrap_err(),
ErrorType::FrontMatterExists.with_line(4)
);
}
#[test]
fn script_unknown_instruction() {
let text = r#"
---
width: 123
---
@hidden
%print
!marker
#comment
$echo "Hello, World!" \
>continuation
unknown
"#;
let text = text.trim();
let mut reader = BufReader::new(text.as_bytes());
assert_eq!(
CastWright::new()
.run(&mut reader, &mut std::io::sink())
.unwrap_err(),
ErrorType::UnknownInstruction.with_line(10)
);
}
#[test]
fn script_expected_continuation_1() {
let text = r#"
$echo "Hello, World!" \
@hidden true
"#;
let text = text.trim();
let mut reader = BufReader::new(text.as_bytes());
assert_eq!(
CastWright::new()
.run(&mut reader, &mut std::io::sink())
.unwrap_err(),
ErrorType::ExpectedContinuation.with_line(2)
);
}
#[test]
fn script_expected_continuation_2() {
let text = r#"
$echo "Hello, World!" \
"#;
let text = text.trim();
let mut reader = BufReader::new(text.as_bytes());
assert_eq!(
CastWright::new()
.run(&mut reader, &mut std::io::sink())
.unwrap_err(),
ErrorType::ExpectedContinuation.with_line(2)
);
}
#[test]
fn script_unexpected_continuation() {
let text = r#"
$echo "Hello, World!"
>continuation
"#;
let text = text.trim();
let mut reader = BufReader::new(text.as_bytes());
assert_eq!(
CastWright::new()
.run(&mut reader, &mut std::io::sink())
.unwrap_err(),
ErrorType::UnexpectedContinuation.with_line(2)
);
}
#[test]
fn execution_context_consume_temporary() {
let mut context = ExecutionContext::new();
context.temporary.prompt = Some("$$ ".to_string());
context.temporary.secondary_prompt = Some(">> ".to_string());
context.temporary.expect = Some(None);
let expected_config = Configuration {
prompt: "$$ ".to_string(),
secondary_prompt: ">> ".to_string(),
line_continuation: " \\".to_string(),
hidden: false,
expect: None,
interval: 100_000,
start_lag: 0,
end_lag: 0,
};
let calculated_config = context
.persistent
.combine(context.temporary.get(true))
.into_owned();
assert_eq!(calculated_config, expected_config);
assert!(context.temporary.is_empty());
}
}