use std::{error::Error, fs, path::PathBuf};
use quote::ToTokens as _;
use syn::{Attribute, ItemImpl, Type, TypePath, parse_quote, punctuated::Punctuated};
use super::*;
fn source_model(source: &str) -> Result<SourceModel, Box<dyn Error>> {
let syntax = syn::parse_file(source)?;
let mut model = SourceModel::new();
model.read_items(&syntax.items, Vec::new(), Path::new("src"))?;
Ok(model)
}
fn read_source(source: &str) -> Result<(), ArchitectureError> {
let syntax = match syn::parse_file(source) {
Ok(syntax) => syntax,
Err(error) => return Err(ArchitectureError::new(error.to_string())),
};
SourceModel::new().read_items(&syntax.items, Vec::new(), Path::new("src"))
}
fn error_message<T>(result: Result<T, ArchitectureError>) -> String {
match result {
Ok(_) => "unexpected success".to_owned(),
Err(error) => error.to_string(),
}
}
fn temporary_root(name: &str) -> Result<PathBuf, Box<dyn Error>> {
let root = std::env::temp_dir().join(format!(
"pipecircuit-visualization-{name}-{}",
std::process::id()
));
if root.exists() {
fs::remove_dir_all(&root)?;
}
fs::create_dir_all(root.join("src"))?;
Ok(root)
}
#[test]
fn resolves_rust_paths_imports_reexports_and_globs() -> Result<(), Box<dyn Error>> {
let model = source_model(
r#"
pub struct Root;
pub mod east {
pub struct East;
pub struct Shared;
pub mod nested {
use crate::Root;
use super::East as Renamed;
use super::*;
pub struct Local;
pub type Alias = Local;
pub union Storage { value: u64 }
}
}
pub mod west {
pub struct Shared;
}
pub mod exports {
pub use crate::east::East;
}
pub mod consumer {
use crate::exports::East;
use crate::east::*;
pub struct Consumer;
}
"#,
)?;
let nested = vec!["east".to_owned(), "nested".to_owned()];
assert_eq!(
model.resolve_path(&nested, &["crate".to_owned(), "Root".to_owned()])?,
vec!["Root"]
);
assert_eq!(
model.resolve_path(&nested, &["self".to_owned(), "Local".to_owned()])?,
vec!["east", "nested", "Local"]
);
assert_eq!(
model.resolve_path(&nested, &["super".to_owned(), "East".to_owned()])?,
vec!["east", "East"]
);
assert_eq!(
model.resolve_path(&nested, &["Renamed".to_owned()])?,
vec!["east", "East"]
);
assert_eq!(
model.resolve_path(&nested, &["Local".to_owned()])?,
vec!["east", "nested", "Local"]
);
assert_eq!(
model.resolve_path(&["consumer".to_owned()], &["East".to_owned()])?,
vec!["east", "East"]
);
assert_eq!(
model.resolve_path(&["consumer".to_owned()], &["Shared".to_owned()])?,
vec!["east", "Shared"]
);
assert_eq!(
model.resolve_path(&nested, &["Unknown".to_owned()])?,
vec!["east", "nested", "Unknown"]
);
assert_eq!(
model.resolve_import_target(&nested, &["Local".to_owned()])?,
vec!["east", "nested", "Local"]
);
assert_eq!(
model.resolve_import_target(&nested, &["Unknown".to_owned()])?,
vec!["Unknown"]
);
assert_eq!(
model.resolve_import_target(&[], &["crate".to_owned()])?,
Vec::<String>::new()
);
assert_eq!(
model.resolve_import_target(&[], &["missing".to_owned(), "Thing".to_owned()])?,
vec!["missing", "Thing"]
);
assert_eq!(
error_message(model.resolve_path(&[], &[])),
"an empty Rust path was encountered"
);
assert_eq!(
error_message(model.resolve_path(&[], &["super".to_owned(), "Root".to_owned()])),
"path `super::Root` escaped the crate root"
);
assert_eq!(
error_message(model.resolve_path(&["missing".to_owned()], &["Thing".to_owned()])),
"module `crate::missing` was absent from the source model"
);
assert_eq!(
error_message(model.resolve_import_target(&[], &[])),
"an empty import path was encountered"
);
Ok(())
}
#[test]
fn rejects_ambiguous_glob_imports() -> Result<(), Box<dyn Error>> {
let model = source_model(
r#"
mod east { pub struct Shared; }
mod west { pub struct Shared; }
mod consumer {
use crate::east::*;
use crate::west::*;
}
"#,
)?;
assert_eq!(
error_message(model.resolve_path(&["consumer".to_owned()], &["Shared".to_owned()])),
"path `Shared` was ambiguous through glob imports in `crate::consumer`"
);
Ok(())
}
#[test]
fn derives_plain_option_and_non_path_outcomes() -> Result<(), Box<dyn Error>> {
let model = source_model(
r#"
enum Outcome { Complete, Waiting }
struct Value;
"#,
)?;
let plain: Type = syn::parse_str("Outcome")?;
let option: Type = syn::parse_str("Option<Outcome>")?;
let tuple: Type = syn::parse_str("(Value, Value)")?;
assert_eq!(model.outcome_node(&[], &plain)?.label, "Outcome");
assert_eq!(model.outcome_node(&[], &option)?.children.len(), 2);
assert_eq!(
model.outcome_node(&[], &tuple)?.label,
tuple.to_token_stream().to_string()
);
assert_eq!(
error_message(model.standard_outcome_node(&[], "Option", &PathArguments::None)),
"outcome `Option` had no type arguments"
);
let wrong: Type = syn::parse_str("Option<Outcome, Value>")?;
let Type::Path(wrong) = wrong else {
return Ok(());
};
if let Some(segment) = wrong.path.segments.first() {
assert_eq!(
error_message(model.standard_outcome_node(&[], "Option", &segment.arguments)),
"outcome `Option` had 2 type arguments instead of 1"
);
} else {
return Err(std::io::Error::other("parsed type path had no segment").into());
}
let lifetime: Type = syn::parse_str("Option<'static>")?;
let Type::Path(lifetime) = lifetime else {
return Err(std::io::Error::other("Option was not parsed as a path").into());
};
if let Some(segment) = lifetime.path.segments.first() {
assert_eq!(
error_message(model.standard_outcome_node(&[], "Option", &segment.arguments)),
"outcome `Option` had 0 type arguments instead of 1"
);
} else {
return Err(std::io::Error::other("parsed Option path had no segment").into());
}
let empty_path = Type::Path(TypePath {
qself: None,
path: syn::Path {
leading_colon: None,
segments: Punctuated::new(),
},
});
assert_eq!(
error_message(model.outcome_node(&[], &empty_path)),
"an outcome path had no segments"
);
Ok(())
}
#[test]
fn preserves_source_and_type_resolution_failures() -> Result<(), Box<dyn Error>> {
let root = temporary_root("source-errors")?;
fs::write(root.join("src/lib.rs"), "this is not Rust {")?;
assert!(error_message(read_crate(&root)).contains("could not parse"));
let missing = root.join("missing");
assert!(error_message(read_crate(&missing)).contains("could not resolve"));
fs::remove_file(root.join("src/lib.rs"))?;
fs::create_dir(root.join("src/lib.rs"))?;
assert!(error_message(read_crate(&root)).contains("could not read"));
fs::remove_dir_all(&root)?;
let model = source_model("struct Known;")?;
let tuple: Type = syn::parse_str("(Known, Known)")?;
assert_eq!(
error_message(model.resolve_type(&[], &tuple)),
"type `(Known , Known)` was not a named path"
);
let unknown: Type = syn::parse_str("Unknown")?;
assert_eq!(
error_message(model.resolve_type(&[], &unknown)),
"type `Unknown` resolved to `crate::Unknown`, which was not declared in this crate"
);
let known: Type = syn::parse_str("Known")?;
assert_eq!(
error_message(model.resolve_type(&["missing".to_owned()], &known)),
"module `crate::missing` was absent from the source model"
);
assert_eq!(
error_message(model.outcome_node(&[], &unknown)),
"type `Unknown` resolved to `crate::Unknown`, which was not declared in this crate"
);
let nested_unknown: Type = syn::parse_str("Result<Unknown, Known>")?;
assert_eq!(
error_message(model.outcome_node(&[], &nested_unknown)),
"type `Unknown` resolved to `crate::Unknown`, which was not declared in this crate"
);
Ok(())
}
#[test]
fn preserves_import_resolution_failures() -> Result<(), Box<dyn Error>> {
let model = source_model(
r#"
use super::*;
struct Known;
"#,
)?;
assert_eq!(
error_message(model.resolve_path(&[], &["Unknown".to_owned()])),
"path `super` escaped the crate root"
);
assert_eq!(
error_message(
model.resolve_import_target(&[], &["super".to_owned(), "Unknown".to_owned()])
),
"path `super::Unknown` escaped the crate root"
);
Ok(())
}
#[test]
fn preserves_module_layout_failures_and_visited_files() -> Result<(), Box<dyn Error>> {
let root = temporary_root("module-layout")?;
fs::write(root.join("src/lib.rs"), "mod missing;")?;
assert!(error_message(read_crate(&root)).contains("had no source"));
fs::create_dir_all(root.join("src/duplicate"))?;
fs::write(root.join("src/lib.rs"), "mod duplicate;")?;
fs::write(root.join("src/duplicate.rs"), "pub struct Direct;")?;
fs::write(root.join("src/duplicate/mod.rs"), "pub struct Nested;")?;
assert!(error_message(read_crate(&root)).contains("had two possible source files"));
fs::write(root.join("src/lib.rs"), "pub struct Root;")?;
let mut model = SourceModel::new();
model.read_module_file(&root.join("src/lib.rs"), Vec::new(), &root.join("src"))?;
model.read_module_file(&root.join("src/lib.rs"), Vec::new(), &root.join("src"))?;
assert_eq!(model.declarations.len(), 1);
fs::remove_dir_all(&root)?;
Ok(())
}
#[test]
fn rejects_duplicate_declarations_and_steps() -> Result<(), Box<dyn Error>> {
let mut model = SourceModel::new();
model.insert_declaration(&[], "Duplicate".to_owned(), DeclarationKind::Other)?;
assert_eq!(
error_message(model.insert_declaration(
&[],
"Duplicate".to_owned(),
DeclarationKind::Other
)),
"type `crate::Duplicate` was declared more than once"
);
for source in [
"enum Duplicate {} enum Duplicate {}",
"struct Duplicate; struct Duplicate;",
"union Duplicate { a: u8 } union Duplicate { b: u8 }",
"type Duplicate = u8; type Duplicate = u16;",
] {
assert!(error_message(read_source(source)).contains("was declared more than once"));
}
let root = temporary_root("duplicate-steps")?;
fs::write(
root.join("src/lib.rs"),
r#"
trait StateMachine {}
trait StepInputOf<T> { type Outcome; }
struct Machine;
struct Input;
enum Outcome { Done }
impl StateMachine for Machine {}
impl StepInputOf<Machine> for Input { type Outcome = Outcome; }
impl StepInputOf<Machine> for Input { type Outcome = Outcome; }
"#,
)?;
assert!(error_message(read_crate(&root)).contains("had more than one step"));
fs::remove_dir_all(&root)?;
Ok(())
}
#[test]
fn rejects_incomplete_state_machine_contracts() -> Result<(), Box<dyn Error>> {
let root = temporary_root("incomplete-contracts")?;
fs::write(
root.join("src/lib.rs"),
r#"
trait StepInputOf<T> { type Outcome; }
struct Machine;
struct Input;
enum Outcome { Done }
impl StepInputOf<Machine> for Input { type Outcome = Outcome; }
"#,
)?;
assert!(error_message(read_crate(&root)).contains("no StateMachine implementation"));
fs::write(
root.join("src/lib.rs"),
r#"
trait StateMachine {}
trait StepInputOf<T> { type Outcome; }
struct Machine;
struct Input;
impl StateMachine for Machine {}
impl StepInputOf<Machine> for Input {}
"#,
)?;
assert_eq!(
error_message(read_crate(&root)),
"a StepInputOf implementation did not declare its Outcome"
);
fs::remove_dir_all(&root)?;
Ok(())
}
#[test]
fn rejects_every_incomplete_derived_contract() -> Result<(), Box<dyn Error>> {
let root = temporary_root("derived-contract-errors")?;
let cases = [
(
r#"
trait StateMachine {}
impl StateMachine for Missing {}
"#,
"type `Missing` resolved",
),
(
r#"
trait StateMachine {}
trait StepInputOf { type Outcome; }
struct Machine;
struct Input;
enum Outcome { Done }
impl StateMachine for Machine {}
impl StepInputOf for Input { type Outcome = Outcome; }
"#,
"did not name its state machine",
),
(
r#"
trait StateMachine {}
trait StepInputOf<T> { type Outcome; }
struct Input;
enum Outcome { Done }
impl StepInputOf<Missing> for Input { type Outcome = Outcome; }
"#,
"type `Missing` resolved",
),
(
r#"
trait StateMachine {}
trait StepInputOf<T> { type Outcome; }
struct Machine;
enum Outcome { Done }
impl StateMachine for Machine {}
impl StepInputOf<Machine> for Missing { type Outcome = Outcome; }
"#,
"type `Missing` resolved",
),
(
r#"
trait StateMachine {}
trait StepInputOf<T> { type Outcome; }
struct Machine;
struct Input;
impl StateMachine for Machine {}
impl StepInputOf<Machine> for Input { type Outcome = Missing; }
"#,
"type `Missing` resolved",
),
];
for (source, expected) in cases {
fs::write(root.join("src/lib.rs"), source)?;
assert!(error_message(read_crate(&root)).contains(expected));
}
fs::remove_dir_all(&root)?;
Ok(())
}
#[test]
fn parses_step_contract_components_exactly() {
let inherent: ItemImpl = parse_quote!(impl Machine {});
assert_eq!(trait_name(&inherent), None);
assert_eq!(
error_message(step_machine_type(&inherent)),
"a StepInputOf implementation had no trait path"
);
let no_machine: ItemImpl = parse_quote!(impl StepInputOf for Input {});
assert_eq!(
error_message(step_machine_type(&no_machine)),
"a StepInputOf implementation did not name its state machine"
);
let lifetime_only: ItemImpl = parse_quote!(impl StepInputOf<'static> for Input {});
assert_eq!(
error_message(step_machine_type(&lifetime_only)),
"a StepInputOf implementation did not contain a state-machine type"
);
let mut empty_trait_path: ItemImpl = parse_quote!(impl StepInputOf<Machine> for Input {});
if let Some((_, path, _)) = &mut empty_trait_path.trait_ {
path.segments.clear();
}
assert_eq!(
error_message(step_machine_type(&empty_trait_path)),
"a StepInputOf implementation had an empty trait path"
);
let outcome_after_const: ItemImpl = parse_quote! {
impl StepInputOf<Machine> for Input {
const MARKER: u8 = 1;
type Outcome = Result;
}
};
assert_eq!(
step_outcome_type(&outcome_after_const)
.map(|outcome| outcome.to_token_stream())
.map(|tokens| tokens.to_string()),
Ok("Result".to_owned())
);
}
#[test]
fn ignores_test_only_items_and_modules() -> Result<(), Box<dyn Error>> {
let model = source_model(
r#"
#[cfg(test)] use crate::Missing;
#[cfg(kani)] struct KaniOnly;
#[cfg(test)] enum TestOnly { Variant }
#[cfg(test)] union TestStorage { value: u64 }
#[cfg(test)] type TestAlias = u64;
#[cfg(test)] impl StateMachine for Missing {}
#[cfg(test)] mod tests { struct Hidden; }
struct Visible;
"#,
)?;
assert_eq!(model.declarations.len(), 1);
assert!(model.implementations.is_empty());
assert!(model.declarations.contains_key(&vec!["Visible".to_owned()]));
Ok(())
}
#[test]
fn qualifies_the_crate_root() {
assert_eq!(qualified(&[]), "crate");
}
#[test]
fn distinguishes_cfg_attribute_forms() {
let ordinary: Attribute = syn::parse_quote!(#[derive(Clone)]);
let name_value: Attribute = syn::parse_quote!(#[cfg = "test"]);
let test: Attribute = syn::parse_quote!(#[cfg(test)]);
assert!(!test_only(&[ordinary]));
assert!(!test_only(&[name_value]));
assert!(test_only(&[test]));
}
#[test]
fn plain_declarations_render_without_children() -> Result<(), Box<dyn Error>> {
let model = source_model("struct Value;")?;
let value = model.resolve_type(&[], &syn::parse_str("Value")?)?;
assert_eq!(declaration_node(value).children, Vec::new());
Ok(())
}