use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use crate::chain_config;
use crate::devnet::{DEFAULT_FUND, UpOptions};
use crate::envfile::{check_name, check_program_name};
use crate::groups;
use crate::home::{RuntimeKind, Settings};
use crate::session::{check_session_name, modules_with};
pub const FILE_NAME: &str = "logos-devnet.toml";
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProjectFile {
home: Option<PathBuf>,
#[serde(default)]
devnet: DevnetSection,
#[serde(default)]
programs: BTreeMap<String, ProgramEntry>,
#[serde(default)]
accounts: BTreeMap<String, GroupSpec>,
#[serde(default)]
sessions: BTreeMap<String, SessionSpec>,
}
#[derive(Debug, Deserialize)]
#[serde(
untagged,
expecting = "a binary's path, or { path = \"...\", mutable = true }"
)]
enum ProgramEntry {
Path(PathBuf),
Table(ProgramTable),
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProgramTable {
path: PathBuf,
#[serde(default)]
mutable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProgramSpec {
pub binary: PathBuf,
pub mutable: bool,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DevnetSection {
pub lez: Option<String>,
pub runtime: Option<RuntimeKind>,
pub port: Option<u16>,
pub block_time: Option<String>,
pub max_txs_per_block: Option<u32>,
pub dev_mode: Option<bool>,
pub log_filter: Option<String>,
}
impl DevnetSection {
pub fn applied_to(&self, settings: Settings) -> Settings {
UpOptions {
docker: self.runtime == Some(RuntimeKind::Docker),
native: self.runtime == Some(RuntimeKind::Native),
lez: self.lez.clone(),
port: self.port,
block_time: self.block_time.clone(),
max_txs_per_block: self.max_txs_per_block,
dev_mode: self.dev_mode,
log_filter: self.log_filter.clone(),
rebuild: false,
}
.apply(settings)
}
pub fn describe(&self, settings: &Settings) -> String {
let mut keys = Vec::new();
if self.lez.is_some() {
keys.push(format!("lez = \"{}\"", settings.lez));
}
if self.runtime.is_some() {
keys.push(format!("runtime = \"{:?}\"", settings.runtime).to_lowercase());
}
if self.port.is_some() {
keys.push(format!("port = {}", settings.port));
}
if self.block_time.is_some() {
keys.push(format!("block_time = \"{}\"", settings.block_time));
}
if self.max_txs_per_block.is_some() {
keys.push(format!(
"max_txs_per_block = {}",
settings.max_txs_per_block
));
}
if self.dev_mode.is_some() {
keys.push(format!("dev_mode = {}", settings.dev_mode));
}
if self.log_filter.is_some() {
keys.push(format!("log_filter = \"{}\"", settings.log_filter));
}
keys.join(", ")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupSpec {
pub count: usize,
pub amount: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionSpec {
#[serde(default)]
pub modules: Vec<String>,
pub amount: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct Project {
pub path: PathBuf,
pub home: Option<PathBuf>,
pub devnet: DevnetSection,
pub programs: BTreeMap<String, ProgramSpec>,
pub accounts: BTreeMap<String, GroupSpec>,
pub sessions: BTreeMap<String, SessionSpec>,
}
pub fn find(start: &Path) -> Option<PathBuf> {
start
.ancestors()
.map(|dir| dir.join(FILE_NAME))
.find(|file| file.is_file())
}
impl Project {
pub fn check_binaries(&self) -> Result<()> {
for (name, ProgramSpec { binary, .. }) in &self.programs {
if !binary.is_file() {
bail!(
"[programs] {name}: {} doesn't exist. Build it first",
binary.display()
);
}
let bytes = std::fs::read(binary)
.with_context(|| format!("[programs] {name}: reading {}", binary.display()))?;
crate::binary::segments(&bytes)
.with_context(|| format!("[programs] {name}: {}", binary.display()))?;
}
Ok(())
}
pub fn load(path: &Path) -> Result<Self> {
let text =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Self::parse(path, &text).with_context(|| format!("in {}", path.display()))
}
fn parse(path: &Path, text: &str) -> Result<Self> {
let file: ProjectFile = toml::from_str(text)?;
let dir = path
.parent()
.context("a project file has a directory")?
.to_path_buf();
let devnet = file.devnet;
if let Some(block_time) = &devnet.block_time {
chain_config::block_time_secs(block_time)?;
}
if let Some(max) = devnet.max_txs_per_block {
chain_config::check_max_txs_per_block(max).context("[devnet]")?;
}
if let Some(filter) = &devnet.log_filter {
crate::home::check_log_filter(filter).context("[devnet]")?;
}
if devnet.port == Some(0) {
bail!("[devnet] port can't be 0");
}
let mut programs = BTreeMap::new();
for (name, entry) in file.programs {
check_program_name(&name)?;
let (path, mutable) = match entry {
ProgramEntry::Path(path) => (path, false),
ProgramEntry::Table(table) => (table.path, table.mutable),
};
programs.insert(
name,
ProgramSpec {
binary: dir.join(path),
mutable,
},
);
}
for (name, group) in &file.accounts {
check_name("account group", name)?;
groups::check_spec(group.count, group.amount.unwrap_or(DEFAULT_FUND))
.with_context(|| format!("[accounts.{name}]"))?;
}
for (name, session) in &file.sessions {
check_session_name(name)?;
modules_with(&session.modules).with_context(|| format!("[sessions.{name}]"))?;
if session.amount == Some(0) {
bail!("[sessions.{name}] amount can't be 0");
}
}
Ok(Self {
path: path.to_path_buf(),
home: file.home.map(|home| dir.join(home)),
devnet,
programs,
accounts: file.accounts,
sessions: file.sessions,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn project_in(dir: &Path, text: &str) -> Result<Project> {
let path = dir.join(FILE_NAME);
std::fs::write(&path, text).unwrap();
Project::load(&path)
}
#[test]
fn reads_every_section_and_resolves_paths_against_the_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("out")).unwrap();
std::fs::write(dir.path().join("out/counter.bin"), [0u8; 4]).unwrap();
let project = project_in(
dir.path(),
r#"
home = ".logos-devnet"
[devnet]
block_time = "500ms"
max_txs_per_block = 100
[programs]
counter = "out/counter.bin"
[accounts.users]
count = 100
amount = 5_000
[sessions.alice]
modules = ["chat_module"]
"#,
)
.unwrap();
assert_eq!(project.home, Some(dir.path().join(".logos-devnet")));
assert_eq!(project.devnet.block_time.as_deref(), Some("500ms"));
assert_eq!(project.devnet.max_txs_per_block, Some(100));
assert_eq!(
project.programs["counter"],
ProgramSpec {
binary: dir.path().join("out/counter.bin"),
mutable: false
}
);
assert_eq!(
project.accounts["users"],
GroupSpec {
count: 100,
amount: Some(5_000)
}
);
assert_eq!(project.sessions["alice"].modules, ["chat_module"]);
}
#[test]
fn the_devnet_section_sets_only_its_keys() {
let section = DevnetSection {
block_time: Some("500ms".into()),
max_txs_per_block: Some(100),
..DevnetSection::default()
};
let applied = section.applied_to(Settings::default());
assert_eq!(applied.block_time, "500ms");
assert_eq!(applied.max_txs_per_block, 100);
assert_eq!(applied.port, Settings::default().port);
assert_eq!(
section.describe(&Settings::default()),
"block_time = \"1s\", max_txs_per_block = 20"
);
}
#[test]
fn an_empty_file_asks_for_nothing() {
let dir = tempfile::tempdir().unwrap();
let project = project_in(dir.path(), "").unwrap();
assert!(project.home.is_none() && project.programs.is_empty());
assert_eq!(project.devnet, DevnetSection::default());
}
#[test]
fn a_program_is_a_path_or_a_table_that_makes_it_mutable() {
let dir = tempfile::tempdir().unwrap();
let project = project_in(
dir.path(),
"[programs]\ncounter = \"c.bin\"\nshop = { path = \"s.bin\", mutable = true }\n\
vault = { path = \"v.bin\" }\n",
)
.unwrap();
assert!(!project.programs["counter"].mutable);
assert_eq!(
project.programs["shop"],
ProgramSpec {
binary: dir.path().join("s.bin"),
mutable: true
}
);
assert!(!project.programs["vault"].mutable, "immutable unless asked");
let odd = project_in(
dir.path(),
"[programs]\nshop = { path = \"s.bin\", mutible = true }\n",
);
assert!(odd.is_err(), "a misspelled key is refused");
}
#[test]
fn the_devnet_section_sets_the_log_filter() {
let dir = tempfile::tempdir().unwrap();
let project = project_in(dir.path(), "[devnet]\nlog_filter = \"debug\"\n").unwrap();
let applied = project.devnet.applied_to(Settings::default());
assert_eq!(applied.log_filter, "debug");
assert_eq!(project.devnet.describe(&applied), "log_filter = \"debug\"");
assert!(project_in(dir.path(), "[devnet]\nlog_filter = \"\"\n").is_err());
}
#[test]
fn the_examples_project_files_load() {
let file =
Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/hello-world/logos-devnet.toml");
let project = Project::load(&file).unwrap();
assert_eq!(project.accounts["tutorial"].count, 2);
let file =
Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/sessions/logos-devnet.toml");
let project = Project::load(&file).unwrap();
assert_eq!(project.devnet.dev_mode, Some(true));
assert_eq!(project.sessions.len(), 2);
}
#[test]
fn the_devnet_section_turns_on_dev_mode() {
let dir = tempfile::tempdir().unwrap();
let project = project_in(dir.path(), "[devnet]\ndev_mode = true\n").unwrap();
let applied = project.devnet.applied_to(Settings::default());
assert!(applied.dev_mode);
assert_eq!(project.devnet.describe(&applied), "dev_mode = true");
}
#[test]
fn refuses_what_up_would_trip_over() {
let dir = tempfile::tempdir().unwrap();
let missing = project_in(dir.path(), "[programs]\ncounter = \"missing.bin\"").unwrap();
assert!(
missing.check_binaries().is_err(),
"loads, but up refuses it"
);
for bad in [
"[programs]\nCounter = \"x\"",
"[accounts.users]\ncount = 0",
"[accounts.users]\ncount = 10001",
"[accounts.users]\ncount = 1\namount = 0",
"[accounts.users]\ncount = 1\namount = 99999999999999999999",
"[accounts.Users]\ncount = 1",
"[sessions.alice]\nmodules = [\"--force\"]",
"[devnet]\nblock_time = \"1\"",
"[devnet]\nmax_txs_per_block = 0",
"[devnet]\nport = 0",
"[devnet]\ncolour = \"blue\"",
"unknown = 1",
] {
assert!(project_in(dir.path(), bad).is_err(), "{bad}");
}
}
#[test]
fn finds_the_nearest_file_above() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("a/b/c");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(dir.path().join("a").join(FILE_NAME), "").unwrap();
assert_eq!(find(&nested), Some(dir.path().join("a").join(FILE_NAME)));
}
}